Merge branch 'dhk-1' into 'master'

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

See merge request knowledge-base-bj/deerflow-server!135
This commit is contained in:
董宏凯 committed 2026-06-18 11:18:24 +00:00
commit 758a8fda09
75 files changed
+4046 -492

No files matched your search

+1 -1
View File
@@ -87,7 +87,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 inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`Line truncated
- **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 inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate Line truncated
### Configuration
@@ -401,7 +401,7 @@ export function PositionManagementPage() {
};
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex size-full min-h-0 flex-col gap-4 overflow-auto p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">岗位管理</h1>
@@ -12,7 +12,7 @@ export function UserManagementPage() {
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex size-full min-h-0 flex-col gap-6 overflow-auto p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
@@ -364,7 +364,7 @@ export function AgentGallery() {
};
return (
<div className="flex size-full flex-col">
<div className="flex size-full min-h-0 flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
@@ -392,7 +392,7 @@ export function AgentGallery() {
setSort("time_desc");
setPublisher(null);
}}
className="flex flex-1 flex-col overflow-hidden"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
@@ -170,8 +170,10 @@ export function AgentModuleSidebar({
});
return (
// 背景按需求固定白色(不走 sidebar token),与全量版 GeneralQaSidebar 的卡片视觉看齐。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground">
// 原有布局不变,仅做配色适配:深色下左栏改为半透明深蓝玻璃面板(对齐 .sidebar-contents:
// bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop-blur 让外壳的深蓝底图朦胧透出;
// 浅色保持白底。其余颜色已走语义 token 自适配。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
@@ -300,8 +300,10 @@ export function GeneralQaSidebar() {
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
return (
// 背景走 sidebar token(浅主题=白、深蓝主题=深蓝),不要写死 bg-white,否则全量版深蓝主题下侧栏仍是白底。
<div className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
// 背景走 sidebar token(浅主题=白、浅蓝主题=浅蓝);深蓝(.dark)主题下由 gq-sidebar
// 改为「深蓝主题底图 + 半透明蓝罩」玻璃面板(见 globals.css),与右侧 workspace-chat-bg
// 主区同底图、略深一档作面板区分,不要写死 bg-white。
<div className="gq-sidebar flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
@@ -15,6 +15,12 @@ import {
getCachedTaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import { requestCrudService } from "@/service/request-axios-service";
import type {
FlowGraphData,
FlowGraphEdge,
FlowGraphNode,
FlowNodeType,
} from "@/roundtable-planning/lib/flow-graph";
/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */
export interface PurposeTreeNode {
@@ -114,6 +120,83 @@ export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][
return rows;
}
/* --------------------- 目的树 → 流程图(flow-graph) --------------------- */
/**
* 目的树各层级标签 → 流程图节点类型(仅决定配色,见 FLOW_NODE_TYPE_META)。
* 未知层级回退 task。层级名本身仍原样写进节点 group,画布上当小标签显示。
*/
const PURPOSE_LABEL_TO_FLOW_TYPE: Record<string, FlowNodeType> = {
目的: "stage",
行为体: "role",
预期行为: "task",
驱动因素: "decision",
叙事: "deliverable",
};
/** 目的树的一个层级(深度):层级中文名 + 对应流程图节点类型(决定配色)。 */
export interface PurposeLevel {
/** 层级中文名(目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…)。 */
label: string;
/** 流程图节点类型(用于取 FLOW_NODE_TYPE_META 配色,与画布节点一致)。 */
type: FlowNodeType;
}
/**
* 按**深度**抽出目的树的有序层级(每层取该深度第一个非空层级名作代表)。
*
* 目的树每一深度的层级是一致的(深度 0 = 目的、1 = 行为体、… 直到叶子叙事),
* 故逐层 BFS、每层取一个代表标签即可得到「任务策划共几级、分别是什么」,
* 供消息列表上方的「流程示例图」按层级画彩色小图标。
*/
export function derivePurposeLevels(tree: PurposeTreeNode[]): PurposeLevel[] {
const levels: PurposeLevel[] = [];
let frontier = tree;
let depth = 0;
while (frontier.length > 0 && depth < 12) {
const label = frontier.find((n) => n.label?.trim())?.label?.trim() || `层级 ${depth + 1}`;
levels.push({ label, type: PURPOSE_LABEL_TO_FLOW_TYPE[label] ?? "task" });
frontier = frontier.flatMap((n) => n.children ?? []);
depth += 1;
}
return levels;
}
/**
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
*
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
* - 父→子建一条边(from=父 id);多个根目的各自成一棵子树(画布支持多根);
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
* 推荐理由并进 detail(点节点弹窗里看)。
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
*/
export function purposeTreeToFlowGraph(
tree: PurposeTreeNode[],
taskId?: string,
): FlowGraphData {
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
let counter = 0;
const walk = (node: PurposeTreeNode, parentId: string | null) => {
const id = `p${++counter}`;
const type = PURPOSE_LABEL_TO_FLOW_TYPE[node.label] ?? "task";
const text = node.text?.trim() || node.label || "(空)";
const detail = node.reason?.trim()
? `${text}\n理由:${node.reason.trim()}`
: node.text?.trim() || undefined;
nodes.push({ id, label: text, type, group: node.label || undefined, detail });
if (parentId) edges.push({ from: parentId, to: id });
node.children?.forEach((child) => walk(child, id));
};
tree.forEach((root) => walk(root, null));
return {
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
nodes,
edges,
};
}
/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
function bodyToTree(body: unknown): PurposeTreeNode[] {
const data =
@@ -87,6 +87,50 @@ export async function deleteAIWritingSession(id: string): Promise<void> {
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
}
/**
* 把一篇写作转入「后台挂起」:后端自动驱动剩余流程到定稿(离页/刷新/切号不打断)。
*
* 幂等——同一会话已在后台跑则后端直接返回 ``started:false``。
*/
export async function suspendAIWritingToBackground(
id: string,
): Promise<{ started: boolean; status: string }> {
const resp = await apiFetch(`${BASE()}/${id}/background`, { method: 'POST' });
if (!resp.ok) throw new Error(`后台挂起失败: ${resp.status}`);
return resp.json();
}
/** 一篇写作的实际进度(供「查看进度」流程图弹窗轮询)。 */
export interface AIWritingProgress {
sessionStatus: string;
/** 规范化阶段 key(与流程图阶段一一对应);null = 还没建 checkpoint。 */
stage: string | null;
note: string | null;
revisionCount: number;
maxRevisions: number;
reviewVerdict: string | null;
isRunning: boolean;
}
export async function getAIWritingProgress(id: string): Promise<AIWritingProgress> {
const resp = await apiFetch(`${BASE()}/${id}/progress`);
if (!resp.ok) throw new Error(`获取进度失败: ${resp.status}`);
return resp.json();
}
/**
* beforeunload 兜底:离开/刷新页面时用 keepalive 发起后台挂起,
* 保证浏览器卸载页面后请求仍能发完。fire-and-forget,不读响应;
* 重复触发由后端「同会话已在后台跑」幂等兜住。
*/
export function suspendAIWritingToBackgroundKeepalive(id: string): void {
try {
void apiFetch(`${BASE()}/${id}/background`, { method: 'POST', keepalive: true }).catch(() => {});
} catch {
/* 卸载阶段尽力而为,忽略一切异常 */
}
}
/**
* 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。
*
@@ -0,0 +1,138 @@
/**
* AI 写作流程图(竖向固定流水线)。
*
* 写作流程是固定线性流水线:意图理解 → 素材搜集 → 素材确认 → 大纲规划 → 大纲确认 →
* 写作 → 草稿确认 → 编辑审核 → 定稿。按传入的 ``activeKey``(后端从 graph checkpoint
* 推导的当前阶段 key)点亮:已过=done 打钩、当前=active 转圈、未到=灰。
*
* 纯展示组件,无弹窗/无窗口 chrome —— 由 ``AIWritingFlowDialog`` 套在弹窗里使用。
*/
import { CheckIcon, Loader2Icon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface FlowStage {
key: string;
label: string;
emoji: string;
}
/** 固定流水线阶段(key 与后端 ai_writing_job_executor 的 stage key 一致)。 */
export const FLOW_STAGES: readonly FlowStage[] = [
{ key: 'intent', label: '意图理解', emoji: '🧭' },
{ key: 'research', label: '素材搜集', emoji: '🔍' },
{ key: 'material_confirm', label: '素材确认', emoji: '📚' },
{ key: 'outline', label: '大纲规划', emoji: '🗂️' },
{ key: 'outline_confirm', label: '大纲确认', emoji: '🧩' },
{ key: 'draft', label: '写作', emoji: '✍️' },
{ key: 'draft_confirm', label: '草稿确认', emoji: '📝' },
{ key: 'review', label: '编辑审核', emoji: '🧐' },
{ key: 'done', label: '定稿', emoji: '🏁' },
];
export const FLOW_STAGE_LABEL: Record<string, string> = Object.fromEntries(
FLOW_STAGES.map((s) => [s.key, s.label]),
);
type StageState = 'pending' | 'active' | 'done' | 'error';
const LINE = 'bg-slate-300 dark:bg-border';
function StageNode({
label,
emoji,
state,
note,
}: {
label: string;
emoji: string;
state: StageState;
note?: string | null;
}) {
return (
<div className="flex shrink-0 flex-col items-center gap-1 px-0.5 text-center">
<div className="relative flex size-11 items-center justify-center">
{state === 'active' && <span className="absolute inset-0 animate-ping rounded-full bg-primary/25" />}
<span
className={cn(
'relative flex size-11 items-center justify-center rounded-full border-2 bg-white text-lg transition-all dark:bg-card',
state === 'active' && 'border-primary shadow-[0_0_0_3px_rgba(22,119,255,0.12)]',
state === 'done' && 'border-emerald-400/70',
state === 'error' && 'border-destructive',
state === 'pending' && 'border-border opacity-45 grayscale',
)}
>
{emoji}
{state === 'done' && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === 'active' && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
'whitespace-nowrap rounded border px-1 py-px text-[10px] font-medium leading-tight',
state === 'active' && 'border-primary/40 bg-primary/10 text-primary',
state === 'done' && 'border-emerald-400/40 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30',
state === 'error' && 'border-destructive/40 bg-destructive/10 text-destructive',
state === 'pending' && 'border-border bg-muted text-muted-foreground',
)}
title={label}
>
{label}
</span>
{note && state === 'active' && (
<span className="rounded bg-amber-100 px-1 py-px text-[9px] font-medium text-amber-600 dark:bg-amber-950/30">
{note}
</span>
)}
</div>
);
}
export function AIWritingFlowChart({
activeKey,
isError = false,
note,
className,
}: {
/** 当前阶段 key(null = 还没开始 / 准备中,全部置灰)。 */
activeKey: string | null;
isError?: boolean;
note?: string | null;
className?: string;
}) {
const allDone = activeKey === 'done';
const activeIdx = activeKey ? FLOW_STAGES.findIndex((s) => s.key === activeKey) : -1;
const stateOf = (idx: number): StageState => {
if (allDone) return 'done';
if (activeIdx < 0) return 'pending';
if (idx < activeIdx) return 'done';
if (idx === activeIdx) return isError ? 'error' : 'active';
return 'pending';
};
return (
// 横向流水线:左→右排列,节点间用横向连线(对齐到圆形头像竖直中心 ≈ 22px)。
// w-max 保持内容固有宽度,由外层 overflow-x-auto 在放不下时横向滚动。
<div className={cn('flex w-max flex-row items-start', className)}>
{FLOW_STAGES.map((stage, idx) => (
<div className="contents" key={stage.key}>
{idx > 0 && <div className={cn('mt-[22px] h-px w-5 shrink-0', LINE)} />}
<StageNode
label={stage.label}
emoji={stage.emoji}
state={stateOf(idx)}
note={stage.key === activeKey ? note : undefined}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,124 @@
/**
* 「查看进度」流程图弹窗。
*
* 在历史记录里点击一条**后台挂起中**的写作 → 弹出本弹窗,里面是写作流程图,
* 实时(轮询)反映该会话后端的实际推进位置,让用户看到「跑到哪一步了」。
*/
import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
getAIWritingProgress,
type AIWritingProgress,
type AIWritingSessionSummary,
} from '../../api/ai-writing-sessions';
import { AIWritingFlowChart, FLOW_STAGE_LABEL } from './AIWritingFlowChart';
const POLL_MS = 2500;
function isTerminal(p: AIWritingProgress | null): boolean {
if (!p) return false;
return p.sessionStatus === 'done' || p.sessionStatus === 'error' || p.stage === 'done';
}
export function AIWritingFlowDialog({
session,
open,
onOpenChange,
}: {
session: AIWritingSessionSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [progress, setProgress] = useState<AIWritingProgress | null>(null);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open || !session) return;
let cancelled = false;
setProgress(null);
setFetchError(false);
setLoading(true);
const tick = async () => {
try {
const p = await getAIWritingProgress(session.id);
if (cancelled) return;
setProgress(p);
setFetchError(false);
setLoading(false);
// 终态停止轮询,否则继续。
if (!isTerminal(p)) {
timerRef.current = setTimeout(tick, POLL_MS);
}
} catch {
if (cancelled) return;
setFetchError(true);
setLoading(false);
timerRef.current = setTimeout(tick, POLL_MS);
}
};
void tick();
return () => {
cancelled = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
}, [open, session]);
const terminal = isTerminal(progress);
const isError = progress?.sessionStatus === 'error';
const stage = progress?.stage ?? null;
let caption: string;
if (loading && !progress) {
caption = '加载进度中…';
} else if (isError) {
caption = '后台执行出错,请重新发起写作';
} else if (progress?.sessionStatus === 'done' || stage === 'done') {
caption = '✓ 写作已完成,点开这条记录即可查看成稿';
} else {
const stageLabel = stage ? (FLOW_STAGE_LABEL[stage] ?? '准备中') : '准备中';
let text = `后台运行中 · 当前:${stageLabel}`;
if (progress?.note) text += `(${progress.note})`;
if (stage === 'review' && (progress?.revisionCount ?? 0) > 0) {
text += ` · 第 ${progress!.revisionCount}/${progress!.maxRevisions} 轮修改`;
}
caption = text;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="truncate pr-6 text-sm" title={session?.title}>
写作进度 · {session?.draft_title || session?.title || '后台写作'}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-1">
<div className="w-full overflow-x-auto py-2">
<AIWritingFlowChart className="mx-auto" activeKey={stage} isError={isError} note={progress?.note} />
</div>
<div className="flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground">
{!terminal && !isError && <Loader2 className="size-3 shrink-0 animate-spin text-sky-500" />}
<span className={fetchError ? 'text-amber-500' : undefined}>
{fetchError ? '获取进度失败,重试中…' : caption}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
import { History, Trash2, Check, X, RefreshCw, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -7,6 +7,8 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
@@ -29,11 +31,15 @@ export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
/** 「挂起中」记录点击时改为查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
export function SessionItem({ session, isSelected, onSelect, onShowProgress, onDelete, onRename }: SessionItemProps) {
const isBackground = session.status === 'background';
const handleRowClick = isBackground && onShowProgress ? onShowProgress : onSelect;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -60,7 +66,7 @@ export function SessionItem({ session, isSelected, onSelect, onDelete, onRename
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? onSelect : undefined}
onClick={!editing ? handleRowClick : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
@@ -89,7 +95,10 @@ export function SessionItem({ session, isSelected, onSelect, onDelete, onRename
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
<span className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
{session.status === 'background' && <Loader2 className="w-2.5 h-2.5 animate-spin" />}
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
@@ -114,6 +123,8 @@ interface AIWritingHistoryDropdownProps {
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
/** 点击「挂起中」记录时查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
@@ -124,6 +135,7 @@ export function AIWritingHistoryDropdown({
selectedId,
loading,
onSelect,
onShowProgress,
onDelete,
onRename,
onRefresh,
@@ -189,6 +201,7 @@ export function AIWritingHistoryDropdown({
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onShowProgress={onShowProgress ? () => { onShowProgress(s); setOpen(false); } : undefined}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
@@ -1,8 +1,34 @@
import { useMemo } from 'react';
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import {
normalizeProgressDict,
normalizeMaterial,
normalizeOutline,
normalizeReviewResult,
} from '../../hooks/useAIWritingStream';
import type { CompletedIntervention } from '../../types/ai-writing';
import { AIWritingTimeline } from './AIWritingTimeline';
/**
* 归一化一条 completedIntervention(后台挂起重建的干预记录里素材/大纲/审核数据是
* snake_case,前端干预卡期望 camelCase)。对前端实时存的 camelCase 记录幂等。
*/
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention {
const out: Record<string, unknown> = { ...rec };
out.pausePoint = rec.pausePoint ?? rec.pause_point;
if (Array.isArray(rec.materials)) {
out.materials = (rec.materials as Record<string, unknown>[]).map(normalizeMaterial);
}
if (rec.outline) out.outline = normalizeOutline(rec.outline);
const rr = rec.reviewResult ?? rec.review_result;
if (rr) out.reviewResult = normalizeReviewResult(rr);
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
return out as unknown as CompletedIntervention;
}
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
@@ -67,6 +93,22 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
// 归一化 transcript 事件:后台挂起跑出的事件是 raw(snake_case),前端实时存的是
// camelCase——统一过一遍 normalizeProgressDict(幂等),两种来源回显都正确一致。
const normalizedEvents = useMemo(
() => (transcript?.progressEvents ?? []).map(
(e) => normalizeProgressDict(e as unknown as Record<string, unknown>, session.id),
),
[transcript, session.id],
);
// 干预记录同样归一化(后台重建的素材/大纲/审核数据为 snake_case;前端实时记录已是
// camelCase,归一化幂等),保证素材列表/大纲/编辑审核卡片正确渲染。
const normalizedInterventions = useMemo(
() => (transcript?.completedInterventions ?? []).map(
(rec) => normalizeCompletedIntervention(rec as unknown as Record<string, unknown>),
),
[transcript],
);
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -94,8 +136,8 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
events={normalizedEvents}
completedInterventions={normalizedInterventions}
currentPause={null}
/>
) : (
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { ChevronsDown, CloudUpload, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -17,7 +17,12 @@ import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
import {
adminCleanupAIWritingSessions,
getAIWritingSession,
suspendAIWritingToBackground,
suspendAIWritingToBackgroundKeepalive,
} from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
@@ -51,6 +56,7 @@ import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
import { AIWritingHistoryView } from './AIWritingHistoryView';
import { AIWritingFlowDialog } from './AIWritingFlowDialog';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
@@ -91,6 +97,8 @@ export function AIWritingPanel({
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
const [reqDialogOpen, setReqDialogOpen] = useState(false);
// 「查看进度」流程图弹窗当前展示的会话(点击历史里的「挂起中」记录时打开)。
const [progressSession, setProgressSession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
@@ -188,11 +196,57 @@ export function AIWritingPanel({
void openHistory(session);
}, [openHistory]);
// 点击历史里的「挂起中」记录 → 打开流程图弹窗看实际进度(而非直接打开会话)。
const handleShowProgress = useCallback((session: AIWritingSessionSummary) => {
setProgressSession(session);
}, []);
const handleNewSession = useCallback(() => {
closeHistory();
reset();
}, [closeHistory, reset]);
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
const handleBackgroundSuspend = useCallback(async () => {
const sid = state.sessionId;
if (!sid) {
toast.error('当前没有可挂起的写作会话');
return;
}
try {
await suspendAIWritingToBackground(sid);
} catch (e) {
toast.error(e instanceof Error ? e.message : '后台挂起失败');
return;
}
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
// reset() 会 stop 客户端 SSE 订阅并回到 idle —— 后端驱动器接管续跑,不冲突。
reset();
// 刷新历史列表,让「挂起中」徽标立刻出现。
refresh();
}, [state.sessionId, reset, refresh]);
// 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。
// 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。
const suspendRef = useRef<{ running: boolean; sid: string | null }>({ running: false, sid: null });
useEffect(() => {
suspendRef.current = { running: isRunning, sid: state.sessionId ?? null };
}, [isRunning, state.sessionId]);
useEffect(() => {
const onBeforeUnload = () => {
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
// 组件卸载(路由离开本页 / 切换工作区标签):写作未完成则自动转后台。
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
}, []);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
@@ -299,6 +353,19 @@ export function AIWritingPanel({
新建
</Button>
)}
{/* 后台挂起:写作进行中(含等待干预)时出现,点击后由后端自动跑完剩余流程 */}
{isRunning && !isHistoryMode && state.sessionId && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-sky-500 hover:text-sky-600 hover:bg-sky-50 dark:hover:bg-sky-950/30"
onClick={handleBackgroundSuspend}
title="转入后台:自动确认素材/大纲、写作、编辑审核直至定稿,离开页面也不中断"
>
<CloudUpload className="size-3" />
后台挂起
</Button>
)}
{/* ⏸️/▶️ "停止 / 继续" 由 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({
</div>
)}
{/* 查看进度流程图弹窗:点击历史里的「挂起中」记录时打开 */}
<AIWritingFlowDialog
session={progressSession}
open={progressSession !== null}
onOpenChange={(o) => { if (!o) setProgressSession(null); }}
/>
{/* 写作参数查看弹窗 */}
{state.request && (
<Dialog open={reqDialogOpen} onOpenChange={setReqDialogOpen}>
@@ -190,7 +190,7 @@ export function ResearchSidebar({
};
return (
<aside className="flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
<aside className="ai-research-sidebar flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
<section className="mb-5">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
@@ -385,9 +385,16 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
// 在 resume 后才运行,若首轮 intent 未把模型存进 state(历史会话 / 捕获为空),
// 后端就只能回退默认模型。这里把表单选定的模型随每次 resume 一并回传,
// 让后端(get_writing_model / resolve_agent_model_name 的 configurable 兜底)
// 始终用上用户真正选的模型。
const resumeModelName = state.request?.modelName;
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
const message = getStreamErrorMessage(err);
@@ -395,7 +395,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
export function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
return {
id: String(m.id ?? ''),
@@ -430,7 +430,7 @@ function normalizeOutlineSection(s: Record<string, unknown>) {
};
}
function normalizeOutline(o: unknown): AIOutline | null {
export function normalizeOutline(o: unknown): AIOutline | null {
if (!o || typeof o !== 'object') return null;
const obj = o as Record<string, unknown>;
return {
@@ -449,7 +449,7 @@ function normalizeReviewIssue(i: Record<string, unknown>): ReviewIssue {
};
}
function normalizeReviewResult(r: unknown): ReviewResult | null {
export function normalizeReviewResult(r: unknown): ReviewResult | null {
if (!r || typeof r !== 'object') return null;
const o = r as Record<string, unknown>;
return {
@@ -472,8 +472,12 @@ function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
};
}
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。 */
function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。
*
* 幂等:对已是 camelCase 的前端事件再跑一遍基本不变(snake_case 键不存在即跳过),
* 所以历史回显时可无脑对**整条** transcript 跑一遍——既归一后台存的 raw 事件,又
* 不破坏前端实时存的事件。 */
export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
const out: Record<string, unknown> = { ...parsed, sessionId };
if ('agent_name' in out) out.agentName = out.agent_name;
+3 -1
View File
@@ -75,7 +75,9 @@ function ModuleChatShell({ module }: { module: AgentModuleConfig }) {
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex size-full min-h-0 overflow-hidden">
// 外壳挂 workspace-chat-bg:深色下整片用同一张深蓝主题底图(fixed),作为左栏半透明
// 玻璃面板透出的「底色」;浅色透明无影响。右侧对话区自带同款底图,左右底色一致。
<div className="workspace-chat-bg flex size-full min-h-0 overflow-hidden">
<AgentModuleSidebar
moduleKey={module.key}
activeAgentId={agent_id}
@@ -145,8 +145,8 @@ export default function BusinessChainMappingPage() {
/* ── 样式 ── */
const shellClass = isDark
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
: "h-full overflow-y-auto bg-white text-[#222]";
? "size-full min-h-0 overflow-y-auto text-slate-100 workspace-chat-bg"
: "size-full min-h-0 overflow-y-auto bg-white text-[#222]";
// 卡片:无边框,纯阴影
const cardClass = isDark
+13 -8
View File
@@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
import {
@@ -494,14 +495,18 @@ function Td({
export default function LlmMetricsPage() {
const [tab, setTab] = useState<"llm" | "tool">("llm");
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
<div className="flex size-full min-h-0 w-full">
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
</div>
</ScrollArea>
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { MemorySettingsPage } from "@/components/workspace/settings/memory-setti
export default function MemoryPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<MemorySettingsPage />
</div>
);
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
@@ -225,7 +226,11 @@ export default function PublicLeaderboardPage() {
}, [userPageCount]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white" style={pageBg}>
<div
className="flex size-full min-h-0 w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white"
style={pageBg}
>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
@@ -372,6 +377,7 @@ export default function PublicLeaderboardPage() {
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
</footer>
</div>
</ScrollArea>
</div>
);
}
@@ -2,7 +2,7 @@ import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evoluti
export default function SkillEvolutionPage() {
return (
<div className="flex h-full flex-col overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillEvolutionPanel />
</div>
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { SkillSettingsPage } from "@/components/workspace/settings/skill-setting
export default function SkillsPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillSettingsPage />
</div>
);
@@ -133,6 +133,18 @@ export interface DraftStep3Snapshot {
* 与经典 `html`(模式一)并存互不覆盖。后台挂起与手动并行绘制都写这里。Optional.
*/
dashboardJson?: string | null;
/**
* 第三步「结果绘制(经典 HTML 看板)」生成过程的完整对话气泡(中间文字 + 步骤条),与第二步
* `step2RoundtableDialogues` 同形(`Step2Dialogue[]`)。存它使加载后能回显整段绘制过程,而非
* 只有最终 HTML。Optional —— 旧草稿没有该字段,恢复时回退为重建单条「历史记录」气泡。
*/
reportDialogues?: unknown[];
/** 经典看板各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物预览。 */
reportSeatStubMessages?: Record<string, unknown[]>;
/** 第三步「大屏多页」生成过程的完整对话气泡(中间文字 + 步骤条),同 `reportDialogues`。Optional。 */
dashboardDialogues?: unknown[];
/** 大屏各席位 write_file 调用重建的 stub。Optional。 */
dashboardSeatStubMessages?: Record<string, unknown[]>;
}
/**
@@ -152,6 +164,20 @@ export interface DraftStep3SummarySnapshot {
model: string;
/** The agent's closing chat text (the report body it echoed into the conversation). */
closing?: string;
/**
* 第三步「总结报告」生成过程的**完整对话气泡**(含中间文字内容 + 每条气泡的步骤条
* `steps`),与第二步 `step2RoundtableDialogues` 同形(`Step2Dialogue[]`)。存它使加载
* 草稿后能像第二步一样回显整段研讨过程,而不只是最终 md / 流程图。Optional —— 此前
* 的草稿没有该字段,恢复时回退为重建单条「历史记录」气泡。
*/
dialogues?: unknown[];
/** 各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物的虚拟预览。 */
seatStubMessages?: Record<string, unknown[]>;
/**
* 入库状态(idle/asking/running/done/later/declined)。存它使重载草稿后不重复询问
* 已入库/已选稍后的会商;恢复展示用。Optional —— 旧草稿无该字段。
*/
ingestStatus?: string;
}
/** Lightweight metadata row returned by the list endpoint. */
@@ -206,7 +232,7 @@ export interface DraftUpdatePayload {
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
@@ -217,8 +243,9 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (furthest_step / created_at / updated_at); the rest
// of the frontend reads camelCase. Map at this boundary so callers stay clean.
// Shared with the task-scoped client (`task-drafts.ts`) — same wire shape.
interface ServerDraftMeta {
export interface ServerDraftMeta {
id: string;
task_id?: string | null;
title: string;
@@ -229,13 +256,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 +276,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 +285,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 +297,7 @@ function fromCreate(p: DraftCreatePayload) {
};
}
function fromUpdate(p: DraftUpdatePayload) {
export function fromUpdate(p: DraftUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
@@ -298,27 +325,6 @@ export async function getDraft(id: string): Promise<DraftRecord | null> {
return toRecord(data);
}
/**
* 按外部任务 id 取最新一条草稿(后端忽略 user,不分权)。无则返回 null(404)。
* 无界嵌入抽屉打开时用于「该任务有聊天记录则回显最新一条」。
*/
export async function getLatestDraftByTask(taskId: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get latest draft by task");
return toRecord(data);
}
/**
* 按外部任务 id 列出该任务全部草稿(轻量 meta,newest first;后端忽略 user,不分权)。
* taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
*/
export async function listDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts by task");
return (data.drafts ?? []).map(toMeta);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
@@ -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;
@@ -0,0 +1,38 @@
/**
* 第三步「入库」功能门控。
*
* 入库询问 / 意图识别 / 手动入库按钮只有在**入库技能存在且启用**时才出现
* (对应需求「如果有了调用技能入库的技能后…」)。当前是示例技能 `knowledge-base-ingest`
* (演示用);后续替换为真实入库技能时只要沿用同一个 name,本门控即继续生效。
*/
import { loadSkills } from "@/core/skills/api";
/** 示例入库技能名(真实技能可改名;门控按「入库相关」关键字判断,不写死此名)。 */
export const INGEST_SKILL_NAME = "knowledge-base-ingest";
/**
* 是否为「入库相关」技能:按 name / name_zh / description 含 `ingest` 或「入库」判断。
* 这样示例技能(knowledge-base-ingest)与**后续改了名的真实入库技能**都能识别;
* 而知识库**检索**类技能(如 knowledge-base-search「知识库检索」)不含「入库」/`ingest`,不会误命中。
*/
export function isIngestRelatedSkill(s: {
name?: string;
name_zh?: string | null;
description?: string;
}): boolean {
const hay = `${s.name ?? ""} ${s.name_zh ?? ""} ${s.description ?? ""}`.toLowerCase();
return hay.includes("ingest") || hay.includes("入库");
}
/**
* 是否**挂载了**可用的入库相关技能(存在且启用)。失败回退 false。
* 为 false 时第三步不显示入库按钮、不触发任何入库操作。
*/
export async function isIngestSkillAvailable(): Promise<boolean> {
try {
const skills = await loadSkills();
return skills.some((s) => s.enabled && isIngestRelatedSkill(s));
} catch {
return false;
}
}
@@ -81,6 +81,8 @@ function toStartBody(p: StartJobPayload) {
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
draftId: p.draftId ?? null,
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
...(p.taskId ? { taskId: p.taskId } : {}),
intent: p.intent,
agents: p.agents,
model: p.model,
@@ -131,23 +133,31 @@ export function startJobKeepalive(payload: StartJobPayload): void {
}
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
/** taskId 非空 → 追加 `task_id` 查询参(task 作业按 task 共享、不分权读取/控制)。 */
function taskQuery(taskId: string | null | undefined, sep: "?" | "&"): string {
return taskId ? `${sep}task_id=${encodeURIComponent(taskId)}` : "";
}
/** 查作业快照。404 → null。`taskId` 非空走 task 共享读取(不分权)。 */
export async function getJob(jobId: string, taskId?: string | null): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}${taskQuery(taskId, "?")}`, { method: "GET" });
if (res.status === 404) return null;
return mapJob(await readJson<Record<string, unknown>>(res, "get job"));
}
/** 查某草稿最近一个作业。无 → null。 */
export async function getJobByDraft(draftId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" });
/** 查某草稿最近一个作业。无 → null。`taskId` 非空走 task 共享读取(不分权)。 */
export async function getJobByDraft(draftId: string, taskId?: string | null): Promise<RoundtableJob | null> {
const res = await apiFetch(
`${BASE()}?draft_id=${encodeURIComponent(draftId)}${taskQuery(taskId, "&")}`,
{ method: "GET" },
);
const data = await readJson<Record<string, unknown> | null>(res, "get job by draft");
return data ? mapJob(data) : null;
}
/** 回答澄清后续跑。 */
export async function resumeJob(jobId: string, answer: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, {
/** 回答澄清后续跑。`taskId` 非空 → task 共享续跑(不分权)。 */
export async function resumeJob(jobId: string, answer: string, taskId?: string | null): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume${taskQuery(taskId, "?")}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
@@ -155,9 +165,9 @@ export async function resumeJob(jobId: string, answer: string): Promise<Roundtab
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。 */
export async function cancelJob(jobId: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" });
/** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */
export async function cancelJob(jobId: string, taskId?: string | null): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
}
@@ -177,12 +187,12 @@ export interface StreamJobHandlers {
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers): () => void {
export function streamJob(jobId: string, handlers: StreamJobHandlers, taskId?: string | null): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream`, {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream${taskQuery(taskId, "?")}`, {
method: "GET",
signal: controller.signal,
});
@@ -0,0 +1,83 @@
/**
* **Task-scoped** roundtable draft storage (taskId 深链聊天记录).
*
* Separate from the per-user `drafts.ts` client: records here are keyed **only by
* `taskId`** and are **not partitioned by user** (the deep-link audience shares
* them). Hits `/api/roundtable-task-drafts` (table `roundtable_task_drafts`),
* physically distinct from the personal `roundtable_drafts` store so the two
* never mix.
*
* The wire shape matches the personal store, so we reuse its types + mappers.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import {
fromCreate,
fromUpdate,
readJson,
toMeta,
toRecord,
type DraftCreatePayload,
type DraftMeta,
type DraftRecord,
type DraftUpdatePayload,
type ServerDraftFull,
type ServerDraftMeta,
} from "@/roundtable-planning/api/drafts";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-task-drafts`;
/** 按外部任务 id 列出该任务全部聊天记录(轻量 meta,newest first;不分权)。 */
export async function listTaskDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list task drafts");
return (data.drafts ?? []).map(toMeta);
}
/** 按外部任务 id 取最新一条聊天记录(不分权)。无则 null(404)。 */
export async function getLatestTaskDraftByTask(taskId: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get latest task draft");
return toRecord(data);
}
/** Full task draft by id (不分权). Returns null on 404. */
export async function getTaskDraft(id: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get task draft");
return toRecord(data);
}
/** Create a task draft. `payload.taskId` is required. */
export async function createTaskDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create task draft");
return toRecord(data);
}
export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update task draft");
return toRecord(data);
}
export async function deleteTaskDraft(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete task draft failed (${res.status}): ${text}`);
}
}
@@ -18,6 +18,11 @@ export interface DispatchChainJobModalProps {
onOpenChange: (open: boolean) => void;
title?: string;
onView?: (job: RoundtableJob) => void;
/**
* 外部任务 id(taskId 深链会商)。非空 → 按 task 共享读取/控制作业(不分权),
* 任何人打开同一 task 都能看在跑的研讨流程图。普通模式留空。
*/
taskId?: string | null;
}
export function DispatchChainJobModal({
@@ -26,6 +31,7 @@ export function DispatchChainJobModal({
onOpenChange,
title,
onView,
taskId = null,
}: DispatchChainJobModalProps) {
const [job, setJob] = useState<RoundtableJob | null>(null);
@@ -36,24 +42,28 @@ export function DispatchChainJobModal({
}
let cancelled = false;
// 首帧快照:弹窗一打开就有内容,不必等 SSE 第一帧。
void getJob(jobId)
void getJob(jobId, taskId)
.then((j) => {
if (!cancelled && j) setJob(j);
})
.catch(() => {});
const unsubscribe = streamJob(jobId, {
onProgress: (j) => {
if (!cancelled) setJob(j);
const unsubscribe = streamJob(
jobId,
{
onProgress: (j) => {
if (!cancelled) setJob(j);
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
});
taskId,
);
return () => {
cancelled = true;
unsubscribe();
};
}, [open, jobId]);
}, [open, jobId, taskId]);
return (
<DispatchChainModal
@@ -75,7 +85,7 @@ export function DispatchChainJobModal({
jobId
? (answer) => {
// 乐观更新为 running,随后 SSE 会推真状态续上。
void resumeJob(jobId, answer)
void resumeJob(jobId, answer, taskId)
.then((j) => setJob(j))
.catch(() => {});
}
@@ -84,7 +94,7 @@ export function DispatchChainJobModal({
onCancel={
jobId
? () => {
void cancelJob(jobId)
void cancelJob(jobId, taskId)
.then((j) => setJob(j))
.catch(() => {});
}
@@ -22,6 +22,7 @@ import {
BackgroundVariant,
Handle,
MarkerType,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
@@ -48,16 +49,36 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import {
applyNodeEdit,
deleteNodeFromGraph,
FLOW_NODE_TYPE_META,
flowNodeMeta,
FLOW_NODE_W,
layoutFlowGraph,
type FlowGraphData,
type FlowGraphNode,
type FlowOrientation,
} from "@/roundtable-planning/lib/flow-graph";
/**
* 深蓝主题色板(《深蓝色 UI 规范》ZS-004 / ZS-002)。浅色保持原 slate/white。
* 用于流程图弹窗在深色下的容器 / 头部 / 画布 / 连线 / 图例 / 小地图 / 详情弹窗。
*/
const DARK = {
pageBg: "#0C1836", // ZS-004-09 画布底
cardBg: "#122347", // ZS-004-08 头部 / 详情卡底
border: "#1A2F5A", // ZS-004-07 卡片边框
borderStrong: "#2D4473", // ZS-004-06 弱分割
text: "#C5CFE0", // ZS-004-02 正文
textMuted: "#6E88B0", // ZS-004-04 次级文字
primary: "#1677FF", // ZS-002-06 主色
link: "#4096FF", // ZS-002-05 链接
dots: "#2D4473", // 画布点阵
edge: "#4A6491", // ZS-004-05 静态连线
edgeActive: "#4096FF", // 活动连线
} as const;
interface FlowGraphPanelProps {
data: FlowGraphData;
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
@@ -67,17 +88,32 @@ interface FlowGraphPanelProps {
onSwitchToReport?: () => void;
/** Phase 3:画布手动编辑——传入则节点详情弹窗出现「编辑 / 删除」,回写整张新图。 */
onChange?: (next: FlowGraphData) => void;
/** 布局方向:默认 "vertical"(自上而下);弹窗里用 "horizontal" 横向铺开。 */
orientation?: FlowOrientation;
/** 右下角缩略图(小地图)—— 弹窗大画布里便于纵览定位。 */
miniMap?: boolean;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
function FlowCardNode({ data }: NodeProps) {
const node = (data as { node?: FlowGraphNode; scale?: number }).node;
const scale = (data as { node?: FlowGraphNode; scale?: number }).scale ?? 1;
const d = data as {
node?: FlowGraphNode;
scale?: number;
orientation?: FlowOrientation;
dark?: boolean;
};
const node = d.node;
const scale = d.scale ?? 1;
if (!node) return null;
const meta = FLOW_NODE_TYPE_META[node.type];
const meta = flowNodeMeta(d.dark ?? false)[node.type];
// 节点宽度随容器宽度缩放(宽时放大、窄时缩小)。
const scaledWidth = FLOW_NODE_W * scale;
// 横向布局连入/连出走左右两侧,纵向走上下。
const horizontal = d.orientation === "horizontal";
const targetPos = horizontal ? Position.Left : Position.Top;
const sourcePos = horizontal ? Position.Right : Position.Bottom;
const handleColor = d.dark ? "!bg-[#4A6491]" : "!bg-slate-400";
return (
<div
style={{
@@ -89,7 +125,7 @@ function FlowCardNode({ data }: NodeProps) {
className="cursor-pointer rounded-xl border-2 px-3 py-2 shadow-sm transition hover:shadow-md"
title="点击查看详情"
>
<Handle type="target" position={Position.Top} className="!h-1.5 !w-1.5 !bg-slate-400" />
<Handle type="target" position={targetPos} className={cn("!h-1.5 !w-1.5", handleColor)} />
<div className="mb-0.5 flex items-center gap-1.5">
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70" style={{ fontSize: `${9 * scale}px` }}>
@@ -97,7 +133,7 @@ function FlowCardNode({ data }: NodeProps) {
</span>
</div>
<div className="break-words font-bold leading-snug" style={{ fontSize: `${11.5 * scale}px` }}>{node.label}</div>
<Handle type="source" position={Position.Bottom} className="!h-1.5 !w-1.5 !bg-slate-400" />
<Handle type="source" position={sourcePos} className={cn("!h-1.5 !w-1.5", handleColor)} />
</div>
);
}
@@ -115,8 +151,12 @@ function FlowCanvasInner({
onClose,
onSwitchToReport,
onChange,
orientation = "vertical",
miniMap = false,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const { theme } = useTheme();
const isDark = theme === "dark";
const [selectedId, setSelectedId] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
@@ -145,7 +185,7 @@ function FlowCanvasInner({
};
}, [rf]);
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
const layoutNodes = useMemo(() => layoutFlowGraph(data, orientation), [data, orientation]);
const derivedNodes: Node[] = useMemo(
() =>
layoutNodes.map((n) => ({
@@ -153,9 +193,9 @@ function FlowCanvasInner({
type: "flowCard",
// 节点随 scale 放大 → 布局坐标同步乘 scale,间距一起拉开,避免同层重叠。
position: manualPosRef.current.get(n.id) ?? { x: n.x * scale, y: n.y * scale },
data: { node: n, scale },
data: { node: n, scale, orientation, dark: isDark },
})),
[layoutNodes, scale],
[layoutNodes, scale, orientation, isDark],
);
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
@@ -174,7 +214,13 @@ function FlowCanvasInner({
() =>
data.edges.map((e) => {
const active = streaming && (e.to === newestNodeId || e.from === newestNodeId);
const color = active ? "#3b82f6" : "#94a3b8";
const color = active
? isDark
? DARK.edgeActive
: "#3b82f6"
: isDark
? DARK.edge
: "#94a3b8";
return {
id: `e-${e.from}->${e.to}`,
source: e.from,
@@ -183,11 +229,11 @@ function FlowCanvasInner({
animated: active,
style: { stroke: color, strokeWidth: active ? 2 : 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
labelStyle: { fontSize: 10, fill: "#64748b", fontWeight: 700 },
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 },
labelStyle: { fontSize: 10, fill: isDark ? DARK.text : "#64748b", fontWeight: 700 },
labelBgStyle: { fill: isDark ? DARK.cardBg : "#ffffff", fillOpacity: isDark ? 0.9 : 0.85 },
} satisfies Edge;
}),
[data.edges, streaming, newestNodeId],
[data.edges, streaming, newestNodeId, isDark],
);
// 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。
@@ -202,15 +248,30 @@ function FlowCanvasInner({
const selectedNode = selectedId ? data.nodes.find((n) => n.id === selectedId) ?? null : null;
return (
<div className="flex h-full min-h-0 flex-col">
<div
className="flex h-full min-h-0 flex-col"
style={isDark ? { background: DARK.pageBg } : undefined}
>
{/* 头部:标题 + 工具栏 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div
className={cn(
"shrink-0 flex items-center justify-between gap-2 border-b px-4 py-2.5",
isDark ? "" : "border-slate-100 bg-white",
)}
style={isDark ? { background: DARK.cardBg, borderColor: DARK.border } : undefined}
>
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-xs font-bold text-slate-800">
<span
className="truncate text-xs font-bold text-slate-800"
style={isDark ? { color: DARK.text } : undefined}
>
{data.title || "方案流程图"}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500">
<span
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500"
style={isDark ? { background: "rgba(45,68,115,0.5)", color: DARK.textMuted } : undefined}
>
{data.nodes.length} 节点 · {data.edges.length} 连线
</span>
{streaming && (
@@ -226,7 +287,8 @@ function FlowCanvasInner({
type="button"
title="切回 Markdown 报告预览(流程图可随时再打开)"
onClick={onSwitchToReport}
className="mr-1 inline-flex h-7 items-center gap-1 rounded-lg border border-slate-200/70 bg-white px-2 text-[10.5px] font-bold text-slate-600 transition hover:border-primary/40 hover:text-primary"
className="mr-1 inline-flex h-7 items-center gap-1 rounded-lg border border-slate-200/70 bg-white px-2 text-[10.5px] font-bold text-slate-600 transition hover:border-primary/40 hover:text-primary dark:hover:text-primary"
style={isDark ? { background: "transparent", borderColor: DARK.border, color: DARK.text } : undefined}
>
<FileText className="h-3.5 w-3.5" />
MD 报告
@@ -271,13 +333,50 @@ function FlowCanvasInner({
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="#cbd5e1" />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color={isDark ? DARK.dots : "#cbd5e1"}
/>
{miniMap && (
<MiniMap
position="bottom-right"
pannable
zoomable
nodeColor={(n) => {
const t = (n.data as { node?: FlowGraphNode }).node?.type;
return (t && flowNodeMeta(isDark)[t]?.dot) || (isDark ? DARK.textMuted : "#94a3b8");
}}
nodeStrokeWidth={2}
maskColor={isDark ? "rgba(6,14,36,0.6)" : "rgba(240,240,240,0.6)"}
className={cn(
"!bottom-2 !right-2 !m-0 overflow-hidden rounded-lg border shadow-sm backdrop-blur-sm",
isDark ? "" : "border-slate-200/70 bg-white/90",
)}
style={
isDark
? { width: 168, height: 112, background: "rgba(18,35,71,0.9)", borderColor: DARK.border }
: { width: 168, height: 112 }
}
/>
)}
</ReactFlow>
{/* 图例 */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border border-slate-200/70 bg-white/85 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => (
<span key={type} className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500">
<div
className={cn(
"pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border px-2.5 py-1.5 backdrop-blur-sm",
isDark ? "" : "border-slate-200/70 bg-white/85",
)}
style={isDark ? { background: "rgba(18,35,71,0.85)", borderColor: DARK.border } : undefined}
>
{Object.entries(flowNodeMeta(isDark)).map(([type, meta]) => (
<span
key={type}
className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500"
style={isDark ? { color: DARK.textMuted } : undefined}
>
<span style={{ background: meta.dot }} className="size-1.5 rounded-full" />
{meta.label}
</span>
@@ -290,6 +389,7 @@ function FlowCanvasInner({
<NodeDetailModal
node={selectedNode}
data={data}
dark={isDark}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
onChange={onChange}
@@ -308,12 +408,15 @@ function ToolButton({
onClick: () => void;
children: React.ReactNode;
}) {
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/70 bg-white text-slate-500 transition hover:border-primary/40 hover:text-primary"
style={isDark ? { background: "transparent", borderColor: DARK.border, color: DARK.textMuted } : undefined}
>
{children}
</button>
@@ -325,18 +428,20 @@ function ToolButton({
function NodeDetailModal({
node,
data,
dark = false,
onClose,
onJump,
onChange,
}: {
node: FlowGraphNode;
data: FlowGraphData;
dark?: boolean;
onClose: () => void;
onJump: (id: string) => void;
/** Phase 3:传入则可编辑 / 删除该节点(回写整张新图)。 */
onChange?: (next: FlowGraphData) => void;
}) {
const meta = FLOW_NODE_TYPE_META[node.type];
const meta = flowNodeMeta(dark)[node.type];
const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id;
const incoming = data.edges.filter((e) => e.to === node.id);
const outgoing = data.edges.filter((e) => e.from === node.id);
@@ -366,6 +471,7 @@ function NodeDetailModal({
>
<div
className="w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
style={dark ? { background: DARK.cardBg, borderColor: DARK.border } : undefined}
onClick={(e) => e.stopPropagation()}
>
<div
@@ -376,7 +482,7 @@ function NodeDetailModal({
<span style={{ background: meta.dot }} className="size-2.5 shrink-0 rounded-full" />
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
style={{ background: dark ? "rgba(6,14,36,0.45)" : "#ffffffaa", color: meta.text }}
>
{node.group?.trim() || meta.label}
</span>
@@ -388,6 +494,7 @@ function NodeDetailModal({
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-slate-500 hover:bg-white/60 hover:text-slate-700"
style={dark ? { color: DARK.textMuted } : undefined}
>
<X className="h-4 w-4" />
</button>
@@ -395,36 +502,48 @@ function NodeDetailModal({
{editing ? (
<div className="max-h-[55vh] space-y-3 overflow-y-auto p-4">
<EditField label="节点名称(label)" value={label} onChange={setLabel} />
<EditField label="所属层级(group,如 目的 / 行为体)" value={group} onChange={setGroup} />
<EditField label="详细内容(detail,名称+理由)" value={detail} onChange={setDetail} multiline />
<EditField label="节点名称(label)" value={label} onChange={setLabel} dark={dark} />
<EditField label="所属层级(group,如 目的 / 行为体)" value={group} onChange={setGroup} dark={dark} />
<EditField label="详细内容(detail,名称+理由)" value={detail} onChange={setDetail} multiline dark={dark} />
</div>
) : (
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">详细说明</div>
<p className="text-xs leading-relaxed text-slate-700 whitespace-pre-wrap">
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
详细说明
</div>
<p
className="text-xs leading-relaxed text-slate-700 whitespace-pre-wrap"
style={dark ? { color: DARK.text } : undefined}
>
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} dark={dark} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} dark={dark} />
)}
</div>
)}
{/* 编辑 / 删除 操作栏(仅传入 onChange 时显示) */}
{onChange && (
<div className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5">
<div
className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5"
style={dark ? { borderColor: DARK.border, background: "rgba(12,24,54,0.6)" } : undefined}
>
{editing ? (
<>
<button
type="button"
onClick={() => setEditing(false)}
className="rounded-lg px-2.5 py-1.5 text-[11px] font-bold text-slate-500 hover:bg-slate-200/60"
style={dark ? { color: DARK.textMuted } : undefined}
>
取消
</button>
@@ -451,6 +570,7 @@ function NodeDetailModal({
type="button"
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-[11px] font-bold text-slate-700 hover:border-primary/40 hover:text-primary"
style={dark ? { borderColor: DARK.border, color: DARK.text } : undefined}
>
<Pencil className="h-3.5 w-3.5" />
编辑
@@ -469,27 +589,39 @@ function EditField({
value,
onChange,
multiline,
dark = false,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
dark?: boolean;
}) {
const fieldStyle = dark
? { background: "rgba(6,14,36,0.45)", borderColor: DARK.border, color: DARK.text }
: undefined;
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</div>
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
{label}
</div>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs leading-relaxed text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
style={fieldStyle}
/>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
style={fieldStyle}
/>
)}
</div>
@@ -500,14 +632,19 @@ function ConnectionList({
title,
entries,
onJump,
dark = false,
}: {
title: string;
entries: Array<{ id: string; label: string; edgeLabel?: string }>;
onJump: (id: string) => void;
dark?: boolean;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
{title}
</div>
<div className="flex flex-wrap gap-1.5">
@@ -517,11 +654,15 @@ function ConnectionList({
type="button"
onClick={() => onJump(entry.id)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-[11px] font-semibold text-slate-700 transition hover:border-primary/40 hover:text-primary"
style={dark ? { borderColor: DARK.border, background: "rgba(6,14,36,0.4)", color: DARK.text } : undefined}
title="查看该节点详情"
>
{entry.label}
{entry.edgeLabel && (
<span className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500">
<span
className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500"
style={dark ? { background: "rgba(45,68,115,0.6)", color: DARK.textMuted } : undefined}
>
{entry.edgeLabel}
</span>
)}
@@ -534,8 +675,10 @@ function ConnectionList({
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onChange }: FlowGraphPanelProps) {
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onChange, orientation, miniMap }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
const { theme } = useTheme();
const isDark = theme === "dark";
const inner = (
<div
className={cn(
@@ -544,6 +687,7 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onC
// 全屏=覆盖整个视口(z 高于 app 顶部导航,保证顶部工具栏可见、可点「退出全屏」)。
fullscreen ? "fixed inset-0 z-[9999]" : "relative h-full min-h-0 w-full",
)}
style={isDark ? { background: DARK.pageBg } : undefined}
>
<ReactFlowProvider>
<FlowCanvasInner
@@ -554,6 +698,8 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onC
onClose={onClose}
onSwitchToReport={onSwitchToReport}
onChange={onChange}
orientation={orientation}
miniMap={miniMap}
/>
</ReactFlowProvider>
</div>
@@ -67,6 +67,11 @@ import type { IntentSummary } from "@/roundtable-planning/api/intent";
export interface Step1PanelProps {
// ─── State ────────────────────────────────────────────────────
/**
* 头部左侧自定义内容(深链 taskId 模式:用「策划流程」小图取代默认标题)。
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
*/
headerLeftSlot?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -93,6 +98,7 @@ export interface Step1PanelProps {
export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
interactiveMessages,
intentThreadId,
intentReady,
@@ -157,14 +163,16 @@ export function Step1Panel(props: Step1PanelProps) {
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */}
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between gap-3 bg-slate-50/50">
{headerLeftSlot ?? (
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
)}
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
</div>
@@ -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({
</div>
)}
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否把本方案入库到知识库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "now", t: "现在入库" },
{ k: "later", t: "稍后入库" },
{ k: "never", t: "不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestChoice(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{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" && (
<button
type="button"
onClick={() => onManualIngest()}
title="把本方案入库到知识库(调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
@@ -24,14 +24,22 @@ import {
deleteDraft,
deriveDraftName,
getDraft,
getLatestDraftByTask,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftCreatePayload,
type DraftRecord,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
type DraftUpdatePayload,
} from "@/roundtable-planning/api/drafts";
import {
createTaskDraft,
deleteTaskDraft,
getLatestTaskDraftByTask,
getTaskDraft,
listTaskDraftsByTask,
updateTaskDraft,
} from "@/roundtable-planning/api/task-drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
@@ -157,6 +165,27 @@ export function useDraftPersistence(
taskIdRef.current = taskId ?? null;
}, [taskId]);
// taskId 模式下所有 CRUD 走**独立的 task-draft 存储**(不分权);否则走个人草稿存储。
// 读 taskIdRef.current(而非闭包捕获)以在 render 前拿到最新值。
const apiCreate = useCallback(
(payload: DraftCreatePayload): Promise<DraftRecord> =>
taskIdRef.current ? createTaskDraft(payload) : createDraft(payload),
[],
);
const apiUpdate = useCallback(
(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> =>
taskIdRef.current ? updateTaskDraft(id, patch) : updateDraft(id, patch),
[],
);
const apiGet = useCallback(
(id: string): Promise<DraftRecord | null> => (taskIdRef.current ? getTaskDraft(id) : getDraft(id)),
[],
);
const apiDelete = useCallback(
(id: string): Promise<void> => (taskIdRef.current ? deleteTaskDraft(id) : deleteDraft(id)),
[],
);
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
@@ -194,7 +223,7 @@ export function useDraftPersistence(
// taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享);
// 普通模式:列当前用户全部草稿。
const tid = taskIdRef.current;
const metas = tid ? await listDraftsByTask(tid) : await listDrafts();
const metas = tid ? await listTaskDraftsByTask(tid) : await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
@@ -244,7 +273,7 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
@@ -253,7 +282,7 @@ export function useDraftPersistence(
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
}
const created = await createDraft({
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
title: deriveDraftName({
intentReady: step1.intentReady,
@@ -272,7 +301,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -311,7 +340,7 @@ export function useDraftPersistence(
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step2 });
const updated = await apiUpdate(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
@@ -320,7 +349,7 @@ export function useDraftPersistence(
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const created = await createDraft({
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
title: deriveDraftName({
intentReady: step1.intentReady,
@@ -338,7 +367,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
@@ -458,7 +487,7 @@ export function useDraftPersistence(
return (async () => {
let draft;
try {
draft = await getDraft(id);
draft = await apiGet(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
@@ -473,7 +502,7 @@ export function useDraftPersistence(
return true;
})();
},
[applyLoadedDraft, refreshList, triggerToast],
[apiGet, applyLoadedDraft, refreshList, triggerToast],
);
/**
@@ -484,7 +513,7 @@ export function useDraftPersistence(
async (tid: string): Promise<boolean> => {
let draft: DraftRecord | null = null;
try {
draft = await getLatestDraftByTask(tid);
draft = await getLatestTaskDraftByTask(tid);
} catch (err) {
console.warn("[drafts] 按任务加载草稿失败:", err);
return false;
@@ -503,7 +532,7 @@ export function useDraftPersistence(
}
void (async () => {
try {
await deleteDraft(id);
await apiDelete(id);
} catch (err) {
console.warn("[drafts] 删除草稿失败:", err);
triggerToast("⚠️ 删除失败,请重试");
@@ -515,7 +544,7 @@ export function useDraftPersistence(
triggerToast("🗑 已删除历史记录");
})();
},
[refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
[apiDelete, refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
);
const handleStartRename = useCallback((draft: DraftListItem) => {
@@ -534,7 +563,7 @@ export function useDraftPersistence(
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
const updated = await updateDraft(id, { title: trimmed });
const updated = await apiUpdate(id, { title: trimmed });
if (updated) {
await refreshList();
triggerToast("✏️ 已重命名");
@@ -548,7 +577,7 @@ export function useDraftPersistence(
}
})();
},
[refreshList, renameDraftValue, triggerToast],
[apiUpdate, refreshList, renameDraftValue, triggerToast],
);
const handleCancelRename = useCallback(() => {
@@ -11,14 +11,18 @@ import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
import {
deleteTaskDraft,
listTaskDraftsByTask,
updateTaskDraft,
} from "@/roundtable-planning/api/task-drafts";
/**
* @param taskId 外部任务 id(深链 taskId 模式)。非空时只列该任务的草稿
* (一个 taskId 多条、跨用户共享);否则列当前用户全部草稿。
* @param taskId 外部任务 id(深链 taskId 模式)。非空时走**独立的 task-draft 存储**
* (一个 taskId 多条、跨用户共享、不分权);否则列当前用户全部个人草稿。
*/
export function useRoundtableDrafts(taskId?: string | null) {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
@@ -27,7 +31,7 @@ export function useRoundtableDrafts(taskId?: string | null) {
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = taskId ? await listDraftsByTask(taskId) : await listDrafts();
const data = taskId ? await listTaskDraftsByTask(taskId) : await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
@@ -36,20 +40,25 @@ export function useRoundtableDrafts(taskId?: string | null) {
}
}, [taskId]);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = await updateDraft(id, { title });
if (updated) {
setDrafts((prev) =>
prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)),
);
}
}, []);
const rename = useCallback(
async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = taskId ? await updateTaskDraft(id, { title }) : await updateDraft(id, { title });
if (updated) {
setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)));
}
},
[taskId],
);
const remove = useCallback(async (id: string) => {
await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
}, []);
const remove = useCallback(
async (id: string) => {
if (taskId) await deleteTaskDraft(id);
else await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
},
[taskId],
);
return { drafts, loading, refresh, rename, remove };
}
@@ -55,6 +55,12 @@ interface UseStep3DashboardOptions {
buildInitialPrompt?: () => string | null | undefined;
}
/** 恢复大屏时可带上的历史生成过程对话(文字 + 步骤条)+ stub —— 整段回显用。 */
export interface Step3DashRestore {
dialogues?: unknown[];
seatStubMessages?: Record<string, unknown[]>;
}
export interface UseStep3DashboardReturn {
phase: DashboardPhase;
error: string | null;
@@ -74,10 +80,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;
/** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */
hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
@@ -453,7 +462,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
);
const hydrateSaved = useCallback(
(savedHtml: string) => {
(savedHtml: string, saved?: Step3DashRestore) => {
abort();
runningRef.current = false;
htmlRef.current = savedHtml;
@@ -470,21 +479,39 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(saved?.dialogues)
? (saved!.dialogues as Step2Dialogue[])
: [];
const savedStubs =
saved?.seatStubMessages && typeof saved.seatStubMessages === "object"
? (saved.seatStubMessages as Record<string, StubAiMessage[]>)
: null;
if (savedStubs && Object.keys(savedStubs).length) {
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
const merged = { ...stubRef.current, ...savedStubs };
stubRef.current = merged;
setSeatStubMessages(merged);
}
if (savedDialogues.length) {
setDialogues(savedDialogues);
} else {
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
@@ -494,11 +521,11 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
const hydrateFromJson = useCallback(
(jsonStr: string) => {
(jsonStr: string, saved?: Step3DashRestore) => {
const parsed = parseReportJson(jsonStr);
if (!parsed) return;
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
hydrateSaved(rendered, saved); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)+ 历史对话
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
},
@@ -1034,7 +1034,17 @@ export function useStep3Report(
},
],
};
stubRef.current = stub;
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(snap.reportDialogues)
? (snap.reportDialogues as Step2Dialogue[])
: [];
const savedStubs =
snap.reportSeatStubMessages && typeof snap.reportSeatStubMessages === "object"
? (snap.reportSeatStubMessages as Record<string, StubAiMessage[]>)
: null;
// 合并:合成 stub 保住「恢复后预览」锚点(synthThreadId),历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub = savedStubs ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 HTML(防串台)。
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
REPORT_OUTPUT_PATH,
@@ -1042,33 +1052,40 @@ export function useStep3Report(
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
setSeatStubMessages(stub);
setSeatStubMessages(effectiveStub);
setReportThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
if (savedDialogues.length) {
// 整段回显历史绘制过程(每条气泡的文字内容 + 步骤条都在),与第二步一致。
bubbleIdRef.current = savedDialogues[savedDialogues.length - 1]?.id ?? null;
setDialogues(savedDialogues);
} else {
// 旧草稿没存对话 → 回退重建单条「历史记录」气泡(仅最终产物)。
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
@@ -41,7 +41,12 @@ import type {
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { classifyFlowIntent, streamFlowExtract, type FlowIntent } from "@/roundtable-planning/api/flow-extract";
import {
classifyFlowIntent,
classifyIngestIntent,
streamFlowExtract,
type FlowIntent,
} from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -73,8 +78,22 @@ interface UseStep3SummaryOptions {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/**
* 入库技能是否可用(示例/真实入库技能存在且启用)。入库功能**仅在深链(taskId)模式**
* (会商 rwfx-A 等)且本项为 true 时启用:流程图生成完成后自动询问是否入库,并启用
* 「稍后入库」自由文字意图识别 + 手动入库按钮。
*/
ingestAvailable?: boolean;
/**
* 深链外部任务 id(rwfx-A 等)。入库功能仅深链模式才有;入库时该 taskId 会传给入库
* 技能(技能必需信息)。非深链(无 taskId)时入库功能整体不出现。
*/
taskId?: string | null;
}
/** 第三步入库状态机。 */
export type IngestStatus = "idle" | "asking" | "running" | "done" | "later" | "declined";
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
@@ -120,6 +139,24 @@ export interface UseStep3SummaryReturn {
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
openArtifactPreview: (threadId: string, path: string) => void;
// ── 入库(生成流程图后询问 / 稍后入库意图 / 手动入库)──
/** 入库技能是否可用(回显 option,组件据此决定是否显示入库按钮/卡片)。 */
ingestAvailable: boolean;
/** 入库状态机。 */
ingestStatus: IngestStatus;
/** 「稍后入库」自由文字意图模糊时挂起的原始消息(非空 → 组件展示入库协助选项)。 */
pendingIngestClarification: string | null;
/** 流程图后入库询问的三选一(现在/稍后/不入库)。 */
resolveIngestChoice: (choice: "now" | "later" | "never") => void;
/** 入库协助选项点选(开始入库/补充内容后再入库/只是提问→转交普通对话)。 */
resolveIngestClarification: (choice: "start_ingest" | "add_data" | "other") => void;
/** 手动入库(按钮 / 直接触发):在总结线程跑入库技能并显示步骤条。 */
runIngest: (opts?: { note?: string }) => void;
}
/** 演示用:步骤条之间的小停顿,让「调用入库技能」的过程看得见。 */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
@@ -393,6 +430,8 @@ export function useStep3Summary(
autoFlow,
savedSummary,
onSummarySaved,
ingestAvailable,
taskId: ingestTaskId,
} = options;
const onSummarySavedRef = useRef(onSummarySaved);
@@ -400,6 +439,28 @@ export function useStep3Summary(
onSummarySavedRef.current = onSummarySaved;
}, [onSummarySaved]);
// ── 入库状态 ──
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [pendingIngestClarification, setPendingIngestClarification] = useState<string | null>(null);
// 镜像供 render 外的回调(runFlowExtract 完成、ask 拦截)同步读到最新值。
const ingestStatusRef = useRef<IngestStatus>("idle");
useEffect(() => {
ingestStatusRef.current = ingestStatus;
}, [ingestStatus]);
// 入库功能「有效可用」= 技能可用 **且** 处于深链(taskId)模式。
const ingestEnabled = !!ingestAvailable && !!ingestTaskId;
const ingestAvailableRef = useRef<boolean>(ingestEnabled);
useEffect(() => {
ingestAvailableRef.current = ingestEnabled;
}, [ingestEnabled]);
// 深链 taskId(入库必传给技能)。
const ingestTaskIdRef = useRef<string | null>(ingestTaskId ?? null);
useEffect(() => {
ingestTaskIdRef.current = ingestTaskId ?? null;
}, [ingestTaskId]);
// 入库演示运行序号:新一轮入库 / 中断会自增,旧循环据此自停(防重复入库)。
const ingestRunRef = useRef(0);
const {
setArtifacts,
select: selectArtifact,
@@ -1046,6 +1107,11 @@ export function useStep3Summary(
model: selectedModel || "",
closing: serializeFlowGraph(graph),
});
// 流程图(实时生成完成)后,若入库技能可用且尚未处理过入库 → 询问是否入库。
// 仅实时生成走这里;hydrate 恢复不触发(不打扰已看历史的用户)。
if (ingestAvailableRef.current && ingestStatusRef.current === "idle") {
setIngestStatus("asking");
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -1165,8 +1231,96 @@ export function useStep3Summary(
runFlowExtract,
]);
/**
* 触发一次草稿落库:重发当前总结快照(md + 流程图 closing 原样),让上层 step3SavedTick
* 自增 → autoSave 把最新对话(含入库气泡)+ ingestStatus 一并存进草稿。入库/选择稍后等
* 不经 onSummarySaved 的状态变化靠它落库。closing 用当前流程图序列化保持与流程图收尾一致。
*/
const persistSummary = useCallback(() => {
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: flowGraphRef.current ? serializeFlowGraph(flowGraphRef.current) : "",
});
}, [selectedModel]);
/**
* 入库(示例 · 演示用):在**总结线程**上出一条「知识库入库」气泡,按
* 调度 → 调用入库技能 →(补充内容)→ 提交 → 完成 的步骤条演示调用过程,与第二步同款。
* 真实入库技能后续替换这里(改为真的跑技能 / 调入库接口)。
*/
const runIngest = useCallback(
async (opts?: { note?: string }) => {
// 未挂载入库技能(或非深链模式)→ 不执行任何入库操作。
if (!ingestAvailableRef.current) return;
setPendingIngestClarification(null);
setIngestStatus("running");
const myRun = ++ingestRunRef.current;
const bubbleId = appendStreamingDialogue({
sender: "知识库入库",
avatarType: "blue",
agentThreadId: liveThreadRef.current ?? undefined,
});
const title = intentReady?.objective?.trim() || "会商方案";
// 入库数据**以最后修改后的为准**:读 flowGraphRef.current(画布手动改图也会同步进来)
// 与当前报告 md。taskId 是入库技能必需信息,必须随入库一起传给技能。
const flow = flowGraphRef.current;
const md = currentMdRef.current;
const taskId = ingestTaskIdRef.current;
const submitArgs: Record<string, unknown> = {
title,
taskId, // 技能必需:深链外部任务 id
hasReport: !!md.trim(),
flowNodes: flow?.nodes?.length ?? 0,
flowEdges: flow?.edges?.length ?? 0,
...(opts?.note ? { note: opts.note } : {}),
};
const steps: Array<{ label: string; key: string; args?: Record<string, unknown> }> = [
{ label: "调度入库技能", key: "tool_calling:ingest_dispatch", args: { taskId } },
{ label: "调用「知识库入库」技能", key: "tool_calling:ingest_skill", args: { skill: "knowledge-base-ingest", taskId } },
...(opts?.note
? [{ label: "并入补充内容", key: "tool_calling:ingest_append", args: { note: opts.note } }]
: []),
{ label: "提交方案数据入库(以最新流程图为准)", key: "tool_calling:ingest_submit", args: submitArgs },
];
for (const s of steps) {
if (ingestRunRef.current !== myRun) return; // 被新一轮入库 / 中断取代
setDialogueConfidence(bubbleId, s.label);
appendStepIfNew(bubbleId, s.label, s.key, s.args);
await sleep(700);
}
if (ingestRunRef.current !== myRun) return;
finalizeDialogue(
bubbleId,
`✅ 已将「${title}」入库到知识库(示例技能 · 演示用,后续接入真实入库技能)。`,
"入库完成",
);
setIngestStatus("done");
persistSummary(); // 把入库气泡 + ingestStatus 落进草稿
},
[appendStreamingDialogue, appendStepIfNew, setDialogueConfidence, finalizeDialogue, intentReady, persistSummary],
);
/** 流程图后入库询问三选一。 */
const resolveIngestChoice = useCallback(
(choice: "now" | "later" | "never") => {
if (choice === "now") {
void runIngest();
} else if (choice === "later") {
setIngestStatus("later");
persistSummary();
} else {
setIngestStatus("declined");
persistSummary();
}
},
[runIngest, persistSummary],
);
const ask = useCallback(
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent }) => {
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent; skipIngest?: boolean }) => {
const text = message.trim();
const hasFiles = !!(files && files.length > 0);
if (!text && !hasFiles) return;
@@ -1184,6 +1338,29 @@ export function useStep3Summary(
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
}
// 入库优先意图:流程图后入库询问中 / 选了「稍后入库」待命时,先判断用户是否想
// 开始入库 / 补充内容后入库;模糊则发起入库协助。其它(other)落到下方报告/流程图分流。
const ingestPending =
!opts?.skipIngest &&
!opts?.forcedIntent &&
(ingestStatusRef.current === "later" || ingestStatusRef.current === "asking");
if (text && !hasFiles && ingestPending) {
const ir = await classifyIngestIntent({ message: text, model: selectedModel || undefined });
if (ir.intent === "start_ingest") {
await runIngest();
return;
}
if (ir.intent === "add_data") {
await runIngest({ note: text });
return;
}
if (ir.intent === "unclear") {
setPendingIngestClarification(text);
return;
}
// ir.intent === "other" → 继续走下方报告/流程图意图分流。
}
// 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 /
// 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
@@ -1343,9 +1520,22 @@ export function useStep3Summary(
finalizeDialogue,
streamOnce,
runFlowExtract,
runIngest,
],
);
/** 入库协助点选:开始入库 / 补充内容后入库 / 只是提问(转交普通对话,跳过入库拦截)。 */
const resolveIngestClarification = useCallback(
(choice: "start_ingest" | "add_data" | "other") => {
const msg = pendingIngestClarification;
setPendingIngestClarification(null);
if (choice === "start_ingest") void runIngest();
else if (choice === "add_data") void runIngest({ note: msg ?? undefined });
else if (msg) void ask(msg, undefined, { skipIngest: true });
},
[pendingIngestClarification, runIngest, ask],
);
/** 澄清后用户点选意图 → 用 forcedIntent 复跑(跳过意图分类)。 */
const resolveFlowClarification = useCallback(
(choice: FlowIntent) => {
@@ -1410,7 +1600,18 @@ export function useStep3Summary(
],
}
: {};
stubRef.current = stub;
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(snap.dialogues)
? (snap.dialogues as Step2Dialogue[])
: [];
const savedStubs =
snap.seatStubMessages && typeof snap.seatStubMessages === "object"
? (snap.seatStubMessages as Record<string, StubAiMessage[]>)
: null;
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -1427,36 +1628,45 @@ 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");
// 恢复入库状态(done/later/declined 不再重复询问;旧草稿无该字段 → idle 且不自动询问)。
setIngestStatus((snap.ingestStatus as IngestStatus | undefined) ?? "idle");
setPendingIngestClarification(null);
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
@@ -1490,5 +1700,12 @@ export function useStep3Summary(
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
// 入库(仅深链 taskId 模式 + 技能可用时有效)
ingestAvailable: ingestEnabled,
ingestStatus,
pendingIngestClarification,
resolveIngestChoice,
resolveIngestClarification,
runIngest: (opts?: { note?: string }) => void runIngest(opts),
};
}
@@ -53,11 +53,16 @@ export interface FlowGraphData {
edges: FlowGraphEdge[];
}
export interface FlowNodeColors {
label: string;
bg: string;
border: string;
text: string;
dot: string;
}
/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */
export const FLOW_NODE_TYPE_META: Record<
FlowNodeType,
{ label: string; bg: string; border: string; text: string; dot: string }
> = {
export const FLOW_NODE_TYPE_META: Record<FlowNodeType, FlowNodeColors> = {
start: { label: "开始", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "结束", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "阶段", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
@@ -68,6 +73,27 @@ export const FLOW_NODE_TYPE_META: Record<
role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" },
};
/**
* 深蓝主题下的节点配色(对齐《深蓝色 UI 规范》ZS-004 深底):保留各类型色相作识别,
* 底色取该色相在深底上的低透明度叠色(dot @ ~16–18%),描边取色相高透明度,
* 文字用同色相浅色调(深底可读),强调点沿用亮色。供流程图弹窗 + 头部小节点用。
*/
export const FLOW_NODE_TYPE_META_DARK: Record<FlowNodeType, FlowNodeColors> = {
start: { label: "开始", bg: "rgba(100,116,139,0.20)", border: "rgba(148,163,184,0.55)", text: "#C5CFE0", dot: "#94a3b8" },
end: { label: "结束", bg: "rgba(100,116,139,0.20)", border: "rgba(148,163,184,0.55)", text: "#C5CFE0", dot: "#94a3b8" },
stage: { label: "阶段", bg: "rgba(59,130,246,0.20)", border: "rgba(96,165,250,0.60)", text: "#bfdbfe", dot: "#60a5fa" },
task: { label: "任务", bg: "rgba(6,182,212,0.20)", border: "rgba(34,211,238,0.60)", text: "#a5f3fc", dot: "#22d3ee" },
decision: { label: "判断", bg: "rgba(245,158,11,0.18)", border: "rgba(251,191,36,0.55)", text: "#fcd34d", dot: "#fbbf24" },
risk: { label: "风险", bg: "rgba(239,68,68,0.18)", border: "rgba(248,113,113,0.55)", text: "#fecaca", dot: "#f87171" },
deliverable: { label: "交付物", bg: "rgba(34,197,94,0.18)", border: "rgba(74,222,128,0.55)", text: "#bbf7d0", dot: "#4ade80" },
role: { label: "角色", bg: "rgba(168,85,247,0.20)", border: "rgba(192,132,252,0.58)", text: "#e9d5ff", dot: "#c084fc" },
};
/** 按明暗取节点配色表。 */
export function flowNodeMeta(dark: boolean): Record<FlowNodeType, FlowNodeColors> {
return dark ? FLOW_NODE_TYPE_META_DARK : FLOW_NODE_TYPE_META;
}
const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
@@ -395,9 +421,22 @@ export interface PositionedFlowNode extends FlowGraphNode {
export const FLOW_NODE_W = 176;
const X_GAP = 224;
const Y_GAP = 132;
// 横向布局:层间走 x(节点宽 176 + 连线/标签留白),层内同层节点沿 y 堆叠。
const LAYER_GAP_X = 248;
const NODE_GAP_Y = 104;
/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */
export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
export type FlowOrientation = "vertical" | "horizontal";
/**
* 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。
*
* orientation="vertical"(默认):层自上而下、同层横向铺开。
* orientation="horizontal":层自左而右、同层纵向铺开(弹窗里宽屏看着更舒展)。
*/
export function layoutFlowGraph(
data: FlowGraphData,
orientation: FlowOrientation = "vertical",
): PositionedFlowNode[] {
const preds = new Map<string, string[]>();
for (const e of data.edges) {
const list = preds.get(e.to);
@@ -428,10 +467,19 @@ export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
const pos = new Map<string, { x: number; y: number }>();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
if (orientation === "horizontal") {
// 层 → x,同层节点沿 y 居中堆叠。
const colHeight = (nodes.length - 1) * NODE_GAP_Y;
nodes.forEach((n, i) => {
pos.set(n.id, { x: layer * LAYER_GAP_X, y: i * NODE_GAP_Y - colHeight / 2 });
});
} else {
// 层 → y,同层节点沿 x 居中铺开(原默认)。
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
}
// 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! }));
@@ -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=链条顺序)。 */
@@ -122,6 +122,15 @@ export default function DashboardAgentPage() {
}
}, [availableModels, selectedModel]);
// init 只跑一次:start / hydrateTranscript 用 ref 持有,effect 只依赖 [taskId]。
// 否则它们的 identity 会随 selectedModel/themeMode 变化重建(模型挂载后异步加载即触发),
// 让 effect 在 await 期间 cleanup → 二次进入被 didInitRef 挡掉 → 首跑的 finally 因 cancelled
// 跳过 setLoading(false),loading 永远停在 true(白转圈,卡在「正在加载任务大屏…」)。
const startRef = useRef(start);
startRef.current = start;
const hydrateTranscriptRef = useRef(hydrateTranscript);
hydrateTranscriptRef.current = hydrateTranscript;
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ──
const didInitRef = useRef(false);
useEffect(() => {
@@ -131,37 +140,38 @@ export default function DashboardAgentPage() {
return;
}
didInitRef.current = true;
let cancelled = false;
void (async () => {
try {
const session = await getDashboardSessionByTask(taskId).catch(() => null);
if (cancelled) return;
// 会话 + 输入 JSON 并行取。输入**始终**备好并写进 inputRef —— 否则恢复历史
// 后用户点「重新绘制」/续问时 buildInitialPrompt 拿不到源数据,会回退到空的
// 圆桌 prompt,智能体没东西可分析 → 生成不出数据(即「模拟数据不生效」)。
const [session, input] = await Promise.all([
getDashboardSessionByTask(taskId).catch(() => null),
fetchDashboardInputJson(taskId).catch(() => null),
]);
inputRef.current = input;
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
const validReport = session?.reportJson && parseReportJson(session.reportJson);
if (validReport) {
// 有有效结果 → 恢复并直接展示大屏页面。
hydrateTranscript(savedDialogues, session!.reportJson);
hydrateTranscriptRef.current(savedDialogues, session!.reportJson);
setView("page");
} else if (savedDialogues.length > 0) {
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑。
hydrateTranscript(savedDialogues, session?.reportJson ?? null);
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。
hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null);
setView("chat");
} else {
// 无任何记录 → 拉输入 JSON 自动开跑,让用户看生成过程(不自动跳)。
const input = await fetchDashboardInputJson(taskId).catch(() => null);
if (cancelled) return;
inputRef.current = input;
// 无任何记录 → 自动开跑,让用户看生成过程(不自动跳)。
setView("chat");
if (input) start();
if (input) startRef.current();
}
} finally {
if (!cancelled) setLoading(false);
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [taskId, hydrateTranscript, start]);
// 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重跑本 effect。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
useEffect(() => {
@@ -245,9 +255,9 @@ export default function DashboardAgentPage() {
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
<div className="flex h-full min-h-0 flex-col text-slate-800 dark:text-slate-200">
{/* 顶栏 */}
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="dashboard-agent-topbar shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="flex items-center gap-2.5">
<Sparkles className="h-4.5 w-4.5 text-primary" />
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
@@ -26,6 +26,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
BookOpen,
Check,
CheckCircle,
ChevronRight,
@@ -46,10 +47,12 @@ import {
Save,
Settings,
Sparkles,
Target,
Trash2,
Upload,
Users,
X,
Zap,
} from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
@@ -62,6 +65,15 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposeDialog";
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
import type { TaskBusiness } from "@/core/auth/task-buttons";
import {
derivePurposeLevels,
fetchTaskPurposeDetail,
type PurposeLevel,
} from "@/core/auth/task-purpose";
import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-graph";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
@@ -86,6 +98,7 @@ import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { isIngestSkillAvailable } from "@/roundtable-planning/api/ingest";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import {
BUILTIN_AGENT_META,
@@ -122,6 +135,16 @@ import { listBusinessMappings } from "@/strategy-components/api/business-mapping
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
/**
* 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏
* (与单智能体问答共用 ``TaskJumpBar``)。与 task-deeplink.ts 的 GOPATH_BUSINESS_CODE 反向一致。
*/
const BUSINESS_CODE_TO_TASK_BUSINESS: Record<string, TaskBusiness> = {
"3Q": "3qfx",
"6BF": "rwfx",
"7BF": "xdfx",
};
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
@@ -230,6 +253,11 @@ export default function RoundtablePlanningPage() {
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
// 任务目的弹窗(多智能体会商深链 taskId 模式:查看线上当前策划进度/目的树)。
const [isTaskPurposeOpen, setIsTaskPurposeOpen] = useState<boolean>(false);
// 深链 taskId 模式:消息列表上方的「策划流程示例图」——按目的树层级画彩色小图标
// (任务→目的→…→叙事),点击打开任务目的流程图弹窗。空 = 无数据/未取到,不显示。
const [purposeLevels, setPurposeLevels] = useState<PurposeLevel[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
@@ -257,6 +285,11 @@ export default function RoundtablePlanningPage() {
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
// 入库技能是否可用(决定第三步生成流程图后是否询问入库 + 显示手动入库按钮)。挂载查一次。
const [ingestAvailable, setIngestAvailable] = useState<boolean>(false);
useEffect(() => {
void isIngestSkillAvailable().then(setIngestAvailable).catch(() => setIngestAvailable(false));
}, []);
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
const activeStep2RunIdRef = useRef<string | null>(null);
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
@@ -557,6 +590,19 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, hydrateStep2Run, mergeActiveRun, step2, step3Report],
);
// 第三步三条生成流(总结报告 / 经典看板 / 大屏)各自生成过程的实时对话(中间文字 + 步骤条)。
// 在 render 阶段镜像进 ref(这些 flow 在本 hook 之后才定义,故用 ref 跨过 TDZ;render 期赋值,
// 保证 autoSave effect 读到的是本次提交已 flush 的最终对话,规避 effect 执行顺序问题)。
type Step3Convo = {
dialogues: unknown[];
seatStubMessages: Record<string, unknown[]>;
/** 仅总结流用:入库状态,随快照落库。 */
ingestStatus?: string;
};
const step3SummaryConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
const step3ReportConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
const step3DashConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({
currentStep,
@@ -566,7 +612,37 @@ 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,
ingestStatus: sumLive.ingestStatus ?? sr.ingestStatus,
};
}
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,
@@ -848,6 +924,25 @@ export default function RoundtablePlanningPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 深链 taskId 模式:挂载即取目的树,抽出有序层级供消息列表上方的「策划流程示例图」。
// 与任务目的弹窗同一条接口(弹窗打开时再各自取一帧,互不影响);取不到则保持空、不显示。
useEffect(() => {
const tid = prefillRef.current?.taskId;
if (!tid) return;
let cancelled = false;
void fetchTaskPurposeDetail(tid)
.then((r) => {
if (!cancelled) setPurposeLevels(derivePurposeLevels(r.tree));
})
.catch(() => {
if (!cancelled) setPurposeLevels([]);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
@@ -1026,7 +1121,16 @@ export default function RoundtablePlanningPage() {
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
taskId: prefillRef.current?.taskId ?? null,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {
dialogues: summaryFlow.dialogues,
seatStubMessages: summaryFlow.seatStubMessages,
ingestStatus: summaryFlow.ingestStatus,
};
const reportFlow = useStep3Report({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
@@ -1035,6 +1139,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 +1153,11 @@ export default function RoundtablePlanningPage() {
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像大屏的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3DashConvoRef.current = {
dialogues: dashboardFlow.dialogues,
seatStubMessages: dashboardFlow.seatStubMessages,
};
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
@@ -1086,10 +1200,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 +1254,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 +1348,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 +1361,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 +1409,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 +1423,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 +1434,7 @@ export default function RoundtablePlanningPage() {
} else {
const job = await startJob({
draftId: savedId ?? undefined,
taskId: tid ?? undefined,
intent,
agents,
model,
@@ -1374,6 +1497,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,
@@ -1473,9 +1597,19 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 任务目的弹窗(深链 taskId 模式:查看线上当前策划进度/目的树,只读) */}
{prefillRef.current?.taskId && (
<TaskPurposeDialog
taskId={prefillRef.current.taskId}
open={isTaskPurposeOpen}
onOpenChange={setIsTaskPurposeOpen}
/>
)}
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
taskId={prefillRef.current?.taskId ?? null}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
@@ -1512,6 +1646,19 @@ export default function RoundtablePlanningPage() {
/>
<div className="flex items-center gap-2">
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
{prefillRef.current?.taskId && (
<button
id="task-purpose-btn"
onClick={() => setIsTaskPurposeOpen(true)}
title="查看该任务在线上的当前策划进度(目的树)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Layers className="w-3.5 h-3.5 text-primary" />
任务目的
</button>
)}
{!inWujieEmbed && (
<button
id="business-chain-btn"
@@ -1627,6 +1774,15 @@ export default function RoundtablePlanningPage() {
>
{currentStep === 1 && (
<Step1Panel
headerLeftSlot={
prefillRef.current?.taskId && purposeLevels.length > 0 ? (
<PurposeFlowPreview
levels={purposeLevels}
onOpen={() => setIsTaskPurposeOpen(true)}
dark={isDark}
/>
) : undefined
}
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
@@ -1767,6 +1923,17 @@ export default function RoundtablePlanningPage() {
onSaveIntent={handleSaveIntent}
/>
</main>
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
{prefillRef.current?.taskId &&
prefillRef.current?.businessCode &&
BUSINESS_CODE_TO_TASK_BUSINESS[prefillRef.current.businessCode] && (
<TaskJumpBar
business={BUSINESS_CODE_TO_TASK_BUSINESS[prefillRef.current.businessCode]}
taskId={prefillRef.current.taskId}
roundtable
/>
)}
</div>
);
}
@@ -3423,6 +3590,66 @@ interface RightSidebarProps {
onSaveIntent: (next: IntentSummary) => void;
}
/**
* 深链 taskId 模式 · Step 1 消息列表头部左侧的「策划流程」小图。
*
* 取代默认标题(任务意图理解),按目的树层级(目的 → 行为体 → … → 叙事)横向排列
* 彩色小图标、图标间用箭头串联,配色与流程图画布节点一致(FLOW_NODE_TYPE_META)。
* 点击整条 → 打开任务目的流程图弹窗看完整大图(不再单独放「查看」按钮)。
* 无层级数据时由调用方退回默认标题(此处 levels 必非空)。
*/
/** 各层级(流程图节点类型)对应的小图标;未列出的类型回退 Layers。 */
const LEVEL_ICON: Partial<Record<FlowNodeType, React.ComponentType<{ className?: string }>>> = {
stage: Target, // 目的
role: Users, // 行为体
task: Activity, // 预期行为
decision: Zap, // 驱动因素
deliverable: BookOpen, // 叙事
};
function PurposeFlowPreview({
levels,
onOpen,
dark = false,
}: {
levels: PurposeLevel[];
onOpen: () => void;
dark?: boolean;
}) {
if (levels.length === 0) return null;
const meta = flowNodeMeta(dark);
return (
<button
type="button"
onClick={onOpen}
title="点击查看完整策划流程图"
className="group/flow custom-scrollbar flex min-w-0 cursor-pointer items-center gap-2 overflow-x-auto rounded-lg text-left transition-opacity hover:opacity-90"
>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
<Layers className="h-3.5 w-3.5 text-primary" />
策划流程
</span>
{levels.map((lv, i) => {
const m = meta[lv.type];
const Icon = LEVEL_ICON[lv.type] ?? Layers;
return (
<span key={`${lv.label}-${i}`} className="flex shrink-0 items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-300" />}
<span
title={lv.label}
style={{ background: m.bg, borderColor: m.border, color: m.text }}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold"
>
<Icon className="size-3 shrink-0" />
{lv.label}
</span>
</span>
);
})}
</button>
);
}
function RightSidebar({
currentStep,
selectedModel,
@@ -268,6 +268,54 @@
border-color: var(--border) !important;
}
/* =============================================================================
* neutral 家族 + Step1 左栏写死的米色/浅底(业务链关系图背板、分析记录卡)。
*
* 这些是 RoundtableEntryRail / 分析记录 用 neutral-* 与米色 arbitrary hex
* (#FAF6F5 / #ECE7E4 / #E8D0CC)写死的浅色,上面 slate 表只覆盖 slate-* 与
* #e2e8f0,未含 neutral 与这些米色,导致深色下「关系图背景」「第N步 tag 底」
* 等仍泛白。这里按 slate 同一套语义 token 收口;neutral-900 浮层底(深色 tooltip)
* 保留不动。
* ========================================================================== */
/* ---- 表面:米白底 / neutral-50·100(含 tag 底)→ muted ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
.bg-\[\#FAF6F5\], .bg-\[\#faf6f5\],
.bg-neutral-50, .bg-neutral-50\/50, .bg-neutral-100
) {
background-color: var(--muted) !important;
}
/* ---- 次级表面 hover:neutral-50 → accent ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
.hover\:bg-neutral-50:hover {
background-color: var(--accent) !important;
}
/* ---- neutral / 米色边框(含 #ECE7E4 / #E8D0CC 虚线,透明度档)→ border ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
.border-neutral-100, .border-neutral-200, .border-neutral-300,
.hover\:border-neutral-300:hover,
.border-\[\#ECE7E4\], .border-\[\#ece7e4\],
.border-\[\#E8D0CC\], .border-\[\#E8D0CC\]\/60
) {
border-color: var(--border) !important;
}
/* ---- neutral 文字:深 → 正文色 ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(.text-neutral-900, .text-neutral-800, .text-neutral-700) {
color: var(--foreground) !important;
}
/* ---- neutral 文字:中 / 浅 → 次级文字色(深色 tooltip 内的 neutral-300 同样可读) ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(.text-neutral-500, .text-neutral-400, .text-neutral-300) {
color: var(--muted-foreground) !important;
}
/* ---- focus ring:brand-200 → 主色淡环 ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
@@ -16,7 +16,7 @@
import { useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowRight, ExternalLink, Loader2, Maximize2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -30,26 +30,14 @@ import {
} from "@/components/ui/dialog";
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import {
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
TASK_BUSINESSES,
buttonsForBusiness,
resolveTaskButtons,
runTaskButton,
type TaskBusiness,
} from "@/core/auth/task-buttons";
import { useTaskButtons } from "@/strategy-components/api/task-buttons";
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
import { useTaskThreads } from "@/core/threads/use-task-threads";
import { titleOfThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import AgentChatPage from "@/pages/AgentChatPage";
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel";
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
@@ -97,79 +85,6 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
return seg && seg !== "new" ? seg : null;
}
/**
* 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。
*/
function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) {
const navigate = useNavigate();
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
useEffect(() => {
let alive = true;
void fetchTaskDeeplinkConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
const { data: storedButtons = [] } = useTaskButtons();
const jumpButtons = useMemo(
() => buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business),
[storedButtons, cfg, business],
);
// 目的树弹框(purpose 类型按钮)由组件层打开。
const [purposeOpen, setPurposeOpen] = useState(false);
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
const ctxActionId = readTaskContext()?.actionId;
const purposeActionId =
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
if (jumpButtons.length === 0) return null;
const handleRun = (btn: (typeof jumpButtons)[number]) => {
if (btn.linkType === "purpose") {
setPurposeOpen(true);
return;
}
void runTaskButton(btn, {
taskId,
actionId: readTaskContext()?.actionId,
navigate,
onError: (m) => toast.error(m),
});
};
return (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{jumpButtons.map((btn) => (
<Button
key={btn.id}
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
onClick={() => handleRun(btn)}
title={desensitize(btn.label)}
>
<span className="truncate">{desensitize(btn.label)}</span>
{btn.linkType === "url" ? (
<ExternalLink className="h-5 w-5 shrink-0" />
) : btn.linkType === "purpose" ? (
<Maximize2 className="h-5 w-5 shrink-0" />
) : (
<ArrowRight className="h-5 w-5 shrink-0" />
)}
</Button>
))}
<TaskPurposeDialog
taskId={taskId}
open={purposeOpen}
onOpenChange={setPurposeOpen}
resolveFromActionId={purposeActionId}
/>
</div>
);
}
/**
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。
* 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。
@@ -0,0 +1,111 @@
/**
* 任务深链的底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转
* 按钮(按钮管理里排序)。单智能体问答(``TaskFxWorkspace``)与多智能体会商
* (``RoundtablePlanningPage`` 深链 taskId 模式)共用。
*
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。
* 见 [[task-buttons]]。
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, ExternalLink, Maximize2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { readTaskContext } from "@/core/auth/task-purpose";
import {
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
buttonsForBusiness,
resolveTaskButtons,
runTaskButton,
type TaskBusiness,
} from "@/core/auth/task-buttons";
import { useTaskButtons } from "@/strategy-components/api/task-buttons";
import { desensitize } from "@/lib/desensitize";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel";
export function TaskJumpBar({
business,
taskId,
roundtable,
}: {
business: TaskBusiness;
taskId: string;
/** 多智能体会商深链模式:不展示「编辑」与「弹框查看」(purpose)按钮。 */
roundtable?: boolean;
}) {
const navigate = useNavigate();
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
useEffect(() => {
let alive = true;
void fetchTaskDeeplinkConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
const { data: storedButtons = [] } = useTaskButtons();
const jumpButtons = useMemo(() => {
const list = buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business);
// 会商页:去掉「编辑」与「弹框查看」(会商页头部已有「任务目的」入口)。
return roundtable
? list.filter((b) => b.linkType !== "purpose" && b.label.trim() !== "编辑")
: list;
}, [storedButtons, cfg, business, roundtable]);
// 目的树弹框(purpose 类型按钮)由组件层打开。
const [purposeOpen, setPurposeOpen] = useState(false);
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
const ctxActionId = readTaskContext()?.actionId;
const purposeActionId =
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
if (jumpButtons.length === 0) return null;
const handleRun = (btn: (typeof jumpButtons)[number]) => {
if (btn.linkType === "purpose") {
setPurposeOpen(true);
return;
}
void runTaskButton(btn, {
taskId,
actionId: readTaskContext()?.actionId,
navigate,
onError: (m) => toast.error(m),
});
};
return (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{jumpButtons.map((btn) => (
<Button
key={btn.id}
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
onClick={() => handleRun(btn)}
title={desensitize(btn.label)}
>
<span className="truncate">{desensitize(btn.label)}</span>
{btn.linkType === "url" ? (
<ExternalLink className="h-5 w-5 shrink-0" />
) : btn.linkType === "purpose" ? (
<Maximize2 className="h-5 w-5 shrink-0" />
) : (
<ArrowRight className="h-5 w-5 shrink-0" />
)}
</Button>
))}
<TaskPurposeDialog
taskId={taskId}
open={purposeOpen}
onOpenChange={setPurposeOpen}
resolveFromActionId={purposeActionId}
/>
</div>
);
}
@@ -0,0 +1,93 @@
/**
* 任务目的树「查看弹窗」——多智能体会商深链(goPath=*-A)等只读场景共用。
*
* 打开时按 taskId 拉取目的树(fetchTaskPurposeDetail,走后端 task_deeplink 配置 /
* 测试假数据,与单智能体闸门面板同一条接口路径),把目的树转换成 Step 3 流程图数据
* (purposeTreeToFlowGraph)后,直接复用第三步的流程图画布组件 FlowGraphPanel 展示
* (节点可点开看详情、拖拽、缩放、全屏)。**纯查看**:不传 onChange,不可编辑。
*/
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import {
fetchTaskPurposeDetail,
purposeTreeToFlowGraph,
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
export function TaskPurposeDialog({
taskId,
open,
onOpenChange,
}: {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
useEffect(() => {
if (!open || !taskId) return;
let cancelled = false;
setLoading(true);
fetchTaskPurposeDetail(taskId)
.then((r) => {
if (!cancelled) setResult(r);
})
.catch(() => {
if (!cancelled) setResult({ tree: [], hasData: false });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, taskId]);
const flowData = useMemo(
() => purposeTreeToFlowGraph(result?.tree ?? [], taskId),
[result, taskId],
);
const hasNodes = flowData.nodes.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* 流程图画布自带工具栏(含关闭),关掉 Dialog 默认右上角关闭按钮避免重叠;去内边距让画布铺满。 */}
<DialogContent
showCloseButton={false}
className="flex h-[85vh] w-[92vw] !max-w-[92vw] flex-col gap-0 overflow-hidden p-0"
>
{/* a11y:Radix 要求 DialogTitle,画布已有可见标题,这里隐藏。 */}
<DialogTitle className="sr-only">任务目的(任务 {taskId})</DialogTitle>
{loading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : hasNodes ? (
<FlowGraphPanel
data={flowData}
streaming={false}
onClose={() => onOpenChange(false)}
orientation="horizontal"
miniMap
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
暂无目的数据
</div>
)}
</DialogContent>
</Dialog>
);
}
export default TaskPurposeDialog;
+140
View File
@@ -516,6 +516,146 @@
background-attachment: fixed;
}
/* AI 写作「课题研究」左栏(ResearchSidebar,class=ai-research-sidebar)的深蓝适配。
* 该组件只为浅色写死了 slate/white,这里仅在 .dark 下统一改写为半透明深蓝玻璃面板 +
* 可读文字(对齐 .sidebar-contents:bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop
* 模糊让外壳 canvas-writing-bg 的深蓝底图朦胧透出。浅色模式不命中,原样式完全不变;
* 彩色图标 chip / rose 选中态作语义高亮,刻意不收进此表。 */
.dark .ai-research-sidebar {
background-color: rgba(3, 38, 80, 0.8) !important;
border-color: rgba(43, 143, 254, 0.45) !important;
color: #c5cfe0 !important;
box-shadow: none !important;
backdrop-filter: blur(12px);
}
.dark .ai-research-sidebar .bg-white {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.dark .ai-research-sidebar
:is(.bg-slate-50, .bg-slate-50\/80, .bg-slate-50\/70, .bg-slate-100) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
.dark .ai-research-sidebar
:is(.hover\:bg-slate-50:hover, .hover\:bg-slate-50\/70:hover) {
background-color: rgba(255, 255, 255, 0.09) !important;
}
.dark .ai-research-sidebar .focus\:bg-white:focus {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.dark .ai-research-sidebar
:is(.border-slate-100, .border-slate-100\/80, .border-slate-200) {
border-color: rgba(43, 143, 254, 0.22) !important;
}
.dark .ai-research-sidebar .hover\:border-slate-200:hover {
border-color: rgba(43, 143, 254, 0.42) !important;
}
.dark .ai-research-sidebar :is(.text-slate-900, .text-slate-800, .text-slate-700) {
color: #e8edf5 !important;
}
.dark .ai-research-sidebar
:is(.text-slate-600, .text-slate-500, .text-slate-400, .text-slate-300) {
color: #8aa0c4 !important;
}
.dark .ai-research-sidebar
:is(
.hover\:text-slate-800:hover,
.hover\:text-slate-900:hover,
.hover\:text-slate-950:hover
) {
color: #e8edf5 !important;
}
/* 浅色投影在深底上发灰,去掉子元素的阴影 */
.dark .ai-research-sidebar [class*="shadow-"] {
box-shadow: none !important;
}
/* 模板图标 chip:浅彩底(*-50)深色下发白 → 改主题语义色 16% 透明软底 + 提亮图标
* (色值取自《深蓝色 UI 规范》§1.1.9 功能语义色 / §1.1.7 装饰色)。 */
.dark .ai-research-sidebar .bg-blue-50 {
background-color: rgba(22, 119, 255, 0.16) !important;
}
.dark .ai-research-sidebar .text-blue-600 {
color: #4096ff !important;
}
.dark .ai-research-sidebar .bg-violet-50 {
background-color: rgba(99, 136, 255, 0.16) !important;
}
.dark .ai-research-sidebar .text-violet-600 {
color: #6388ff !important;
}
.dark .ai-research-sidebar .bg-emerald-50 {
background-color: rgba(82, 196, 26, 0.16) !important;
}
.dark .ai-research-sidebar .text-emerald-600 {
color: #52c41a !important;
}
.dark .ai-research-sidebar .bg-cyan-50 {
background-color: rgba(19, 194, 194, 0.16) !important;
}
.dark .ai-research-sidebar .text-cyan-600 {
color: #36cfc9 !important;
}
.dark .ai-research-sidebar .bg-rose-50 {
background-color: rgba(245, 34, 45, 0.16) !important;
}
.dark .ai-research-sidebar .text-rose-600 {
color: #ff7875 !important;
}
/* 警告橙(amber):模板「周报」chip + 「笔记本服务暂不可用」提示,统一警告软底 +
* 警告色文字(§1.1.9:警告 #FAAD14 / 软底 rgba(250,173,20,0.16))。 */
.dark .ai-research-sidebar .bg-amber-50 {
background-color: rgba(250, 173, 20, 0.16) !important;
}
.dark .ai-research-sidebar :is(.text-amber-700, .text-amber-600) {
color: #faad14 !important;
}
/* 全量模式通用问答左栏(GeneralQaSidebar,class=gq-sidebar)的深蓝适配:深蓝(.dark)
* 主题下由扁平 bg-sidebar 改为「深蓝主题底图 + 半透明蓝罩」玻璃面板——与右侧主区
* workspace-chat-bg 同一张 fixed 底图(视口对齐、左右连续),叠一层蓝罩使其略深、读作
* 独立面板。内部元素已走语义 token,自适配。浅色 / 浅蓝主题不命中,仍用 sidebar token。 */
.dark .gq-sidebar {
background-color: transparent !important;
background-image: linear-gradient(
rgba(3, 38, 80, 0.72),
rgba(3, 38, 80, 0.72)
),
url("../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover, cover;
background-position: center top, center top;
background-repeat: no-repeat, no-repeat;
background-attachment: fixed, fixed;
}
/* 「大屏绘制智能体」独立页顶栏(DashboardAgentPage,class=dashboard-agent-topbar)深色
* 适配:顶栏只为浅色写死了 slate/white 渐变,深色(.dark)下统一改深蓝(《深蓝色 UI 规范》
* 卡片底 #122347 / 页面底 #0C1836 / 边框 #1A2F5A / 字体 #C5CFE0),仅作用于顶栏内,
* 不影响下方大屏/聊天区。浅色不命中,原样式不变。 */
.dark .dashboard-agent-topbar {
background-image: linear-gradient(to right, #122347, #0c1836) !important;
border-color: #1a2f5a !important;
}
.dark .dashboard-agent-topbar .text-slate-900 {
color: #e8edf5 !important;
}
.dark .dashboard-agent-topbar
:is(.text-slate-700, .hover\:text-slate-700:hover) {
color: #c5cfe0 !important;
}
.dark .dashboard-agent-topbar :is(.text-slate-500, .text-slate-400) {
color: #6e88b0 !important;
}
.dark .dashboard-agent-topbar .bg-white {
background-color: #1a2f5a !important;
}
.dark .dashboard-agent-topbar
:is(.bg-slate-50, .hover\:bg-slate-50:hover) {
background-color: #0c1836 !important;
}
.dark .dashboard-agent-topbar
:is(.border-slate-100, .border-slate-200) {
border-color: #1a2f5a !important;
}
.workspace-panel {
border: 1px solid #f0d7d0;
background: #ffffff;
+8 -1
View File
@@ -245,7 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. **External-task binding (`task_id`)**: drafts carry an optional `task_id` (deep-link / 无界 embed); task-bound drafts are **shared** (the store ignores `user_id` for them, so a task's drafts span users). `GET /by-task/{task_id}/latest` - newest draft for a task (回显最新一条); `GET /by-task/{task_id}` - **all** drafts for a task, newest-first (taskId-mode history dropdown: 一个 taskId 多条记录), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_drafts_by_task.py`. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
@@ -271,6 +272,12 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
**Skill-call timeout** is `AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS` (env, default **120s**; `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS`); the DuckDuckGo provider's internal cap was raised 30s→120s so it doesn't undercut it.
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
**Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -0,0 +1,546 @@
"""AI 写作「后台挂起」执行器。
把 AI 写作 LangGraph 图作为**后台 asyncio 任务**驱动到定稿,survive 发起请求的
生命周期(离开页面 / 刷新 / 切换账号都不打断)。
与圆桌后台作业(``roundtable_job_executor``)的根本区别:AI 写作的编排**就是
LangGraph 图本身**(4 个 ``interrupt()`` 干预点),所以这里不另起编排引擎,而是
直接驱动同一张图——用 app 启动时创建的**共享 checkpointer**(``app.state.checkpointer``,
即运行时 worker 跑该图用的同一个 saver,见 ``runtime/runs/worker.py`` 的
``agent.checkpointer = ctx.checkpointer``)续上前端已经写到 checkpoint 的线程状态,
在每个 ``interrupt()`` 处用「默认放行」动作自动 resume,一路推进到定稿:
素材确认 → confirm
大纲确认 → confirm
缺素材求助 → 全部「通用知识续写」(loose,不回检索、不丢章节)
草稿确认 → 未审过先 to_editor,审过(通过/用完次数)再 finalize
编辑打回 → accept_review(按编辑意见重写),直到通过或用完 max_revisions
图节点本就把进度落库(``writer_draft`` 写 draft、``editor`` 写 review_result、
``pause_*`` finalize 时 ``_mark_session_done`` 写 status=done),所以后台跑完用户
从历史里重新打开会话即可看到成稿——这里无需额外的产物表。
"""
from __future__ import annotations
import asyncio
import logging
import os
from langgraph.types import Command
from deerflow.runtime.user_context import AUTO, reset_current_user, set_current_user
logger = logging.getLogger(__name__)
def _slots_per_user() -> int:
"""每个用户同时真正在跑的后台写作上限(默认 2)。
根因与圆桌同源:checkpointer 多为单连接 SQLite(一把 asyncio.Lock 串行全进程
checkpoint 读写),后台作业无并发上限会把多张图的模型轮次压到同一把锁 + 同一
离线模型端点,拖慢前台。按用户限流后随便挂多少都能建,只是同时真跑的 ≤ N。
前台实时路径走 LangGraph 运行时 worker,不经这里,永不受限。
"""
raw = os.getenv("AI_WRITING_BACKGROUND_SLOTS_PER_USER", "").strip()
if not raw:
return 2
try:
return max(1, int(raw))
except ValueError:
return 2
# 每用户一把信号量(懒建)。键为 user_id(None → "default")。
_user_semaphores: dict[str, asyncio.Semaphore] = {}
def _user_semaphore(user_id: str | None) -> asyncio.Semaphore:
key = user_id or "default"
sem = _user_semaphores.get(key)
if sem is None:
sem = asyncio.Semaphore(_slots_per_user())
_user_semaphores[key] = sem
return sem
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
def __init__(self, user_id: str | None) -> None:
self.id = user_id
# 后台运行中的会话状态值(历史下拉据此显示「挂起中」徽标)。
STATUS_BACKGROUND = "background"
# 单个会话最多自动推进的步数(resume / 续跑各算一步)。安全上限,防图拓扑异常死循环。
# 正常一篇:素材→大纲→草稿→审核(最多 max_revisions 轮)→定稿,远小于这个数。
_MAX_DRIVE_STEPS = 60
# 「等待可操作态」轮询:用户在节点执行中(如素材搜集)点后台挂起时,运行时 worker
# 可能还在跑那个节点。此时**绝不能**并发 ainvoke 同一线程(会撞坏 checkpoint),先观察:
# - 出现 interrupt / 走到 done → 立刻接管;
# - 停在节点间且 checkpoint 连续 _INFLIGHT_MAX_STABLE 次不再推进 → 判定无 worker 在跑
# (被遗弃 / 进程重启续跑),可安全自驱。
_INFLIGHT_POLL_SECONDS = 1.5
_INFLIGHT_MAX_STABLE = 3
# 等待极长节点(LLM 慢)的轮询上限,≈ _INFLIGHT_MAX_WAIT_POLLS * _INFLIGHT_POLL_SECONDS 秒。
_INFLIGHT_MAX_WAIT_POLLS = 800
# 进度弹窗:把「下一个待执行节点 / 当前 interrupt 暂停点」映射成前端流程图的规范化阶段 key。
_NODE_TO_STAGE = {
"intent_parser": "intent",
"researcher": "research",
"pause_material": "material_confirm",
"writer_outline": "outline",
"pause_outline": "outline_confirm",
"writer_draft": "draft",
"pause_section_help": "draft",
"pause_draft": "draft_confirm",
"editor": "review",
"pause_review": "review",
}
_PAUSE_TO_STAGE = {
"material_confirm": "material_confirm",
"outline_confirm": "outline_confirm",
"section_help": "draft",
"draft_confirm": "draft_confirm",
"review_confirm": "review",
}
# 暂停点附带的细化说明(流水线主链之外的分支)。
_STAGE_NOTE = {
"section_help": "素材不足求助",
"review_confirm": "编辑打回",
}
# resumed 事件 message → (pause_point, action)。与 graph.py 各 pause 节点 emit 的 resumed
# 文案严格对齐(见 graph.py pause_material/outline/draft/section_help/review)。从 checkpoint
# 重建 await_user/干预卡时据此分类——await_user 与 completedInterventions 都源自同一批 resumed
# 事件,永远 1:1 对齐(不再依赖前端可能滞后的已存 interventions,根治错位 off-by-one)。
def _classify_resumed(message: str) -> tuple[str | None, str | None]:
m = message or ""
if "确认素材" in m:
return "material_confirm", "confirm"
if "匹配技能检索" in m or "重新检索" in m or "重新搜索" in m:
return "material_confirm", "re_search"
if "确认大纲" in m:
return "outline_confirm", "confirm"
if "重新规划大纲" in m:
return "outline_confirm", "re_outline"
if "提交编辑审核" in m:
return "draft_confirm", "to_editor"
if "强制定稿" in m:
return "review_confirm", "force_finalize"
if "确认定稿" in m:
return "draft_confirm", "finalize"
if "按用户意见重写" in m:
return "draft_confirm", "user_revise"
if "按编辑意见重写" in m:
return "review_confirm", "accept_review"
if "补充检索素材" in m:
return "section_help", "supplement"
if "继续写作" in m:
return "section_help", "loose"
return None, None
# 暂停提示文案(await_user 的 message)——对齐 graph.py 各 interrupt() 的 message,
# 让历史回显里暂停点显示与实时一致的提示语。
_PAUSE_MESSAGES = {
"material_confirm": "素材收集专家已完成素材搜集,请确认素材后继续。",
"outline_confirm": "作家已完成大纲规划,请确认或编辑大纲后继续。",
"draft_confirm": "草稿已完成,请确认或给出修改意见。",
"section_help": "部分章节缺少足够素材支撑写作,请决定如何处理。",
"review_confirm": "编辑认为文章需要修改,请决定下一步操作。",
}
def _build_transcript_from_checkpoint(values: dict) -> tuple[list[dict], list[dict]]:
"""从最终 checkpoint 重建 (progressEvents, completedInterventions),二者自洽 1:1 对齐。
每条 ``resumed`` 事件 = 一个已处理的暂停点:在其前插一条 ``await_user``(带暂停提示
文案),并据 resumed 文案分类出 pausePoint+action 生成**带富数据**的干预卡(素材列表/
大纲/草稿标题字数/审核结果取自最终 state;snake_case 字段由前端归一化)。素材确认前
再插一条 ``materials_ready``。其余里程碑(intent_ready/step_*/outline_ready/draft_ready/
review_ready/notice/error)节点本就 append 进 progress_events,原位即正确顺序。
时间线靠 ``await_user`` 锚点按序取 completedInterventions 渲染干预卡——这里两者都源自
同一批 resumed,数量与顺序天然一致,根治了「卡片错位 / 只显示标题没数据」的 bug。
"""
raw_events = list(values.get("progress_events") or [])
pkg = values.get("material_package") or {}
outline = values.get("current_outline")
draft = values.get("current_draft") or {}
review = values.get("review_result")
status = values.get("status")
events: list[dict] = []
interventions: list[dict] = []
materials_inserted = not pkg
for ev in raw_events:
if isinstance(ev, dict) and ev.get("type") == "resumed":
pp, action = _classify_resumed(ev.get("message") or "")
if pp == "material_confirm" and not materials_inserted:
events.append({
"type": "materials_ready",
"materials": pkg.get("materials") or [],
"keywords": pkg.get("keywords") or [],
"summary": pkg.get("summary") or "",
})
materials_inserted = True
events.append({"type": "await_user", "pause_point": pp, "message": _PAUSE_MESSAGES.get(pp, "")})
rec: dict = {"pausePoint": pp, "action": action}
if pp == "material_confirm":
rec["materials"] = pkg.get("materials") or []
elif pp == "outline_confirm":
if outline:
rec["outline"] = outline
elif pp == "draft_confirm":
rec["draftTitle"] = draft.get("title") or ""
rec["draftWordCount"] = int(draft.get("word_count") or 0)
if review:
rec["reviewResult"] = review
elif pp == "review_confirm":
if review:
rec["reviewResult"] = review
interventions.append(rec)
events.append(ev)
if not materials_inserted:
# 没有 material_confirm 暂停(罕见)但有素材包 → 插到第一条 outline_ready 前,否则附末尾。
mr = {"type": "materials_ready", "materials": pkg.get("materials") or [],
"keywords": pkg.get("keywords") or [], "summary": pkg.get("summary") or ""}
idx = next((i for i, e in enumerate(events) if isinstance(e, dict) and e.get("type") == "outline_ready"), None)
events.insert(idx, mr) if idx is not None else events.append(mr)
if status == "done" and not any(isinstance(e, dict) and e.get("type") == "done" for e in events):
events.append({"type": "done", "message": "写作完成"})
return events, interventions
def _checkpoint_id(snapshot) -> str | None:
"""取快照的 checkpoint_id,用于判断图状态是否在被(worker)推进。"""
cfg = getattr(snapshot, "config", None) or {}
try:
return (cfg.get("configurable") or {}).get("checkpoint_id")
except AttributeError:
return None
def _auto_resume_payload(pause_point: str | None, interrupt_value: dict, values: dict) -> dict:
"""给定当前 ``interrupt()`` 暂停点 + 图状态,返回「默认放行」的 resume 入参。
设计目标:一路推进到定稿且**不绕回检索 / 不无限重审**。判定逻辑见模块 docstring。
"""
if pause_point == "material_confirm":
# 不传 approvedMaterialIds → 节点默认采用全部素材。
return {"action": "confirm"}
if pause_point == "outline_confirm":
# 不传 editedOutline → 节点采用当前大纲。
return {"action": "confirm"}
if pause_point == "section_help":
# 严格模式缺素材求助:全部章节按「通用知识续写」(loose)处理——既不回检索
# (supplement 会绕回 researcher 再触发暂停),也不删章节,直接续写到底。
blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or []
decisions = []
for b in blocked:
title = (b or {}).get("section_title") or (b or {}).get("sectionTitle") or ""
if title:
decisions.append({"sectionTitle": title, "decision": "loose"})
return {"action": "section_help", "sectionDecisions": decisions}
if pause_point == "draft_confirm":
review = values.get("review_result")
rev = int(values.get("revision_count") or 0)
max_rev = int(values.get("max_revisions") or 3)
if not review:
# 还没经过编辑审核 → 先送审。
return {"action": "to_editor"}
if (review or {}).get("verdict") == "pass":
# 审核通过 → 定稿。
return {"action": "finalize"}
if rev >= max_rev:
# 用完修改次数仍未通过(after_review 强制落到草稿确认)→ 接受现稿定稿。
return {"action": "finalize"}
# 刚按编辑意见改过、review 还是旧的不通过结果 → 再次送审,拿新一轮审核。
return {"action": "to_editor"}
if pause_point == "review_confirm":
# 编辑打回(仍有修改额度)→ 按编辑意见重写,重写完会再次 to_editor 复审。
return {"action": "accept_review"}
# 未知暂停点:保守放行。
return {"action": "confirm"}
def _pending_interrupt(snapshot) -> dict | None:
"""从状态快照里取第一个待处理 ``interrupt()`` 的 value(dict),无则 None。"""
for task in getattr(snapshot, "tasks", None) or ():
for intr in getattr(task, "interrupts", None) or ():
value = getattr(intr, "value", None)
if isinstance(value, dict):
return value
return {"value": value}
return None
class AIWritingJobExecutor:
"""驱动 AI 写作图在后台跑到定稿。按 thread_id 幂等、按用户限流。"""
def __init__(self, app) -> None:
# 闭包持有 app,运行时再读 ``app.state.checkpointer`` / session store——它们在
# lifespan 里创建,运行时读保证拿到最新实例(与 worker 用同一个 checkpointer)。
self._app = app
self._tasks: dict[str, asyncio.Task] = {}
# ── 公开 API ──────────────────────────────────────────────────────────────
def start(self, *, thread_id: str, user_id: str | None) -> bool:
"""启动一个后台驱动任务。同一 thread 已在跑则忽略(幂等)。返回是否新启动。"""
existing = self._tasks.get(thread_id)
if existing is not None and not existing.done():
return False
task = asyncio.create_task(self._run(thread_id, user_id))
self._tasks[thread_id] = task
task.add_done_callback(lambda t, tid=thread_id: self._tasks.pop(tid, None))
return True
def is_running(self, thread_id: str) -> bool:
task = self._tasks.get(thread_id)
return task is not None and not task.done()
async def get_progress(self, thread_id: str) -> dict:
"""读取该写作线程当前所处阶段(从 graph checkpoint 推导),供「查看进度」弹窗。
返回规范化的 ``stage`` key(见 ``_NODE_TO_STAGE`` / ``_PAUSE_TO_STAGE``,与前端
流程图阶段一一对应)+ 可选 ``note`` + 审核进度。无 checkpoint / 读失败 → stage=None。
"""
empty = {"stage": None, "note": None, "revisionCount": 0, "maxRevisions": 3, "reviewVerdict": None}
checkpointer = self._checkpointer()
if checkpointer is None:
return empty
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
graph = make_ai_writing_graph()
graph.checkpointer = checkpointer
try:
snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
except Exception: # noqa: BLE001 — 读不到 checkpoint 不致命
return empty
values = getattr(snapshot, "values", None) or {}
review = values.get("review_result") or {}
out = {
"stage": None,
"note": None,
"revisionCount": int(values.get("revision_count") or 0),
"maxRevisions": int(values.get("max_revisions") or 3),
"reviewVerdict": review.get("verdict"),
}
if not values:
return out # 还没建 checkpoint
if values.get("status") == "done":
out["stage"] = "done"
return out
interrupt_value = _pending_interrupt(snapshot)
if interrupt_value is not None:
pp = interrupt_value.get("pause_point")
out["stage"] = _PAUSE_TO_STAGE.get(pp, pp)
out["note"] = _STAGE_NOTE.get(pp)
return out
next_nodes = list(getattr(snapshot, "next", None) or ())
if next_nodes:
node = next_nodes[0]
out["stage"] = _NODE_TO_STAGE.get(node, node)
return out
# 无 interrupt、无 next → 已结束。
out["stage"] = "done"
return out
def cancel(self, thread_id: str) -> bool:
task = self._tasks.get(thread_id)
if task is None or task.done():
return False
task.cancel()
return True
async def await_job(self, thread_id: str) -> None:
"""等待某后台任务结束(测试用;生产不调用)。"""
task = self._tasks.get(thread_id)
if task is not None:
try:
await task
except (Exception, asyncio.CancelledError): # noqa: BLE001
pass
def reconcile(self, *, sessions: list[dict]) -> int:
"""启动时把仍标记为 ``background`` 的会话重新拉起驱动(进程重启续跑)。
``sessions`` 为 ``[{id, user_id}, ...]``。返回拉起的任务数。
"""
launched = 0
for s in sessions:
tid = s.get("id")
if not tid:
continue
if self.start(thread_id=tid, user_id=s.get("user_id")):
launched += 1
if launched:
logger.info("[ai-writing-bg] reconcile relaunched %d background session(s)", launched)
return launched
# ── 内部 ────────────────────────────────────────────────────────────────
def _checkpointer(self):
return getattr(self._app.state, "checkpointer", None)
def _store(self):
return getattr(self._app.state, "ai_writing_session_store", None)
async def _set_status(self, thread_id: str, status: str) -> None:
store = self._store()
if store is None:
return
try:
await store.update(thread_id, user_id=AUTO, status=status)
except Exception: # noqa: BLE001 — 落库失败不致命
logger.warning("[ai-writing-bg] set status=%s for %s failed", status, thread_id, exc_info=True)
async def _run(self, thread_id: str, user_id: str | None) -> None:
token = set_current_user(_JobUser(user_id))
try:
checkpointer = self._checkpointer()
if checkpointer is None:
logger.warning("[ai-writing-bg] no checkpointer; cannot drive %s", thread_id)
return
await self._set_status(thread_id, STATUS_BACKGROUND)
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
graph = make_ai_writing_graph()
# 与运行时 worker 同款:把共享 checkpointer 挂到图上,续上同一线程的 checkpoint。
graph.checkpointer = checkpointer
config = {"configurable": {"thread_id": thread_id}, "recursion_limit": 100}
# 按用户限流:同用户最多 N 篇后台写作同时真跑(见 _slots_per_user)。
async with _user_semaphore(user_id):
done, _interventions = await self._drive(graph, config)
# 把后台跑出的对话时间线写进 transcript(与前端直接写作的存取一致)。
await self._save_transcript(thread_id, graph, config)
if not done:
# 图已停在非 done 终态(无 interrupt 也无 next):兜底标 done,避免会话
# 永久卡在「挂起中」。正常路径下 finalize 已由节点 _mark_session_done 写 done。
await self._mark_terminal(thread_id, graph, config)
except asyncio.CancelledError:
logger.info("[ai-writing-bg] job %s cancelled", thread_id)
raise
except Exception: # noqa: BLE001 — 顶层兜底
logger.exception("[ai-writing-bg] job %s crashed", thread_id)
await self._set_status(thread_id, "error")
finally:
reset_current_user(token)
async def _wait_for_actionable(self, graph, config) -> tuple[object, str]:
"""等到图处于可安全操作的状态,返回 ``(snapshot, action)``。
action ∈ {``interrupt``(有干预点待 resume), ``done``(已结束),
``push``(停在节点间且无 worker 在推进,可自驱)}。
见 ``_INFLIGHT_*`` 注释:避免与运行时 worker 并发跑同一线程。
"""
last_ckpt: object = object()
stable = 0
for _ in range(_INFLIGHT_MAX_WAIT_POLLS):
snapshot = await graph.aget_state(config)
if (getattr(snapshot, "values", None) or {}).get("status") == "done":
return snapshot, "done"
if _pending_interrupt(snapshot) is not None:
return snapshot, "interrupt"
if not getattr(snapshot, "next", None):
return snapshot, "done" # 无中断、无待执行 → 图已结束
ckpt = _checkpoint_id(snapshot)
if ckpt != last_ckpt:
# checkpoint 在变 → 有 worker 正推进该节点,继续等它到下一个干预点。
last_ckpt = ckpt
stable = 0
else:
stable += 1
if stable >= _INFLIGHT_MAX_STABLE:
return snapshot, "push" # 连续不动 → 无 worker,自己推进
await asyncio.sleep(_INFLIGHT_POLL_SECONDS)
# 等待超时(极长节点)→ 保守按可推进处理。
return await graph.aget_state(config), "push"
async def _drive(self, graph, config) -> tuple[bool, list[dict]]:
"""推进图直到定稿(status==done)或无可推进。
返回 ``(是否走到 done, 本次自动处理的 interventions)``。每个干预点记录
``{pausePoint, action}``,供重建 transcript 时与 ``await_user`` 对齐。
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
interventions: list[dict] = []
for _ in range(_MAX_DRIVE_STEPS):
snapshot, action = await self._wait_for_actionable(graph, config)
values = getattr(snapshot, "values", None) or {}
if action == "done":
return values.get("status") == "done", interventions
if action == "interrupt":
interrupt_value = _pending_interrupt(snapshot) or {}
pause_point = interrupt_value.get("pause_point")
payload = _auto_resume_payload(pause_point, interrupt_value, values)
interventions.append({"pausePoint": pause_point, "action": payload.get("action")})
logger.info("[ai-writing-bg] auto-resume thread=%s pause=%s action=%s",
thread_id, pause_point, payload.get("action"))
await graph.ainvoke(Command(resume=payload), config)
continue
# action == "push":停在节点间且无 worker 在跑 → 继续推进。
await graph.ainvoke(None, config)
logger.warning("[ai-writing-bg] drive hit max steps for thread=%s", thread_id)
return False, interventions
async def _save_transcript(self, thread_id: str, graph, config) -> None:
"""把后台跑出的对话时间线写进 ``transcript`` —— 与前端直接写作的存取完全一致。
完全从最终 checkpoint 自洽重建(``_build_transcript_from_checkpoint``):progressEvents
与 completedInterventions 都源自同一批 ``resumed`` 事件,永远 1:1 对齐——不再依赖前端
可能滞后的已存 interventions(那是「卡片错位」bug 的根因)。
"""
store = self._store()
if store is None:
return
try:
snapshot = await graph.aget_state(config)
except Exception: # noqa: BLE001
return
values = getattr(snapshot, "values", None) or {}
events, interventions = _build_transcript_from_checkpoint(values)
transcript = {"progressEvents": events, "completedInterventions": interventions}
try:
await store.update(
thread_id, user_id=AUTO, transcript=transcript, completed_interventions=interventions
)
except Exception: # noqa: BLE001 — transcript 是锦上添花,存失败不影响作业
logger.warning("[ai-writing-bg] save transcript for %s failed", thread_id, exc_info=True)
async def _mark_terminal(self, thread_id: str, graph, config) -> None:
try:
snapshot = await graph.aget_state(config)
status = (getattr(snapshot, "values", None) or {}).get("status")
except Exception: # noqa: BLE001
status = None
# 走到 done 由节点写过了;否则若图已停止(无 next)兜底标 done,停在中途则保留。
if status == "done":
return
try:
snapshot = await graph.aget_state(config)
if not getattr(snapshot, "next", None):
await self._set_status(thread_id, "done")
except Exception: # noqa: BLE001
logger.warning("[ai-writing-bg] mark terminal for %s failed", thread_id, exc_info=True)
@@ -64,6 +64,7 @@ from app.gateway.routers import (
roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
roundtable_task_drafts,
runs,
scheduled_tasks,
sensitive_words,
@@ -1336,6 +1337,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)
@@ -8,6 +8,7 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
@@ -172,6 +173,24 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
_register_ai_writing_default_store(app.state.ai_writing_session_store)
# AI 写作「后台挂起」执行器:把写作图作为后台任务驱动到定稿(离页/刷新/切号不打断)。
# 用 app.state.checkpointer(与运行时 worker 同一份 saver)续上线程 checkpoint。
from app.gateway.ai_writing_job_executor import AIWritingJobExecutor
app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
# 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
if app.state.ai_writing_session_store is not None:
from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
async def _reconcile_ai_writing_background() -> None:
try:
sessions = await app.state.ai_writing_session_store.list_by_status(STATUS_BACKGROUND)
app.state.ai_writing_job_executor.reconcile(sessions=sessions)
except Exception: # noqa: BLE001
logger.warning("[ai-writing-bg] startup reconcile failed", exc_info=True)
asyncio.create_task(_reconcile_ai_writing_background())
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
@@ -223,6 +242,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)
@@ -310,6 +334,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
@@ -470,6 +496,11 @@ def get_ai_writing_session_store(request: Request):
return getattr(request.app.state, "ai_writing_session_store", None)
def get_ai_writing_job_executor(request: Request):
"""Return the AI writing background-suspend executor, or None when unavailable."""
return getattr(request.app.state, "ai_writing_job_executor", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
@@ -168,6 +168,9 @@ class StartParams:
coordinator_name: str = "roundtable-coordinator"
# 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。
draft_id: str | None = None
# 外部任务 id(taskId 深链会商)。非空 = task 作业:draft_id 指向独立的
# roundtable_task_drafts 表,研讨/报告写回 task-draft 存储(不分权)。
task_id: str | None = None
# 是否为「续跑」(resume 澄清)。首启=False → 真网关建全新线程(不复用前端线程,
# 避免与前端本地研讨的活跃 run 撞 409);续跑=True → 复用作业自己的线程。
is_resume: bool = False
@@ -192,13 +195,19 @@ GatewayFactory = Callable[[StartParams], object]
class RoundtableJobExecutor:
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None) -> None:
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None, task_draft_store=None) -> None:
self._store = store
self._gateway_factory = gateway_factory
# 草稿仓储(可选):作业完成后把 step3 写回草稿,供前端「查看报告」。
self._draft_store = draft_store
# task 草稿仓储(独立、不分权):task 作业(params.task_id 非空)写回这里而非个人草稿。
self._task_draft_store = task_draft_store
self._tasks: dict[str, asyncio.Task] = {}
def _draft_store_for(self, params: StartParams):
"""task 作业 → 独立 task-draft 存储;普通作业 → 个人草稿存储。"""
return self._task_draft_store if params.task_id else self._draft_store
def start_job(self, params: StartParams) -> bool:
"""启动一个后台编排任务。同一 job 已在跑则忽略(幂等)。返回是否新启动。"""
if params.job_id in self._tasks and not self._tasks[params.job_id].done():
@@ -238,7 +247,7 @@ class RoundtableJobExecutor:
self._store,
job_id=params.job_id,
agents=params.agents,
on_dialogue=_on_dialogue if (self._draft_store and params.draft_id) else None,
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
)
result = await run_orchestration(
gateway=gateway,
@@ -294,7 +303,8 @@ class RoundtableJobExecutor:
读现有 step2 → 按 run_id=``job-<jobId>`` upsert(保留其它手动 run)→ 设为 activeRun
→ update_draft。前端加载草稿即可像手动跑一样 hydrate 出对话。
"""
if self._draft_store is None or not params.draft_id:
draft_store = self._draft_store_for(params)
if draft_store is None or not params.draft_id:
return
idx_by_agent = {a.agent_id: i for i, a in enumerate(params.agents)}
s2_dialogues = [_to_step2_dialogue(d, idx_by_agent) for d in dialogues]
@@ -303,7 +313,7 @@ class RoundtableJobExecutor:
)
run_id = f"job-{params.job_id}"
try:
draft = await self._draft_store.get_draft(params.draft_id, params.user_id)
draft = await draft_store.get_draft(params.draft_id, params.user_id)
except Exception: # noqa: BLE001
draft = None
step2 = dict((draft or {}).get("step2") or {})
@@ -352,7 +362,7 @@ class RoundtableJobExecutor:
if has_report:
update_kwargs["step3"] = effective_step3
try:
await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
await draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
except Exception: # noqa: BLE001 — 写草稿失败不影响作业本身
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
@@ -233,6 +233,98 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
# 会话不存在或不属于当前用户:同样不抛 500,返回 success=False 即可
return {"success": False, "reason": "session_not_found"}
return {"success": True}
class BackgroundResponse(BaseModel):
# 是否新拉起了后台任务(False = 已在后台跑,幂等返回)
started: bool
# 会话当前状态(background = 挂起中)
status: str
@router.post("/sessions/{session_id}/background", response_model=BackgroundResponse)
async def suspend_to_background(session_id: str, request: Request):
"""把这篇写作转入「后台挂起」:后端自动驱动剩余流程到定稿。
前端在用户点「后台挂起」或离开页面时调用。幂等:同一会话已在后台跑则直接返回。
驱动逻辑见 ``app.gateway.ai_writing_job_executor``(素材/大纲自动确认、缺素材
通用知识续写、草稿送审、审核不过自动重写直到通过或用完次数再定稿)。
"""
from app.gateway.deps import (
get_ai_writing_job_executor,
get_ai_writing_session_store,
get_current_user,
)
executor = get_ai_writing_job_executor(request)
if executor is None:
raise HTTPException(status_code=503, detail="Background executor not available")
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
# 鉴权 + 存在性:会话必须属于当前用户(get 已带 user_id 过滤)。
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
if record.get("status") == "done":
# 已定稿,无需后台驱动。
return BackgroundResponse(started=False, status="done")
started = executor.start(thread_id=session_id, user_id=user_id)
return BackgroundResponse(started=started, status="background")
class ProgressResponse(BaseModel):
# 业务表 status(background = 挂起中 / done / error / in_progress)
sessionStatus: str
# 规范化阶段 key(intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done),
# 从 graph checkpoint 推导;None = 还没建 checkpoint。
stage: Optional[str] = None
# 阶段细化说明(素材不足求助 / 编辑打回)
note: Optional[str] = None
revisionCount: int = 0
maxRevisions: int = 3
reviewVerdict: Optional[str] = None
# 后台驱动任务是否在本进程在跑(重启后可能为 False 但 status 仍 background,由 reconcile 续起)
isRunning: bool = False
@router.get("/sessions/{session_id}/progress", response_model=ProgressResponse)
async def get_session_progress(session_id: str, request: Request):
"""读取一篇写作的实际进度(当前阶段),供「查看进度」流程图弹窗轮询。
阶段从 graph checkpoint 推导,反映后端真实推进位置;鉴权要求会话属于当前用户。
"""
from app.gateway.deps import (
get_ai_writing_job_executor,
get_ai_writing_session_store,
get_current_user,
)
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
executor = get_ai_writing_job_executor(request)
progress = await executor.get_progress(session_id) if executor is not None else {}
return ProgressResponse(
sessionStatus=str(record.get("status") or "in_progress"),
stage=progress.get("stage") or None,
note=progress.get("note"),
revisionCount=int(progress.get("revisionCount") or 0),
maxRevisions=int(progress.get("maxRevisions") or 3),
reviewVerdict=progress.get("reviewVerdict"),
isRunning=bool(executor.is_running(session_id)) if executor is not None else False,
)
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
@@ -711,3 +711,54 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
logger.warning("flow intent classify failed", exc_info=True)
return {"intent": "unclear", "reason": "意图识别失败"}
# ── 入库意图识别(「稍后入库」后用户在输入框的自由文字)──────────────────────────
class IngestIntentRequest(BaseModel):
"""第三步「稍后入库」待命时,识别输入框消息是否要开始入库 / 补充内容后入库 / 其他。"""
message: str = ""
model: str | None = Field(default=None)
_INGEST_INTENTS = {"start_ingest", "add_data", "other", "unclear"}
_INGEST_INTENT_SYS = (
"你是圆桌「方案总结」第三步**入库意图识别器**。流程图已生成、并且用户此前选择了「稍后入库」,"
"现在他在输入框发了一条消息。请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
'{"intent":"<枚举>","reason":"<一句话依据>"}\n'
"intent 只能取其一:\n"
"- start_ingest:现在就把当前方案入库(如「入库吧」「开始入库」「现在入库」「把方案存进知识库」);\n"
"- add_data:想先补充/追加一些内容再入库(如「补一段背景再入库」「加上风险说明后入库」「把这段也一起入库」);\n"
"- other:与入库无关,是改报告/改流程图/提问等其它诉求(如「报告再写细点」「流程图加个验收节点」「为什么选这个方案」);\n"
"- unclear:和入库相关但说不清是现在入库还是要补内容(如只说「入库」却又像没说完、「那个再说」之类模糊表达)。\n"
"示例:\n"
'「现在入库」→ {"intent":"start_ingest","reason":"明确要求立即入库"}\n'
'「我再补一段实施风险,然后入库」→ {"intent":"add_data","reason":"先补内容再入库"}\n'
'「报告执行部分写细一点」→ {"intent":"other","reason":"是改报告,与入库无关"}\n'
'「入库……嗯」→ {"intent":"unclear","reason":"提到入库但表达不完整"}'
)
@router.post("/ingest-intent")
async def classify_ingest_intent(request: Request, payload: IngestIntentRequest = Body(...)) -> dict[str, str]:
"""识别「稍后入库」待命时输入框消息的入库意图(一次模型调用)。失败/解析不出 → unclear,前端发起协助。"""
msg = (payload.message or "").strip()
if not msg:
return {"intent": "unclear", "reason": "空消息"}
try:
model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
res = await model.ainvoke(
[SystemMessage(content=_INGEST_INTENT_SYS), HumanMessage(content="用户消息:" + msg + "\n\n请输出 JSON:")]
)
text = res.content if isinstance(res.content, str) else _as_str(getattr(res, "text", ""))
obj = _parse_intent_json(text) or {}
intent = _as_str(obj.get("intent")).lower()
if intent not in _INGEST_INTENTS:
intent = "unclear"
return {"intent": intent, "reason": _as_str(obj.get("reason"))}
except Exception: # noqa: BLE001 — 分类失败不致命,回退协助让用户自己定
logger.warning("ingest intent classify failed", exc_info=True)
return {"intent": "unclear", "reason": "意图识别失败"}
@@ -4,6 +4,10 @@ A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
roundtable). Bound to the requesting user (not the browser), so it follows the
account across devices/sessions — replacing the old ``localStorage`` storage.
This store is **strictly per-user**. taskId 深链聊天记录 (无界嵌入抽屉) live in a
separate, unpartitioned store — see ``/api/roundtable-task-drafts``
(``roundtable_task_drafts`` table) — so the two never mix.
Recommendation history is bound to a draft: the recommend dialog shows "last
recommendation for this session" on open, while still letting the user
re-analyze (which appends a new history row).
@@ -181,45 +185,6 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
return DraftResponse(**row)
@router.get("/by-task/{task_id}/latest", response_model=DraftResponse)
async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftResponse:
"""按外部任务 id 取最新一条草稿(**不按 user 分权**);无则 404。
无界嵌入抽屉打开时调用:若该任务已有聊天记录,回显最新一条。
"""
store = _get_store(request)
row = await store.get_latest_draft_by_task(task_id)
if row is None:
raise HTTPException(status_code=404, detail="No draft for this task")
return DraftResponse(**row)
@router.get("/by-task/{task_id}", response_model=DraftListResponse)
async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse:
"""按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts`
一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。
"""
store = _get_store(request)
rows = await store.list_drafts_by_task(task_id)
job_store = getattr(request.app.state, "roundtable_job_store", None)
if job_store is not None and rows:
try:
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
except AttributeError:
status_map = {}
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True)
status_map = {}
for r in rows:
info = status_map.get(r.get("id"))
if info:
r["status"] = info.get("status")
r["job_id"] = info.get("jobId")
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
@router.get("/{draft_id}", response_model=DraftResponse)
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
store = _get_store(request)
@@ -55,6 +55,9 @@ class JobAgent(BaseModel):
class StartJobRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
taskId: str | None = Field(default=None, max_length=128)
intent: str = ""
agents: list[JobAgent] = Field(default_factory=list)
model: str | None = None
@@ -191,8 +194,10 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
task_id = (body.taskId or "").strip() or None
if body.draftId:
existing = await store.get_by_draft(body.draftId, user_id=user_id)
# task 作业按 task_id 去重(跨用户共享);个人作业按 user 去重。
existing = await store.get_by_draft(body.draftId, user_id=user_id, task_id=task_id)
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
return _to_response(existing)
@@ -202,6 +207,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
id=job_id,
draft_id=body.draftId,
user_id=user_id,
task_id=task_id,
orchestration_mode=body.orchestrationMode,
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
@@ -223,6 +229,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
task_id=task_id,
chain=body.chain,
orchestration_plan=body.orchestrationPlan,
seat_thinking_enabled=body.thinking_enabled,
@@ -234,26 +241,30 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
@router.get("", response_model=RoundtableJobResponse | None)
async def get_job_by_draft(request: Request, draft_id: str) -> RoundtableJobResponse | None:
async def get_job_by_draft(request: Request, draft_id: str, task_id: str | None = None) -> RoundtableJobResponse | None:
# task_id 非空 = task 作业查询(按 task 共享、不分权);否则按 user 分权。
store = _get_store(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
row = await store.get_by_draft(draft_id, user_id=user_id)
row = await store.get_by_draft(draft_id, user_id=user_id, task_id=tid)
return _to_response(row) if row is not None else None
@router.get("/{job_id}", response_model=RoundtableJobResponse)
async def get_job(request: Request, job_id: str) -> RoundtableJobResponse:
async def get_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
store = _get_store(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
row = await store.get(job_id, user_id=user_id, task_id=tid)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
return _to_response(row)
@router.get("/{job_id}/stream")
async def stream_job(request: Request, job_id: str) -> StreamingResponse:
async def stream_job(request: Request, job_id: str, task_id: str | None = None) -> StreamingResponse:
store = _get_store(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
def _frame(payload: dict[str, Any]) -> str:
@@ -263,7 +274,7 @@ async def stream_job(request: Request, job_id: str) -> StreamingResponse:
for _ in range(_MAX_STREAM_TICKS):
if await request.is_disconnected():
return
row = await store.get(job_id, user_id=user_id)
row = await store.get(job_id, user_id=user_id, task_id=tid)
if row is None:
yield _frame({"event": "error", "detail": "Job not found"})
return
@@ -280,12 +291,15 @@ async def stream_job(request: Request, job_id: str) -> StreamingResponse:
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> RoundtableJobResponse:
async def resume_job(
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
row = await store.get(job_id, user_id=user_id, task_id=tid)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
if (row.get("status") or "") != "awaiting_input":
@@ -295,14 +309,18 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
if not answer:
raise HTTPException(status_code=400, detail="answer is required")
# task 作业不分权:放开写入归属校验(user_id=None);并以**原作业所有者**身份续跑,
# 保证编排上下文 / 写回 task-draft 用一致的 user 上下文。
write_user_id = None if tid else user_id
run_user_id = row.get("user_id") or user_id
updated = await store.update_progress(
job_id, user_id=user_id, status="running", pending_clarification=None
job_id, user_id=write_user_id, status="running", pending_clarification=None
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
user_id=run_user_id,
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
seed_message=_RESUME_PREFIX + answer,
model=None,
@@ -311,6 +329,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
thread_ids=row.get("thread_ids"),
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
draft_id=row.get("draft_id"),
task_id=tid or row.get("task_id"),
is_resume=True,
chain=row.get("chain"),
orchestration_plan=row.get("orchestration_plan"),
@@ -359,15 +378,17 @@ async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupRespon
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
async def cancel_job(request: Request, job_id: str) -> RoundtableJobResponse:
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
row = await store.get(job_id, user_id=user_id, task_id=tid)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
executor.cancel(job_id)
updated = await store.update_progress(job_id, user_id=user_id, status="cancelled")
# task 作业不分权:放开写入归属校验。
updated = await store.update_progress(job_id, user_id=(None if tid else user_id), status="cancelled")
return _to_response(updated or row)
@@ -0,0 +1,187 @@
"""CRUD API for **task-scoped** roundtable-planning drafts (聊天记录).
This is the dedicated store for roundtable sessions opened via a taskId deep-link
(无界嵌入抽屉). It is physically and semantically separate from the per-user
``/api/roundtable-drafts`` store:
* records are keyed **only by ``task_id``** (one task → many records);
* there is **no user 分权** — any authenticated caller may read/write/delete a
task's records (the deep-link audience is shared); ``created_by`` is recorded
for audit only.
Routes (prefix ``/api/roundtable-task-drafts``):
GET "/by-task/{task_id}" list all drafts for a task (lightweight)
GET "/by-task/{task_id}/latest" newest draft for a task (full)
POST "" create (``task_id`` required)
GET "/{draft_id}" full draft (incl. step1/step2/step3)
PUT "/{draft_id}" partial update
DELETE "/{draft_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-task-drafts", tags=["roundtable-task-drafts"])
# ── schemas ────────────────────────────────────────────────────────────────
class TaskDraftMetaResponse(BaseModel):
id: str
task_id: str
title: str = ""
furthest_step: int = 1
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
# Latest background-job status for this draft (history-dropdown loading badge).
status: str | None = None
job_id: str | None = None
class TaskDraftListResponse(BaseModel):
drafts: list[TaskDraftMetaResponse]
class TaskDraftResponse(TaskDraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class TaskDraftCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=64)
task_id: str = Field(max_length=128)
title: str = Field(default="", max_length=512)
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class TaskDraftUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=512)
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_task_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable task draft store not available")
return store
async def _enrich_status(request: Request, rows: list[dict[str, Any]]) -> None:
"""Attach the latest background-job status/jobId per draft (best-effort).
Uses the job store's draft-id keyed lookup (no user filter) — a task's drafts
span users. Failure or a missing job store silently degrades to no status.
"""
job_store = getattr(request.app.state, "roundtable_job_store", None)
if job_store is None or not rows:
return
try:
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
except AttributeError:
status_map = {}
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
logger.warning("roundtable task draft list: job status enrichment failed", exc_info=True)
status_map = {}
for r in rows:
info = status_map.get(r.get("id"))
if info:
r["status"] = info.get("status")
r["job_id"] = info.get("jobId")
# ── routes ───────────────────────────────────────────────────────────────
@router.get("/by-task/{task_id}", response_model=TaskDraftListResponse)
async def list_drafts_by_task(request: Request, task_id: str) -> TaskDraftListResponse:
"""All drafts for a task, newest-first (一个 taskId 多条记录,不分权)。"""
store = _get_store(request)
rows = await store.list_drafts_by_task(task_id)
await _enrich_status(request, rows)
return TaskDraftListResponse(drafts=[TaskDraftMetaResponse(**r) for r in rows])
@router.get("/by-task/{task_id}/latest", response_model=TaskDraftResponse)
async def get_latest_draft_by_task(request: Request, task_id: str) -> TaskDraftResponse:
"""Newest draft for a task; 404 if none. 回显最新一条聊天记录用。"""
store = _get_store(request)
row = await store.get_latest_draft_by_task(task_id)
if row is None:
raise HTTPException(status_code=404, detail="No draft for this task")
return TaskDraftResponse(**row)
@router.post("", response_model=TaskDraftResponse, status_code=201)
async def create_draft(request: Request, body: TaskDraftCreateRequest) -> TaskDraftResponse:
store = _get_store(request)
task_id = (body.task_id or "").strip()
if not task_id:
raise HTTPException(status_code=400, detail="task_id is required")
row = await store.create_draft(
{
"id": (body.id or uuid4().hex),
"task_id": task_id,
"title": body.title.strip(),
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
created_by=_current_user_id(request),
)
return TaskDraftResponse(**row)
@router.get("/{draft_id}", response_model=TaskDraftResponse)
async def get_draft(request: Request, draft_id: str) -> TaskDraftResponse:
store = _get_store(request)
row = await store.get_draft(draft_id)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return TaskDraftResponse(**row)
@router.put("/{draft_id}", response_model=TaskDraftResponse)
async def update_draft(request: Request, draft_id: str, body: TaskDraftUpdateRequest) -> TaskDraftResponse:
store = _get_store(request)
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return TaskDraftResponse(**row)
@router.delete("/{draft_id}", status_code=204)
async def delete_draft(request: Request, draft_id: str) -> None:
store = _get_store(request)
deleted = await store.delete_draft(draft_id)
if not deleted:
raise HTTPException(status_code=404, detail="Draft not found")
@@ -169,7 +169,19 @@ async def pause_outline_confirm(state: AIWritingState) -> dict:
async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
"""PAUSE-3:草稿确认"""
draft = state["current_draft"]
draft = state.get("current_draft")
# 防御:草稿缺失(写作节点异常未产出)时不进入确认暂停,转 error 优雅结束,
# 避免 draft["title"] 在 None 上崩溃。正常路径下 after_writer_draft 已拦截
# status:error,这里是兜底第二道防线(如 editor 异常等边角情形)。
if not draft:
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "草稿缺失", "detail": "草稿尚未生成,无法进入确认环节,请重试。",
"message": "草稿生成失败",
}],
}
user_input: dict = interrupt({
"pause_point": "draft_confirm",
"message": "草稿已完成,请确认或给出修改意见。",
@@ -308,7 +320,15 @@ def after_outline_pause(state: AIWritingState) -> str:
def after_writer_draft(state: AIWritingState) -> str:
"""写作完成后:素材不足则去求助暂停点,否则去草稿确认。"""
"""写作完成后:素材不足则去求助暂停点,否则去草稿确认。
写作节点兜底成 ``status:error``(如模型创建失败)时直接结束本次 run —— 否则会
流到 pause_draft,而此时 ``current_draft`` 为 None,``pause_draft_confirm`` 取
``draft["title"]`` 会二次崩溃('NoneType' object is not subscriptable)。错误事件
已由写作节点 emit,run 优雅结束即可,用户重试不受影响。
"""
if state["status"] == "error":
return "end"
return "pause_section_help" if state["status"] == "awaiting_section_help" else "pause_draft"
@@ -377,7 +397,7 @@ def make_ai_writing_graph(config=None):
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
g.add_conditional_edges("writer_draft", after_writer_draft,
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft"})
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft", "end": END})
g.add_conditional_edges("pause_section_help", after_section_help_pause,
{"researcher": "researcher", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_draft", after_draft_pause,
@@ -126,9 +126,11 @@ def resolve_agent_model_name(
传入,如 researcher 的 keyword_model/rank_model)> ``state.model_name``
(用户写作表单选的主模型)> configurable.model_name。
strict=True(大纲/写作/审核):全部为空抛 ValueError(与 get_writing_model
口径一致——不静默回退默认模型);strict=False(researcher 辅助任务):
返回 None,由 create_chat_model 回退 config.yaml 的 models[0]。
全部为空时**不再抛错**(旧实现 strict=True 抛 ValueError 会把「未选模型」直接打成
草稿失败,且后续 pause_draft 还会因 current_draft 为空二次崩溃):统一返回 None,
由 create_chat_model 回退 config.yaml 的 ``models[0]``。``strict`` 仅用于区分日志
级别——大纲/写作/审核阶段缺模型属异常配置,记 warning,调用方据 None 向用户提示
「已用默认模型」。
"""
candidates = (
(getattr(agent_cfg, "model", None) or "") if agent_cfg else "",
@@ -140,7 +142,7 @@ def resolve_agent_model_name(
if isinstance(name, str) and name.strip():
return name.strip()
if strict:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
logger.warning("写作模型未指定(state/configurable/agent.model 均为空),回退默认模型")
return None
@@ -0,0 +1,177 @@
"""像「智能体调用技能」那样用一个技能检索素材。
背景:素材收集专家(ai-writing-researcher)是内置智能体,但此前它「用技能检索」只是
拿技能的 ``description`` 拼一个 query 丢给通用 web/intranet 检索 provider——技能自己
SKILL.md 里写的检索说明 / 策略并没有真正跑起来,和真实智能体「读 SKILL.md → 按说明
调工具检索」的逻辑不一致。
本模块把两者对齐:把技能 **SKILL.md 全文**连同素材收集专家 SOUL 注入 system prompt,
给模型绑定一个 ``search`` 工具,跑一个**有界的工具调用循环**——模型按技能说明自行决定
检索词与轮次(就像智能体读了技能后驱动检索),我们直接**收割工具返回的 SearchResult**
作为素材(无需再从自由文本里抽取,检索 provider 的结果本就是结构化素材)。
仅在 ``config.yaml → ai_writing.researcher_skill_agent`` 打开时启用(默认 false,走旧的
description→检索 直连路径)。弱模型不会工具调用 / 检索失败时,调用方回退旧路径,保证
素材不为空。
"""
from __future__ import annotations
import asyncio
import logging
from langchain_core.runnables import RunnableConfig
from ._agent_runner import (
RESEARCHER_AGENT_ID,
WritingAgentUnavailable,
load_writing_agent,
resolve_agent_model_name,
)
from ._utils import get_ai_writing_config
logger = logging.getLogger(__name__)
# 工具调用循环上限(每轮 = 一次模型调用,可含多个 search 调用)。
_MAX_TURNS = 4
def use_skill_search_agent() -> bool:
"""读 ``config.yaml → ai_writing.researcher_skill_agent`` 开关(默认 false,mtime 热加载)。"""
cfg = get_ai_writing_config()
return bool(cfg.get("researcher_skill_agent", False)) if isinstance(cfg, dict) else False
def _read_skill_body(skill) -> str:
"""读技能 SKILL.md 全文;失败回退 name+description。"""
try:
return skill.skill_file.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
return f"# 技能:{getattr(skill, 'name', '')}\n{getattr(skill, 'description', '') or ''}"
async def run_skill_search_agent(
*,
skill,
intent: str,
keywords: list[str],
user_query: str,
config: RunnableConfig,
state: dict,
model_name_fallback: str | None,
max_results: int,
timeout_s: int,
emit_query=None,
) -> tuple[list, str]:
"""像智能体一样按技能 SKILL.md 检索素材。
Returns ``(results, error_detail)``:results 为收割到的 SearchResult 列表(调用方再
去全局去重/合并);error_detail 非空且 results 为空 = 该技能整体失败(触发兜底)。
模型不支持工具调用 / 始终不发起检索时返回 ``([], "")``,调用方据此回退旧检索路径。
"""
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from ..search import get_search_provider
skill_name = getattr(skill, "name", "") or "技能"
skill_body = _read_skill_body(skill)
try:
agent_cfg, soul = load_writing_agent(RESEARCHER_AGENT_ID)
except WritingAgentUnavailable:
agent_cfg, soul = None, ""
model_name = resolve_agent_model_name(
agent_cfg, state, config, explicit_fallback=model_name_fallback, strict=False
)
from deerflow.models import create_chat_model
try:
model = create_chat_model(name=model_name)
except Exception as e: # noqa: BLE001
return [], f"模型创建失败: {e}"
@tool
def search(query: str) -> str: # noqa: ARG001 — 仅用于给模型生成 schema,真正检索在循环里执行
"""根据 query 检索素材。一次只传一个检索词;可多次调用以覆盖不同角度。"""
return ""
try:
model_tools = model.bind_tools([search])
except Exception as e: # noqa: BLE001 — 模型不支持工具调用
logger.info("skill agent: model lacks tool-calling, fallback. err=%s", e)
return [], ""
provider = get_search_provider(get_ai_writing_config())
system = (
(soul + "\n\n" if soul else "")
+ "你现在挂载了下面这个检索技能,请**严格按它的说明**来检索素材。\n"
"通过调用 search 工具执行检索(一次一个检索词,可多次调用覆盖不同角度/来源);\n"
"收集到足够素材后直接回复「检索完成」即可,不要编造任何内容。\n\n"
f"=== 技能:{skill_name} ===\n{skill_body}"
)
task_lines = [f"写作意图:{intent or '(无)'}"]
if user_query:
task_lines.append(f"用户补充:{user_query}")
if keywords:
task_lines.append(f"可参考的检索方向:{', '.join(keywords[:8])}")
task_lines.append(f"请按上面技能的说明检索相关素材(目标约 {max_results} 条)。")
messages: list = [SystemMessage(content=system), HumanMessage(content="\n".join(task_lines))]
results: list = []
seen_urls: set[str] = set()
last_error = ""
for _turn in range(_MAX_TURNS):
try:
ai = await model_tools.ainvoke(messages)
except Exception as e: # noqa: BLE001
last_error = f"模型调用失败: {e}"
logger.warning("skill agent[%s] model invoke failed: %s", skill_name, e)
break
messages.append(ai)
tool_calls = getattr(ai, "tool_calls", None) or []
if not tool_calls:
break # 模型不再检索(回复「检索完成」或纯文本)
for tc in tool_calls:
tc_id = tc.get("id") or ""
query = ((tc.get("args") or {}).get("query") or "").strip()
if not query:
messages.append(ToolMessage(content="query 为空,已跳过", tool_call_id=tc_id))
continue
if emit_query:
emit_query(query)
try:
found = await asyncio.wait_for(
provider.search(query, max_results=max_results), timeout=timeout_s
)
except asyncio.TimeoutError:
last_error = f"检索超时(>{timeout_s}s)"
messages.append(ToolMessage(content=last_error, tool_call_id=tc_id))
continue
except Exception as e: # noqa: BLE001
last_error = str(e)
messages.append(ToolMessage(content=f"检索失败: {e}", tool_call_id=tc_id))
continue
added = 0
for r in found or []:
url = getattr(r, "url", None)
if url and url in seen_urls:
continue
if url:
seen_urls.add(url)
results.append(r)
added += 1
# 给模型一个简短回执,让它据此决定是否继续换角度检索。
sample = ";".join((getattr(r, "title", "") or "")[:40] for r in (found or [])[:5]) or "无结果"
messages.append(
ToolMessage(content=f"检索到 {added} 条新素材。标题样例:{sample}", tool_call_id=tc_id)
)
if len(results) >= max_results:
break
if not results and last_error:
return [], last_error
return results, ""
@@ -74,24 +74,30 @@ def get_model(config: RunnableConfig, model_name: str | None = None):
return create_chat_model(name=name)
def get_writing_model(config: RunnableConfig, state: dict):
"""大纲 / 写作 / 审核专用:严格使用用户在表单选定的模型,**不回退默认**。
def resolve_writing_model_name(config: RunnableConfig, state: dict) -> tuple[str | None, bool]:
"""解析大纲 / 写作 / 审核阶段使用的模型名,返回 ``(name, used_default)``。
用户模型在 intent_parser(首次 invocation,configurable.model_name 可用)阶段
捕获进 ``state['model_name']``,经 checkpoint 持久化。这三个节点都在
「确认素材 / 大纲」resume 之后才运行,而 resume 是新 invocation、其 config 不再
带 model_name —— 所以必须从 state 读,不能从 configurable 读(否则回退到 config
列表第一个模型,常是推理模型,既影响质量也会让 reasoning_content 走不出流式)。
优先级:``state['model_name']``(用户在表单选定,由 intent_parser 在首次
invocation 捕获并经 checkpoint 持久化)> ``configurable.model_name``(同一
invocation 内直跑、或前端在 resume 时一并回传的兜底)。
缺失视为配置错误直接抛 ValueError —— 由各节点的 try/except 兜底成 status:error,
而不是静默回退默认模型。
interrupt/resume 是新 invocation,其 config 默认不带原 model_name,所以正常情况
要从 state 读。两者皆空(历史会话 / 首轮捕获就为空 / 前端没传)时不再视为致命
错误,而是返回 ``(None, True)``:调用方据此回退 config.yaml 的 ``models[0]`` 并向
用户提示「未选模型,已用默认模型」,保证写作流程不被一个空模型名硬卡住。
"""
name = ((state or {}).get("model_name") or "").strip()
if not name:
# 兜底再看一眼 configurable(首次同 invocation 内直跑到这里的极端情况)
name = (config.get("configurable", {}).get("model_name", "") or "").strip()
if not name:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
if name:
return name, False
logger.warning("写作模型未指定(state/configurable 均为空),回退默认模型")
return None, True
def get_writing_model(config: RunnableConfig, state: dict):
"""大纲 / 写作 / 审核:解析用户选定模型,缺失时回退默认模型(见 resolve_writing_model_name)。"""
name, _ = resolve_writing_model_name(config, state)
from deerflow.models import create_chat_model
return create_chat_model(name=name)
@@ -40,6 +40,7 @@ from ._agent_runner import (
run_writing_agent,
use_builtin_agent,
)
from ._skill_agent import run_skill_search_agent, use_skill_search_agent
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -83,10 +84,21 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
# 单个技能的检索超时(秒)。技能检索是顺序循环,provider 偶发限速/挂起会把整段
# "技能检索过程"拖住;超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
# 默认 120s(≥2 分钟):内网检索 provider 本身就允许 timeout=120,之前 25s 会过早放弃
# 慢但有效的检索。可用环境变量 AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS 覆盖。
def _env_int(name: str, default: int) -> int:
raw = (os.getenv(name) or "").strip()
if raw:
try:
return max(1, int(raw))
except ValueError:
pass
return default
_SKILL_SEARCH_TIMEOUT_SECONDS = _env_int("AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS", 120)
# 内置「知识库检索」技能名:素材收集专家 agent 的默认技能。researcher 对它
# 特殊处理——不用 description 拼 query,而是对 Step 1 生成的全部检索词做
@@ -607,8 +619,34 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
error_detail = ";".join(dict.fromkeys(errs))[:200]
return added, error_detail
else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。
# ── 智能体式技能调用(config ai_writing.researcher_skill_agent 打开时)──
# 把技能 SKILL.md 注入 + 绑定 search 工具,让模型按技能说明驱动检索,
# 收割工具返回的 SearchResult 作为素材(与「智能体调用技能」逻辑一致)。
if use_skill_search_agent():
def _emit_q(q: str, _name=sk.name) -> None:
_emit_skill_step({
"type": "invoke_skill",
"message": f"素材收集专家用技能「{_name}」检索:{q[:60]}",
"skill_name": _name,
})
agent_results, agent_err = await run_skill_search_agent(
skill=sk,
intent=intent,
keywords=keywords,
user_query=user_query if is_user_query_search else "",
config=config,
state=state,
model_name_fallback=keyword_model,
max_results=per_skill,
timeout_s=_SKILL_SEARCH_TIMEOUT_SECONDS,
emit_query=_emit_q,
)
if agent_results:
return _collect(agent_results), ""
# 弱模型不会工具调用 / 智能体检索失败 → 回退直连检索,保证素材不空。
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
else:
@@ -21,7 +21,7 @@ from ._agent_runner import (
stream_writing_agent,
use_builtin_agent,
)
from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -209,11 +209,18 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
use_agent = use_builtin_agent("writer")
agent_label = "作家"
model = None
# 未选模型回退默认时给用户一条非致命提示(最终随草稿一并返回)
model_default_notice: dict | None = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
# 模型解析提前到循环外:返回 None 表示 state/configurable/agent.model 全空,
# 后续 create_chat_model 会回退默认模型,这里据此准备一条用户提示。
if resolve_agent_model_name(agent_cfg, state, config) is None:
model_default_notice = {
"type": "notice", "agent_name": agent_display_name(WRITER_AGENT_ID, "作家"),
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
agent_label = agent_display_name(WRITER_AGENT_ID, "作家")
except WritingAgentUnavailable as e:
logger.warning("writer builtin agent unavailable, falling back to legacy prompts: %s", e)
@@ -231,7 +238,14 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
model_name, used_default = resolve_writing_model_name(config, state)
if used_default:
model_default_notice = {
"type": "notice", "agent_name": "作家",
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
@@ -449,6 +463,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 分节写作出现模型异常时,以非致命 notice 提示用户,草稿仍照常产出
notices: list[dict] = []
if model_default_notice:
notices.append(model_default_notice)
if section_errors:
notices.append({
"type": "notice",
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""使用 DuckDuckGo 进行互联网搜索,无需 API Key。"""
def __init__(self, region: str = "cn-zh", max_timeout: int = 30):
def __init__(self, region: str = "cn-zh", max_timeout: int = 120):
self.region = region
self.max_timeout = max_timeout
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户
@@ -207,6 +207,21 @@ class AIWritingSessionRepository:
await session.commit()
return True
async def list_by_status(self, status: str, *, limit: int = 500) -> list[dict]:
"""列出指定 status 的会话(仅 id + user_id,用于后台驱动重启续跑 reconcile)。
不按 user_id 过滤——进程级 reconcile 要把所有用户「挂起中」的会话都拉起。
"""
stmt = (
select(AIWritingSessionRow.id, AIWritingSessionRow.user_id)
.where(AIWritingSessionRow.status == status)
.order_by(AIWritingSessionRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [{"id": row[0], "user_id": row[1]} for row in result.all()]
async def list_older_than(
self,
cutoff: datetime,
@@ -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")
@@ -55,6 +55,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 (
@@ -109,6 +110,7 @@ __all__ = [
"RoundtableDraftShareRow",
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RoundtableTaskDraftRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -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()
@@ -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 /
@@ -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)
@@ -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)
@@ -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),
)
@@ -0,0 +1,166 @@
"""SQLAlchemy-backed **task-scoped** roundtable-draft store.
Mirrors :mod:`roundtable_drafts` but every read/write/delete is keyed **only by
``task_id`` / row id — never by ``user_id``** (task records are shared, not
partitioned by user). ``created_by`` is stored for audit but never filtered on.
``get_draft`` / ``update_draft`` keep a ``user_id`` positional purely for
signature-compatibility with :class:`RoundtableDraftRepository` (so the job
executor can call either store uniformly); the value is ignored here.
JSON columns (``step1`` / ``step2`` / ``step3``) are stored as serialized strings
in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableTaskDraftRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_meta(row: RoundtableTaskDraftRow) -> dict[str, Any]:
"""Lightweight projection for the history dropdown (no big blobs)."""
return {
"id": row.id,
"task_id": row.task_id,
"title": row.title,
"furthest_step": row.furthest_step,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
@classmethod
def _to_dict(cls, row: RoundtableTaskDraftRow) -> dict[str, Any]:
d = cls._to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
d["step3"] = _loads(row.step3)
return d
# ── reads (all keyed by task_id / id only) ───────────────────────────────
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
"""All drafts for a task (lightweight meta, newest first). 不按 user 分权。"""
stmt = (
select(RoundtableTaskDraftRow)
.where(RoundtableTaskDraftRow.task_id == task_id)
.order_by(RoundtableTaskDraftRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_meta(row) for row in result.scalars()]
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
"""Newest draft for a task (full), or None."""
stmt = (
select(RoundtableTaskDraftRow)
.where(RoundtableTaskDraftRow.task_id == task_id)
.order_by(RoundtableTaskDraftRow.updated_at.desc())
.limit(1)
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
return self._to_dict(row) if row is not None else None
async def get_draft(self, draft_id: str, user_id: str | None = None) -> dict[str, Any] | None:
"""Full draft by id. ``user_id`` is ignored (signature-compat only)."""
async with self._sf() as session:
row = await session.get(RoundtableTaskDraftRow, draft_id)
return self._to_dict(row) if row is not None else None
# ── writes ───────────────────────────────────────────────────────────────
async def create_draft(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
task_id = (data.get("task_id") or "").strip()
if not task_id:
raise ValueError("task_id is required for a task draft")
now = datetime.now(UTC)
row = RoundtableTaskDraftRow(
id=data["id"],
task_id=task_id,
created_by=(created_by or None),
title=(data.get("title") or "").strip(),
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
step3=_dumps(data.get("step3")),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def update_draft(
self,
draft_id: str,
user_id: str | None = None,
*,
title=_UNSET,
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
) -> dict[str, Any] | None:
"""Partial update by id. ``user_id`` is ignored (signature-compat only)."""
async with self._sf() as session:
row = await session.get(RoundtableTaskDraftRow, draft_id)
if row is None:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
row.furthest_step = int(furthest_step)
if step1 is not _UNSET:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_draft(self, draft_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableTaskDraftRow, draft_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,127 @@
"""一次性迁移:把已有的 taskId 会商记录从个人草稿表搬进独立的 task 存储。
背景
====
早期 taskId 深链会商记录混存在按用户分权的 ``roundtable_drafts`` 表里(带一个
``task_id`` 列 + 放开归属校验)。现在 taskId 聊天记录有了独立、不分权的存储
``roundtable_task_drafts``(只按 ``task_id`` 区分,跨用户共享)。本脚本把历史数据搬过去:
1. ``roundtable_drafts`` 中 ``task_id`` 非空的每一行 → 在 ``roundtable_task_drafts``
建同 id 的记录(``created_by`` = 原 ``user_id``,审计用);
2. 这些草稿对应的 ``roundtable_jobs`` 行(``draft_id`` 匹配且 ``task_id`` 为空)补上
``task_id``,使其转为「task 作业」(按 task 共享);
3. 从 ``roundtable_drafts`` 删除已迁走的行,使个人历史不再混入 task 记录。
幂等:目标表已存在同 id 记录则跳过;可重复运行。alembic 自动升级在运行时是关闭的
(见 ``persistence/engine.py``),所以这步数据搬迁用本脚本手动执行。
用法(在 backend 目录)
======================
::
# 预览(不写盘)
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py --dry-run
# 执行
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("migrate_roundtable_task_drafts")
async def _run(dry_run: bool) -> int:
from sqlalchemy import select, update
from deerflow.config import get_app_config
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.roundtable_drafts.model import RoundtableDraftRow
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
config = get_app_config()
await init_engine_from_config(config.database)
sf = get_session_factory()
moved = 0
skipped = 0
jobs_tagged = 0
try:
async with sf() as session:
rows = (
await session.execute(
select(RoundtableDraftRow).where(RoundtableDraftRow.task_id.is_not(None))
)
).scalars().all()
logger.info("发现 %d 条带 task_id 的个人草稿待迁移。", len(rows))
for row in rows:
# 幂等:目标已存在同 id → 跳过(仍尝试给作业补 task_id + 删源行)。
exists = await session.get(RoundtableTaskDraftRow, row.id)
if exists is None:
if not dry_run:
session.add(
RoundtableTaskDraftRow(
id=row.id,
task_id=row.task_id,
created_by=row.user_id,
title=row.title or "",
furthest_step=row.furthest_step or 1,
step1=row.step1,
step2=row.step2,
step3=row.step3,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
moved += 1
logger.info(" 迁移 draft %s (task_id=%s)", row.id, row.task_id)
else:
skipped += 1
logger.info(" 跳过 draft %s (目标已存在)", row.id)
# 关联后台作业补 task_id(未标记的才动)。
if not dry_run:
res = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.draft_id == row.id)
.where(RoundtableJobRow.task_id.is_(None))
.values(task_id=row.task_id)
)
jobs_tagged += int(res.rowcount or 0)
# 删除源行(task 记录不再留在个人表)。
if not dry_run:
await session.delete(row)
if not dry_run:
await session.commit()
logger.info(
"完成%s:迁移 %d 条,跳过 %d 条,关联作业打标 %d 条。",
"(dry-run,未写盘)" if dry_run else "",
moved,
skipped,
jobs_tagged,
)
return 0
finally:
await close_engine()
def main() -> int:
parser = argparse.ArgumentParser(description="迁移 taskId 会商记录到独立 task 存储")
parser.add_argument("--dry-run", action="store_true", help="只预览,不写盘")
args = parser.parse_args()
return asyncio.run(_run(args.dry_run))
if __name__ == "__main__":
sys.exit(main())
@@ -105,6 +105,13 @@ CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARC
-- (knowledge_folders / knowledge_extract_templates 为【新表】,由 create_all 自动建,无需在此补列。)
CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFAULT NULL');
-- ---- taskId 深链会商独立存储,对应 migration 20260618_01 ----------------------
-- roundtable_task_drafts 是【新表】(taskId 聊天记录,只按 task_id 不分权),由 create_all
-- 自动建,无需补列。以下给【已存在的】roundtable_jobs 补 task_id 列:
-- task_id: 非空 = task 作业(按 task 共享、不按 user 分权,报告写回 roundtable_task_drafts)。
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing;
@@ -136,11 +143,26 @@ PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- roundtable_jobs.task_id 索引(幂等) -------------------------------------
SET @idx_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'roundtable_jobs'
AND index_name = 'ix_roundtable_jobs_task_id'
);
SET @idx_ddl = IF(@idx_exists = 0,
'CREATE INDEX `ix_roundtable_jobs_task_id` ON `roundtable_jobs` (`task_id`)',
'SELECT ''SKIPPED ix_roundtable_jobs_task_id (already exists)'' AS result');
PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id'))
ORDER BY table_name, column_name;
@@ -0,0 +1,43 @@
---
name: knowledge-base-ingest
description: 把会商/方案的最终产物(总结报告 Markdown + 结构化流程图)入库到知识库。当用户在第三步生成流程图后表示「现在入库 / 开始入库 / 把方案入库 / 补充内容后入库」时使用本 Skill 触发入库。
allowed-tools:
- read_file
---
# 知识库入库 Skill(示例 · 演示用)
> ⚠️ **这是一个示例(演示)技能,仅用于打通第三步「生成流程图 → 入库」的交互流程。**
> 真正的入库技能(对接实际知识库写入接口)后续会替换本技能;届时只需保持
> `name: knowledge-base-ingest` 不变,前端的入库询问 / 意图识别 / 手动入库按钮即可
> 无缝切到真实实现。
## 用途
在多智能体会商的第三步,方案总结报告与结构化流程图生成完成后,把这份成果作为一条
记录沉淀进知识库,便于后续检索与复用。
## 何时触发
- 流程图生成完成后,系统会询问用户「现在入库 / 稍后入库 / 不入库」;
- 用户选择「现在入库」、点击「入库」按钮,或在输入框中表达「开始入库 / 把方案入库 /
补充 xxx 内容后再入库」时触发。
## 适用范围
仅**深链(taskId)模式**(会商 `rwfx-A` / `3qfx-A` / `xdfx-A` 等)才提供入库;普通会商
不出现入库询问/按钮。
## 入库内容
- **taskId**:深链外部任务 id —— **技能必需信息**,入库时一并传入;
- 标题:本次会商的任务目标(intent objective);
- 正文:方案总结报告(Markdown)+ 结构化流程图数据(**以用户最后修改后的流程图为准**);
- 可选:用户在「补充内容后再入库」时追加的说明。
## 调用过程(演示)
本示例技能不写真实知识库,仅在消息列表中以「调度入库技能 → 调用入库技能 → 提交方案
数据 → 入库完成」的步骤条演示调用过程。真实技能将把上述内容提交到知识库写入接口
(如 `POST /api/knowledge-ingest/publish`,按智能体 `config.yaml` 的 `template_name`
解析入库模板)。