feat(roundtable): 增强多智能体 Step2 流式推理与产物展示

提交内容:将 roundtable 的流式消息拆分为文本与 think 推理,支持思考内容以 <think> 形式内联渲染,解决仅返回 reasoning token 时的内容空洞问题。新增 stream-reasoning 工具与单元测试,保证 reasoning 首包到正文的闭合行为稳定。

交互与预览修正:write_file/str_replace 工具的沙箱打开策略改为更接近实时席位行为,不再因为文件后缀限制阻塞临时脚本写入可视化;通过新增 streaming-artifacts 工具统一处理可预览路径、最终交付产物过滤与目录快照比对,支持 bash/脚本直接落盘场景在流式阶段自动发现新增文档。

体验细节:步骤面板文件卡片从工具路径与最终 artifact 统一收敛,避免 read_file 点击误触发;自动滚动逻辑新增布局高度抖动识别,防止模型推理渲染导致的误判向上滚动。
This commit is contained in:
董宏凯 committed 2026-08-31 16:45:01 +08:00
1 parent fbf9031e25
commit f0ddade8a7
9 files changed
+596 -102

No files matched your search

@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report"; import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
export type ThreadIdMap = Record<string, string>; export type ThreadIdMap = Record<string, string>;
@@ -469,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is {
* structured content blocks. Returns null when the frame is not an AI text * structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots). * chunk (e.g., tool calls, tool results, value snapshots).
*/ */
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { function extractAiTextDelta(
parsed: unknown,
): { content: string; reasoning: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null; if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0]; const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null; if (!chunk || typeof chunk !== "object") return null;
@@ -492,9 +495,18 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
} }
} }
} }
if (!text) return null; const additionalKwargs = obj.additional_kwargs;
const directReasoning = obj.reasoning_content ?? obj.reasoning;
const nestedReasoning =
additionalKwargs && typeof additionalKwargs === "object"
? (additionalKwargs as Record<string, unknown>).reasoning_content ??
(additionalKwargs as Record<string, unknown>).reasoning
: undefined;
const rawReasoning = nestedReasoning ?? directReasoning;
const reasoning = typeof rawReasoning === "string" ? rawReasoning : "";
if (!text && !reasoning) return null;
const id = typeof obj.id === "string" ? obj.id : ""; const id = typeof obj.id === "string" ? obj.id : "";
return { text, id }; return { content: text, reasoning, id };
} }
/** /**
@@ -623,6 +635,10 @@ export async function streamMultiAgent(
let currentPhaseArgsSignature = ""; let currentPhaseArgsSignature = "";
let currentPhaseToolCallId = ""; let currentPhaseToolCallId = "";
req.onPhaseChange?.("connecting"); req.onPhaseChange?.("connecting");
// The normal chat renders provider reasoning from a distinct message field.
// Roundtable's bespoke SSE reader keeps that field inline so its existing
// `<think>` renderer remains the single display path for Steps 2 and 3.
const inlineAiDelta = createInlineReasoningDeltaFormatter();
const setPhase = ( const setPhase = (
next: StreamPhase, next: StreamPhase,
name?: string, name?: string,
@@ -788,6 +804,7 @@ export async function streamMultiAgent(
const toolCalls = chunkObj.tool_calls; const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks; const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content; const content = chunkObj.content;
const streamDelta = extractAiTextDelta(parsed);
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
@@ -843,7 +860,9 @@ export async function streamMultiAgent(
setPhase("tool_calling", toolName, args, toolCallId); setPhase("tool_calling", toolName, args, toolCallId);
} else if ( } else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) (Boolean(streamDelta) ||
(typeof content === "string" && content.length > 0) ||
(Array.isArray(content) && content.length > 0))
) { ) {
setPhase("streaming"); setPhase("streaming");
} }
@@ -911,8 +930,9 @@ export async function streamMultiAgent(
const delta = extractAiTextDelta(parsed); const delta = extractAiTextDelta(parsed);
if (delta) { if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`); const text = inlineAiDelta(delta);
req.onTextDelta?.(delta.text, delta.id); if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
if (text) req.onTextDelta?.(text, delta.id);
} }
req.onUpstreamFrame?.(parsed); req.onUpstreamFrame?.(parsed);
@@ -22,7 +22,7 @@ import {
ChainOfThoughtStep, ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought"; } from "@/components/ai-elements/chain-of-thought";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { languageOfStreamingArtifact } from "@/core/artifacts/streaming-write-preview"; import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
getStepDisplay, getStepDisplay,
@@ -178,17 +178,16 @@ function ToolStep({
const display = getStepDisplay(step.key, step.label, step.args); const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName); const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":"); const [, toolName] = step.key.split(":");
// 只让「自己生成/展示的文件」可点,与主聊天页一致。read_file 不可点 —— 否则智能体 // 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行
// 调用技能时读取的 SOUL.md、执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。 // 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。
// write_file / str_replace 还要过与文件卡同一道门槛(`languageOfStreamingArtifact`, // read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。
// 只认 .md/.html/.mermaid 等可预览成品):席位写的过程文件(fetch.py / r1.txt)与 // str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。
// 畸形路径(mnt/user-data/outputs)只作步骤展示、点了也不会打开沙箱。
// present_files 由后端校验"outputs 下真实存在",保持可点。
const stepPath = typeof step.args?.path === "string" ? step.args.path : ""; const stepPath = typeof step.args?.path === "string" ? step.args.path : "";
const isArtifactStep = const isArtifactStep =
toolName === "present_files" || toolName === "present_files" ||
((toolName === "write_file" || toolName === "str_replace") && toolName === "write_file" ||
!!languageOfStreamingArtifact(stepPath)); (toolName === "str_replace" &&
collectRoundtableDeliverablePaths([stepPath]).length > 0);
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -43,7 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { languageOfStreamingArtifact } from "@/core/artifacts/streaming-write-preview"; import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -1086,7 +1086,7 @@ export function Step2Message({
const rawContent = msg.content ?? ""; const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent); const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0; const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps); const presentedFiles = collectPresentedFiles(msg.steps, msg.artifactPaths);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0"); const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。 // 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
@@ -1193,23 +1193,25 @@ function RoundtableSubtaskCard({
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了 * 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 成品文件就出可点的卡片"。 * 成品文件就出可点的卡片"。
* *
* write_file/str_replace 的 path 需过 `languageOfStreamingArtifact`(只认 .md/.html/ * write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/
* .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套): * .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套):
* 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。 * 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。
*/ */
function collectPresentedFiles( function collectPresentedFiles(
steps: Step2Dialogue["steps"], steps: Step2Dialogue["steps"],
settledArtifactPaths?: string[],
): string[] { ): string[] {
if (!Array.isArray(steps) || steps.length === 0) return []; const toolSteps = Array.isArray(steps) ? steps : [];
if (toolSteps.length === 0 && (!settledArtifactPaths || settledArtifactPaths.length === 0)) return [];
const out: string[] = []; const out: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (fp: unknown, requirePreviewable = false) => { const add = (fp: unknown, requirePreviewable = false) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return; if (typeof fp !== "string" || !fp || seen.has(fp)) return;
if (requirePreviewable && !languageOfStreamingArtifact(fp)) return; if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return;
seen.add(fp); seen.add(fp);
out.push(fp); out.push(fp);
}; };
for (const step of steps) { for (const step of toolSteps) {
if (step.key === "tool_calling:present_files") { if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths; const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach((fp) => add(fp)); if (Array.isArray(fps)) fps.forEach((fp) => add(fp));
@@ -1222,6 +1224,9 @@ function collectPresentedFiles(
add(step.args?.path, true); add(step.args?.path, true);
} }
} }
// A weak model may create a report through bash rather than present_files;
// the completed dialogue adds only real, user-facing document paths here.
settledArtifactPaths?.forEach((path) => add(path, true));
return out; return out;
} }
@@ -53,16 +53,22 @@ export function useAutoScroll<T extends HTMLElement>(
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随 // - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
const lastObservedScrollTopRef = useRef(0); const lastObservedScrollTopRef = useRef(0);
// 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下
// clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。
const lastObservedScrollHeightRef = useRef(0);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
let prevScrollTop = el.scrollTop; let prevScrollTop = el.scrollTop;
lastObservedScrollTopRef.current = prevScrollTop; lastObservedScrollTopRef.current = prevScrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
const onScroll = () => { const onScroll = () => {
const currentScrollTop = el.scrollTop; const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold; const isAtBottom = distanceFromBottom <= threshold;
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
const programmaticTarget = programmaticScrollTargetRef.current; const programmaticTarget = programmaticScrollTargetRef.current;
if ( if (
@@ -73,6 +79,7 @@ export function useAutoScroll<T extends HTMLElement>(
programmaticScrollTargetRef.current = null; programmaticScrollTargetRef.current = null;
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
// 程序滚动后通常落在底部,把 atBottom 维持 true。 // 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) { if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true; atBottomRef.current = true;
@@ -86,7 +93,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**, // 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。 // 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
if (currentScrollTop < prevScrollTop && atBottomRef.current) { if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) {
atBottomRef.current = false; atBottomRef.current = false;
setAtBottom(false); setAtBottom(false);
} else if (isAtBottom && !atBottomRef.current) { } else if (isAtBottom && !atBottomRef.current) {
@@ -99,6 +106,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。 // 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
}; };
el.addEventListener("scroll", onScroll, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true });
// 初次测量 // 初次测量
@@ -116,21 +124,25 @@ export function useAutoScroll<T extends HTMLElement>(
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const currentScrollTop = el.scrollTop; const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。 // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1; const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
if (userScrolledUp && distanceFromBottom > threshold) { const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) {
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。 // 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
if (atBottomRef.current) { if (atBottomRef.current) {
atBottomRef.current = false; atBottomRef.current = false;
setAtBottom(false); setAtBottom(false);
} }
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return; return;
} }
if (!atBottomRef.current) { if (!atBottomRef.current) {
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。 // 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return; return;
} }
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。 // 在底部 (或仅是内容刚增长) → 同步拉到新底部。
@@ -140,6 +152,7 @@ export function useAutoScroll<T extends HTMLElement>(
); );
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop; lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, deps);
@@ -165,6 +178,7 @@ export function useAutoScroll<T extends HTMLElement>(
); );
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop; lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
}); });
}); });
observer.observe(content); observer.observe(content);
@@ -185,6 +199,7 @@ export function useAutoScroll<T extends HTMLElement>(
el.scrollHeight - el.clientHeight, el.scrollHeight - el.clientHeight,
); );
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true; atBottomRef.current = true;
setAtBottom(true); setAtBottom(true);
}, [ref]); }, [ref]);
@@ -201,6 +216,7 @@ export function useAutoScroll<T extends HTMLElement>(
); );
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop; lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true; atBottomRef.current = true;
setAtBottom(true); setAtBottom(true);
}, [ref]); }, [ref]);
@@ -22,6 +22,7 @@ import {
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { import {
initMultiAgent, initMultiAgent,
listSessionArtifacts,
streamMultiAgent, streamMultiAgent,
type ModelSwitchInfo, type ModelSwitchInfo,
type ClarificationPayload, type ClarificationPayload,
@@ -32,7 +33,13 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning"; import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning";
import { uploadFiles } from "@/core/uploads/api"; import { uploadFiles } from "@/core/uploads/api";
import { languageOfStreamingArtifact } from "@/core/artifacts/streaming-write-preview"; import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
import {
collectRoundtableDeliverablePaths,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "@/roundtable-planning/lib/streaming-artifacts";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */ /** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
@@ -167,6 +174,8 @@ export type Step2Dialogue = {
streaming?: boolean; streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string; agentThreadId?: string;
/** Stream settlement scans the real sandbox output directory for these cards. */
artifactPaths?: string[];
/** /**
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具 * 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片 * 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
@@ -204,6 +213,8 @@ export interface ParallelSeatView {
*/ */
export interface StubToolCall { export interface StubToolCall {
id: string; id: string;
/** Original LangGraph id when a pre-id stream chunk needed a local fallback. */
sourceToolCallId?: string;
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
} }
@@ -227,25 +238,34 @@ export function completeStubWriteFile(
messages: StubAiMessage[], messages: StubAiMessage[],
toolCallId: string, toolCallId: string,
): StubAiMessage[] { ): StubAiMessage[] {
if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) { if (!toolCallId) {
return messages; return messages;
} }
const source = messages.find( const source = messages.find((message) =>
(message) =>
message.type === "ai" && message.type === "ai" &&
message.tool_calls.some( message.tool_calls.some(
(toolCall) => toolCall.id === toolCallId && toolCall.name === "write_file", (toolCall) =>
toolCall.name === "write_file" &&
(toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId),
), ),
); );
if (!source) return messages; const matchedCall = source?.tool_calls.find(
(toolCall) =>
toolCall.name === "write_file" &&
(toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId),
);
if (!source || !matchedCall) return messages;
if (messages.some((message) => message.type === "tool" && message.tool_call_id === matchedCall.id)) {
return messages;
}
return [ return [
...messages, ...messages,
{ {
id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`, id: `roundtable-stub-tool-result-${source.id}-${matchedCall.id}`,
type: "tool", type: "tool",
content: "", content: "",
tool_calls: [], tool_calls: [],
tool_call_id: toolCallId, tool_call_id: matchedCall.id,
}, },
]; ];
} }
@@ -591,6 +611,12 @@ export function useStep2Orchestration(
}, [artifacts]); }, [artifacts]);
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */ /** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
const hasAutoOpenedRef = useRef<Set<string>>(new Set()); const hasAutoOpenedRef = useRef<Set<string>>(new Set());
/** 技能脚本内部落盘没有 write_file SSE;保留目录快照以发现新增/覆盖文件。 */
const artifactDirectorySnapshotsRef = useRef<
Map<string, Map<string, string>>
>(new Map());
/** 每轮、每线程、每路径只自动打开一次,尊重用户随后手动关闭沙箱。 */
const autoOpenedDiscoveredArtifactsRef = useRef<Set<string>>(new Set());
// ─── State ───────────────────────────────────────────────────────── // ─── State ─────────────────────────────────────────────────────────
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>( const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
@@ -855,6 +881,7 @@ export function useStep2Orchestration(
seatThreadId: string, seatThreadId: string,
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
sourceToolCallId?: string,
): { messageId: string; toolCallId: string } => { ): { messageId: string; toolCallId: string } => {
const path = typeof args.path === "string" ? args.path : ""; const path = typeof args.path === "string" ? args.path : "";
const current = seatStubMessagesRef.current[seatThreadId] ?? []; const current = seatStubMessagesRef.current[seatThreadId] ?? [];
@@ -865,17 +892,25 @@ export function useStep2Orchestration(
content: "", content: "",
tool_calls: [], tool_calls: [],
}; };
const idx = baseMsg.tool_calls.findIndex( const bySourceId = sourceToolCallId
? baseMsg.tool_calls.findIndex(
(tc) => tc.id === sourceToolCallId || tc.sourceToolCallId === sourceToolCallId,
)
: -1;
const byPath = baseMsg.tool_calls.findIndex(
(tc) => (tc) =>
tc.name === toolName && tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path, String((tc.args as { path?: unknown })?.path ?? "") === path,
); );
const idx = bySourceId >= 0 ? bySourceId : byPath;
const toolCallId = const toolCallId =
idx >= 0 idx >= 0
? baseMsg.tool_calls[idx].id ? baseMsg.tool_calls[idx].id
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`; : sourceToolCallId || `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
const nextToolCall: StubToolCall = { const nextToolCall: StubToolCall = {
id: toolCallId, id: toolCallId,
sourceToolCallId:
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
name: toolName, name: toolName,
args, args,
}; };
@@ -990,19 +1025,9 @@ export function useStep2Orchestration(
[openStreamedArtifact, selectArtifact, setArtifactsOpen], [openStreamedArtifact, selectArtifact, setArtifactsOpen],
); );
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
/** /**
* sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。 * sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。 * 从该 thread 的 stub messages 里倒序找第一个可预览的 write_file / str_replace。
* 无产物返回 false(调用方据此关闭沙箱)。 * 无产物返回 false(调用方据此关闭沙箱)。
*/ */
const openSeatLatestArtifact = useCallback( const openSeatLatestArtifact = useCallback(
@@ -1012,15 +1037,13 @@ export function useStep2Orchestration(
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) { for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
const tc = stub.tool_calls[i]; const tc = stub.tool_calls[i];
if (tc.name !== "write_file" && tc.name !== "str_replace") continue; if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const path = const args =
typeof (tc.args as { path?: unknown })?.path === "string" tc.args && typeof tc.args === "object"
? ((tc.args as { path?: string }).path as string) ? (tc.args as Record<string, unknown>)
: ""; : undefined;
const path = resolveRoundtableStreamingWritePreview(tc.name, args);
if (!path) continue; if (!path) continue;
// 同 handleArtifactToolPhase 的门槛:跳过 .py/.txt 等过程文件与畸形路径, const virtualUrl = buildWriteFileVirtualUrl({ path, messageId: stub.id, toolCallId: tc.id });
// 沙箱跟随只落在该席位最近的**成品**文件上。
if (!languageOfStreamingArtifact(path)) continue;
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id);
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化 // manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。 // 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false }); openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
@@ -1101,11 +1124,11 @@ export function useStep2Orchestration(
// not found 的影响。 // not found 的影响。
const seatSource = findSeatArtifactSource(normalized); const seatSource = findSeatArtifactSource(normalized);
if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) { if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
const virtualUrl = buildWriteFileVirtualUrl( const virtualUrl = buildWriteFileVirtualUrl({
normalized, path: normalized,
seatSource.messageId, messageId: seatSource.messageId,
seatSource.toolCallId, toolCallId: seatSource.toolCallId,
); });
openSeatVirtualArtifact( openSeatVirtualArtifact(
seatSource.seatThreadId, seatSource.seatThreadId,
virtualUrl, virtualUrl,
@@ -1150,6 +1173,96 @@ export function useStep2Orchestration(
], ],
); );
// 对齐普通聊天的产物目录刷新:技能可以通过 bash / Python 在 outputs 中直接落盘,
// 这种路径没有顶层 write_file 工具帧。只要对应气泡仍在流式,就每 1.5 秒扫描一次;
// 发现新文件或同路径被覆盖时自动打开真实沙箱。最终消息卡片仍由
// collectRoundtableDeliverablePaths 单独限制为 md/html/mermaid。
const streamingArtifactThreadSignature = useMemo(() => {
const ids = new Set<string>();
for (const dialogue of step2RoundtableDialogues) {
if (dialogue.streaming && dialogue.agentThreadId) {
ids.add(dialogue.agentThreadId);
}
}
return [...ids].sort().join("\n");
}, [step2RoundtableDialogues]);
useEffect(() => {
if (currentStep !== 2 || !streamingArtifactThreadSignature) return;
const watchedThreadIds = streamingArtifactThreadSignature
.split("\n")
.filter(Boolean);
if (watchedThreadIds.length === 0) return;
const controller = new AbortController();
const watchStartedAtMs = Date.now();
let stopped = false;
let polling = false;
const poll = async () => {
if (polling || stopped) return;
polling = true;
try {
const results = await Promise.allSettled(
watchedThreadIds.map(async (threadId) => ({
threadId,
listing: await listSessionArtifacts(threadId, {
signal: controller.signal,
}),
})),
);
if (stopped) return;
let newest:
| { threadId: string; path: string; modifiedAt: string }
| null = null;
let newestTime = Number.NEGATIVE_INFINITY;
for (const result of results) {
if (result.status !== "fulfilled") continue;
const { threadId, listing } = result.value;
const previous = artifactDirectorySnapshotsRef.current.get(threadId);
const changed = newestChangedRoundtableArtifact(
listing.files,
previous,
watchStartedAtMs,
);
artifactDirectorySnapshotsRef.current.set(
threadId,
roundtableArtifactDiskSnapshot(listing.files),
);
if (!changed) continue;
const changedTime = Date.parse(changed.modifiedAt);
const sortableTime = Number.isFinite(changedTime) ? changedTime : 0;
if (!newest || sortableTime > newestTime) {
newest = {
threadId,
path: changed.path,
modifiedAt: changed.modifiedAt,
};
newestTime = sortableTime;
}
}
if (!newest) return;
const autoOpenKey = `${orchestrationEpochRef.current}:${newest.threadId}:${newest.path}`;
if (autoOpenedDiscoveredArtifactsRef.current.has(autoOpenKey)) return;
autoOpenedDiscoveredArtifactsRef.current.add(autoOpenKey);
openArtifactPreview(newest.threadId, newest.path, { auto: true });
} finally {
polling = false;
}
};
void poll();
const intervalId = window.setInterval(() => void poll(), 1_500);
return () => {
stopped = true;
controller.abort();
window.clearInterval(intervalId);
};
}, [currentStep, openArtifactPreview, streamingArtifactThreadSignature]);
const openArtifactFromStep = useCallback( const openArtifactFromStep = useCallback(
( (
dialogue: Step2Dialogue, dialogue: Step2Dialogue,
@@ -1159,16 +1272,15 @@ export function useStep2Orchestration(
if (!seatThreadId) return; if (!seatThreadId) return;
const [, toolName] = step.key.split(":"); const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") { if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path; if (!step.args) return;
if (typeof path !== "string" || !step.args) return; const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
// 与 MessageStepsCard 的可点判定同一道门槛:过程文件/畸形路径点了不开沙箱。 if (!path) return;
if (!languageOfStreamingArtifact(path)) return;
const { messageId, toolCallId } = upsertSeatToolCall( const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId, seatThreadId,
toolName, toolName,
step.args, step.args,
); );
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
openSeatVirtualArtifact(seatThreadId, virtualUrl, false); openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return; return;
} }
@@ -1198,32 +1310,22 @@ export function useStep2Orchestration(
toolName: string | undefined, toolName: string | undefined,
args: Record<string, unknown> | undefined, args: Record<string, unknown> | undefined,
seatThreadId: string | undefined, seatThreadId: string | undefined,
sourceToolCallId?: string,
) => { ) => {
if (phase !== "tool_calling") return; if (phase !== "tool_calling") return;
if (!seatThreadId) return; if (!seatThreadId) return;
const isArtifactTool = if (toolName !== "write_file" && toolName !== "str_replace") return;
toolName === "write_file" || toolName === "str_replace"; // `write_file` can stream its body before its path. It opens the sandbox
if (!isArtifactTool) return; // for every file type; only final message-list cards are filtered.
const path = args?.path; const path = resolveRoundtableStreamingWritePreview(toolName, args);
if (typeof path !== "string" || !path || !args) { if (!path || !args) return;
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] tool_calling without path yet",
{ toolName, args },
);
}
return;
}
// 与主聊天 ArtifactWriteToolCall 的流式预览同一道门槛:只有 .md/.html/.mermaid
// 等可预览产物才自动打开沙箱。席位写的过程文件(fetch.py / r*.txt)与无扩展名的
// 畸形路径不再触发自动预览,免得右侧沙箱被无意义文件反复抢占。
if (!languageOfStreamingArtifact(path)) return;
const { messageId, toolCallId } = upsertSeatToolCall( const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId, seatThreadId,
toolName as string, toolName as string,
args, args,
sourceToolCallId,
); );
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
openSeatVirtualArtifact(seatThreadId, virtualUrl, true); openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
}, },
[ [
@@ -1239,7 +1341,9 @@ export function useStep2Orchestration(
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开; * - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界, * - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。 * 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。 * - 同一 messages 帧可先写文件、再读技能。阶段层只会把最后一个 read_skill 当作
* active tool,因而这里必须补一次 write_file 自动打开;以 tool-call 虚拟 URL 去重,
* 不会在后续内容增量或用户手动关闭后反复弹出。
*/ */
const handleUpstreamFrame = useCallback( const handleUpstreamFrame = useCallback(
(frame: unknown, seatThreadId: string | undefined) => { (frame: unknown, seatThreadId: string | undefined) => {
@@ -1265,14 +1369,79 @@ export function useStep2Orchestration(
const tcArgs = tcObj.args; const tcArgs = tcObj.args;
if (!tcArgs || typeof tcArgs !== "object") continue; if (!tcArgs || typeof tcArgs !== "object") continue;
const argsObj = tcArgs as Record<string, unknown>; const argsObj = tcArgs as Record<string, unknown>;
const path = argsObj.path; const sourceToolCallId = typeof tcObj.id === "string" ? tcObj.id : undefined;
if (typeof path !== "string" || !path) continue; // Keep the content-first call in the stub as well. The virtual preview
upsertSeatToolCall(seatThreadId, name, argsObj); // reads this message directly, so dropping pathless chunks recreates the
// exact race fixed in the normal chat.
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
name,
argsObj,
sourceToolCallId,
);
// `setPhase` 只报告一个当前工具。若 AI chunk 同时携带
// `write_file → read_skill`,它会落在 read_skill,旧逻辑便遗漏了这里的
// 实时沙箱。逐条扫描所有 write_file,把过程脚本和最终文档一律接入沙箱;
// 最终消息卡片仍由 collectRoundtableDeliverablePaths 单独过滤。
if (name !== "write_file") continue;
const previewPath = resolveRoundtableStreamingWritePreview(name, argsObj);
if (!previewPath) continue;
openSeatVirtualArtifact(
seatThreadId,
buildWriteFileVirtualUrl({
path: previewPath,
messageId,
toolCallId,
}),
true,
);
} }
}, },
[completeSeatStubWriteFile, upsertSeatToolCall], [
buildWriteFileVirtualUrl,
completeSeatStubWriteFile,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
); );
/**
* A weak model may create the final report through bash after an earlier
* write_file call. Those tools have no `present_files` step, so mirror the
* normal chat's post-stream artifact-directory refresh and attach only the
* user-facing document types to this completed bubble.
*/
const attachSettledArtifactCards = useCallback((dialogueId: number, threadId?: string) => {
if (!threadId) return;
void listSessionArtifacts(threadId)
.then(({ files }) => {
const artifactPaths = collectRoundtableDeliverablePaths(files.map((file) => file.path));
if (artifactPaths.length === 0) return;
setStep2RoundtableDialogues((previous) => {
const index = previous.findIndex((dialogue) => dialogue.id === dialogueId);
if (index < 0) return previous;
const dialogue = previous[index];
const merged = collectRoundtableDeliverablePaths([
...(dialogue.artifactPaths ?? []),
...artifactPaths,
]);
if (
merged.length === (dialogue.artifactPaths ?? []).length &&
merged.every((path, itemIndex) => path === dialogue.artifactPaths?.[itemIndex])
) {
return previous;
}
const next = [...previous];
next[index] = { ...dialogue, artifactPaths: merged };
return next;
});
})
.catch(() => {
// The normal message/tool path remains usable when directory listing is
// unavailable; no noisy toast is warranted for this recovery fallback.
});
}, []);
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里: // ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress); // (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
@@ -1773,8 +1942,12 @@ export function useStep2Orchestration(
(id: number, finalContent: string, confidence?: string) => { (id: number, finalContent: string, confidence?: string) => {
// 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。 // 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。
// 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。 // 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。
completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]); const threadId = dialogueThreadIdRef.current[id];
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 completeSeatStubWriteFiles(threadId);
attachSettledArtifactCards(id, threadId);
// 完成帧可能与最后一批节流 token 落在同一任务:把尚未 flush 的增量合并进
// 收尾内容,既防止旧增量晚到反写,也不能丢最后的思考/正文 token。
const pendingDelta = directStreamDeltaRef.current.get(id) ?? "";
directStreamDeltaRef.current.delete(id); directStreamDeltaRef.current.delete(id);
if ( if (
directStreamDeltaRef.current.size === 0 && directStreamDeltaRef.current.size === 0 &&
@@ -1787,10 +1960,18 @@ export function useStep2Orchestration(
let changed = false; let changed = false;
const next = prev.map((d) => { const next = prev.map((d) => {
if (d.id !== id) return d; if (d.id !== id) return d;
const streamed = `${d.content ?? ""}${pendingDelta}`;
const streamedVisible = stripReasoning(streamed).trim();
const finalVisible = stripReasoning(finalContent).trim();
// Special-seat terminal frames intentionally return the visible
// deliverable without provider reasoning. When it matches the
// streamed body, retain the inline <think> trace already rendered.
const next = const next =
finalContent && finalContent.trim().length > 0 streamed.trim() && streamedVisible === finalVisible
? streamed
: finalContent && finalContent.trim().length > 0
? finalContent ? finalContent
: d.content || "(无文本输出)"; : streamed || "(无文本输出)";
if ( if (
d.content === next && d.content === next &&
d.confidence === (confidence ?? d.confidence) && d.confidence === (confidence ?? d.confidence) &&
@@ -1809,7 +1990,7 @@ export function useStep2Orchestration(
return changed ? next : prev; return changed ? next : prev;
}); });
}, },
[completeSeatStubWriteFiles], [attachSettledArtifactCards, completeSeatStubWriteFiles],
); );
const highlightSpeaker = useCallback( const highlightSpeaker = useCallback(
@@ -2008,11 +2189,11 @@ export function useStep2Orchestration(
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel), onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label); setDialogueConfidence(a.bubbleId, label);
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId); handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, a.seatThreadId); handleUpstreamFrame(frame, a.seatThreadId);
@@ -2116,7 +2297,7 @@ export function useStep2Orchestration(
streamStepKey(phase, toolName, toolCallId), streamStepKey(phase, toolName, toolCallId),
args, args,
); );
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId), handleUpstreamFrame(frame, leaderThreadId),
@@ -2399,7 +2580,7 @@ export function useStep2Orchestration(
streamStepKey(phase, toolName, toolCallId), streamStepKey(phase, toolName, toolCallId),
args, args,
); );
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2478,11 +2659,11 @@ export function useStep2Orchestration(
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false), onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text), onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label); setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -2590,7 +2771,7 @@ export function useStep2Orchestration(
streamStepKey(phase, toolName, toolCallId), streamStepKey(phase, toolName, toolCallId),
args, args,
); );
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2774,7 +2955,7 @@ export function useStep2Orchestration(
streamStepKey(phase, toolName, toolCallId), streamStepKey(phase, toolName, toolCallId),
args, args,
); );
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2897,11 +3078,11 @@ export function useStep2Orchestration(
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel), onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -3109,11 +3290,11 @@ export function useStep2Orchestration(
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, true), onModelSwitch: makeModelSwitchHandler(bubbleId, true),
onTextDelta: (text) => ingestParallelDelta(bubbleId, text), onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -3656,6 +3837,8 @@ export function useStep2Orchestration(
setArtifacts([]); setArtifacts([]);
resetSeatStubMessages(); resetSeatStubMessages();
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
artifactDirectorySnapshotsRef.current = new Map();
autoOpenedDiscoveredArtifactsRef.current = new Set();
pendingInjectPromptRef.current = null; pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false; dagPlanExecutedRef.current = false;
humanValidationContinuationRef.current = null; humanValidationContinuationRef.current = null;
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInlineReasoningDeltaFormatter } from "./stream-reasoning";
test("provider reasoning chunks form one live think block before answer content", () => {
const format = createInlineReasoningDeltaFormatter();
assert.equal(
format({ id: "msg-1", reasoning: "先检查约束", content: "" }),
"<think>先检查约束",
);
assert.equal(
format({ id: "msg-1", reasoning: ",再组织答案", content: "" }),
",再组织答案",
);
assert.equal(
format({ id: "msg-1", reasoning: "", content: "最终结论" }),
"</think>最终结论",
);
});
@@ -0,0 +1,30 @@
export type StreamedAiTextDelta = {
content: string;
reasoning: string;
id: string;
};
/**
* Preserve an open `<think>` block across provider reasoning-only chunks and
* close it exactly when visible content begins. The resulting text feeds the
* same Step2Message renderer used for literal `<think>` model output.
*/
export function createInlineReasoningDeltaFormatter(): (delta: StreamedAiTextDelta) => string {
const reasoningOpenByMessageId = new Set<string>();
return (delta) => {
const key = delta.id || "__roundtable-anonymous-reasoning__";
let text = "";
if (delta.reasoning) {
if (!reasoningOpenByMessageId.has(key)) {
reasoningOpenByMessageId.add(key);
text += "<think>";
}
text += delta.reasoning;
}
if (delta.content) {
if (reasoningOpenByMessageId.delete(key)) text += "</think>";
text += delta.content;
}
return text;
};
}
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectRoundtableDeliverablePaths,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "./streaming-artifacts";
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
"未命名文件.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { path: "/mnt/user-data/outputs/report.m", content: "# 报告" }),
"未命名文件.md",
);
});
test("roundtable live-previews every write_file type but filters final cards", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告",
}),
"/mnt/user-data/outputs/report.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/workspace/fetch.py",
content: "print('fetch')",
}),
"/mnt/user-data/workspace/fetch.py",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
}),
"/mnt/user-data/outputs/task-reports.json",
);
assert.equal(
resolveRoundtableStreamingWritePreview("str_replace", { content: "# 报告" }),
null,
);
assert.deepEqual(
collectRoundtableDeliverablePaths([
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
"/mnt/user-data/workspace/fetch.py",
"/mnt/user-data/workspace/notes.txt",
]),
[
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
],
);
});
test("roundtable detects files created or overwritten by a skill without write_file SSE", () => {
const oldFile = {
path: "/mnt/user-data/outputs/report.md",
sizeBytes: 10,
modifiedAt: "2026-08-31T01:00:00.000Z",
};
const baseline = roundtableArtifactDiskSnapshot([oldFile]);
const overwritten = {
...oldFile,
sizeBytes: 20,
modifiedAt: "2026-08-31T01:00:05.000Z",
};
const generatedBySkill = {
path: "/mnt/user-data/outputs/chart.html",
sizeBytes: 30,
modifiedAt: "2026-08-31T01:00:06.000Z",
};
assert.deepEqual(
newestChangedRoundtableArtifact(
[overwritten, generatedBySkill],
baseline,
Date.parse("2026-08-31T01:00:04.000Z"),
),
generatedBySkill,
);
assert.equal(
newestChangedRoundtableArtifact(
[oldFile],
undefined,
Date.parse("2026-08-31T01:00:10.000Z"),
),
null,
);
assert.deepEqual(
newestChangedRoundtableArtifact(
[generatedBySkill],
undefined,
Date.parse("2026-08-31T01:00:07.000Z"),
),
generatedBySkill,
);
});
@@ -0,0 +1,113 @@
import {
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
/**
* Resolve the path that may be live-previewed while a roundtable tool call is
* still streaming.
*
* `write_file` is deliberately unrestricted here: the sandbox is a live tool
* activity view, not the final-deliverable list. Agents commonly write a
* temporary Python script before reading a skill or running it; suppressing
* that preview makes the page look stalled even though the backend is busy.
* The separate `collectRoundtableDeliverablePaths` gate remains responsible
* for limiting message-list file cards to Markdown, HTML, and Mermaid.
*
* `str_replace` retains its existing document-only behavior. It has no full
* content payload for a virtual preview, and is not a new-file write.
*/
export function resolveRoundtableStreamingWritePreview(
toolName: string | undefined,
args: Record<string, unknown> | undefined,
): string | null {
const rawPath = typeof args?.path === "string" ? args.path : "";
const path = normalizeStreamingArtifactPath(rawPath);
if (toolName === "write_file") {
// Weak models can emit content before the `path` JSON key. Give that same
// call a temporary name so the sandbox opens and continues receiving data.
const content = typeof args?.content === "string" ? args.content : "";
// A complete path of *any* file type is previewable. Keep an incomplete
// suffix such as `report.m` provisional while content is already flowing,
// so it upgrades cleanly to Markdown once the final `d` arrives.
if (path && (hasCompleteArtifactExtension(path) || !content.trim())) {
return path;
}
return content.trim() ? provisionalWriteFilePath(content) : null;
}
// Preserve the prior, conservative behavior for in-place replacement.
return languageOfStreamingArtifact(path) ? path : null;
}
/** Final artifact cards intentionally expose only user-facing deliverables. */
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
const seen = new Set<string>();
const paths: string[] = [];
for (const value of values) {
if (typeof value !== "string") continue;
const path = normalizeStreamingArtifactPath(value);
if (!path || !languageOfStreamingArtifact(path) || seen.has(path)) continue;
seen.add(path);
paths.push(path);
}
return paths;
}
export interface RoundtableWatchedArtifact {
path: string;
sizeBytes: number;
modifiedAt: string;
}
/** Stable disk signature used to notice both newly created and overwritten files. */
export function roundtableArtifactDiskSnapshot(
files: Iterable<RoundtableWatchedArtifact>,
): Map<string, string> {
const snapshot = new Map<string, string>();
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
snapshot.set(path, `${file.sizeBytes}:${file.modifiedAt}`);
}
return snapshot;
}
/**
* Pick the newest file created or changed since the prior directory poll.
*
* On the first poll there is no baseline yet. A small grace window catches a
* skill output that lands between mounting the streaming bubble and the first
* HTTP response, without reopening unrelated files from older roundtable runs.
*/
export function newestChangedRoundtableArtifact(
files: readonly RoundtableWatchedArtifact[],
previous: ReadonlyMap<string, string> | undefined,
watchStartedAtMs: number,
firstPollGraceMs = 2_000,
): RoundtableWatchedArtifact | null {
let newest: RoundtableWatchedArtifact | null = null;
let newestTime = Number.NEGATIVE_INFINITY;
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
const signature = `${file.sizeBytes}:${file.modifiedAt}`;
const modifiedTime = Date.parse(file.modifiedAt);
const changed = previous
? previous.get(path) !== signature
: Number.isFinite(modifiedTime) &&
modifiedTime >= watchStartedAtMs - firstPollGraceMs;
if (!changed) continue;
const sortableTime = Number.isFinite(modifiedTime) ? modifiedTime : 0;
if (!newest || sortableTime > newestTime) {
newest = { ...file, path };
newestTime = sortableTime;
}
}
return newest;
}