550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|