diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 5006d77a..98a37030 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -76,6 +76,7 @@ import { useArtifactContent, useUpdateArtifactContent, } from "@/core/artifacts/hooks"; +import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { isSandboxStreamPerfEnabled, @@ -308,19 +309,33 @@ export function ArtifactFileDetail({ const isSpreadsheetFile = useMemo(() => { return /\.xlsx$/i.test(filepath); }, [filepath]); - // 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实 - // artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅 - // allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。 + // 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。 + // 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的 + // md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。 + const writeFileInFlight = useMemo(() => { + if (!isWriteFileVirtual || isImageFile(filepath)) { + return false; + } + return isWriteFileInFlight(thread, filepathFromProps); + }, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]); + // 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API + // (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。 const editVirtualMarkdown = useMemo(() => { return ( allowVirtualMarkdownEdit && isWriteFileVirtual && !isImageFile(filepath) && !isMock && - !thread.isLoading && + !writeFileInFlight && checkCodeFile(filepath).language === "markdown" ); - }, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]); + }, [ + allowVirtualMarkdownEdit, + isWriteFileVirtual, + filepath, + isMock, + writeFileInFlight, + ]); /** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API; * 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */ const useWriteFileStreaming = useMemo(() => { @@ -377,7 +392,7 @@ export function ArtifactFileDetail({ language === "markdown" && !isSkillFile && !isMock && - !thread.isLoading && + !writeFileInFlight && (canEditArtifact || useWriteFileStreaming); const [draftContent, setDraftContent] = useState(""); const [hasLocalEdits, setHasLocalEdits] = useState(false); @@ -406,7 +421,7 @@ export function ArtifactFileDetail({ ? draftContent : (content ?? "")); const streamPerfEnabled = isSandboxStreamPerfEnabled(); - const isLiveStreaming = thread.isLoading || documentRewriteActive; + const isLiveStreaming = writeFileInFlight || documentRewriteActive; const sandboxDisplayText = useMemo(() => { const raw = displayContent ?? ""; if (disableDesensitize) return raw; @@ -426,7 +441,7 @@ export function ArtifactFileDetail({ const isAutoPreviewLanguage = language === "html" || language === "markdown" || language === "mermaid"; const isPreviewStreaming = - isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive); + isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive); const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { if (isImageFile(filepath)) return "IMG"; @@ -889,7 +904,7 @@ export function ArtifactFileDetail({ } if (language === "html") { - setViewMode(thread.isLoading ? "code" : "preview"); + setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview"); return; } @@ -899,14 +914,20 @@ export function ArtifactFileDetail({ } setViewMode("code"); - }, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]); + }, [ + documentRewriteActive, + isPreviewStreaming, + isSupportPreview, + language, + writeFileInFlight, + ]); useEffect(() => { if (!allowAutoViewSwitch || !isAutoPreviewLanguage) { return; } - if (thread.isLoading) { + if (writeFileInFlight || documentRewriteActive) { setViewMode("code"); return; } @@ -918,7 +939,13 @@ export function ArtifactFileDetail({ }, 1400); return () => window.clearTimeout(timeoutId); - }, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]); + }, [ + allowAutoViewSwitch, + documentRewriteActive, + isAutoPreviewLanguage, + isSupportPreview, + writeFileInFlight, + ]); const scrollArtifactToBottom = useCallback( (behavior: ScrollBehavior = "auto") => { @@ -1015,13 +1042,13 @@ export function ArtifactFileDetail({ }, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]); const handlePauseAutoScroll = useCallback(() => { - if (!thread.isLoading || !autoScrollEnabled) { + if (!isLiveStreaming || !autoScrollEnabled) { return; } setAutoScrollEnabled(false); setShowResumeAutoScroll(true); - }, [autoScrollEnabled, thread.isLoading]); + }, [autoScrollEnabled, isLiveStreaming]); const handleResumeAutoScroll = useCallback(() => { setAutoScrollEnabled(true); @@ -1774,7 +1801,7 @@ export function ArtifactFileDetail({ } : undefined } - isStreaming={thread.isLoading} + isStreaming={writeFileInFlight || documentRewriteActive} streamingReadonlyFallback={!canEditArtifact} /> diff --git a/frontend-web/src/core/artifacts/hooks.ts b/frontend-web/src/core/artifacts/hooks.ts index af2856ba..5c12b72d 100644 --- a/frontend-web/src/core/artifacts/hooks.ts +++ b/frontend-web/src/core/artifacts/hooks.ts @@ -3,8 +3,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useThread } from "@/components/workspace/messages/context"; -import { isImageFile } from "../utils/files"; - import { loadArtifactContent, loadArtifactContentFromLatestWriteFile, @@ -15,22 +13,13 @@ import { isSandboxStreamPerfEnabled, } from "./sandbox-stream-perf"; import { isArtifactApiPath } from "./streaming-write-preview"; +import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight"; import { updateArtifactContent } from "./api"; -function isWriteFileStreamingUrl(filepath: string): boolean { - if (!filepath.startsWith("write-file:")) { - return false; - } - try { - const url = new URL(filepath); - const path = decodeURIComponent(url.pathname); - return !isImageFile(path); - } catch { - return true; - } -} - -function hasToolResult(thread: ReturnType["thread"], toolCallId?: string) { +function hasToolResult( + thread: ReturnType["thread"], + toolCallId?: string, +) { if (!toolCallId) { return false; } @@ -144,11 +133,13 @@ export function useArtifactContent({ const liveWriteFileContent = useWriteFileStreaming ? loadArtifactContentFromToolCall({ url: filepath, thread }) : undefined; + const writeFileInFlight = + useWriteFileStreaming && isWriteFileInFlight(thread, filepath); const displayedWriteFileContent = useThrottledWriteFileDisplay({ filepath, liveContent: liveWriteFileContent, enabled: streamPerfEnabled && useWriteFileStreaming, - streaming: thread.isLoading, + streaming: writeFileInFlight, }); const content = useWriteFileStreaming ? displayedWriteFileContent : null; const toolCallFallbackContent = useMemo(() => { diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts new file mode 100644 index 00000000..68686482 --- /dev/null +++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight"; + +test("resolveWriteFilePath reads the virtual URL pathname", () => { + assert.equal( + resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"), + "report.md", + ); + assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md"); +}); + +test("isWriteFileInFlight is true only until that file's tool result arrives", () => { + const inFlight = { + messages: [ + { + type: "ai", + tool_calls: [ + { id: "w1", name: "write_file", args: { path: "report.md" } }, + ], + }, + ], + }; + assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true); + + const completed = { + messages: [ + { + type: "ai", + tool_calls: [ + { id: "w1", name: "write_file", args: { path: "report.md" } }, + ], + }, + { type: "tool", tool_call_id: "w1", name: "write_file" }, + ], + }; + assert.equal( + isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"), + false, + ); +}); + +test("a later thread turn does not treat a finished write as in-flight", () => { + const laterTurn = { + messages: [ + { + type: "ai", + tool_calls: [ + { id: "w1", name: "write_file", args: { path: "report.md" } }, + ], + }, + { type: "tool", tool_call_id: "w1", name: "write_file" }, + { type: "human", content: "按方案 A" }, + { + type: "ai", + tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }], + }, + ], + }; + assert.equal( + isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"), + false, + ); +}); diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts new file mode 100644 index 00000000..3aae5083 --- /dev/null +++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts @@ -0,0 +1,77 @@ +import { isImageFile } from "../utils/files"; + +export function isWriteFileStreamingUrl(filepath: string): boolean { + if (!filepath.startsWith("write-file:")) { + return false; + } + try { + const url = new URL(filepath); + const path = decodeURIComponent(url.pathname); + return !isImageFile(path); + } catch { + return true; + } +} + +export function resolveWriteFilePath(filepath: string): string { + if (filepath.startsWith("write-file:")) { + try { + return decodeURIComponent(new URL(filepath).pathname).trim(); + } catch { + const rest = filepath.slice("write-file:".length); + return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath; + } + } + return filepath.trim(); +} + +type WriteFileThreadLike = { + messages: Array<{ + type?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id?: string; + name?: string; + args?: unknown; + }>; + }>; +}; + +function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) { + if (!toolCallId) { + return false; + } + return thread.messages.some( + (message) => message.type === "tool" && message.tool_call_id === toolCallId, + ); +} + +/** True only while THIS file's write_file call has not yet returned a tool result. */ +export function isWriteFileInFlight( + thread: WriteFileThreadLike, + filepath: string, +): boolean { + const path = resolveWriteFilePath(filepath); + if (!path) { + return false; + } + for (const message of thread.messages) { + if (message.type !== "ai" || !message.tool_calls) { + continue; + } + for (const toolCall of message.tool_calls) { + if (toolCall.name !== "write_file") { + continue; + } + const args = toolCall.args as { path?: unknown } | undefined; + const callPath = typeof args?.path === "string" ? args.path.trim() : ""; + if (callPath !== path) { + continue; + } + if (!hasToolResult(thread, toolCall.id)) { + return true; + } + } + } + return false; +} diff --git a/frontend-web/src/core/messages/utils-group.test.ts b/frontend-web/src/core/messages/utils-group.test.ts new file mode 100644 index 00000000..e984a338 --- /dev/null +++ b/frontend-web/src/core/messages/utils-group.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Message } from "@langchain/langgraph-sdk"; + +import { groupMessages } from "./utils"; + +function groupTypes(messages: Message[]) { + const types: string[] = []; + groupMessages(messages, (group) => { + types.push(group.type); + return group.type; + }); + return types; +} + +test("write_file tool after 用户协助 opens a processing group instead of erroring", () => { + const types = groupTypes([ + { type: "human", id: "h1", content: "写一份报告" } as Message, + { + type: "ai", + id: "a1", + content: "", + tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }], + } as Message, + { + type: "tool", + id: "t1", + name: "ask_clarification", + content: "请选择范围", + tool_call_id: "c1", + } as Message, + { type: "human", id: "h2", content: "按方案 A" } as Message, + { + type: "tool", + id: "t2", + name: "write_file", + content: "OK", + tool_call_id: "w1", + } as Message, + { + type: "tool", + id: "t3", + name: "write_file", + content: "OK", + tool_call_id: "w2", + } as Message, + ]); + + assert.deepEqual(types, [ + "human", + "assistant:processing", + "assistant:clarification", + "human", + "assistant:processing", + ]); +}); + +test("tool messages still attach to an open processing group", () => { + const types = groupTypes([ + { + type: "ai", + id: "a1", + content: "", + tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }], + } as Message, + { + type: "tool", + id: "t1", + name: "write_file", + content: "OK", + tool_call_id: "w1", + } as Message, + ]); + + assert.deepEqual(types, ["assistant:processing"]); +}); diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 9fb76bb2..f803fac4 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -119,10 +119,15 @@ export function groupMessages( if (open) { open.messages.push(message); } else { - console.error( - "Unexpected tool message outside a processing group", - message, - ); + // After 用户协助 the last group is human/clarification, so later + // write_file results have nowhere to attach. Opening a processing + // group keeps them renderable and avoids console.error(full message) + // on every MessageList render (that froze the SSE thread). + groups.push({ + id: message.id, + type: "assistant:processing", + messages: [message], + }); } } continue;