deerflow2/frontend/src/app/workspace/chats/[thread_id]/page.tsx

550 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
DevDialog,
DevDialogContent,
DevDialogFooter,
DevDialogHeader,
DevDialogTitle,
} from "@/components/ui/dev-dialog";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats";
import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { pathOfThread, textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
useSpecificChatMode();
const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const router = useRouter();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
selectedArtifact,
deselect: deselectArtifact,
setFullscreen: setArtifactsFullscreen,
fullscreen,
} = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
const searchParams = useSearchParams();
// History render rules:
// - /workspace/chats/{thread_id}: always render history
// - /workspace/chats/new: render history only when xclaw_used=true
const shouldRenderHistory =
!isNewThread ||
searchParams.get("xclaw_used")?.trim().toLowerCase() === "true";
// Submission strategy:
// - isnew=false + thread_id: reuse existing thread (explicit request from URL)
// - xclaw_used=true: follow `isnew` (isnew=false => reuse existing thread)
// - otherwise: create/start a new session (no history)
const createNewSession = useMemo(() => {
if (!isNewThread) {
return false;
}
const queryThreadId = searchParams.get("thread_id")?.trim();
const reuseExistingThread =
!!queryThreadId &&
searchParams.get("isnew")?.trim().toLowerCase() === "false";
if (reuseExistingThread) {
return false;
}
if (searchParams.get("xclaw_used")?.trim().toLowerCase() !== "true") {
return true;
}
return searchParams.get("isnew")?.trim().toLowerCase() !== "false";
}, [isNewThread, searchParams]);
const streamThreadId = useMemo(() => {
return isNewThread && createNewSession ? undefined : threadId;
}, [createNewSession, isNewThread, threadId]);
const { showNotification } = useNotification();
// 监听宿主页 selectedSkill 消息
const {
skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId });
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: streamThreadId,
context: settings.context,
isMock,
onStart: (currentThreadId) => {
setIsNewThread(false);
// Keep /new in history so router.back() can return to it.
history.pushState(null, "", pathOfThread(currentThreadId));
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const title = useMemo(() => {
const result = thread.values?.title ?? "";
return result === "Untitled" ? "" : result;
}, [thread.values?.title]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const showInputBox = !(isNewThread && thread.isThreadLoading);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
useEffect(() => {
if (shouldRenderHistory) {
setHistoryCutoff(null);
return;
}
if (historyCutoff === null && !thread.isThreadLoading) {
setHistoryCutoff(thread.messages.length);
}
}, [
historyCutoff,
shouldRenderHistory,
thread.isThreadLoading,
thread.messages.length,
]);
useEffect(() => {
const pageTitle = isNewThread
? t.pages.newChat
: thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title
: t.pages.untitled;
if (thread.isThreadLoading) {
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,
]);
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 = true;
const [showExitDialog, setShowExitDialog] = useState(false);
const handleSubmit = useCallback(
(message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) {
return;
}
setHasSubmitted(true);
void sendMessage(threadId, message);
},
[isSelectedSkillBootstrapping, sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const resetNewSessionState = useCallback(() => {
setIsNewThread(true);
setHasSubmitted(false);
setHistoryCutoff(null);
setArtifacts([]);
deselectArtifact();
setArtifactsOpen(false);
setArtifactsFullscreen(false);
}, [
deselectArtifact,
setArtifacts,
setArtifactsFullscreen,
setArtifactsOpen,
setIsNewThread,
]);
return (
<ThreadContext.Provider value={{ thread }}>
<div
className={cn(
"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
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",
isNewThread && !hasSubmitted ? "hidden" : "",
)}
>
<div className="flex items-center justify-start overflow-hidden text-sm font-medium">
<Button
size="sm"
variant="ghost"
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
onClick={() => setShowExitDialog(true)}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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 className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} />
)}
</div>
<div className="flex items-center justify-end gap-2 overflow-hidden">
<DevTodoList
className="bg-white"
todos={thread.values.todos ?? []}
hidden={
!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-[58px]",
)}
threadId={threadId}
thread={thread}
messagesOverride={
shouldRenderHistory || historyCutoff === null
? undefined
: thread.messages.slice(historyCutoff)
}
paddingBottom={todoListCollapsed ? 160 : 280}
/>
</div>
</main>
</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)]",
)}
>
{showInputBox ? (
<InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
threadId={threadId}
isNewThread={isNewThread}
hasSubmitted={hasSubmitted}
autoFocus={isNewThread}
status={
thread.error
? "error"
: isUploading || thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{isNewThread && !hasSubmitted && (
<Welcome mode={settings.context.mode} />
)}
</div>
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping ||
isUploading
}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
// <InputBoxSkeleton />
''
)}
{/* {isSelectedSkillBootstrapping && (
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
正在初始化 Skill 文件...
</div>
)} */}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
{/* 退出确认对话框 */}
<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);
sendToParent({
type: POST_MESSAGE_TYPES.XCLAW_USED,
XClawUsed: false,
});
resetNewSessionState();
// 因为threadId可能为undefined所以这里不直接导航到 /workspace/chats/new而是通过 replace 的方式更新 URL 参数,保持在当前页面,触发 useThreadChat 重新计算状态。
const nextQuery = new URLSearchParams();
nextQuery.set("isnew", "false");
nextQuery.set("xclaw_used", "false");
if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId);
}
router.replace(`/workspace/chats/new?${nextQuery.toString()}`);
}}
>
</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 通信功能测试面板 */}
{/* {process.env.NODE_ENV !== "production" && <IframeTestPanel />} */}
</div>
</ThreadContext.Provider>
);
}