diff --git a/frontend-web/src/maf-roundtable/__tests__/maf-roundtable.test.ts b/frontend-web/src/maf-roundtable/__tests__/maf-roundtable.test.ts index 1e1e541c..dc26490d 100644 --- a/frontend-web/src/maf-roundtable/__tests__/maf-roundtable.test.ts +++ b/frontend-web/src/maf-roundtable/__tests__/maf-roundtable.test.ts @@ -1,27 +1,58 @@ /** - * MAF 会商前端回归测试。 + * MAF 会商前端回归测试(阶段 6 重做版)。 * - * 运行(当前前端未引入 Vitest): - * pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts --bundle --platform=node --format=esm --outfile=.maf-roundtable.test.mjs --banner:js="import.meta.env={};" - * node --test .maf-roundtable.test.mjs + * 核心原则:**测试只 import 真实纯函数**,绝不在测试文件里复制组件/逻辑。 + * 这样组件实现坏掉时测试会同步失败,避免「实现坏、测试仍通过」的假绿灯。 * - * 覆盖: - * 1. 经理与席位重复 → participants 构建时自动排除 - * 2. 事件翻页 → 超过 PAGE_LIMIT 时 mergeBySeq 不丢不重 - * 3. 终态历史 → 终态 run 的事件仍被折叠为时间线 - * 4. 延迟出现的审批卡 → pending 审批在 actions 到达后正确关联 - * 5. 风险等级映射 → R0–R4 正确映射 + * 运行(前端未引入 Vitest,沿用 esbuild + node --test): + * pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts \ + * --bundle --platform=node --format=esm \ + * --outfile=%TEMP%\maf-roundtable.test.mjs \ + * --banner:js="import.meta.env={};" + * node --test %TEMP%\maf-roundtable.test.mjs + * + * 覆盖(按阶段 6 验收清单): + * 1. 新建弹窗显示服务端默认值 → createInitialSessionForm / defaultsSummaryLine + * 2. 经理切换时席位被真实移除 → filterSeatsForManager / toggleSeat + * 3. maxRounds=0/65/小数 无法提交 → validateMaxRounds / buildSessionOverrides + * 4. pending 审批双击只发一次决策 → isDecisionButtonDisabled(守卫单一事实源) + * 5. 有 pending approval 时不可恢复 → canManualResume + * 6. 审批清零且仍 waiting 才允许恢复 → canManualResume + * 7. missing_secret health 提示判定 → (MafHealthGate 已上线,这里覆盖风险/动作分类) + * 8. 切换 run 时旧请求不污染新 run → createMafEventRequestCoordinator + * 9. 终态 run 能补齐历史事件 → runIsLive + foldEventsToTimeline + * 10. executed/failed/unknown/expired 动作分类 → classifyActionsByStatus + * 11. 风险等级映射 R0–R4 → riskThemeFor(单一事实源) + * 12. MAF 目录没有旧会商 import → 边界扫描(fs 读 MAF 目录断言无 roundtable 依赖) + * 13. 事件翻页 mergeBySeq 不丢不重 → mergeBySeq(真实导出) + * 14. buildMafFlowGraph 纯函数 → flow-json 解析 + 事实图构造 */ import assert from "node:assert/strict"; import { describe, test } from "node:test"; +import { readdirSync, readFileSync, statSync, existsSync, type Dirent } from "node:fs"; +import { extname, join, relative } from "node:path"; -// @ts-ignore Node's test runner resolves .ts directly. -import { foldEventsToTimeline, type TimelineEntry } from "../lib/event-timeline.ts"; +// @ts-ignore esbuild 解析 .ts +import { foldEventsToTimeline } from "../lib/event-timeline.ts"; // @ts-ignore import { runIsLive, type MafEvent } from "../api/types.ts"; -// @ts-ignore Node's test runner resolves .ts directly. -import { createMafEventRequestCoordinator } from "../hooks/use-maf-events.ts"; +// @ts-ignore +import { createMafEventRequestCoordinator, mergeBySeq } from "../hooks/use-maf-events.ts"; +// @ts-ignore +import { riskThemeFor, riskLabelFor } from "../lib/risk-theme.ts"; +// @ts-ignore +import { buildSessionOverrides, createInitialSessionForm, defaultsSummaryLine, filterSeatsForManager, toggleSeat, validateMaxRounds } from "../lib/session-form.ts"; +// @ts-ignore +import { canManualResume } from "../lib/run-resume.ts"; +// @ts-ignore +import { classifyActionsByStatus, classifyActionStatus } from "../lib/action-audit.ts"; +// @ts-ignore +import { isDecisionButtonDisabled } from "../lib/approval-state.ts"; +// @ts-ignore +import { buildMafFlowGraph, extractMafFlowGraph } from "../lib/maf-flow-graph.ts"; +// @ts-ignore +import { safeDisplayJson } from "../lib/safe-display.ts"; // ── helpers ───────────────────────────────────────────────────────────────── @@ -39,243 +70,306 @@ function makeEvent(overrides: Partial & { seq: number }): MafEvent { }; } -/** 模拟 mergeBySeq(与 hook 内部逻辑一致)。 */ -function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] { - if (incoming.length === 0) return prev; - const bySeq = new Map(); - for (const e of prev) bySeq.set(e.seq, e); - for (const e of incoming) bySeq.set(e.seq, e); - return [...bySeq.values()].sort((a, b) => a.seq - b.seq); -} +// ── 1. 新建弹窗显示服务端默认值 ───────────────────────────────────────────── -// ── 1. 经理与席位重复 ────────────────────────────────────────────────────── +describe("createInitialSessionForm:弹窗默认值来自 health", () => { + test("health 提供 default_mode/policy/rounds 时全部透传到表单", () => { + const form = createInitialSessionForm({ + default_mode: "group_chat", + default_write_policy: "confirm_all_writes", + default_max_rounds: 16, + }); + assert.equal(form.mode, "group_chat"); + assert.equal(form.writePolicy, "confirm_all_writes"); + assert.equal(form.maxRounds, "16"); + // 其它字段干净 + assert.equal(form.title, ""); + assert.equal(form.taskText, ""); + assert.deepEqual(form.seatAgentIds, []); + }); -describe("participants 构建:经理与席位互斥", () => { - test("切换经理后,席位列表自动排除该 agent", () => { - // 模拟 UI 逻辑:managerAgentId 变化时 filter - const seatAgentIds = ["a1", "a2", "a3"]; - const managerAgentId = "a2"; - const filtered = seatAgentIds.filter((id) => id !== managerAgentId); - assert.deepEqual(filtered, ["a1", "a3"]); - assert.ok(!filtered.includes(managerAgentId)); + test("health 为空时兜底 mode=structured,其余留空", () => { + const form = createInitialSessionForm(null); + assert.equal(form.mode, "structured"); + assert.equal(form.writePolicy, ""); + assert.equal(form.maxRounds, ""); + }); + + test("defaultsSummaryLine 只来自 health,不硬编码", () => { + const line = defaultsSummaryLine({ + default_mode: "group_chat", + default_write_policy: "auto", + default_max_rounds: 12, + }); + assert.ok(line.includes("group_chat")); + assert.ok(line.includes("auto")); + assert.ok(line.includes("12")); + // 空健康值走兜底占位 + const fallback = defaultsSummaryLine(null); + assert.ok(fallback.includes("structured")); + assert.ok(fallback.includes("confirm_high_risk")); + }); +}); + +// ── 2. 经理切换时席位真实移除 ────────────────────────────────────────────── + +describe("经理与席位互斥(真实纯函数)", () => { + test("filterSeatsForManager 移除当前经理", () => { + assert.deepEqual(filterSeatsForManager(["a1", "a2", "a3"], "a2"), ["a1", "a3"]); + assert.deepEqual(filterSeatsForManager(["a1", "a2"], "aX"), ["a1", "a2"]); + // 无经理 → 原样返回 + assert.deepEqual(filterSeatsForManager(["a1", "a2"], ""), ["a1", "a2"]); }); test("toggleSeat 拒绝添加当前经理", () => { - const managerAgentId = "a1"; - const seatAgentIds = ["a2", "a3"]; - // 模拟 toggleSeat 逻辑 - const agentId = "a1"; - const result = agentId === managerAgentId - ? seatAgentIds // 不变 - : seatAgentIds.includes(agentId) - ? seatAgentIds.filter((id) => id !== agentId) - : [...seatAgentIds, agentId]; - assert.deepEqual(result, ["a2", "a3"]); - assert.ok(!result.includes("a1")); + const seats = ["a2", "a3"]; + // a1 是经理 → toggle a1 不变 + assert.deepEqual(toggleSeat(seats, "a1", "a1"), seats); + // toggle 非经理 → 切换 + assert.deepEqual(toggleSeat(["a2", "a3"], "a4", "a1"), ["a2", "a3", "a4"]); + assert.deepEqual(toggleSeat(["a2", "a3", "a4"], "a4", "a1"), ["a2", "a3"]); }); }); -// ── 2. 事件翻页(超过 PAGE_LIMIT 不丢不重)───────────────────────────────── +// ── 3. maxRounds 校验 + 提交时拦截 ────────────────────────────────────────── -describe("事件翻页 mergeBySeq", () => { - test("两页各 1000 条,合并后 2000 条无重复", () => { - const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 })); - const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 })); - - let merged: MafEvent[] = []; - merged = mergeBySeq(merged, page1); - assert.equal(merged.length, 1000); - merged = mergeBySeq(merged, page2); - assert.equal(merged.length, 2000); - // 验证升序 - for (let i = 1; i < merged.length; i++) { - assert.ok(merged[i].seq > merged[i - 1].seq, `seq not ascending at ${i}`); - } +describe("maxRounds 校验(真实 validateMaxRounds)", () => { + test("空串有效(= 使用默认)", () => { + assert.ok(validateMaxRounds("").valid); + assert.ok(validateMaxRounds(" ").valid); }); - - test("重叠页(网络重传)不产生重复", () => { - const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 })); - // 第二页重叠了 seq 91-100,并新增 101-120 - const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 })); - - let merged: MafEvent[] = []; - merged = mergeBySeq(merged, page1); - merged = mergeBySeq(merged, page2); - assert.equal(merged.length, 120); + test("1–64 整数有效", () => { + assert.ok(validateMaxRounds("1").valid); + assert.ok(validateMaxRounds("64").valid); }); - - test("游标只推进到实际收到的最大 seq", () => { - // 模拟 fetchBacklog 的游标推进逻辑 - const page = [makeEvent({ seq: 5 }), makeEvent({ seq: 3 }), makeEvent({ seq: 7 })]; - let cursor = 0; - let maxSeq = cursor; - for (const e of page) { - if (e.seq > maxSeq) maxSeq = e.seq; - } - assert.equal(maxSeq, 7); - // 不是 latest_seq(可能是 100),而是实际最大 seq - assert.ok(maxSeq <= 7); + test("0 / 65 / 负数 / 小数 / 非数字 无效", () => { + assert.ok(!validateMaxRounds("0").valid); + assert.ok(!validateMaxRounds("65").valid); + assert.ok(!validateMaxRounds("-1").valid); + assert.ok(!validateMaxRounds("3.5").valid); + assert.ok(!validateMaxRounds("abc").valid); }); }); -// ── 3. 终态历史(终态 run 的事件仍被折叠)────────────────────────────────── - -describe("终态 run 事件折叠", () => { - test("completed run 的事件正常折叠为时间线", () => { - const events: MafEvent[] = [ - makeEvent({ seq: 1, event_type: "run.started", payload: {} }), - makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }), - makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }), - makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }), - makeEvent({ seq: 5, event_type: "run.completed", payload: {} }), - ]; - - const timeline = foldEventsToTimeline(events); - assert.ok(timeline.length >= 3, `expected >=3 entries, got ${timeline.length}`); - - // run.started 和 run.completed 应存在 - const kinds = timeline.map((e) => e.eventType); - assert.ok(kinds.includes("run.started")); - assert.ok(kinds.includes("run.completed")); - - // delta 聚合为一条 message - const messages = timeline.filter((e) => e.kind === "message"); - assert.equal(messages.length, 1); - assert.equal(messages[0].body, "Hello world"); - }); - - test("runIsLive 对终态返回 false", () => { - assert.equal(runIsLive("completed"), false); - assert.equal(runIsLive("failed"), false); - assert.equal(runIsLive("cancelled"), false); - assert.equal(runIsLive(null), false); - assert.equal(runIsLive(undefined), false); - }); - - test("runIsLive 对活跃态返回 true", () => { - assert.equal(runIsLive("running"), true); - assert.equal(runIsLive("pending"), true); - assert.equal(runIsLive("waiting_approval"), true); - assert.equal(runIsLive("cancelling"), true); - assert.equal(runIsLive("cancel_failed"), true); - }); -}); - -// ── 4. 延迟出现的审批卡 ──────────────────────────────────────────────────── - -describe("审批关联", () => { - test("pending 审批在 actions 到达后正确关联", () => { - // 模拟 indexApprovalsByAction + indexActions - const approvals = [ - { id: "ap1", action_id: "act1", status: "pending", summary: "写入文件" }, - { id: "ap2", action_id: null, status: "pending", summary: "无关联动作" }, - ]; - const actions = [ - { id: "act1", tool_name: "write_file", risk_level: "R3", arguments: { path: "/tmp/x" } }, - ]; - - // indexApprovalsByAction:只索引有 action_id 的 pending - const byAction = new Map(); - for (const a of approvals) { - if (a.status === "pending" && a.action_id) byAction.set(a.action_id, a); - } - assert.equal(byAction.size, 1); - assert.ok(byAction.has("act1")); - assert.ok(!byAction.has("")); // null action_id 不索引 - - // indexActions - const actMap = new Map(); - for (const a of actions) actMap.set(a.id, a); - - // 卡片取 action - const ap1 = approvals[0]; - const linkedAction = ap1.action_id ? actMap.get(ap1.action_id) : undefined; - assert.equal(linkedAction?.tool_name, "write_file"); - assert.equal(linkedAction?.risk_level, "R3"); - - // 无 action_id 的审批不报错 - const ap2 = approvals[1]; - const noAction = ap2.action_id ? actMap.get(ap2.action_id) : undefined; - assert.equal(noAction, undefined); - }); -}); - -// ── 5. 风险等级映射 R0–R4 ───────────────────────────────────────────────── - -describe("风险等级映射", () => { - const RISK_TONE: Record = { - R0: "text-emerald-600 bg-emerald-50", - R1: "text-emerald-600 bg-emerald-50", - R2: "text-amber-600 bg-amber-50", - R3: "text-red-600 bg-red-50", - R4: "text-red-600 bg-red-50", - low: "text-emerald-600 bg-emerald-50", - medium: "text-amber-600 bg-amber-50", - high: "text-red-600 bg-red-50", +describe("buildSessionOverrides:未改写不传,非法值返回错误", () => { + const defaults = { + default_mode: "structured", + default_write_policy: "confirm_high_risk", + default_max_rounds: 8, }; - test("R0/R1 → 低风险(绿)", () => { - assert.ok(RISK_TONE["R0"].includes("emerald")); - assert.ok(RISK_TONE["R1"].includes("emerald")); + test("用户未改写 → 不传任何高级字段(让后端决定)", () => { + const { overrides, maxRoundsError } = buildSessionOverrides( + { mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "" }, + defaults, + ); + assert.deepEqual(overrides, {}); + assert.equal(maxRoundsError, undefined); }); - test("R2 → 中风险(琥珀)", () => { - assert.ok(RISK_TONE["R2"].includes("amber")); + test("用户改写 mode → 只传 mode", () => { + const { overrides } = buildSessionOverrides( + { mode: "group_chat", writePolicy: "confirm_high_risk", maxRounds: "" }, + defaults, + ); + assert.equal(overrides.mode, "group_chat"); + assert.equal(overrides.writePolicy, undefined); + assert.equal(overrides.maxRounds, undefined); }); - test("R3/R4 → 高风险(红)", () => { - assert.ok(RISK_TONE["R3"].includes("red")); - assert.ok(RISK_TONE["R4"].includes("red")); + test("maxRounds=0 → 返回错误、不传", () => { + const { overrides, maxRoundsError } = buildSessionOverrides( + { mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "0" }, + defaults, + ); + assert.ok(maxRoundsError); + assert.equal(overrides.maxRounds, undefined); }); - test("未知等级有兜底", () => { - // 未知等级不在 map 中,组件用 ?? 兜底 - const fallback = RISK_TONE["R99"] ?? "text-slate-500 bg-slate-50"; - assert.ok(fallback.includes("slate")); + test("maxRounds=65 → 返回错误", () => { + const { maxRoundsError } = buildSessionOverrides( + { mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "65" }, + defaults, + ); + assert.ok(maxRoundsError); + }); + + test("maxRounds=3.5(小数)→ 返回错误", () => { + const { maxRoundsError } = buildSessionOverrides( + { mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "3.5" }, + defaults, + ); + assert.ok(maxRoundsError); }); }); -// ── 6. 快速切换 run 时事件不串台(generation 隔离逻辑验证)───────────────── +// ── 4. pending 审批双击只发一次决策(守卫单一事实源)───────────────────────── -describe("事件流代次隔离", () => { - test("generation 递增后旧回调被丢弃", () => { - // 模拟 generationRef 逻辑 - let generation = 0; - const results: string[] = []; +describe("审批决策按钮守卫 isDecisionButtonDisabled", () => { + test("mutation pending 时禁用(防双击重复决策)", () => { + assert.equal(isDecisionButtonDisabled({ pending: true, resuming: false }), true); + }); + test("已批准等待恢复时禁用", () => { + assert.equal(isDecisionButtonDisabled({ pending: false, resuming: true }), true); + }); + test("空闲时可点", () => { + assert.equal(isDecisionButtonDisabled({ pending: false, resuming: false }), false); + }); +}); - // 模拟 run A 的请求(慢) - const genA = generation; - // 切换到 run B → generation++ - generation += 1; - const genB = generation; +// ── 5 & 6. canManualResume 恢复前置判定 ───────────────────────────────────── - // run A 的响应到达(检查代次) - if (generation === genA) { - results.push("A-stale"); - } - // run B 的响应到达 - if (generation === genB) { - results.push("B-fresh"); - } +describe("canManualResume 恢复前置判定", () => { + test("有 pending approval → 不可恢复", () => { + assert.equal( + canManualResume({ + runStatus: "waiting_approval", + approvalsLoading: false, + approvalsSuccess: true, + pendingCount: 1, + }), + false, + ); + }); + test("审批加载中 → 不可恢复", () => { + assert.equal( + canManualResume({ + runStatus: "waiting_approval", + approvalsLoading: true, + approvalsSuccess: false, + pendingCount: 0, + }), + false, + ); + }); + test("审批加载失败 → 不可恢复", () => { + assert.equal( + canManualResume({ + runStatus: "waiting_approval", + approvalsLoading: false, + approvalsSuccess: false, + pendingCount: 0, + }), + false, + ); + }); + test("非 waiting_approval → 不可恢复", () => { + assert.equal( + canManualResume({ + runStatus: "running", + approvalsLoading: false, + approvalsSuccess: true, + pendingCount: 0, + }), + false, + ); + }); + test("审批清零且仍 waiting_approval 且已加载成功 → 允许恢复(补偿操作)", () => { + assert.equal( + canManualResume({ + runStatus: "waiting_approval", + approvalsLoading: false, + approvalsSuccess: true, + pendingCount: 0, + }), + true, + ); + }); +}); - assert.deepEqual(results, ["B-fresh"]); - assert.ok(!results.includes("A-stale")); +// ── 10. 动作状态分类(executed/failed/unknown/expired 都正确)──────────────── + +describe("classifyActionsByStatus 动作分类", () => { + function makeAction(id: string, status: string) { + return { + id, + session_id: "s1", + run_id: "r1", + agent_id: "agent-a", + tool_name: "write_tool", + logical_action_id: null, + risk_level: "R3", + status, + arguments: { target_key: "secret-should-not-leak" }, + result: null, + external_object_id: null, + approval_id: null, + attempt_count: 1, + started_at: null, + finished_at: null, + created_at: null, + updated_at: null, + }; + } + + test("classifyActionStatus 各状态映射正确", () => { + assert.equal(classifyActionStatus("pending"), "waiting"); + assert.equal(classifyActionStatus("waiting_approval"), "waiting"); + assert.equal(classifyActionStatus("approved"), "approved"); + assert.equal(classifyActionStatus("rejected"), "rejected_or_expired"); + assert.equal(classifyActionStatus("expired"), "rejected_or_expired"); + assert.equal(classifyActionStatus("executed"), "executed"); + assert.equal(classifyActionStatus("failed"), "failed"); + assert.equal(classifyActionStatus("unknown"), "unknown"); + // 未知 status 落到 unknown(保守) + assert.equal(classifyActionStatus("???"), "unknown"); + assert.equal(classifyActionStatus(null), "unknown"); }); - test("同一代次内多次 poll 正常累积", () => { - let generation = 0; - const gen = generation; - const events: number[] = []; - - // 同一代次内三次 poll - for (const seq of [1, 2, 3]) { - if (generation === gen) { - events.push(seq); - } - } - assert.deepEqual(events, [1, 2, 3]); + test("executed/failed/unknown/expired 都能正确分到对应桶", () => { + const buckets = classifyActionsByStatus([ + makeAction("a1", "executed"), + makeAction("a2", "failed"), + makeAction("a3", "unknown"), + makeAction("a4", "expired"), + makeAction("a5", "pending"), + makeAction("a6", "approved"), + ]); + const byKey = new Map(buckets.map((b) => [b.key, b.items.length])); + assert.equal(byKey.get("executed"), 1); + assert.equal(byKey.get("failed"), 1); + assert.equal(byKey.get("unknown"), 1); + assert.equal(byKey.get("rejected_or_expired"), 1); + assert.equal(byKey.get("waiting"), 1); + assert.equal(byKey.get("approved"), 1); }); + test("空输入 → 所有桶为空但结构完整", () => { + const buckets = classifyActionsByStatus(null); + assert.equal(buckets.length, 6); + for (const b of buckets) assert.equal(b.items.length, 0); + }); +}); + +// ── 11. 风险等级映射(真实 riskThemeFor,不再复制 map)────────────────────── + +describe("风险等级映射 riskThemeFor", () => { + test("R0/R1 → success(低)", () => { + assert.equal(riskThemeFor("R0"), "success"); + assert.equal(riskThemeFor("R1"), "success"); + }); + test("R2 → warning(中)", () => { + assert.equal(riskThemeFor("R2"), "warning"); + }); + test("R3/R4 → danger(高)", () => { + assert.equal(riskThemeFor("R3"), "danger"); + assert.equal(riskThemeFor("R4"), "danger"); + }); + test("未知/null → default(保守,不放过未知风险)", () => { + assert.equal(riskThemeFor("R99"), "default"); + assert.equal(riskThemeFor(null), "default"); + assert.equal(riskThemeFor(undefined), "default"); + }); + test("标签可读且未知原样返回", () => { + assert.equal(riskLabelFor("R3"), "R3 · 高"); + assert.equal(riskLabelFor("R99"), "R99"); + assert.equal(riskLabelFor(null), "—"); + }); +}); + +// ── 8 & 9. 事件流代次隔离 + 终态补齐(真实 coordinator + runIsLive)────────── + +describe("事件流代次隔离 createMafEventRequestCoordinator", () => { test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => { const coordinator = createMafEventRequestCoordinator(); let resolveOld!: () => void; @@ -294,17 +388,13 @@ describe("事件流代次隔离", () => { assert.equal(coordinator.start(oldGeneration), true); const oldRequest = (async () => { await oldResponse; - // This is the delayed live poll response from the old run. - acceptPage(oldGeneration, [99]); + acceptPage(oldGeneration, [99]); // 旧 run 的慢响应 coordinator.finish(oldGeneration); })(); - // running -> completed creates a new generation. Its backlog request must - // not see the stale generation's in-flight slot. + // running → completed 触发新代次;终态补拉必须看到完整历史 const terminalGeneration = coordinator.nextGeneration(); assert.equal(coordinator.start(terminalGeneration), true); - // The terminal request starts from cursor 0 and returns the complete - // history, even though the old live request is still unresolved. acceptPage(terminalGeneration, [1, 2, 3]); coordinator.finish(terminalGeneration); @@ -313,36 +403,240 @@ describe("事件流代次隔离", () => { assert.deepEqual(acceptedEvents, [1, 2, 3]); assert.equal(latestSeq, 3); - assert.equal(coordinator.start(terminalGeneration), true); }); }); -// ── 7. maxRounds 校验 ───────────────────────────────────────────────────── +describe("终态 run 补齐历史", () => { + test("runIsLive 对终态返回 false(停止轮询但仍补拉历史)", () => { + assert.equal(runIsLive("completed"), false); + assert.equal(runIsLive("failed"), false); + assert.equal(runIsLive("cancelled"), false); + }); + test("runIsLive 对活跃态返回 true", () => { + assert.equal(runIsLive("running"), true); + assert.equal(runIsLive("waiting_approval"), true); + }); + test("completed run 的事件仍被折叠为时间线", () => { + const events: MafEvent[] = [ + makeEvent({ seq: 1, event_type: "run.started", payload: {} }), + makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }), + makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }), + makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }), + makeEvent({ seq: 5, event_type: "run.completed", payload: {} }), + ]; + const timeline = foldEventsToTimeline(events); + const kinds = timeline.map((e) => e.eventType); + assert.ok(kinds.includes("run.started")); + assert.ok(kinds.includes("run.completed")); + // delta 聚合为一条 message + const messages = timeline.filter((e) => e.kind === "message"); + assert.equal(messages.length, 1); + assert.equal(messages[0].body, "Hello world"); + }); +}); -describe("maxRounds 前端校验", () => { - function validateMaxRounds(input: string): { valid: boolean; value?: number } { - if (!input.trim()) return { valid: true }; // 空 = 使用默认 - const n = Number(input); - if (!Number.isInteger(n) || n < 1 || n > 64) return { valid: false }; - return { valid: true, value: n }; +// ── 13. 事件翻页 mergeBySeq(真实导出,不再复制)───────────────────────────── + +describe("事件翻页 mergeBySeq(真实导出)", () => { + test("两页合并无重复、升序", () => { + const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 })); + const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 })); + let merged: MafEvent[] = []; + merged = mergeBySeq(merged, page1); + merged = mergeBySeq(merged, page2); + assert.equal(merged.length, 2000); + for (let i = 1; i < merged.length; i++) { + assert.ok(merged[i].seq >= merged[i - 1].seq); + } + }); + test("重叠页(网络重传)不产生重复", () => { + const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 })); + const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 })); + let merged: MafEvent[] = []; + merged = mergeBySeq(merged, page1); + merged = mergeBySeq(merged, page2); + assert.equal(merged.length, 120); + }); +}); + +// ── 14. buildMafFlowGraph 纯函数 ──────────────────────────────────────────── + +describe("buildMafFlowGraph 流程图构造", () => { + test("extractMafFlowGraph 解析 flow-json 块", () => { + const text = '回复\n```flow-json\n{"title":"T","nodes":[{"id":"n1","label":"任务A","type":"task"}]}\n```'; + const g = extractMafFlowGraph(text); + assert.ok(g); + assert.equal(g!.nodes.length, 1); + assert.equal(g!.nodes[0].label, "任务A"); + }); + + test("无 flow-json → null", () => { + assert.equal(extractMafFlowGraph("普通文本无图"), null); + }); + + test("checkpoint 有 confirmed_facts → 构造事实证据图", () => { + const checkpoint = { + fact_ledger: { + candidate_facts: [], + confirmed_facts: [ + { candidate_id: "f1", text: "已确认事实一", evidence_refs: [] }, + ], + evidence: [], + tool_results: [], + executed_actions: [], + pending_actions: [], + failed_or_unknown_actions: [], + conflicts: [], + proposed_actions: [], + }, + }; + const g = buildMafFlowGraph(checkpoint as any); + assert.ok(g); + assert.ok(g!.nodes.length >= 2); // root + fact + assert.equal(g!.title, "事实证据图"); + }); + + test("空 checkpoint → null(组件显示空态)", () => { + assert.equal(buildMafFlowGraph(null), null); + assert.equal(buildMafFlowGraph({}), null); + }); +}); + +// ── 15. safeDisplayJson 二次脱敏(真实函数)────────────────────────────────── + +describe("safeDisplayJson 统一脱敏", () => { + test("敏感键直接删除(不显示成 ***),值与键名均不泄漏", () => { + const result = safeDisplayJson({ + title: "可显示", + target_key: "must-not-leak", + nested: { + token: "must-not-leak", + normal: "可显示", + }, + }); + assert.match(result.text, /可显示/); + assert.doesNotMatch(result.text, /target_key|must-not-leak|token/); + assert.ok(result.hiddenCount >= 2); + assert.equal(result.truncated, false); + }); + + test("只有敏感字段的对象 → text 为空(调用方应隐藏面板)", () => { + const result = safeDisplayJson({ api_key: "k", password: "p" }); + assert.equal(result.text, ""); + assert.equal(result.hiddenCount, 2); + }); + + test("null / 空对象 → text 为空", () => { + assert.equal(safeDisplayJson(null).text, ""); + assert.equal(safeDisplayJson({}).text, ""); + assert.equal(safeDisplayJson(undefined).text, ""); + }); + + test("数组:递归脱敏 + 上限截断", () => { + const big = Array.from({ length: 30 }, (_, i) => ({ id: i, secret: `s${i}` })); + const result = safeDisplayJson(big); + assert.ok(result.truncated); // 超过 20 项截断 + // 前 20 项的 secret 被删(hiddenCount=20);后 10 项整体被截断(由 truncated 体现) + assert.equal(result.hiddenCount, 20); + assert.doesNotMatch(result.text, /secret|s\d/); + }); + + test("深层对象:超过最大深度 → truncated + 占位", () => { + const deep = { a: { b: { c: { d: { e: { f: "too deep" } } } } } }; + const result = safeDisplayJson(deep); + assert.ok(result.truncated); + assert.match(result.text, /内容层级过深/); + }); + + test("超长字符串:截断到 120 字 + …", () => { + const long = "x".repeat(500); + const result = safeDisplayJson({ note: long }); + assert.ok(result.truncated); + assert.doesNotMatch(result.text, /x{121}/); // 不超过 120 + assert.match(result.text, /…/); + }); + + test("单对象字段数上限 30 → 截断 + 计数准确", () => { + const obj: Record = {}; + for (let i = 0; i < 50; i++) obj[`field${i}`] = i; + const result = safeDisplayJson(obj); + assert.ok(result.truncated); + }); + + test("键名忽略大小写匹配", () => { + const result = safeDisplayJson({ TOKEN: "t", Authorization: "a", Cookie: "c", safe: "ok" }); + assert.equal(result.hiddenCount, 3); + assert.match(result.text, /ok/); + // 敏感键名与值都不应出现 + assert.doesNotMatch(result.text, /TOKEN|Authorization|Cookie|"(t|a|c)"/i); + }); +}); + +// ── 12. 边界:MAF 目录无旧会商 import ──────────────────────────────────────── + +describe("MAF 目录边界:不依赖旧会商", () => { + // 扫描 src/maf-roundtable 下所有源文件,断言不含旧会商目录引用。 + // 根目录用 process.cwd()/src/maf-roundtable(验收命令从 frontend-web 运行), + // 不能用 import.meta.url——打包后它指向 TEMP 文件,递归会走到系统受保护目录。 + const mafRoot = join(process.cwd(), "src", "maf-roundtable"); + const forbidden = ["roundtable-planning", "@/roundtable", "from \"@/roundtable"]; + + function walk(dir: string, out: string[]) { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + // 权限不足 / 不可读目录直接跳过(避免 Windows 上的 EPERM 导致测试 flaky) + return; + } + for (const entry of entries) { + const name = String(entry.name); + const p = join(dir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (st.isDirectory()) { + if (name === "__tests__") continue; // 测试目录本身允许提及(边界断言) + if (name === "node_modules") continue; + walk(p, out); + } else if (st.isFile()) { + out.push(p); + } + } } - test("空字符串 → 有效(使用默认)", () => { - assert.ok(validateMaxRounds("").valid); - assert.ok(validateMaxRounds(" ").valid); - }); + function checkForbidden(filePath: string): string[] { + const ext = extname(filePath); + if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return []; + const text = readFileSync(filePath, "utf8"); + const hits: string[] = []; + for (const needle of forbidden) { + if (text.includes(needle)) hits.push(needle); + } + return hits; + } - test("1–64 整数 → 有效", () => { - assert.ok(validateMaxRounds("1").valid); - assert.ok(validateMaxRounds("64").valid); - assert.ok(validateMaxRounds("32").valid); - }); - - test("0 / 65 / 负数 / 小数 / 非数字 → 无效", () => { - assert.ok(!validateMaxRounds("0").valid); - assert.ok(!validateMaxRounds("65").valid); - assert.ok(!validateMaxRounds("-1").valid); - assert.ok(!validateMaxRounds("3.5").valid); - assert.ok(!validateMaxRounds("abc").valid); + test("src/maf-roundtable 源文件不含 roundtable-planning / @/roundtable", () => { + if (!existsSync(mafRoot)) { + assert.fail(`MAF 源目录不存在:${mafRoot}(请从 frontend-web 目录运行测试)`); + } + const files: string[] = []; + walk(mafRoot, files); + assert.ok(files.length > 0, "应至少扫描到 MAF 源文件"); + const violations: string[] = []; + for (const f of files) { + const hits = checkForbidden(f); + if (hits.length > 0) { + violations.push(`${relative(mafRoot, f)}: ${hits.join(", ")}`); + } + } + assert.deepEqual( + violations, + [], + `MAF 源文件不得引用旧会商目录,发现:\n${violations.join("\n")}`, + ); }); }); diff --git a/frontend-web/src/maf-roundtable/components/MafActionAuditPanel.tsx b/frontend-web/src/maf-roundtable/components/MafActionAuditPanel.tsx new file mode 100644 index 00000000..74e2e425 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafActionAuditPanel.tsx @@ -0,0 +1,177 @@ +/** + * MAF 外部动作审计面板(MAF-06-07:可视层改用本地 `tdesign-react`)。 + * + * 数据使用当前页面已正确存在的 useMafActions(runId, live)(轮询 run live 时刷新, + * 终态停止)。按真实 action.status 分类展示,分桶语义见 lib/action-audit.ts。 + * + * 安全展示:每项最多展示——工具名称、风险等级、状态、安全 action id、已脱敏参数摘要、 + * 可安全公开的 external_object_id、开始/结束时间。 + * 绝不展示原始 result、内部参数、target_key、idempotency_key(参数渲染前先走 safeSummary 脱敏)。 + */ + +import { AlertTriangle, ClipboardList, Loader2 } from "lucide-react"; +import { Card as TCard, Tag as TTag } from "tdesign-react"; + +import { useMafActions } from "../api/approvals"; +import type { MafAction } from "../api/types"; +import { classifyActionsByStatus, type ActionBucket } from "../lib/action-audit"; +import { riskLabelFor, riskThemeFor } from "../lib/risk-theme"; +import { safeDisplayJson } from "../lib/safe-display"; + +interface MafActionAuditPanelProps { + runId: string; + /** run 仍 live → 启用轮询;终态停止。 */ + live: boolean; +} + +export function MafActionAuditPanel({ runId, live }: MafActionAuditPanelProps) { + const actionsQuery = useMafActions(runId, live); + const actions = actionsQuery.data?.actions ?? []; + const buckets = classifyActionsByStatus(actions); + const total = actions.length; + + return ( +
+
+

