feat(02-01): 移除 isnew 路由逻辑并收敛线程单路径
- 新会话仅由 /workspace/chats/new 路由控制 - 删除 isnew 参数分支并同步 iframe 跳转逻辑
This commit is contained in:
parent
d238e40bcd
commit
28ab2ac39f
|
|
@ -1,105 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
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,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
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 { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
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 { useThreadSettings } from "@/core/settings";
|
||||
import { bootstrapRemoteSkill } from "@/core/skills";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } 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";
|
||||
|
||||
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() {
|
||||
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();
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/xclaw_used 参数。
|
||||
const shouldRenderHistory = !showWelcomeStyle;
|
||||
const createNewSession = useMemo(() => isNewThread, [isNewThread]);
|
||||
|
||||
const streamThreadId = useMemo(() => {
|
||||
return isNewThread && createNewSession ? undefined : threadId;
|
||||
}, [createNewSession, isNewThread, threadId]);
|
||||
|
||||
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({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
threadId: streamThreadId,
|
||||
context: settings.context,
|
||||
createNewSession,
|
||||
isMock,
|
||||
onStart: () => {
|
||||
// 发送消息后跳转的逻辑
|
||||
onStart: (currentThreadId) => {
|
||||
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.
|
||||
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
||||
// if (!shouldStayOnNewRoute) {
|
||||
// Keep /new in history so router.back() can return to it.
|
||||
router.replace(`/workspace/chats/${currentThreadId}`);
|
||||
// }
|
||||
// history.pushState(null, "", pathOfThread(currentThreadId));
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
|
|
@ -119,154 +113,367 @@ 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(() => {
|
||||
if (!threadId || !skillBootstrap?.contentIds?.length) {
|
||||
if (shouldRenderHistory) {
|
||||
setHistoryCutoff(null);
|
||||
return;
|
||||
}
|
||||
if (historyCutoff === null && !thread.isThreadLoading) {
|
||||
setHistoryCutoff(thread.messages.length);
|
||||
}
|
||||
}, [
|
||||
historyCutoff,
|
||||
shouldRenderHistory,
|
||||
thread.isThreadLoading,
|
||||
thread.messages.length,
|
||||
]);
|
||||
|
||||
const languageType = skillBootstrap.languageType ?? 0;
|
||||
const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`;
|
||||
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 (
|
||||
skillBootstrappedKeysRef.current.has(initKey) ||
|
||||
skillBootstrappingKeysRef.current.has(initKey)
|
||||
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;
|
||||
}
|
||||
|
||||
skillBootstrappingKeysRef.current.add(initKey);
|
||||
|
||||
const runBootstrap = async () => {
|
||||
try {
|
||||
await bootstrapRemoteSkill({
|
||||
thread_id: threadId,
|
||||
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 handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasSubmitted(true);
|
||||
void sendMessage(threadId, message);
|
||||
// 仅切换界面风格,不影响线程状态
|
||||
if (forceNewStyle) {
|
||||
setForceNewStyle(false);
|
||||
}
|
||||
},
|
||||
[sendMessage, threadId, forceNewStyle],
|
||||
[isSelectedSkillBootstrapping, sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const resetNewSessionState = useCallback(() => {
|
||||
setIsNewThread(true);
|
||||
setHasSubmitted(false);
|
||||
setHistoryCutoff(null);
|
||||
setArtifacts([]);
|
||||
deselectArtifact();
|
||||
setArtifactsOpen(false);
|
||||
setArtifactsFullscreen(false);
|
||||
}, [
|
||||
deselectArtifact,
|
||||
setArtifacts,
|
||||
setArtifactsFullscreen,
|
||||
setArtifactsOpen,
|
||||
setIsNewThread,
|
||||
]);
|
||||
// shouldRenderHistory || historyCutoff === null
|
||||
// console.log('shouldRenderHistory', shouldRenderHistory, 'historyCutoff', historyCutoff);
|
||||
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
|
||||
(forceNewStyle || isNewThread)
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</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">
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
(forceNewStyle || isNewThread) && "-translate-y-[calc(50vh-96px)]",
|
||||
(forceNewStyle || isNewThread)
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
"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="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
<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 && !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
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-full justify-center">
|
||||
{invalidNewRoute ? (
|
||||
<div className="flex size-full items-center justify-center px-6">
|
||||
<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>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={forceNewStyle || isNewThread}
|
||||
) : (
|
||||
<MessageList
|
||||
className={cn(
|
||||
"size-full",
|
||||
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
|
||||
)}
|
||||
threadId={threadId}
|
||||
autoFocus={forceNewStyle || isNewThread}
|
||||
thread={thread}
|
||||
messagesOverride={
|
||||
shouldRenderHistory || historyCutoff === null
|
||||
? undefined
|
||||
: 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",
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
: thread.isLoading
|
||||
: isUploading || thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
(forceNewStyle || isNewThread) && <Welcome mode={settings.context.mode} />
|
||||
<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)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
// <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}
|
||||
|
|
@ -274,9 +481,83 @@ export default function ChatPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 退出确认对话框 */}
|
||||
<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>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,115 @@
|
|||
"use client";
|
||||
|
||||
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 = {
|
||||
newThreadId?: string;
|
||||
};
|
||||
|
||||
export function useThreadChat(options?: UseThreadChatOptions) {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
export function useThreadChat() {
|
||||
const pathname = usePathname();
|
||||
const fallbackNewThreadIdRef = useRef<string>(options?.newThreadId ?? uuid());
|
||||
const fallbackNewThreadId = options?.newThreadId ?? fallbackNewThreadIdRef.current;
|
||||
const params = useParams<{ thread_id?: string }>();
|
||||
// 兜底:当 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();
|
||||
// 读取 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(() => {
|
||||
return threadIdFromPath === "new" ? fallbackNewThreadId : threadIdFromPath;
|
||||
return effectiveThreadIdFromPath ?? undefined;
|
||||
});
|
||||
|
||||
const [isNewThread, setIsNewThread] = useState(
|
||||
() => threadIdFromPath === "new",
|
||||
);
|
||||
// New session is only controlled by `/workspace/chats/new`.
|
||||
const [isNewThread, setIsNewThread] = useState(() => isNewRequested);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.endsWith("/new")) {
|
||||
setIsNewThread(true);
|
||||
setThreadId(fallbackNewThreadId);
|
||||
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
||||
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
||||
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";
|
||||
return { threadId, isNewThread, setIsNewThread, isMock };
|
||||
return {
|
||||
threadId,
|
||||
isNewThread,
|
||||
setIsNewThread,
|
||||
isMock,
|
||||
showWelcomeStyle,
|
||||
invalidNewRoute,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -14,9 +14,14 @@ import type { FileInMessage } from "../messages/utils";
|
|||
import type { LocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
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 = {
|
||||
name: string;
|
||||
|
|
@ -26,14 +31,19 @@ export type ToolEndEvent = {
|
|||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
createNewSession?: boolean;
|
||||
isMock?: boolean;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
export type LegacyThreadStreamOptions = {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
fetchStateHistory?: boolean;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
useSubmitThread?: boolean;
|
||||
};
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
|
|
@ -59,9 +69,67 @@ function getStreamErrorMessage(error: unknown): string {
|
|||
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({
|
||||
threadId,
|
||||
context,
|
||||
createNewSession = false,
|
||||
isMock,
|
||||
onStart,
|
||||
onFinish,
|
||||
|
|
@ -113,9 +181,10 @@ export function useThreadStream({
|
|||
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const apiClient = getAPIClient(isMock);
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(isMock),
|
||||
client: apiClient,
|
||||
assistantId: "lead_agent",
|
||||
threadId: onStreamThreadId,
|
||||
reconnectOnMount: true,
|
||||
|
|
@ -174,20 +243,6 @@ export function useThreadStream({
|
|||
message: AIMessage;
|
||||
};
|
||||
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) {
|
||||
|
|
@ -219,10 +274,9 @@ export function useThreadStream({
|
|||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
threadId: string | undefined,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
if (sendInFlightRef.current) {
|
||||
return;
|
||||
|
|
@ -230,6 +284,13 @@ export function useThreadStream({
|
|||
sendInFlightRef.current = true;
|
||||
|
||||
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
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
|
|
@ -243,23 +304,17 @@ export function useThreadStream({
|
|||
}),
|
||||
);
|
||||
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
const newOptimistic: Message[] = [];
|
||||
if (!hideFromUI) {
|
||||
newOptimistic.push({
|
||||
// Create optimistic human message (shown immediately)
|
||||
const optimisticHumanMsg: Message = {
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs: optimisticAdditionalKwargs,
|
||||
});
|
||||
}
|
||||
additional_kwargs:
|
||||
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
|
||||
};
|
||||
|
||||
if (optimisticFiles.length > 0 && !hideFromUI) {
|
||||
const newOptimistic: Message[] = [optimisticHumanMsg];
|
||||
if (optimisticFiles.length > 0) {
|
||||
// Mock AI message while files are being uploaded
|
||||
newOptimistic.push({
|
||||
type: "ai",
|
||||
|
|
@ -270,18 +325,44 @@ export function useThreadStream({
|
|||
}
|
||||
setOptimisticMessages(newOptimistic);
|
||||
|
||||
_handleOnStart(threadId);
|
||||
if (resolvedThreadId) {
|
||||
_handleOnStart(resolvedThreadId);
|
||||
}
|
||||
|
||||
let uploadedFileInfo: UploadedFileInfo[] = [];
|
||||
|
||||
try {
|
||||
// 新会话模式下,删除旧线程并创建同名新线程
|
||||
if (createNewSession && resolvedThreadId) {
|
||||
await apiClient.threads.delete(resolvedThreadId).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Upload files first if any
|
||||
if (message.files && message.files.length > 0) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const filePromises = message.files.map((fileUIPart) =>
|
||||
promptInputFilePartToFile(fileUIPart),
|
||||
// Convert FileUIPart to File objects by fetching blob URLs
|
||||
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 files = conversionResults.filter(
|
||||
|
|
@ -295,12 +376,12 @@ export function useThreadStream({
|
|||
);
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
if (!resolvedThreadId) {
|
||||
throw new Error("Thread is not ready for file upload.");
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const uploadResponse = await uploadFiles(threadId, files);
|
||||
const uploadResponse = await uploadFiles(resolvedThreadId, files);
|
||||
uploadedFileInfo = uploadResponse.files;
|
||||
|
||||
// Update optimistic human message with uploaded status + paths
|
||||
|
|
@ -327,6 +408,7 @@ export function useThreadStream({
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to upload files:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
|
@ -360,17 +442,13 @@ export function useThreadStream({
|
|||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
additional_kwargs:
|
||||
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
threadId: resolvedThreadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
|
|
@ -391,7 +469,7 @@ export function useThreadStream({
|
|||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
thread_id: threadId,
|
||||
...(resolvedThreadId ? { thread_id: resolvedThreadId } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -404,7 +482,15 @@ export function useThreadStream({
|
|||
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
|
||||
|
|
@ -416,7 +502,129 @@ export function useThreadStream({
|
|||
} as typeof 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(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,54 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||
|
||||
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) {
|
||||
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) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue