Merge branch 'dhk-1' into 'master'
feat(roundtable): taskId 会商记录独立存储(不分权) + 第三步生成过程对话/步骤条入库 See merge request knowledge-base-bj/deerflow-server!135
This commit is contained in:
commit
758a8fda09
75 files changed
+4046
-492
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-88
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
+177
@@ -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, ""
|
||||
+18
-12
@@ -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)
|
||||
|
||||
|
||||
+44
-6
@@ -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:
|
||||
|
||||
+20
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 节点取出并提示用户
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"""Separate, unpartitioned storage for taskId-deeplink roundtable 聊天记录.
|
||||
|
||||
Two changes:
|
||||
|
||||
1. New table ``roundtable_task_drafts`` — the dedicated store for roundtable
|
||||
sessions opened via a taskId deep-link (无界嵌入抽屉). Keyed **only by
|
||||
``task_id``** (one task → many records), never partitioned by user
|
||||
(``created_by`` is audit-only). Physically separate from ``roundtable_drafts``
|
||||
so personal history and task records never mix. (On a brand-new DB the table
|
||||
is created by ``create_all``; this revision patches existing DBs.)
|
||||
|
||||
2. ``roundtable_jobs.task_id`` column — marks a background job as task-scoped so
|
||||
its progress/dialogues are shared by task (read 不分权) and its final report is
|
||||
written back to the ``roundtable_task_drafts`` store.
|
||||
|
||||
The matching data move (existing ``roundtable_drafts`` rows with a ``task_id`` →
|
||||
``roundtable_task_drafts``) is a one-off, run via
|
||||
``scripts/migrate_roundtable_task_drafts.py`` (alembic auto-upgrade is disabled at
|
||||
runtime — see ``persistence/engine.py``).
|
||||
|
||||
Revision ID: 20260618_01
|
||||
Revises: 20260617_01
|
||||
Create Date: 2026-06-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260618_01"
|
||||
down_revision = "20260617_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("roundtable_task_drafts"):
|
||||
op.create_table(
|
||||
"roundtable_task_drafts",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("task_id", sa.String(128), nullable=False),
|
||||
sa.Column("created_by", sa.String(64), nullable=True),
|
||||
sa.Column("title", sa.String(512), nullable=False, server_default=""),
|
||||
sa.Column("furthest_step", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("step1", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("step2", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("step3", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_roundtable_task_drafts_task_id", "roundtable_task_drafts", ["task_id"])
|
||||
|
||||
if _has_table("roundtable_jobs") and not _has_column("roundtable_jobs", "task_id"):
|
||||
op.add_column("roundtable_jobs", sa.Column("task_id", sa.String(128), nullable=True))
|
||||
op.create_index("ix_roundtable_jobs_task_id", "roundtable_jobs", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_jobs", "task_id"):
|
||||
op.drop_index("ix_roundtable_jobs_task_id", table_name="roundtable_jobs")
|
||||
op.drop_column("roundtable_jobs", "task_id")
|
||||
if _has_table("roundtable_task_drafts"):
|
||||
op.drop_index("ix_roundtable_task_drafts_task_id", table_name="roundtable_task_drafts")
|
||||
op.drop_table("roundtable_task_drafts")
|
||||
+2
@@ -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",
|
||||
|
||||
+7
-34
@@ -82,39 +82,12 @@ class RoundtableDraftRepository:
|
||||
async def get_draft(self, draft_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权);非 task 草稿仍按 user 隔离。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离。taskId 深链聊天记录已迁出到独立的
|
||||
# ``roundtable_task_drafts`` 表(不分权),不再走这里。
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._draft_to_dict(row)
|
||||
|
||||
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""按外部任务 id 取最新一条草稿(**忽略 user_id**,不分权)。无则 None。"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._draft_to_dict(row) if row is not None else None
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
|
||||
"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._draft_to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableDraftRow(
|
||||
@@ -149,8 +122,8 @@ class RoundtableDraftRepository:
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离(task 聊天记录已迁出到独立存储)。
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if task_id is not _UNSET and task_id is not None:
|
||||
row.task_id = str(task_id)
|
||||
@@ -172,8 +145,8 @@ class RoundtableDraftRepository:
|
||||
async def delete_draft(self, draft_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
# 个人草稿严格按 user 隔离(task 聊天记录已迁出到独立存储)。
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
+4
@@ -33,6 +33,10 @@ class RoundtableJobRow(Base):
|
||||
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
|
||||
draft_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# 外部任务 id(taskId 深链会商)。非空 = 这是一条 task 作业:读取按 task_id 共享、
|
||||
# **不按 user 分权**(任何人打开同一 task 都能看在跑的研讨);其 draft_id 指向独立的
|
||||
# ``roundtable_task_drafts`` 表。为空 = 普通个人作业(按 user 分权)。
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
# Job status machine (§2.1): queued / running / awaiting_input / done / error / cancelled.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
|
||||
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
|
||||
|
||||
+23
-4
@@ -67,6 +67,7 @@ class RoundtableJobRepository:
|
||||
id: str,
|
||||
draft_id: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
orchestration_mode: str = "recommend",
|
||||
coordinator_name: str | None = None,
|
||||
thread_ids: dict[str, str] | None = None,
|
||||
@@ -82,6 +83,7 @@ class RoundtableJobRepository:
|
||||
id=id,
|
||||
draft_id=draft_id,
|
||||
user_id=resolved_user_id,
|
||||
task_id=(task_id or None),
|
||||
status=status,
|
||||
phase=phase,
|
||||
cycle=0,
|
||||
@@ -167,7 +169,16 @@ class RoundtableJobRepository:
|
||||
job_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
# task 作业按 task_id 共享、**不按 user 分权**:给定 task_id 时只校验作业属于该
|
||||
# task(忽略 user_id)。否则走原「按 user 分权」逻辑。
|
||||
if task_id is not None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableJobRow, job_id)
|
||||
if row is None or row.task_id != task_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableJobRow, job_id)
|
||||
@@ -182,12 +193,20 @@ class RoundtableJobRepository:
|
||||
draft_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the most-recently-updated job for a draft (or None)."""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
|
||||
"""Return the most-recently-updated job for a draft (or None).
|
||||
|
||||
``task_id`` set → task 作业查询:按 ``draft_id`` + ``task_id`` 过滤,**不按 user
|
||||
分权**(同一 task 的作业跨用户共享)。否则按 user 分权。
|
||||
"""
|
||||
stmt = select(RoundtableJobRow).where(RoundtableJobRow.draft_id == draft_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||
if task_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.task_id == task_id)
|
||||
else:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(1)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Task-scoped roundtable-draft persistence (separate, unpartitioned store)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
from deerflow.persistence.roundtable_task_drafts.sql import RoundtableTaskDraftRepository
|
||||
|
||||
__all__ = [
|
||||
"RoundtableTaskDraftRepository",
|
||||
"RoundtableTaskDraftRow",
|
||||
"make_roundtable_task_draft_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_task_draft_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return RoundtableTaskDraftRepository(session_factory)
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""ORM model for **task-scoped** roundtable-planning drafts.
|
||||
|
||||
This is the dedicated, separate store for roundtable 聊天记录 opened through a
|
||||
taskId deep-link (无界嵌入抽屉). Unlike :mod:`roundtable_drafts` (per-login-user
|
||||
isolation), task drafts are keyed **purely by ``task_id``** and are **never
|
||||
partitioned by user** — one ``task_id`` may have many records, all shared across
|
||||
whoever opens that task. ``created_by`` is audit-only (never used for filtering).
|
||||
|
||||
Kept physically separate from ``roundtable_drafts`` so the two never mix: a
|
||||
user's personal history (``GET /api/roundtable-drafts``) holds only their own
|
||||
drafts, and task records live entirely here.
|
||||
|
||||
``step1`` / ``step2`` / ``step3`` are JSON serialized into ``PortableLongText``
|
||||
(MySQL ``LONGTEXT``) — the Step 2 transcript / Step 3 report HTML easily exceed
|
||||
MySQL's 64 KB ``TEXT`` cap — mirroring ``roundtable_drafts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class RoundtableTaskDraftRow(Base):
|
||||
__tablename__ = "roundtable_task_drafts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# 外部任务 id(无界嵌入抽屉传入)。这是唯一的归属维度:所有读/写/删只按它过滤,
|
||||
# 一个 task_id 可有多条记录、跨用户共享。必填、建索引。
|
||||
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
# 仅审计:建档时的登录用户。绝不参与任何过滤/鉴权(task 记录不分权)。
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Auto-derived task summary (objective → first user message → timestamp).
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# Furthest step reached when this snapshot was taken (1 | 2 | 3).
|
||||
furthest_step: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot / Step3 shapes.
|
||||
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"""SQLAlchemy-backed **task-scoped** roundtable-draft store.
|
||||
|
||||
Mirrors :mod:`roundtable_drafts` but every read/write/delete is keyed **only by
|
||||
``task_id`` / row id — never by ``user_id``** (task records are shared, not
|
||||
partitioned by user). ``created_by`` is stored for audit but never filtered on.
|
||||
|
||||
``get_draft`` / ``update_draft`` keep a ``user_id`` positional purely for
|
||||
signature-compatibility with :class:`RoundtableDraftRepository` (so the job
|
||||
executor can call either store uniformly); the value is ignored here.
|
||||
|
||||
JSON columns (``step1`` / ``step2`` / ``step3``) are stored as serialized strings
|
||||
in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class RoundtableTaskDraftRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_meta(row: RoundtableTaskDraftRow) -> dict[str, Any]:
|
||||
"""Lightweight projection for the history dropdown (no big blobs)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"title": row.title,
|
||||
"furthest_step": row.furthest_step,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _to_dict(cls, row: RoundtableTaskDraftRow) -> dict[str, Any]:
|
||||
d = cls._to_meta(row)
|
||||
d["step1"] = _loads(row.step1)
|
||||
d["step2"] = _loads(row.step2)
|
||||
d["step3"] = _loads(row.step3)
|
||||
return d
|
||||
|
||||
# ── reads (all keyed by task_id / id only) ───────────────────────────────
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""All drafts for a task (lightweight meta, newest first). 不按 user 分权。"""
|
||||
stmt = (
|
||||
select(RoundtableTaskDraftRow)
|
||||
.where(RoundtableTaskDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableTaskDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Newest draft for a task (full), or None."""
|
||||
stmt = (
|
||||
select(RoundtableTaskDraftRow)
|
||||
.where(RoundtableTaskDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableTaskDraftRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def get_draft(self, draft_id: str, user_id: str | None = None) -> dict[str, Any] | None:
|
||||
"""Full draft by id. ``user_id`` is ignored (signature-compat only)."""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
# ── writes ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_draft(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||
task_id = (data.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
raise ValueError("task_id is required for a task draft")
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableTaskDraftRow(
|
||||
id=data["id"],
|
||||
task_id=task_id,
|
||||
created_by=(created_by or None),
|
||||
title=(data.get("title") or "").strip(),
|
||||
furthest_step=int(data.get("furthest_step") or 1),
|
||||
step1=_dumps(data.get("step1")),
|
||||
step2=_dumps(data.get("step2")),
|
||||
step3=_dumps(data.get("step3")),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def update_draft(
|
||||
self,
|
||||
draft_id: str,
|
||||
user_id: str | None = None,
|
||||
*,
|
||||
title=_UNSET,
|
||||
furthest_step=_UNSET,
|
||||
step1=_UNSET,
|
||||
step2=_UNSET,
|
||||
step3=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Partial update by id. ``user_id`` is ignored (signature-compat only)."""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
if row is None:
|
||||
return None
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
if furthest_step is not _UNSET and furthest_step is not None:
|
||||
row.furthest_step = int(furthest_step)
|
||||
if step1 is not _UNSET:
|
||||
row.step1 = _dumps(step1)
|
||||
if step2 is not _UNSET:
|
||||
row.step2 = _dumps(step2)
|
||||
if step3 is not _UNSET:
|
||||
row.step3 = _dumps(step3)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def delete_draft(self, draft_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,127 @@
|
||||
"""一次性迁移:把已有的 taskId 会商记录从个人草稿表搬进独立的 task 存储。
|
||||
|
||||
背景
|
||||
====
|
||||
早期 taskId 深链会商记录混存在按用户分权的 ``roundtable_drafts`` 表里(带一个
|
||||
``task_id`` 列 + 放开归属校验)。现在 taskId 聊天记录有了独立、不分权的存储
|
||||
``roundtable_task_drafts``(只按 ``task_id`` 区分,跨用户共享)。本脚本把历史数据搬过去:
|
||||
|
||||
1. ``roundtable_drafts`` 中 ``task_id`` 非空的每一行 → 在 ``roundtable_task_drafts``
|
||||
建同 id 的记录(``created_by`` = 原 ``user_id``,审计用);
|
||||
2. 这些草稿对应的 ``roundtable_jobs`` 行(``draft_id`` 匹配且 ``task_id`` 为空)补上
|
||||
``task_id``,使其转为「task 作业」(按 task 共享);
|
||||
3. 从 ``roundtable_drafts`` 删除已迁走的行,使个人历史不再混入 task 记录。
|
||||
|
||||
幂等:目标表已存在同 id 记录则跳过;可重复运行。alembic 自动升级在运行时是关闭的
|
||||
(见 ``persistence/engine.py``),所以这步数据搬迁用本脚本手动执行。
|
||||
|
||||
用法(在 backend 目录)
|
||||
======================
|
||||
::
|
||||
|
||||
# 预览(不写盘)
|
||||
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py --dry-run
|
||||
|
||||
# 执行
|
||||
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("migrate_roundtable_task_drafts")
|
||||
|
||||
|
||||
async def _run(dry_run: bool) -> int:
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||
from deerflow.persistence.roundtable_drafts.model import RoundtableDraftRow
|
||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
|
||||
config = get_app_config()
|
||||
await init_engine_from_config(config.database)
|
||||
sf = get_session_factory()
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
jobs_tagged = 0
|
||||
try:
|
||||
async with sf() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RoundtableDraftRow).where(RoundtableDraftRow.task_id.is_not(None))
|
||||
)
|
||||
).scalars().all()
|
||||
logger.info("发现 %d 条带 task_id 的个人草稿待迁移。", len(rows))
|
||||
|
||||
for row in rows:
|
||||
# 幂等:目标已存在同 id → 跳过(仍尝试给作业补 task_id + 删源行)。
|
||||
exists = await session.get(RoundtableTaskDraftRow, row.id)
|
||||
if exists is None:
|
||||
if not dry_run:
|
||||
session.add(
|
||||
RoundtableTaskDraftRow(
|
||||
id=row.id,
|
||||
task_id=row.task_id,
|
||||
created_by=row.user_id,
|
||||
title=row.title or "",
|
||||
furthest_step=row.furthest_step or 1,
|
||||
step1=row.step1,
|
||||
step2=row.step2,
|
||||
step3=row.step3,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
moved += 1
|
||||
logger.info(" 迁移 draft %s (task_id=%s)", row.id, row.task_id)
|
||||
else:
|
||||
skipped += 1
|
||||
logger.info(" 跳过 draft %s (目标已存在)", row.id)
|
||||
|
||||
# 关联后台作业补 task_id(未标记的才动)。
|
||||
if not dry_run:
|
||||
res = await session.execute(
|
||||
update(RoundtableJobRow)
|
||||
.where(RoundtableJobRow.draft_id == row.id)
|
||||
.where(RoundtableJobRow.task_id.is_(None))
|
||||
.values(task_id=row.task_id)
|
||||
)
|
||||
jobs_tagged += int(res.rowcount or 0)
|
||||
|
||||
# 删除源行(task 记录不再留在个人表)。
|
||||
if not dry_run:
|
||||
await session.delete(row)
|
||||
|
||||
if not dry_run:
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"完成%s:迁移 %d 条,跳过 %d 条,关联作业打标 %d 条。",
|
||||
"(dry-run,未写盘)" if dry_run else "",
|
||||
moved,
|
||||
skipped,
|
||||
jobs_tagged,
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="迁移 taskId 会商记录到独立 task 存储")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只预览,不写盘")
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(_run(args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -105,6 +105,13 @@ CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARC
|
||||
-- (knowledge_folders / knowledge_extract_templates 为【新表】,由 create_all 自动建,无需在此补列。)
|
||||
CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- taskId 深链会商独立存储,对应 migration 20260618_01 ----------------------
|
||||
-- roundtable_task_drafts 是【新表】(taskId 聊天记录,只按 task_id 不分权),由 create_all
|
||||
-- 自动建,无需补列。以下给【已存在的】roundtable_jobs 补 task_id 列:
|
||||
-- task_id: 非空 = task 作业(按 task 共享、不按 user 分权,报告写回 roundtable_task_drafts)。
|
||||
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
|
||||
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
@@ -136,11 +143,26 @@ PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---- roundtable_jobs.task_id 索引(幂等) -------------------------------------
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'roundtable_jobs'
|
||||
AND index_name = 'ix_roundtable_jobs_task_id'
|
||||
);
|
||||
SET @idx_ddl = IF(@idx_exists = 0,
|
||||
'CREATE INDEX `ix_roundtable_jobs_task_id` ON `roundtable_jobs` (`task_id`)',
|
||||
'SELECT ''SKIPPED ix_roundtable_jobs_task_id (already exists)'' AS result');
|
||||
PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id'))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -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`
|
||||
解析入库模板)。
|
||||
Reference in new issue
Block a user