From 69e455274a5b2d0047523a1c2f8b72271ce1191f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 4 Aug 2026 00:51:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E5=B2=97=E4=BD=8D=E4=BC=9A?= =?UTF-8?q?=E5=95=86=E6=B2=99=E7=AE=B1=E5=9C=A8=20write=5Ffile=20=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E9=98=B6=E6=AE=B5=E5=AE=9E=E6=97=B6=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=B9=B6=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content 先于 path 到达时用临时文件名开沙箱,流式快照改走外部 store 避免整页重渲染卡顿。 Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 71 +++-- frontend-web/src/core/threads/hooks.ts | 78 ++++-- .../src/position-roundtable/README.md | 4 +- .../components/PositionNodeChat.tsx | 250 ++++++++++++++++-- .../pages/PositionRoundtablePage.tsx | 194 +++++++++++--- 5 files changed, 480 insertions(+), 117 deletions(-) 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 fa0fec1c..7dc78695 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) { export function ArtifactFileDetail({ className, - filepath: filepathFromProps, - threadId, + filepath: filepathFromProps, + threadId, + mimeType = null, onClose, allowVirtualMarkdownEdit = false, disableDesensitize = false, @@ -131,6 +132,8 @@ export function ArtifactFileDetail({ className?: string; filepath: string; threadId: string; + /** Server/tool supplied type wins over path guessing when available. */ + mimeType?: string | null; /** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */ onClose?: () => void; /** @@ -150,16 +153,38 @@ export function ArtifactFileDetail({ const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); - const isWriteFileVirtual = useMemo(() => { - return filepathFromProps.startsWith("write-file:"); - }, [filepathFromProps]); - const filepath = useMemo(() => { - if (isWriteFileVirtual) { - const url = new URL(filepathFromProps); - return decodeURIComponent(url.pathname); - } - return filepathFromProps; - }, [filepathFromProps, isWriteFileVirtual]); + const isWriteFileVirtual = useMemo(() => { + return filepathFromProps.startsWith("write-file:"); + }, [filepathFromProps]); + // A streamed tool call can be opened before the path has been normalized by + // the artifacts API. Keep the format supplied by the writer so Markdown is + // rendered as Markdown instead of falling back to a TEXT source preview. + const writeFileLanguageHint = useMemo(() => { + if (!isWriteFileVirtual) return null; + try { + const language = new URL(filepathFromProps).searchParams.get("language"); + return language === "markdown" ? language : null; + } catch { + return null; + } + }, [filepathFromProps, isWriteFileVirtual]); + const mimeLanguageHint = useMemo(() => { + const normalized = mimeType?.toLowerCase().trim() ?? ""; + if (normalized.includes("markdown")) return "markdown"; + if (normalized.includes("html")) return "html"; + if (normalized.includes("mermaid")) return "mermaid"; + if (normalized.startsWith("text/")) return "text"; + return null; + }, [mimeType]); + const filepath = useMemo(() => { + if (isWriteFileVirtual) { + const url = new URL(filepathFromProps); + return decodeURIComponent(url.pathname).trim(); + } + // Tool arguments may contain harmless surrounding whitespace. Passing it + // into extension detection turns `report.md ` into a generic TEXT file. + return filepathFromProps.trim(); + }, [filepathFromProps, isWriteFileVirtual]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); @@ -186,17 +211,22 @@ export function ArtifactFileDetail({ const useWriteFileStreaming = useMemo(() => { return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown; }, [filepath, isWriteFileVirtual, editVirtualMarkdown]); - const { isCodeFile, language } = useMemo(() => { - if (useWriteFileStreaming) { - let nextLanguage = checkCodeFile(filepath).language; - nextLanguage ??= "text"; - return { isCodeFile: true, language: nextLanguage }; + const { isCodeFile, language } = useMemo(() => { + if (useWriteFileStreaming) { + let nextLanguage = + mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language; + nextLanguage ??= "text"; + return { isCodeFile: true, language: nextLanguage }; } if (isSkillFile) { return { isCodeFile: true, language: "markdown" }; } - return checkCodeFile(filepath); - }, [filepath, useWriteFileStreaming, isSkillFile]); + const detected = checkCodeFile(filepath); + if (mimeLanguageHint) { + return { isCodeFile: true as const, language: mimeLanguageHint }; + } + return detected; + }, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]); const isSupportPreview = useMemo(() => { return ( language === "html" || @@ -209,7 +239,8 @@ export function ArtifactFileDetail({ // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 // 不会被旧的内存 stub 值覆盖回退。 - filepath: editVirtualMarkdown ? filepath : filepathFromProps, + filepath: + editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps, enabled: isCodeFile && !useWriteFileStreaming, }); diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 088cf4a9..ac54c431 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import type { AgentThread, AgentThreadState } from "./types"; -export type ToolEndEvent = { - name: string; - data: unknown; -}; +export type ToolEndEvent = { + name: string; + data: unknown; + toolCallId?: string; +}; + +export type ToolStartEvent = ToolEndEvent; export type ThreadStreamOptions = { threadId?: string | null | undefined; @@ -47,10 +50,11 @@ export type ThreadStreamOptions = { /** 额外写到新建 thread 的 metadata(与 agent_id 合并)。 * 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */ threadMetadata?: Record; - onStart?: (threadId: string) => void; - onFinish?: (state: AgentThreadState) => void; - onToolEnd?: (event: ToolEndEvent) => void; -}; + onStart?: (threadId: string) => void; + onFinish?: (state: AgentThreadState) => void; + onToolStart?: (event: ToolStartEvent) => void; + onToolEnd?: (event: ToolEndEvent) => void; +}; type SendMessageOptions = { additionalKwargs?: Record; @@ -447,10 +451,11 @@ export function useThreadStream({ assistantId, runOptions, threadMetadata, - onStart, - onFinish, - onToolEnd, -}: ThreadStreamOptions) { + onStart, + onFinish, + onToolStart, + onToolEnd, +}: ThreadStreamOptions) { const { t } = useI18n(); const [localSettings] = useLocalSettings(); // Track the thread ID that is currently streaming to handle thread changes during streaming @@ -466,16 +471,17 @@ export function useThreadStream({ lastStreamActivityAtRef.current = Date.now(); }, []); - const listeners = useRef({ - onStart, - onFinish, - onToolEnd, - }); + const listeners = useRef({ + onStart, + onFinish, + onToolStart, + onToolEnd, + }); // Keep listeners ref updated with latest callbacks - useEffect(() => { - listeners.current = { onStart, onFinish, onToolEnd }; - }, [onStart, onFinish, onToolEnd]); + useEffect(() => { + listeners.current = { onStart, onFinish, onToolStart, onToolEnd }; + }, [onStart, onFinish, onToolStart, onToolEnd]); useEffect(() => { const normalizedThreadId = threadId ?? null; @@ -545,12 +551,6 @@ export function useThreadStream({ }, onLangChainEvent(event) { markStreamActivity(); - if (event.event === "on_tool_end") { - listeners.current.onToolEnd?.({ - name: event.name, - data: event.data, - }); - } // 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条), // 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用 // 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。 @@ -572,8 +572,32 @@ export function useThreadStream({ } catch { /* 上报绝不影响问答 */ } - } + } }, + // The SDK emits UI tool lifecycle events on the dedicated `tools` stream + // mode. `events` is a separate LangChain trace channel; using it for UI + // reactions can miss a tool that the conversation has already rendered. + onToolEvent: + onToolStart || onToolEnd + ? (event) => { + markStreamActivity(); + if (event.event === "on_tool_start") { + listeners.current.onToolStart?.({ + name: event.name, + data: event.input, + toolCallId: event.toolCallId, + }); + return; + } + if (event.event === "on_tool_end") { + listeners.current.onToolEnd?.({ + name: event.name, + data: event.output, + toolCallId: event.toolCallId, + }); + } + } + : undefined, onUpdateEvent(data) { markStreamActivity(); const updates: Array | null> = Object.values( diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md index 6c2b7e6b..2dcdfd22 100644 --- a/frontend-web/src/position-roundtable/README.md +++ b/frontend-web/src/position-roundtable/README.md @@ -135,7 +135,9 @@ PositionRoundtablePage 设计要点: - 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览; -- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState; +- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付; +- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染; +- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容; - 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开; - 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。 diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 8d713a9f..600d4c2b 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context"; import { MessageList } from "@/components/workspace/messages"; import { useAgent } from "@/core/agents"; import { useLocalSettings } from "@/core/settings"; -import { useEditMessage, useThreadStream } from "@/core/threads/hooks"; +import { + useEditMessage, + useThreadStream, + type ToolStartEvent, +} from "@/core/threads/hooks"; import type { AgentThreadState } from "@/core/threads/types"; import { textOfMessage } from "@/core/threads/utils"; import { uuid } from "@/core/utils/uuid"; @@ -40,7 +44,6 @@ import { readConversationDraft, readConversationMessages, } from "@/position-roundtable/lib/conversation-snapshot"; - interface PositionNodeSwitchOption { node: PositionRoundtableNode; agentName?: string | null; @@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string { return path.split("/").filter(Boolean).at(-1) || "Markdown 报告"; } +function normalizeWriteFilePath(path: string): string { + let normalized = path.trim(); + const first = normalized[0]; + const last = normalized.at(-1); + if ( + normalized.length >= 2 && + ((first === '"' && last === '"') || + (first === "'" && last === "'") || + (first === "`" && last === "`")) + ) { + normalized = normalized.slice(1, -1).trim(); + } + return normalized; +} + +function isMarkdownPath(path: string): boolean { + const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; + return pathname.endsWith(".md") || pathname.endsWith(".markdown"); +} + +function hasCompleteArtifactExtension(path: string): boolean { + const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? ""; + const filename = pathname.split("/").filter(Boolean).at(-1) ?? ""; + // During JSON argument streaming a Markdown path arrives as `.m` and then + // `.md`. Opening on the partial suffix permanently labels the preview TEXT. + // Extensionless files still open from on_tool_start once the full path is known. + return /\.[a-z0-9]{2,12}$/i.test(filename); +} + +/** + * `write_file` arguments stream as one JSON object and the key order is up to + * the model. Agents that emit `content` before `path` leave the file nameless + * for as long as the body takes to write — a minute for a full report. The + * sandbox follows a streaming file by message/tool-call id, not by path, so it + * can open under a provisional name and adopt the real one later. Sniff the + * body so the provisional name at least carries the right extension. + */ +function provisionalWriteFilePath(content: string): string { + const head = content.slice(0, 256).trimStart().toLowerCase(); + if (head.startsWith(" 3 || data == null) return null; + if (typeof data === "string") { + const value = data.trim(); + if (!value) return null; + if (value.startsWith("{") || value.startsWith("[")) { + try { + return writeFilePathFromToolEvent(JSON.parse(value), depth + 1); + } catch { + return null; + } + } + return null; + } + if (typeof data !== "object" || Array.isArray(data)) return null; + const record = data as Record; + if (typeof record.path === "string") { + const path = normalizeWriteFilePath(record.path); + return path || null; + } + for (const key of ["input", "inputs", "args", "kwargs"]) { + const path = writeFilePathFromToolEvent(record[key], depth + 1); + if (path) return path; + } + return null; +} + function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string { + const language = isMarkdownPath(path) ? "&language=markdown" : ""; return new URL( `write-file:${path}?message_id=${encodeURIComponent( messageId, - )}&tool_call_id=${encodeURIComponent(toolCallId)}`, + )}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`, ).toString(); } +function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" { + return name === "write_file" || name === "str_replace"; +} + function latestStreamingWriteFile( messages: AgentThreadState["messages"], -): { key: string; path: string; name: string; mimeType: string | null } | null { +): { + key: string; + path: string; + sourcePath: string; + name: string; + mimeType: string | null; +} | null { for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { const message = messages[messageIndex] as (typeof messages)[number] & { id?: string; @@ -183,17 +269,33 @@ function latestStreamingWriteFile( if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) { const toolCall = message.tool_calls[toolIndex]; - if (toolCall?.name !== "write_file" || !toolCall.id) continue; - const rawPath = toolCall.args?.path; - if (typeof rawPath !== "string" || !rawPath.trim()) continue; - const content = toolCall.args?.content; - if (typeof content !== "string" || content.length === 0) continue; - const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id); + if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue; + const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : ""; + const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : ""; + const normalizedPath = normalizeWriteFilePath(rawPath); + const hasUsablePath = + Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath); + if (!hasUsablePath) { + // `str_replace` edits an existing file, so it can only be previewed once + // its real path is known. A new file is identified by its tool call, so + // its body can be previewed as soon as the first characters arrive. + if (toolCall.name !== "write_file" || !rawContent.trim()) continue; + } + const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent); + // Do not wait for the complete `content` argument. LangGraph streams + // write_file arguments incrementally, and the virtual artifact loader + // will redraw as `content` arrives. Waiting for it turns this into a + // post-generation preview instead of a live sandbox. + const previewPath = + toolCall.name === "write_file" + ? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id) + : sourcePath; return { key: `${message.id}:${toolCall.id}`, - path: virtualPath, - name: artifactDisplayName(rawPath), - mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null, + path: previewPath, + sourcePath, + name: artifactDisplayName(sourcePath), + mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null, }; } } @@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set }; if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; for (const toolCall of message.tool_calls) { - if (toolCall?.name === "write_file" && toolCall.id) { + if (isArtifactWriteTool(toolCall?.name) && toolCall.id) { keys.add(`${message.id}:${toolCall.id}`); } } @@ -265,11 +367,21 @@ export function PositionNodeChat({ ); const [boundThreadId, setBoundThreadId] = useState(node.threadId); const boundThreadIdRef = useRef(node.threadId); + const previousNodeBindingRef = useRef({ + nodeKey: node.nodeKey, + threadId: node.threadId, + }); const draftThreadIdRef = useRef(node.threadId ?? uuid()); const upstreamRevisionRef = useRef | null>(null); const autoPreviewedArtifactKeyRef = useRef(null); const autoPreviewBaselineCallKeysRef = useRef>(new Set()); const autoPreviewRunArmedRef = useRef(false); + const writeFilePreviewSequenceRef = useRef(0); + const activeWriteFilePreviewRef = useRef<{ + path: string; + previewKey: string; + toolCallId?: string; + } | null>(null); const editMessage = useEditMessage(); const [messageContentOverrides, setMessageContentOverrides] = useState>({}); const persistedMessages = useMemo( @@ -288,12 +400,28 @@ export function PositionNodeChat({ !node.invalidatedBy; useEffect(() => { + const previousBinding = previousNodeBindingRef.current; + previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId }; + // `onStart` binds a newly created LangGraph thread before the session API + // persists that id back into `node.threadId`. That prop update happens + // while the first turn is already streaming; it is not a node switch and + // must not clear the preview run that has just been armed. + const isOwnThreadBinding = + previousBinding.nodeKey === node.nodeKey && + previousBinding.threadId == null && + node.threadId != null && + node.threadId === boundThreadIdRef.current; + if (isOwnThreadBinding) return; + setBoundThreadId(node.threadId); boundThreadIdRef.current = node.threadId; setInputDraft(readConversationDraft(node.conversationSnapshot)); setMessageContentOverrides({}); + autoPreviewedArtifactKeyRef.current = null; autoPreviewBaselineCallKeysRef.current = new Set(); autoPreviewRunArmedRef.current = false; + writeFilePreviewSequenceRef.current = 0; + activeWriteFilePreviewRef.current = null; // The component is keyed by nodeKey in the page. This reset is therefore // only a defensive guard for callers that reuse the component instance. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -314,6 +442,43 @@ export function PositionNodeChat({ [node.nodeKey, onNodeUpdate, sessionId], ); + const handleToolStart = useCallback( + (event: ToolStartEvent) => { + if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return; + const path = writeFilePathFromToolEvent(event.data); + const threadId = boundThreadIdRef.current; + if (!path || !threadId) return; + + // Message chunks and on_tool_start can describe the same invocation in + // either order. Reuse the first preview key so a manual close suppresses + // only this write, while a later rewrite receives a fresh key and opens. + const activePreview = activeWriteFilePreviewRef.current; + if ( + activePreview?.path === path && + (activePreview.toolCallId === event.toolCallId || + (event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`))) + ) { + return; + } + const previewKey = event.toolCallId + ? `tool-start:${event.toolCallId}` + : `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`; + activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId }; + const artifactKey = `${threadId}:${previewKey}`; + if (autoPreviewedArtifactKeyRef.current === artifactKey) return; + autoPreviewedArtifactKeyRef.current = artifactKey; + onPreviewArtifact?.({ + threadId, + path, + name: artifactDisplayName(path), + mimeType: isMarkdownPath(path) ? "text/markdown" : null, + previewKey, + auto: true, + }); + }, + [node.nodeKey, onPreviewArtifact], + ); + const [thread, sendMessage, , actualThreadId] = useThreadStream({ threadId: boundThreadId, assistantId: node.agentId, @@ -334,15 +499,21 @@ export function PositionNodeChat({ agent_id: node.agentId, }, onStart: bindCreatedThread, + onToolStart: handleToolStart, onFinish: (state) => { // A turn is not considered delivered by the UI alone. The persisted // completion call records the answer, file manifest and upstream version // so the service can decide whether this seat unlocks its downstream. const threadId = boundThreadIdRef.current; - const latestAi = [...(state.messages ?? [])] + // `present_files` is often the last AI tool call and has no prose of + // its own. Keep walking backwards to the last actual answer; otherwise + // a successful file delivery would return early here and never refresh + // the node's artifact cards. + const latestAnswer = [...(state.messages ?? [])] .reverse() - .find((message) => message.type === "ai"); - const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : ""; + .filter((message) => message.type === "ai") + .map((message) => textOfMessage(message) ?? "") + .find((content) => content.trim()) ?? ""; if (!threadId || !latestAnswer.trim()) return; void (async () => { let artifactManifest: Array> = []; @@ -378,6 +549,10 @@ export function PositionNodeChat({ const activeThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current; + // A sandbox must always receive the server thread id. In particular, do not + // attach a streaming preview to the provisional UUID used for a new node + // before `useThreadStream` has created and bound its thread. + const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null; const persistedThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null; const recoveredMessages = useMemo( @@ -566,13 +741,17 @@ export function PositionNodeChat({ () => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null), [thread.isLoading, thread.messages], ); + const newStreamingArtifact = + streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key) + ? streamingArtifact + : null; useEffect(() => { if ( !thread.isLoading || - !streamingArtifact || - !autoPreviewRunArmedRef.current || - autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key) + !newStreamingArtifact || + !previewThreadId || + !autoPreviewRunArmedRef.current ) { return; } @@ -580,18 +759,34 @@ export function PositionNodeChat({ // guards manual close state, because it owns the page-wide singleton sandbox. // A report is often rewritten to the same output path. The tool-call key // (not just the path) makes that a new previewable delivery. - const artifactKey = `${activeThreadId}:${streamingArtifact.key}`; + const startedPreview = activeWriteFilePreviewRef.current; + const previewKey = + startedPreview?.path === newStreamingArtifact.sourcePath + ? startedPreview.previewKey + : newStreamingArtifact.key; + if (!startedPreview || startedPreview.path !== newStreamingArtifact.sourcePath) { + activeWriteFilePreviewRef.current = { + path: newStreamingArtifact.sourcePath, + previewKey, + toolCallId: startedPreview?.toolCallId, + }; + } + // The path takes part in the identity so that a preview opened under a + // provisional name is re-published once the real path streams in. The + // preview key stays the same, so the parent treats it as an update of the + // open panel rather than a second delivery. + const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`; if (autoPreviewedArtifactKeyRef.current === artifactKey) return; autoPreviewedArtifactKeyRef.current = artifactKey; onPreviewArtifact?.({ - threadId: activeThreadId, - path: streamingArtifact.path, - name: streamingArtifact.name, - mimeType: streamingArtifact.mimeType, - previewKey: streamingArtifact.key, + threadId: previewThreadId, + path: newStreamingArtifact.path, + name: newStreamingArtifact.name, + mimeType: newStreamingArtifact.mimeType, + previewKey, auto: true, }); - }, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]); + }, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]); // SDK thread creation is asynchronous. ``onStart`` normally binds it, but // watching the actual stream id as well closes the last race where a page @@ -628,6 +823,7 @@ export function PositionNodeChat({ : null; // A thread retains historical write_file tool calls. Only files whose // call id appears after this exact submission may open the sandbox. + activeWriteFilePreviewRef.current = null; autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages); autoPreviewRunArmedRef.current = true; await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, { diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 412d8a21..348fb357 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -1,5 +1,13 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -336,6 +344,72 @@ function sandboxCloseKey( return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`; } +type SandboxThreadStore = { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => SandboxThreadSnapshot | null; + publish: (next: SandboxThreadSnapshot | null) => void; +}; + +/** + * The streaming thread reaches the sandbox through a subscription rather than + * page state. This page is large — task panel, intent results, flowchart, node + * chat — and a streamed file publishes several times per second. Driving that + * through `useState` here re-renders the whole page at that rate and starves the + * browser so badly that even the message list stops painting until the run ends. + * With a store, only `SandboxThreadProvider` and the preview below it re-render. + */ +function createSandboxThreadStore(): SandboxThreadStore { + let snapshot: SandboxThreadSnapshot | null = null; + const listeners = new Set<() => void>(); + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => snapshot, + publish(next) { + const isUnchanged = + snapshot === next || + (snapshot != null && + next != null && + snapshot.threadId === next.threadId && + snapshot.signature === next.signature); + if (isUnchanged) return; + snapshot = next; + for (const listener of listeners) { + listener(); + } + }, + }; +} + +function SandboxThreadProvider({ + store, + threadId, + fallbackThread, + children, +}: { + store: SandboxThreadStore; + threadId: string; + fallbackThread: BaseStream; + children: ReactNode; +}) { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + const thread = snapshot?.threadId === threadId ? snapshot.thread : fallbackThread; + const value = useMemo(() => ({ thread, isMock: false }), [thread]); + return {children}; +} + +/** + * Upper bound on how often the streaming thread is re-published to the sandbox. + * Message chunks arrive roughly every 30ms and re-rendering the artifact panel + * is expensive, so updates are coalesced — but never postponed past this gap, + * otherwise the panel visibly freezes while the file is being written. + */ +const SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160; + function sandboxThreadSignature(thread: BaseStream): string { const messages = thread.messages ?? []; for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { @@ -872,10 +946,11 @@ export default function PositionRoundtablePage({ const [sandboxArtifact, setSandboxArtifact] = useState(null); const sandboxArtifactRef = useRef(null); const closedSandboxKeyRef = useRef(null); - const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState(null); + const sandboxThreadStore = useMemo(createSandboxThreadStore, []); const latestNodeThreadSnapshotRef = useRef(null); const pendingSandboxThreadSnapshotRef = useRef(null); const sandboxSnapshotTimerRef = useRef(null); + const sandboxSnapshotAppliedAtRef = useRef(0); const [flowDetailItem, setFlowDetailItem] = useState(null); const [taskEditorOpen, setTaskEditorOpen] = useState(false); const [taskDraft, setTaskDraft] = useState(fallbackTask); @@ -908,7 +983,10 @@ export default function PositionRoundtablePage({ sandboxArtifactRef.current = sandboxArtifact; }, [sandboxArtifact]); const flushSandboxThreadSnapshot = useCallback(() => { - sandboxSnapshotTimerRef.current = null; + if (sandboxSnapshotTimerRef.current !== null) { + window.clearTimeout(sandboxSnapshotTimerRef.current); + sandboxSnapshotTimerRef.current = null; + } const pending = pendingSandboxThreadSnapshotRef.current; pendingSandboxThreadSnapshotRef.current = null; if (!pending) return; @@ -916,21 +994,28 @@ export default function PositionRoundtablePage({ if (!currentArtifact || currentArtifact.threadId !== pending.threadId) { return; } - setSandboxThreadSnapshot((current) => - current?.threadId === pending.threadId && current.signature === pending.signature - ? current - : pending, - ); - }, []); + sandboxSnapshotAppliedAtRef.current = Date.now(); + sandboxThreadStore.publish(pending); + }, [sandboxThreadStore]); const queueSandboxThreadSnapshot = useCallback( (snapshot: SandboxThreadSnapshot) => { pendingSandboxThreadSnapshotRef.current = snapshot; - if (sandboxSnapshotTimerRef.current !== null) return; - if (typeof window === "undefined") { + // Leading-edge throttle: publish straight away whenever the previous + // publish is old enough. Trailing timers alone are not dependable here — + // a continuous stream keeps the main thread busy and the panel then stays + // frozen on the content it had when it opened. + const sinceApplied = Date.now() - sandboxSnapshotAppliedAtRef.current; + if (typeof window === "undefined" || sinceApplied >= SANDBOX_SNAPSHOT_MIN_INTERVAL_MS) { flushSandboxThreadSnapshot(); return; } - sandboxSnapshotTimerRef.current = window.setTimeout(flushSandboxThreadSnapshot, 160); + // Still inside the quiet window: make sure the last chunk of a stream that + // stops right now is not left unpublished. + if (sandboxSnapshotTimerRef.current !== null) return; + sandboxSnapshotTimerRef.current = window.setTimeout( + flushSandboxThreadSnapshot, + SANDBOX_SNAPSHOT_MIN_INTERVAL_MS - sinceApplied, + ); }, [flushSandboxThreadSnapshot], ); @@ -938,6 +1023,7 @@ export default function PositionRoundtablePage({ () => () => { if (sandboxSnapshotTimerRef.current !== null) { window.clearTimeout(sandboxSnapshotTimerRef.current); + sandboxSnapshotTimerRef.current = null; } }, [], @@ -2014,7 +2100,7 @@ export default function PositionRoundtablePage({ } closedSandboxKeyRef.current = null; setSandboxArtifact(null); - setSandboxThreadSnapshot(null); + sandboxThreadStore.publish(null); intent.resetIntent(); hydratedIntentSessionRef.current = null; restoringIntentSessionRef.current = null; @@ -2102,7 +2188,7 @@ export default function PositionRoundtablePage({ // 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。 intent.stopIntentStream(); setSandboxArtifact(null); - setSandboxThreadSnapshot(null); + sandboxThreadStore.publish(null); setActiveNodeKey(null); setActiveBuiltinKind(null); setSelectedAgentId(null); @@ -2225,7 +2311,7 @@ export default function PositionRoundtablePage({ return; } - const artifactKey = sandboxCloseKey(artifact); + const artifactKey = sandboxCloseKey(artifact); if (artifact.auto) { // Closing an old file should only suppress that exact preview. A later // write_file in the same thread is a new deliverable and must still be @@ -2236,22 +2322,47 @@ export default function PositionRoundtablePage({ } closedSandboxKeyRef.current = null; - setSandboxArtifact((current) => { - const isSamePreview = - current?.threadId === artifact.threadId && - current.path === artifact.path && - current.previewKey === artifact.previewKey; - return isSamePreview ? current : artifact; - }); - setSandboxThreadSnapshot((current) => - current?.threadId === artifact.threadId ? current : null, - ); + // Follow the same lifecycle as the regular conversation view. The + // position workspace owns a custom visual container, but the artifact + // selection/open state must still advance at tool-start so its preview is + // not deferred until the final artifact list is written. + const ctx = artifactsCtxRef.current; + ctx?.select(artifact.path, true); + ctx?.setOpen(true); + + const openedArtifact = sandboxArtifactRef.current; + const isSamePreview = + openedArtifact?.threadId === artifact.threadId && + openedArtifact.path === artifact.path && + openedArtifact.previewKey === artifact.previewKey; + // A streaming file is previewed under a provisional name until its real path + // arrives. Adopting that path renames the open panel; it does not deliver a + // second file, so the surrounding layout must not be reshuffled again. + const isPathUpgrade = + !isSamePreview && + Boolean(artifact.previewKey) && + openedArtifact?.threadId === artifact.threadId && + openedArtifact.previewKey === artifact.previewKey; + if (!isSamePreview) { + setSandboxArtifact(artifact); + // The snapshot queued below is published synchronously, before the state + // update above commits, so the ref that gates it has to advance here. + sandboxArtifactRef.current = artifact; + // A newly opened panel must show the file's current content immediately + // instead of waiting out the throttle window of the previous preview. + sandboxSnapshotAppliedAtRef.current = 0; + } + if (sandboxThreadStore.getSnapshot()?.threadId !== artifact.threadId) { + sandboxThreadStore.publish(null); + } const latestSnapshot = latestNodeThreadSnapshotRef.current; if (latestSnapshot?.threadId === artifact.threadId) { queueSandboxThreadSnapshot(latestSnapshot); } - setRightPanelOpen(false); + if (!isPathUpgrade) { + setRightPanelOpen(false); + } }, [queueSandboxThreadSnapshot]); const handlePreviewFlowArtifact = useCallback( (item: ResultItem) => { @@ -2322,18 +2433,6 @@ export default function PositionRoundtablePage({ }) as unknown as BaseStream, [artifactsCtx?.artifacts], ); - const sandboxThread = useMemo( - () => - sandboxArtifact && - sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId - ? sandboxThreadSnapshot.thread - : sandboxThreadStub, - [sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub], - ); - const sandboxThreadContextValue = useMemo( - () => ({ thread: sandboxThread, isMock: false }), - [sandboxThread], - ); const handleNodeThreadSnapshot = useCallback( (snapshot: { threadId: string; thread: BaseStream }) => { const signature = sandboxThreadSignature(snapshot.thread); @@ -2356,10 +2455,16 @@ export default function PositionRoundtablePage({ if (ctx?.open) { ctx?.setOpen(false); } + if (sandboxSnapshotTimerRef.current !== null) { + window.clearTimeout(sandboxSnapshotTimerRef.current); + sandboxSnapshotTimerRef.current = null; + } pendingSandboxThreadSnapshotRef.current = null; + sandboxSnapshotAppliedAtRef.current = 0; + sandboxArtifactRef.current = null; setSandboxArtifact(null); - setSandboxThreadSnapshot(null); - }, []); + sandboxThreadStore.publish(null); + }, [sandboxThreadStore]); const handleSelectBuiltinStage = useCallback( (kind: ClosureStageKind) => { // The closing agents are part of the testing-analysis workspace only; @@ -2993,18 +3098,23 @@ export default function PositionRoundtablePage({ )} {sandboxOpen && sandboxArtifact && artifactsCtx && ( )}