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

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

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

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

No files matched your search

@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { 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;
}