mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-22 17:42:24 +00:00
209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
|
|
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 } from "@/hooks/useNanobotStream";
|
|
import { useSessionHistory } from "@/hooks/useSessions";
|
|
import type { ChatSummary, UIMessage } from "@/lib/types";
|
|
import { useClient } from "@/providers/ClientProvider";
|
|
|
|
interface ThreadShellProps {
|
|
session: ChatSummary | null;
|
|
title: string;
|
|
onToggleSidebar: () => void;
|
|
onGoHome: () => void;
|
|
onNewChat: () => Promise<string | null>;
|
|
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;
|
|
}
|
|
|
|
export function ThreadShell({
|
|
session,
|
|
title,
|
|
onToggleSidebar,
|
|
onGoHome,
|
|
onNewChat,
|
|
hideSidebarToggleOnDesktop = false,
|
|
}: ThreadShellProps) {
|
|
const { t } = useTranslation();
|
|
const chatId = session?.chatId ?? null;
|
|
const historyKey = session?.key ?? null;
|
|
const { messages: historical, loading } = useSessionHistory(historyKey);
|
|
const { client, modelName } = useClient();
|
|
const [booting, setBooting] = useState(false);
|
|
const pendingFirstRef = useRef<string | null>(null);
|
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
|
|
|
const initial = useMemo(() => {
|
|
if (!chatId) return historical;
|
|
return messageCacheRef.current.get(chatId) ?? historical;
|
|
}, [chatId, historical]);
|
|
const {
|
|
messages,
|
|
isStreaming,
|
|
send,
|
|
setMessages,
|
|
streamError,
|
|
dismissStreamError,
|
|
} = useNanobotStream(chatId, initial);
|
|
const showHeroComposer = messages.length === 0 && !loading;
|
|
const pendingAsk = useMemo(() => {
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index];
|
|
if (message.kind === "trace") continue;
|
|
if (message.role === "user") return null;
|
|
if (message.role === "assistant" && message.buttons?.some((row) => row.length > 0)) {
|
|
return {
|
|
question: message.content,
|
|
buttons: message.buttons,
|
|
};
|
|
}
|
|
if (message.role === "assistant") return null;
|
|
}
|
|
return null;
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId || loading) return;
|
|
const cached = messageCacheRef.current.get(chatId);
|
|
// 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.
|
|
setMessages(cached && cached.length > 0 ? cached : historical);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [loading, chatId, historical]);
|
|
|
|
useEffect(() => {
|
|
if (chatId) return;
|
|
setMessages(historical);
|
|
}, [chatId, historical, setMessages]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId) return;
|
|
messageCacheRef.current.set(chatId, messages);
|
|
}, [chatId, messages]);
|
|
|
|
useEffect(() => {
|
|
if (!chatId) return;
|
|
const pending = pendingFirstRef.current;
|
|
if (!pending) return;
|
|
pendingFirstRef.current = null;
|
|
client.sendMessage(chatId, pending);
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
role: "user",
|
|
content: pending,
|
|
createdAt: Date.now(),
|
|
},
|
|
]);
|
|
setBooting(false);
|
|
}, [chatId, client, setMessages]);
|
|
|
|
const handleWelcomeSend = useCallback(
|
|
async (content: string) => {
|
|
if (booting) return;
|
|
setBooting(true);
|
|
pendingFirstRef.current = content;
|
|
const newId = await onNewChat();
|
|
if (!newId) {
|
|
pendingFirstRef.current = null;
|
|
setBooting(false);
|
|
}
|
|
},
|
|
[booting, onNewChat],
|
|
);
|
|
|
|
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 max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
|
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
|
|
<img
|
|
src="/brand/nanobot_icon.png"
|
|
alt=""
|
|
aria-hidden
|
|
draggable={false}
|
|
className="h-4 w-4 rounded-sm opacity-90"
|
|
/>
|
|
<span className="text-foreground/82">nanobot</span>
|
|
</div>
|
|
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
|
|
{t("thread.empty.description")}
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<ThreadHeader
|
|
title={title}
|
|
onToggleSidebar={onToggleSidebar}
|
|
onGoHome={onGoHome}
|
|
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
|
/>
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
emptyState={emptyState}
|
|
composer={
|
|
<>
|
|
{streamError ? (
|
|
<StreamErrorNotice
|
|
error={streamError}
|
|
onDismiss={dismissStreamError}
|
|
/>
|
|
) : null}
|
|
{pendingAsk ? (
|
|
<AskUserPrompt
|
|
question={pendingAsk.question}
|
|
buttons={pendingAsk.buttons}
|
|
onAnswer={send}
|
|
/>
|
|
) : null}
|
|
{session ? (
|
|
<ThreadComposer
|
|
onSend={send}
|
|
disabled={!chatId}
|
|
placeholder={
|
|
showHeroComposer
|
|
? t("thread.composer.placeholderHero")
|
|
: t("thread.composer.placeholderThread")
|
|
}
|
|
modelLabel={toModelBadgeLabel(modelName)}
|
|
variant={showHeroComposer ? "hero" : "thread"}
|
|
/>
|
|
) : (
|
|
<ThreadComposer
|
|
onSend={handleWelcomeSend}
|
|
disabled={booting}
|
|
placeholder={
|
|
booting
|
|
? t("thread.composer.placeholderOpening")
|
|
: t("thread.composer.placeholderHero")
|
|
}
|
|
modelLabel={toModelBadgeLabel(modelName)}
|
|
variant="hero"
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|