Files
deerflow-code/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts
T
董宏凯andClaude Opus 4.8 b1b1ff98f5 feat(roundtable): 深链接业务链抽取规范 + 总结报告文件干净/对话强制核对
深链接会商(taskId+goPath,businessCode)下按所选业务链完整层级强制校正
结构化抽取(flow-json) 与 方案总结报告:

- 新增 harness 层 extraction_spec(app+harness 共用,单份领域知识两角色用法):
  build_structure_directive / build_summary_directive 按 businessCode 取
  · rwfx(6BF):线性 任务→目的→行为体→预期行为→驱动因素→叙事;预期行为↔驱动因素 1:1、余 1:N
  · xdfx(7BF):分叉汇聚 DAG,根=行动;行动→受众目标/关键传播节点(1:N)→脆弱点(1:N)→策略(多对一)
  · 其余业务通用规范(强约束完整性+命名+取数、不套固定模板);business_code 空→零副作用
- 结构化抽取:flow_extract._hierarchy_hint + _validate_messages 用 structure_directive
  权威覆盖默认层级(根治"无论什么业务都按目的→行为体抽/停在行为体");前端 streamFlowExtract
  传 business_code(源 ingestBusinessCode)
- 方案总结报告:前台 multi_agent._special_run(summary) + 后台 build_summary_prompt 注入
  business 完整性核对(StartJobRequest/StartParams 透传)
- 报告产物策略:.md 报告文件保持完整、干净、无需修改(不写 待补充/不足/⚠️);
  「核对说明」(意图对齐+业务链完整性+不足/可补充)改为只在对话回复正文、且每次必出
- 种子 roundtable-structure SOUL 层级顺序改 spec-driven、去 rwfx 写死示例

