fix(会商第二步): 恢复总控派活实时展示并修正沙箱预览状态
- 流式解析改为跟踪当前 tool call id,连续多次 agent_orchestration 派活时前端即时展示最新席位和任务,不再等模型完整回复后一次性出现。 - 总控派活步骤按 tool call 独立归档并合并增量参数,避免同名工具调用互相覆盖或只显示半截任务。 - 沙箱自动打开改为按单个产物预览键管理:用户关闭当前文件后不反复弹出,但后续新文件仍可自动预览。 - 为圆桌虚拟 write_file 补齐工具完成结果,并在席位收尾和历史草稿恢复时兜底收口,防止完成文件长期显示“正在生成”。
This commit is contained in:
1 parent
74fc849241
commit
2254841822
3 files changed
+214
-58
No files matched your search
@@ -290,12 +290,14 @@ export interface StreamMultiAgentRequest {
|
|||||||
* show progressive status messages during the inevitable first-token wait.
|
* show progressive status messages during the inevitable first-token wait.
|
||||||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||||
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
||||||
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
|
* 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
|
||||||
|
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
|
||||||
*/
|
*/
|
||||||
onPhaseChange?: (
|
onPhaseChange?: (
|
||||||
phase: StreamPhase,
|
phase: StreamPhase,
|
||||||
name?: string,
|
name?: string,
|
||||||
args?: Record<string, unknown>,
|
args?: Record<string, unknown>,
|
||||||
|
toolCallId?: string,
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
/**
|
||||||
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
|
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
|
||||||
@@ -619,27 +621,32 @@ export async function streamMultiAgent(
|
|||||||
let currentPhase: StreamPhase = "connecting";
|
let currentPhase: StreamPhase = "connecting";
|
||||||
let currentPhaseName = "";
|
let currentPhaseName = "";
|
||||||
let currentPhaseArgsSignature = "";
|
let currentPhaseArgsSignature = "";
|
||||||
|
let currentPhaseToolCallId = "";
|
||||||
req.onPhaseChange?.("connecting");
|
req.onPhaseChange?.("connecting");
|
||||||
const setPhase = (
|
const setPhase = (
|
||||||
next: StreamPhase,
|
next: StreamPhase,
|
||||||
name?: string,
|
name?: string,
|
||||||
args?: Record<string, unknown>,
|
args?: Record<string, unknown>,
|
||||||
|
toolCallId?: string,
|
||||||
) => {
|
) => {
|
||||||
const nextName = name ?? "";
|
const nextName = name ?? "";
|
||||||
const nextArgsSignature = phaseArgsSignature(args);
|
const nextArgsSignature = phaseArgsSignature(args);
|
||||||
|
const nextToolCallId = toolCallId ?? "";
|
||||||
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
|
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
|
||||||
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
|
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
|
||||||
if (
|
if (
|
||||||
next === currentPhase &&
|
next === currentPhase &&
|
||||||
nextName === currentPhaseName &&
|
nextName === currentPhaseName &&
|
||||||
nextArgsSignature === currentPhaseArgsSignature
|
nextArgsSignature === currentPhaseArgsSignature &&
|
||||||
|
nextToolCallId === currentPhaseToolCallId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentPhase = next;
|
currentPhase = next;
|
||||||
currentPhaseName = nextName;
|
currentPhaseName = nextName;
|
||||||
currentPhaseArgsSignature = nextArgsSignature;
|
currentPhaseArgsSignature = nextArgsSignature;
|
||||||
req.onPhaseChange?.(next, name, args);
|
currentPhaseToolCallId = nextToolCallId;
|
||||||
|
req.onPhaseChange?.(next, name, args, toolCallId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -811,24 +818,29 @@ export async function streamMultiAgent(
|
|||||||
if (chunkType === "tool") {
|
if (chunkType === "tool") {
|
||||||
setPhase("tool_result");
|
setPhase("tool_result");
|
||||||
} else if (hasToolCall) {
|
} else if (hasToolCall) {
|
||||||
// Pick the most informative name + args we have so far.
|
// Pick the active (latest) call's name + args. A leader can dispatch
|
||||||
|
// several seats with the same `agent_orchestration` tool in one run;
|
||||||
|
// choosing the first accumulated call would leave the UI stuck on an
|
||||||
|
// earlier assignment until the final response arrives.
|
||||||
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
||||||
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
||||||
const firstCall =
|
const activeCall =
|
||||||
effectiveToolCalls && effectiveToolCalls.length > 0
|
effectiveToolCalls && effectiveToolCalls.length > 0
|
||||||
? (effectiveToolCalls[0] as Record<string, unknown>)
|
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
|
||||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||||
? (toolCallChunks[0] as Record<string, unknown>)
|
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const toolName =
|
const toolName =
|
||||||
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
|
||||||
const args =
|
const args =
|
||||||
firstCall &&
|
activeCall &&
|
||||||
typeof firstCall.args === "object" &&
|
typeof activeCall.args === "object" &&
|
||||||
firstCall.args !== null
|
activeCall.args !== null
|
||||||
? (firstCall.args as Record<string, unknown>)
|
? (activeCall.args as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
setPhase("tool_calling", toolName, args);
|
const toolCallId =
|
||||||
|
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
|
||||||
|
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))
|
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import {
|
||||||
|
artifactPreviewKey,
|
||||||
|
useArtifacts,
|
||||||
|
} from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
initMultiAgent,
|
initMultiAgent,
|
||||||
streamMultiAgent,
|
streamMultiAgent,
|
||||||
@@ -37,6 +40,21 @@ type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
|||||||
|
|
||||||
type SeatDelivery = { name: string; content: string };
|
type SeatDelivery = { name: string; content: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活,
|
||||||
|
* 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。
|
||||||
|
*/
|
||||||
|
function streamStepKey(
|
||||||
|
phase: StreamPhase,
|
||||||
|
toolName?: string,
|
||||||
|
toolCallId?: string,
|
||||||
|
): string {
|
||||||
|
if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) {
|
||||||
|
return `${phase}:${toolName}:${toolCallId}`;
|
||||||
|
}
|
||||||
|
return `${phase}:${toolName ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** In-memory cursor for a business-chain human-validation pause. */
|
/** In-memory cursor for a business-chain human-validation pause. */
|
||||||
type HumanValidationContinuation =
|
type HumanValidationContinuation =
|
||||||
| { mode: "chain"; nextSeatIndex: number }
|
| { mode: "chain"; nextSeatIndex: number }
|
||||||
@@ -179,7 +197,7 @@ export interface ParallelSeatView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext
|
* Stub thread message —— 用于把 special seat SSE 拿到的工具调用(及其合成 tool result)注入到 ThreadContext
|
||||||
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
|
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
|
||||||
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
|
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
|
||||||
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
|
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
|
||||||
@@ -191,9 +209,59 @@ export interface StubToolCall {
|
|||||||
}
|
}
|
||||||
export interface StubAiMessage {
|
export interface StubAiMessage {
|
||||||
id: string;
|
id: string;
|
||||||
type: "ai";
|
type: "ai" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
tool_calls: StubToolCall[];
|
tool_calls: StubToolCall[];
|
||||||
|
/** tool 类型消息对应的原始 tool call;供沙箱判定 write_file 已完成。 */
|
||||||
|
tool_call_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆桌流为了实时预览会合成一条带 write_file 的 ai 消息。主聊天真实流在工具执行结束后
|
||||||
|
* 还会收到对应的 tool 消息;圆桌 special 流此前漏了这一步,导致沙箱永久认为文件仍在写。
|
||||||
|
*
|
||||||
|
* 这里只补齐已经存在的 write_file 调用,绝不根据整条会商/其它并行流的 running 状态推断,
|
||||||
|
* 因而每个席位、报告、流程图和大屏预览的完成态彼此独立。
|
||||||
|
*/
|
||||||
|
export function completeStubWriteFile(
|
||||||
|
messages: StubAiMessage[],
|
||||||
|
toolCallId: string,
|
||||||
|
): StubAiMessage[] {
|
||||||
|
if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
const source = messages.find(
|
||||||
|
(message) =>
|
||||||
|
message.type === "ai" &&
|
||||||
|
message.tool_calls.some(
|
||||||
|
(toolCall) => toolCall.id === toolCallId && toolCall.name === "write_file",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!source) return messages;
|
||||||
|
return [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`,
|
||||||
|
type: "tool",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [],
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补齐一段已正常收尾的圆桌 stub 中全部 write_file 的工具返回。 */
|
||||||
|
export function completeAllStubWriteFiles(messages: StubAiMessage[]): StubAiMessage[] {
|
||||||
|
let next = messages;
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== "ai") continue;
|
||||||
|
for (const toolCall of message.tool_calls) {
|
||||||
|
if (toolCall.name === "write_file") {
|
||||||
|
next = completeStubWriteFile(next, toolCall.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
||||||
@@ -515,19 +583,12 @@ export function useStep2Orchestration(
|
|||||||
select: selectArtifact,
|
select: selectArtifact,
|
||||||
setOpen: setArtifactsOpen,
|
setOpen: setArtifactsOpen,
|
||||||
deselect: deselectArtifact,
|
deselect: deselectArtifact,
|
||||||
autoOpen,
|
openStreamedArtifact,
|
||||||
autoSelect,
|
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const artifactPathsRef = useRef<string[]>([]);
|
const artifactPathsRef = useRef<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
artifactPathsRef.current = artifacts ?? [];
|
artifactPathsRef.current = artifacts ?? [];
|
||||||
}, [artifacts]);
|
}, [artifacts]);
|
||||||
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
|
|
||||||
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
|
|
||||||
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
|
|
||||||
useEffect(() => {
|
|
||||||
autoOpenFlagsRef.current = { autoOpen, autoSelect };
|
|
||||||
}, [autoOpen, autoSelect]);
|
|
||||||
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
|
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
|
||||||
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
|
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -562,6 +623,9 @@ export function useStep2Orchestration(
|
|||||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
|
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
|
||||||
Step2Dialogue[]
|
Step2Dialogue[]
|
||||||
>([]);
|
>([]);
|
||||||
|
// 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file,
|
||||||
|
// 不会误影响仍在并行运行的其它席位。
|
||||||
|
const dialogueThreadIdRef = useRef<Record<number, string>>({});
|
||||||
const [pendingClarification, setPendingClarification] =
|
const [pendingClarification, setPendingClarification] =
|
||||||
useState<ClarificationPayload | null>(null);
|
useState<ClarificationPayload | null>(null);
|
||||||
const [clarificationDraft, setClarificationDraft] = useState<string>("");
|
const [clarificationDraft, setClarificationDraft] = useState<string>("");
|
||||||
@@ -837,13 +901,42 @@ export function useStep2Orchestration(
|
|||||||
[scheduleSeatStubMessagesFlush],
|
[scheduleSeatStubMessagesFlush],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 将某次真实 tool 返回同步到对应的虚拟 write_file 预览。 */
|
||||||
|
const completeSeatStubWriteFile = useCallback(
|
||||||
|
(seatThreadId: string, toolCallId: string) => {
|
||||||
|
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||||
|
const nextMessages = completeStubWriteFile(current, toolCallId);
|
||||||
|
if (nextMessages === current) return;
|
||||||
|
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
|
||||||
|
seatStubMessagesRef.current = nextMap;
|
||||||
|
pendingSeatStubMessagesRef.current = nextMap;
|
||||||
|
scheduleSeatStubMessagesFlush();
|
||||||
|
},
|
||||||
|
[scheduleSeatStubMessagesFlush],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** special 流正常收尾的兜底:补齐该席位尚未透传出来的 write_file tool 返回。 */
|
||||||
|
const completeSeatStubWriteFiles = useCallback(
|
||||||
|
(seatThreadId: string | undefined) => {
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||||
|
const nextMessages = completeAllStubWriteFiles(current);
|
||||||
|
if (nextMessages === current) return;
|
||||||
|
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
|
||||||
|
seatStubMessagesRef.current = nextMap;
|
||||||
|
pendingSeatStubMessagesRef.current = nextMap;
|
||||||
|
scheduleSeatStubMessagesFlush();
|
||||||
|
},
|
||||||
|
[scheduleSeatStubMessagesFlush],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
|
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
|
||||||
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
|
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
|
||||||
*
|
*
|
||||||
* auto=true 流式期间:
|
* auto=true 流式期间:
|
||||||
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
|
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
|
||||||
* - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。
|
* - 用户手动关闭过**当前文件**时不反复弹开;后续新的 write_file 调用仍允许自动预览。
|
||||||
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
|
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
|
||||||
*/
|
*/
|
||||||
const openSeatVirtualArtifact = useCallback(
|
const openSeatVirtualArtifact = useCallback(
|
||||||
@@ -861,8 +954,6 @@ export function useStep2Orchestration(
|
|||||||
const manual = opts?.manual ?? !auto;
|
const manual = opts?.manual ?? !auto;
|
||||||
if (auto) {
|
if (auto) {
|
||||||
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
|
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
|
||||||
const flags = autoOpenFlagsRef.current;
|
|
||||||
if (!flags.autoOpen || !flags.autoSelect) return;
|
|
||||||
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
|
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
|
||||||
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
|
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
|
||||||
if (
|
if (
|
||||||
@@ -882,6 +973,11 @@ export function useStep2Orchestration(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasAutoOpenedRef.current.add(virtualUrl);
|
hasAutoOpenedRef.current.add(virtualUrl);
|
||||||
|
setActiveArtifactThreadId(seatThreadId);
|
||||||
|
// 由 ArtifactsProvider 以 tool-call 维度管理「手动关闭后不再弹同一份文件」:
|
||||||
|
// 当前文件被关闭过 → false;下一次 write_file 的 previewKey 不同 → 仍会自动打开。
|
||||||
|
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setActiveArtifactThreadId(seatThreadId);
|
setActiveArtifactThreadId(seatThreadId);
|
||||||
const open = () => {
|
const open = () => {
|
||||||
@@ -889,13 +985,9 @@ export function useStep2Orchestration(
|
|||||||
selectArtifact(virtualUrl, !manual);
|
selectArtifact(virtualUrl, !manual);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
};
|
};
|
||||||
if (auto) {
|
open();
|
||||||
window.setTimeout(open, 100);
|
|
||||||
} else {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[selectArtifact, setArtifactsOpen],
|
[openStreamedArtifact, selectArtifact, setArtifactsOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildWriteFileVirtualUrl = useCallback(
|
const buildWriteFileVirtualUrl = useCallback(
|
||||||
@@ -1156,6 +1248,13 @@ export function useStep2Orchestration(
|
|||||||
const chunk = frame[0];
|
const chunk = frame[0];
|
||||||
if (!chunk || typeof chunk !== "object") return;
|
if (!chunk || typeof chunk !== "object") return;
|
||||||
const obj = chunk as Record<string, unknown>;
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
// special 流透传真实工具返回时,必须同步收口虚拟 write_file;否则右侧沙箱会把
|
||||||
|
// 已完成的文件永久当成「正在生成」。
|
||||||
|
if (String(obj.type ?? "").toLowerCase() === "tool") {
|
||||||
|
const toolCallId = typeof obj.tool_call_id === "string" ? obj.tool_call_id : "";
|
||||||
|
if (toolCallId) completeSeatStubWriteFile(seatThreadId, toolCallId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const toolCalls = obj.tool_calls;
|
const toolCalls = obj.tool_calls;
|
||||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||||
for (const tc of toolCalls) {
|
for (const tc of toolCalls) {
|
||||||
@@ -1171,7 +1270,7 @@ export function useStep2Orchestration(
|
|||||||
upsertSeatToolCall(seatThreadId, name, argsObj);
|
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[upsertSeatToolCall],
|
[completeSeatStubWriteFile, upsertSeatToolCall],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
|
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
|
||||||
@@ -1402,15 +1501,28 @@ export function useStep2Orchestration(
|
|||||||
let nextDialogue: Step2Dialogue;
|
let nextDialogue: Step2Dialogue;
|
||||||
|
|
||||||
if (last && last.key === key) {
|
if (last && last.key === key) {
|
||||||
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
|
// 总控派活的 task 是 tool_call JSON 的流式增量。不能沿用普通工具的
|
||||||
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
|
// 「首次可见参数即停」策略,否则只显示 agent_name,整段任务会在收尾时
|
||||||
// content chunk 更新对话 state。
|
// 才被最终消息一次性补上。
|
||||||
const hasVisibleArgs = (value?: Record<string, unknown>) =>
|
if (key.startsWith("tool_calling:agent_orchestration:") && args) {
|
||||||
!!value && Object.keys(value).some((argKey) => argKey !== "content");
|
const mergedArgs = { ...(last.args ?? {}), ...args };
|
||||||
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
|
if (JSON.stringify(last.args ?? {}) === JSON.stringify(mergedArgs)) {
|
||||||
const nextSteps = [...steps];
|
return prev;
|
||||||
nextSteps[nextSteps.length - 1] = { ...last, args };
|
}
|
||||||
nextDialogue = { ...dialogue, steps: nextSteps };
|
const nextSteps = [...steps];
|
||||||
|
nextSteps[nextSteps.length - 1] = { ...last, args: mergedArgs };
|
||||||
|
nextDialogue = { ...dialogue, steps: nextSteps };
|
||||||
|
} else {
|
||||||
|
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
|
||||||
|
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
|
||||||
|
// content chunk 更新对话 state。
|
||||||
|
const hasVisibleArgs = (value?: Record<string, unknown>) =>
|
||||||
|
!!value && Object.keys(value).some((argKey) => argKey !== "content");
|
||||||
|
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
|
||||||
|
const nextSteps = [...steps];
|
||||||
|
nextSteps[nextSteps.length - 1] = { ...last, args };
|
||||||
|
nextDialogue = { ...dialogue, steps: nextSteps };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nextDialogue = {
|
nextDialogue = {
|
||||||
...dialogue,
|
...dialogue,
|
||||||
@@ -1438,6 +1550,9 @@ export function useStep2Orchestration(
|
|||||||
agentThreadId?: string;
|
agentThreadId?: string;
|
||||||
}): number => {
|
}): number => {
|
||||||
const id = Date.now() + Math.random();
|
const id = Date.now() + Math.random();
|
||||||
|
if (card.agentThreadId) {
|
||||||
|
dialogueThreadIdRef.current[id] = card.agentThreadId;
|
||||||
|
}
|
||||||
setStep2RoundtableDialogues((prev) => [
|
setStep2RoundtableDialogues((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -1641,10 +1756,8 @@ export function useStep2Orchestration(
|
|||||||
const opened = openSeatLatestArtifact(tid);
|
const opened = openSeatLatestArtifact(tid);
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
|
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
|
||||||
// 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen);
|
// 用 deselect 收起并清空旧预览,不把别的席位当前文件标记为「手动关闭」。这样该席位
|
||||||
// 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false
|
// 随后的新 write_file 仍能按自己的 previewKey 自动打开。
|
||||||
// (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的
|
|
||||||
// `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。
|
|
||||||
setActiveArtifactThreadId(null);
|
setActiveArtifactThreadId(null);
|
||||||
deselectArtifact();
|
deselectArtifact();
|
||||||
}
|
}
|
||||||
@@ -1658,6 +1771,9 @@ export function useStep2Orchestration(
|
|||||||
|
|
||||||
const finalizeDialogue = useCallback(
|
const finalizeDialogue = useCallback(
|
||||||
(id: number, finalContent: string, confidence?: string) => {
|
(id: number, finalContent: string, confidence?: string) => {
|
||||||
|
// 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。
|
||||||
|
// 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。
|
||||||
|
completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]);
|
||||||
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
|
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
|
||||||
directStreamDeltaRef.current.delete(id);
|
directStreamDeltaRef.current.delete(id);
|
||||||
if (
|
if (
|
||||||
@@ -1693,7 +1809,7 @@ export function useStep2Orchestration(
|
|||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[completeSeatStubWriteFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const highlightSpeaker = useCallback(
|
const highlightSpeaker = useCallback(
|
||||||
@@ -1991,13 +2107,13 @@ export function useStep2Orchestration(
|
|||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
onPhaseChange: (phase, toolName, args) => {
|
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||||
const label = phaseLabel(phase, "leader", toolName);
|
const label = phaseLabel(phase, "leader", toolName);
|
||||||
setDialogueConfidence(leaderBubbleId, label);
|
setDialogueConfidence(leaderBubbleId, label);
|
||||||
appendStepIfNew(
|
appendStepIfNew(
|
||||||
leaderBubbleId,
|
leaderBubbleId,
|
||||||
label,
|
label,
|
||||||
`${phase}:${toolName ?? ""}`,
|
streamStepKey(phase, toolName, toolCallId),
|
||||||
args,
|
args,
|
||||||
);
|
);
|
||||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||||
@@ -2274,10 +2390,15 @@ export function useStep2Orchestration(
|
|||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
onPhaseChange: (phase, toolName, args) => {
|
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||||
const label = phaseLabel(phase, "leader", toolName);
|
const label = phaseLabel(phase, "leader", toolName);
|
||||||
setDialogueConfidence(leaderBubbleId, label);
|
setDialogueConfidence(leaderBubbleId, label);
|
||||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
appendStepIfNew(
|
||||||
|
leaderBubbleId,
|
||||||
|
label,
|
||||||
|
streamStepKey(phase, toolName, toolCallId),
|
||||||
|
args,
|
||||||
|
);
|
||||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||||
@@ -2460,10 +2581,15 @@ export function useStep2Orchestration(
|
|||||||
signal: closeoutAborter.signal,
|
signal: closeoutAborter.signal,
|
||||||
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
|
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
|
||||||
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
|
||||||
onPhaseChange: (phase, toolName, args) => {
|
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||||
const label = phaseLabel(phase, "leader", toolName);
|
const label = phaseLabel(phase, "leader", toolName);
|
||||||
setDialogueConfidence(closeoutBubbleId, label);
|
setDialogueConfidence(closeoutBubbleId, label);
|
||||||
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
appendStepIfNew(
|
||||||
|
closeoutBubbleId,
|
||||||
|
label,
|
||||||
|
streamStepKey(phase, toolName, toolCallId),
|
||||||
|
args,
|
||||||
|
);
|
||||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||||
@@ -2639,10 +2765,15 @@ export function useStep2Orchestration(
|
|||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
onPhaseChange: (phase, toolName, args) => {
|
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||||
const label = phaseLabel(phase, "leader", toolName);
|
const label = phaseLabel(phase, "leader", toolName);
|
||||||
setDialogueConfidence(leaderBubbleId, label);
|
setDialogueConfidence(leaderBubbleId, label);
|
||||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
appendStepIfNew(
|
||||||
|
leaderBubbleId,
|
||||||
|
label,
|
||||||
|
streamStepKey(phase, toolName, toolCallId),
|
||||||
|
args,
|
||||||
|
);
|
||||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||||
},
|
},
|
||||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||||
@@ -3431,8 +3562,20 @@ export function useStep2Orchestration(
|
|||||||
setHasConsensus(snapshot.hasConsensus);
|
setHasConsensus(snapshot.hasConsensus);
|
||||||
setConsensusPercentage(snapshot.consensusPercentage);
|
setConsensusPercentage(snapshot.consensusPercentage);
|
||||||
setBudgetLimit(snapshot.budgetLimit);
|
setBudgetLimit(snapshot.budgetLimit);
|
||||||
const restoredStubs = snapshot.seatStubMessages ?? {};
|
// 历史草稿是静态完成记录;旧版本未持久化 tool result 的 write_file 也必须收口,
|
||||||
|
// 否则从「最终方案展示」回看时会错误显示仍在生成。
|
||||||
|
const restoredStubs = Object.fromEntries(
|
||||||
|
Object.entries(snapshot.seatStubMessages ?? {}).map(([threadId, messages]) => [
|
||||||
|
threadId,
|
||||||
|
completeAllStubWriteFiles(messages),
|
||||||
|
]),
|
||||||
|
);
|
||||||
replaceSeatStubMessages(restoredStubs);
|
replaceSeatStubMessages(restoredStubs);
|
||||||
|
dialogueThreadIdRef.current = Object.fromEntries(
|
||||||
|
(snapshot.step2RoundtableDialogues ?? [])
|
||||||
|
.filter((dialogue) => Boolean(dialogue.agentThreadId))
|
||||||
|
.map((dialogue) => [dialogue.id, dialogue.agentThreadId!]),
|
||||||
|
);
|
||||||
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
|
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
|
||||||
hasAutoOpenedRef.current = new Set();
|
hasAutoOpenedRef.current = new Set();
|
||||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||||
@@ -3499,6 +3642,7 @@ export function useStep2Orchestration(
|
|||||||
setCoordinatorName(null);
|
setCoordinatorName(null);
|
||||||
setInitError(null);
|
setInitError(null);
|
||||||
setStep2RoundtableDialogues([]);
|
setStep2RoundtableDialogues([]);
|
||||||
|
dialogueThreadIdRef.current = {};
|
||||||
setLastLeaderContent("");
|
setLastLeaderContent("");
|
||||||
setHasConsensus(false);
|
setHasConsensus(false);
|
||||||
setConsensusPercentage(0);
|
setConsensusPercentage(0);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
||||||
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
|
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。
|
||||||
*
|
*
|
||||||
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
|
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
|
||||||
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
|
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
|
||||||
|
|||||||
Reference in new issue
Block a user