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
@@ -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