557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
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 { 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 { TodoList } from "@/components/workspace/todo-list";
|
||
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 { useNotification } from "@/core/notification/hooks";
|
||
import { useLocalSettings } from "@/core/settings";
|
||
import { bootstrapRemoteSkill } from "@/core/skills";
|
||
import { type AgentThread, type AgentThreadState } from "@/core/threads";
|
||
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
||
import {
|
||
pathOfThread,
|
||
textOfMessage,
|
||
titleOfThread,
|
||
} from "@/core/threads/utils";
|
||
import { uuid } from "@/core/utils/uuid";
|
||
import { env } from "@/env";
|
||
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
export default function ChatPage() {
|
||
const { t } = useI18n();
|
||
const router = useRouter();
|
||
useSpecificChatMode();
|
||
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();
|
||
const promptInputController = usePromptInputController();
|
||
|
||
// UI mode depends only on route: /workspace/chats/new is always "new page" mode.
|
||
const isNewThread = useMemo(
|
||
() => 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();
|
||
|
||
// 监听宿主页 selectedSkill 消息
|
||
const {
|
||
selectedSkill,
|
||
skillError: selectedSkillError,
|
||
clearSkillError: clearSelectedSkillError,
|
||
isBootstrapping: isSelectedSkillBootstrapping,
|
||
} = useSelectedSkillListener({ threadId });
|
||
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
|
||
const thread = useThreadStream({
|
||
// Keep UI in new-page mode, but runtime may reuse existing thread
|
||
isNewThread: reuseExistingThread ? false : isNewThread,
|
||
threadId,
|
||
fetchStateHistory: true,
|
||
onFinish: (state) => {
|
||
setFinalState(state);
|
||
// 新对话完成后导航到对话页面
|
||
if (isNewThread && threadId) {
|
||
router.push(pathOfThread(threadId));
|
||
}
|
||
if (document.hidden || !document.hasFocus()) {
|
||
let body = "Conversation finished";
|
||
const lastMessage = state.messages[state.messages.length - 1];
|
||
if (lastMessage) {
|
||
const textContent = textOfMessage(lastMessage);
|
||
if (textContent) {
|
||
if (textContent.length > 200) {
|
||
body = textContent.substring(0, 200) + "...";
|
||
} else {
|
||
body = textContent;
|
||
}
|
||
}
|
||
}
|
||
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, setTodoListCollapsed] = 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(
|
||
(message: Parameters<typeof submitThread>[0]) => {
|
||
if (isSelectedSkillBootstrapping) {
|
||
return;
|
||
}
|
||
setHasSubmitted(true);
|
||
void submitThread(message);
|
||
},
|
||
[isSelectedSkillBootstrapping, submitThread],
|
||
);
|
||
const handleStop = useCallback(async () => {
|
||
await thread.stop();
|
||
}, [thread]);
|
||
|
||
if (!threadId) {
|
||
return null;
|
||
}
|
||
|
||
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} thread={thread} />
|
||
)}
|
||
</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-[20px]",
|
||
)}
|
||
threadId={threadId}
|
||
thread={thread}
|
||
suppressThreadLoading={suppressExistingThreadPrefetchUi}
|
||
messagesOverride={
|
||
suppressExistingThreadPrefetchUi
|
||
? []
|
||
: !thread.isLoading && finalState?.messages
|
||
? finalState.messages
|
||
: undefined
|
||
}
|
||
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)]",
|
||
)}
|
||
>
|
||
<InputBox
|
||
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
||
isNewThread={isNewThread}
|
||
hasSubmitted={hasSubmitted}
|
||
autoFocus={isNewThread}
|
||
status={
|
||
suppressExistingThreadPrefetchUi
|
||
? "ready"
|
||
: 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
|
||
}
|
||
onContextChange={(context) => setSettings("context", context)}
|
||
onSubmit={handleSubmit}
|
||
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" && (
|
||
<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);
|
||
// 使用完整页面刷新确保组件重新挂载,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>
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|