deerflow2/frontend/src/app/workspace/chats/[thread_id]/page.tsx
mt 9eb494b1b4 feat(brand): 聊天页 sxwz 模式下输入框左移 172px
- ChatPage 接入 useBrand,brand === 'sxwz' 时主容器和输入框 translate-x-[-172px]
- 退出对话回欢迎页时同步关闭 artifacts 面板
2026-06-10 17:51:53 +08:00

743 lines
27 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 { Ticker } from "@tombcato/smart-ticker";
import { FilesIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
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 { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { useBrand } from "@/core/brand/provider";
import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
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 { clearThreadMemoryOnExit } from "@/core/threads/exit-thread-memory";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils";
import "@tombcato/smart-ticker/style.css";
import motivationSlogans from "./motivation-slogans.json";
export default function ChatPage() {
const { t } = useI18n();
const { brand } = useBrand();
useSpecificChatMode();
const [sloganIndex, setSloganIndex] = useState(0);
const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const router = useRouter();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
selectedArtifact,
fullscreen,
} = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
useThreadChat();
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
const shouldRenderHistory = !showWelcomeStyle;
const safeThreadId = useMemo(() => {
if (!threadId || threadId === "new") {
return undefined;
}
return threadId;
}, [threadId]);
// `/new` + `thread_id` now reuses the pre-created thread, instead of creating
// a new session on first submit.
const createNewSession = useMemo(
() => isNewThread && !safeThreadId,
[isNewThread, safeThreadId],
);
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
const streamThreadId = useMemo(() => {
if (!safeThreadId) {
return undefined;
}
// In /new flow, defer history loading until thread init is finished:
// delete -> create -> history.
if (isNewThread && !isThreadInitReady) {
return undefined;
}
return safeThreadId;
}, [isNewThread, isThreadInitReady, safeThreadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null);
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
const { showNotification } = useNotification();
const currentSlogan = motivationSlogans[
sloganIndex % motivationSlogans.length
] ?? {
text: t.chatPage.defaultSlogan,
color: "var(--color-ws-fg-primary)",
};
const tickerCharacterList = useMemo(() => {
const seen = new Set<string>();
const uniqueChars: string[] = [];
for (const slogan of motivationSlogans) {
for (const char of slogan.text) {
if (seen.has(char)) continue;
seen.add(char);
uniqueChars.push(char);
}
}
return uniqueChars.join("");
}, []);
useEffect(() => {
if (motivationSlogans.length <= 1) return;
const timer = window.setInterval(
() => {
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
},
10 * 60 * 1000,
);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!isNewThread) {
warnedMissingThreadIdRef.current = false;
setIsThreadInitReady(true);
return;
}
if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) {
warnedMissingThreadIdRef.current = true;
toast.error(t.chatPage.missingThreadIdForCreate);
}
setIsThreadInitReady(false);
return;
}
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId;
setIsThreadInitReady(false);
const initPromise = apiClient.threads
.delete(safeThreadId)
.catch(() => undefined)
.then(() =>
apiClient.threads.create({
threadId: safeThreadId,
ifExists: "do_nothing",
}),
)
.then(() => {
setIsThreadInitReady(true);
})
.catch(() => {
initializedThreadRef.current = null;
setIsThreadInitReady(false);
toast.error(t.chatPage.createSessionFailed);
});
threadInitPromiseRef.current = initPromise;
void initPromise.finally(() => {
if (threadInitPromiseRef.current === initPromise) {
threadInitPromiseRef.current = null;
}
});
}, [
apiClient,
isNewThread,
safeThreadId,
t.chatPage.createSessionFailed,
t.chatPage.missingThreadIdForCreate,
]);
// 监听宿主页 selectedSkill 消息
const {
skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId: safeThreadId ?? null });
// 对话行为控制器
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: streamThreadId,
context: settings.context,
createNewSession,
isMock,
// 发送消息后跳转的逻辑
onStart: (currentThreadId) => {
setIsNewThread(false);
// if (!shouldStayOnNewRoute) {
// Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// }
// history.pushState(null, "", pathOfThread(currentThreadId));
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = t.chatPage.conversationFinished;
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 sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
useEffect(() => {
if (shouldRenderHistory) {
setHistoryCutoff(null);
return;
}
// Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
// 这样即使历史消息是后续异步补齐,也不会重新露出。
setHistoryCutoff((prev) => {
const next = thread.messages.length;
if (prev === null) return next;
return next > prev ? next : prev;
});
}, [
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 = `${t.common.loading} - ${t.pages.appName}`;
} else {
document.title = `${pageTitle} - ${t.pages.appName}`;
}
}, [
isNewThread,
t.common.loading,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.values?.title,
thread.isThreadLoading,
]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(sanitizedArtifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (sanitizedArtifacts.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(sanitizedArtifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
sanitizedArtifacts,
selectArtifact,
setArtifacts,
]);
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 [clearMemoryOnExit, setClearMemoryOnExit] = useState(false);
const [isConfirmingExit, setIsConfirmingExit] = useState(false);
const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback(
async (message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) {
return;
}
if (isNewThread && !safeThreadId) {
toast.error(t.chatPage.missingThreadIdForSend);
return;
}
if (isNewThread && safeThreadId) {
await threadInitPromiseRef.current;
}
if (isNewThread && safeThreadId && !isThreadInitReady) {
return;
}
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
}
void sendMessage(safeThreadId, message);
},
[
isNewThread,
isThreadInitReady,
isSelectedSkillBootstrapping,
router,
safeThreadId,
sendMessage,
showWelcomeStyle,
t.chatPage.missingThreadIdForSend,
],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
return (
<ThreadContext.Provider value={{ threadId, 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%]",
brand === "sxwz" && artifactsOpen === false && "translate-x-[-172px]",
)}
>
<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",
showWelcomeStyle ? "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-ws-base-1 hover:text-ws-base-1/80"
disabled={isStreaming}
onClick={() => {
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
router.replace(`/workspace/chats/${threadId}?is_chatting=false`)
setArtifactsOpen(false);
}
}
>
<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"
className="text-ws-text-muted"
stroke="currentColor"
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-ws-fg-primary"
style={{
color: currentSlogan.color,
}}
>
{/* threadTitle={title} */}
{title !== "Untitled" && (
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
<Ticker
value={currentSlogan.text}
duration={800}
easing="easeInOut"
charWidth={1}
characterLists={tickerCharacterList}
/>
)}
</div>
<div className="flex items-center justify-end gap-2 overflow-hidden">
{/* 取消TodoList */}
{/* <DevTodoList
className="bg-ws-surface-base"
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-ws-base-1 hover:text-ws-base-1"
>
<ListTodoIcon className="size-4" /> To-dos
</Button>
}
/> */}
<Tooltip content={t.common.resetThread}>
<Button
size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1"
disabled={isStreaming}
onClick={() => setShowExitDialog(true)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M2 4H6M16 4H12M6 4H12M6 4C6 2.89543 6.89543 2 8 2H10C11.1046 2 12 2.89543 12 4M4 6V14C4 15.1046 4.89543 16 6 16H12C13.1046 16 14 15.1046 14 14V6M7 8V13M11 8V13" stroke="#150033" strokeLinecap="round" />
</svg>
{t.common.resetThread}
</Button>
</Tooltip>
{artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content={t.chatPage.viewArtifactsTooltip}>
<Button
data-testid="artifacts-open-button"
className="text-ws-base-1 hover:text-ws-base-1/80"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M16 7V4C16 2.89543 15.1046 2 14 2H4C2.89543 2 2 2.89543 2 4V14C2 15.1046 2.89543 16 4 16H9" stroke="#150033" strokeLinecap="round" />
<path d="M5 5H9M5 8H7" stroke="#150033" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="11.5" cy="10.5" r="3" stroke="#150033" />
<path d="M15.5 14.5L14 13" stroke="#150033" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{t.common.artifacts}
</Button>
</Tooltip>
)}
</div>
</header>
<main
className={cn(
"flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle ? "bg-ws-surface-base" : "bg-background",
)}
>
<div className="flex size-full justify-center">
<MessageList
className={cn(
"size-full",
!showWelcomeStyle && "pt-[58px]",
)}
threadId={threadId}
thread={thread}
messagesOverride={
shouldRenderHistory
? undefined
: historyCutoff === null
? []
: thread.messages.slice(historyCutoff)
}
paddingBottom={todoListCollapsed ? 160 : 280}
showScrollToBottomButton={!showWelcomeStyle}
scrollButtonClassName="bottom-[112px]"
/>
</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",
showWelcomeStyle ? "translate-x-0" : "",
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="z-30"></div>
{sanitizedArtifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle}
description={t.chatPage.noArtifactSelectedDescription}
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
<header className="flex shrink-0 items-center justify-between border-b">
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-fg-primary">
<span>{t.common.artifacts}</span>
</h2>
<Button
data-testid="artifacts-panel-close"
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</header>
<main className="min-h-0 grow overflow-auto">
<ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={sanitizedArtifacts}
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]",
showWelcomeStyle && "-translate-y-[calc(50vh-96px)]",
brand === "sxwz" && "-translate-x-[172px]"
)}
>
{!(showWelcomeStyle && thread.isThreadLoading) ? (
<>
<InputBox
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
threadId={threadId}
showWelcomeStyle={showWelcomeStyle}
autoFocus={showWelcomeStyle}
status={
thread.error
? "error"
: isUploading || thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{showWelcomeStyle && <Welcome mode={settings.context.mode} />}
</div>
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping ||
isUploading ||
(isNewThread && !safeThreadId)
}
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={(open) => {
setShowExitDialog(open);
if (!open) {
setClearMemoryOnExit(false);
setIsConfirmingExit(false);
}
}}
>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle>{t.chatPage.exitDialogTitle}</DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
{t.chatPage.exitDialogDescription}
</p>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ws-fg-primary">
<input
type="checkbox"
className="h-4 w-4 rounded border-ws-divider accent-ws-interactive-primary"
checked={clearMemoryOnExit}
onChange={(e) => setClearMemoryOnExit(e.target.checked)}
disabled={isConfirmingExit}
/>
<span>{t.chatPage.exitDialogClearMemory}</span>
</label>
<DevDialogFooter>
<Button
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={() => setShowExitDialog(false)}
disabled={isConfirmingExit}
>
{t.common.cancel}
</Button>
<Button
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={async () => {
setIsConfirmingExit(true);
try {
if (thread.isLoading) {
await handleStop();
}
await clearThreadMemoryOnExit({
backendBaseURL: getBackendBaseURL(),
threadId: safeThreadId,
shouldClearMemory: clearMemoryOnExit,
});
setShowExitDialog(false);
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
router.replace(`/workspace/chats/new?thread_id=${threadId}`);
} catch {
toast.error(t.threadMemoryPanel.toastDeleteFailed);
} finally {
setIsConfirmingExit(false);
}
}}
disabled={isConfirmingExit}
>
{t.chatPage.exitDialogConfirm}
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* selectedSkill 失败:错误弹窗 */}
<DevDialog
open={!!selectedSkillError}
onOpenChange={(open) => {
if (!open) clearSelectedSkillError();
}}
>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle>
{" "}
{selectedSkillError?.title ??
t.chatPage.selectedSkillLoadFailed}
</DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
{selectedSkillError?.message ?? t.chatPage.unknownErrorRetry}
</p>
<DevDialogFooter singleColumn>
<Button
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={clearSelectedSkillError}
>
{t.common.close}
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* {process.env.NODE_ENV !== "production" && <IframeTestPanel />} */}
</div>
</ThreadContext.Provider>
);
}