fix(frontend): 修复 /chats/new 误用 thread_id=new 导致 artifact 路径与写入异常

- 消息渲染链路统一使用解析后的 threadId,移除对路由参数 thread_id 的直接依赖\n- InputBox 移除 params.thread_id 兜底,避免 new 泄漏到请求链路\n- 在提交流程中拦截 threadId="new",阻止错误写入\n- 命中复用旧线程时从 /workspace/chats/new 立即跳转到真实线程路由\n- 新增排错总结文档:docs/troubleshooting-new-thread-artifact.md
This commit is contained in:
肖应宇 2026-03-31 19:38:39 +08:00
parent 248bf67ec9
commit d2b5df210b
6 changed files with 106 additions and 13 deletions

View File

@ -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. 在目标线程中重新生成。

View File

@ -1,11 +1,12 @@
"use client"; "use client";
import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function useThreadChat() { export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const xClawUsedFromQuery = searchParams.get("xclaw_used"); const xClawUsedFromQuery = searchParams.get("xclaw_used");
@ -38,12 +39,16 @@ export function useThreadChat() {
pathname.startsWith("/workspace/chats/") && pathname.startsWith("/workspace/chats/") &&
!!nextQueryThreadId && !!nextQueryThreadId &&
(nextXClawUsed === "true" || nextIsNewFromQuery); (nextXClawUsed === "true" || nextIsNewFromQuery);
if (nextShouldUseQueryThreadId && nextQueryThreadId) {
router.replace(`/workspace/chats/${nextQueryThreadId}`);
return;
}
setThreadId(nextShouldUseQueryThreadId ? nextQueryThreadId : undefined); setThreadId(nextShouldUseQueryThreadId ? nextQueryThreadId : undefined);
return; return;
} }
setIsNewThread(false); setIsNewThread(false);
setThreadId(threadIdFromPath); setThreadId(threadIdFromPath);
}, [pathname, searchParams, threadIdFromPath]); }, [pathname, router, searchParams, threadIdFromPath]);
const isMock = searchParams.get("mock") === "true"; const isMock = searchParams.get("mock") === "true";
return { threadId, isNewThread, setIsNewThread, isMock }; return { threadId, isNewThread, setIsNewThread, isMock };
} }

View File

@ -12,7 +12,7 @@ import {
XIcon, XIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
import { useParams, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@ -128,8 +128,7 @@ export function InputBox({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill(); const iframeSkill = useIframeSkill();
const params = useParams(); const threadId = threadIdFromProps;
const threadId = threadIdFromProps ?? params?.thread_id;
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);

View File

@ -1,6 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react"; import { FileIcon, Loader2Icon } from "lucide-react";
import { useParams } from "next/navigation";
import { memo, useMemo, useState, type ImgHTMLAttributes } from "react"; import { memo, useMemo, useState, type ImgHTMLAttributes } from "react";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
@ -41,10 +40,12 @@ export function MessageListItem({
className, className,
message, message,
isLoading, isLoading,
threadId,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId?: string;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === "human";
return ( return (
@ -59,6 +60,7 @@ export function MessageListItem({
className={isHuman ? "w-fit" : "w-full"} className={isHuman ? "w-fit" : "w-full"}
message={message} message={message}
isLoading={isLoading} isLoading={isLoading}
threadId={threadId}
/> />
{!isLoading && ( {!isLoading && (
<MessageToolbar <MessageToolbar
@ -92,7 +94,7 @@ function MessageImage({
maxWidth = "90%", maxWidth = "90%",
...props ...props
}: React.ImgHTMLAttributes<HTMLImageElement> & { }: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string; threadId?: string;
maxWidth?: string; maxWidth?: string;
}) { }) {
if (!src) return null; if (!src) return null;
@ -103,7 +105,8 @@ function MessageImage({
return <img className={imgClassName} src={src} alt={alt} {...props} />; return <img className={imgClassName} src={src} alt={alt} {...props} />;
} }
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; const url =
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src;
return ( return (
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
@ -116,21 +119,22 @@ function MessageContent_({
className, className,
message, message,
isLoading = false, isLoading = false,
threadId,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId?: string;
}) { }) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const { thread_id } = useParams<{ thread_id: string }>();
const components = useMemo( const components = useMemo(
() => ({ () => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => ( img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={thread_id} maxWidth="90%" /> <MessageImage {...props} threadId={threadId} maxWidth="90%" />
), ),
}), }),
[thread_id], [threadId],
); );
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
@ -156,8 +160,8 @@ function MessageContent_({
}, [rawContent, isHuman]); }, [rawContent, isHuman]);
const filesList = const filesList =
files && files.length > 0 && thread_id ? ( files && files.length > 0 && threadId ? (
<RichFilesList files={files} threadId={thread_id} /> <RichFilesList files={files} threadId={threadId} />
) : null; ) : null;
// Uploading state: mock AI message shown while files upload // Uploading state: mock AI message shown while files upload

View File

@ -65,6 +65,7 @@ export function MessageList({
key={group.id} key={group.id}
message={group.messages[0]!} message={group.messages[0]!}
isLoading={thread.isLoading} isLoading={thread.isLoading}
threadId={threadId}
/> />
); );
} else if (group.type === "assistant:clarification") { } else if (group.type === "assistant:clarification") {

View File

@ -283,6 +283,11 @@ export function useThreadStream({
const text = message.text.trim(); const text = message.text.trim();
const resolvedThreadId = const resolvedThreadId =
threadId ?? threadIdRef.current ?? undefined; threadId ?? threadIdRef.current ?? undefined;
if (resolvedThreadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry.");
sendInFlightRef.current = false;
return;
}
// Capture current count before showing optimistic messages // Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length; prevMsgCountRef.current = thread.messages.length;
@ -507,6 +512,10 @@ export function useSubmitThread({
const apiClient = getAPIClient(); const apiClient = getAPIClient();
const callback = useCallback( const callback = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (threadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry.");
return;
}
const text = message.text.trim(); const text = message.text.trim();
const hasFiles = !!(message.files && message.files.length > 0); const hasFiles = !!(message.files && message.files.length > 0);