feat(frontend): 合并线程流与工件详情并兼容聊天页

This commit is contained in:
肖应宇 2026-03-29 00:04:46 +08:00
parent a8f6e934ad
commit a34622c45c
3 changed files with 649 additions and 1108 deletions

View File

@ -1,475 +1,149 @@
"use client"; "use client";
import type { UseStream } from "@langchain/langgraph-sdk/react"; import type { UseStream } from "@langchain/langgraph-sdk/react";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { useCallback } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { import {
DevDialog, ChatBox,
DevDialogContent, useSpecificChatMode,
DevDialogFooter, useThreadChat,
DevDialogHeader, } from "@/components/workspace/chats";
DevDialogTitle, import { ExportTrigger } from "@/components/workspace/export-trigger";
} from "@/components/ui/dev-dialog";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "@/components/workspace/artifacts";
import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip"; import { TodoList } from "@/components/workspace/todo-list";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { type AgentThread, type AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { import { textOfMessage } from "@/core/threads/utils";
pathOfThread,
textOfMessage,
titleOfThread,
} from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { env } from "@/env"; import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
useSpecificChatMode();
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
selectedArtifact,
fullscreen,
} = useArtifacts();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
// UI mode depends only on route: /workspace/chats/new is always "new page" mode. const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
const isNewThread = useMemo( useSpecificChatMode();
() => threadIdFromPath === "new",
[threadIdFromPath],
);
// Submission strategy is controlled by `isnew` query param only.
// - isnew=false: reuse existing thread
// - otherwise: create/start a new session
const createNewSession = useMemo(() => {
if (threadIdFromPath !== "new") {
return false;
}
return searchParams.get("isnew")?.trim().toLowerCase() !== "false";
}, [threadIdFromPath, searchParams]);
const uploadTarget = useMemo(() => {
const target = searchParams.get("upload_target")?.trim().toLowerCase();
return target === "skill" ? "skill" : undefined;
}, [searchParams]);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath);
} else {
const queryThreadId = searchParams.get("thread_id")?.trim();
setThreadId(queryThreadId ?? uuid());
}
}, [threadIdFromPath, searchParams]);
// Runtime strategy for /new page:
// - UI remains new-page mode
// - if isnew=false, execute against existing thread_id without creating a new one
const reuseExistingThread = useMemo(
() => threadIdFromPath === "new" && !createNewSession && !!threadId,
[threadIdFromPath, createNewSession, threadId],
);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
// 监听宿主页 selectedSkill 消息 const [thread, sendMessage, isUploading] = useThreadStream({
const { threadId: isNewThread ? undefined : threadId,
selectedSkill, context: settings.context,
skillError: selectedSkillError, isMock,
clearSkillError: clearSelectedSkillError, onStart: () => {
isBootstrapping: isSelectedSkillBootstrapping, setIsNewThread(false);
} = useSelectedSkillListener({ threadId }); // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
const [finalState, setFinalState] = useState<AgentThreadState | null>(null); history.replaceState(null, "", `/workspace/chats/${threadId}`);
const thread = useThreadStream({ },
// Keep UI in new-page mode, but runtime may reuse existing thread
isNewThread: reuseExistingThread ? false : isNewThread,
threadId,
fetchStateHistory: true,
onFinish: (state) => { onFinish: (state) => {
setFinalState(state);
// 新对话完成后导航到对话页面
if (isNewThread && threadId) {
router.push(pathOfThread(threadId));
}
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1]; const lastMessage = state.messages.at(-1);
if (lastMessage) { if (lastMessage) {
const textContent = textOfMessage(lastMessage); const textContent = textOfMessage(lastMessage);
if (textContent) { if (textContent) {
if (textContent.length > 200) { body =
body = textContent.substring(0, 200) + "..."; textContent.length > 200
} else { ? textContent.substring(0, 200) + "..."
body = textContent; : textContent;
} }
} }
showNotification(state.title, { body });
} }
showNotification(state.title, {
body,
});
}
},
}) as unknown as UseStream<AgentThreadState>;
useEffect(() => {
if (thread.isLoading) setFinalState(null);
}, [thread.isLoading]);
const title = useMemo(() => {
let result = isNewThread
? ""
: titleOfThread(thread as unknown as AgentThread);
if (result === "Untitled") {
result = "";
}
return result;
}, [thread, isNewThread]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
useEffect(() => {
const pageTitle = isNewThread
? t.pages.newChat
: thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title
: t.pages.untitled;
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${pageTitle} - ${t.pages.appName}`;
}
}, [
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.values.title,
thread.isThreadLoading,
suppressExistingThreadPrefetchUi,
]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(thread.values.artifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const [todoListCollapsed] = useState(true);
const [showExitDialog, setShowExitDialog] = useState(false);
const submitThread = useSubmitThread({
isNewThread,
createNewSession,
threadId,
thread,
uploadTarget,
threadContext: {
...settings.context,
thinking_enabled: settings.context.mode !== "flash",
is_plan_mode:
settings.context.mode === "pro" || settings.context.mode === "ultra",
subagent_enabled: settings.context.mode === "ultra",
},
afterSubmit() {
// 导航已在 onFinish 中处理,确保 stream 完成后再导航
}, },
}); });
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: Parameters<typeof submitThread>[0]) => { (message: PromptInputMessage) => {
if (isSelectedSkillBootstrapping) { void sendMessage(threadId, message);
return;
}
setHasSubmitted(true);
void submitThread(message);
}, },
[isSelectedSkillBootstrapping, submitThread], [sendMessage, threadId],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const legacyThread = thread as unknown as UseStream<AgentThreadState>;
if (!threadId) {
return null;
}
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread, isMock }}>
<div <ChatBox threadId={threadId}>
className={cn( <div className="relative flex size-full min-h-0 justify-between">
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
artifactsOpen ? "w-full" : "w-[70%]",
)}
>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<div
className={cn(
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
artifactPanelOpen ? "w-[50%]" : "w-full",
fullscreen && "hidden",
)}
>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<header <header
className={cn( className={cn(
"bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out", "absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
isNewThread && !hasSubmitted ? "hidden" : "", isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)} )}
> >
<div className="flex items-center justify-start overflow-hidden text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
<Button <ThreadTitle threadId={threadId} thread={legacyThread} />
size="sm" </div>
variant="ghost" <div className="flex items-center gap-2">
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80" <TokenUsageIndicator messages={thread.messages} />
onClick={() => setShowExitDialog(true)} <ExportTrigger threadId={threadId} />
> <ArtifactTrigger />
<svg </div>
width="20" </header>
height="20" <main className="flex min-h-0 max-w-full grow flex-col">
viewBox="0 0 20 20" <div className="flex size-full justify-center">
fill="none" <MessageList
xmlns="http://www.w3.org/2000/svg" className={cn("size-full", !isNewThread && "pt-10")}
> threadId={threadId}
<path thread={legacyThread}
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/> />
</svg>
</Button>
</div> </div>
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
{title !== "Untitled" && ( <div
<ThreadTitle threadId={threadId} thread={thread} /> className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)} )}
</div> >
<div className="flex items-center justify-end gap-2 overflow-hidden"> <div className="absolute -top-4 right-0 left-0 z-0">
<DevTodoList <div className="absolute right-0 bottom-0 left-0">
className="bg-white" <TodoList
className="bg-background/5"
todos={thread.values.todos ?? []} todos={thread.values.todos ?? []}
hidden={ hidden={
!thread.values.todos || thread.values.todos.length === 0 !thread.values.todos || thread.values.todos.length === 0
} }
trigger={
<Button
size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]"
>
<ListTodoIcon className="size-4" /> To-dos
</Button>
}
/>
{artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content="点击可查看生成的文件结果">
<Button
className="text-[#150033] hover:text-[#150033]/80"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<FilesIcon />
{t.common.artifacts}
</Button>
</Tooltip>
)}
</div>
</header>
<main
className={cn(
"flex min-h-0 max-w-full grow flex-col",
isNewThread && !hasSubmitted ? "bg-white" : "bg-background",
)}
>
<div className="flex size-full justify-center">
<MessageList
className={cn(
"size-full",
(!isNewThread || hasSubmitted) && "pt-[20px]",
)}
threadId={threadId}
thread={thread}
suppressThreadLoading={suppressExistingThreadPrefetchUi}
messagesOverride={
suppressExistingThreadPrefetchUi
? []
: !thread.isLoading && finalState?.messages
? finalState.messages
: undefined
}
paddingBottom={todoListCollapsed ? 160 : 280}
/> />
</div> </div>
</main>
</div> </div>
</div>
<div
className={cn(
"bg-background ml-[20px] rounded-t-[20px] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
artifactPanelOpen
? fullscreen
? "ml-0 w-full"
: "w-[50%]"
: "w-0",
)}
>
<div
className={cn(
"h-full w-full transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center px-[20px]">
<div className="absolute top-2 right-2 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4">
<header className="shrink-0">
<h2 className="text-[14px] font-bold text-[#333333]">
{t.common.artifacts}
</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Fixed 底部居中输入框容器 */}
<div
className={cn(
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
"transition-all duration-300 ease-in-out",
fullscreen ? "hidden" : "",
)}
>
<div
className={cn(
"pointer-events-auto relative w-full max-w-[720px]",
isNewThread && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
)}
>
<InputBox <InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")} className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread} isNewThread={isNewThread}
hasSubmitted={hasSubmitted}
autoFocus={isNewThread} autoFocus={isNewThread}
status={ status={
suppressExistingThreadPrefetchUi thread.error
? "ready" ? "error"
: thread.isLoading : thread.isLoading
? "streaming" ? "streaming"
: "ready" : "ready"
} }
context={settings.context} context={settings.context}
extraHeader={ extraHeader={
<div className="flex flex-col gap-4"> isNewThread && <Welcome mode={settings.context.mode} />
{isNewThread && !hasSubmitted && (
<Welcome mode={settings.context.mode} />
)}
</div>
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping
} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />
{/* {isSelectedSkillBootstrapping && (
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
Skill ...
</div>
)} */}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> <div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode} {t.common.notAvailableInDemoMode}
@ -477,74 +151,9 @@ export default function ChatPage() {
)} )}
</div> </div>
</div> </div>
</main>
{/* 退出确认对话框 */}
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle></DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
退
</p>
<DevDialogFooter>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={() => setShowExitDialog(false)}
>
</Button>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={async () => {
// 如果正在生成,先终止再退出
if (thread.isLoading) {
await handleStop();
}
setShowExitDialog(false);
// 使用完整页面刷新确保组件重新挂载isNewThread 为 true
window.location.href = "/workspace/chats/new";
}}
>
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* selectedSkill 失败:错误弹窗 */}
<DevDialog
open={!!selectedSkillError}
onOpenChange={(open) => {
if (!open) clearSelectedSkillError();
}}
>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle>
{selectedSkillError?.title ?? "技能加载失败"}
</DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
{selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"}
</p>
<DevDialogFooter singleColumn>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={clearSelectedSkillError}
>
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* <IframeTestPanel /> */}
</div> </div>
</ChatBox>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
} }

View File

@ -1,11 +1,14 @@
import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react";
import { import {
useCallback, Code2Icon,
useEffect, CopyIcon,
useMemo, DownloadIcon,
useState, EyeIcon,
type HTMLAttributes, LoaderIcon,
} from "react"; PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@ -17,26 +20,27 @@ import {
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { Select, SelectItem } from "@/components/ui/select";
import { import {
DropdownMenu, SelectContent,
DropdownMenuContent, SelectGroup,
DropdownMenuItem, SelectTrigger,
DropdownMenuTrigger, SelectValue,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/select";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
import { useMarkdownDownload } from "@/core/utils/markdown-download"; import { env } from "@/env";
import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { ArtifactLink } from "../citations/artifact-link";
import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@ -50,8 +54,7 @@ export function ArtifactFileDetail({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select, fullscreen, setFullscreen } = const { artifacts, setOpen, select } = useArtifacts();
useArtifacts();
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@ -77,9 +80,9 @@ export function ArtifactFileDetail({
} }
return checkCodeFile(filepath); return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]); }, [filepath, isWriteFile, isSkillFile]);
const previewable = useMemo(() => { const isSupportPreview = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown"; return language === "html" || language === "markdown";
}, [isWriteFile, language]); }, [language]);
const { content } = useArtifactContent({ const { content } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
@ -88,62 +91,16 @@ export function ArtifactFileDetail({
const displayContent = content ?? ""; const displayContent = content ?? "";
const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({
value: artifactPath,
label: getFileName(artifactPath),
}));
}, [artifacts]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(80); const { isMock } = useThread();
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
// 是否可以转换为docx/pdf仅markdown文件支持
const canConvertToDocxPdf = language === "markdown";
// 使用 Markdown 下载 hook
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
onError: (error, format) => {
console.error(`Failed to download as ${format}:`, error);
toast.error(`Failed to download as ${format.toUpperCase()}`);
},
});
// 下载为 DOCX
const handleDownloadDocx = useCallback(() => {
if (content) {
void downloadAsDocx(content, fileName);
}
}, [content, fileName, downloadAsDocx]);
// 下载为 PDF
const handleDownloadPdf = useCallback(() => {
if (content) {
void downloadAsPdf(content, fileName);
}
}, [content, fileName, downloadAsPdf]);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
const newFullscreen = !fullscreen;
setFullscreen(newFullscreen);
sendToParent({
type: POST_MESSAGE_TYPES.FULLSCREEN,
fullscreen: newFullscreen,
});
}, [fullscreen, setFullscreen]);
useEffect(() => { useEffect(() => {
if (previewable) { if (isSupportPreview) {
setViewMode("preview"); setViewMode("preview");
} else { } else {
setViewMode("code"); setViewMode("code");
} }
}, [previewable]); }, [isSupportPreview]);
const handleInstallSkill = useCallback(async () => { const handleInstallSkill = useCallback(async () => {
if (isInstalling) return; if (isInstalling) return;
@ -166,18 +123,38 @@ export function ArtifactFileDetail({
setIsInstalling(false); setIsInstalling(false);
} }
}, [threadId, filepath, isInstalling]); }, [threadId, filepath, isInstalling]);
return ( return (
// 给滚动遮挡头部定位relative <Artifact className={cn(className)}>
<Artifact className={cn("relative",className)}> <ArtifactHeader className="px-2">
<ArtifactHeader> <div className="flex items-center gap-2">
<div className="flex items-center justify-start gap-2"> <ArtifactTitle>
{previewable && ( {isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center">
{isSupportPreview && (
<ToggleGroup <ToggleGroup
className="mx-auto"
type="single" type="single"
variant={null} variant="outline"
size="default" size="sm"
className="h-[28px]"
value={viewMode} value={viewMode}
onValueChange={(value) => { onValueChange={(value) => {
if (value) { if (value) {
@ -186,75 +163,47 @@ export function ArtifactFileDetail({
}} }}
> >
<ToggleGroupItem value="code"> <ToggleGroupItem value="code">
<svg <Code2Icon />
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6L2 9L5 12"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 3L7 15"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 6L16 9L13 12"
stroke="#150033"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="preview"> <ToggleGroupItem value="preview">
<svg <EyeIcon />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
>
<path
d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z"
stroke="#666666"
/>
<circle cx="8" cy="5" r="1.5" stroke="#666666" />
</svg>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="flex items-center gap-2">
<ArtifactTitle>
{isWriteFile ? (
<div className=" w-full text-center overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
) : (
<DropdownSelector
value={filepath}
options={artifactOptions}
onChange={select}
/>
)}
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
<ArtifactActions> <ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
isInstalling ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={handleInstallSkill}
/>
</Tooltip>
)}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
/>
</a>
)}
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
icon={CopyIcon}
label={t.clipboard.copyToClipboard} label={t.clipboard.copyToClipboard}
disabled={!content} disabled={!content}
onClick={async () => { onClick={async () => {
try { try {
await copyToClipboard(displayContent ?? ""); await navigator.clipboard.writeText(displayContent ?? "");
toast.success(t.clipboard.copiedToClipboard); toast.success(t.clipboard.copiedToClipboard);
} catch (error) { } catch (error) {
toast.error("Failed to copy to clipboard"); toast.error("Failed to copy to clipboard");
@ -262,210 +211,49 @@ export function ArtifactFileDetail({
} }
}} }}
tooltip={t.clipboard.copyToClipboard} tooltip={t.clipboard.copyToClipboard}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/> />
<rect
x="2.5"
y="4.5"
width="10"
height="11"
rx="1.5"
stroke="#666666"
/>
</svg>
</ArtifactAction>
)} )}
{!isWriteFile && ( {!isWriteFile && (
<DropdownMenu> <a
<DropdownMenuTrigger asChild> href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction <ArtifactAction
icon={DownloadIcon}
label={t.common.download} label={t.common.download}
tooltip={t.common.download} tooltip={t.common.download}
>
{isDownloading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
strokeLinecap="round"
/> />
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
>
<DownloadIcon className="size-4" />
{t.common.downloadOriginal}
</a> </a>
</DropdownMenuItem>
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && (
<>
<DropdownMenuItem
onClick={handleDownloadDocx}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTextIcon className="size-4" />
{isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadPdf}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTypeIcon className="size-4" />
{isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf}
</DropdownMenuItem>
</>
)} )}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 全屏按钮 */}
<ArtifactAction
label={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
onClick={handleFullscreenToggle}
tooltip={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
>
{fullscreen ? (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
{!fullscreen && (
<ArtifactAction <ArtifactAction
icon={XIcon}
label={t.common.close} label={t.common.close}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
tooltip={t.common.close} tooltip={t.common.close}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
strokeLinecap="round"
/> />
</svg>
</ArtifactAction>
)}
</ArtifactActions> </ArtifactActions>
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className=" rounded-b-[10px] bg-white p-0"> <ArtifactContent className="p-0">
{/* 遮挡多余的滚动顶部 */} {isSupportPreview &&
<div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div>
{previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
zoom={zoom}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<CodeEditor <CodeEditor
className="size-full py-[20px] resize-none rounded-none border-none" className="size-full resize-none rounded-none border-none"
value={displayContent ?? ""} value={displayContent ?? ""}
zoom={zoom}
readonly readonly
/> />
)} )}
{!isCodeFile && ( {!isCodeFile && (
<iframe <iframe
className="size-full" className="size-full"
src={urlOfArtifact({ filepath, threadId })} src={urlOfArtifact({ filepath, threadId, isMock })}
/> />
)} )}
</ArtifactContent> </ArtifactContent>
@ -476,24 +264,17 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
language, language,
zoom = 100,
}: { }: {
content: string; content: string;
language: string; language: string;
zoom?: number;
}) { }) {
const zoomScale = zoom / 100;
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <div className="size-full px-4">
className={cn("size-full p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: CitationLink }} components={{ a: ArtifactLink }}
> >
{content ?? ""} {content ?? ""}
</Streamdown> </Streamdown>
@ -507,130 +288,8 @@ export function ArtifactFilePreview({
title="Artifact preview" title="Artifact preview"
srcDoc={content} srcDoc={content}
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/> />
); );
} }
return null; return null;
} }
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export type ArtifactZoomSelectorProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: number;
onChange?: (value: number) => void;
};
export const ArtifactZoomSelector = ({
value = 100,
onChange,
className,
...props
}: ArtifactZoomSelectorProps) => {
const handleZoomIn = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1];
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) {
onChange?.(nextValue);
}
};
const handleZoomOut = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const prevValue = ZOOM_LEVELS[currentIndex - 1];
if (currentIndex > 0 && prevValue !== undefined) {
onChange?.(prevValue);
}
};
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
return (
<div
className={cn(
"inline-flex h-[28px] items-center gap-1 rounded-[10px] bg-white backdrop-blur-sm",
"dark:border-gray-700/50 dark:bg-gray-800/90",
className,
)}
{...props}
>
<button
type="button"
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="放大"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M5.33325 7.5H9.7777M7.55547 5V10"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<span
className={cn(
"min-w-[36px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
{value}%
</span>
<button
type="button"
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-full w-10 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="缩小"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M4.99927 7.5H9.99927"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
);
};

View File

@ -1,43 +1,163 @@
import type { AIMessage } from "@langchain/langgraph-sdk"; import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream, type UseStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { uploadFiles } from "../uploads"; import { uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api";
import type { import type { AgentThread, AgentThreadState } from "./types";
AgentThread,
AgentThreadContext, export type ToolEndEvent = {
AgentThreadState, name: string;
} from "./types"; data: unknown;
};
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) {
return message;
}
const nestedError = Reflect.get(error, "error");
if (nestedError instanceof Error && nestedError.message.trim()) {
return nestedError.message;
}
if (typeof nestedError === "string" && nestedError.trim()) {
return nestedError;
}
}
return "Request failed.";
}
export function useThreadStream({ export function useThreadStream({
threadId, threadId,
isNewThread, context,
fetchStateHistory = true, isMock,
onStart,
onFinish, onFinish,
}: { onToolEnd,
isNewThread: boolean; }: ThreadStreamOptions) {
threadId: string | null | undefined; const { t } = useI18n();
fetchStateHistory?: boolean; // Track the thread ID that is currently streaming to handle thread changes during streaming
onFinish?: (state: AgentThreadState) => void; const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
}) { // Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const listeners = useRef({
onStart,
onFinish,
onToolEnd,
});
// Keep listeners ref updated with latest callbacks
useEffect(() => {
listeners.current = { onStart, onFinish, onToolEnd };
}, [onStart, onFinish, onToolEnd]);
useEffect(() => {
const normalizedThreadId = threadId ?? null;
if (!normalizedThreadId) {
// Just reset for new thread creation when threadId becomes null/undefined
startedRef.current = false;
setOnStreamThreadId(normalizedThreadId);
}
threadIdRef.current = normalizedThreadId;
}, [threadId]);
const _handleOnStart = useCallback((id: string) => {
if (!startedRef.current) {
listeners.current.onStart?.(id);
startedRef.current = true;
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(), client: getAPIClient(isMock),
assistantId: "lead_agent", assistantId: "lead_agent",
threadId: isNewThread ? undefined : threadId, threadId: onStreamThreadId,
reconnectOnMount: true, reconnectOnMount: true,
fetchStateHistory, fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
data || {},
);
for (const update of updates) {
if (update && "title" in update && update.title) {
void queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
return oldData?.map((t) => {
if (t.thread_id === threadIdRef.current) {
return {
...t,
values: {
...t.values,
title: update.title,
},
};
}
return t;
});
},
);
}
}
},
onCustomEvent(event: unknown) { onCustomEvent(event: unknown) {
console.info(event);
if ( if (
typeof event === "object" && typeof event === "object" &&
event !== null && event !== null &&
@ -52,75 +172,87 @@ export function useThreadStream({
updateSubtask({ id: e.task_id, latestMessage: e.message }); updateSubtask({ id: e.task_id, latestMessage: e.message });
} }
}, },
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
},
onFinish(state) { onFinish(state) {
onFinish?.(state.values); listeners.current.onFinish?.(state.values);
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
}, },
(oldData: Array<AgentThread>) => { });
return oldData.map((t) => {
if (t.thread_id === threadId) { // Optimistic messages shown before the server stream responds
return { const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
...t, const [isUploading, setIsUploading] = useState(false);
values: { const sendInFlightRef = useRef(false);
...t.values, // Track message count before sending so we know when server has responded
title: state.values.title, const prevMsgCountRef = useRef(thread.messages.length);
},
}; // Clear optimistic when server messages arrive (count increases)
useEffect(() => {
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
) {
setOptimisticMessages([]);
} }
return t; }, [thread.messages.length, optimisticMessages.length]);
});
},
);
},
});
return thread;
}
export function useSubmitThread({ const sendMessage = useCallback(
threadId, async (
thread, threadId: string,
threadContext, message: PromptInputMessage,
isNewThread, extraContext?: Record<string, unknown>,
createNewSession, ) => {
uploadTarget, if (sendInFlightRef.current) {
afterSubmit,
}: {
isNewThread: boolean;
createNewSession: boolean;
threadId: string | null | undefined;
thread: UseStream<AgentThreadState>;
threadContext: Omit<AgentThreadContext, "thread_id">;
uploadTarget?: UploadTarget;
afterSubmit?: () => void;
}) {
const queryClient = useQueryClient();
const apiClient = getAPIClient();
const callback = useCallback(
async (message: PromptInputMessage) => {
const text = message.text.trim();
// Guard: ignore empty submits (avoids unintended side effects during page init).
const hasFiles = !!(message.files && message.files.length > 0);
if (!text && !hasFiles) {
return; return;
} }
sendInFlightRef.current = true;
const text = message.text.trim();
// Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length;
// Build optimistic files list with uploading status
const optimisticFiles: FileInMessage[] = (message.files ?? []).map(
(f) => ({
filename: f.filename ?? "",
size: 0,
status: "uploading" as const,
}),
);
// Create optimistic human message (shown immediately)
const optimisticHumanMsg: Message = {
type: "human",
id: `opt-human-${Date.now()}`,
content: text ? [{ type: "text", text }] : "",
additional_kwargs:
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
};
const newOptimistic: Message[] = [optimisticHumanMsg];
if (optimisticFiles.length > 0) {
// Mock AI message while files are being uploaded
newOptimistic.push({
type: "ai",
id: `opt-ai-${Date.now()}`,
content: t.uploads.uploadingFiles,
additional_kwargs: { element: "task" },
});
}
setOptimisticMessages(newOptimistic);
_handleOnStart(threadId);
let uploadedFileInfo: UploadedFileInfo[] = [];
// For "new session" semantics, ensure the target thread id starts fresh.
// If the same id already exists, delete it first and let submit recreate it.
if (createNewSession && threadId) {
try { try {
await apiClient.threads.delete(threadId);
} catch {
// Ignore delete errors (e.g. thread does not exist yet)
}
}
// Upload files first if any // Upload files first if any
if (message.files && message.files.length > 0) { if (message.files && message.files.length > 0) {
setIsUploading(true);
try { try {
// Convert FileUIPart to File objects by fetching blob URLs // Convert FileUIPart to File objects by fetching blob URLs
const filePromises = message.files.map(async (fileUIPart) => { const filePromises = message.files.map(async (fileUIPart) => {
@ -145,20 +277,73 @@ export function useSubmitThread({
return null; return null;
}); });
const files = (await Promise.all(filePromises)).filter( const conversionResults = await Promise.all(filePromises);
const files = conversionResults.filter(
(file): file is File => file !== null, (file): file is File => file !== null,
); );
const failedConversions = conversionResults.length - files.length;
if (files.length > 0 && threadId) { if (failedConversions > 0) {
await uploadFiles(threadId, files, { target: uploadTarget }); throw new Error(
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
);
}
if (!threadId) {
throw new Error("Thread is not ready for file upload.");
}
if (files.length > 0) {
const uploadResponse = await uploadFiles(threadId, files);
uploadedFileInfo = uploadResponse.files;
// Update optimistic human message with uploaded status + paths
const uploadedFiles: FileInMessage[] = uploadedFileInfo.map(
(info) => ({
filename: info.filename,
size: info.size,
path: info.virtual_path,
status: "uploaded" as const,
}),
);
setOptimisticMessages((messages) => {
if (messages.length > 1 && messages[0]) {
const humanMessage: Message = messages[0];
return [
{
...humanMessage,
additional_kwargs: { files: uploadedFiles },
},
...messages.slice(1),
];
}
return messages;
});
} }
} catch (error) { } catch (error) {
console.error("Failed to upload files:", error); console.error("Failed to upload files:", error);
// Continue with message submission even if upload fails const errorMessage =
// You might want to show an error toast here error instanceof Error
? error.message
: "Failed to upload files.";
toast.error(errorMessage);
setOptimisticMessages([]);
throw error;
} finally {
setIsUploading(false);
} }
} }
// Build files metadata for submission (included in additional_kwargs)
const filesForSubmit: FileInMessage[] = uploadedFileInfo.map(
(info) => ({
filename: info.filename,
size: info.size,
path: info.virtual_path,
status: "uploaded" as const,
}),
);
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
@ -170,40 +355,59 @@ export function useSubmitThread({
text, text,
}, },
], ],
additional_kwargs:
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
}, },
], ],
}, },
{ {
threadId: createNewSession ? threadId! : undefined, threadId: threadId,
streamSubgraphs: true, streamSubgraphs: true,
streamResumable: true, streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
config: { config: {
recursion_limit: 1000, recursion_limit: 1000,
}, },
context: { context: {
...threadContext, ...extraContext,
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
reasoning_effort:
context.reasoning_effort ??
(context.mode === "ultra"
? "high"
: context.mode === "pro"
? "medium"
: context.mode === "thinking"
? "low"
: undefined),
thread_id: threadId, thread_id: threadId,
}, },
}, },
); );
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.(); } catch (error) {
setOptimisticMessages([]);
setIsUploading(false);
throw error;
} finally {
sendInFlightRef.current = false;
}
}, },
[ [thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
thread,
isNewThread,
createNewSession,
threadId,
threadContext,
uploadTarget,
queryClient,
apiClient,
afterSubmit,
],
); );
return callback;
// Merge thread with optimistic messages for display
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
return [mergedThread, sendMessage, isUploading] as const;
} }
export function useThreads( export function useThreads(
@ -211,15 +415,64 @@ export function useThreads(
limit: 50, limit: 50,
sortBy: "updated_at", sortBy: "updated_at",
sortOrder: "desc", sortOrder: "desc",
select: ["thread_id", "updated_at", "values"],
}, },
) { ) {
const apiClient = getAPIClient(); const apiClient = getAPIClient();
return useQuery<AgentThread[]>({ return useQuery<AgentThread[]>({
queryKey: ["threads", "search", params], queryKey: ["threads", "search", params],
queryFn: async () => { queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response = await apiClient.threads.search<AgentThreadState>(params); const response = await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[]; return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
}, },
refetchOnWindowFocus: false,
}); });
} }
@ -229,6 +482,20 @@ export function useDeleteThread() {
return useMutation({ return useMutation({
mutationFn: async ({ threadId }: { threadId: string }) => { mutationFn: async ({ threadId }: { threadId: string }) => {
await apiClient.threads.delete(threadId); await apiClient.threads.delete(threadId);
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Failed to delete local thread data." }));
throw new Error(error.detail ?? "Failed to delete local thread data.");
}
}, },
onSuccess(_, { threadId }) { onSuccess(_, { threadId }) {
queryClient.setQueriesData( queryClient.setQueriesData(
@ -236,11 +503,17 @@ export function useDeleteThread() {
queryKey: ["threads", "search"], queryKey: ["threads", "search"],
exact: false, exact: false,
}, },
(oldData: Array<AgentThread>) => { (oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.filter((t) => t.thread_id !== threadId); return oldData.filter((t) => t.thread_id !== threadId);
}, },
); );
}, },
onSettled() {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
}); });
} }