feat(roundtable): taskId 会商记录独立存储(不分权) + 第三步生成过程对话/步骤条入库

taskId 深链会商存储独立化:
- 新增独立表 roundtable_task_drafts + 路由 /api/roundtable-task-drafts,只按 task_id
  归属、完全不按 user 分权(created_by 仅审计),与个人 roundtable_drafts 物理分离;
  个人草稿表恢复严格按用户隔离(去掉原 task 放权)。
- 后台研讨作业按 taskId 共享:roundtable_jobs 加 task_id,get/get_by_draft/stream/
  resume/cancel 支持 ?task_id= 跨用户读取与控制,作业报告写回 task-draft 存储。
- 前端:新增 api/task-drafts.ts 客户端,useDraftPersistence / useRoundtableDrafts /
  roundtable-jobs / DispatchChainJobModal / RoundtablePlanningPage 按 taskId 路由读写。
- 迁移 20260618_01 + column_additions_mysql.sql(jobs.task_id) + 数据搬迁脚本
  scripts/migrate_roundtable_task_drafts.py(把历史 task_id 行迁出个人表)。

第三步聊天记录像第二步一样存全:
- 总结报告 / 经典看板 / 大屏 三条流的生成过程对话(中间文字 + 步骤条 + stub)落库,
  加载草稿整段回显,而非只剩最终 md / 流程图 / HTML。DraftStep3Snapshot 扩展
  dialogues/seatStubMessages 槽位;存稿走页面 render 期 ref,恢复走各 hook hydrateSaved
  (旧草稿无字段则回退单条「历史记录」气泡)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-18 15:11:50 +08:00
1 parent 500a562bb6
commit a8da6c2632
29 files changed
+1191 -263

No files matched your search

