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:
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 { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
|
||||
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
|
||||
|
||||
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
|
||||
* 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;
|
||||
const chunk = parsed[0];
|
||||
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 : "";
|
||||
return { text, id };
|
||||
return { content: text, reasoning, id };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -623,6 +635,10 @@ export async function streamMultiAgent(
|
||||
let currentPhaseArgsSignature = "";
|
||||
let currentPhaseToolCallId = "";
|
||||
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 = (
|
||||
next: StreamPhase,
|
||||
name?: string,
|
||||
@@ -788,6 +804,7 @@ export async function streamMultiAgent(
|
||||
const toolCalls = chunkObj.tool_calls;
|
||||
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||
const content = chunkObj.content;
|
||||
const streamDelta = extractAiTextDelta(parsed);
|
||||
|
||||
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||||
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||||
@@ -843,7 +860,9 @@ export async function streamMultiAgent(
|
||||
setPhase("tool_calling", toolName, args, toolCallId);
|
||||
} else if (
|
||||
(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");
|
||||
}
|
||||
@@ -911,8 +930,9 @@ export async function streamMultiAgent(
|
||||
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
const text = inlineAiDelta(delta);
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
|
||||
if (text) req.onTextDelta?.(text, delta.id);
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
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 {
|
||||
getStepDisplay,
|
||||
@@ -178,17 +178,16 @@ function ToolStep({
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||
const [, toolName] = step.key.split(":");
|
||||
// 只让「自己生成/展示的文件」可点,与主聊天页一致。read_file 不可点 —— 否则智能体
|
||||
// 调用技能时读取的 SOUL.md、执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。
|
||||
// write_file / str_replace 还要过与文件卡同一道门槛(`languageOfStreamingArtifact`,
|
||||
// 只认 .md/.html/.mermaid 等可预览成品):席位写的过程文件(fetch.py / r1.txt)与
|
||||
// 畸形路径(mnt/user-data/outputs)只作步骤展示、点了也不会打开沙箱。
|
||||
// present_files 由后端校验"outputs 下真实存在",保持可点。
|
||||
// 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行
|
||||
// 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。
|
||||
// read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。
|
||||
// str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。
|
||||
const stepPath = typeof step.args?.path === "string" ? step.args.path : "";
|
||||
const isArtifactStep =
|
||||
toolName === "present_files" ||
|
||||
((toolName === "write_file" || toolName === "str_replace") &&
|
||||
!!languageOfStreamingArtifact(stepPath));
|
||||
toolName === "write_file" ||
|
||||
(toolName === "str_replace" &&
|
||||
collectRoundtableDeliverablePaths([stepPath]).length > 0);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
|
||||
@@ -43,7 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true;
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
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 type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
|
||||
|
||||
@@ -1086,7 +1086,7 @@ export function Step2Message({
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||
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");
|
||||
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
|
||||
@@ -1193,23 +1193,25 @@ function RoundtableSubtaskCard({
|
||||
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
|
||||
* 成品文件就出可点的卡片"。
|
||||
*
|
||||
* write_file/str_replace 的 path 需过 `languageOfStreamingArtifact`(只认 .md/.html/
|
||||
* write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/
|
||||
* .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套):
|
||||
* 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。
|
||||
*/
|
||||
function collectPresentedFiles(
|
||||
steps: Step2Dialogue["steps"],
|
||||
settledArtifactPaths?: 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 seen = new Set<string>();
|
||||
const add = (fp: unknown, requirePreviewable = false) => {
|
||||
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
|
||||
if (requirePreviewable && !languageOfStreamingArtifact(fp)) return;
|
||||
if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return;
|
||||
seen.add(fp);
|
||||
out.push(fp);
|
||||
};
|
||||
for (const step of steps) {
|
||||
for (const step of toolSteps) {
|
||||
if (step.key === "tool_calling:present_files") {
|
||||
const fps = step.args?.filepaths;
|
||||
if (Array.isArray(fps)) fps.forEach((fp) => add(fp));
|
||||
@@ -1222,6 +1224,9 @@ function collectPresentedFiles(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,16 +53,22 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
|
||||
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
|
||||
const lastObservedScrollTopRef = useRef(0);
|
||||
// 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下
|
||||
// clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。
|
||||
const lastObservedScrollHeightRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
lastObservedScrollTopRef.current = prevScrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
const onScroll = () => {
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const currentScrollHeight = el.scrollHeight;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
const isAtBottom = distanceFromBottom <= threshold;
|
||||
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
|
||||
|
||||
const programmaticTarget = programmaticScrollTargetRef.current;
|
||||
if (
|
||||
@@ -73,6 +79,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
programmaticScrollTargetRef.current = null;
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
@@ -86,7 +93,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
|
||||
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
|
||||
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
} else if (isAtBottom && !atBottomRef.current) {
|
||||
@@ -99,6 +106,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// 初次测量
|
||||
@@ -116,21 +124,25 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const currentScrollHeight = el.scrollHeight;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
|
||||
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
|
||||
if (userScrolledUp && distanceFromBottom > threshold) {
|
||||
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
|
||||
if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) {
|
||||
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
return;
|
||||
}
|
||||
if (!atBottomRef.current) {
|
||||
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
return;
|
||||
}
|
||||
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
|
||||
@@ -140,6 +152,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
@@ -165,6 +178,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
observer.observe(content);
|
||||
@@ -185,6 +199,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}, [ref]);
|
||||
@@ -201,6 +216,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}, [ref]);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/workspace/artifacts";
|
||||
import {
|
||||
initMultiAgent,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type ModelSwitchInfo,
|
||||
type ClarificationPayload,
|
||||
@@ -32,7 +33,13 @@ import {
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
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";
|
||||
|
||||
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
|
||||
@@ -167,6 +174,8 @@ export type Step2Dialogue = {
|
||||
streaming?: boolean;
|
||||
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
|
||||
agentThreadId?: string;
|
||||
/** Stream settlement scans the real sandbox output directory for these cards. */
|
||||
artifactPaths?: string[];
|
||||
/**
|
||||
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
|
||||
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
|
||||
@@ -204,6 +213,8 @@ export interface ParallelSeatView {
|
||||
*/
|
||||
export interface StubToolCall {
|
||||
id: string;
|
||||
/** Original LangGraph id when a pre-id stream chunk needed a local fallback. */
|
||||
sourceToolCallId?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
@@ -227,25 +238,34 @@ export function completeStubWriteFile(
|
||||
messages: StubAiMessage[],
|
||||
toolCallId: string,
|
||||
): StubAiMessage[] {
|
||||
if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) {
|
||||
if (!toolCallId) {
|
||||
return messages;
|
||||
}
|
||||
const source = messages.find(
|
||||
(message) =>
|
||||
const source = messages.find((message) =>
|
||||
message.type === "ai" &&
|
||||
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 [
|
||||
...messages,
|
||||
{
|
||||
id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`,
|
||||
id: `roundtable-stub-tool-result-${source.id}-${matchedCall.id}`,
|
||||
type: "tool",
|
||||
content: "",
|
||||
tool_calls: [],
|
||||
tool_call_id: toolCallId,
|
||||
tool_call_id: matchedCall.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -591,6 +611,12 @@ export function useStep2Orchestration(
|
||||
}, [artifacts]);
|
||||
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
|
||||
@@ -855,6 +881,7 @@ export function useStep2Orchestration(
|
||||
seatThreadId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
sourceToolCallId?: string,
|
||||
): { messageId: string; toolCallId: string } => {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||
@@ -865,17 +892,25 @@ export function useStep2Orchestration(
|
||||
content: "",
|
||||
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.name === toolName &&
|
||||
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||
);
|
||||
const idx = bySourceId >= 0 ? bySourceId : byPath;
|
||||
const toolCallId =
|
||||
idx >= 0
|
||||
? baseMsg.tool_calls[idx].id
|
||||
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||
: sourceToolCallId || `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||
const nextToolCall: StubToolCall = {
|
||||
id: toolCallId,
|
||||
sourceToolCallId:
|
||||
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
|
||||
name: toolName,
|
||||
args,
|
||||
};
|
||||
@@ -990,19 +1025,9 @@ export function useStep2Orchestration(
|
||||
[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)。
|
||||
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。
|
||||
* 从该 thread 的 stub messages 里倒序找第一个可预览的 write_file / str_replace。
|
||||
* 无产物返回 false(调用方据此关闭沙箱)。
|
||||
*/
|
||||
const openSeatLatestArtifact = useCallback(
|
||||
@@ -1012,15 +1037,13 @@ export function useStep2Orchestration(
|
||||
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
|
||||
const tc = stub.tool_calls[i];
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const path =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
const args =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
const path = resolveRoundtableStreamingWritePreview(tc.name, args);
|
||||
if (!path) continue;
|
||||
// 同 handleArtifactToolPhase 的门槛:跳过 .py/.txt 等过程文件与畸形路径,
|
||||
// 沙箱跟随只落在该席位最近的**成品**文件上。
|
||||
if (!languageOfStreamingArtifact(path)) continue;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId: stub.id, toolCallId: tc.id });
|
||||
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
|
||||
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
|
||||
@@ -1101,11 +1124,11 @@ export function useStep2Orchestration(
|
||||
// not found 的影响。
|
||||
const seatSource = findSeatArtifactSource(normalized);
|
||||
if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(
|
||||
normalized,
|
||||
seatSource.messageId,
|
||||
seatSource.toolCallId,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({
|
||||
path: normalized,
|
||||
messageId: seatSource.messageId,
|
||||
toolCallId: seatSource.toolCallId,
|
||||
});
|
||||
openSeatVirtualArtifact(
|
||||
seatSource.seatThreadId,
|
||||
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(
|
||||
(
|
||||
dialogue: Step2Dialogue,
|
||||
@@ -1159,16 +1272,15 @@ export function useStep2Orchestration(
|
||||
if (!seatThreadId) return;
|
||||
const [, toolName] = step.key.split(":");
|
||||
if (toolName === "write_file" || toolName === "str_replace") {
|
||||
const path = step.args?.path;
|
||||
if (typeof path !== "string" || !step.args) return;
|
||||
// 与 MessageStepsCard 的可点判定同一道门槛:过程文件/畸形路径点了不开沙箱。
|
||||
if (!languageOfStreamingArtifact(path)) return;
|
||||
if (!step.args) return;
|
||||
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
|
||||
if (!path) return;
|
||||
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||
seatThreadId,
|
||||
toolName,
|
||||
step.args,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||
return;
|
||||
}
|
||||
@@ -1198,32 +1310,22 @@ export function useStep2Orchestration(
|
||||
toolName: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
seatThreadId: string | undefined,
|
||||
sourceToolCallId?: string,
|
||||
) => {
|
||||
if (phase !== "tool_calling") return;
|
||||
if (!seatThreadId) return;
|
||||
const isArtifactTool =
|
||||
toolName === "write_file" || toolName === "str_replace";
|
||||
if (!isArtifactTool) return;
|
||||
const path = args?.path;
|
||||
if (typeof path !== "string" || !path || !args) {
|
||||
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;
|
||||
if (toolName !== "write_file" && toolName !== "str_replace") return;
|
||||
// `write_file` can stream its body before its path. It opens the sandbox
|
||||
// for every file type; only final message-list cards are filtered.
|
||||
const path = resolveRoundtableStreamingWritePreview(toolName, args);
|
||||
if (!path || !args) return;
|
||||
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||
seatThreadId,
|
||||
toolName as string,
|
||||
args,
|
||||
sourceToolCallId,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
|
||||
},
|
||||
[
|
||||
@@ -1239,7 +1341,9 @@ export function useStep2Orchestration(
|
||||
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||
* - 同一 messages 帧可先写文件、再读技能。阶段层只会把最后一个 read_skill 当作
|
||||
* active tool,因而这里必须补一次 write_file 自动打开;以 tool-call 虚拟 URL 去重,
|
||||
* 不会在后续内容增量或用户手动关闭后反复弹出。
|
||||
*/
|
||||
const handleUpstreamFrame = useCallback(
|
||||
(frame: unknown, seatThreadId: string | undefined) => {
|
||||
@@ -1265,14 +1369,79 @@ export function useStep2Orchestration(
|
||||
const tcArgs = tcObj.args;
|
||||
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||
const argsObj = tcArgs as Record<string, unknown>;
|
||||
const path = argsObj.path;
|
||||
if (typeof path !== "string" || !path) continue;
|
||||
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||
const sourceToolCallId = typeof tcObj.id === "string" ? tcObj.id : undefined;
|
||||
// Keep the content-first call in the stub as well. The virtual preview
|
||||
// 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 识别)──────────
|
||||
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
|
||||
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
|
||||
@@ -1773,8 +1942,12 @@ export function useStep2Orchestration(
|
||||
(id: number, finalContent: string, confidence?: string) => {
|
||||
// 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。
|
||||
// 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。
|
||||
completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]);
|
||||
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
|
||||
const threadId = dialogueThreadIdRef.current[id];
|
||||
completeSeatStubWriteFiles(threadId);
|
||||
attachSettledArtifactCards(id, threadId);
|
||||
// 完成帧可能与最后一批节流 token 落在同一任务:把尚未 flush 的增量合并进
|
||||
// 收尾内容,既防止旧增量晚到反写,也不能丢最后的思考/正文 token。
|
||||
const pendingDelta = directStreamDeltaRef.current.get(id) ?? "";
|
||||
directStreamDeltaRef.current.delete(id);
|
||||
if (
|
||||
directStreamDeltaRef.current.size === 0 &&
|
||||
@@ -1787,10 +1960,18 @@ export function useStep2Orchestration(
|
||||
let changed = false;
|
||||
const next = prev.map((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 =
|
||||
finalContent && finalContent.trim().length > 0
|
||||
streamed.trim() && streamedVisible === finalVisible
|
||||
? streamed
|
||||
: finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: d.content || "(无文本输出)";
|
||||
: streamed || "(无文本输出)";
|
||||
if (
|
||||
d.content === next &&
|
||||
d.confidence === (confidence ?? d.confidence) &&
|
||||
@@ -1809,7 +1990,7 @@ export function useStep2Orchestration(
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[completeSeatStubWriteFiles],
|
||||
[attachSettledArtifactCards, completeSeatStubWriteFiles],
|
||||
);
|
||||
|
||||
const highlightSpeaker = useCallback(
|
||||
@@ -2008,11 +2189,11 @@ export function useStep2Orchestration(
|
||||
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
|
||||
onTextDelta: (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);
|
||||
setDialogueConfidence(a.bubbleId, label);
|
||||
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, a.seatThreadId);
|
||||
@@ -2116,7 +2297,7 @@ export function useStep2Orchestration(
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) =>
|
||||
handleUpstreamFrame(frame, leaderThreadId),
|
||||
@@ -2399,7 +2580,7 @@ export function useStep2Orchestration(
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2478,11 +2659,11 @@ export function useStep2Orchestration(
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(subBubbleId, label);
|
||||
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -2590,7 +2771,7 @@ export function useStep2Orchestration(
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2774,7 +2955,7 @@ export function useStep2Orchestration(
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2897,11 +3078,11 @@ export function useStep2Orchestration(
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
|
||||
onTextDelta: (text) =>
|
||||
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -3109,11 +3290,11 @@ export function useStep2Orchestration(
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
|
||||
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -3656,6 +3837,8 @@ export function useStep2Orchestration(
|
||||
setArtifacts([]);
|
||||
resetSeatStubMessages();
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
artifactDirectorySnapshotsRef.current = new Map();
|
||||
autoOpenedDiscoveredArtifactsRef.current = new Set();
|
||||
pendingInjectPromptRef.current = null;
|
||||
dagPlanExecutedRef.current = false;
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user