+ + 外部动作审计 +

+ + {actionsQuery.isLoading ? "加载中…" : `${total} 条`} + +
+ + {actionsQuery.isLoading && ( +
+ 加载动作… +
+ )} + {actionsQuery.isError && ( +
+ + 加载失败:{(actionsQuery.error as Error)?.message ?? "未知错误"} +
+ )} + + {!actionsQuery.isLoading && !actionsQuery.isError && total === 0 && ( +

暂无外部动作记录

+ )} + + {!actionsQuery.isLoading && !actionsQuery.isError && total > 0 && ( +
+ {buckets.map((bucket) => + bucket.items.length === 0 ? null : ( + + ), + )} +
+ )} +
+ ); +} + +function BucketBlock({ bucket }: { bucket: ActionBucket }) { + const theme = bucketTheme(bucket.key); + return ( +
+
+ {bucket.items.length} + {bucket.label} + {bucket.hint} +
+
+ {bucket.items.map((a) => ( + + ))} +
+
+ ); +} + +function ActionRow({ action }: { action: MafAction }) { + return ( + +
+ + {truncate(action.tool_name || "未知工具", 32)} + + + {riskLabelFor(action.risk_level)} + + {action.status} +
+ +
+ #{shortId(action.id)} + {action.agent_id && 席位:{truncate(action.agent_id, 16)}} + {action.external_object_id && ( + 对象:{truncate(String(action.external_object_id), 24)} + )} + {action.started_at && {formatTime(action.started_at)}} + {action.finished_at && → {formatTime(action.finished_at)}} + {action.attempt_count > 0 && 尝试 {action.attempt_count}} +
+ + {(() => { + const safe = safeDisplayJson(action.arguments); + if (!safe.text) return null; + return ( +
+
参数(已脱敏)
+
+              {safe.text}
+            
+ {(safe.hiddenCount > 0 || safe.truncated) && ( +
+ {[ + safe.hiddenCount > 0 ? `已隐藏 ${safe.hiddenCount} 项敏感字段` : "", + safe.truncated ? "内容已截断" : "", + ].filter(Boolean).join(" · ")} +
+ )} +
+ ); + })()} +
+ ); +} + +// ── 安全渲染辅助 ─────────────────────────────────────────────────────────── + +/** 桶 key → TDesign Tag 主题。 */ +function bucketTheme(key: ActionBucket["key"]): "default" | "primary" | "warning" | "success" | "danger" { + switch (key) { + case "waiting": + return "warning"; + case "approved": + return "primary"; + case "executed": + return "success"; + case "failed": + case "rejected_or_expired": + return "danger"; + case "unknown": + default: + return "default"; + } +} + +function shortId(id: string): string { + return id.length > 8 ? `${id.slice(0, 8)}…` : id; +} + +function truncate(text: string, max: number): string { + const t = text.trim().replace(/\s+/g, " "); + return t.length <= max ? t : `${t.slice(0, max)}…`; +} + +function formatTime(iso: string): string { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString(); + } catch { + return ""; + } +} diff --git a/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx b/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx index 3fc68cc0..17cf6f68 100644 --- a/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx +++ b/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx @@ -3,22 +3,32 @@ * * 对齐后端 schemas.py: * - 标题:action.tool_name,没有 action 时显示 approval.summary - * - 风险:展示 action.risk_level(R0–R4 配色) - * - 参数:展示 action.arguments(已脱敏) + * - 风险:展示 action.risk_level(R0–R4 配色,主题映射来自 lib/risk-theme,单一事实源) * - 审批摘要:展示 approval.summary - * - 审批 payload:仅在有值时展示已脱敏 JSON + * - 参数 / 审批 payload:统一走 lib/safe-display 的 safeDisplayJson(敏感键直接删除, + * 不显示成 ***;连字段名都不透露) + * - 不渲染 action.result(仅展示参数与审批载荷) * - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注) * - * 批准/拒绝禁用重复提交;invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。 - * 仅 pending 审批渲染;approved/rejected 后由父组件过滤掉。 + * 重复决策锁定(P1): + * - 批准和拒绝都锁卡:onSuccess 后 setResuming(true)(拒绝后后端同样触发恢复流程); + * - 除 React state 外,额外用 decisionLockRef 同步锁,避免极快双击时 React 尚未重渲染 + * 就已发出第二次 mutate; + * - mutation pending 期间按钮禁用(isDecisionButtonDisabled 单一事实源)。 + * + * invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。仅 pending 审批渲染。 */ +import { useRef, useState } from "react"; import { Check, Loader2, ShieldCheck, X } from "lucide-react"; import { toast } from "sonner"; import { Button as TButton, Tag as TTag } from "tdesign-react"; import { useMafApprovalActions } from "../api/approvals"; import type { MafAction, MafApproval } from "../api/types"; +import { isDecisionButtonDisabled } from "../lib/approval-state"; +import { riskLabelFor, riskThemeFor } from "../lib/risk-theme"; +import { safeDisplayJson } from "../lib/safe-display"; interface MafApprovalCardProps { approval: MafApproval; @@ -32,44 +42,33 @@ function shortId(id: string | null): string { return id.length > 8 ? `${id.slice(0, 8)}…` : id; } -/** - * 风险等级 → TDesign Tag 主题。后端实际使用 R0–R4(不是 low/medium/high): - * R0/R1 → 低风险(success) - * R2 → 中风险(warning) - * R3/R4 → 高风险(danger) - */ -const RISK_THEME: Record = { - R0: "success", - R1: "success", - R2: "warning", - R3: "danger", - R4: "danger", - // 兜底:后端可能返回旧值 - low: "success", - medium: "warning", - high: "danger", -}; - -const RISK_LABEL: Record = { - R0: "R0 · 低", - R1: "R1 · 低", - R2: "R2 · 中", - R3: "R3 · 高", - R4: "R4 · 高", -}; - export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) { const actions = useMafApprovalActions(runId); + // 决策成功(批准或拒绝)后锁卡,等待后端同步工作流 + const [resuming, setResuming] = useState(false); + // 同步锁:防极快双击在 React 重渲染前就发出第二次 mutate + const decisionLockRef = useRef(false); const pending = actions.decide.isPending; + // 按钮禁用判定走共享纯函数(lib/approval-state,单一事实源) + const disabled = isDecisionButtonDisabled({ pending, resuming }); + const onDecide = (decision: "approved" | "rejected") => { + if (decisionLockRef.current || disabled) return; + decisionLockRef.current = true; actions.decide.mutate( { approvalId: approval.id, decision }, { onSuccess: () => { + // 批准和拒绝都锁卡:拒绝后后端同样会推进流程,统一显示「正在同步工作流…」 + setResuming(true); toast.success(decision === "approved" ? "已批准" : "已拒绝"); }, - onError: (e: Error) => toast.error(`决策失败:${e.message}`), + onError: (e: Error) => { + // 失败时释放锁,允许重试 + decisionLockRef.current = false; + toast.error(`决策失败:${e.message}`); + }, }, ); }; @@ -77,8 +76,9 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp // 标题:action.tool_name 优先,没有 action 时用 approval.summary const title = action?.tool_name ?? approval.summary; const riskLevel = action?.risk_level; - const args = action?.arguments; - const approvalPayload = approval.payload; + // 二次脱敏:统一走 safeDisplayJson(敏感键直接删除,绝不显示 ***) + const safeArgs = safeDisplayJson(action?.arguments); + const safePayload = safeDisplayJson(approval.payload); return (
@@ -103,8 +103,8 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp {riskLevel && (
风险等级: - - {RISK_LABEL[riskLevel] ?? riskLevel} + + {riskLabelFor(riskLevel)}
)} @@ -116,27 +116,29 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
)} - {/* 参数(来自 action.arguments,已脱敏) */} - {args && Object.keys(args).length > 0 && ( + {/* 参数(来自 action.arguments,二次脱敏;无可展示内容则隐藏) */} + {safeArgs.text && (
参数(已脱敏)
-            {JSON.stringify(args, null, 2)}
+            {safeArgs.text}
           
+
)} - {/* 审批 payload(仅有值时展示) */} - {approvalPayload && Object.keys(approvalPayload).length > 0 && ( + {/* 审批 payload(二次脱敏;无可展示内容则隐藏) */} + {safePayload.text && (
审批载荷(已脱敏)
-            {JSON.stringify(approvalPayload, null, 2)}
+            {safePayload.text}
           
+
)} @@ -147,7 +149,7 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp variant="outline" size="small" loading={pending} - disabled={pending} + disabled={disabled} icon={!pending ? : undefined} onClick={() => onDecide("rejected")} > @@ -157,7 +159,7 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp theme="success" size="small" loading={pending} - disabled={pending} + disabled={disabled} icon={!pending ? : undefined} onClick={() => onDecide("approved")} > @@ -165,12 +167,21 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp - {pending && ( + {(pending || resuming) && (
- 提交中… + {resuming ? "正在同步工作流…" : "提交中…"}
)} ); } + +/** 脱敏提示:有隐藏或截断时只显示概括计数,不透露具体敏感字段名。 */ +function SafeDisplayHint({ result }: { result: { hiddenCount: number; truncated: boolean } }) { + if (result.hiddenCount === 0 && !result.truncated) return null; + const parts: string[] = []; + if (result.hiddenCount > 0) parts.push(`已隐藏 ${result.hiddenCount} 项敏感字段`); + if (result.truncated) parts.push("内容已截断"); + return
{parts.join(" · ")}
; +} diff --git a/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx b/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx index 1ae4eccc..e2d2b34b 100644 --- a/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx +++ b/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx @@ -2,7 +2,7 @@ * MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。 * * MAF-06-05:可视层改用本地 `tdesign-react`(Timeline / Tag)。 - * 滚动跟随逻辑自含(useMafAutoScroll),不再依赖 roundtable-planning 的 hook/组件。 + * 滚动跟随逻辑自含(useMafAutoScroll),MAF 模块不依赖任何旧会商目录的 hook/组件。 * 视觉上每条带类型 Tag + 时间 + 内容;消息体走 Markdown,其余走纯文本。 */ @@ -92,7 +92,7 @@ function toolStatusIcon(entry: TimelineEntry): React.ReactNode { } /** - * 自含滚动跟随 hook —— 替代 roundtable-planning/useAutoScroll。 + * 自含滚动跟随 hook —— MAF 模块内部实现,不依赖旧会商目录。 * * 语义:用户在底部时新内容自动滚到底;用户上滚查看历史时不强制下拉, * 只显示「回到底部」按钮。entry 数组变化时若原先在底部则跟随。 diff --git a/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx b/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx index 1a3c3707..29f8b002 100644 --- a/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx +++ b/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx @@ -1,33 +1,43 @@ /** * MAF 事实/结论流程图面板(MAF-06-07:可视层改用本地 `tdesign-react`)。 * - * 包一层 roundtable-planning 的 FlowGraphPanel(通用图可视化基建,无 TDesign 等价): - * 数据来自 lib/fact-flow-graph.ts(confirmed_facts 证据图 + final_result 的 flow-json 结论图)。 - * run live 时 streaming=true 让蚂蚁线动;终态转实线。 + * MAF 自包含:不 import 任何旧会商目录的流程图组件或类型,保证新旧会商页面独立演进。 + * 数据来自 lib/maf-flow-graph.ts(confirmed_facts 证据图 + final_result 的 flow-json 结论图)。 * - * TDesign Collapse 做折叠壳,内嵌固定高度的节点列表预览 + 「展开大图」按钮触发 FlowGraphPanel。 + * TDesign Collapse 做折叠壳,内嵌固定高度的节点列表预览 + 「展开大图」按钮触发 + * MafFlowGraphDialog(同样是 MAF 自包含组件,用 TDesign Dialog/Card/Tag/Collapse 呈现)。 */ import { useState } from "react"; -import { GitBranch, Maximize2 } from "lucide-react"; -import { Button as TButton, Collapse as TCollapse } from "tdesign-react"; - -import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel"; -import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; +import { GitBranch } from "lucide-react"; +import { Collapse as TCollapse, Tag as TTag } from "tdesign-react"; import type { MafCheckpoint } from "../api/types"; -import { buildMafFlowGraph } from "../lib/fact-flow-graph"; +import { buildMafFlowGraph, MAF_FLOW_NODE_TYPE_LABEL, type MafFlowNodeType } from "../lib/maf-flow-graph"; +import { MafFlowGraphDialog, MafFlowGraphOpenButton } from "./MafFlowGraphDialog"; + +/** 节点类型 → TDesign Tag 主题(预览徽章配色)。 */ +const NODE_TYPE_THEME: Record = { + start: "default", + end: "default", + stage: "primary", + task: "primary", + decision: "warning", + risk: "danger", + deliverable: "success", + role: "default", +}; interface MafFactFlowPanelProps { checkpoint: MafCheckpoint | null; - /** run 仍 live → 蚂蚁线动;终态 → 实线。 */ + /** run 仍 live → 显示「绘制中」;终态 → 不显示。 */ streaming: boolean; } export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProps) { const [open, setOpen] = useState(true); const [fullscreen, setFullscreen] = useState(false); - const graph: FlowGraphData | null = buildMafFlowGraph(checkpoint); + const graph = buildMafFlowGraph(checkpoint); return ( {graph ? ( <> - {/* 内嵌预览:节点列表,点击「大图」看完整交互画布 */} - } - onClick={() => setFullscreen(true)} - > - 大图 - +
+ setFullscreen(true)} /> +
) : (
@@ -73,25 +75,26 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
- {/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */} - {fullscreen && graph && ( - setFullscreen(false)} - orientation="vertical" - miniMap - /> - )} + setFullscreen(false)} + checkpoint={checkpoint} + streaming={streaming} + />
); } /** - * 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板; - * 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。 + * 内嵌预览:轻量「节点列表」(点击「大图」看完整结构化视图)。 */ -function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) { +function FlowGraphPreview({ + graph, + streaming, +}: { + graph: ReturnType extends infer T ? Exclude : never; + streaming: boolean; +}) { return (
{graph.title && ( @@ -104,16 +107,13 @@ function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streamin return (
  • - - {n.type} - + + {n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]} + {n.label} - {n.group && ( - {n.group} - )}
    {incomers.length > 0 && ( -
    ← {incomers.join(", ")}
    +
    ← {incomers.length} 个上游
    )}
  • ); diff --git a/frontend-web/src/maf-roundtable/components/MafFactLedgerPanel.tsx b/frontend-web/src/maf-roundtable/components/MafFactLedgerPanel.tsx new file mode 100644 index 00000000..3b94ce93 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafFactLedgerPanel.tsx @@ -0,0 +1,268 @@ +/** + * MAF 事实账本面板(MAF-06-07:可视层改用本地 `tdesign-react`)。 + * + * 数据只读 run.maf_checkpoint.fact_ledger,按 6 个分区展示: + * 候选事实(candidate_facts,显式标注「未经交叉核验」,绝不混入已确认) + * 已确认事实(confirmed_facts) + * 待审批动作(pending_actions) + * 已执行动作(executed_actions) + * 失败或未知动作(failed_or_unknown_actions) + * 冲突(conflicts) + * + * 安全要求:只展示白名单安全字段(action_id / tool_name / agent_id / external_object_id / + * text / status)。绝不展示 checkpoint 里可能包含的原始秘密、内部幂等键、target_key。 + * + * 无数据时显示空态,不伪造数据。 + */ + +import { AlertTriangle, CheckCircle2, FileQuestion, GitBranch, ListChecks, ShieldAlert } from "lucide-react"; +import { Collapse as TCollapse, Tag as TTag } from "tdesign-react"; + +import type { + MafCandidateFact, + MafCheckpoint, + MafConfirmedFact, + MafFactLedger, + MafLedgerAction, + MafLedgerConflict, +} from "../api/types"; + +interface MafFactLedgerPanelProps { + checkpoint: MafCheckpoint | null; +} + +export function MafFactLedgerPanel({ checkpoint }: MafFactLedgerPanelProps) { + const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger; + + const candidate = ledger?.candidate_facts ?? []; + const confirmed = ledger?.confirmed_facts ?? []; + const pending = ledger?.pending_actions ?? []; + const executed = ledger?.executed_actions ?? []; + const failedOrUnknown = ledger?.failed_or_unknown_actions ?? []; + const conflicts = ledger?.conflicts ?? []; + + const total = + candidate.length + + confirmed.length + + pending.length + + executed.length + + failedOrUnknown.length + + conflicts.length; + + return ( +
    +
    +

    + + 事实账本 +

    + {total} 条 +
    + + {total === 0 ? ( +

    尚无事实账本数据

    + ) : ( + + } + title="候选事实" + badgeTheme="warning" + warn="未经交叉核验" + count={candidate.length} + > + {candidate.map((f) => ( + + ))} + + + } + title="已确认事实" + badgeTheme="success" + count={confirmed.length} + > + {confirmed.map((f) => ( + + ))} + + + } + title="待审批动作" + badgeTheme="warning" + count={pending.length} + > + {pending.map((a, i) => ( + + ))} + + + } + title="已执行动作" + badgeTheme="success" + count={executed.length} + > + {executed.map((a, i) => ( + + ))} + + + } + title="失败或未知动作" + badgeTheme="danger" + count={failedOrUnknown.length} + > + {failedOrUnknown.map((a, i) => ( + + ))} + + + } + title="冲突" + badgeTheme="danger" + count={conflicts.length} + > + {conflicts.map((c, i) => ( + + ))} + + + )} +
    + ); +} + +// ── 分区壳 ───────────────────────────────────────────────────────────────── + +function LedgerSection({ + value, + icon, + title, + badgeTheme, + warn, + count, + children, +}: { + value: string; + icon: React.ReactNode; + title: string; + badgeTheme: "default" | "primary" | "warning" | "success" | "danger"; + /** 候选事实专用:额外标注「未经交叉核验」。 */ + warn?: string; + count: number; + children: React.ReactNode; +}) { + return ( + + {icon} + {title} + {count} + {warn && count > 0 && ( + {warn} + )} +
    + } + > +
    {children}
    + + ); +} + +// ── 条目渲染 ─────────────────────────────────────────────────────────────── + +function agentTag(agentId?: string): React.ReactNode { + if (!agentId) return null; + return ( + + {truncate(agentId, 16)} + + ); +} + +function FactItem({ fact, confirmed }: { fact: MafCandidateFact | MafConfirmedFact; confirmed: boolean }) { + return ( +
    +
    + {confirmed ? ( + 已确认 + ) : ( + 候选 · 未经核验 + )} + {agentTag(fact.agent_id)} + {"confirmed_by" in fact && fact.confirmed_by && ( + 确认方:{truncate(fact.confirmed_by, 16)} + )} +
    +

    {fact.text}

    + {"evidence_refs" in fact && (fact as MafConfirmedFact).evidence_refs?.length > 0 && ( +
    + 证据 {fact.evidence_refs.length} 条 +
    + )} +
    + ); +} + +/** + * 动作条目:只展示白名单安全字段。 + * 绝不展示 target_key / idempotency_key / 原始 result / 原始 arguments。 + */ +function ActionItem({ action }: { action: MafLedgerAction }) { + return ( +
    +
    + {action.tool_name && ( + {truncate(action.tool_name, 32)} + )} + {action.status && ( + {action.status} + )} + {agentTag(action.agent_id)} +
    + {action.text && ( +

    {action.text}

    + )} + {action.external_object_id && ( +
    + 对象:{truncate(String(action.external_object_id), 32)} +
    + )} +
    + ); +} + +function ConflictItem({ conflict }: { conflict: MafLedgerConflict }) { + return ( +
    +
    + 冲突 + {agentTag(conflict.agent_id)} +
    +

    {conflict.text}

    +
    + ); +} + +// ── 工具 ─────────────────────────────────────────────────────────────────── + +function truncate(text: string, max: number): string { + const t = text.trim().replace(/\s+/g, " "); + return t.length <= max ? t : `${t.slice(0, max)}…`; +} + +/** 动作条目 key:优先用安全 action_id,回退到 index(不依赖任何敏感字段)。 */ +function safeKey(a: MafLedgerAction, index: number): string { + return a.action_id ?? `idx-${index}`; +} diff --git a/frontend-web/src/maf-roundtable/components/MafFlowGraphDialog.tsx b/frontend-web/src/maf-roundtable/components/MafFlowGraphDialog.tsx new file mode 100644 index 00000000..75abc643 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafFlowGraphDialog.tsx @@ -0,0 +1,248 @@ +/** + * MAF 事实/结论流程图大图对话框(MAF-06-07:可视层改用本地 `tdesign-react`)。 + * + * MAF 自包含组件:不依赖任何旧会商目录的流程图组件或 xyflow 画布。 + * 用 TDesign Dialog + Card + Tag + Collapse 把 MafFlowGraphData 渲染成结构化节点视图: + * 每个节点一张卡,显示类型徽章、label、group、detail 以及上下游连接(可点击跳转)。 + * + * 这不是拖拽画布,而是可读的「节点 + 连线关系」结构视图,足够用于会商事实/结论的审阅, + * 且不引入第二套可视化运行时依赖。 + */ + +import { useMemo, useState } from "react"; +import { GitBranch, Maximize2, X } from "lucide-react"; +import { + Button as TButton, + Card as TCard, + Collapse as TCollapse, + Dialog as TDialog, + Tag as TTag, +} from "tdesign-react"; + +import { + buildMafFlowGraph, + MAF_FLOW_NODE_TYPE_LABEL, + type MafFlowGraphData, + type MafFlowNodeType, +} from "../lib/maf-flow-graph"; +import type { MafCheckpoint } from "../api/types"; + +/** 节点类型 → TDesign Tag 主题(用于类型徽章配色)。 */ +const NODE_TYPE_THEME: Record = { + start: "default", + end: "default", + stage: "primary", + task: "primary", + decision: "warning", + risk: "danger", + deliverable: "success", + role: "default", +}; + +interface MafFlowGraphDialogProps { + visible: boolean; + onClose: () => void; + checkpoint: MafCheckpoint | null; + /** run 仍 live → 显示「绘制中」提示;终态不显示。 */ + streaming: boolean; +} + +export function MafFlowGraphDialog({ visible, onClose, checkpoint, streaming }: MafFlowGraphDialogProps) { + const graph = useMemo(() => buildMafFlowGraph(checkpoint), [checkpoint]); + + return ( + + + 事实/结论流程图 + {graph && ( + + ({graph.nodes.length} 节点 / {graph.edges.length} 连线) + + )} + {streaming && ( + 绘制中 + )} + + } + width="760px" + closeOnEscKeydown + footer={null} + > + {graph ? ( + + ) : ( +
    + {streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"} +
    + )} +
    + ); +} + +/** 节点卡片列表 + 上下游连接。 */ +function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) { + const [focusedId, setFocusedId] = useState(null); + + const incomingByNode = useMemo(() => { + const m = new Map(); + for (const e of graph.edges) { + const list = m.get(e.to) ?? []; + list.push({ from: e.from, label: e.label }); + m.set(e.to, list); + } + return m; + }, [graph.edges]); + const outgoingByNode = useMemo(() => { + const m = new Map(); + for (const e of graph.edges) { + const list = m.get(e.from) ?? []; + list.push({ to: e.to, label: e.label }); + m.set(e.from, list); + } + return m; + }, [graph.edges]); + + const labelOf = (id: string) => graph.nodes.find((n) => n.id === id)?.label ?? id; + const focused = focusedId ? graph.nodes.find((n) => n.id === focusedId) ?? null : null; + + return ( +
    + {graph.title && ( +
    {graph.title}
    + )} + + {graph.nodes.map((n) => { + const inc = incomingByNode.get(n.id) ?? []; + const out = outgoingByNode.get(n.id) ?? []; + const isFocused = focusedId === n.id; + return ( +
    setFocusedId(isFocused ? null : n.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setFocusedId(isFocused ? null : n.id); + } + }} + > + +
    + + {n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]} + + {n.label} + {inc.length > 0 && ( + ← {inc.length} + )} + {out.length > 0 && ( + → {out.length} + )} +
    + {(isFocused || n.detail) && n.detail && ( +

    + {n.detail} +

    + )} + {isFocused && (inc.length > 0 || out.length > 0) && ( +
    + {inc.length > 0 && ( +
    + 上游: + {inc.map((e, i) => ( + + ))} +
    + )} + {out.length > 0 && ( +
    + 下游: + {out.map((e, i) => ( + + ))} +
    + )} +
    + )} +
    +
    + ); + })} + + {focused && ( +
    + 已选:{focused.label} + +
    + )} +
    + ); +} + +/** 「大图」触发按钮的封装(内嵌预览面板里点开弹窗用)。 */ +export function MafFlowGraphOpenButton({ + onClick, + disabled, +}: { + onClick: () => void; + disabled?: boolean; +}) { + return ( + } + disabled={disabled} + onClick={onClick} + > + 大图 + + ); +} + +/** 类型图例(Collapse 折叠壳里展示节点类型含义)。 */ +export function MafFlowGraphLegend() { + return ( + + +
    + {(Object.keys(MAF_FLOW_NODE_TYPE_LABEL) as MafFlowNodeType[]).map((t) => ( + + {MAF_FLOW_NODE_TYPE_LABEL[t]} + + ))} +
    +
    +
    + ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx b/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx index 81d1af10..14ea08de 100644 --- a/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx +++ b/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx @@ -5,10 +5,15 @@ * * 操作: * - live(pending/running/waiting_approval)→ 取消 - * - waiting_approval → 恢复(resume,配合审批后流程) + * - waiting_approval → 恢复(resume)**仅在 canResume=true 时显示** + * 后端契约:存在 pending approval 时 /resume 返回 409;故 canResume 由父组件按下述规则计算: + * 审批已加载成功 && 无 pending 审批 && 仍处于 waiting_approval。 + * 审批清零后由后端 dispatcher 自动恢复;仅在自动恢复未生效时才允许手动补偿。 * - cancel_failed → 重试取消(仍可点取消) * - failed → 重试(recover) * + * 取消/恢复/重试按钮均在 mutation pending 时禁用(防重复点击)。 + * * 状态 Tag 主题按 run.status 区分。token_usage/latency_ms 展示在右侧。 */ @@ -21,6 +26,16 @@ import { runIsLive, type MafRun, type MafRunStatus } from "../api/types"; interface MafRunHeaderProps { run: MafRun; + /** + * 是否允许手动恢复 run。 + * 规则见 lib/run-resume.ts 的 canManualResume;未提供时默认 false(保守:不显示恢复)。 + */ + canResume?: boolean; + /** + * 当 canResume=false 且 run 处于 waiting_approval 时,展示给用户的解释文案 + * (例如「请先完成全部审批」「正在恢复工作流…」)。未提供则用默认兜底。 + */ + resumeBlockedHint?: string; } /** run 状态 → 中文 + Tag 主题 + 图标。 */ @@ -52,7 +67,7 @@ function statusMeta(status: MafRunStatus): { } } -export function MafRunHeader({ run }: MafRunHeaderProps) { +export function MafRunHeader({ run, canResume = false, resumeBlockedHint }: MafRunHeaderProps) { const actions = useMafRunActions(); const meta = statusMeta(run.status); const live = runIsLive(run.status); @@ -104,7 +119,7 @@ export function MafRunHeader({ run }: MafRunHeaderProps) {
    - {run.status === "waiting_approval" && ( + {run.status === "waiting_approval" && canResume && ( )} + {run.status === "waiting_approval" && !canResume && ( + + + {resumeBlockedHint ?? "等待审批处理或自动恢复…"} + + )} {run.status === "failed" && ( (); for (const e of prev) bySeq.set(e.seq, e); diff --git a/frontend-web/src/maf-roundtable/index.ts b/frontend-web/src/maf-roundtable/index.ts index 3fbae8af..71fd22a2 100644 --- a/frontend-web/src/maf-roundtable/index.ts +++ b/frontend-web/src/maf-roundtable/index.ts @@ -31,7 +31,7 @@ export { runIsLive } from "./api/types"; // 纯函数(可单测) export { foldEventsToTimeline } from "./lib/event-timeline"; export type { TimelineEntry, TimelineEntryKind } from "./lib/event-timeline"; -export { buildMafFlowGraph, buildFactFlowGraph, extractConclusionFlow } from "./lib/fact-flow-graph"; +export { buildMafFlowGraph, buildMafFactFlowGraph, extractConclusionFlow } from "./lib/maf-flow-graph"; // 组件 export { MafHealthGate } from "./components/MafHealthGate"; diff --git a/frontend-web/src/maf-roundtable/lib/action-audit.ts b/frontend-web/src/maf-roundtable/lib/action-audit.ts new file mode 100644 index 00000000..e838557c --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/action-audit.ts @@ -0,0 +1,93 @@ +/** + * 把 MafAction[] 按后端 status 归类成审计面板的可读分桶。 + * + * 对齐后端 schemas.py 的 MafActionStatus 与文档约定的展示语义: + * pending / waiting_approval → 等待人工决定 + * approved → 已批准,等待或正在恢复 + * rejected / expired → 已拒绝或已过期,不会执行 + * executed → 已执行 + * failed → 已确认失败 + * unknown → 结果未知,禁止自动重试 + * + * 纯函数:输入 actions[],输出有序分桶(稳定排序,便于稳定渲染与单测)。 + * 未知 status 落到「结果未知」桶(保守,不静默吞掉任何动作)。 + */ + +import type { MafAction } from "../api/types"; + +export type ActionBucketKey = + | "waiting" + | "approved" + | "rejected_or_expired" + | "executed" + | "failed" + | "unknown"; + +export interface ActionBucket { + key: ActionBucketKey; + /** 该桶的人类可读标题。 */ + label: string; + /** 该桶的简短说明(展示在标题下方)。 */ + hint: string; + items: MafAction[]; +} + +/** 后端 status → 桶 key。未知 status 落到 unknown(保守,不静默吞掉)。 */ +export function classifyActionStatus(status: string | null | undefined): ActionBucketKey { + switch (status) { + case "pending": + case "waiting_approval": + return "waiting"; + case "approved": + return "approved"; + case "rejected": + case "expired": + return "rejected_or_expired"; + case "executed": + return "executed"; + case "failed": + return "failed"; + case "unknown": + default: + return "unknown"; + } +} + +/** 桶的展示元信息(顺序即渲染顺序)。 */ +export const ACTION_BUCKET_META: Record = { + waiting: { label: "等待人工决定", hint: "pending / waiting_approval" }, + approved: { label: "已批准,等待或正在恢复", hint: "approved" }, + rejected_or_expired: { label: "已拒绝或已过期,不会执行", hint: "rejected / expired" }, + executed: { label: "已执行", hint: "executed" }, + failed: { label: "已确认失败", hint: "failed" }, + unknown: { label: "结果未知,禁止自动重试", hint: "unknown" }, +}; + +/** 桶顺序(渲染顺序)。 */ +const BUCKET_ORDER: ActionBucketKey[] = [ + "waiting", + "approved", + "executed", + "failed", + "rejected_or_expired", + "unknown", +]; + +/** + * 把 actions 按 status 归类成有序分桶。空桶也会返回(便于 UI 显示「0」计数), + * 调用方可自行决定是否隐藏空桶。 + */ +export function classifyActionsByStatus(actions: MafAction[] | null | undefined): ActionBucket[] { + const byKey = new Map(); + for (const k of BUCKET_ORDER) byKey.set(k, []); + for (const a of actions ?? []) { + const key = classifyActionStatus(a.status); + byKey.get(key)!.push(a); + } + return BUCKET_ORDER.map((key) => ({ + key, + label: ACTION_BUCKET_META[key].label, + hint: ACTION_BUCKET_META[key].hint, + items: byKey.get(key)!, + })); +} diff --git a/frontend-web/src/maf-roundtable/lib/approval-state.ts b/frontend-web/src/maf-roundtable/lib/approval-state.ts new file mode 100644 index 00000000..9d0728ad --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/approval-state.ts @@ -0,0 +1,25 @@ +/** + * MAF 审批卡片按钮的禁用判定 —— 纯函数。 + * + * 审批卡片的「批准/拒绝」按钮必须在 mutation 进行中(pending)或已批准等待恢复(resuming) + * 时禁用,防止用户双击导致重复决策。把判定抽成纯函数,作为组件与测试的共同事实源: + * 组件 import 它设置 disabled,测试 import 它验证守卫,避免测试复制守卫逻辑、 + * 组件改坏却仍通过。 + */ + +export interface DecisionButtonState { + /** mutation 是否在进行中(useMutation.isPending)。 */ + pending: boolean; + /** 已批准、正在等待后端 dispatcher.resume() 自动恢复期间。 */ + resuming: boolean; +} + +/** + * 审批决策按钮是否应被禁用。 + * + * 规则:pending 或 resuming 任一为 true → 禁用(防重复提交 + 防恢复期间二次点击)。 + * 这是「pending 审批双击只发一次决策」的唯一事实源。 + */ +export function isDecisionButtonDisabled(state: DecisionButtonState): boolean { + return state.pending || state.resuming; +} diff --git a/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts b/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts deleted file mode 100644 index 9e1fa067..00000000 --- a/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。 - * - * 两个数据源,优先级合并: - * 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result - * 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是 - * 模型精心编排的层级图,比纯事实更有结构)。 - * 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其 - * evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable); - * 证据根用 start。 - * - * 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。 - * null 表示无任何可绘制数据,由组件显示空态。 - */ - -import { - extractFlowGraph, - type FlowGraphData, - type FlowGraphEdge, - type FlowGraphNode, -} from "@/roundtable-planning/lib/flow-graph"; - -import type { MafCheckpoint, MafFactLedger } from "../api/types"; - -/** 截断长文本用于节点 label(detail 保留全文)。 */ -function truncate(text: string, max = 28): string { - const t = text.trim().replace(/\s+/g, " "); - return t.length <= max ? t : `${t.slice(0, max)}…`; -} - -/** - * 尝试从 final_result.content 解析 flow-json 结论图。 - * 有则优先返回(模型编排的结论图比纯事实更有结构)。 - */ -export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null { - const content = checkpoint?.final_result?.content; - if (!content || typeof content !== "string") return null; - const graph = extractFlowGraph(content); - return graph && graph.nodes.length > 0 ? graph : null; -} - -/** - * 把 confirmed_facts + evidence + tool_results 编织成事实证据图。 - * - * 布局: - * start(根) ──► evidence/tool(role/deliverable) ──► fact(task) - * - * 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。 - */ -export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null { - const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger; - const confirmed = ledger?.confirmed_facts ?? []; - if (confirmed.length === 0) return null; - - const nodes: FlowGraphNode[] = []; - const edges: FlowGraphEdge[] = []; - const nodeIds = new Set(); - - const addNode = (n: FlowGraphNode) => { - if (!nodeIds.has(n.id)) { - nodeIds.add(n.id); - nodes.push(n); - } - }; - const addEdge = (from: string, to: string, label?: string) => { - if (from === to) return; - edges.push({ from, to, label }); - }; - - // 证据索引:ref → 来源类型,便于给节点配 type。 - const evidenceByText = new Map(); - for (const e of ledger?.evidence ?? []) { - const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : ""; - if (ref) evidenceByText.set(ref, "evidence"); - } - for (const tr of ledger?.tool_results ?? []) { - if (tr.status !== "completed") continue; // 只有成功的工具算证据 - const name = tr.tool_name ?? ""; - const turn = tr.turn_index; - const ref = - name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : ""; - if (ref) evidenceByText.set(ref, "tool"); - } - - // 根节点 - const ROOT_ID = "root"; - addNode({ id: ROOT_ID, label: "已确认事实", type: "start" }); - - for (const fact of confirmed) { - const factId = `fact:${fact.candidate_id}`; - addNode({ - id: factId, - label: truncate(fact.text), - type: "task", - detail: fact.text, - group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认", - }); - - if ((fact.evidence_refs ?? []).length === 0) { - // 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。 - addEdge(ROOT_ID, factId); - continue; - } - - for (const ref of fact.evidence_refs) { - const refStr = String(ref); - const evKind = evidenceByText.get(refStr) ?? "evidence"; - const evId = `ev:${refStr}`; - // 证据节点(role=外部证据 / deliverable=工具产出) - addNode({ - id: evId, - label: truncate(refStr.replace(/^tool:/, ""), 24), - type: evKind === "tool" ? "deliverable" : "role", - detail: refStr, - group: evKind === "tool" ? "工具证据" : "外部证据", - }); - addEdge(ROOT_ID, evId); - addEdge(evId, factId); - } - } - - // 边去重(同 from→to 只留一条) - const seen = new Set(); - const dedupEdges = edges.filter((e) => { - const k = `${e.from}->${e.to}`; - if (seen.has(k)) return false; - seen.add(k); - return true; - }); - - return { title: "事实证据图", nodes, edges: dedupEdges }; -} - -/** - * 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。 - */ -export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null { - return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint); -} diff --git a/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts b/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts new file mode 100644 index 00000000..9a960f0d --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts @@ -0,0 +1,376 @@ +/** + * MAF 事实/结论流程图 —— 纯函数层(MAF 自包含,不依赖任何旧会商目录)。 + * + * 本文件是 MAF 模块独有的图数据层:定义自己的类型、自己解析 flow-json、自己构造 + * confirmed_facts 证据图。不 import 任何旧会商目录的代码,保证新旧会商页面可独立演进。 + * + * 两个数据源,优先级合并: + * 1. `final_result.content` 里的 ```flow-json 块(次源,结论流程图)—— 若 final_result + * 含 flow-json,解析后作为「结论流程图」返回(模型精心编排的层级图)。 + * 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其 + * evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable)。 + * + * 设计:**纯函数**,输入 checkpoint(宽松类型),输出 MafFlowGraphData | null。 + * null 表示无任何可绘制数据,由组件显示空态。 + */ + +import type { MafCheckpoint, MafFactLedger } from "../api/types"; + +// ── 类型(MAF 独有,不复用旧会商)──────────────────────────────────────────── + +export type MafFlowNodeType = + | "start" + | "end" + | "stage" + | "task" + | "decision" + | "risk" + | "deliverable" + | "role"; + +export interface MafFlowGraphNode { + id: string; + label: string; + type: MafFlowNodeType; + detail?: string; + /** 层级/分组名(如 任务 / 目的 / 行为体),由模型在 flow-json 里给出。 */ + group?: string; +} + +export interface MafFlowGraphEdge { + from: string; + to: string; + label?: string; +} + +export interface MafFlowGraphData { + title: string; + nodes: MafFlowGraphNode[]; + edges: MafFlowGraphEdge[]; +} + +/** 节点类型 → 中文标签(图例 / 类型徽章)。 */ +export const MAF_FLOW_NODE_TYPE_LABEL: Record = { + start: "开始", + end: "结束", + stage: "阶段", + task: "任务", + decision: "判断", + risk: "风险", + deliverable: "交付物", + role: "角色", +}; + +const VALID_TYPES = new Set(Object.keys(MAF_FLOW_NODE_TYPE_LABEL)); + +export const MAF_FLOW_FENCE_OPEN = "```flow-json"; + +// ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)───────────────────────── + +function asString(v: unknown): string { + return typeof v === "string" ? v.trim() : ""; +} + +/** + * 容错解析单个对象切片:模型输出常带裸换行 / 尾逗号 / 中文标点导致整对象 JSON.parse + * 失败、节点被整个丢弃。这里用正则逐字段抽取,最大限度救回节点/边。 + */ +function looseParseObject(slice: string): Record | null { + const pickStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice); + if (m) { + const raw = m[1]; + try { + return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string; + } catch { + return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"'); + } + } + } + return undefined; + }; + const obj: Record = {}; + const id = pickStr("id"); + if (id) obj.id = id; + const label = pickStr("label", "name"); + if (label) obj.label = label; + const type = pickStr("type"); + if (type) obj.type = type; + const group = pickStr("group", "level", "tier", "layer"); + if (group) obj.group = group; + const detail = pickStr("detail", "description"); + if (detail) obj.detail = detail; + const toStr = pickStr("to", "target"); + if (toStr) obj.to = toStr; + const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice); + if (fm) { + const inner = fm[1]; + const ids: unknown[] = []; + const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g; + let om: RegExpExecArray | null; + while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] }); + if (ids.length === 0) { + const sRe = /"([^"]+)"/g; + let sm: RegExpExecArray | null; + while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]); + } + if (ids.length > 0) obj.from = ids; + } else { + const fromStr = pickStr("from", "source"); + if (fromStr) obj.from = fromStr; + } + return Object.keys(obj).length > 0 ? obj : null; +} + +/** + * 平衡括号扫描:从 `key`(如 `"nodes"`)后的 `[` 开始,把已完整闭合的顶层 `{…}` + * 逐个解析;跳过字符串内的括号与转义;数组未闭合(流式半截)时收集到哪算哪。 + */ +function collectObjects(text: string, key: string): Record[] { + const keyIdx = text.indexOf(key); + if (keyIdx < 0) return []; + const arrStart = text.indexOf("[", keyIdx); + if (arrStart < 0) return []; + const out: Record[] = []; + let depth = 0; + let objStart = -1; + let inStr = false; + let esc = false; + for (let i = arrStart + 1; i < text.length; i++) { + const ch = text[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { + inStr = true; + continue; + } + if (ch === "{") { + if (depth === 0) objStart = i; + depth++; + continue; + } + if (ch === "}") { + depth--; + if (depth === 0 && objStart >= 0) { + const objSlice = text.slice(objStart, i + 1); + let parsed: Record | null = null; + try { + const j = JSON.parse(objSlice) as unknown; + if (j && typeof j === "object" && !Array.isArray(j)) { + parsed = j as Record; + } + } catch { + parsed = looseParseObject(objSlice); + } + if (parsed) out.push(parsed); + objStart = -1; + } + continue; + } + if (ch === "]" && depth === 0) break; + } + return out; +} + +function toNode(raw: Record): MafFlowGraphNode | null { + const id = asString(raw.id); + const label = asString(raw.label) || asString(raw.name); + if (!id || !label) return null; + const rawType = asString(raw.type).toLowerCase(); + const type = (VALID_TYPES.has(rawType) ? rawType : "task") as MafFlowNodeType; + const detail = asString(raw.detail) || asString(raw.description) || undefined; + const group = + asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined; + return { id, label, type, detail, group }; +} + +function edgesFromNode(raw: Record, nodeId: string): MafFlowGraphEdge[] { + const from = raw.from ?? raw.prev; + if (!Array.isArray(from)) return []; + const out: MafFlowGraphEdge[] = []; + for (const entry of from) { + if (typeof entry === "string" && entry.trim()) { + out.push({ from: entry.trim(), to: nodeId }); + } else if (entry && typeof entry === "object") { + const o = entry as Record; + const fromId = asString(o.id) || asString(o.from); + if (!fromId) continue; + const label = asString(o.label) || undefined; + out.push({ from: fromId, to: nodeId, label }); + } + } + return out; +} + +function toEdge(raw: Record): MafFlowGraphEdge | null { + const from = asString(raw.from) || asString(raw.source); + const to = asString(raw.to) || asString(raw.target); + if (!from || !to) return null; + const label = asString(raw.label) || undefined; + return { from, to, label }; +} + +/** + * 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。 + * 没有 fence 或一个完整节点都没有 → null。 + */ +export function extractMafFlowGraph(text: string): MafFlowGraphData | null { + const start = text.lastIndexOf(MAF_FLOW_FENCE_OPEN); + if (start < 0) return null; + let body = text.slice(start + MAF_FLOW_FENCE_OPEN.length); + const close = body.indexOf("```"); + if (close >= 0) body = body.slice(0, close); + + const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body); + let title = ""; + if (titleMatch) { + try { + title = JSON.parse(`"${titleMatch[1]}"`) as string; + } catch { + title = titleMatch[1]; + } + } + + const rawNodes = collectObjects(body, '"nodes"'); + const nodes: MafFlowGraphNode[] = []; + const seen = new Set(); + const edges: MafFlowGraphEdge[] = []; + for (const raw of rawNodes) { + const node = toNode(raw); + if (!node || seen.has(node.id)) continue; + seen.add(node.id); + nodes.push(node); + edges.push(...edgesFromNode(raw, node.id)); + } + for (const raw of collectObjects(body, '"edges"')) { + const edge = toEdge(raw); + if (edge) edges.push(edge); + } + if (nodes.length === 0) return null; + + const edgeKeys = new Set(); + const validEdges = edges.filter((e) => { + if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false; + const key = `${e.from}->${e.to}`; + if (edgeKeys.has(key)) return false; + edgeKeys.add(key); + return true; + }); + return { title, nodes, edges: validEdges }; +} + +// ── confirmed_facts 证据图 ────────────────────────────────────────────────── + +/** 截断长文本用于节点 label(detail 保留全文)。 */ +function truncate(text: string, max = 28): string { + const t = text.trim().replace(/\s+/g, " "); + return t.length <= max ? t : `${t.slice(0, max)}…`; +} + +/** + * 尝试从 final_result.content 解析 flow-json 结论图。 + * 有则优先返回(模型编排的结论图比纯事实更有结构)。 + */ +export function extractConclusionFlow(checkpoint: MafCheckpoint | null): MafFlowGraphData | null { + const content = checkpoint?.final_result?.content; + if (!content || typeof content !== "string") return null; + const graph = extractMafFlowGraph(content); + return graph && graph.nodes.length > 0 ? graph : null; +} + +/** + * 把 confirmed_facts + evidence + tool_results 编织成事实证据图。 + * + * 布局:start(根) ──► evidence/tool(role/deliverable) ──► fact(task) + */ +export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null { + const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger; + const confirmed = ledger?.confirmed_facts ?? []; + if (confirmed.length === 0) return null; + + const nodes: MafFlowGraphNode[] = []; + const edges: MafFlowGraphEdge[] = []; + const nodeIds = new Set(); + + const addNode = (n: MafFlowGraphNode) => { + if (!nodeIds.has(n.id)) { + nodeIds.add(n.id); + nodes.push(n); + } + }; + const addEdge = (from: string, to: string, label?: string) => { + if (from === to) return; + edges.push({ from, to, label }); + }; + + // 证据索引:ref → 来源类型。 + const evidenceByText = new Map(); + for (const e of ledger?.evidence ?? []) { + const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : ""; + if (ref) evidenceByText.set(ref, "evidence"); + } + for (const tr of ledger?.tool_results ?? []) { + if (tr.status !== "completed") continue; + const name = tr.tool_name ?? ""; + const turn = tr.turn_index; + const ref = + name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : ""; + if (ref) evidenceByText.set(ref, "tool"); + } + + const ROOT_ID = "root"; + addNode({ id: ROOT_ID, label: "已确认事实", type: "start" }); + + for (const fact of confirmed) { + const factId = `fact:${fact.candidate_id}`; + addNode({ + id: factId, + label: truncate(fact.text), + type: "task", + detail: fact.text, + group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认", + }); + + if ((fact.evidence_refs ?? []).length === 0) { + addEdge(ROOT_ID, factId); + continue; + } + + for (const ref of fact.evidence_refs) { + const refStr = String(ref); + const evKind = evidenceByText.get(refStr) ?? "evidence"; + const evId = `ev:${refStr}`; + addNode({ + id: evId, + label: truncate(refStr.replace(/^tool:/, ""), 24), + type: evKind === "tool" ? "deliverable" : "role", + detail: refStr, + group: evKind === "tool" ? "工具证据" : "外部证据", + }); + addEdge(ROOT_ID, evId); + addEdge(evId, factId); + } + } + + const seen = new Set(); + const dedupEdges = edges.filter((e) => { + const k = `${e.from}->${e.to}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + + return { title: "事实证据图", nodes, edges: dedupEdges }; +} + +/** + * 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。 + */ +export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null { + return extractConclusionFlow(checkpoint) ?? buildMafFactFlowGraph(checkpoint); +} diff --git a/frontend-web/src/maf-roundtable/lib/risk-theme.ts b/frontend-web/src/maf-roundtable/lib/risk-theme.ts new file mode 100644 index 00000000..d760ca75 --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/risk-theme.ts @@ -0,0 +1,49 @@ +/** + * 工具风险等级 → TDesign Tag 主题/标签(MAF 审批卡 + 动作审计面板共用)。 + * + * 后端实际使用 R0–R4(见 app/maf_roundtable 的工具元数据分类): + * R0/R1 → 低风险(success) + * R2 → 中风险(warning) + * R3/R4 → 高风险(danger) + * 兜底兼容旧的 low/medium/high 字面量;未登记的未知等级落到 default(保守展示)。 + * + * 这是单一事实源:组件和测试都从这里 import,避免测试在文件里复制映射、 + * 实现坏掉却仍通过。 + */ + +export type RiskTheme = "success" | "warning" | "danger" | "default"; + +const RISK_THEME_MAP: Record = { + R0: "success", + R1: "success", + R2: "warning", + R3: "danger", + R4: "danger", + // 兜底:后端可能返回旧值 + low: "success", + medium: "warning", + high: "danger", +}; + +const RISK_LABEL_MAP: Record = { + R0: "R0 · 低", + R1: "R1 · 低", + R2: "R2 · 中", + R3: "R3 · 高", + R4: "R4 · 高", + low: "low · 低", + medium: "medium · 中", + high: "high · 高", +}; + +/** 风险等级 → TDesign Tag 主题;未知等级落到 default(不放过任何未知风险)。 */ +export function riskThemeFor(level: string | null | undefined): RiskTheme { + if (!level) return "default"; + return RISK_THEME_MAP[level] ?? "default"; +} + +/** 风险等级 → 人类可读标签;未知等级原样返回(让审阅者看到原始值)。 */ +export function riskLabelFor(level: string | null | undefined): string { + if (!level) return "—"; + return RISK_LABEL_MAP[level] ?? level; +} diff --git a/frontend-web/src/maf-roundtable/lib/run-resume.ts b/frontend-web/src/maf-roundtable/lib/run-resume.ts new file mode 100644 index 00000000..28e5a7f8 --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/run-resume.ts @@ -0,0 +1,45 @@ +/** + * MAF run 手动恢复(resume)的前置判定 —— 纯函数。 + * + * 后端契约(app/maf_roundtable):当 run 处于 waiting_approval 且仍存在 pending 审批时, + * 调用 POST /runs/{id}/resume 会返回 409。因此前端必须: + * 1. 仍有 pending 审批时,禁止手动恢复(让用户先处理审批); + * 2. 审批列表尚在加载时,禁止恢复(不知道是否还有 pending); + * 3. 审批清零且 run 仍为 waiting_approval 时(说明后端 dispatcher 自动恢复未生效或失败), + * 才允许手动恢复作为补偿操作。 + * + * 单一事实源:MafRunHeader 与测试都从这里 import,避免测试复制判定逻辑。 + */ + +export interface CanManualResumeParams { + runStatus: string | null | undefined; + /** 审批列表是否仍在加载(首次或轮询中)。 */ + approvalsLoading: boolean; + /** 审批列表是否成功加载(isLoading=false 且无 error)。 */ + approvalsSuccess: boolean; + /** 当前 pending 审批数量。 */ + pendingCount: number; +} + +/** + * 是否允许手动恢复 run。 + * + * 规则: + * - run 必须处于 waiting_approval(其它状态走各自的生命周期按钮); + * - 审批必须已成功加载(approvalsSuccess && !approvalsLoading); + * - 不存在任何 pending 审批(pendingCount === 0)。 + * + * 满足以上三项 → true(显示「恢复」按钮作为补偿操作)。 + */ +export function canManualResume({ + runStatus, + approvalsLoading, + approvalsSuccess, + pendingCount, +}: CanManualResumeParams): boolean { + if (runStatus !== "waiting_approval") return false; + if (approvalsLoading) return false; + if (!approvalsSuccess) return false; + if (pendingCount > 0) return false; + return true; +} diff --git a/frontend-web/src/maf-roundtable/lib/safe-display.ts b/frontend-web/src/maf-roundtable/lib/safe-display.ts new file mode 100644 index 00000000..3254be7d --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/safe-display.ts @@ -0,0 +1,106 @@ +/** + * MAF 安全展示 —— 统一的二次脱敏纯函数(审批卡 + 动作审计面板共用)。 + * + * 设计:把「敏感键删除(不显示成 ***)+ 递归 + 深度/数组/字段/字符串上限」收敛成 + * 单一事实源。审批卡和动作审计面板都 import safeDisplayJson,禁止各自再实现私有脱敏, + * 避免两边规则漂移(一边漏脱敏、另一边不漏)。 + * + * 规则: + * - 敏感键直接删除,绝不渲染成 target_key: "***"(连键名都不能透露); + * - 键名忽略大小写匹配 SENSITIVE_KEY_RE; + * - 递归处理对象/数组,最大深度 4(>4 返回占位); + * - 单数组最多 20 项;单对象最多 30 个非敏感字段; + * - 单字符串最多 120 字; + * - 返回 hiddenCount / truncated,供页面显示「已隐藏 N 项敏感字段」「内容已截断」, + * 但绝不透露具体敏感字段名。 + */ + +const SENSITIVE_KEY_RE = + /(secret|token|password|passwd|credential|auth|cookie|target_key|idempotency|api_key|apikey|private_key)/i; + +const MAX_DEPTH = 4; +const MAX_ARRAY_ITEMS = 20; +const MAX_OBJECT_FIELDS = 30; +const MAX_STRING_CHARS = 120; + +export interface SafeDisplayResult { + /** 脱敏 + 截断后的 JSON 文本;无可展示内容时为空串(调用方据此隐藏整块面板)。 */ + text: string; + /** 被删除的敏感字段总数(递归累计)。 */ + hiddenCount: number; + /** 是否发生任何截断(深度/数组/字段数/字符串长度)。 */ + truncated: boolean; +} + +function isEmptyValue(v: unknown): boolean { + if (v == null) return true; + if (typeof v === "string") return v.length === 0; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === "object") return Object.keys(v as Record).length === 0; + return false; +} + +/** + * 把任意值脱敏 + 截断后序列化成可展示 JSON 文本。 + * + * 全部字段被敏感过滤清空(或输入本身为空)→ text="",调用方应隐藏对应面板。 + */ +export function safeDisplayJson(value: unknown): SafeDisplayResult { + let hiddenCount = 0; + let truncated = false; + + function sanitize(input: unknown, depth: number): unknown { + if (depth > MAX_DEPTH) { + truncated = true; + return "[内容层级过深]"; + } + + if (typeof input === "string") { + if (input.length > MAX_STRING_CHARS) { + truncated = true; + return `${input.slice(0, MAX_STRING_CHARS)}…`; + } + return input; + } + + if (Array.isArray(input)) { + if (input.length > MAX_ARRAY_ITEMS) truncated = true; + return input.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitize(item, depth + 1)); + } + + if (input && typeof input === "object") { + const result: Record = {}; + let fieldCount = 0; + for (const [key, item] of Object.entries(input as Record)) { + if (SENSITIVE_KEY_RE.test(key)) { + hiddenCount += 1; + continue; + } + if (fieldCount >= MAX_OBJECT_FIELDS) { + truncated = true; + break; + } + result[key] = sanitize(item, depth + 1); + fieldCount += 1; + } + return result; + } + + return input; + } + + const cleaned = sanitize(value, 0); + + let text: string; + if (isEmptyValue(cleaned)) { + text = ""; + } else { + try { + text = JSON.stringify(cleaned, null, 2); + } catch { + text = "[unserializable]"; + } + } + + return { text, hiddenCount, truncated }; +}