@@ -133,6 +133,18 @@ export interface DraftStep3Snapshot {
* 与经典 `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[]>;
}
/**
@@ -152,6 +164,15 @@ export interface DraftStep3SummarySnapshot {
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[]>;
}
/** Lightweight metadata row returned by the list endpoint. */
@@ -206,7 +227,7 @@ export interface DraftUpdatePayload {
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
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}`);
@@ -217,8 +238,9 @@ async function readJson<T>(res: Response, ctx: string): Promise<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.
interface ServerDraftMeta {
export interface ServerDraftMeta {
id: string;
task_id?: string | null;
title: string;
@@ -229,13 +251,13 @@ interface ServerDraftMeta {
job_id?: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
export interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
function toMeta(s: ServerDraftMeta): DraftMeta {
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,
@@ -249,7 +271,7 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
};
}
function toRecord(s: ServerDraftFull): DraftRecord {
export function toRecord(s: ServerDraftFull): DraftRecord {
return {
...toMeta(s),
step1: s.step1 ?? null,
@@ -258,7 +280,7 @@ function toRecord(s: ServerDraftFull): DraftRecord {
};
}
function fromCreate(p: DraftCreatePayload) {
export function fromCreate(p: DraftCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
...(p.taskId ? { task_id: p.taskId } : {}),
@@ -270,7 +292,7 @@ function fromCreate(p: DraftCreatePayload) {
};
}
function fromUpdate(p: DraftUpdatePayload) {
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;
@@ -298,27 +320,6 @@ export async function getDraft(id: string): Promise<DraftRecord | null> {
return toRecord(data);
}
/**
* 按外部任务 id 取最新一条草稿(后端忽略 user,不分权)。无则返回 null(404)。
* 无界嵌入抽屉打开时用于「该任务有聊天记录则回显最新一条」。
*/
export async function getLatestDraftByTask(taskId: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get latest draft by task");
return toRecord(data);
}
/**
* 按外部任务 id 列出该任务全部草稿(轻量 meta,newest first;后端忽略 user,不分权)。
* taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
*/
export async function listDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts by task");
return (data.drafts ?? []).map(toMeta);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
@@ -81,6 +81,8 @@ function toStartBody(p: StartJobPayload) {
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,
@@ -131,23 +133,31 @@ export function startJobKeepalive(payload: StartJobPayload): void {
}
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
/** 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。 */
export async function getJobByDraft(draftId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" });
/** 查某草稿最近一个作业。无 → 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;
}
/** 回答澄清后续跑。 */
export async function resumeJob(jobId: string, answer: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, {
/** 回答澄清后续跑。`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 }),
@@ -155,9 +165,9 @@ export async function resumeJob(jobId: string, answer: string): Promise<Roundtab
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。 */
export async function cancelJob(jobId: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" });
/** 取消作业。`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"));
}
@@ -177,12 +187,12 @@ export interface StreamJobHandlers {
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers): () => void {
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`, {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream${taskQuery(taskId, "?")}`, {
method: "GET",
signal: controller.signal,
});
@@ -0,0 +1,83 @@
/**
* **Task-scoped** roundtable draft storage (taskId 深链聊天记录).
*
* Separate from the per-user `drafts.ts` client: records here are keyed **only by
* `taskId`** and are **not partitioned by user** (the deep-link audience shares
* them). Hits `/api/roundtable-task-drafts` (table `roundtable_task_drafts`),
* physically distinct from the personal `roundtable_drafts` store so the two
* never mix.
*
* The wire shape matches the personal store, so we reuse its types + mappers.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import {
fromCreate,
fromUpdate,
readJson,
toMeta,
toRecord,
type DraftCreatePayload,
type DraftMeta,
type DraftRecord,
type DraftUpdatePayload,
type ServerDraftFull,
type ServerDraftMeta,
} from "@/roundtable-planning/api/drafts";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-task-drafts`;
/** 按外部任务 id 列出该任务全部聊天记录(轻量 meta,newest first;不分权)。 */
export async function listTaskDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list task drafts");
return (data.drafts ?? []).map(toMeta);
}
/** 按外部任务 id 取最新一条聊天记录(不分权)。无则 null(404)。 */
export async function getLatestTaskDraftByTask(taskId: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get latest task draft");
return toRecord(data);
}
/** Full task draft by id (不分权). Returns null on 404. */
export async function getTaskDraft(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 task draft");
return toRecord(data);
}
/** Create a task draft. `payload.taskId` is required. */
export async function createTaskDraft(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 task draft");
return toRecord(data);
}
export async function updateTaskDraft(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 task draft");
return toRecord(data);
}
export async function deleteTaskDraft(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 task draft failed (${res.status}): ${text}`);
}
}
@@ -18,6 +18,11 @@ export interface DispatchChainJobModalProps {
onOpenChange: (open: boolean) => void;
title?: string;
onView?: (job: RoundtableJob) => void;
/**
* 外部任务 id(taskId 深链会商)。非空 → 按 task 共享读取/控制作业(不分权),
* 任何人打开同一 task 都能看在跑的研讨流程图。普通模式留空。
*/
taskId?: string | null;
}
export function DispatchChainJobModal({
@@ -26,6 +31,7 @@ export function DispatchChainJobModal({
onOpenChange,
title,
onView,
taskId = null,
}: DispatchChainJobModalProps) {
const [job, setJob] = useState<RoundtableJob | null>(null);
@@ -36,24 +42,28 @@ export function DispatchChainJobModal({
}
let cancelled = false;
// 首帧快照:弹窗一打开就有内容,不必等 SSE 第一帧。
void getJob(jobId)
void getJob(jobId, taskId)
.then((j) => {
if (!cancelled && j) setJob(j);
})
.catch(() => {});
const unsubscribe = streamJob(jobId, {
onProgress: (j) => {
if (!cancelled) setJob(j);
const unsubscribe = streamJob(
jobId,
{
onProgress: (j) => {
if (!cancelled) setJob(j);
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
});
taskId,
);
return () => {
cancelled = true;
unsubscribe();
};
}, [open, jobId]);
}, [open, jobId, taskId]);
return (
<DispatchChainModal
@@ -75,7 +85,7 @@ export function DispatchChainJobModal({
jobId
? (answer) => {
// 乐观更新为 running,随后 SSE 会推真状态续上。
void resumeJob(jobId, answer)
void resumeJob(jobId, answer, taskId)
.then((j) => setJob(j))
.catch(() => {});
}
@@ -84,7 +94,7 @@ export function DispatchChainJobModal({
onCancel={
jobId
? () => {
void cancelJob(jobId)
void cancelJob(jobId, taskId)
.then((j) => setJob(j))
.catch(() => {});
}
@@ -24,14 +24,22 @@ import {
deleteDraft,
deriveDraftName,
getDraft,
getLatestDraftByTask,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftCreatePayload,
type DraftRecord,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
type DraftUpdatePayload,
} from "@/roundtable-planning/api/drafts";
import {
createTaskDraft,
deleteTaskDraft,
getLatestTaskDraftByTask,
getTaskDraft,
listTaskDraftsByTask,
updateTaskDraft,
} from "@/roundtable-planning/api/task-drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
@@ -157,6 +165,27 @@ export function useDraftPersistence(
taskIdRef.current = taskId ?? null;
}, [taskId]);
// taskId 模式下所有 CRUD 走**独立的 task-draft 存储**(不分权);否则走个人草稿存储。
// 读 taskIdRef.current(而非闭包捕获)以在 render 前拿到最新值。
const apiCreate = useCallback(
(payload: DraftCreatePayload): Promise<DraftRecord> =>
taskIdRef.current ? createTaskDraft(payload) : createDraft(payload),
[],
);
const apiUpdate = useCallback(
(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> =>
taskIdRef.current ? updateTaskDraft(id, patch) : updateDraft(id, patch),
[],
);
const apiGet = useCallback(
(id: string): Promise<DraftRecord | null> => (taskIdRef.current ? getTaskDraft(id) : getDraft(id)),
[],
);
const apiDelete = useCallback(
(id: string): Promise<void> => (taskIdRef.current ? deleteTaskDraft(id) : deleteDraft(id)),
[],
);
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
@@ -194,7 +223,7 @@ export function useDraftPersistence(
// taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享);
// 普通模式:列当前用户全部草稿。
const tid = taskIdRef.current;
const metas = tid ? await listDraftsByTask(tid) : await listDrafts();
const metas = tid ? await listTaskDraftsByTask(tid) : await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
@@ -244,7 +273,7 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
@@ -253,7 +282,7 @@ export function useDraftPersistence(
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
}
const created = await createDraft({
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
title: deriveDraftName({
intentReady: step1.intentReady,
@@ -272,7 +301,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -311,7 +340,7 @@ export function useDraftPersistence(
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step2 });
const updated = await apiUpdate(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
@@ -320,7 +349,7 @@ export function useDraftPersistence(
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const created = await createDraft({
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
title: deriveDraftName({
intentReady: step1.intentReady,
@@ -338,7 +367,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
@@ -458,7 +487,7 @@ export function useDraftPersistence(
return (async () => {
let draft;
try {
draft = await getDraft(id);
draft = await apiGet(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
@@ -473,7 +502,7 @@ export function useDraftPersistence(
return true;
})();
},
[applyLoadedDraft, refreshList, triggerToast],
[apiGet, applyLoadedDraft, refreshList, triggerToast],
);
/**
@@ -484,7 +513,7 @@ export function useDraftPersistence(
async (tid: string): Promise<boolean> => {
let draft: DraftRecord | null = null;
try {
draft = await getLatestDraftByTask(tid);
draft = await getLatestTaskDraftByTask(tid);
} catch (err) {
console.warn("[drafts] 按任务加载草稿失败:", err);
return false;
@@ -503,7 +532,7 @@ export function useDraftPersistence(
}
void (async () => {
try {
await deleteDraft(id);
await apiDelete(id);
} catch (err) {
console.warn("[drafts] 删除草稿失败:", err);
triggerToast("⚠️ 删除失败,请重试");
@@ -515,7 +544,7 @@ export function useDraftPersistence(
triggerToast("🗑 已删除历史记录");
})();
},
[refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
[apiDelete, refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
);
const handleStartRename = useCallback((draft: DraftListItem) => {
@@ -534,7 +563,7 @@ export function useDraftPersistence(
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
const updated = await updateDraft(id, { title: trimmed });
const updated = await apiUpdate(id, { title: trimmed });
if (updated) {
await refreshList();
triggerToast("✏️ 已重命名");
@@ -548,7 +577,7 @@ export function useDraftPersistence(
}
})();
},
[refreshList, renameDraftValue, triggerToast],
[apiUpdate, refreshList, renameDraftValue, triggerToast],
);
const handleCancelRename = useCallback(() => {
@@ -11,14 +11,18 @@ import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
import {
deleteTaskDraft,
listTaskDraftsByTask,
updateTaskDraft,
} from "@/roundtable-planning/api/task-drafts";
/**
* @param taskId 外部任务 id(深链 taskId 模式)。非空时只列该任务的草稿
* (一个 taskId 多条、跨用户共享);否则列当前用户全部草稿。
* @param taskId 外部任务 id(深链 taskId 模式)。非空时走**独立的 task-draft 存储**
* (一个 taskId 多条、跨用户共享、不分权);否则列当前用户全部个人草稿。
*/
export function useRoundtableDrafts(taskId?: string | null) {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
@@ -27,7 +31,7 @@ export function useRoundtableDrafts(taskId?: string | null) {
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = taskId ? await listDraftsByTask(taskId) : await listDrafts();
const data = taskId ? await listTaskDraftsByTask(taskId) : await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
@@ -36,20 +40,25 @@ export function useRoundtableDrafts(taskId?: string | null) {
}
}, [taskId]);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = await updateDraft(id, { title });
if (updated) {
setDrafts((prev) =>
prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)),
);
}
}, []);
const rename = useCallback(
async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = taskId ? await updateTaskDraft(id, { title }) : await updateDraft(id, { title });
if (updated) {
setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)));
}
},
[taskId],
);
const remove = useCallback(async (id: string) => {
await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
}, []);
const remove = useCallback(
async (id: string) => {
if (taskId) await deleteTaskDraft(id);
else await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
},
[taskId],
);
return { drafts, loading, refresh, rename, remove };
}
@@ -50,6 +50,12 @@ interface UseStep3DashboardOptions {
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
}
/** 恢复大屏时可带上的历史生成过程对话(文字 + 步骤条)+ stub —— 整段回显用。 */
export interface Step3DashRestore {
dialogues?: unknown[];
seatStubMessages?: Record<string, unknown[]>;
}
export interface UseStep3DashboardReturn {
phase: DashboardPhase;
error: string | null;
@@ -69,10 +75,13 @@ export interface UseStep3DashboardReturn {
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
/** 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 完成气泡,不自动开沙箱。 */
hydrateSaved: (html: string) => void;
/**
* 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 气泡,不自动开沙箱。
* `saved` 带历史完整对话(文字 + 步骤条) + stub 时整段回显,否则重建单条「历史记录」气泡。
*/
hydrateSaved: (html: string, saved?: Step3DashRestore) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string) => void;
hydrateFromJson: (jsonStr: string, saved?: Step3DashRestore) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
openDashboardPreview: () => void;
}
@@ -434,7 +443,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
);
const hydrateSaved = useCallback(
(savedHtml: string) => {
(savedHtml: string, saved?: Step3DashRestore) => {
abort();
runningRef.current = false;
htmlRef.current = savedHtml;
@@ -451,21 +460,39 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(saved?.dialogues)
? (saved!.dialogues as Step2Dialogue[])
: [];
const savedStubs =
saved?.seatStubMessages && typeof saved.seatStubMessages === "object"
? (saved.seatStubMessages as Record<string, StubAiMessage[]>)
: null;
if (savedStubs && Object.keys(savedStubs).length) {
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
const merged = { ...stubRef.current, ...savedStubs };
stubRef.current = merged;
setSeatStubMessages(merged);
}
if (savedDialogues.length) {
setDialogues(savedDialogues);
} else {
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
@@ -475,11 +502,11 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
const hydrateFromJson = useCallback(
(jsonStr: string) => {
(jsonStr: string, saved?: Step3DashRestore) => {
const parsed = parseReportJson(jsonStr);
if (!parsed) return;
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
hydrateSaved(rendered, saved); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)+ 历史对话
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
},
@@ -1034,7 +1034,17 @@ export function useStep3Report(
},
],
};
stubRef.current = stub;
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(snap.reportDialogues)
? (snap.reportDialogues as Step2Dialogue[])
: [];
const savedStubs =
snap.reportSeatStubMessages && typeof snap.reportSeatStubMessages === "object"
? (snap.reportSeatStubMessages as Record<string, StubAiMessage[]>)
: null;
// 合并:合成 stub 保住「恢复后预览」锚点(synthThreadId),历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub = savedStubs ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 HTML(防串台)。
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
REPORT_OUTPUT_PATH,
@@ -1042,33 +1052,40 @@ export function useStep3Report(
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
setSeatStubMessages(stub);
setSeatStubMessages(effectiveStub);
setReportThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
if (savedDialogues.length) {
// 整段回显历史绘制过程(每条气泡的文字内容 + 步骤条都在),与第二步一致。
bubbleIdRef.current = savedDialogues[savedDialogues.length - 1]?.id ?? null;
setDialogues(savedDialogues);
} else {
// 旧草稿没存对话 → 回退重建单条「历史记录」气泡(仅最终产物)。
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
@@ -1410,7 +1410,18 @@ export function useStep3Summary(
],
}
: {};
stubRef.current = stub;
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(snap.dialogues)
? (snap.dialogues as Step2Dialogue[])
: [];
const savedStubs =
snap.seatStubMessages && typeof snap.seatStubMessages === "object"
? (snap.seatStubMessages as Record<string, StubAiMessage[]>)
: null;
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -1427,34 +1438,40 @@ export function useStep3Summary(
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(stub);
setSeatStubMessages(effectiveStub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "方案总结报告智能体",
avatarType: "blue",
content: snap.closing?.trim() || "已从历史记录恢复上次生成的方案总结报告。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: hasMd
? [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已撰写报告 Markdown",
ts: nowTime(),
args: { path: SUMMARY_OUTPUT_PATH },
},
]
: [],
agentThreadId: synthThreadId,
},
]);
if (savedDialogues.length) {
// 整段回显历史研讨过程(每条气泡的文字内容 + 步骤条都在),与第二步一致。
setDialogues(savedDialogues);
} else {
// 旧草稿没存对话 → 回退重建单条「历史记录」气泡(仅最终产物)。
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "方案总结报告智能体",
avatarType: "blue",
content: snap.closing?.trim() || "已从历史记录恢复上次生成的方案总结报告。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: hasMd
? [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已撰写报告 Markdown",
ts: nowTime(),
args: { path: SUMMARY_OUTPUT_PATH },
},
]
: [],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
@@ -107,6 +107,11 @@ export interface RoundtableJob {
/** 启动一个后台作业的入参(对齐后端 `StartJobRequest`)。 */
export interface StartJobPayload {
draftId?: string | null;
/**
* 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
* 其 draft_id 指向独立的 task-draft 存储(最终报告写回那里)。
*/
taskId?: string | null;
/** 任务意图;缺省 seedMessage 时用它作第一轮 leader prompt。 */
intent: string;
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
@@ -557,6 +557,14 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, hydrateStep2Run, mergeActiveRun, step2, step3Report],
);
// 第三步三条生成流(总结报告 / 经典看板 / 大屏)各自生成过程的实时对话(中间文字 + 步骤条)。
// 在 render 阶段镜像进 ref(这些 flow 在本 hook 之后才定义,故用 ref 跨过 TDZ;render 期赋值,
// 保证 autoSave effect 读到的是本次提交已 flush 的最终对话,规避 effect 执行顺序问题)。
const emptyConvo = { dialogues: [] as unknown[], seatStubMessages: {} as Record<string, unknown[]> };
const step3SummaryConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
const step3ReportConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
const step3DashConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({
currentStep,
@@ -566,7 +574,36 @@ export default function RoundtablePlanningPage() {
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
getStep3Snapshot: () => step3Report,
// 存草稿时把第三步三条流(总结报告 / 经典看板 / 大屏)生成过程的完整对话(文字 + 步骤条)
// 一并落库,使加载后能像第二步一样回显整段研讨/绘制过程,而非只有最终 md/流程图/HTML。
// 仅在有实时对话时覆盖(避免对应 flow 尚未 hydrate 时用空对话抹掉已存的)。
getStep3Snapshot: () => {
const rep = step3Report;
if (!rep) return null;
const enriched: DraftStep3Snapshot = { ...rep };
const sr = rep.summaryReport;
const sumLive = step3SummaryConvoRef.current;
if (sr) {
enriched.summaryReport = {
...sr,
dialogues: sumLive.dialogues.length ? sumLive.dialogues : sr.dialogues,
seatStubMessages: Object.keys(sumLive.seatStubMessages).length
? sumLive.seatStubMessages
: sr.seatStubMessages,
};
}
const repLive = step3ReportConvoRef.current;
if (repLive.dialogues.length) {
enriched.reportDialogues = repLive.dialogues;
enriched.reportSeatStubMessages = repLive.seatStubMessages;
}
const dashLive = step3DashConvoRef.current;
if (dashLive.dialogues.length) {
enriched.dashboardDialogues = dashLive.dialogues;
enriched.dashboardSeatStubMessages = dashLive.seatStubMessages;
}
return enriched;
},
hydrateStep3: (snapshot) => setStep3Report(snapshot),
getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId,
@@ -1027,6 +1064,11 @@ export default function RoundtablePlanningPage() {
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {
dialogues: summaryFlow.dialogues,
seatStubMessages: summaryFlow.seatStubMessages,
};
const reportFlow = useStep3Report({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
@@ -1035,6 +1077,11 @@ export default function RoundtablePlanningPage() {
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像经典看板的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3ReportConvoRef.current = {
dialogues: reportFlow.dialogues,
seatStubMessages: reportFlow.seatStubMessages,
};
const dashboardFlow = useStep3Dashboard({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
@@ -1044,6 +1091,11 @@ export default function RoundtablePlanningPage() {
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像大屏的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3DashConvoRef.current = {
dialogues: dashboardFlow.dialogues,
seatStubMessages: dashboardFlow.seatStubMessages,
};
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
@@ -1086,10 +1138,15 @@ export default function RoundtablePlanningPage() {
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
}
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。
const dashSaved = {
dialogues: rep?.dashboardDialogues,
seatStubMessages: rep?.dashboardSeatStubMessages,
};
if (rep?.dashboardJson) {
dashboardFlow.hydrateFromJson(rep.dashboardJson);
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
} else if (rep?.html && isDashboardHtml(rep.html)) {
dashboardFlow.hydrateSaved(rep.html);
dashboardFlow.hydrateSaved(rep.html, dashSaved);
} else {
dashboardFlow.start();
}
@@ -1135,7 +1192,7 @@ export default function RoundtablePlanningPage() {
step2.stopOrchestration();
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
await cancelJob(jobId).catch(() => null);
await cancelJob(jobId, prefillRef.current?.taskId ?? null).catch(() => null);
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
await drafts.handleLoadDraft(draftId);
@@ -1229,8 +1286,9 @@ export default function RoundtablePlanningPage() {
if (agents.length === 0) return null;
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
const tid = prefillRef.current?.taskId ?? null;
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
@@ -1241,6 +1299,7 @@ export default function RoundtablePlanningPage() {
}
const job = await startJob({
draftId: draftId ?? undefined,
taskId: tid ?? undefined, // task 作业:报告写回独立 task-draft 存储、跨用户共享
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
@@ -1288,6 +1347,7 @@ export default function RoundtablePlanningPage() {
const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null;
const draftId = drafts.currentDraftId;
const tid = prefillRef.current?.taskId ?? null;
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
if (draftId) markPendingSuspend(draftId);
@@ -1301,7 +1361,7 @@ export default function RoundtablePlanningPage() {
const savedId = (await drafts.saveStep2SnapshotNow(step2Snapshot, 2)) ?? draftId ?? null;
// 防重复:该草稿已有未终态作业就不再新建(同 startBackgroundJob 的客户端去重,
// 后端另有「同草稿未终态作业幂等返回」兜底)。
const existing = savedId ? await getJobByDraft(savedId).catch(() => null) : null;
const existing = savedId ? await getJobByDraft(savedId, tid).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
@@ -1312,6 +1372,7 @@ export default function RoundtablePlanningPage() {
} else {
const job = await startJob({
draftId: savedId ?? undefined,
taskId: tid ?? undefined,
intent,
agents,
model,
@@ -1374,6 +1435,7 @@ export default function RoundtablePlanningPage() {
if (agents.length === 0) return;
startJobKeepalive({
draftId: drafts.currentDraftId ?? undefined,
taskId: prefillRef.current?.taskId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
@@ -1476,6 +1538,7 @@ export default function RoundtablePlanningPage() {
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
taskId={prefillRef.current?.taskId ?? null}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);