feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch

Backend:
- Conversation API at /api/maf-roundtable/conversations: create/list/detail,
  paginated messages, atomic send+run start, owner-only isolation,
  admin read-only diagnostic access
- SSE real-time event stream (GET /conversations/{id}/stream) backed by
  ConversationEventHub + conversation_events store; multi-worker deployment doc
- Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py):
  routing resolved from trusted MAF deployment config instead of manager-chosen
  agent ids; config.yaml keeps stage-3 routing deployment-owned
  (default_manager_agent_id / seat ids / business_routes / max_seats_per_run)
- Conversation persistence (conversations/messages stores) + v2 migration script
- Tests: conversations, conversation stream, migration, phase3 conversation dispatch

Frontend:
- Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with
  component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events
- Session form lib, flow-graph dialog / run-detail / sessions page updates,
  routes and nav tweaks
- Docs: R0 legacy interaction baseline mapping, updated implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-03 12:09:47 +08:00
1 parent df408831e5
commit 99642fca6c
46 files changed
+6358 -1306

No files matched your search

@@ -47,6 +47,8 @@ export interface MafFlowGraphData {
title: string;
nodes: MafFlowGraphNode[];
edges: MafFlowGraphEdge[];
/** 是否因规模上限截断了节点/边/源文本(弱模型超大输出保护)。 */
truncated?: boolean;
}
/** 节点类型 → 中文标签(图例 / 类型徽章)。 */
@@ -65,6 +67,21 @@ const VALID_TYPES = new Set<string>(Object.keys(MAF_FLOW_NODE_TYPE_LABEL));
export const MAF_FLOW_FENCE_OPEN = "```flow-json";
// ── 规模上限(弱模型超大输出保护)─────────────────────────────────────────
const MAX_FLOW_SOURCE_CHARS = 100_000;
const MAX_FLOW_NODES = 200;
const MAX_FLOW_EDGES = 400;
const MAX_FLOW_LABEL_CHARS = 160;
const MAX_FLOW_DETAIL_CHARS = 2_000;
const MAX_FLOW_TITLE_CHARS = 160;
/** 把文本截断到 max 字符(超出时追加 …),用于 node label / detail / graph title。 */
function clampText(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}…`;
}
// ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)─────────────────────────
function asString(v: unknown): string {
@@ -179,14 +196,20 @@ function collectObjects(text: string, key: string): Record<string, unknown>[] {
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 labelRaw = asString(raw.label) || asString(raw.name);
if (!id || !labelRaw) 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 detailRaw = 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 };
return {
id,
label: clampText(labelRaw, MAX_FLOW_LABEL_CHARS),
type,
detail: detailRaw ? clampText(detailRaw, MAX_FLOW_DETAIL_CHARS) : undefined,
group,
};
}
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): MafFlowGraphEdge[] {
@@ -226,6 +249,12 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
let truncated = false;
if (body.length > MAX_FLOW_SOURCE_CHARS) {
body = body.slice(0, MAX_FLOW_SOURCE_CHARS);
truncated = true;
}
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
@@ -235,6 +264,10 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
title = titleMatch[1];
}
}
if (title.length > MAX_FLOW_TITLE_CHARS) {
title = clampText(title, MAX_FLOW_TITLE_CHARS);
truncated = true;
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes: MafFlowGraphNode[] = [];
@@ -253,15 +286,26 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
}
if (nodes.length === 0) return null;
if (nodes.length > MAX_FLOW_NODES) {
truncated = true;
}
const cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
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;
if (!cappedNodeIds.has(e.from) || !cappedNodeIds.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 };
let cappedEdges = validEdges;
if (validEdges.length > MAX_FLOW_EDGES) {
cappedEdges = validEdges.slice(0, MAX_FLOW_EDGES);
truncated = true;
}
return { title, nodes: cappedNodes, edges: cappedEdges, truncated };
}
// ── confirmed_facts 证据图 ──────────────────────────────────────────────────
@@ -332,7 +376,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
detail: clampText(fact.text, MAX_FLOW_DETAIL_CHARS),
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
@@ -349,7 +393,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
detail: clampText(refStr, MAX_FLOW_DETAIL_CHARS),
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
@@ -365,7 +409,21 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
let truncated = false;
let cappedNodes = nodes;
let cappedEdges = dedupEdges;
if (nodes.length > MAX_FLOW_NODES) {
truncated = true;
cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
cappedEdges = dedupEdges.filter((e) => cappedNodeIds.has(e.from) && cappedNodeIds.has(e.to));
}
if (cappedEdges.length > MAX_FLOW_EDGES) {
truncated = true;
cappedEdges = cappedEdges.slice(0, MAX_FLOW_EDGES);
}
return { title: "事实证据图", nodes: cappedNodes, edges: cappedEdges, truncated };
}
/**
@@ -0,0 +1,164 @@
/**
* MAF 会商创建表单的纯逻辑层。
*
* 把「从 health 默认值初始化表单」「校验最大轮数」「经理互斥过滤席位」「构建只含改写项的
* 请求体」等逻辑抽成纯函数,作为组件与测试的共同事实源。组件不得在内部复制这些逻辑,
* 测试必须 import 这里的真实函数,避免「实现坏掉、测试仍通过」。
*/
export interface HealthDefaults {
default_mode?: string;
default_write_policy?: string;
default_max_rounds?: number;
}
export interface SessionFormState {
title: string;
taskText: string;
mode: string;
managerAgentId: string;
seatAgentIds: string[];
modelName: string;
reasoningEffort: string;
/** 文本输入,空串表示「使用默认」。 */
maxRounds: string;
writePolicy: string;
showAdvanced: boolean;
}
/**
* 根据 health 返回的后端默认值创建一份干净的初始表单。
* - mode 默认取 health.default_mode(兜底 structured);
* - writePolicy 默认取 health.default_write_policy(空串表示使用默认);
* - maxRounds 默认取 health.default_max_rounds 的字符串(空串表示使用默认)。
*
* 注意:mode 即便来自 health 也只用作 UI 初始展示;提交时是否真的传回后端,
* 由 buildSessionOverrides 按「是否被改写」决定(见下)。
*/
export function createInitialSessionForm(
health: HealthDefaults | null | undefined,
): SessionFormState {
return {
title: "",
taskText: "",
mode: health?.default_mode ?? "structured",
managerAgentId: "",
seatAgentIds: [],
modelName: "",
reasoningEffort: "",
maxRounds:
typeof health?.default_max_rounds === "number"
? String(health.default_max_rounds)
: "",
writePolicy: health?.default_write_policy ?? "",
showAdvanced: false,
};
}
export interface MaxRoundsValidation {
valid: boolean;
value?: number;
error?: string;
}
/**
* 校验最大轮数输入。空串视为有效(= 使用后端默认)。必须是 1–64 的整数。
*/
export function validateMaxRounds(input: string): MaxRoundsValidation {
const trimmed = input.trim();
if (!trimmed) return { valid: true };
const n = Number(trimmed);
if (!Number.isInteger(n) || n < 1 || n > 64) {
return { valid: false, error: "最大轮数必须为 1–64 的整数" };
}
return { valid: true, value: n };
}
/**
* 经理与席位互斥(后端参与者唯一约束):切换经理时,从已选席位中移除该 agent。
*/
export function filterSeatsForManager(seats: string[], managerId: string): string[] {
if (!managerId) return seats;
return seats.filter((id) => id !== managerId);
}
/**
* 切换某个席位的选择(toggle)。若该 agent 已是经理,则拒绝(返回原数组)。
*/
export function toggleSeat(
seats: string[],
agentId: string,
managerAgentId: string,
): string[] {
if (agentId === managerAgentId) return seats;
return seats.includes(agentId)
? seats.filter((id) => id !== agentId)
: [...seats, agentId];
}
/**
* 展示一行只读的后端默认摘要(高级项折叠时用)。值只来自 health,绝不硬编码。
*/
export function defaultsSummaryLine(health: HealthDefaults | null | undefined): string {
if (!health) return "正在读取后端默认配置…";
return `当前后端默认:${health.default_mode} | ${health.default_write_policy} | 最大 ${health.default_max_rounds} 轮`;
}
export interface SessionOverrides {
mode?: "structured" | "group_chat";
writePolicy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
maxRounds?: number;
}
/**
* 只产出「用户相对后端默认确实改写了」的字段。
*
* 设计:用户没改写 → 不传 → 后端决定(符合「最好不传对应字段」)。
* 模式必须是 structured/group_chat 之一才算有效改写;写入策略必须是合法枚举值;
* 最大轮数走 validateMaxRounds 校验。
*/
export function buildSessionOverrides(
form: Pick<SessionFormState, "mode" | "writePolicy" | "maxRounds">,
defaults: HealthDefaults | null | undefined,
): { overrides: SessionOverrides; maxRoundsError?: string } {
const result: SessionOverrides = {};
let maxRoundsError: string | undefined;
// 模式:仅当与后端默认不同且合法时传
if (form.mode !== (defaults?.default_mode ?? "structured")) {
if (form.mode === "structured" || form.mode === "group_chat") {
result.mode = form.mode;
}
}
// 写入策略:仅当与后端默认不同且合法时传
const policyDefault = defaults?.default_write_policy ?? "";
if (form.writePolicy !== policyDefault) {
if (
form.writePolicy === "auto" ||
form.writePolicy === "confirm_high_risk" ||
form.writePolicy === "confirm_all_writes"
) {
result.writePolicy = form.writePolicy;
}
// 空串 = 使用默认,不传
}
// 最大轮数:空串不传;非空走校验
const roundsValidation = validateMaxRounds(form.maxRounds);
if (form.maxRounds.trim()) {
if (!roundsValidation.valid) {
maxRoundsError = roundsValidation.error;
} else if (roundsValidation.value !== undefined) {
const defaultRounds =
typeof defaults?.default_max_rounds === "number"
? defaults.default_max_rounds
: null;
if (defaultRounds === null || roundsValidation.value !== defaultRounds) {
result.maxRounds = roundsValidation.value;
}
}
}
return { overrides: result, maxRoundsError };
}