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:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user