feat(maf): unified safe display + decision lock for approvals
Stage-6 MAF frontend foundation plus P1 approval safety hardening: - Add lib/safe-display.ts: single-source safeDisplayJson that deletes sensitive keys (never renders as ***), recurses with depth/array/field/ string caps, returns hiddenCount + truncated. - MafApprovalCard & MafActionAuditPanel both use safeDisplayJson; remove private sanitize duplicates. Panels hide entirely when nothing is left. - Lock the card on both approve AND reject (resuming state) since the backend advances the workflow on either decision; add a decisionLockRef sync guard so a double-click before React re-render cannot fire a second mutate. Status text becomes '正在同步工作流…'. - Stage-6 base shipped here as prerequisite: self-contained maf-flow-graph (no roundtable-planning dep), MafFlowGraphDialog/MafFactLedgerPanel/ MafActionAuditPanel, shared risk-theme/action-audit/approval-state/ run-resume libs, mergeBySeq export, canResume wiring, TButton icon fixes. - Tests import real functions only; 48 pass (adds 8 safeDisplayJson cases).
This commit is contained in:
1 parent
889a97a04d
commit
df408831e5
17 files changed
+2060
-483
No files matched your search
@@ -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<MafEvent> & { seq: number }): MafEvent {
|
||||
};
|
||||
}
|
||||
|
||||
/** 模拟 mergeBySeq(与 hook 内部逻辑一致)。 */
|
||||
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
if (incoming.length === 0) return prev;
|
||||
const bySeq = new Map<number, MafEvent>();
|
||||
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<string, typeof approvals[0]>();
|
||||
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<string, typeof actions[0]>();
|
||||
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<string, string> = {
|
||||
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<string, unknown> = {};
|
||||
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")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<ClipboardList className="h-4 w-4 text-slate-500" />
|
||||
外部动作审计
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">
|
||||
{actionsQuery.isLoading ? "加载中…" : `${total} 条`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{actionsQuery.isLoading && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载动作…
|
||||
</div>
|
||||
)}
|
||||
{actionsQuery.isError && (
|
||||
<div className="flex items-center gap-1 py-3 text-center text-xs text-red-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
加载失败:{(actionsQuery.error as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && total === 0 && (
|
||||
<p className="py-4 text-center text-xs text-slate-400">暂无外部动作记录</p>
|
||||
)}
|
||||
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && total > 0 && (
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) =>
|
||||
bucket.items.length === 0 ? null : (
|
||||
<BucketBlock key={bucket.key} bucket={bucket} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketBlock({ bucket }: { bucket: ActionBucket }) {
|
||||
const theme = bucketTheme(bucket.key);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<TTag size="small" theme={theme} variant="light">{bucket.items.length}</TTag>
|
||||
<span className="text-xs font-medium text-slate-700">{bucket.label}</span>
|
||||
<span className="text-[10px] text-slate-400">{bucket.hint}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{bucket.items.map((a) => (
|
||||
<ActionRow key={a.id} action={a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ action }: { action: MafAction }) {
|
||||
return (
|
||||
<TCard bordered className="px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs font-medium text-slate-700">
|
||||
{truncate(action.tool_name || "未知工具", 32)}
|
||||
</span>
|
||||
<TTag size="small" theme={riskThemeFor(action.risk_level)} variant="light-outline">
|
||||
{riskLabelFor(action.risk_level)}
|
||||
</TTag>
|
||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-slate-400">
|
||||
<span title="安全 action id">#{shortId(action.id)}</span>
|
||||
{action.agent_id && <span title="席位">席位:{truncate(action.agent_id, 16)}</span>}
|
||||
{action.external_object_id && (
|
||||
<span title="外部对象 id">对象:{truncate(String(action.external_object_id), 24)}</span>
|
||||
)}
|
||||
{action.started_at && <span>{formatTime(action.started_at)}</span>}
|
||||
{action.finished_at && <span>→ {formatTime(action.finished_at)}</span>}
|
||||
{action.attempt_count > 0 && <span title="尝试次数">尝试 {action.attempt_count}</span>}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const safe = safeDisplayJson(action.arguments);
|
||||
if (!safe.text) return null;
|
||||
return (
|
||||
<div className="mt-1 rounded border border-slate-100 bg-slate-50/70 px-1.5 py-1">
|
||||
<div className="text-[9px] font-medium uppercase tracking-wide text-slate-400">参数(已脱敏)</div>
|
||||
<pre className="mt-0.5 max-h-24 overflow-auto whitespace-pre-wrap break-all text-[11px] text-slate-600">
|
||||
{safe.text}
|
||||
</pre>
|
||||
{(safe.hiddenCount > 0 || safe.truncated) && (
|
||||
<div className="mt-0.5 text-[9px] text-slate-400">
|
||||
{[
|
||||
safe.hiddenCount > 0 ? `已隐藏 ${safe.hiddenCount} 项敏感字段` : "",
|
||||
safe.truncated ? "内容已截断" : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 安全渲染辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** 桶 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 "";
|
||||
}
|
||||
}
|
||||
@@ -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<string, "success" | "warning" | "danger" | "default"> = {
|
||||
R0: "success",
|
||||
R1: "success",
|
||||
R2: "warning",
|
||||
R3: "danger",
|
||||
R4: "danger",
|
||||
// 兜底:后端可能返回旧值
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "danger",
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
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 (
|
||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
@@ -103,8 +103,8 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
|
||||
{riskLevel && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-400">风险等级:</span>
|
||||
<TTag size="small" theme={RISK_THEME[riskLevel] ?? "default"} variant="light-outline">
|
||||
{RISK_LABEL[riskLevel] ?? riskLevel}
|
||||
<TTag size="small" theme={riskThemeFor(riskLevel)} variant="light-outline">
|
||||
{riskLabelFor(riskLevel)}
|
||||
</TTag>
|
||||
</div>
|
||||
)}
|
||||
@@ -116,27 +116,29 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数(来自 action.arguments,已脱敏) */}
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
{/* 参数(来自 action.arguments,二次脱敏;无可展示内容则隐藏) */}
|
||||
{safeArgs.text && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
参数(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
{safeArgs.text}
|
||||
</pre>
|
||||
<SafeDisplayHint result={safeArgs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批 payload(仅有值时展示) */}
|
||||
{approvalPayload && Object.keys(approvalPayload).length > 0 && (
|
||||
{/* 审批 payload(二次脱敏;无可展示内容则隐藏) */}
|
||||
{safePayload.text && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
审批载荷(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{JSON.stringify(approvalPayload, null, 2)}
|
||||
{safePayload.text}
|
||||
</pre>
|
||||
<SafeDisplayHint result={safePayload} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,7 +149,7 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={pending}
|
||||
disabled={pending}
|
||||
disabled={disabled}
|
||||
icon={!pending ? <X className="h-3.5 w-3.5" /> : 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 ? <Check className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => onDecide("approved")}
|
||||
>
|
||||
@@ -165,12 +167,21 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
|
||||
</TButton>
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
{(pending || resuming) && (
|
||||
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] text-slate-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
提交中…
|
||||
{resuming ? "正在同步工作流…" : "提交中…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 脱敏提示:有隐藏或截断时只显示概括计数,不透露具体敏感字段名。 */
|
||||
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 <div className="mt-1 text-[10px] text-slate-400">{parts.join(" · ")}</div>;
|
||||
}
|
||||
@@ -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 数组变化时若原先在底部则跟随。
|
||||
|
||||
@@ -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<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
||||
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 (
|
||||
<TCollapse
|
||||
@@ -52,18 +62,10 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
|
||||
<div className="relative h-64 border-t border-slate-100">
|
||||
{graph ? (
|
||||
<>
|
||||
{/* 内嵌预览:节点列表,点击「大图」看完整交互画布 */}
|
||||
<FlowGraphPreview graph={graph} streaming={streaming} />
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
className="absolute right-2 top-2 z-10 bg-white/90"
|
||||
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
||||
onClick={() => setFullscreen(true)}
|
||||
>
|
||||
大图
|
||||
</TButton>
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<MafFlowGraphOpenButton onClick={() => setFullscreen(true)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-slate-400">
|
||||
@@ -73,25 +75,26 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
|
||||
</div>
|
||||
</TCollapse.Panel>
|
||||
|
||||
{/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */}
|
||||
{fullscreen && graph && (
|
||||
<FlowGraphPanel
|
||||
data={graph}
|
||||
streaming={streaming}
|
||||
onClose={() => setFullscreen(false)}
|
||||
orientation="vertical"
|
||||
miniMap
|
||||
/>
|
||||
)}
|
||||
<MafFlowGraphDialog
|
||||
visible={fullscreen}
|
||||
onClose={() => setFullscreen(false)}
|
||||
checkpoint={checkpoint}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</TCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板;
|
||||
* 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。
|
||||
* 内嵌预览:轻量「节点列表」(点击「大图」看完整结构化视图)。
|
||||
*/
|
||||
function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) {
|
||||
function FlowGraphPreview({
|
||||
graph,
|
||||
streaming,
|
||||
}: {
|
||||
graph: ReturnType<typeof buildMafFlowGraph> extends infer T ? Exclude<T, null> : never;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2">
|
||||
{graph.title && (
|
||||
@@ -104,16 +107,13 @@ function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streamin
|
||||
return (
|
||||
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-white px-1 py-0.5 text-[9px] font-mono text-slate-400">
|
||||
{n.type}
|
||||
</span>
|
||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
||||
</TTag>
|
||||
<span className="truncate text-xs text-slate-700">{n.label}</span>
|
||||
{n.group && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-slate-400">{n.group}</span>
|
||||
)}
|
||||
</div>
|
||||
{incomers.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.join(", ")}</div>
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.length} 个上游</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<ListChecks className="h-4 w-4 text-slate-500" />
|
||||
事实账本
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{total} 条</span>
|
||||
</header>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-slate-400">尚无事实账本数据</p>
|
||||
) : (
|
||||
<TCollapse defaultExpandAll className="rounded border border-slate-100">
|
||||
<LedgerSection
|
||||
value="candidate"
|
||||
icon={<FileQuestion className="h-3.5 w-3.5 text-amber-500" />}
|
||||
title="候选事实"
|
||||
badgeTheme="warning"
|
||||
warn="未经交叉核验"
|
||||
count={candidate.length}
|
||||
>
|
||||
{candidate.map((f) => (
|
||||
<FactItem key={`c-${f.candidate_id}`} fact={f} confirmed={false} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="confirmed"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
title="已确认事实"
|
||||
badgeTheme="success"
|
||||
count={confirmed.length}
|
||||
>
|
||||
{confirmed.map((f) => (
|
||||
<FactItem key={`f-${f.candidate_id}`} fact={f} confirmed />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="pending"
|
||||
icon={<ShieldAlert className="h-3.5 w-3.5 text-amber-600" />}
|
||||
title="待审批动作"
|
||||
badgeTheme="warning"
|
||||
count={pending.length}
|
||||
>
|
||||
{pending.map((a, i) => (
|
||||
<ActionItem key={`p-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="executed"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
title="已执行动作"
|
||||
badgeTheme="success"
|
||||
count={executed.length}
|
||||
>
|
||||
{executed.map((a, i) => (
|
||||
<ActionItem key={`e-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="failed"
|
||||
icon={<AlertTriangle className="h-3.5 w-3.5 text-red-500" />}
|
||||
title="失败或未知动作"
|
||||
badgeTheme="danger"
|
||||
count={failedOrUnknown.length}
|
||||
>
|
||||
{failedOrUnknown.map((a, i) => (
|
||||
<ActionItem key={`u-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="conflicts"
|
||||
icon={<GitBranch className="h-3.5 w-3.5 text-red-500" />}
|
||||
title="冲突"
|
||||
badgeTheme="danger"
|
||||
count={conflicts.length}
|
||||
>
|
||||
{conflicts.map((c, i) => (
|
||||
<ConflictItem key={`x-${i}`} conflict={c} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
</TCollapse>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 分区壳 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<TCollapse.Panel
|
||||
value={value}
|
||||
header={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{icon}
|
||||
<span className="text-sm font-medium text-slate-700">{title}</span>
|
||||
<TTag size="small" theme={badgeTheme} variant="light">{count}</TTag>
|
||||
{warn && count > 0 && (
|
||||
<TTag size="small" theme="warning" variant="light-outline">{warn}</TTag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</TCollapse.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 条目渲染 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function agentTag(agentId?: string): React.ReactNode {
|
||||
if (!agentId) return null;
|
||||
return (
|
||||
<TTag size="small" theme="default" variant="outline" title={agentId}>
|
||||
{truncate(agentId, 16)}
|
||||
</TTag>
|
||||
);
|
||||
}
|
||||
|
||||
function FactItem({ fact, confirmed }: { fact: MafCandidateFact | MafConfirmedFact; confirmed: boolean }) {
|
||||
return (
|
||||
<div className={`rounded border px-2 py-1.5 ${confirmed ? "border-emerald-100 bg-emerald-50/40" : "border-amber-100 bg-amber-50/40"}`}>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{confirmed ? (
|
||||
<TTag size="small" theme="success" variant="light">已确认</TTag>
|
||||
) : (
|
||||
<TTag size="small" theme="warning" variant="light">候选 · 未经核验</TTag>
|
||||
)}
|
||||
{agentTag(fact.agent_id)}
|
||||
{"confirmed_by" in fact && fact.confirmed_by && (
|
||||
<span className="text-[10px] text-slate-400">确认方:{truncate(fact.confirmed_by, 16)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-xs text-slate-700">{fact.text}</p>
|
||||
{"evidence_refs" in fact && (fact as MafConfirmedFact).evidence_refs?.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
||||
证据 {fact.evidence_refs.length} 条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作条目:只展示白名单安全字段。
|
||||
* 绝不展示 target_key / idempotency_key / 原始 result / 原始 arguments。
|
||||
*/
|
||||
function ActionItem({ action }: { action: MafLedgerAction }) {
|
||||
return (
|
||||
<div className="rounded border border-slate-100 bg-slate-50/60 px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{action.tool_name && (
|
||||
<span className="text-xs font-medium text-slate-700">{truncate(action.tool_name, 32)}</span>
|
||||
)}
|
||||
{action.status && (
|
||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
||||
)}
|
||||
{agentTag(action.agent_id)}
|
||||
</div>
|
||||
{action.text && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-[11px] text-slate-500">{action.text}</p>
|
||||
)}
|
||||
{action.external_object_id && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
||||
对象:{truncate(String(action.external_object_id), 32)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictItem({ conflict }: { conflict: MafLedgerConflict }) {
|
||||
return (
|
||||
<div className="rounded border border-red-100 bg-red-50/40 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<TTag size="small" theme="danger" variant="light">冲突</TTag>
|
||||
{agentTag(conflict.agent_id)}
|
||||
</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-xs text-slate-700">{conflict.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 工具 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
||||
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 (
|
||||
<TDialog
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
||||
{graph && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
||||
</span>
|
||||
)}
|
||||
{streaming && (
|
||||
<TTag size="small" theme="primary" variant="light">绘制中</TTag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width="760px"
|
||||
closeOnEscKeydown
|
||||
footer={null}
|
||||
>
|
||||
{graph ? (
|
||||
<FlowGraphBody graph={graph} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 text-xs text-slate-400">
|
||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
||||
</div>
|
||||
)}
|
||||
</TDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 节点卡片列表 + 上下游连接。 */
|
||||
function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
|
||||
const incomingByNode = useMemo(() => {
|
||||
const m = new Map<string, { from: string; label?: string }[]>();
|
||||
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<string, { to: string; label?: string }[]>();
|
||||
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 (
|
||||
<div className="max-h-[70vh] space-y-2 overflow-y-auto py-1">
|
||||
{graph.title && (
|
||||
<div className="text-xs font-medium text-slate-500">{graph.title}</div>
|
||||
)}
|
||||
|
||||
{graph.nodes.map((n) => {
|
||||
const inc = incomingByNode.get(n.id) ?? [];
|
||||
const out = outgoingByNode.get(n.id) ?? [];
|
||||
const isFocused = focusedId === n.id;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`cursor-pointer rounded transition ${isFocused ? "ring-1 ring-blue-300" : ""}`}
|
||||
onClick={() => setFocusedId(isFocused ? null : n.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setFocusedId(isFocused ? null : n.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TCard bordered>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
||||
</TTag>
|
||||
<span className="text-sm font-semibold text-slate-800">{n.label}</span>
|
||||
{inc.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-slate-400">← {inc.length}</span>
|
||||
)}
|
||||
{out.length > 0 && (
|
||||
<span className="text-[10px] text-slate-400">→ {out.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{(isFocused || n.detail) && n.detail && (
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-xs text-slate-600">
|
||||
{n.detail}
|
||||
</p>
|
||||
)}
|
||||
{isFocused && (inc.length > 0 || out.length > 0) && (
|
||||
<div className="mt-2 space-y-1 border-t border-slate-100 pt-1.5">
|
||||
{inc.length > 0 && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
<span className="text-slate-400">上游:</span>
|
||||
{inc.map((e, i) => (
|
||||
<button
|
||||
key={`in-${i}`}
|
||||
type="button"
|
||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.from); }}
|
||||
>
|
||||
{labelOf(e.from)}
|
||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{out.length > 0 && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
<span className="text-slate-400">下游:</span>
|
||||
{out.map((e, i) => (
|
||||
<button
|
||||
key={`out-${i}`}
|
||||
type="button"
|
||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.to); }}
|
||||
>
|
||||
{labelOf(e.to)}
|
||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TCard>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{focused && (
|
||||
<div className="sticky bottom-0 flex items-center justify-between rounded border border-slate-200 bg-white/95 px-2 py-1 text-[10px] text-slate-400 backdrop-blur">
|
||||
<span>已选:{focused.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-0.5 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => setFocusedId(null)}
|
||||
>
|
||||
<X className="h-3 w-3" /> 取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「大图」触发按钮的封装(内嵌预览面板里点开弹窗用)。 */
|
||||
export function MafFlowGraphOpenButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
大图
|
||||
</TButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** 类型图例(Collapse 折叠壳里展示节点类型含义)。 */
|
||||
export function MafFlowGraphLegend() {
|
||||
return (
|
||||
<TCollapse className="rounded-lg border border-slate-200 bg-white">
|
||||
<TCollapse.Panel value="legend" header="节点类型说明">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(Object.keys(MAF_FLOW_NODE_TYPE_LABEL) as MafFlowNodeType[]).map((t) => (
|
||||
<TTag key={t} size="small" theme={NODE_TYPE_THEME[t]} variant="light-outline">
|
||||
{MAF_FLOW_NODE_TYPE_LABEL[t]}
|
||||
</TTag>
|
||||
))}
|
||||
</div>
|
||||
</TCollapse.Panel>
|
||||
</TCollapse>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === "waiting_approval" && (
|
||||
{run.status === "waiting_approval" && canResume && (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
@@ -116,6 +131,15 @@ export function MafRunHeader({ run }: MafRunHeaderProps) {
|
||||
恢复
|
||||
</TButton>
|
||||
)}
|
||||
{run.status === "waiting_approval" && !canResume && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] text-slate-400"
|
||||
title="审批清零后由后端自动恢复;自动恢复未生效时才会出现手动恢复按钮"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resumeBlockedHint ?? "等待审批处理或自动恢复…"}
|
||||
</span>
|
||||
)}
|
||||
{run.status === "failed" && (
|
||||
<TButton
|
||||
theme="default"
|
||||
|
||||
@@ -31,7 +31,7 @@ const POLL_INTERVAL = 1500;
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
/** 按 seq 去重合并(新事件覆盖同 seq 旧事件,正常不会发生但做幂等保险)。 */
|
||||
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
export function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
if (incoming.length === 0) return prev;
|
||||
const bySeq = new Map<number, MafEvent>();
|
||||
for (const e of prev) bySeq.set(e.seq, e);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ActionBucketKey, { label: string; hint: string }> = {
|
||||
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<ActionBucketKey, MafAction[]>();
|
||||
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)!,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<string, "evidence" | "tool">();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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<MafFlowNodeType, string> = {
|
||||
start: "开始",
|
||||
end: "结束",
|
||||
stage: "阶段",
|
||||
task: "任务",
|
||||
decision: "判断",
|
||||
risk: "风险",
|
||||
deliverable: "交付物",
|
||||
role: "角色",
|
||||
};
|
||||
|
||||
const VALID_TYPES = new Set<string>(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<string, unknown> | 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<string, unknown> = {};
|
||||
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<string, unknown>[] {
|
||||
const keyIdx = text.indexOf(key);
|
||||
if (keyIdx < 0) return [];
|
||||
const arrStart = text.indexOf("[", keyIdx);
|
||||
if (arrStart < 0) return [];
|
||||
const out: Record<string, unknown>[] = [];
|
||||
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<string, unknown> | null = null;
|
||||
try {
|
||||
const j = JSON.parse(objSlice) as unknown;
|
||||
if (j && typeof j === "object" && !Array.isArray(j)) {
|
||||
parsed = j as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsed = looseParseObject(objSlice);
|
||||
}
|
||||
if (parsed) out.push(parsed);
|
||||
objStart = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "]" && depth === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toNode(raw: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>;
|
||||
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<string, unknown>): 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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
|
||||
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<string, "evidence" | "tool">();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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<string, RiskTheme> = {
|
||||
R0: "success",
|
||||
R1: "success",
|
||||
R2: "warning",
|
||||
R3: "danger",
|
||||
R4: "danger",
|
||||
// 兜底:后端可能返回旧值
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "danger",
|
||||
};
|
||||
|
||||
const RISK_LABEL_MAP: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>).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<string, unknown> = {};
|
||||
let fieldCount = 0;
|
||||
for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user