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
@@ -86,7 +86,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -245,7 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. **External-task binding (`task_id`)**: drafts carry an optional `task_id` (deep-link / 无界 embed); task-bound drafts are **shared** (the store ignores `user_id` for them, so a task's drafts span users). `GET /by-task/{task_id}/latest` - newest draft for a task (回显最新一条); `GET /by-task/{task_id}` - **all** drafts for a task, newest-first (taskId-mode history dropdown: 一个 taskId 多条记录), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
|
||||
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
|
||||
@@ -63,6 +63,7 @@ from app.gateway.routers import (
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
roundtable_task_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
sensitive_words,
|
||||
@@ -1334,6 +1335,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||||
app.include_router(roundtable_task_drafts.router)
|
||||
|
||||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||
app.include_router(flow_extract.router)
|
||||
|
||||
|
||||
@@ -223,6 +223,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
|
||||
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不按 user 分权)。
|
||||
from deerflow.persistence.roundtable_task_drafts import make_roundtable_task_draft_store
|
||||
|
||||
app.state.roundtable_task_draft_store = make_roundtable_task_draft_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
|
||||
|
||||
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||
@@ -302,6 +307,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
app.state.roundtable_job_store,
|
||||
gateway_factory=gateway_factory,
|
||||
draft_store=getattr(app.state, "roundtable_draft_store", None),
|
||||
# task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
|
||||
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
|
||||
)
|
||||
else:
|
||||
app.state.roundtable_job_executor = None
|
||||
|
||||
@@ -168,6 +168,9 @@ class StartParams:
|
||||
coordinator_name: str = "roundtable-coordinator"
|
||||
# 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。
|
||||
draft_id: str | None = None
|
||||
# 外部任务 id(taskId 深链会商)。非空 = task 作业:draft_id 指向独立的
|
||||
# roundtable_task_drafts 表,研讨/报告写回 task-draft 存储(不分权)。
|
||||
task_id: str | None = None
|
||||
# 是否为「续跑」(resume 澄清)。首启=False → 真网关建全新线程(不复用前端线程,
|
||||
# 避免与前端本地研讨的活跃 run 撞 409);续跑=True → 复用作业自己的线程。
|
||||
is_resume: bool = False
|
||||
@@ -192,13 +195,19 @@ GatewayFactory = Callable[[StartParams], object]
|
||||
|
||||
|
||||
class RoundtableJobExecutor:
|
||||
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None) -> None:
|
||||
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None, task_draft_store=None) -> None:
|
||||
self._store = store
|
||||
self._gateway_factory = gateway_factory
|
||||
# 草稿仓储(可选):作业完成后把 step3 写回草稿,供前端「查看报告」。
|
||||
self._draft_store = draft_store
|
||||
# task 草稿仓储(独立、不分权):task 作业(params.task_id 非空)写回这里而非个人草稿。
|
||||
self._task_draft_store = task_draft_store
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
def _draft_store_for(self, params: StartParams):
|
||||
"""task 作业 → 独立 task-draft 存储;普通作业 → 个人草稿存储。"""
|
||||
return self._task_draft_store if params.task_id else self._draft_store
|
||||
|
||||
def start_job(self, params: StartParams) -> bool:
|
||||
"""启动一个后台编排任务。同一 job 已在跑则忽略(幂等)。返回是否新启动。"""
|
||||
if params.job_id in self._tasks and not self._tasks[params.job_id].done():
|
||||
@@ -238,7 +247,7 @@ class RoundtableJobExecutor:
|
||||
self._store,
|
||||
job_id=params.job_id,
|
||||
agents=params.agents,
|
||||
on_dialogue=_on_dialogue if (self._draft_store and params.draft_id) else None,
|
||||
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
|
||||
)
|
||||
result = await run_orchestration(
|
||||
gateway=gateway,
|
||||
@@ -294,7 +303,8 @@ class RoundtableJobExecutor:
|
||||
读现有 step2 → 按 run_id=``job-<jobId>`` upsert(保留其它手动 run)→ 设为 activeRun
|
||||
→ update_draft。前端加载草稿即可像手动跑一样 hydrate 出对话。
|
||||
"""
|
||||
if self._draft_store is None or not params.draft_id:
|
||||
draft_store = self._draft_store_for(params)
|
||||
if draft_store is None or not params.draft_id:
|
||||
return
|
||||
idx_by_agent = {a.agent_id: i for i, a in enumerate(params.agents)}
|
||||
s2_dialogues = [_to_step2_dialogue(d, idx_by_agent) for d in dialogues]
|
||||
@@ -303,7 +313,7 @@ class RoundtableJobExecutor:
|
||||
)
|
||||
run_id = f"job-{params.job_id}"
|
||||
try:
|
||||
draft = await self._draft_store.get_draft(params.draft_id, params.user_id)
|
||||
draft = await draft_store.get_draft(params.draft_id, params.user_id)
|
||||
except Exception: # noqa: BLE001
|
||||
draft = None
|
||||
step2 = dict((draft or {}).get("step2") or {})
|
||||
@@ -352,7 +362,7 @@ class RoundtableJobExecutor:
|
||||
if has_report:
|
||||
update_kwargs["step3"] = effective_step3
|
||||
try:
|
||||
await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
|
||||
await draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
|
||||
except Exception: # noqa: BLE001 — 写草稿失败不影响作业本身
|
||||
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
|
||||
roundtable). Bound to the requesting user (not the browser), so it follows the
|
||||
account across devices/sessions — replacing the old ``localStorage`` storage.
|
||||
|
||||
This store is **strictly per-user**. taskId 深链聊天记录 (无界嵌入抽屉) live in a
|
||||
separate, unpartitioned store — see ``/api/roundtable-task-drafts``
|
||||
(``roundtable_task_drafts`` table) — so the two never mix.
|
||||
|
||||
Recommendation history is bound to a draft: the recommend dialog shows "last
|
||||
recommendation for this session" on open, while still letting the user
|
||||
re-analyze (which appends a new history row).
|
||||
@@ -181,45 +185,6 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}/latest", response_model=DraftResponse)
|
||||
async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftResponse:
|
||||
"""按外部任务 id 取最新一条草稿(**不按 user 分权**);无则 404。
|
||||
|
||||
无界嵌入抽屉打开时调用:若该任务已有聊天记录,回显最新一条。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
row = await store.get_latest_draft_by_task(task_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No draft for this task")
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=DraftListResponse)
|
||||
async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse:
|
||||
"""按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts`
|
||||
一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
rows = await store.list_drafts_by_task(task_id)
|
||||
job_store = getattr(request.app.state, "roundtable_job_store", None)
|
||||
if job_store is not None and rows:
|
||||
try:
|
||||
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
|
||||
except AttributeError:
|
||||
status_map = {}
|
||||
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
|
||||
logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True)
|
||||
status_map = {}
|
||||
for r in rows:
|
||||
info = status_map.get(r.get("id"))
|
||||
if info:
|
||||
r["status"] = info.get("status")
|
||||
r["job_id"] = info.get("jobId")
|
||||
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
@@ -55,6 +55,9 @@ class JobAgent(BaseModel):
|
||||
|
||||
class StartJobRequest(BaseModel):
|
||||
draftId: str | None = Field(default=None, max_length=64)
|
||||
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
|
||||
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
|
||||
taskId: str | None = Field(default=None, max_length=128)
|
||||
intent: str = ""
|
||||
agents: list[JobAgent] = Field(default_factory=list)
|
||||
model: str | None = None
|
||||
@@ -191,8 +194,10 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
||||
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
|
||||
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
|
||||
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
|
||||
task_id = (body.taskId or "").strip() or None
|
||||
if body.draftId:
|
||||
existing = await store.get_by_draft(body.draftId, user_id=user_id)
|
||||
# task 作业按 task_id 去重(跨用户共享);个人作业按 user 去重。
|
||||
existing = await store.get_by_draft(body.draftId, user_id=user_id, task_id=task_id)
|
||||
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
|
||||
return _to_response(existing)
|
||||
|
||||
@@ -202,6 +207,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
||||
id=job_id,
|
||||
draft_id=body.draftId,
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
orchestration_mode=body.orchestrationMode,
|
||||
coordinator_name=body.coordinatorName,
|
||||
thread_ids=body.threadIds,
|
||||
@@ -223,6 +229,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
||||
thread_ids=body.threadIds,
|
||||
coordinator_name=body.coordinatorName,
|
||||
draft_id=body.draftId,
|
||||
task_id=task_id,
|
||||
chain=body.chain,
|
||||
orchestration_plan=body.orchestrationPlan,
|
||||
seat_thinking_enabled=body.thinking_enabled,
|
||||
@@ -234,26 +241,30 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
||||
|
||||
|
||||
@router.get("", response_model=RoundtableJobResponse | None)
|
||||
async def get_job_by_draft(request: Request, draft_id: str) -> RoundtableJobResponse | None:
|
||||
async def get_job_by_draft(request: Request, draft_id: str, task_id: str | None = None) -> RoundtableJobResponse | None:
|
||||
# task_id 非空 = task 作业查询(按 task 共享、不分权);否则按 user 分权。
|
||||
store = _get_store(request)
|
||||
tid = (task_id or "").strip() or None
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get_by_draft(draft_id, user_id=user_id)
|
||||
row = await store.get_by_draft(draft_id, user_id=user_id, task_id=tid)
|
||||
return _to_response(row) if row is not None else None
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=RoundtableJobResponse)
|
||||
async def get_job(request: Request, job_id: str) -> RoundtableJobResponse:
|
||||
async def get_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||||
store = _get_store(request)
|
||||
tid = (task_id or "").strip() or None
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get(job_id, user_id=user_id)
|
||||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.get("/{job_id}/stream")
|
||||
async def stream_job(request: Request, job_id: str) -> StreamingResponse:
|
||||
async def stream_job(request: Request, job_id: str, task_id: str | None = None) -> StreamingResponse:
|
||||
store = _get_store(request)
|
||||
tid = (task_id or "").strip() or None
|
||||
user_id = _current_user_id(request)
|
||||
|
||||
def _frame(payload: dict[str, Any]) -> str:
|
||||
@@ -263,7 +274,7 @@ async def stream_job(request: Request, job_id: str) -> StreamingResponse:
|
||||
for _ in range(_MAX_STREAM_TICKS):
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
row = await store.get(job_id, user_id=user_id)
|
||||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||||
if row is None:
|
||||
yield _frame({"event": "error", "detail": "Job not found"})
|
||||
return
|
||||
@@ -280,12 +291,15 @@ async def stream_job(request: Request, job_id: str) -> StreamingResponse:
|
||||
|
||||
|
||||
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
|
||||
async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> RoundtableJobResponse:
|
||||
async def resume_job(
|
||||
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
|
||||
) -> RoundtableJobResponse:
|
||||
store = _get_store(request)
|
||||
executor = _get_executor(request)
|
||||
tid = (task_id or "").strip() or None
|
||||
user_id = _current_user_id(request)
|
||||
|
||||
row = await store.get(job_id, user_id=user_id)
|
||||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if (row.get("status") or "") != "awaiting_input":
|
||||
@@ -295,14 +309,18 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
|
||||
if not answer:
|
||||
raise HTTPException(status_code=400, detail="answer is required")
|
||||
|
||||
# task 作业不分权:放开写入归属校验(user_id=None);并以**原作业所有者**身份续跑,
|
||||
# 保证编排上下文 / 写回 task-draft 用一致的 user 上下文。
|
||||
write_user_id = None if tid else user_id
|
||||
run_user_id = row.get("user_id") or user_id
|
||||
updated = await store.update_progress(
|
||||
job_id, user_id=user_id, status="running", pending_clarification=None
|
||||
job_id, user_id=write_user_id, status="running", pending_clarification=None
|
||||
)
|
||||
|
||||
executor.start_job(
|
||||
StartParams(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
user_id=run_user_id,
|
||||
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
|
||||
seed_message=_RESUME_PREFIX + answer,
|
||||
model=None,
|
||||
@@ -311,6 +329,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
|
||||
thread_ids=row.get("thread_ids"),
|
||||
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
|
||||
draft_id=row.get("draft_id"),
|
||||
task_id=tid or row.get("task_id"),
|
||||
is_resume=True,
|
||||
chain=row.get("chain"),
|
||||
orchestration_plan=row.get("orchestration_plan"),
|
||||
@@ -359,15 +378,17 @@ async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupRespon
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
|
||||
async def cancel_job(request: Request, job_id: str) -> RoundtableJobResponse:
|
||||
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||||
store = _get_store(request)
|
||||
executor = _get_executor(request)
|
||||
tid = (task_id or "").strip() or None
|
||||
user_id = _current_user_id(request)
|
||||
|
||||
row = await store.get(job_id, user_id=user_id)
|
||||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
executor.cancel(job_id)
|
||||
updated = await store.update_progress(job_id, user_id=user_id, status="cancelled")
|
||||
# task 作业不分权:放开写入归属校验。
|
||||
updated = await store.update_progress(job_id, user_id=(None if tid else user_id), status="cancelled")
|
||||
return _to_response(updated or row)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""CRUD API for **task-scoped** roundtable-planning drafts (聊天记录).
|
||||
|
||||
This is the dedicated store for roundtable sessions opened via a taskId deep-link
|
||||
(无界嵌入抽屉). It is physically and semantically separate from the per-user
|
||||
``/api/roundtable-drafts`` store:
|
||||
|
||||
* records are keyed **only by ``task_id``** (one task → many records);
|
||||
* there is **no user 分权** — any authenticated caller may read/write/delete a
|
||||
task's records (the deep-link audience is shared); ``created_by`` is recorded
|
||||
for audit only.
|
||||
|
||||
Routes (prefix ``/api/roundtable-task-drafts``):
|
||||
GET "/by-task/{task_id}" list all drafts for a task (lightweight)
|
||||
GET "/by-task/{task_id}/latest" newest draft for a task (full)
|
||||
POST "" create (``task_id`` required)
|
||||
GET "/{draft_id}" full draft (incl. step1/step2/step3)
|
||||
PUT "/{draft_id}" partial update
|
||||
DELETE "/{draft_id}" delete
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/roundtable-task-drafts", tags=["roundtable-task-drafts"])
|
||||
|
||||
|
||||
# ── schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TaskDraftMetaResponse(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
title: str = ""
|
||||
furthest_step: int = 1
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
# Latest background-job status for this draft (history-dropdown loading badge).
|
||||
status: str | None = None
|
||||
job_id: str | None = None
|
||||
|
||||
|
||||
class TaskDraftListResponse(BaseModel):
|
||||
drafts: list[TaskDraftMetaResponse]
|
||||
|
||||
|
||||
class TaskDraftResponse(TaskDraftMetaResponse):
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
step3: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskDraftCreateRequest(BaseModel):
|
||||
id: str | None = Field(default=None, max_length=64)
|
||||
task_id: str = Field(max_length=128)
|
||||
title: str = Field(default="", max_length=512)
|
||||
furthest_step: int = Field(default=1, ge=1, le=3)
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
step3: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskDraftUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=512)
|
||||
furthest_step: int | None = Field(default=None, ge=1, le=3)
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
step3: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_task_draft_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable task draft store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _enrich_status(request: Request, rows: list[dict[str, Any]]) -> None:
|
||||
"""Attach the latest background-job status/jobId per draft (best-effort).
|
||||
|
||||
Uses the job store's draft-id keyed lookup (no user filter) — a task's drafts
|
||||
span users. Failure or a missing job store silently degrades to no status.
|
||||
"""
|
||||
job_store = getattr(request.app.state, "roundtable_job_store", None)
|
||||
if job_store is None or not rows:
|
||||
return
|
||||
try:
|
||||
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
|
||||
except AttributeError:
|
||||
status_map = {}
|
||||
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
|
||||
logger.warning("roundtable task draft list: job status enrichment failed", exc_info=True)
|
||||
status_map = {}
|
||||
for r in rows:
|
||||
info = status_map.get(r.get("id"))
|
||||
if info:
|
||||
r["status"] = info.get("status")
|
||||
r["job_id"] = info.get("jobId")
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=TaskDraftListResponse)
|
||||
async def list_drafts_by_task(request: Request, task_id: str) -> TaskDraftListResponse:
|
||||
"""All drafts for a task, newest-first (一个 taskId 多条记录,不分权)。"""
|
||||
store = _get_store(request)
|
||||
rows = await store.list_drafts_by_task(task_id)
|
||||
await _enrich_status(request, rows)
|
||||
return TaskDraftListResponse(drafts=[TaskDraftMetaResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}/latest", response_model=TaskDraftResponse)
|
||||
async def get_latest_draft_by_task(request: Request, task_id: str) -> TaskDraftResponse:
|
||||
"""Newest draft for a task; 404 if none. 回显最新一条聊天记录用。"""
|
||||
store = _get_store(request)
|
||||
row = await store.get_latest_draft_by_task(task_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No draft for this task")
|
||||
return TaskDraftResponse(**row)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskDraftResponse, status_code=201)
|
||||
async def create_draft(request: Request, body: TaskDraftCreateRequest) -> TaskDraftResponse:
|
||||
store = _get_store(request)
|
||||
task_id = (body.task_id or "").strip()
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail="task_id is required")
|
||||
row = await store.create_draft(
|
||||
{
|
||||
"id": (body.id or uuid4().hex),
|
||||
"task_id": task_id,
|
||||
"title": body.title.strip(),
|
||||
"furthest_step": body.furthest_step,
|
||||
"step1": body.step1,
|
||||
"step2": body.step2,
|
||||
"step3": body.step3,
|
||||
},
|
||||
created_by=_current_user_id(request),
|
||||
)
|
||||
return TaskDraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=TaskDraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> TaskDraftResponse:
|
||||
store = _get_store(request)
|
||||
row = await store.get_draft(draft_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return TaskDraftResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{draft_id}", response_model=TaskDraftResponse)
|
||||
async def update_draft(request: Request, draft_id: str, body: TaskDraftUpdateRequest) -> TaskDraftResponse:
|
||||
store = _get_store(request)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
row = await store.update_draft(draft_id, **fields)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return TaskDraftResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{draft_id}", status_code=204)
|
||||
async def delete_draft(request: Request, draft_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
deleted = await store.delete_draft(draft_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"""Separate, unpartitioned storage for taskId-deeplink roundtable 聊天记录.
|
||||
|
||||
Two changes:
|
||||
|
||||
1. New table ``roundtable_task_drafts`` — the dedicated store for roundtable
|
||||
sessions opened via a taskId deep-link (无界嵌入抽屉). Keyed **only by
|
||||
``task_id``** (one task → many records), never partitioned by user
|
||||
(``created_by`` is audit-only). Physically separate from ``roundtable_drafts``
|
||||
so personal history and task records never mix. (On a brand-new DB the table
|
||||
is created by ``create_all``; this revision patches existing DBs.)
|
||||
|
||||
2. ``roundtable_jobs.task_id`` column — marks a background job as task-scoped so
|
||||
its progress/dialogues are shared by task (read 不分权) and its final report is
|
||||
written back to the ``roundtable_task_drafts`` store.
|
||||
|
||||
The matching data move (existing ``roundtable_drafts`` rows with a ``task_id`` →
|
||||
``roundtable_task_drafts``) is a one-off, run via
|
||||
``scripts/migrate_roundtable_task_drafts.py`` (alembic auto-upgrade is disabled at
|
||||
runtime — see ``persistence/engine.py``).
|
||||
|
||||
Revision ID: 20260618_01
|
||||
Revises: 20260617_01
|
||||
Create Date: 2026-06-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260618_01"
|
||||
down_revision = "20260617_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("roundtable_task_drafts"):
|
||||
op.create_table(
|
||||
"roundtable_task_drafts",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("task_id", sa.String(128), nullable=False),
|
||||
sa.Column("created_by", sa.String(64), nullable=True),
|
||||
sa.Column("title", sa.String(512), nullable=False, server_default=""),
|
||||
sa.Column("furthest_step", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("step1", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("step2", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("step3", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_roundtable_task_drafts_task_id", "roundtable_task_drafts", ["task_id"])
|
||||
|
||||
if _has_table("roundtable_jobs") and not _has_column("roundtable_jobs", "task_id"):
|
||||
op.add_column("roundtable_jobs", sa.Column("task_id", sa.String(128), nullable=True))
|
||||
op.create_index("ix_roundtable_jobs_task_id", "roundtable_jobs", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_jobs", "task_id"):
|
||||
op.drop_index("ix_roundtable_jobs_task_id", table_name="roundtable_jobs")
|
||||
op.drop_column("roundtable_jobs", "task_id")
|
||||
if _has_table("roundtable_task_drafts"):
|
||||
op.drop_index("ix_roundtable_task_drafts_task_id", table_name="roundtable_task_drafts")
|
||||
op.drop_table("roundtable_task_drafts")
|
||||
+2
@@ -54,6 +54,7 @@ from deerflow.persistence.roundtable_drafts.model import (
|
||||
RoundtableRecommendHistoryRow,
|
||||
)
|
||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
@@ -106,6 +107,7 @@ __all__ = [
|
||||
"RoundtableDraftShareRow",
|
||||
"RoundtableJobRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RoundtableTaskDraftRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
|
||||
+7
-34
@@ -82,39 +82,12 @@ class RoundtableDraftRepository:
|
||||
async def get_draft(self, draft_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权);非 task 草稿仍按 user 隔离。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离。taskId 深链聊天记录已迁出到独立的
|
||||
# ``roundtable_task_drafts`` 表(不分权),不再走这里。
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._draft_to_dict(row)
|
||||
|
||||
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""按外部任务 id 取最新一条草稿(**忽略 user_id**,不分权)。无则 None。"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._draft_to_dict(row) if row is not None else None
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
|
||||
"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._draft_to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableDraftRow(
|
||||
@@ -149,8 +122,8 @@ class RoundtableDraftRepository:
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离(task 聊天记录已迁出到独立存储)。
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if task_id is not _UNSET and task_id is not None:
|
||||
row.task_id = str(task_id)
|
||||
@@ -172,8 +145,8 @@ class RoundtableDraftRepository:
|
||||
async def delete_draft(self, draft_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离(task 聊天记录已迁出到独立存储)。
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
+4
@@ -33,6 +33,10 @@ class RoundtableJobRow(Base):
|
||||
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
|
||||
draft_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# 外部任务 id(taskId 深链会商)。非空 = 这是一条 task 作业:读取按 task_id 共享、
|
||||
# **不按 user 分权**(任何人打开同一 task 都能看在跑的研讨);其 draft_id 指向独立的
|
||||
# ``roundtable_task_drafts`` 表。为空 = 普通个人作业(按 user 分权)。
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
# Job status machine (§2.1): queued / running / awaiting_input / done / error / cancelled.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
|
||||
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
|
||||
|
||||
+23
-4
@@ -67,6 +67,7 @@ class RoundtableJobRepository:
|
||||
id: str,
|
||||
draft_id: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
orchestration_mode: str = "recommend",
|
||||
coordinator_name: str | None = None,
|
||||
thread_ids: dict[str, str] | None = None,
|
||||
@@ -82,6 +83,7 @@ class RoundtableJobRepository:
|
||||
id=id,
|
||||
draft_id=draft_id,
|
||||
user_id=resolved_user_id,
|
||||
task_id=(task_id or None),
|
||||
status=status,
|
||||
phase=phase,
|
||||
cycle=0,
|
||||
@@ -167,7 +169,16 @@ class RoundtableJobRepository:
|
||||
job_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
# task 作业按 task_id 共享、**不按 user 分权**:给定 task_id 时只校验作业属于该
|
||||
# task(忽略 user_id)。否则走原「按 user 分权」逻辑。
|
||||
if task_id is not None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableJobRow, job_id)
|
||||
if row is None or row.task_id != task_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableJobRow, job_id)
|
||||
@@ -182,12 +193,20 @@ class RoundtableJobRepository:
|
||||
draft_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the most-recently-updated job for a draft (or None)."""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
|
||||
"""Return the most-recently-updated job for a draft (or None).
|
||||
|
||||
``task_id`` set → task 作业查询:按 ``draft_id`` + ``task_id`` 过滤,**不按 user
|
||||
分权**(同一 task 的作业跨用户共享)。否则按 user 分权。
|
||||
"""
|
||||
stmt = select(RoundtableJobRow).where(RoundtableJobRow.draft_id == draft_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||
if task_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.task_id == task_id)
|
||||
else:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(1)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Task-scoped roundtable-draft persistence (separate, unpartitioned store)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
from deerflow.persistence.roundtable_task_drafts.sql import RoundtableTaskDraftRepository
|
||||
|
||||
__all__ = [
|
||||
"RoundtableTaskDraftRepository",
|
||||
"RoundtableTaskDraftRow",
|
||||
"make_roundtable_task_draft_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_task_draft_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return RoundtableTaskDraftRepository(session_factory)
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""ORM model for **task-scoped** roundtable-planning drafts.
|
||||
|
||||
This is the dedicated, separate store for roundtable 聊天记录 opened through a
|
||||
taskId deep-link (无界嵌入抽屉). Unlike :mod:`roundtable_drafts` (per-login-user
|
||||
isolation), task drafts are keyed **purely by ``task_id``** and are **never
|
||||
partitioned by user** — one ``task_id`` may have many records, all shared across
|
||||
whoever opens that task. ``created_by`` is audit-only (never used for filtering).
|
||||
|
||||
Kept physically separate from ``roundtable_drafts`` so the two never mix: a
|
||||
user's personal history (``GET /api/roundtable-drafts``) holds only their own
|
||||
drafts, and task records live entirely here.
|
||||
|
||||
``step1`` / ``step2`` / ``step3`` are JSON serialized into ``PortableLongText``
|
||||
(MySQL ``LONGTEXT``) — the Step 2 transcript / Step 3 report HTML easily exceed
|
||||
MySQL's 64 KB ``TEXT`` cap — mirroring ``roundtable_drafts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class RoundtableTaskDraftRow(Base):
|
||||
__tablename__ = "roundtable_task_drafts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# 外部任务 id(无界嵌入抽屉传入)。这是唯一的归属维度:所有读/写/删只按它过滤,
|
||||
# 一个 task_id 可有多条记录、跨用户共享。必填、建索引。
|
||||
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
# 仅审计:建档时的登录用户。绝不参与任何过滤/鉴权(task 记录不分权)。
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Auto-derived task summary (objective → first user message → timestamp).
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# Furthest step reached when this snapshot was taken (1 | 2 | 3).
|
||||
furthest_step: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot / Step3 shapes.
|
||||
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"""SQLAlchemy-backed **task-scoped** roundtable-draft store.
|
||||
|
||||
Mirrors :mod:`roundtable_drafts` but every read/write/delete is keyed **only by
|
||||
``task_id`` / row id — never by ``user_id``** (task records are shared, not
|
||||
partitioned by user). ``created_by`` is stored for audit but never filtered on.
|
||||
|
||||
``get_draft`` / ``update_draft`` keep a ``user_id`` positional purely for
|
||||
signature-compatibility with :class:`RoundtableDraftRepository` (so the job
|
||||
executor can call either store uniformly); the value is ignored here.
|
||||
|
||||
JSON columns (``step1`` / ``step2`` / ``step3``) are stored as serialized strings
|
||||
in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class RoundtableTaskDraftRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_meta(row: RoundtableTaskDraftRow) -> dict[str, Any]:
|
||||
"""Lightweight projection for the history dropdown (no big blobs)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"title": row.title,
|
||||
"furthest_step": row.furthest_step,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _to_dict(cls, row: RoundtableTaskDraftRow) -> dict[str, Any]:
|
||||
d = cls._to_meta(row)
|
||||
d["step1"] = _loads(row.step1)
|
||||
d["step2"] = _loads(row.step2)
|
||||
d["step3"] = _loads(row.step3)
|
||||
return d
|
||||
|
||||
# ── reads (all keyed by task_id / id only) ───────────────────────────────
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""All drafts for a task (lightweight meta, newest first). 不按 user 分权。"""
|
||||
stmt = (
|
||||
select(RoundtableTaskDraftRow)
|
||||
.where(RoundtableTaskDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableTaskDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Newest draft for a task (full), or None."""
|
||||
stmt = (
|
||||
select(RoundtableTaskDraftRow)
|
||||
.where(RoundtableTaskDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableTaskDraftRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def get_draft(self, draft_id: str, user_id: str | None = None) -> dict[str, Any] | None:
|
||||
"""Full draft by id. ``user_id`` is ignored (signature-compat only)."""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
# ── writes ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_draft(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||
task_id = (data.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
raise ValueError("task_id is required for a task draft")
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableTaskDraftRow(
|
||||
id=data["id"],
|
||||
task_id=task_id,
|
||||
created_by=(created_by or None),
|
||||
title=(data.get("title") or "").strip(),
|
||||
furthest_step=int(data.get("furthest_step") or 1),
|
||||
step1=_dumps(data.get("step1")),
|
||||
step2=_dumps(data.get("step2")),
|
||||
step3=_dumps(data.get("step3")),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def update_draft(
|
||||
self,
|
||||
draft_id: str,
|
||||
user_id: str | None = None,
|
||||
*,
|
||||
title=_UNSET,
|
||||
furthest_step=_UNSET,
|
||||
step1=_UNSET,
|
||||
step2=_UNSET,
|
||||
step3=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Partial update by id. ``user_id`` is ignored (signature-compat only)."""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
if row is None:
|
||||
return None
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
if furthest_step is not _UNSET and furthest_step is not None:
|
||||
row.furthest_step = int(furthest_step)
|
||||
if step1 is not _UNSET:
|
||||
row.step1 = _dumps(step1)
|
||||
if step2 is not _UNSET:
|
||||
row.step2 = _dumps(step2)
|
||||
if step3 is not _UNSET:
|
||||
row.step3 = _dumps(step3)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def delete_draft(self, draft_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,127 @@
|
||||
"""一次性迁移:把已有的 taskId 会商记录从个人草稿表搬进独立的 task 存储。
|
||||
|
||||
背景
|
||||
====
|
||||
早期 taskId 深链会商记录混存在按用户分权的 ``roundtable_drafts`` 表里(带一个
|
||||
``task_id`` 列 + 放开归属校验)。现在 taskId 聊天记录有了独立、不分权的存储
|
||||
``roundtable_task_drafts``(只按 ``task_id`` 区分,跨用户共享)。本脚本把历史数据搬过去:
|
||||
|
||||
1. ``roundtable_drafts`` 中 ``task_id`` 非空的每一行 → 在 ``roundtable_task_drafts``
|
||||
建同 id 的记录(``created_by`` = 原 ``user_id``,审计用);
|
||||
2. 这些草稿对应的 ``roundtable_jobs`` 行(``draft_id`` 匹配且 ``task_id`` 为空)补上
|
||||
``task_id``,使其转为「task 作业」(按 task 共享);
|
||||
3. 从 ``roundtable_drafts`` 删除已迁走的行,使个人历史不再混入 task 记录。
|
||||
|
||||
幂等:目标表已存在同 id 记录则跳过;可重复运行。alembic 自动升级在运行时是关闭的
|
||||
(见 ``persistence/engine.py``),所以这步数据搬迁用本脚本手动执行。
|
||||
|
||||
用法(在 backend 目录)
|
||||
======================
|
||||
::
|
||||
|
||||
# 预览(不写盘)
|
||||
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py --dry-run
|
||||
|
||||
# 执行
|
||||
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("migrate_roundtable_task_drafts")
|
||||
|
||||
|
||||
async def _run(dry_run: bool) -> int:
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||
from deerflow.persistence.roundtable_drafts.model import RoundtableDraftRow
|
||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
|
||||
config = get_app_config()
|
||||
await init_engine_from_config(config.database)
|
||||
sf = get_session_factory()
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
jobs_tagged = 0
|
||||
try:
|
||||
async with sf() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RoundtableDraftRow).where(RoundtableDraftRow.task_id.is_not(None))
|
||||
)
|
||||
).scalars().all()
|
||||
logger.info("发现 %d 条带 task_id 的个人草稿待迁移。", len(rows))
|
||||
|
||||
for row in rows:
|
||||
# 幂等:目标已存在同 id → 跳过(仍尝试给作业补 task_id + 删源行)。
|
||||
exists = await session.get(RoundtableTaskDraftRow, row.id)
|
||||
if exists is None:
|
||||
if not dry_run:
|
||||
session.add(
|
||||
RoundtableTaskDraftRow(
|
||||
id=row.id,
|
||||
task_id=row.task_id,
|
||||
created_by=row.user_id,
|
||||
title=row.title or "",
|
||||
furthest_step=row.furthest_step or 1,
|
||||
step1=row.step1,
|
||||
step2=row.step2,
|
||||
step3=row.step3,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
moved += 1
|
||||
logger.info(" 迁移 draft %s (task_id=%s)", row.id, row.task_id)
|
||||
else:
|
||||
skipped += 1
|
||||
logger.info(" 跳过 draft %s (目标已存在)", row.id)
|
||||
|
||||
# 关联后台作业补 task_id(未标记的才动)。
|
||||
if not dry_run:
|
||||
res = await session.execute(
|
||||
update(RoundtableJobRow)
|
||||
.where(RoundtableJobRow.draft_id == row.id)
|
||||
.where(RoundtableJobRow.task_id.is_(None))
|
||||
.values(task_id=row.task_id)
|
||||
)
|
||||
jobs_tagged += int(res.rowcount or 0)
|
||||
|
||||
# 删除源行(task 记录不再留在个人表)。
|
||||
if not dry_run:
|
||||
await session.delete(row)
|
||||
|
||||
if not dry_run:
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"完成%s:迁移 %d 条,跳过 %d 条,关联作业打标 %d 条。",
|
||||
"(dry-run,未写盘)" if dry_run else "",
|
||||
moved,
|
||||
skipped,
|
||||
jobs_tagged,
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="迁移 taskId 会商记录到独立 task 存储")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只预览,不写盘")
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(_run(args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -105,6 +105,13 @@ CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARC
|
||||
-- (knowledge_folders / knowledge_extract_templates 为【新表】,由 create_all 自动建,无需在此补列。)
|
||||
CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- taskId 深链会商独立存储,对应 migration 20260618_01 ----------------------
|
||||
-- roundtable_task_drafts 是【新表】(taskId 聊天记录,只按 task_id 不分权),由 create_all
|
||||
-- 自动建,无需补列。以下给【已存在的】roundtable_jobs 补 task_id 列:
|
||||
-- task_id: 非空 = task 作业(按 task 共享、不按 user 分权,报告写回 roundtable_task_drafts)。
|
||||
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
|
||||
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
@@ -136,11 +143,26 @@ PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---- roundtable_jobs.task_id 索引(幂等) -------------------------------------
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'roundtable_jobs'
|
||||
AND index_name = 'ix_roundtable_jobs_task_id'
|
||||
);
|
||||
SET @idx_ddl = IF(@idx_exists = 0,
|
||||
'CREATE INDEX `ix_roundtable_jobs_task_id` ON `roundtable_jobs` (`task_id`)',
|
||||
'SELECT ''SKIPPED ix_roundtable_jobs_task_id (already exists)'' AS result');
|
||||
PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id'))
|
||||
ORDER BY table_name, column_name;
|
||||
Reference in new issue
Block a user