feat(frontend): 支持 Roundtable 写文件流式预览与消息流聚合展示
功能说明:\n- 为写文件工具链增加流式预览状态模型(in-flight、chunk 归并、已完成)\n- 在消息面板中支持 write_file 流式 artifact 的动态插入/更新与完成态呈现\n- 优化 artifact 详情页的文件名与来源显示,兼容流式生成中文件路径\n- 在 Roundtable 工作流 step2 流程中注入与监听 write_file 事件,支持多阶段消息统一展示\n- 补充单测覆盖:streaming-write-preview、write-file-in-flight、streaming-artifacts 等关键路径
This commit is contained in:
1 parent
553f72604a
commit
f5bf7897b9
11 files changed
+254
-32
No files matched your search
@@ -433,6 +433,10 @@ export function ArtifactFileDetail({
|
|||||||
!useWriteFileStreaming &&
|
!useWriteFileStreaming &&
|
||||||
content === undefined &&
|
content === undefined &&
|
||||||
isArtifactContentLoading;
|
isArtifactContentLoading;
|
||||||
|
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||||
|
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||||
|
const streamingWriteContentPending =
|
||||||
|
useWriteFileStreaming && writeFileInFlight && !content;
|
||||||
const artifactContentUnavailable =
|
const artifactContentUnavailable =
|
||||||
isCodeFile &&
|
isCodeFile &&
|
||||||
!useWriteFileStreaming &&
|
!useWriteFileStreaming &&
|
||||||
@@ -1766,10 +1770,10 @@ export function ArtifactFileDetail({
|
|||||||
activeCursor={isApplyingEdit}
|
activeCursor={isApplyingEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artifactContentPending && viewMode === "code" && (
|
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||||
正在加载文档内容…
|
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{artifactContentUnavailable && viewMode === "code" && (
|
{artifactContentUnavailable && viewMode === "code" && (
|
||||||
@@ -1787,7 +1791,7 @@ export function ArtifactFileDetail({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||||
<div className="size-full">
|
<div className="size-full">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
hasCompleteArtifactExtension,
|
hasCompleteArtifactExtension,
|
||||||
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
provisionalWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
@@ -804,7 +805,8 @@ function ArtifactWriteToolCall({
|
|||||||
!isLoading ||
|
!isLoading ||
|
||||||
result !== undefined ||
|
result !== undefined ||
|
||||||
!sourcePath ||
|
!sourcePath ||
|
||||||
!languageOfStreamingArtifact(sourcePath)
|
!languageOfStreamingArtifact(sourcePath) ||
|
||||||
|
!isAutoPreviewableArtifactPath(sourcePath)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isAutoPreviewableArtifactPath,
|
||||||
isArtifactApiPath,
|
isArtifactApiPath,
|
||||||
isProvisionalStreamingArtifactPath,
|
isProvisionalStreamingArtifactPath,
|
||||||
provisionalWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
@@ -30,3 +31,10 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
|
|||||||
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
|
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
|
||||||
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
|
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
|
||||||
|
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
|
||||||
|
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
|
||||||
|
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
|
||||||
|
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
|
||||||
|
});
|
||||||
@@ -59,6 +59,23 @@ export function isArtifactApiPath(path: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a streamed write is a user-facing deliverable that may open the
|
||||||
|
* artifact panel automatically. Agents often use ``workspace/`` for helper
|
||||||
|
* scripts and intermediate data; previewing those files is noisy and can show
|
||||||
|
* an empty sandbox before the real output exists.
|
||||||
|
*/
|
||||||
|
export function isAutoPreviewableArtifactPath(path: string): boolean {
|
||||||
|
if (isProvisionalStreamingArtifactPath(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const normalized = normalizeStreamingArtifactPath(path)
|
||||||
|
.split(/[?#]/, 1)[0]
|
||||||
|
?.replace(/^\/+/, "")
|
||||||
|
.replace(/\\/g, "/");
|
||||||
|
return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/");
|
||||||
|
}
|
||||||
|
|
||||||
export function languageOfStreamingArtifact(
|
export function languageOfStreamingArtifact(
|
||||||
path: string,
|
path: string,
|
||||||
): StreamingArtifactLanguage | null {
|
): StreamingArtifactLanguage | null {
|
||||||
|
|||||||
@@ -208,6 +208,34 @@ test("normal mode keeps previewing pathless streaming drafts", () => {
|
|||||||
assert.match(preview?.artifactPath ?? "", /\.md/);
|
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
|
||||||
|
const preview = latestInFlightWriteFilePreview([
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "w1",
|
||||||
|
name: "write_file",
|
||||||
|
args: {
|
||||||
|
path: "/mnt/user-data/workspace/scan_structure.py",
|
||||||
|
content: "print('scan')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "w2",
|
||||||
|
name: "write_file",
|
||||||
|
args: {
|
||||||
|
path: "/mnt/user-data/workspace/draft.html",
|
||||||
|
content: "<!doctype html><html></html>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(preview, null);
|
||||||
|
});
|
||||||
|
|
||||||
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||||
const preview = latestInFlightWriteFilePreview([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isImageFile } from "../utils/files";
|
|||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
hasCompleteArtifactExtension,
|
hasCompleteArtifactExtension,
|
||||||
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
provisionalWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
@@ -140,6 +141,9 @@ export function latestInFlightWriteFilePreview(
|
|||||||
if (!languageOfStreamingArtifact(sourcePath)) {
|
if (!languageOfStreamingArtifact(sourcePath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isAutoPreviewableArtifactPath(sourcePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
artifactPath: buildWriteFileVirtualUrl({
|
artifactPath: buildWriteFileVirtualUrl({
|
||||||
path: sourcePath,
|
path: sourcePath,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
@@ -92,6 +93,8 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
|
|
||||||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||||||
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||||||
|
/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */
|
||||||
|
const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
|
||||||
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
||||||
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
||||||
|
|
||||||
@@ -226,6 +229,7 @@ export default function AgentChatPage({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { baseMode } = useTheme();
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
|
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
|
||||||
@@ -677,13 +681,18 @@ export default function AgentChatPage({
|
|||||||
settings.context.writing_mode && writingArtifactPath
|
settings.context.writing_mode && writingArtifactPath
|
||||||
? { writing_artifact_path: writingArtifactPath }
|
? { writing_artifact_path: writingArtifactPath }
|
||||||
: {};
|
: {};
|
||||||
|
const pageReportThemeContext =
|
||||||
|
agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
|
||||||
|
? { presentation_theme: baseMode }
|
||||||
|
: {};
|
||||||
void sendMessage(threadId, message, {
|
void sendMessage(threadId, message, {
|
||||||
...writingExtraContext,
|
...writingExtraContext,
|
||||||
...(extraContext ?? {}),
|
...(extraContext ?? {}),
|
||||||
agent_id,
|
agent_id,
|
||||||
|
...pageReportThemeContext,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath],
|
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||||
@@ -757,9 +766,14 @@ export default function AgentChatPage({
|
|||||||
async (answer: string) => {
|
async (answer: string) => {
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||||
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
await sendMessage(threadId, { text: answer, files: [] }, {
|
||||||
|
agent_id,
|
||||||
|
...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
|
||||||
|
? { presentation_theme: baseMode }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[sendMessage, thread, threadId, agent_id],
|
[sendMessage, thread, threadId, agent_id, baseMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
|||||||
@@ -809,8 +809,20 @@ export async function streamMultiAgent(
|
|||||||
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||||||
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||||||
ingestToolCallChunks(toolCallChunks);
|
ingestToolCallChunks(toolCallChunks);
|
||||||
|
// Some providers serialize `tool_calls` before their `args` have
|
||||||
|
// been decoded, leaving args as the raw JSON string. Treating that
|
||||||
|
// as a complete call suppresses the accumulated tool_call_chunks,
|
||||||
|
// so write_file reaches the sandbox with a path but no content.
|
||||||
const hasCompleteToolCalls =
|
const hasCompleteToolCalls =
|
||||||
Array.isArray(toolCalls) && toolCalls.length > 0;
|
Array.isArray(toolCalls) &&
|
||||||
|
toolCalls.length > 0 &&
|
||||||
|
toolCalls.every(
|
||||||
|
(toolCall) =>
|
||||||
|
!!toolCall &&
|
||||||
|
typeof toolCall === "object" &&
|
||||||
|
!!(toolCall as Record<string, unknown>).args &&
|
||||||
|
typeof (toolCall as Record<string, unknown>).args === "object",
|
||||||
|
);
|
||||||
let synthetic: SyntheticToolCall[] = [];
|
let synthetic: SyntheticToolCall[] = [];
|
||||||
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
||||||
synthetic = buildSyntheticToolCalls();
|
synthetic = buildSyntheticToolCalls();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { uploadFiles } from "@/core/uploads/api";
|
|||||||
import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
|
import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
|
||||||
import {
|
import {
|
||||||
collectRoundtableDeliverablePaths,
|
collectRoundtableDeliverablePaths,
|
||||||
|
mergeRoundtableStreamingToolArgs,
|
||||||
newestChangedRoundtableArtifact,
|
newestChangedRoundtableArtifact,
|
||||||
resolveRoundtableStreamingWritePreview,
|
resolveRoundtableStreamingWritePreview,
|
||||||
roundtableArtifactDiskSnapshot,
|
roundtableArtifactDiskSnapshot,
|
||||||
@@ -48,15 +49,21 @@ type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
|||||||
type SeatDelivery = { name: string; content: string };
|
type SeatDelivery = { name: string; content: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活,
|
* `agent_orchestration` / 写文件工具可在一轮内重复调用。用 tool call id 区分每一笔
|
||||||
* 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。
|
* 派活或写入,避免用户点击较早的步骤时误打开后来同名调用的文件。
|
||||||
*/
|
*/
|
||||||
function streamStepKey(
|
function streamStepKey(
|
||||||
phase: StreamPhase,
|
phase: StreamPhase,
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
toolCallId?: string,
|
toolCallId?: string,
|
||||||
): string {
|
): string {
|
||||||
if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) {
|
if (
|
||||||
|
phase === "tool_calling" &&
|
||||||
|
(toolName === "agent_orchestration" ||
|
||||||
|
toolName === "write_file" ||
|
||||||
|
toolName === "str_replace") &&
|
||||||
|
toolCallId
|
||||||
|
) {
|
||||||
return `${phase}:${toolName}:${toolCallId}`;
|
return `${phase}:${toolName}:${toolCallId}`;
|
||||||
}
|
}
|
||||||
return `${phase}:${toolName ?? ""}`;
|
return `${phase}:${toolName ?? ""}`;
|
||||||
@@ -912,7 +919,12 @@ export function useStep2Orchestration(
|
|||||||
sourceToolCallId:
|
sourceToolCallId:
|
||||||
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
|
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
|
||||||
name: toolName,
|
name: toolName,
|
||||||
args,
|
// 进度步骤只保存可见参数(通常只有 path),不能让一次手动点击把先前
|
||||||
|
// 流进来的 content 覆盖掉;同理也要保住 content-first 帧随后补齐的 path。
|
||||||
|
args:
|
||||||
|
idx >= 0
|
||||||
|
? mergeRoundtableStreamingToolArgs(baseMsg.tool_calls[idx].args, args)
|
||||||
|
: { ...args },
|
||||||
};
|
};
|
||||||
const nextToolCalls =
|
const nextToolCalls =
|
||||||
idx >= 0
|
idx >= 0
|
||||||
@@ -1067,12 +1079,16 @@ export function useStep2Orchestration(
|
|||||||
const findSeatArtifactSource = useCallback(
|
const findSeatArtifactSource = useCallback(
|
||||||
(
|
(
|
||||||
rawPath: string,
|
rawPath: string,
|
||||||
|
preferredToolCallId?: string,
|
||||||
): {
|
): {
|
||||||
seatThreadId: string;
|
seatThreadId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
/** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */
|
/** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */
|
||||||
content?: string;
|
content?: string;
|
||||||
|
isWriteFile: boolean;
|
||||||
|
/** 当前 write_file 尚未收到对应 tool result,真实文件可能还没有落盘。 */
|
||||||
|
inFlight: boolean;
|
||||||
} | null => {
|
} | null => {
|
||||||
const target = rawPath.trim();
|
const target = rawPath.trim();
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
@@ -1081,30 +1097,70 @@ export function useStep2Orchestration(
|
|||||||
const c = (tc.args as { content?: unknown })?.content;
|
const c = (tc.args as { content?: unknown })?.content;
|
||||||
return typeof c === "string" ? c : undefined;
|
return typeof c === "string" ? c : undefined;
|
||||||
};
|
};
|
||||||
|
let exactMatch:
|
||||||
|
| {
|
||||||
|
seatThreadId: string;
|
||||||
|
messageId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
content?: string;
|
||||||
|
isWriteFile: boolean;
|
||||||
|
inFlight: boolean;
|
||||||
|
}
|
||||||
|
| null = null;
|
||||||
let basenameMatch:
|
let basenameMatch:
|
||||||
| { seatThreadId: string; messageId: string; toolCallId: string; content?: string }
|
| {
|
||||||
|
seatThreadId: string;
|
||||||
|
messageId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
content?: string;
|
||||||
|
isWriteFile: boolean;
|
||||||
|
inFlight: boolean;
|
||||||
|
}
|
||||||
| null = null;
|
| null = null;
|
||||||
for (const [seatThreadId, msgs] of Object.entries(
|
for (const [seatThreadId, msgs] of Object.entries(
|
||||||
seatStubMessagesRef.current,
|
seatStubMessagesRef.current,
|
||||||
)) {
|
)) {
|
||||||
const msg = msgs?.[0];
|
const msg = msgs?.[0];
|
||||||
if (!msg) continue;
|
if (!msg) continue;
|
||||||
for (const tc of msg.tool_calls) {
|
// 同一路径可能在同一轮被重写;倒序保证文件卡/步骤优先打开最后一次写入。
|
||||||
|
for (let toolIndex = msg.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||||
|
const tc = msg.tool_calls[toolIndex];
|
||||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||||
const p =
|
const p =
|
||||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||||
? ((tc.args as { path?: string }).path as string)
|
? ((tc.args as { path?: string }).path as string)
|
||||||
: "";
|
: "";
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
|
const isWriteFile = tc.name === "write_file";
|
||||||
|
const inFlight =
|
||||||
|
isWriteFile &&
|
||||||
|
!msgs.some(
|
||||||
|
(message) => message.type === "tool" && message.tool_call_id === tc.id,
|
||||||
|
);
|
||||||
|
const source = {
|
||||||
|
seatThreadId,
|
||||||
|
messageId: msg.id,
|
||||||
|
toolCallId: tc.id,
|
||||||
|
content: readContent(tc),
|
||||||
|
isWriteFile,
|
||||||
|
inFlight,
|
||||||
|
};
|
||||||
if (p === target) {
|
if (p === target) {
|
||||||
return { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) };
|
if (
|
||||||
|
preferredToolCallId &&
|
||||||
|
(tc.id === preferredToolCallId || tc.sourceToolCallId === preferredToolCallId)
|
||||||
|
) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (!exactMatch) exactMatch = source;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
||||||
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) };
|
basenameMatch = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return basenameMatch;
|
return exactMatch ?? basenameMatch;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1119,11 +1175,15 @@ export function useStep2Orchestration(
|
|||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
|
|
||||||
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
|
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
|
||||||
// 先反查 stub:**仅当 stub 内存里确有内容**(流式期间 / 草稿恢复时捕获了 write_file content)
|
// 先反查 stub:write_file 尚未完成时即使正文还没有抵达,也必须留在虚拟预览。
|
||||||
// 才走虚拟预览——关闭沙箱后再点也能显示,且不受总控 coordinator thread 在 artifacts 路由
|
// 真实文件尚未写盘时错误走 HTTP 会造成“点文件 → 加载 → 找不到文件”;正文到达后
|
||||||
// not found 的影响。
|
// stub 的同一 tool call 会自动刷新预览。完成且无正文的旧草稿才降级走 HTTP。
|
||||||
const seatSource = findSeatArtifactSource(normalized);
|
const seatSource = findSeatArtifactSource(normalized);
|
||||||
if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
|
if (
|
||||||
|
seatSource &&
|
||||||
|
seatSource.isWriteFile &&
|
||||||
|
(seatSource.inFlight || typeof seatSource.content === "string")
|
||||||
|
) {
|
||||||
const virtualUrl = buildWriteFileVirtualUrl({
|
const virtualUrl = buildWriteFileVirtualUrl({
|
||||||
path: normalized,
|
path: normalized,
|
||||||
messageId: seatSource.messageId,
|
messageId: seatSource.messageId,
|
||||||
@@ -1270,18 +1330,41 @@ export function useStep2Orchestration(
|
|||||||
) => {
|
) => {
|
||||||
const seatThreadId = dialogue.agentThreadId;
|
const seatThreadId = dialogue.agentThreadId;
|
||||||
if (!seatThreadId) return;
|
if (!seatThreadId) return;
|
||||||
const [, toolName] = step.key.split(":");
|
const [, toolName, sourceToolCallId] = step.key.split(":");
|
||||||
if (toolName === "write_file" || toolName === "str_replace") {
|
if (toolName === "write_file") {
|
||||||
if (!step.args) return;
|
if (!step.args) return;
|
||||||
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
|
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const { messageId, toolCallId } = upsertSeatToolCall(
|
// 点击步骤优先复用已由 upstream frame 建好的同一条 stub,绝不再次用
|
||||||
|
// `step.args`(只有路径)写回它。否则会把正在接收的正文覆盖为空。
|
||||||
|
const source = findSeatArtifactSource(path, sourceToolCallId);
|
||||||
|
if (
|
||||||
|
source &&
|
||||||
|
source.isWriteFile &&
|
||||||
|
(source.inFlight || typeof source.content === "string")
|
||||||
|
) {
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl({
|
||||||
|
path,
|
||||||
|
messageId: source.messageId,
|
||||||
|
toolCallId: source.toolCallId,
|
||||||
|
});
|
||||||
|
openSeatVirtualArtifact(source.seatThreadId, virtualUrl, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 极早期还没拿到 upstream frame 的兜底:先建虚拟调用,后续帧会合并内容。
|
||||||
|
const { messageId, toolCallId } = upsertSeatToolCall(seatThreadId, toolName, step.args);
|
||||||
|
openSeatVirtualArtifact(
|
||||||
seatThreadId,
|
seatThreadId,
|
||||||
toolName,
|
buildWriteFileVirtualUrl({ path, messageId, toolCallId }),
|
||||||
step.args,
|
false,
|
||||||
);
|
);
|
||||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
|
return;
|
||||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
}
|
||||||
|
// str_replace 没有完整 content,不适合虚拟 write_file 预览;它的文件已存在,
|
||||||
|
// 直接走真实产物读取,避免打开一个永远为空的虚拟编辑器。
|
||||||
|
if (toolName === "str_replace") {
|
||||||
|
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
|
||||||
|
if (path) openArtifactPreview(seatThreadId, path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
|
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
|
||||||
@@ -1298,6 +1381,7 @@ export function useStep2Orchestration(
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
|
findSeatArtifactSource,
|
||||||
openArtifactPreview,
|
openArtifactPreview,
|
||||||
openSeatVirtualArtifact,
|
openSeatVirtualArtifact,
|
||||||
upsertSeatToolCall,
|
upsertSeatToolCall,
|
||||||
@@ -2192,7 +2276,7 @@ export function useStep2Orchestration(
|
|||||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
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, streamStepKey(phase, toolName, toolCallId), args);
|
||||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
|
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => {
|
onUpstreamFrame: (frame) => {
|
||||||
@@ -2662,7 +2746,7 @@ export function useStep2Orchestration(
|
|||||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
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, streamStepKey(phase, toolName, toolCallId), args);
|
||||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => {
|
onUpstreamFrame: (frame) => {
|
||||||
@@ -3081,7 +3165,7 @@ export function useStep2Orchestration(
|
|||||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
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, streamStepKey(phase, toolName, toolCallId), args);
|
||||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => {
|
onUpstreamFrame: (frame) => {
|
||||||
@@ -3293,7 +3377,7 @@ export function useStep2Orchestration(
|
|||||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
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, streamStepKey(phase, toolName, toolCallId), args);
|
||||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => {
|
onUpstreamFrame: (frame) => {
|
||||||
|
|||||||
@@ -3,11 +3,29 @@ import test from "node:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
collectRoundtableDeliverablePaths,
|
collectRoundtableDeliverablePaths,
|
||||||
|
mergeRoundtableStreamingToolArgs,
|
||||||
newestChangedRoundtableArtifact,
|
newestChangedRoundtableArtifact,
|
||||||
resolveRoundtableStreamingWritePreview,
|
resolveRoundtableStreamingWritePreview,
|
||||||
roundtableArtifactDiskSnapshot,
|
roundtableArtifactDiskSnapshot,
|
||||||
} from "./streaming-artifacts";
|
} from "./streaming-artifacts";
|
||||||
|
|
||||||
|
test("roundtable keeps streamed file bytes when a compact progress step only carries path", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeRoundtableStreamingToolArgs(
|
||||||
|
{ path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
|
||||||
|
{ path: "/mnt/user-data/outputs/report.md" },
|
||||||
|
),
|
||||||
|
{ path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeRoundtableStreamingToolArgs(
|
||||||
|
{ content: "# 内容先到" },
|
||||||
|
{ path: "/mnt/user-data/outputs/report.md", content: "" },
|
||||||
|
),
|
||||||
|
{ path: "/mnt/user-data/outputs/report.md", content: "# 内容先到" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
|
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
|
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ export function resolveRoundtableStreamingWritePreview(
|
|||||||
return languageOfStreamingArtifact(path) ? path : null;
|
return languageOfStreamingArtifact(path) ? path : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge successive snapshots of one streaming tool call without losing bytes
|
||||||
|
* that arrived in an earlier SSE frame.
|
||||||
|
*
|
||||||
|
* LangChain's partial JSON snapshots frequently contain only `path` while the
|
||||||
|
* complete `content` has already been received (and the inverse can happen
|
||||||
|
* while the path key is still incomplete). A click on a progress step also
|
||||||
|
* carries the deliberately compact `{ path }` payload. Blindly replacing the
|
||||||
|
* prior args with either shape empties the virtual write_file preview.
|
||||||
|
*/
|
||||||
|
export function mergeRoundtableStreamingToolArgs(
|
||||||
|
previous: Record<string, unknown> | undefined,
|
||||||
|
incoming: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!previous) return { ...incoming };
|
||||||
|
|
||||||
|
const merged = { ...previous, ...incoming };
|
||||||
|
for (const key of ["path", "content"] as const) {
|
||||||
|
const prior = previous[key];
|
||||||
|
const next = incoming[key];
|
||||||
|
if (
|
||||||
|
typeof prior === "string" &&
|
||||||
|
prior.length > 0 &&
|
||||||
|
!(typeof next === "string" && next.length > 0)
|
||||||
|
) {
|
||||||
|
merged[key] = prior;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/** Final artifact cards intentionally expose only user-facing deliverables. */
|
/** Final artifact cards intentionally expose only user-facing deliverables. */
|
||||||
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
|
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
Reference in new issue
Block a user