From a8da6c2632fb99cdba9f7466ee941301232f5af7 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 18 Jun 2026 15:11:50 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(roundtable):=20taskId=20=E4=BC=9A?= =?UTF-8?q?=E5=95=86=E8=AE=B0=E5=BD=95=E7=8B=AC=E7=AB=8B=E5=AD=98=E5=82=A8?= =?UTF-8?q?(=E4=B8=8D=E5=88=86=E6=9D=83)=20+=20=E7=AC=AC=E4=B8=89=E6=AD=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=BF=87=E7=A8=8B=E5=AF=B9=E8=AF=9D/?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E6=9D=A1=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 2 +- .../src/roundtable-planning/api/drafts.ts | 57 +++--- .../api/roundtable-jobs.ts | 38 ++-- .../roundtable-planning/api/task-drafts.ts | 83 ++++++++ .../components/DispatchChainJobModal.tsx | 32 +-- .../hooks/useDraftPersistence.ts | 61 ++++-- .../hooks/useRoundtableDrafts.ts | 43 ++-- .../hooks/useStep3Dashboard.ts | 69 +++++-- .../hooks/useStep3Report.ts | 67 ++++--- .../hooks/useStep3Summary.ts | 69 ++++--- .../src/roundtable-planning/lib/job-types.ts | 5 + .../pages/RoundtablePlanningPage.tsx | 75 ++++++- offline-backend-20260512/backend/CLAUDE.md | 3 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 7 + .../app/gateway/roundtable_job_executor.py | 20 +- .../app/gateway/routers/roundtable_drafts.py | 43 +--- .../app/gateway/routers/roundtable_jobs.py | 49 +++-- .../gateway/routers/roundtable_task_drafts.py | 187 ++++++++++++++++++ .../20260618_01_roundtable_task_storage.py | 79 ++++++++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/roundtable_drafts/sql.py | 41 +--- .../persistence/roundtable_jobs/model.py | 4 + .../persistence/roundtable_jobs/sql.py | 27 ++- .../roundtable_task_drafts/__init__.py | 18 ++ .../roundtable_task_drafts/model.py | 52 +++++ .../persistence/roundtable_task_drafts/sql.py | 166 ++++++++++++++++ .../scripts/migrate_roundtable_task_drafts.py | 127 ++++++++++++ .../scripts/sql/column_additions_mysql.sql | 24 ++- 29 files changed, 1191 insertions(+), 263 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/task-drafts.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260618_01_roundtable_task_storage.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py create mode 100644 offline-backend-20260512/backend/scripts/migrate_roundtable_task_drafts.py diff --git a/CLAUDE.md b/CLAUDE.md index 91f9c7b3..a46ed807 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `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//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `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 diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 6b14bdee..fb09ab0a 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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; + /** 第三步「大屏多页」生成过程的完整对话气泡(中间文字 + 步骤条),同 `reportDialogues`。Optional。 */ + dashboardDialogues?: unknown[]; + /** 大屏各席位 write_file 调用重建的 stub。Optional。 */ + dashboardSeatStubMessages?: Record; } /** @@ -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; } /** Lightweight metadata row returned by the list endpoint. */ @@ -206,7 +227,7 @@ export interface DraftUpdatePayload { const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`; -async function readJson(res: Response, ctx: string): Promise { +export async function readJson(res: Response, ctx: string): Promise { if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`${ctx} failed (${res.status}): ${text}`); @@ -217,8 +238,9 @@ async function readJson(res: Response, ctx: string): Promise { // ── 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 = {}; 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 { return toRecord(data); } -/** - * 按外部任务 id 取最新一条草稿(后端忽略 user,不分权)。无则返回 null(404)。 - * 无界嵌入抽屉打开时用于「该任务有聊天记录则回显最新一条」。 - */ -export async function getLatestDraftByTask(taskId: string): Promise { - const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" }); - if (res.status === 404) return null; - const data = await readJson(res, "get latest draft by task"); - return toRecord(data); -} - -/** - * 按外部任务 id 列出该任务全部草稿(轻量 meta,newest first;后端忽略 user,不分权)。 - * taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。 - */ -export async function listDraftsByTask(taskId: string): Promise { - 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 { const res = await apiFetch(BASE(), { method: "POST", diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index d766d0c3..bad57302 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -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 { - 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 { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}${taskQuery(taskId, "?")}`, { method: "GET" }); if (res.status === 404) return null; return mapJob(await readJson>(res, "get job")); } -/** 查某草稿最近一个作业。无 → null。 */ -export async function getJobByDraft(draftId: string): Promise { - const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" }); +/** 查某草稿最近一个作业。无 → null。`taskId` 非空走 task 共享读取(不分权)。 */ +export async function getJobByDraft(draftId: string, taskId?: string | null): Promise { + const res = await apiFetch( + `${BASE()}?draft_id=${encodeURIComponent(draftId)}${taskQuery(taskId, "&")}`, + { method: "GET" }, + ); const data = await readJson | null>(res, "get job by draft"); return data ? mapJob(data) : null; } -/** 回答澄清后续跑。 */ -export async function resumeJob(jobId: string, answer: string): Promise { - const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, { +/** 回答澄清后续跑。`taskId` 非空 → task 共享续跑(不分权)。 */ +export async function resumeJob(jobId: string, answer: string, taskId?: string | null): Promise { + 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>(res, "resume job")); } -/** 取消作业。 */ -export async function cancelJob(jobId: string): Promise { - const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }); +/** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */ +export async function cancelJob(jobId: string, taskId?: string | null): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" }); return mapJob(await readJson>(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, }); diff --git a/frontend-web/src/roundtable-planning/api/task-drafts.ts b/frontend-web/src/roundtable-planning/api/task-drafts.ts new file mode 100644 index 00000000..a83e3f79 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/task-drafts.ts @@ -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 { + 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 { + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" }); + if (res.status === 404) return null; + const data = await readJson(res, "get latest task draft"); + return toRecord(data); +} + +/** Full task draft by id (不分权). Returns null on 404. */ +export async function getTaskDraft(id: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" }); + if (res.status === 404) return null; + const data = await readJson(res, "get task draft"); + return toRecord(data); +} + +/** Create a task draft. `payload.taskId` is required. */ +export async function createTaskDraft(payload: DraftCreatePayload): Promise { + const res = await apiFetch(BASE(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(fromCreate(payload)), + }); + const data = await readJson(res, "create task draft"); + return toRecord(data); +} + +export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Promise { + 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(res, "update task draft"); + return toRecord(data); +} + +export async function deleteTaskDraft(id: string): Promise { + 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}`); + } +} diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx index 19f2eba2..696614ea 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx @@ -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(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 ( { // 乐观更新为 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(() => {}); } diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts index 285458ad..ba087653 100644 --- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts +++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts @@ -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 => + taskIdRef.current ? createTaskDraft(payload) : createDraft(payload), + [], + ); + const apiUpdate = useCallback( + (id: string, patch: DraftUpdatePayload): Promise => + taskIdRef.current ? updateTaskDraft(id, patch) : updateDraft(id, patch), + [], + ); + const apiGet = useCallback( + (id: string): Promise => (taskIdRef.current ? getTaskDraft(id) : getDraft(id)), + [], + ); + const apiDelete = useCallback( + (id: string): Promise => (taskIdRef.current ? deleteTaskDraft(id) : deleteDraft(id)), + [], + ); + const [drafts, setDrafts] = useState([]); const [currentDraftId, setCurrentDraftIdState] = useState(null); const [isHistoryOpen, setIsHistoryOpen] = useState(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; }, - [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 => { 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; }, - [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 => { 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(() => { diff --git a/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts index 08166b03..2854ed4e 100644 --- a/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts +++ b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts @@ -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([]); @@ -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 }; } diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts index b96ebadf..af040adf 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts @@ -50,6 +50,12 @@ interface UseStep3DashboardOptions { onReportSaved?: (snapshot: DraftStep3Snapshot) => void; } +/** 恢复大屏时可带上的历史生成过程对话(文字 + 步骤条)+ stub —— 整段回显用。 */ +export interface Step3DashRestore { + dialogues?: unknown[]; + seatStubMessages?: Record; +} + 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) + : 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; // 续轮修改的基线 → 从历史恢复后也能精准局部改 }, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index 14153576..41bbc4f2 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -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) + : 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):进入第三步默认关闭沙箱,只有开始 diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 914351a6..f99aafdb 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -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) + : 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):进入第三步默认关闭沙箱,只有开始 diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index d86c286b..7c1da33e 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -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=链条顺序)。 */ diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 13a97094..f4178a69 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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 }; + const step3SummaryConvoRef = useRef({ ...emptyConvo }); + const step3ReportConvoRef = useRef({ ...emptyConvo }); + const step3DashConvoRef = useRef({ ...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 进度) */} { if (!o) setChainModalJobId(null); diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 827f4398..440e6ee7 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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_` ? 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/`. 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"`. | diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 8af923c8..3ddae9d6 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index babc3932..7cd7b910 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py index 2fb39e8f..1c85aaf9 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -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-`` 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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py index 14290b2b..9ef5ccd5 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py index e5b37c27..b6569fd7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py new file mode 100644 index 00000000..b0696cdd --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260618_01_roundtable_task_storage.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260618_01_roundtable_task_storage.py new file mode 100644 index 00000000..9c268a0b --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260618_01_roundtable_task_storage.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 849fcb4b..23da5f28 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py index 7fec70b5..aeb74b88 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py index 6555417a..03af942d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py @@ -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 / diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py index 61c510ab..7ff979db 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/__init__.py new file mode 100644 index 00000000..58f279b2 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/model.py new file mode 100644 index 00000000..707ba655 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/model.py @@ -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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py new file mode 100644 index 00000000..c415f62d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py @@ -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 diff --git a/offline-backend-20260512/backend/scripts/migrate_roundtable_task_drafts.py b/offline-backend-20260512/backend/scripts/migrate_roundtable_task_drafts.py new file mode 100644 index 00000000..0ce0cc47 --- /dev/null +++ b/offline-backend-20260512/backend/scripts/migrate_roundtable_task_drafts.py @@ -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()) diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index a858af21..852d3d78 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -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; From 19028ba627e0b5f3e7208f3b3666b5fad114a16f Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 18 Jun 2026 16:49:36 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(roundtable):=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=AD=A5=E6=B5=81=E7=A8=8B=E5=9B=BE=E5=90=8E=E5=85=A5=E5=BA=93?= =?UTF-8?q?=EF=BC=88=E7=A4=BA=E4=BE=8B=E6=8A=80=E8=83=BD=20=C2=B7=20?= =?UTF-8?q?=E4=BB=85=E6=B7=B1=E9=93=BE=20taskId=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流程图生成完成后,发起用户协助询问是否入库:现在 / 稍后 / 不入库。 - 仅**深链(taskId)模式**(会商 rwfx-A / 3qfx-A / xdfx-A 等)且入库技能启用时才出现入库 询问 / 协助卡 / 手动入库按钮;普通会商不受影响。 - 「稍后入库」后输入框自由文字做意图识别(后端 /api/flow-extract/ingest-intent: start_ingest / add_data / other / unclear),模糊则发起入库协助;另提供手动入库按钮。 - 入库执行复用总结智能体的同一线程,在消息列表出一条「知识库入库」气泡并显示步骤条 (调度 → 调用入库技能 →(补充内容)→ 提交 → 完成),与第二步同款。 - 入库数据以**用户最后修改后的流程图**为准;并把深链 **taskId 一并传给入库技能**(技能必需)。 - 入库状态(asking/later/done 等)随第三步草稿存取,重载不重复询问。 - 入库技能先用示例(演示用):skills/public/knowledge-base-ingest/SKILL.md(force-add, skills/ 目录默认 gitignore),后续替换为真实入库技能时沿用同名即无缝切换。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/roundtable-planning/api/drafts.ts | 5 + .../roundtable-planning/api/flow-extract.ts | 27 +++ .../src/roundtable-planning/api/ingest.ts | 21 ++ .../components/Step3SummaryMode.tsx | 90 ++++++++ .../hooks/useStep3Summary.ts | 202 +++++++++++++++++- .../pages/RoundtablePlanningPage.tsx | 26 ++- .../app/gateway/routers/flow_extract.py | 51 +++++ .../public/knowledge-base-ingest/SKILL.md | 43 ++++ 8 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/ingest.ts create mode 100644 offline-backend-20260512/backend/skills/public/knowledge-base-ingest/SKILL.md diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index fb09ab0a..7641baba 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -173,6 +173,11 @@ export interface DraftStep3SummarySnapshot { dialogues?: unknown[]; /** 各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物的虚拟预览。 */ seatStubMessages?: Record; + /** + * 入库状态(idle/asking/running/done/later/declined)。存它使重载草稿后不重复询问 + * 已入库/已选稍后的会商;恢复展示用。Optional —— 旧草稿无该字段。 + */ + ingestStatus?: string; } /** Lightweight metadata row returned by the list endpoint. */ diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts index 503832d3..44c735de 100644 --- a/frontend-web/src/roundtable-planning/api/flow-extract.ts +++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts @@ -46,6 +46,33 @@ export async function classifyFlowIntent(req: { } } +/** 「稍后入库」待命时输入框消息的入库意图(与后端 /ingest-intent 对应)。 */ +export type IngestIntent = "start_ingest" | "add_data" | "other" | "unclear"; + +/** + * 识别「稍后入库」后用户在输入框的入库意图(一次模型调用)。 + * 失败回退 "unclear"(前端发起协助让用户自己选)。 + */ +export async function classifyIngestIntent(req: { + message: string; + model?: string; + signal?: AbortSignal; +}): Promise<{ intent: IngestIntent; reason: string }> { + try { + const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/ingest-intent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: req.message, model: req.model }), + signal: req.signal, + }); + if (!res.ok) return { intent: "unclear", reason: "" }; + const data = (await res.json()) as { intent?: string; reason?: string }; + return { intent: (data.intent ?? "unclear") as IngestIntent, reason: data.reason ?? "" }; + } catch { + return { intent: "unclear", reason: "" }; + } +} + export interface FlowExtractRequest { /** 报告 Markdown(优先数据来源)。 */ reportMd?: string | null; diff --git a/frontend-web/src/roundtable-planning/api/ingest.ts b/frontend-web/src/roundtable-planning/api/ingest.ts new file mode 100644 index 00000000..c0cc44ed --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/ingest.ts @@ -0,0 +1,21 @@ +/** + * 第三步「入库」功能门控。 + * + * 入库询问 / 意图识别 / 手动入库按钮只有在**入库技能存在且启用**时才出现 + * (对应需求「如果有了调用技能入库的技能后…」)。当前是示例技能 `knowledge-base-ingest` + * (演示用);后续替换为真实入库技能时只要沿用同一个 name,本门控即继续生效。 + */ +import { loadSkills } from "@/core/skills/api"; + +/** 入库技能名(示例/真实共用同一名字,方便无缝替换)。 */ +export const INGEST_SKILL_NAME = "knowledge-base-ingest"; + +/** 入库技能是否存在且启用。失败回退 false(不弹入库询问)。 */ +export async function isIngestSkillAvailable(): Promise { + try { + const skills = await loadSkills(); + return skills.some((s) => s.name === INGEST_SKILL_NAME && s.enabled); + } catch { + return false; + } +} diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index e5c31fae..b6e7ce03 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -15,6 +15,7 @@ import remarkGfm from "remark-gfm"; import { ArrowRight, CheckCircle, + Database, Download, Eye, FileText, @@ -414,6 +415,12 @@ export function Step3SummaryMode({ onRegenerateFlow={summary.regenerateFlow} pendingClarification={summary.pendingFlowClarification} onResolveClarification={summary.resolveFlowClarification} + ingestAvailable={summary.ingestAvailable} + ingestStatus={summary.ingestStatus} + pendingIngestClarification={summary.pendingIngestClarification} + onResolveIngestChoice={summary.resolveIngestChoice} + onResolveIngestClarification={summary.resolveIngestClarification} + onManualIngest={summary.runIngest} onRetry={redraw} onAsk={handleAsk} onPause={pause} @@ -560,6 +567,12 @@ function AgentProgressPanel({ onRegenerateFlow, pendingClarification, onResolveClarification, + ingestAvailable, + ingestStatus, + pendingIngestClarification, + onResolveIngestChoice, + onResolveIngestClarification, + onManualIngest, onRetry, onAsk, onPause, @@ -583,6 +596,13 @@ function AgentProgressPanel({ /** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */ pendingClarification: string | null; onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"]; + /** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */ + ingestAvailable: boolean; + ingestStatus: UseStep3SummaryReturn["ingestStatus"]; + pendingIngestClarification: string | null; + onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"]; + onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"]; + onManualIngest: UseStep3SummaryReturn["runIngest"]; onRetry: () => void; onAsk: (message: string, files?: File[]) => void; onPause: () => void; @@ -784,6 +804,60 @@ function AgentProgressPanel({ )} + {/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */} + {ingestAvailable && ingestStatus === "asking" && ( +
+
+ 流程图已生成,是否把本方案入库到知识库? +
+
+ {( + [ + { k: "now", t: "现在入库" }, + { k: "later", t: "稍后入库" }, + { k: "never", t: "不入库" }, + ] as const + ).map((o) => ( + + ))} +
+
+ )} + + {/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */} + {ingestAvailable && pendingIngestClarification && ( +
+
+ 没太理解你的入库意思,你是想—— +
+
+ {( + [ + { k: "start_ingest", t: "开始入库" }, + { k: "add_data", t: "补充内容后再入库" }, + { k: "other", t: "只是提问 / 改报告" }, + ] as const + ).map((o) => ( + + ))} +
+
+ )} +
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */} {askFiles.length > 0 && ( @@ -830,6 +904,22 @@ function AgentProgressPanel({ className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none" /> + {/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */} + {ingestAvailable && + ingestStatus !== "asking" && + ingestStatus !== "running" && + ingestStatus !== "idle" && ( + + )} + {/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */} )} + {/* 后台挂起:写作进行中(含等待干预)时出现,点击后由后端自动跑完剩余流程 */} + {isRunning && !isHistoryMode && state.sessionId && ( + + )} {/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。 后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */} {ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && ( @@ -340,6 +407,7 @@ export function AIWritingPanel({ selectedId={historySession?.id ?? null} loading={sessionsLoading} onSelect={handleSelectHistory} + onShowProgress={handleShowProgress} onDelete={remove} onRename={rename} onRefresh={refresh} @@ -533,6 +601,13 @@ export function AIWritingPanel({ )} + {/* 查看进度流程图弹窗:点击历史里的「挂起中」记录时打开 */} + { if (!o) setProgressSession(null); }} + /> + {/* 写作参数查看弹窗 */} {state.request && ( diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx index ba103d88..f161a0dd 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx @@ -190,7 +190,7 @@ export function ResearchSidebar({ }; return ( -