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:
parent
248bf67ec9
commit
d2b5df210b
|
|
@ -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. 在目标线程中重新生成。
|
||||
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<MessageToolbar
|
||||
|
|
@ -92,7 +94,7 @@ function MessageImage({
|
|||
maxWidth = "90%",
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
threadId?: string;
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
if (!src) return null;
|
||||
|
|
@ -103,7 +105,8 @@ function MessageImage({
|
|||
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 (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
|
@ -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<HTMLImageElement>) => (
|
||||
<MessageImage {...props} threadId={thread_id} maxWidth="90%" />
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
}),
|
||||
[thread_id],
|
||||
[threadId],
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
|
|
@ -156,8 +160,8 @@ function MessageContent_({
|
|||
}, [rawContent, isHuman]);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 && thread_id ? (
|
||||
<RichFilesList files={files} threadId={thread_id} />
|
||||
files && files.length > 0 && threadId ? (
|
||||
<RichFilesList files={files} threadId={threadId} />
|
||||
) : null;
|
||||
|
||||
// Uploading state: mock AI message shown while files upload
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue