feat(02-01): 移除 isnew 路由逻辑并收敛线程单路径

- 新会话仅由 /workspace/chats/new 路由控制

- 删除 isnew 参数分支并同步 iframe 跳转逻辑
This commit is contained in:
肖应宇 2026-04-07 12:53:49 +08:00
parent d238e40bcd
commit 28ab2ac39f
5 changed files with 979 additions and 274 deletions

View File

@ -1,105 +1,99 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { Button } from "@/components/ui/button";
import { import {
ChatBox, DevDialog,
useSpecificChatMode, DevDialogContent,
useThreadChat, DevDialogFooter,
} from "@/components/workspace/chats"; DevDialogHeader,
import { ExportTrigger } from "@/components/workspace/export-trigger"; 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 { InputBox } from "@/components/workspace/input-box";
import { import { MessageList } from "@/components/workspace/messages";
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} 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 { TodoList } from "@/components/workspace/todo-list"; import { Tooltip } from "@/components/workspace/tooltip";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
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 { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { bootstrapRemoteSkill } from "@/core/skills";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } 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";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const searchParams = useSearchParams();
const generatedThreadIdRef = useRef<string>("");
if (!generatedThreadIdRef.current) {
const queryThreadId = searchParams.get("thread_id")?.trim();
generatedThreadIdRef.current =
queryThreadId && UUID_REGEX.test(queryThreadId) ? queryThreadId : uuid();
}
// 检查 xclaw_used 参数,仅用于界面风格控制,不影响线程创建逻辑
const xclawUsedParam = searchParams.get("xclaw_used");
const initialForceNewStyle = xclawUsedParam === "false";
const [forceNewStyle, setForceNewStyle] = useState(initialForceNewStyle);
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat({
newThreadId: generatedThreadIdRef.current,
});
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
useSpecificChatMode(); 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,
showWelcomeStyle,
invalidNewRoute,
} = useThreadChat();
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/xclaw_used 参数。
const shouldRenderHistory = !showWelcomeStyle;
const createNewSession = useMemo(() => isNewThread, [isNewThread]);
useEffect(() => { const streamThreadId = useMemo(() => {
setMounted(true); return isNewThread && createNewSession ? undefined : threadId;
}, []); }, [createNewSession, isNewThread, threadId]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const skillBootstrappedKeysRef = useRef<Set<string>>(new Set());
const skillBootstrappingKeysRef = useRef<Set<string>>(new Set());
const skillBootstrap = useMemo(() => {
const skillIdRaw = searchParams.get("skill_id")?.trim();
if (!skillIdRaw) return undefined;
const contentIds = skillIdRaw
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
// Deduplicate while preserving incoming order.
const uniqueContentIds = Array.from(new Set(contentIds));
if (uniqueContentIds.length === 0) return undefined;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
return {
contentIds: uniqueContentIds,
languageType: Number.isFinite(languageType) ? languageType : 0,
};
}, [searchParams]);
// 监听宿主页 selectedSkill 消息
const {
skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId });
// 对话行为控制器
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: streamThreadId,
context: settings.context, context: settings.context,
createNewSession,
isMock, isMock,
onStart: () => { // 发送消息后跳转的逻辑
onStart: (currentThreadId) => {
setIsNewThread(false); setIsNewThread(false);
// ! 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. // if (!shouldStayOnNewRoute) {
history.replaceState(null, "", `/workspace/chats/${threadId}`); // Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}`);
// }
// history.pushState(null, "", pathOfThread(currentThreadId));
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
@ -119,164 +113,451 @@ export default function ChatPage() {
}, },
}); });
const title = useMemo(() => {
const result = thread.values?.title ?? "";
return result === "Untitled" ? "" : result;
}, [thread.values?.title]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const showInputBox = !invalidNewRoute && !(showWelcomeStyle && thread.isThreadLoading);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (!threadId || !skillBootstrap?.contentIds?.length) { if (shouldRenderHistory) {
setHistoryCutoff(null);
return; return;
} }
if (historyCutoff === null && !thread.isThreadLoading) {
setHistoryCutoff(thread.messages.length);
}
}, [
historyCutoff,
shouldRenderHistory,
thread.isThreadLoading,
thread.messages.length,
]);
const languageType = skillBootstrap.languageType ?? 0; useEffect(() => {
const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`; 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 ( if (
skillBootstrappedKeysRef.current.has(initKey) || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
skillBootstrappingKeysRef.current.has(initKey) autoSelectFirstArtifact
) { ) {
return; if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
} }
}, [
autoSelectFirstArtifact,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
skillBootstrappingKeysRef.current.add(initKey); const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
const runBootstrap = async () => { return artifactsOpen && artifacts?.length > 0;
try { }
await bootstrapRemoteSkill({ return artifactsOpen;
thread_id: threadId, }, [artifactsOpen, artifacts]);
content_ids: skillBootstrap.contentIds,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
skillBootstrappedKeysRef.current.add(initKey);
} catch (error) {
const message =
error instanceof Error ? error.message : "Skill initialization failed";
showNotification("Skill initialization failed", { body: message });
} finally {
skillBootstrappingKeysRef.current.delete(initKey);
}
};
void runBootstrap();
}, [threadId, skillBootstrap, showNotification]);
const todoListCollapsed = true;
const [showExitDialog, setShowExitDialog] = useState(false);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: Parameters<typeof sendMessage>[1]) => {
void sendMessage(threadId, message); if (isSelectedSkillBootstrapping) {
// 仅切换界面风格,不影响线程状态 return;
if (forceNewStyle) {
setForceNewStyle(false);
} }
setHasSubmitted(true);
void sendMessage(threadId, message);
}, },
[sendMessage, threadId, forceNewStyle], [isSelectedSkillBootstrapping, sendMessage, threadId],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const messageListPaddingBottom = showFollowups const resetNewSessionState = useCallback(() => {
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + setIsNewThread(true);
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM setHasSubmitted(false);
: undefined; setHistoryCutoff(null);
setArtifacts([]);
deselectArtifact();
setArtifactsOpen(false);
setArtifactsFullscreen(false);
}, [
deselectArtifact,
setArtifacts,
setArtifactsFullscreen,
setArtifactsOpen,
setIsNewThread,
]);
// shouldRenderHistory || historyCutoff === null
// console.log('shouldRenderHistory', shouldRenderHistory, 'historyCutoff', historyCutoff);
return ( return (
<ThreadContext.Provider value={{ thread, isMock }}> <ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}> <div
<div className="relative flex size-full min-h-0 justify-between"> className={cn(
<header "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( className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4", "relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
(forceNewStyle || isNewThread) artifactPanelOpen ? "w-[50%]" : "w-full",
? "bg-background/0 backdrop-blur-none" fullscreen && "hidden",
: "bg-background/80 shadow-xs backdrop-blur",
)} )}
> >
<div className="flex w-full items-center text-sm font-medium"> <div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<ThreadTitle threadId={threadId} thread={thread} /> <header
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator messages={thread.messages} />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
{/* forceNewStyle 时隐藏消息列表,提交后再显示 */}
{!(forceNewStyle) && (
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
/>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn( className={cn(
"relative w-full", "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",
(forceNewStyle || isNewThread) && "-translate-y-[calc(50vh-96px)]", showWelcomeStyle && !hasSubmitted ? "hidden" : "",
(forceNewStyle || isNewThread)
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)} )}
> >
<div className="absolute -top-4 right-0 left-0 z-0"> <div className="flex items-center justify-start overflow-hidden text-sm font-medium">
<div className="absolute right-0 bottom-0 left-0"> <Button
<TodoList size="sm"
className="bg-background/5" variant="ghost"
todos={thread.values.todos ?? []} className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
hidden={ onClick={() => setShowExitDialog(true)}
!thread.values.todos || thread.values.todos.length === 0 >
} <svg
/> width="20"
</div> 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>
{mounted ? ( <div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
<InputBox {title !== "Untitled" && (
className={cn("bg-background/5 w-full -translate-y-4")} <ThreadTitle threadId={threadId} threadTitle={title} />
isNewThread={forceNewStyle || isNewThread} )}
threadId={threadId} </div>
autoFocus={forceNewStyle || isNewThread} <div className="flex items-center justify-end gap-2 overflow-hidden">
status={ <DevTodoList
thread.error className="bg-white"
? "error" todos={thread.values.todos ?? []}
: thread.isLoading hidden={
? "streaming" !thread.values.todos || thread.values.todos.length === 0
: "ready"
} }
context={settings.context} trigger={
extraHeader={ <Button
(forceNewStyle || isNewThread) && <Welcome mode={settings.context.mode} /> 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>
} }
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/> />
{artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content="点击可查看生成的文件结果">
<Button
data-testid="artifacts-open-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",
showWelcomeStyle && !hasSubmitted ? "bg-white" : "bg-background",
)} )}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( >
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> <div className="flex size-full justify-center">
{t.common.notAvailableInDemoMode} {invalidNewRoute ? (
</div> <div className="flex size-full items-center justify-center px-6">
)} <div
</div> className="max-w-md rounded-2xl border border-[#E5DDF2] bg-white px-6 py-5 text-center shadow-sm"
data-testid="missing-thread-id-state"
role="alert"
>
<h2 className="text-base font-semibold text-[#150033]">
thread_id
</h2>
<p className="mt-2 text-sm text-[#666666]">
访
<span className="mx-1 font-mono">/workspace/chats/new</span>
<span className="mx-1 font-mono">?thread_id=...</span>
使
</p>
</div>
</div>
) : (
<MessageList
className={cn(
"size-full",
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
)}
threadId={threadId}
thread={thread}
messagesOverride={
shouldRenderHistory || historyCutoff === null
? undefined
: thread.messages.slice(historyCutoff)
}
paddingBottom={todoListCollapsed ? 160 : 280}
showScrollToBottomButton={!showWelcomeStyle}
scrollButtonClassName="bottom-[112px]"
/>
)}
</div>
</main>
</div> </div>
</main> </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
data-testid="artifacts-panel-close"
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> </div>
</ChatBox>
{/* 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 && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
)}
>
{showInputBox ? (
<InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
threadId={threadId}
showWelcomeStyle={showWelcomeStyle}
hasSubmitted={hasSubmitted}
autoFocus={showWelcomeStyle}
status={
thread.error
? "error"
: isUploading || thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{showWelcomeStyle && !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();
// 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams();
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> </ThreadContext.Provider>
); );
} }

View File

@ -1,35 +1,115 @@
"use client"; "use client";
import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { uuid } from "@/core/utils/uuid"; import { resolveThreadQueryIntent } from "@/core/threads/utils";
type UseThreadChatOptions = { export function useThreadChat() {
newThreadId?: string;
};
export function useThreadChat(options?: UseThreadChatOptions) {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname(); const pathname = usePathname();
const fallbackNewThreadIdRef = useRef<string>(options?.newThreadId ?? uuid()); const params = useParams<{ thread_id?: string }>();
const fallbackNewThreadId = options?.newThreadId ?? fallbackNewThreadIdRef.current; // 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
const threadIdFromPathname = (() => {
const parts = pathname.split("?")[0]?.split("/") ?? [];
const idx = parts.lastIndexOf("chats");
if (idx >= 0 && parts.length > idx + 1) {
return parts[idx + 1];
}
return undefined;
})();
const rawPathThreadId = params?.thread_id ?? threadIdFromPathname;
const isNewRoute = rawPathThreadId === "new";
const threadIdFromPath = isNewRoute ? undefined : rawPathThreadId;
// console.log("[useThreadChat] pathname", pathname);
// console.log("[useThreadChat] params.thread_id", params?.thread_id);
// console.log("[useThreadChat] threadIdFromPathname", threadIdFromPathname);
// console.log("[useThreadChat] threadIdFromPath", threadIdFromPath);
// 持久化兜底:用于处理首屏水合或 params 时序问题。
const readStoredThreadId = () => {
if (typeof window === "undefined") {
return undefined;
}
const stored = window.sessionStorage.getItem("workspace.thread_id");
return stored && stored !== "new" ? stored : undefined;
};
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// 读取 query 的 thread_id先用 hook必要时用 window 兜底)。
const readQueryThreadId = () => {
const fromHook = searchParams.get("thread_id")?.trim();
if (fromHook && fromHook !== "new") {
return fromHook;
}
if (typeof window === "undefined") {
return undefined;
}
const fromLocation = new URLSearchParams(window.location.search).get(
"thread_id",
);
if (fromLocation && fromLocation !== "new") {
return fromLocation.trim();
}
return undefined;
};
const queryThreadIdFromParams = readQueryThreadId();
// console.log("[useThreadChat] query.thread_id", queryThreadIdFromParams);
// 归一化:当值为 "new" 时,替换为 query 中的 thread_id如果存在
const normalizeThreadId = useCallback(
(value?: string | null) => {
if (!value) {
return undefined;
}
return value === "new" ? queryThreadIdFromParams : value;
},
[queryThreadIdFromParams],
);
const intent = resolveThreadQueryIntent({
pathThreadId: threadIdFromPath,
queryThreadId: queryThreadIdFromParams,
isNewRoute,
});
const { isNewThread: isNewRequested, showWelcomeStyle, invalidNewRoute } = intent;
const effectiveThreadIdFromPath =
invalidNewRoute
? undefined
: normalizeThreadId(threadIdFromPath) ??
(isNewRoute ? undefined : readStoredThreadId());
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
const [threadId, setThreadId] = useState(() => { const [threadId, setThreadId] = useState(() => {
return threadIdFromPath === "new" ? fallbackNewThreadId : threadIdFromPath; return effectiveThreadIdFromPath ?? undefined;
}); });
const [isNewThread, setIsNewThread] = useState( // New session is only controlled by `/workspace/chats/new`.
() => threadIdFromPath === "new", const [isNewThread, setIsNewThread] = useState(() => isNewRequested);
);
useEffect(() => { useEffect(() => {
if (pathname.endsWith("/new")) { // 记住最近一次有效的 thread_id供下次加载兜底使用。
setIsNewThread(true); if (threadId && threadId !== "new" && typeof window !== "undefined") {
setThreadId(fallbackNewThreadId); window.sessionStorage.setItem("workspace.thread_id", threadId);
} }
}, [pathname, fallbackNewThreadId]); setIsNewThread(isNewRoute);
// Prefer path thread id, fall back to query thread_id when path is /new.
setThreadId(
invalidNewRoute ? undefined : normalizeThreadId(threadIdFromPath),
);
}, [
invalidNewRoute,
isNewRoute,
normalizeThreadId,
pathname,
searchParams,
threadId,
threadIdFromPath,
]);
const isMock = searchParams.get("mock") === "true"; const isMock = searchParams.get("mock") === "true";
return { threadId, isNewThread, setIsNewThread, isMock }; return {
threadId,
isNewThread,
setIsNewThread,
isMock,
showWelcomeStyle,
invalidNewRoute,
};
} }

View File

@ -1,6 +1,6 @@
import type { AIMessage, Message } 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 } from "@langchain/langgraph-sdk/react"; import { useStream, type UseStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -14,9 +14,14 @@ import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api";
import type { AgentThread, AgentThreadState } from "./types"; import type {
AgentThread,
AgentThreadContext,
AgentThreadState,
} from "./types";
export type ToolEndEvent = { export type ToolEndEvent = {
name: string; name: string;
@ -26,14 +31,19 @@ export type ToolEndEvent = {
export type ThreadStreamOptions = { export type ThreadStreamOptions = {
threadId?: string | null | undefined; threadId?: string | null | undefined;
context: LocalSettings["context"]; context: LocalSettings["context"];
createNewSession?: boolean;
isMock?: boolean; isMock?: boolean;
onStart?: (threadId: string) => void; onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void; onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void; onToolEnd?: (event: ToolEndEvent) => void;
}; };
type SendMessageOptions = { export type LegacyThreadStreamOptions = {
additionalKwargs?: Record<string, unknown>; isNewThread: boolean;
threadId: string | null | undefined;
fetchStateHistory?: boolean;
onFinish?: (state: AgentThreadState) => void;
useSubmitThread?: boolean;
}; };
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
@ -59,9 +69,67 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed."; return "Request failed.";
} }
export function useThreadStreamLegacy({
threadId,
isNewThread,
fetchStateHistory = true,
onFinish,
}: LegacyThreadStreamOptions): UseStream<AgentThreadState> {
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
const thread = useStream<AgentThreadState>({
client: getAPIClient(),
assistantId: "lead_agent",
threadId: isNewThread ? undefined : threadId,
reconnectOnMount: true,
fetchStateHistory,
onCustomEvent(event: unknown) {
console.info(event);
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
}
},
onFinish(state) {
onFinish?.(state.values);
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread>) => {
return oldData.map((t) => {
if (t.thread_id === threadId) {
return {
...t,
values: {
...t.values,
title: state.values.title,
},
};
}
return t;
});
},
);
},
});
return thread as UseStream<AgentThreadState>;
}
export function useThreadStream({ export function useThreadStream({
threadId, threadId,
context, context,
createNewSession = false,
isMock, isMock,
onStart, onStart,
onFinish, onFinish,
@ -113,9 +181,10 @@ export function useThreadStream({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const apiClient = getAPIClient(isMock);
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock), client: apiClient,
assistantId: "lead_agent", assistantId: "lead_agent",
threadId: onStreamThreadId, threadId: onStreamThreadId,
reconnectOnMount: true, reconnectOnMount: true,
@ -174,20 +243,6 @@ export function useThreadStream({
message: AIMessage; message: AIMessage;
}; };
updateSubtask({ id: e.task_id, latestMessage: e.message }); updateSubtask({ id: e.task_id, latestMessage: e.message });
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "llm_retry" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) {
const e = event as { type: "llm_retry"; message: string };
toast(e.message);
} }
}, },
onError(error) { onError(error) {
@ -219,10 +274,9 @@ export function useThreadStream({
const sendMessage = useCallback( const sendMessage = useCallback(
async ( async (
threadId: string, threadId: string | undefined,
message: PromptInputMessage, message: PromptInputMessage,
extraContext?: Record<string, unknown>, extraContext?: Record<string, unknown>,
options?: SendMessageOptions,
) => { ) => {
if (sendInFlightRef.current) { if (sendInFlightRef.current) {
return; return;
@ -230,6 +284,13 @@ export function useThreadStream({
sendInFlightRef.current = true; sendInFlightRef.current = true;
const text = message.text.trim(); const text = message.text.trim();
const resolvedThreadId =
threadId ?? threadIdRef.current ?? undefined;
if (resolvedThreadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry.");
sendInFlightRef.current = false;
return;
}
// Capture current count before showing optimistic messages // Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length; prevMsgCountRef.current = thread.messages.length;
@ -243,23 +304,17 @@ export function useThreadStream({
}), }),
); );
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true; // Create optimistic human message (shown immediately)
const optimisticAdditionalKwargs = { const optimisticHumanMsg: Message = {
...options?.additionalKwargs, type: "human",
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}), id: `opt-human-${Date.now()}`,
content: text ? [{ type: "text", text }] : "",
additional_kwargs:
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
}; };
const newOptimistic: Message[] = []; const newOptimistic: Message[] = [optimisticHumanMsg];
if (!hideFromUI) { if (optimisticFiles.length > 0) {
newOptimistic.push({
type: "human",
id: `opt-human-${Date.now()}`,
content: text ? [{ type: "text", text }] : "",
additional_kwargs: optimisticAdditionalKwargs,
});
}
if (optimisticFiles.length > 0 && !hideFromUI) {
// Mock AI message while files are being uploaded // Mock AI message while files are being uploaded
newOptimistic.push({ newOptimistic.push({
type: "ai", type: "ai",
@ -270,18 +325,44 @@ export function useThreadStream({
} }
setOptimisticMessages(newOptimistic); setOptimisticMessages(newOptimistic);
_handleOnStart(threadId); if (resolvedThreadId) {
_handleOnStart(resolvedThreadId);
}
let uploadedFileInfo: UploadedFileInfo[] = []; let uploadedFileInfo: UploadedFileInfo[] = [];
try { try {
// 新会话模式下,删除旧线程并创建同名新线程
if (createNewSession && resolvedThreadId) {
await apiClient.threads.delete(resolvedThreadId).catch(() => undefined);
}
// 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); setIsUploading(true);
try { try {
const filePromises = message.files.map((fileUIPart) => // Convert FileUIPart to File objects by fetching blob URLs
promptInputFilePartToFile(fileUIPart), const filePromises = message.files.map(async (fileUIPart) => {
); if (fileUIPart.url && fileUIPart.filename) {
try {
// Fetch the blob URL to get the file data
const response = await fetch(fileUIPart.url);
const blob = await response.blob();
// Create a File object from the blob
return new File([blob], fileUIPart.filename, {
type: fileUIPart.mediaType || blob.type,
});
} catch (error) {
console.error(
`Failed to fetch file ${fileUIPart.filename}:`,
error,
);
return null;
}
}
return null;
});
const conversionResults = await Promise.all(filePromises); const conversionResults = await Promise.all(filePromises);
const files = conversionResults.filter( const files = conversionResults.filter(
@ -295,12 +376,12 @@ export function useThreadStream({
); );
} }
if (!threadId) { if (!resolvedThreadId) {
throw new Error("Thread is not ready for file upload."); throw new Error("Thread is not ready for file upload.");
} }
if (files.length > 0) { if (files.length > 0) {
const uploadResponse = await uploadFiles(threadId, files); const uploadResponse = await uploadFiles(resolvedThreadId, files);
uploadedFileInfo = uploadResponse.files; uploadedFileInfo = uploadResponse.files;
// Update optimistic human message with uploaded status + paths // Update optimistic human message with uploaded status + paths
@ -327,6 +408,7 @@ export function useThreadStream({
}); });
} }
} catch (error) { } catch (error) {
console.error("Failed to upload files:", error);
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
? error.message ? error.message
@ -360,17 +442,13 @@ export function useThreadStream({
text, text,
}, },
], ],
additional_kwargs: { additional_kwargs:
...options?.additionalKwargs, filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
}, },
], ],
}, },
{ {
threadId: threadId, threadId: resolvedThreadId,
streamSubgraphs: true, streamSubgraphs: true,
streamResumable: true, streamResumable: true,
config: { config: {
@ -391,7 +469,7 @@ export function useThreadStream({
: context.mode === "thinking" : context.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
thread_id: threadId, ...(resolvedThreadId ? { thread_id: resolvedThreadId } : {}),
}, },
}, },
); );
@ -404,7 +482,15 @@ export function useThreadStream({
sendInFlightRef.current = false; sendInFlightRef.current = false;
} }
}, },
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient], [
thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
queryClient,
apiClient,
createNewSession,
],
); );
// Merge thread with optimistic messages for display // Merge thread with optimistic messages for display
@ -416,7 +502,129 @@ export function useThreadStream({
} as typeof thread) } as typeof thread)
: thread; : thread;
return [mergedThread, sendMessage, isUploading] as const; return [
mergedThread as UseStream<AgentThreadState>,
sendMessage,
isUploading,
] as const;
}
export function useSubmitThread({
threadId,
thread,
threadContext,
createNewSession,
uploadTarget,
afterSubmit,
}: {
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) => {
if (threadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry.");
return;
}
const text = message.text.trim();
const hasFiles = !!(message.files && message.files.length > 0);
if (!text && !hasFiles) {
return;
}
if (createNewSession && threadId) {
await apiClient.threads.delete(threadId).catch(() => undefined);
await apiClient.threads.create({
threadId,
ifExists: "do_nothing",
});
}
if (message.files && message.files.length > 0) {
try {
const filePromises = message.files.map(async (fileUIPart) => {
if (fileUIPart.url && fileUIPart.filename) {
try {
const response = await fetch(fileUIPart.url);
const blob = await response.blob();
return new File([blob], fileUIPart.filename, {
type: fileUIPart.mediaType || blob.type,
});
} catch (error) {
console.error(
`Failed to fetch file ${fileUIPart.filename}:`,
error,
);
return null;
}
}
return null;
});
const files = (await Promise.all(filePromises)).filter(
(file): file is File => file !== null,
);
if (files.length > 0 && threadId) {
await uploadFiles(threadId, files, { target: uploadTarget });
}
} catch (error) {
console.error("Failed to upload files:", error);
}
}
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
},
] as Message[],
},
{
threadId: createNewSession ? threadId! : undefined,
streamSubgraphs: true,
streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
config: {
recursion_limit: 1000,
},
context: {
...threadContext,
...(threadId ? { thread_id: threadId } : {}),
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.();
},
[
thread,
createNewSession,
threadId,
threadContext,
uploadTarget,
queryClient,
apiClient,
afterSubmit,
],
);
return callback;
} }
export function useThreads( export function useThreads(

View File

@ -2,10 +2,54 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread } from "./types"; import type { AgentThread } from "./types";
export interface ThreadQueryIntentInput {
pathThreadId?: string | null;
queryThreadId?: string | null;
isNewRoute?: boolean;
}
export interface ThreadQueryIntent {
threadId: string | undefined;
isNewThread: boolean;
showWelcomeStyle: boolean;
invalidNewRoute: boolean;
}
export function pathOfThread(threadId: string) { export function pathOfThread(threadId: string) {
return `/workspace/chats/${threadId}`; return `/workspace/chats/${threadId}`;
} }
function normalizeThreadId(value?: string | null): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed || trimmed === "new") {
return undefined;
}
return trimmed;
}
export function resolveThreadQueryIntent({
pathThreadId,
queryThreadId,
isNewRoute = false,
}: ThreadQueryIntentInput): ThreadQueryIntent {
const normalizedPathId = normalizeThreadId(pathThreadId);
const normalizedQueryId = normalizeThreadId(queryThreadId);
const isNewThread = isNewRoute;
return {
// 优先使用路径 thread_id/new 场景回落到 query thread_id
threadId: normalizedPathId ?? normalizedQueryId,
// 新逻辑只由路由 /workspace/chats/new 控制“新会话”
isNewThread,
showWelcomeStyle: isNewThread,
// 新逻辑下不再要求 /new 必带 query thread_id
invalidNewRoute: false,
};
}
export function textOfMessage(message: Message) { export function textOfMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content; return message.content;

View File

@ -0,0 +1,92 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react";
import {
POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES,
sendToParent,
type SelectedSkillMessage,
} from "@/core/iframe-messages";
// Skill 数据类型
interface SkillData {
skill_id: string;
title: string;
}
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
sendSelectSkill: (skill_id: string) => void;
openSkillDialog: () => void;
clearSkill: () => void;
}
export function useIframeSkill(): UseIframeSkillReturn {
const router = useRouter();
const searchParams = useSearchParams();
const skillIdFromQuery = searchParams.get("skill_id");
const titleFromQuery = searchParams.get("title");
const threadIdFromQuery = searchParams.get("thread_id");
const xClawUsedFromQuery = searchParams.get("xclaw_used");
const lastThreadIdRef = useRef<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
// 1. 监听 query 参数变化
useEffect(() => {
if (skillIdFromQuery && titleFromQuery) {
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
}
}, [skillIdFromQuery, titleFromQuery]);
// 0. 监听 query 中 XClawUsed=true 且带 thread_id 时跳转并清理 query
useEffect(() => {
if (!threadIdFromQuery) return;
if (xClawUsedFromQuery !== "true") return;
if (lastThreadIdRef.current === threadIdFromQuery) return;
lastThreadIdRef.current = threadIdFromQuery;
router.replace(`/workspace/chats/${threadIdFromQuery}`);
}, [router, threadIdFromQuery, xClawUsedFromQuery]);
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
const { id, title } = event.data as SelectedSkillMessage;
setSelectedSkill({ skill_id: String(id), title });
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// 发送选择预定义 skill
const sendSelectSkill = useCallback((skill_id: string) => {
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id };
console.log("[useIframeSkill] sendSelectSkill:", message);
sendToParent(message);
}, []);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
const message = {
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
openSkillDialog: true,
} as const;
console.log("[useIframeSkill] openSkillDialog:", message);
sendToParent(message);
}, []);
// 清除选中并发送 skill_id=0 给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送 skill_id=0 给主页,通知取消选择
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message);
sendToParent(message);
}, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
}