Tests: test_roundtable_extraction_spec / test_roundtable_summary_prompt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:13:03 +08:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 圆桌后台作业 API 客户端(Phase 4)。
*
* 对接后端 `/api/roundtable-jobs`(Phase 3a):启动「后台挂起」作业、查快照、订阅
* SSE 进度、回答澄清续跑、取消。后端响应已是 camelCase(`RoundtableJobResponse`),
* 故无需 snake_case 转换;唯一加工是给 `dispatchChain` 节点补 `avatarType`(后端不下发,
* 由前端 `getAvatarTypeFor(agentId, order)` 派生,避免把配色表移植到后端而漂移)。
*
* SSE 走「后端轮询 job 表后下发整份快照」的模型(见后端注释):每帧 `data: {event:
* "progress", ...job}`,前端拿到的就是最新整份 `RoundtableJob`,直接渲染即可,无需做
* 增量事件归并。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { reportRoundtableError } from "./diagnostics-report";
import { seatModeToRunParams } from "../lib/seat-mode";
import {
isTerminalJobStatus,
type DispatchChainNode,
type JobDialogue,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "job", event: "job_api_http_error",
message: `后台作业接口「${ctx}」失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
});
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** 给链路节点补 avatarType(后端不下发)。 */
function withAvatar(nodes: unknown): DispatchChainNode[] {
if (!Array.isArray(nodes)) return [];
return nodes.map((n, i) => {
const node = (n ?? {}) as Partial<DispatchChainNode> & { agentId?: string; order?: number };
const agentId = String(node.agentId ?? "");
const order = typeof node.order === "number" ? node.order : i + 1;
return {
agentId,
name: String(node.name ?? agentId),
order,
state: (node.state as DispatchChainNode["state"]) ?? "pending",
avatarType: node.avatarType || getAvatarTypeFor(agentId, order - 1),
...(node.lastSpokeCycle != null ? { lastSpokeCycle: node.lastSpokeCycle } : {}),
};
});
}
/** 后端快照 → 前端 RoundtableJob(容错 + 补 avatarType)。 */
function mapJob(raw: Record<string, unknown>): RoundtableJob {
return {
id: String(raw.id ?? ""),
draftId: (raw.draftId as string | null) ?? null,
status: (raw.status as RoundtableJob["status"]) ?? "queued",
phase: (raw.phase as RoundtableJob["phase"]) ?? "initializing",
cycle: typeof raw.cycle === "number" ? raw.cycle : 0,
dispatchChain: withAvatar(raw.dispatchChain),
activeAgentId: (raw.activeAgentId as string | null) ?? null,
consensusPercentage: typeof raw.consensusPercentage === "number" ? raw.consensusPercentage : 0,
pendingClarification: (raw.pendingClarification as string | null) ?? null,
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
orchestrationPlan: (raw.orchestrationPlan as RoundtableJob["orchestrationPlan"]) ?? null,
error: (raw.error as string | null) ?? null,
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
step3: (raw.step3 as RoundtableJob["step3"]) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
}
function toStartBody(p: StartJobPayload) {
// 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射;
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
draftId: p.draftId ?? null,
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
...(p.taskId ? { taskId: p.taskId } : {}),
intent: p.intent,
agents: p.agents,
model: p.model,
orchestrationMode: p.orchestrationMode ?? "recommend",
chain: p.chain ?? null,
orchestrationPlan: p.orchestrationPlan ?? null,
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
// 每席位技能识别强化映射 {agentId: bool}:沿用所选业务链条里各席位的开关;缺省 {}。
// 后端 run_seat 按 agentId 查表,与各席位 agent 自身开关取 OR。
seat_skill_directives: p.seatSkillDirectives ?? {},
// 每席位推理深度覆盖映射 {agentId: SeatMode|null}:沿用所选业务链条里各席位单独配的推理深度;
// 后端 run_seat 按 agentId 查表,覆盖优先、否则跟随作业级 seatMode(thinking/reasoning/subagent)。
seat_modes: Object.fromEntries(
Object.entries(p.seatModes ?? {}).filter(([, v]) => v != null) as [string, string][],
),
// 研讨前并行取数开关(来源链条 gatherFirst):true = 后台研讨前先并行取数再研讨。缺省 false。
gather_first: p.gatherFirst ?? false,
// 深链接业务码:后台总结报告据此做业务链完整性核对(写实业务如 rwfx 才生效)。空 → 不注入。
business_code: p.businessCode ?? null,
...(seat
? {
thinking_enabled: seat.thinking_enabled,
reasoning_effort: seat.reasoning_effort,
subagent_enabled: seat.subagent_enabled,
}
: {}),
};
}
// ── CRUD / 控制 ───────────────────────────────────────────────────────────────
/** 启动一个后台作业。 */
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
});
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/**
* 启动后台作业(``keepalive`` 版)—— 给 ``beforeunload``(刷新 / 关标签页)兜底用:
* keepalive fetch 在页面卸载后浏览器仍会把请求发完(payload ≤64KB),保证「正在
* 运行的研讨」在用户刷新 / 关页时也能转入后台挂起。fire-and-forget,不读响应;
* 重复触发由后端「同草稿未终态作业幂等返回」兜住,不会重复建作业。
*/
export function startJobKeepalive(payload: StartJobPayload): void {
try {
void apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
keepalive: true,
}).catch(() => {});
} catch {
/* 卸载途中同步异常也不能抛 */
}
}
/** taskId 非空 → 追加 `task_id` 查询参(task 作业按 task 共享、不分权读取/控制)。 */
function taskQuery(taskId: string | null | undefined, sep: "?" | "&"): string {
return taskId ? `${sep}task_id=${encodeURIComponent(taskId)}` : "";
}
/** 查作业快照。404 → null。`taskId` 非空走 task 共享读取(不分权)。 */
export async function getJob(jobId: string, taskId?: string | null): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}${taskQuery(taskId, "?")}`, { method: "GET" });
if (res.status === 404) return null;
return mapJob(await readJson<Record<string, unknown>>(res, "get job"));
}
/** 查某草稿最近一个作业。无 → null。`taskId` 非空走 task 共享读取(不分权)。 */
export async function getJobByDraft(draftId: string, taskId?: string | null): Promise<RoundtableJob | null> {
const res = await apiFetch(
`${BASE()}?draft_id=${encodeURIComponent(draftId)}${taskQuery(taskId, "&")}`,
{ method: "GET" },
);
const data = await readJson<Record<string, unknown> | null>(res, "get job by draft");
return data ? mapJob(data) : null;
}
/** 回答澄清后续跑。`taskId` 非空 → task 共享续跑(不分权)。 */
export async function resumeJob(jobId: string, answer: string, taskId?: string | null): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume${taskQuery(taskId, "?")}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */
export async function cancelJob(jobId: string, taskId?: string | null): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
}
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
export interface StreamJobHandlers {
/** 每收到一帧整份快照就回调(含首帧)。 */
onProgress: (job: RoundtableJob) => void;
/** 终态(done/error/cancelled)时回调一次(在对应的 onProgress 之后)。 */
onDone?: (job: RoundtableJob) => void;
/** 流错误 / 作业不存在。 */
onError?: (detail: string) => void;
}
/**
* 订阅作业 SSE 进度。返回 unsubscribe(中断连接)。
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers, taskId?: string | null): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream${taskQuery(taskId, "?")}`, {
method: "GET",
signal: controller.signal,
});
if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const json = line.slice("data:".length).trim();
if (!json) continue;
let frame: Record<string, unknown>;
try {
frame = JSON.parse(json) as Record<string, unknown>;
} catch {
continue;
}
if (frame.event === "error") {
const detail = String(frame.detail ?? "unknown stream error");
reportRoundtableError({
stage: "job", event: "job_stream_error_frame",
message: `后台作业进度流报错:${detail.slice(0, 500)}`,
jobId, detail: { taskId },
});
handlers.onError?.(detail);
continue;
}
const job = mapJob(frame);
handlers.onProgress(job);
if (isTerminalJobStatus(job.status)) handlers.onDone?.(job);
}
}
} catch (err) {
if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return () => controller.abort();
}