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