feat(frontend): 合并线程流与工件详情并兼容聊天页
This commit is contained in:
parent
a8f6e934ad
commit
a34622c45c
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue