Files
deerflow-code/frontend-web/src/roundtable-planning/api/drafts.ts
T
董宏凯andClaude Opus 4.8 7748661963 feat(roundtable): 历史卡片显示所用业务链(自由模式) + 诊断只收报错日志
- 历史「分析记录」卡片/侧边栏列表:把「第X步」改为本次会商所用业务链名,
  无链条显示「自由模式」。后端 list 轻量 meta 从 step2 正则抽取 chain_title
  (不整体解析大块 JSON),个人草稿 + task 草稿两个 store 都覆盖。
- 会商诊断改为只收集报错日志:record_diagnostic 写库前丢弃 level==info
  运行日志(begin/consensus/gather/job_started),只留 warning/error。
  单点收口覆盖前台/后台/recorder 全部写路径;诊断页标题改「会商报错日志」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:36:31 +08:00

391 lines
16 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.
/**
* Backend-persisted draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted server-side (MySQL) bound to the logged-in user, so it
* follows the account across devices/sessions — replacing the old browser-only
* `localStorage` storage (see git history of `utils/drafts.ts`).
*
* The list endpoint returns lightweight metadata only; the full step1/step2
* snapshots are fetched on demand via {@link getDraft}. This keeps the history
* dropdown cheap and guarantees a load always reads the freshest server copy
* (the old localStorage path loaded from a possibly-stale in-memory array).
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
* because their inline types in the hooks are wide; the hook casts on hydrate.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
activeRunId?: string | null;
runs?: DraftStep2RunSnapshot[];
/**
* The seats actually selected for this session. Restored on load so the
* Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6.
* Optional — drafts saved before 2026-06 won't carry it.
*/
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
seatStubMessages?: Record<string, unknown[]>;
/**
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
* dispatches freely; chain = dispatch follows the saved business chain order;
* dag = layered Stage DAG (stage-internal parallel, stage-to-stage serial,
* see `lib/dag-types.ts`). Optional — drafts saved before this won't carry it
* (hydrate defaults to "recommend").
*/
orchestrationMode?: "recommend" | "chain" | "dag";
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
chain?: { id: string; title: string } | null;
/**
* Layered DAG plan, when `orchestrationMode === "dag"`. Persisted so a reloaded
* draft restores the编排. Optional — null/absent for non-dag drafts. Old `chain`
* drafts are converted on read via `chainToDagPlan` (compat, original draft
* untouched).
*/
orchestrationPlan?: OrchestrationPlan | null;
/**
* dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
* 仅 `orchestrationMode === "dag"` 有意义;缺省 fast。
*/
dagExecMode?: "fast" | "sandbox";
}
export interface DraftStep2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "dag" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain" | "dag";
chain?: { id: string; title: string } | null;
orchestrationPlan?: OrchestrationPlan | null;
/** dag 执行子模式:fast / sandbox(见 DraftStep2Snapshot.dagExecMode)。 */
dagExecMode?: "fast" | "sandbox";
step3?: DraftStep3Snapshot | null;
jobId?: string | null;
jobStatus?: JobStatus | null;
}
/**
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
* so a reloaded draft can show it without depending on the ephemeral report
* thread. Optional — drafts saved before this feature (or never drawn) won't carry it.
*/
export interface DraftStep3Snapshot {
/**
* The full self-contained HTML written to outputs/方案总览.html by the
* `roundtable-report` (HTML 看板) agent. Optional — a draft may only carry the
* Markdown 总结报告 (`summaryReport`) and never draw the HTML 看板.
*/
html?: string;
/** ISO timestamp when the HTML report was generated. */
generatedAt?: string;
/** The inference model used to draw the HTML report. */
model?: string;
/** The HTML agent's closing one-liner (rendered as the report message bubble). */
summary?: string;
/**
* 绘制这份 HTML 时的界面主题(light=浅红 / dark=深蓝)。恢复草稿时与当前主题
* 比对,不一致则提示「重新绘制以匹配当前主题」。旧草稿没有该字段 → 不提示。
*/
themeMode?: "light" | "dark";
/**
* Step 3「总结报告」snapshot — output of the `roundtable-summary` agent
* (Markdown report). Stored alongside (not instead of) the HTML report so the
* two Step-3 surfaces persist independently. Nested in the free-form step3 JSON
* blob, so the backend needs no schema change. Optional.
*/
summaryReport?: DraftStep3SummarySnapshot | null;
/**
* Step 3「大屏多页(模式二)」的结构化数据(report-json 字符串)。**只存数据不存
* HTML** —— 前端查看时用 renderDashboardHtml 按当前主题渲染,主题切换天然适配;
* 与经典 `html`(模式一)并存互不覆盖。后台挂起与手动并行绘制都写这里。Optional.
*/
dashboardJson?: string | null;
/**
* 第三步「结果绘制(经典 HTML 看板)」生成过程的完整对话气泡(中间文字 + 步骤条),与第二步
* `step2RoundtableDialogues` 同形(`Step2Dialogue[]`)。存它使加载后能回显整段绘制过程,而非
* 只有最终 HTML。Optional —— 旧草稿没有该字段,恢复时回退为重建单条「历史记录」气泡。
*/
reportDialogues?: unknown[];
/** 经典看板各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物预览。 */
reportSeatStubMessages?: Record<string, unknown[]>;
/** 第三步「大屏多页」生成过程的完整对话气泡(中间文字 + 步骤条),同 `reportDialogues`。Optional。 */
dashboardDialogues?: unknown[];
/** 大屏各席位 write_file 调用重建的 stub。Optional。 */
dashboardSeatStubMessages?: Record<string, unknown[]>;
}
/**
* Step 3「总结报告」snapshot — the Markdown report written by `roundtable-summary`,
* stored inline so a reloaded draft can show it without depending on the ephemeral
* summary thread. `threadId` keeps the real summary thread id so a reloaded draft
* can continue Q&A / refinement on the same thread (the agent keeps its context).
*/
export interface DraftStep3SummarySnapshot {
/** The full Markdown written to outputs/方案总结报告.md. */
md: string;
/** The real summary thread id — reused for follow-up Q&A / refine after reload. */
threadId?: string;
/** ISO timestamp when the summary was generated. */
generatedAt: string;
/** The inference model used to write it. */
model: string;
/** The agent's closing chat text (the report body it echoed into the conversation). */
closing?: string;
/**
* 第三步「总结报告」生成过程的**完整对话气泡**(含中间文字内容 + 每条气泡的步骤条
* `steps`),与第二步 `step2RoundtableDialogues` 同形(`Step2Dialogue[]`)。存它使加载
* 草稿后能像第二步一样回显整段研讨过程,而不只是最终 md / 流程图。Optional —— 此前
* 的草稿没有该字段,恢复时回退为重建单条「历史记录」气泡。
*/
dialogues?: unknown[];
/** 各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物的虚拟预览。 */
seatStubMessages?: Record<string, unknown[]>;
/**
* 入库状态(idle/asking/running/done/later/declined)。存它使重载草稿后不重复询问
* 已入库/已选稍后的会商;恢复展示用。Optional —— 旧草稿无该字段。
*/
ingestStatus?: string;
}
/** Lightweight metadata row returned by the list endpoint. */
export interface DraftMeta {
id: string;
/** 外部任务 id(无界嵌入抽屉传入);非空时草稿按 task_id 共享、不按 user 分权。 */
taskId?: string | null;
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
/**
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
*/
chainTitle?: string | null;
createdAt: string;
updatedAt: string;
/**
* Latest background-job status for this draft (Phase 6 history-dropdown
* loading badge). Undefined/null when the draft has never been backgrounded.
*/
status?: JobStatus | null;
/** Latest background job id (for opening the dispatch-chain modal / 查看). */
jobId?: string | null;
}
/** Full record including the step snapshots. */
export interface DraftRecord extends DraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
/** Fields accepted when creating a draft. */
export interface DraftCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
/** 外部任务 id(无界嵌入抽屉);设置后草稿按 task_id 共享、不按 user 分权。 */
taskId?: string | null;
title: string;
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
export interface DraftUpdatePayload {
title?: string;
furthestStep?: 1 | 2 | 3;
/** 给已有草稿补绑外部任务 id。 */
taskId?: string | null;
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (furthest_step / created_at / updated_at); the rest
// of the frontend reads camelCase. Map at this boundary so callers stay clean.
// Shared with the task-scoped client (`task-drafts.ts`) — same wire shape.
export interface ServerDraftMeta {
id: string;
task_id?: string | null;
title: string;
furthest_step: number;
chain_title?: string | null;
created_at: string | null;
updated_at: string | null;
status?: string | null;
job_id?: string | null;
}
export interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
export function toMeta(s: ServerDraftMeta): DraftMeta {
const step = Math.min(3, Math.max(1, s.furthest_step || 1)) as 1 | 2 | 3;
return {
id: s.id,
taskId: s.task_id ?? null,
title: s.title ?? "",
furthestStep: step,
chainTitle: s.chain_title ?? null,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
status: (s.status as JobStatus | null | undefined) ?? null,
jobId: s.job_id ?? null,
};
}
export function toRecord(s: ServerDraftFull): DraftRecord {
return {
...toMeta(s),
step1: s.step1 ?? null,
step2: s.step2 ?? null,
step3: s.step3 ?? null,
};
}
export function fromCreate(p: DraftCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
...(p.taskId ? { task_id: p.taskId } : {}),
title: p.title,
furthest_step: p.furthestStep,
step1: p.step1,
step2: p.step2,
...(p.step3 !== undefined ? { step3: p.step3 } : {}),
};
}
export function fromUpdate(p: DraftUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
if (p.taskId !== undefined) out.task_id = p.taskId;
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
if (p.step3 !== undefined) out.step3 = p.step3;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's drafts, newest first (lightweight metadata only). */
export async function listDrafts(): Promise<DraftMeta[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
return (data.drafts ?? []).map(toMeta);
}
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
export async function getDraft(id: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get draft");
return toRecord(data);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create draft");
return toRecord(data);
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update draft");
return toRecord(data);
}
export async function deleteDraft(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete draft failed (${res.status}): ${text}`);
}
}
/**
* Derive a short task-summary name from whatever the user supplied so far.
* Preference: intent objective → first user message → timestamp. Trimmed to
* ~40 chars so dropdown rows stay scannable. (Ported from the old utils/drafts.)
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}