From d2b5df210bf7700c1549031f4d1e4add7d5acc7e Mon Sep 17 00:00:00 2001 From: MT-Fire <798521692@qq.com> Date: Tue, 31 Mar 2026 19:38:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20/chats/ne?= =?UTF-8?q?w=20=E8=AF=AF=E7=94=A8=20thread=5Fid=3Dnew=20=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=20artifact=20=E8=B7=AF=E5=BE=84=E4=B8=8E=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消息渲染链路统一使用解析后的 threadId,移除对路由参数 thread_id 的直接依赖\n- InputBox 移除 params.thread_id 兜底,避免 new 泄漏到请求链路\n- 在提交流程中拦截 threadId="new",阻止错误写入\n- 命中复用旧线程时从 /workspace/chats/new 立即跳转到真实线程路由\n- 新增排错总结文档:docs/troubleshooting-new-thread-artifact.md --- docs/troubleshooting-new-thread-artifact.md | 75 +++++++++++++++++++ .../workspace/chats/use-thread-chat.ts | 9 ++- .../src/components/workspace/input-box.tsx | 5 +- .../workspace/messages/message-list-item.tsx | 20 +++-- .../workspace/messages/message-list.tsx | 1 + frontend/src/core/threads/hooks.ts | 9 +++ 6 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 docs/troubleshooting-new-thread-artifact.md diff --git a/docs/troubleshooting-new-thread-artifact.md b/docs/troubleshooting-new-thread-artifact.md new file mode 100644 index 00000000..a996d985 --- /dev/null +++ b/docs/troubleshooting-new-thread-artifact.md @@ -0,0 +1,75 @@ +# 排错总结:`/workspace/chats/new` 场景下 Artifact 图片访问异常 + +## 背景 + +在以下 URL 场景中: + +`/workspace/chats/new?isnew=false&thread_id=b9a30765-0575-44c9-954e-bdaaf3d083fa&xclaw_used=true` + +页面会复用已有线程,并跳转到: + +`/workspace/chats/b9a30765-0575-44c9-954e-bdaaf3d083fa` + +但出现现象: + +- `.../api/threads/b9.../artifacts/mnt/user-data/outputs/cat-generated.jpg` 返回 `Artifact not found` +- `.../api/threads/new/artifacts/mnt/user-data/outputs/cat-generated.jpg` 可以访问 + +## 现象与证据 + +本地线程目录检查结果显示: + +- `backend/.deer-flow/threads/new/user-data/outputs/cat-generated.jpg` 存在 +- `backend/.deer-flow/threads/b9a30765-0575-44c9-954e-bdaaf3d083fa/user-data/outputs/` 不存在对应文件 + +这说明文件实际写入了 `thread_id = "new"` 的目录,而不是目标线程 `b9...`。 + +## 根因 + +问题由“线程 ID 来源不一致”触发: + +1. 页面层已经解析出“真实线程 ID”(query 中的 `thread_id`)。 +2. 消息渲染组件中仍直接读取路由参数 `useParams().thread_id`。 +3. 在 `/workspace/chats/new` 下,路由参数固定是 `"new"`,导致图片/文件 URL 被拼接为 `/api/threads/new/artifacts/...`。 +4. 输入与提交链路存在对 `params.thread_id` 的兜底,存在将 `"new"` 带入写操作的风险。 + +## 修复内容 + +本次修复统一了线程 ID 的来源,并增加了防御性校验: + +1. 消息渲染链路统一使用页面透传的解析后 `threadId` + - `MessageListItem` 移除 `useParams().thread_id` + - `MessageList` 将 `threadId` 透传给 `MessageListItem` + +2. 输入组件移除路由参数兜底 + - `InputBox` 不再使用 `threadIdFromProps ?? params?.thread_id` + - 仅使用上游传入的解析后 `threadId` + +3. 提交流程增加硬保护 + - 在 `useThreadStream` / `useSubmitThread` 中拦截 `threadId === "new"`,阻止上传和提交继续执行 + +4. 复用旧线程时强制路径对齐 + - 在 `/workspace/chats/new` 且命中复用条件时,立即 `replace` 到 `/workspace/chats/{thread_id}` + +## 涉及文件 + +- `frontend/src/components/workspace/messages/message-list-item.tsx` +- `frontend/src/components/workspace/messages/message-list.tsx` +- `frontend/src/components/workspace/input-box.tsx` +- `frontend/src/core/threads/hooks.ts` +- `frontend/src/components/workspace/chats/use-thread-chat.ts` + +## 验证结果 + +- TypeScript 编译检查通过:`pnpm -C frontend exec tsc --noEmit --pretty false` +- 逻辑上可确保后续不会再写入 `threads/new/...`。 + +## 影响与后续处理 + +本次修复不自动迁移历史文件。 + +已落在 `threads/new/...` 的旧产物,仍需要: + +1. 手动迁移到目标线程目录,或 +2. 在目标线程中重新生成。 + diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index e86a310b..7009a368 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -1,11 +1,12 @@ "use client"; -import { useParams, usePathname, useSearchParams } from "next/navigation"; +import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; export function useThreadChat() { const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); const xClawUsedFromQuery = searchParams.get("xclaw_used"); @@ -38,12 +39,16 @@ export function useThreadChat() { pathname.startsWith("/workspace/chats/") && !!nextQueryThreadId && (nextXClawUsed === "true" || nextIsNewFromQuery); + if (nextShouldUseQueryThreadId && nextQueryThreadId) { + router.replace(`/workspace/chats/${nextQueryThreadId}`); + return; + } setThreadId(nextShouldUseQueryThreadId ? nextQueryThreadId : undefined); return; } setIsNewThread(false); setThreadId(threadIdFromPath); - }, [pathname, searchParams, threadIdFromPath]); + }, [pathname, router, searchParams, threadIdFromPath]); const isMock = searchParams.get("mock") === "true"; return { threadId, isNewThread, setIsNewThread, isMock }; } diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index f8d1b2cd..40843ae7 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -12,7 +12,7 @@ import { XIcon, ZapIcon, } from "lucide-react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, @@ -128,8 +128,7 @@ export function InputBox({ const searchParams = useSearchParams(); const iframeSkill = useIframeSkill(); - const params = useParams(); - const threadId = threadIdFromProps ?? params?.thread_id; + const threadId = threadIdFromProps; const { textInput } = usePromptInputController(); const attachments = usePromptInputAttachments(); const promptRootRef = useRef(null); diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 957c4e74..55313d3e 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,6 +1,5 @@ import type { Message } from "@langchain/langgraph-sdk"; import { FileIcon, Loader2Icon } from "lucide-react"; -import { useParams } from "next/navigation"; import { memo, useMemo, useState, type ImgHTMLAttributes } from "react"; import rehypeKatex from "rehype-katex"; @@ -41,10 +40,12 @@ export function MessageListItem({ className, message, isLoading, + threadId, }: { className?: string; message: Message; isLoading?: boolean; + threadId?: string; }) { const isHuman = message.type === "human"; return ( @@ -59,6 +60,7 @@ export function MessageListItem({ className={isHuman ? "w-fit" : "w-full"} message={message} isLoading={isLoading} + threadId={threadId} /> {!isLoading && ( & { - threadId: string; + threadId?: string; maxWidth?: string; }) { if (!src) return null; @@ -103,7 +105,8 @@ function MessageImage({ return {alt}; } - const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; + const url = + src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src; return ( @@ -116,21 +119,22 @@ function MessageContent_({ className, message, isLoading = false, + threadId, }: { className?: string; message: Message; isLoading?: boolean; + threadId?: string; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; - const { thread_id } = useParams<{ thread_id: string }>(); const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( - + ), }), - [thread_id], + [threadId], ); const rawContent = extractContentFromMessage(message); @@ -156,8 +160,8 @@ function MessageContent_({ }, [rawContent, isHuman]); const filesList = - files && files.length > 0 && thread_id ? ( - + files && files.length > 0 && threadId ? ( + ) : null; // Uploading state: mock AI message shown while files upload diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 69c08277..d76d3a28 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -65,6 +65,7 @@ export function MessageList({ key={group.id} message={group.messages[0]!} isLoading={thread.isLoading} + threadId={threadId} /> ); } else if (group.type === "assistant:clarification") { diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index eacd668a..e96696a8 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -283,6 +283,11 @@ export function useThreadStream({ 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; @@ -507,6 +512,10 @@ export function useSubmitThread({ 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);