diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 3a522b2f..b4508e06 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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(""); - 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(); + + // 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/xclaw_used 参数。 + const shouldRenderHistory = !showWelcomeStyle; + const createNewSession = useMemo(() => isNewThread, [isNewThread]); - useEffect(() => { - setMounted(true); - }, []); + const streamThreadId = useMemo(() => { + return isNewThread && createNewSession ? undefined : threadId; + }, [createNewSession, isNewThread, threadId]); const { showNotification } = useNotification(); - const skillBootstrappedKeysRef = useRef>(new Set()); - const skillBootstrappingKeysRef = useRef>(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,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(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 ) { - 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 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 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: PromptInputMessage) => { - void sendMessage(threadId, message); - // 仅切换界面风格,不影响线程状态 - if (forceNewStyle) { - setForceNewStyle(false); + (message: Parameters[1]) => { + if (isSelectedSkillBootstrapping) { + return; } + setHasSubmitted(true); + void sendMessage(threadId, message); }, - [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 ( - - -
-
+
+
+
-
- -
-
- - - -
-
-
-
- {/* forceNewStyle 时隐藏消息列表,提交后再显示 */} - {!(forceNewStyle) && ( - - )} -
-
-
+
-
-
-
+
+
- {mounted ? ( - + {title !== "Untitled" && ( + + )} +
+
+
+
- {t.common.notAvailableInDemoMode} -
- )} -
+ > +
+ {invalidNewRoute ? ( +
+
+

+ 缺少 thread_id 参数 +

+

+ 访问 + /workspace/chats/new + 时必须显式传入 + ?thread_id=... + ,当前页面不会继续使用本地缓存兜底。 +

+
+
+ ) : ( + + )} +
+
- + +
+
+ {selectedArtifact ? ( + + ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

+ {t.common.artifacts} +

+
+
+ +
+
+ )} +
+ )} +
+
-
+ + {/* Fixed 底部居中输入框容器 */} +
+
+ {showInputBox ? ( + + {showWelcomeStyle && !hasSubmitted && ( + + )} +
+ } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isSelectedSkillBootstrapping || + isUploading + } + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + ) : ( + // + '' + )} + + {/* {isSelectedSkillBootstrapping && ( +
+ 正在初始化 Skill 文件... +
+ )} */} + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
+ + + {/* 退出确认对话框 */} + + + + 提示 + +

+ (测试中:计划销毁但是现在没有销毁) 退出后,当前会话结束并销毁,请先下载保存当前结果! +

+ + + + +
+
+ + {/* selectedSkill 失败:错误弹窗 */} + { + if (!open) clearSelectedSkillError(); + }} + > + + + + ⚠️ {selectedSkillError?.title ?? "技能加载失败"} + + +

+ {selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"} +

+ + + +
+
+ + {/* MARK: 开发测试:iframe 通信功能测试面板 */} + {/* {process.env.NODE_ENV !== "production" && } */} +
); } diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index 1b555cc5..76740cdc 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -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(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, + }; } diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index fbcce030..9c0a368f 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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; +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 { + const queryClient = useQueryClient(); + const updateSubtask = useUpdateSubtask(); + const thread = useStream({ + 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) => { + return oldData.map((t) => { + if (t.thread_id === threadId) { + return { + ...t, + values: { + ...t.values, + title: state.values.title, + }, + }; + } + return t; + }); + }, + ); + }, + }); + return thread as UseStream; +} + 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({ - 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, - 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 } : {}), + // Create optimistic human message (shown immediately) + const optimisticHumanMsg: Message = { + type: "human", + id: `opt-human-${Date.now()}`, + content: text ? [{ type: "text", text }] : "", + additional_kwargs: + optimisticFiles.length > 0 ? { files: optimisticFiles } : {}, }; - const newOptimistic: Message[] = []; - if (!hideFromUI) { - newOptimistic.push({ - type: "human", - id: `opt-human-${Date.now()}`, - content: text ? [{ type: "text", text }] : "", - additional_kwargs: optimisticAdditionalKwargs, - }); - } - - 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, + sendMessage, + isUploading, + ] as const; +} + +export function useSubmitThread({ + threadId, + thread, + threadContext, + createNewSession, + uploadTarget, + afterSubmit, +}: { + createNewSession: boolean; + threadId: string | null | undefined; + thread: UseStream; + threadContext: Omit; + 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( diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index 22510fa8..8d8db93f 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -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; diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts new file mode 100644 index 00000000..f33bdabb --- /dev/null +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -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(null); + + const [selectedSkill, setSelectedSkill] = useState(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 }; +}