Merge branch 'feat/originui-frontend-integration' of https://git.xueai.art/skills/deerflow2 into feat/originui-frontend-integration

This commit is contained in:
肖应宇 2026-04-01 08:59:19 +08:00
commit ce4b0dcd4d
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";
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 };
}

View File

@ -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);

View File

@ -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

View File

@ -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") {

View File

@ -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);