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
+600
-106
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) => toolCall.id === toolCallId && toolCall.name === "write_file",
|
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
|
||||||
? finalContent
|
? streamed
|
||||||
: d.content || "(无文本输出)";
|
: finalContent && finalContent.trim().length > 0
|
||||||
|
? finalContent
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user