mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-26 03:22:38 +00:00
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
BarChart3,
|
|
BookOpen,
|
|
ChevronRight,
|
|
Code2,
|
|
ImageIcon,
|
|
LayoutGrid,
|
|
Lightbulb,
|
|
MoreHorizontal,
|
|
Palette,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
|
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
|
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
|
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
|
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
|
import { useSessionHistory } from "@/hooks/useSessions";
|
|
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
|
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
|
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
|
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
|
import { useClient } from "@/providers/ClientProvider";
|
|
|
|
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
|
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
|
}
|
|
|
|
interface ThreadShellProps {
|
|
session: ChatSummary | null;
|
|
title: string;
|
|
onToggleSidebar: () => void;
|
|
onGoHome?: () => void;
|
|
onNewChat?: () => void;
|
|
onCreateChat?: () => Promise<string | null>;
|
|
onTurnEnd?: () => void;
|
|
theme?: "light" | "dark";
|
|
onToggleTheme?: () => void;
|
|
hideSidebarToggleOnDesktop?: boolean;
|
|
}
|
|
|
|
function toModelBadgeLabel(modelName: string | null): string | null {
|
|
if (!modelName) return null;
|
|
const trimmed = modelName.trim();
|
|
if (!trimmed) return null;
|
|
const leaf = trimmed.split("/").pop() ?? trimmed;
|
|
return leaf || trimmed;
|
|
}
|
|
|
|
const QUICK_ACTION_KEYS = [
|
|
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
|
|
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
|
|
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
|
|
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
|
|
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
|
|
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
|
|
] as const;
|
|
|
|
const IMAGE_QUICK_ACTION_KEYS = [
|
|
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
|
|
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
|
|
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
|
|
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
|
|
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
|
|
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
|
|
] as const;
|
|
|
|
interface PendingFirstMessage {
|
|
content: string;
|
|
images?: SendImage[];
|
|
options?: SendOptions;
|
|
}
|
|
|
|
export function ThreadShell({
|
|
session,
|
|
title,
|
|
onToggleSidebar,
|
|
onCreateChat,
|
|
onTurnEnd,
|
|
theme = "light",
|
|
onToggleTheme = () => {},
|
|
hideSidebarToggleOnDesktop = false,
|
|
}: ThreadShellProps) {
|
|
const { t } = useTranslation();
|
|
const chatId = session?.chatId ?? null;
|
|
const historyKey = session?.key ?? null;
|
|
const {
|
|
messages: historical,
|
|
loading,
|
|
hasPendingToolCalls,
|
|
refresh: refreshHistory,
|
|
version: historyVersion,
|
|
} = useSessionHistory(historyKey);
|
|
const { client, modelName, token } = useClient();
|
|
const [booting, setBooting] = useState(false);
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
|
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
|
const [heroImageMode, setHeroImageMode] = useState(false);
|
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
|
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
|
const skipLayoutCacheRef = useRef(false);
|
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
|
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
|
|
|
const initial = useMemo(() => {
|
|
if (!chatId) return historical;
|
|
return messageCacheRef.current.get(chatId) ?? historical;
|
|
}, [chatId, historical]);
|
|
const handleTurnEnd = useCallback(() => {
|
|
onTurnEnd?.();
|
|
}, [onTurnEnd]);
|
|
const {
|
|
messages,
|
|
isStreaming,
|
|
runStartedAt,
|
|
goalState,
|
|
send,
|
|
stop,
|
|
setMessages,
|
|
streamError,
|
|
dismissStreamError,
|
|
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
|
|
|
useEffect(() => {
|
|
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
|
}, [chatId, historyKey]);
|
|
|
|
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
|
|
|
const showHeroComposer = messages.length === 0 && !loading;
|
|
|
|
useEffect(() => {
|
|
if (!chatId || loading) return;
|
|
const cached = messageCacheRef.current.get(chatId);
|
|
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
|
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
|
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
|
// When the user switches away and back, keep the local in-memory thread
|
|
// state (including not-yet-persisted messages) instead of replacing it with
|
|
// whatever the history endpoint currently knows about. Once a fresh
|
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
|
// so rendering converges to the same shape as a manual refresh.
|
|
setMessages((prev) => {
|
|
if (hasNewCanonicalHistory && historical.length > 0) {
|
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
|
const normalized = projectWebuiThreadMessages(historical);
|
|
messageCacheRef.current.set(chatId, normalized);
|
|
return normalized;
|
|
}
|
|
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
|
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
|
const next = projectWebuiThreadMessages(historical);
|
|
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
|
return next;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [loading, chatId, historical, historyVersion]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId) return;
|
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
|
if (updatedChatId !== chatId) return;
|
|
if (scope === "metadata") return;
|
|
pendingCanonicalHydrateRef.current.add(chatId);
|
|
refreshHistory();
|
|
});
|
|
}, [chatId, client, refreshHistory]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId || loading) return;
|
|
setScrollToBottomSignal((value) => value + 1);
|
|
}, [chatId, loading, historical]);
|
|
|
|
useEffect(() => {
|
|
if (chatId) return;
|
|
setMessages(projectWebuiThreadMessages(historical));
|
|
}, [chatId, historical, setMessages]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (chatId) {
|
|
const prev = prevChatIdForCacheRef.current;
|
|
if (prev && prev !== chatId) {
|
|
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
|
|
skipLayoutCacheRef.current = true;
|
|
}
|
|
prevChatIdForCacheRef.current = chatId;
|
|
} else {
|
|
if (prevChatIdForCacheRef.current) {
|
|
messageCacheRef.current.set(
|
|
prevChatIdForCacheRef.current,
|
|
projectWebuiThreadMessages(messages),
|
|
);
|
|
skipLayoutCacheRef.current = true;
|
|
}
|
|
prevChatIdForCacheRef.current = null;
|
|
}
|
|
}, [chatId, messages]);
|
|
|
|
// Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch
|
|
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
|
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
|
useEffect(() => {
|
|
if (!chatId) {
|
|
return;
|
|
}
|
|
if (skipLayoutCacheRef.current) {
|
|
skipLayoutCacheRef.current = false;
|
|
return;
|
|
}
|
|
if (loading) {
|
|
return;
|
|
}
|
|
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
|
|
}, [chatId, loading, messages]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId) return;
|
|
const pending = pendingFirstRef.current;
|
|
if (!pending) return;
|
|
pendingFirstRef.current = null;
|
|
setScrollToBottomSignal((value) => value + 1);
|
|
send(pending.content, pending.images, pending.options);
|
|
setBooting(false);
|
|
}, [chatId, send]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const commands = await listSlashCommands(token);
|
|
if (!cancelled) setSlashCommands(commands);
|
|
} catch {
|
|
if (!cancelled) setSlashCommands([]);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [token]);
|
|
|
|
const refreshCliApps = useCallback(async () => {
|
|
try {
|
|
const payload = await fetchCliApps(token);
|
|
setCliApps(payload.apps.filter((app) => app.installed));
|
|
} catch {
|
|
setCliApps([]);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
try {
|
|
const payload = await fetchCliApps(token);
|
|
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
|
|
} catch {
|
|
if (!cancelled) setCliApps([]);
|
|
}
|
|
};
|
|
load();
|
|
|
|
const refreshOnFocus = () => {
|
|
if (document.visibilityState === "hidden") return;
|
|
void refreshCliApps();
|
|
};
|
|
window.addEventListener("focus", refreshOnFocus);
|
|
document.addEventListener("visibilitychange", refreshOnFocus);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener("focus", refreshOnFocus);
|
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
|
};
|
|
}, [refreshCliApps, token]);
|
|
|
|
const handleWelcomeSend = useCallback(
|
|
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
|
if (booting) return;
|
|
setBooting(true);
|
|
pendingFirstRef.current = { content, images, options };
|
|
const newId = await onCreateChat?.();
|
|
if (!newId) {
|
|
pendingFirstRef.current = null;
|
|
setBooting(false);
|
|
}
|
|
},
|
|
[booting, onCreateChat],
|
|
);
|
|
|
|
const handleThreadSend = useCallback(
|
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
|
setScrollToBottomSignal((value) => value + 1);
|
|
send(content, images, options);
|
|
},
|
|
[send],
|
|
);
|
|
|
|
const handleQuickAction = useCallback(
|
|
(prompt: string) => {
|
|
const options: SendOptions | undefined = heroImageMode
|
|
? { imageGeneration: { enabled: true, aspect_ratio: null } }
|
|
: undefined;
|
|
if (session) {
|
|
handleThreadSend(prompt, undefined, options);
|
|
return;
|
|
}
|
|
void handleWelcomeSend(prompt, undefined, options);
|
|
},
|
|
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
|
|
);
|
|
|
|
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
|
|
const quickActionPrefix = heroImageMode
|
|
? "thread.empty.imageQuickActions"
|
|
: "thread.empty.quickActions";
|
|
const quickActions = (
|
|
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
|
|
{quickActionItems.map(({ key, icon: Icon, tone }) => {
|
|
const title = t(`${quickActionPrefix}.${key}.title`);
|
|
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => handleQuickAction(prompt)}
|
|
disabled={booting || isStreaming}
|
|
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
|
|
>
|
|
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
|
|
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
|
|
{title}
|
|
</span>
|
|
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
const composer = (
|
|
<>
|
|
{streamError ? (
|
|
<StreamErrorNotice
|
|
error={streamError}
|
|
onDismiss={dismissStreamError}
|
|
/>
|
|
) : null}
|
|
{session ? (
|
|
<ThreadComposer
|
|
onSend={handleThreadSend}
|
|
disabled={!chatId}
|
|
isStreaming={isStreaming}
|
|
placeholder={
|
|
showHeroComposer
|
|
? t("thread.composer.placeholderHero")
|
|
: t("thread.composer.placeholderThread")
|
|
}
|
|
modelLabel={toModelBadgeLabel(modelName)}
|
|
variant={showHeroComposer ? "hero" : "thread"}
|
|
slashCommands={slashCommands}
|
|
cliApps={cliApps}
|
|
imageMode={showHeroComposer ? heroImageMode : undefined}
|
|
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
|
onStop={stop}
|
|
runStartedAt={runStartedAt}
|
|
goalState={goalState}
|
|
/>
|
|
) : (
|
|
<ThreadComposer
|
|
onSend={handleWelcomeSend}
|
|
disabled={booting}
|
|
isStreaming={isStreaming}
|
|
placeholder={
|
|
booting
|
|
? t("thread.composer.placeholderOpening")
|
|
: t("thread.composer.placeholderHero")
|
|
}
|
|
modelLabel={toModelBadgeLabel(modelName)}
|
|
variant="hero"
|
|
slashCommands={slashCommands}
|
|
cliApps={cliApps}
|
|
imageMode={heroImageMode}
|
|
onImageModeChange={setHeroImageMode}
|
|
runStartedAt={runStartedAt}
|
|
goalState={goalState}
|
|
/>
|
|
)}
|
|
{showHeroComposer ? quickActions : null}
|
|
</>
|
|
);
|
|
|
|
const emptyState = loading ? (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
{t("thread.loadingConversation")}
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
|
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
|
|
{t("thread.empty.greeting")}
|
|
</h1>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<ThreadHeader
|
|
title={title}
|
|
onToggleSidebar={onToggleSidebar}
|
|
theme={theme}
|
|
onToggleTheme={onToggleTheme}
|
|
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
|
minimal={!session && !loading}
|
|
/>
|
|
<ThreadViewport
|
|
messages={displayMessages}
|
|
isStreaming={isStreaming}
|
|
emptyState={emptyState}
|
|
composer={composer}
|
|
scrollToBottomSignal={scrollToBottomSignal}
|
|
conversationKey={historyKey}
|
|
showScrollToBottomButton={!!session}
|
|
cliApps={cliApps}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|