- 历史「分析记录」卡片/侧边栏列表:把「第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>
391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
/**
|
||
* 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 })}`;
|
||
}
|