Merge branch 'dhk-1' into 'master'

AI 写作四阶段内置智能体 Phase 1-4 落地

See merge request knowledge-base-bj/deerflow-server!116
This commit is contained in:
董宏凯 committed 2026-06-12 10:20:27 +00:00
commit c2f6d4fd6f
94 files changed
+6935 -1132

No files matched your search

+2 -1
View File
@@ -78,7 +78,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
The frontend (`frontend-web/`) is a React 19 + Vite SPA: The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks) - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **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/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+319
View File
@@ -0,0 +1,319 @@
// src/roundtable-planning/lib/flow-graph.test.ts
import assert from "node:assert/strict";
import test from "node:test";
// src/roundtable-planning/lib/flow-graph.ts
var FLOW_NODE_TYPE_META = {
start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" }
};
var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META));
var FLOW_FENCE_OPEN = "```flow-json";
function collectObjects(text, key) {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed);
}
} catch {
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break;
}
return out;
}
function asString(v) {
return typeof v === "string" ? v.trim() : "";
}
function toNode(raw) {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = VALID_TYPES.has(rawType) ? rawType : "task";
const detail = asString(raw.detail) || asString(raw.description) || void 0;
return { id, label, type, detail };
}
function edgesFromNode(raw, nodeId) {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || void 0;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
function toEdge(raw) {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || void 0;
return { from, to, label };
}
function extractFlowGraph(text) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`);
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes = [];
const seen = /* @__PURE__ */ new Set();
const edges = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
const edgeKeys = /* @__PURE__ */ new Set();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
var FLOW_INTENT_PATTERNS = [
"\u6D41\u7A0B\u56FE",
"\u53EF\u89C6\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u8F93\u51FA",
"\u7ED3\u6784\u5316\u6570\u636E",
"\u7ED3\u6784\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u603B\u7ED3",
"\u683C\u5F0F\u5316\u8F93\u51FA",
"\u683C\u5F0F\u5316\u603B\u7ED3",
"flow-json",
"flowchart"
];
function detectFlowIntent(text) {
if (!text) return false;
const lower = text.toLowerCase();
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
}
function stripFlowFence(text, streaming) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
var X_GAP = 224;
var Y_GAP = 132;
function layoutFlowGraph(data) {
const preds = /* @__PURE__ */ new Map();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = /* @__PURE__ */ new Map();
const visiting = /* @__PURE__ */ new Set();
const layerOf = (id) => {
const cached = memo.get(id);
if (cached !== void 0) return cached;
if (visiting.has(id)) return 0;
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = /* @__PURE__ */ new Map();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
const pos = /* @__PURE__ */ new Map();
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 });
});
}
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
}
// src/roundtable-planning/lib/flow-graph.test.ts
var FULL = [
"\u62A5\u544A\u6B63\u6587\u2026\u2026",
"```flow-json",
'{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[',
'{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},',
'{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},',
'{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}',
"]}",
"```"
].join("\n");
test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "\u8BC4\u5BA1" }
]);
});
test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => {
const partial = [
"\u6B63\u6587",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty'
// 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => {
assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"\u8D70"}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]);
});
test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("\u62A5\u544A\u6B63\u6587"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
});
test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => {
const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => {
const g = extractFlowGraph(FULL);
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => {
assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE"));
assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B"));
assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E"));
});
test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => {
assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false);
assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false);
assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false);
assert.equal(detectFlowIntent(""), false);
assert.equal(detectFlowIntent(null), false);
assert.equal(detectFlowIntent(void 0), false);
});
test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
@@ -0,0 +1,225 @@
# AI 写作「四阶段内置智能体化」改造方案
> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能,
> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动——
> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。
本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。
---
## 0. 一句话结论
不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。
---
## 1. 现状梳理(精确到文件)
AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py`
```
intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认)
→ writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done
```
四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时):
| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) |
|---|---|---|---|
| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) |
| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) |
| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) |
| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) |
关键事实:
- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。
- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。
- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。
- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。
---
## 2. 可复用的现成机制(不用造轮子)
代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`:
1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets/<agent-id>/`
- `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`)
- `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**)
2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents`
- 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents/<id>/` + DB(**已存在则永不覆盖**,尊重本地编辑)。
3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。
4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。
> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。
另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。
---
## 3. 目标设计
### 3.1 四个内置写作智能体(建议 id / 职责)
| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema |
|---|---|---|---|---|
| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` |
| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` |
| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown |
| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` |
每个 agent 自带:
- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。
- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。
### 3.2 节点改造方式(核心)
每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从:
```
# 旧:硬编码 prompt 直调
model = get_model(config, state["model_name"])
result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...))
```
改为:
```
# 新:跑对应内置 agent,按 marker 解析
result = await run_writing_agent(
agent_id="ai-writing-outliner",
state=state, config=config,
user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入
marker="[OUTLINE_READY]", # 结构化输出契约
)
```
新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。
### 3.3 为什么选这个方案(A)而非全多智能体重写(B)
- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。
- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。
---
## 4. 配置与可管理性
- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。
- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。
- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。
- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。
---
## 5. 分步实现计划(每步可独立交付 / 验收)
### Phase 0 · 契约冻结
- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。
- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。
### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程)
- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。
- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。
- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。
### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度)
- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。
- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。
- **接入顺序(由易到难)**:
1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。
2. **outliner**(输出 Outline JSON)。
3. **researcher**(保留技能检索步骤事件、素材确认暂停)。
4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。
- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。
- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。
### Phase 3 · 每阶段 agent 配置化
- per-agent 模型覆盖生效(`agent.config.model > model_name`)。
- researcher 的检索能力改为挂载技能,admin 可增减。
- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。
- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。
### Phase 4 · 前端适配(小改)
- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。
- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。
- **验收**:前端展示各阶段对应的智能体名;配置入口可达。
### Phase 5 · 清理收口
- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。
- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。
- **验收**:回归测试通过、冗余删除、文档同步。
---
## 6. 结构化输出契约(与现有 state 对齐)
各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`:
- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。
- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。
- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。
- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。
解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。
---
## 7. 风险与注意
1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。
2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。
3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。
4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents/<id>/` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。
5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。
6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。
---
## 8. 涉及文件清单(落地时对照)
**后端(主要改动)**
- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}`
- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`)
- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`)
- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一)
- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型)
- 保留 `prompts/*.py`(回退用,Phase 5 删)
**前端(小改)**
- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name)
- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页)
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
@@ -1,228 +0,0 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -1,274 +0,0 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
+152 -1
View File
@@ -320,4 +320,155 @@
max-width: 64px; max-width: 64px;
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
} }
.sidebar-contnet--top-submenu .item-nav:hover::before,
.sidebar-contnet--top-submenu .item-nav-active::before,
.sidebar-contnet--top-submenu .item-nav-hovered::before {
display: none;
}
.top-submenu-bar {
position: relative;
z-index: 35;
height: 64px;
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
margin-left: 34px;
background: transparent;
border: 0;
box-shadow: none;
overflow-x: auto;
overflow-y: hidden;
}
.top-submenu-list {
min-width: max-content;
height: 64px;
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
}
.top-submenu-item {
position: relative;
min-width: 122px;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(224, 242, 255, 0.9);
font-family: Noto Sans S Chinese;
font-size: 16px;
line-height: 1;
font-weight: 500;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease;
}
.top-submenu-item:hover,
.top-submenu-item--active {
color: #ffffff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.45);
background:
linear-gradient(135deg, rgba(15, 84, 169, 0.52) 0%, rgba(5, 43, 103, 0.34) 76%),
linear-gradient(180deg, rgba(39, 126, 230, 0.2) 0%, rgba(0, 50, 122, 0.26) 100%);
box-shadow: inset 0 1px 0 rgba(98, 180, 255, 0.16), inset 0 -18px 22px rgba(0, 31, 79, 0.24);
}
.top-submenu-item--active::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, rgba(59, 162, 255, 0) 0%, #35d7ff 50%, rgba(59, 162, 255, 0) 100%);
box-shadow: 0 0 12px rgba(53, 215, 255, 0.9), 0 -6px 18px rgba(33, 143, 255, 0.36);
}
.top-submenu-item--active::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 16px;
background: radial-gradient(ellipse at bottom, rgba(37, 189, 255, 0.34) 0%, rgba(37, 189, 255, 0) 68%);
pointer-events: none;
}
.third-submenu-bar {
position: relative;
z-index: 34;
height: 42px;
margin-left: 118px;
flex-shrink: 0;
background:
linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%),
radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%);
border-top: 1px solid rgba(79, 154, 255, 0.18);
border-bottom: 1px solid rgba(43, 143, 254, 0.26);
box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22);
overflow-x: auto;
overflow-y: hidden;
}
.third-submenu-list {
min-width: max-content;
height: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
}
.third-submenu-item {
position: relative;
height: 28px;
min-width: 86px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(90, 169, 255, 0.2);
border-radius: 2px;
padding: 0 16px;
background: rgba(2, 28, 65, 0.36);
color: rgba(221, 242, 255, 0.78);
font-family: Noto Sans S Chinese;
font-size: 14px;
line-height: 1;
font-weight: 500;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.third-submenu-item:hover,
.third-submenu-item--active {
color: #ffffff;
border-color: rgba(82, 205, 255, 0.55);
background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%);
box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14);
text-shadow: 0 0 8px rgba(87, 215, 255, 0.34);
}
.third-submenu-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 2px;
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { PanelLeftOpen, PanelLeftClose } from "lucide-react"; import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { import {
defaultExpandedIds, defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem, type MenuItem,
} from "@/core/page-layout/sidebar-menu"; } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import './page-sdebar.css' import './page-sdebar.css'
type PageSidebarDarkProps = { type PageSidebarDarkProps = {
@@ -19,10 +19,8 @@ type PageSidebarDarkProps = {
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) { export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useMemo( const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin), const inWujieEnv = isWujieEnv();
[isAdmin],
);
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds)); const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const navigate = useNavigate(); const navigate = useNavigate();
@@ -163,6 +161,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
})} })}
</div> </div>
); );
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口 // collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
if (collapsed) { if (collapsed) {
return ( return (
@@ -180,16 +179,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
} }
return ( return (
<aside className="sidebar-contnet"> <aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<div className="sidebar-contents"> <div className="sidebar-contents">
{visibleMenuItems.map((item) => ( {visibleMenuItems.map((item) => (
<div <div
key={item.id} key={item.id}
className="item-nav-group" className="item-nav-group"
onMouseEnter={() => handleNavHoverEnter(item.id)} onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
onMouseLeave={scheduleNavPanelClose} onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
> >
{item.children?.length ? ( {inWujieEnv && item.children?.length ? (
<div <div
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`} className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
> >
@@ -204,7 +203,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
> >
<span className="item-nav__label">{item.label}</span> <span className="item-nav__label">{item.label}</span>
</button> </button>
{item.children?.length && hoveredTopId === item.id ? ( {inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" /> <span className="item-nav-hover-bridge" aria-hidden="true" />
) : null} ) : null}
</div> </div>
+2 -3
View File
@@ -26,10 +26,9 @@ import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications"; import { useUnreadCount } from "@/core/notifications";
import { import {
defaultExpandedIds, defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem, type MenuItem,
} from "@/core/page-layout/sidebar-menu"; } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
type PageSidebarProps = { type PageSidebarProps = {
collapsed: boolean; collapsed: boolean;
@@ -45,7 +44,7 @@ export function PageSidebar({
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin); const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n(); const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount(); const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
@@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } {
return { color: "#ef4444", label: "缓慢" }; return { color: "#ef4444", label: "缓慢" };
} }
function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`; if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000); const mins = Math.floor(ms / 60_000);
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
return `${mins}m ${secs}s`; return `${mins}m ${secs}s`;
} }
function SpeedPill({ metric }: { metric: MessageMetric }) { export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec); const { color, label } = classifySpeed(metric.tokensPerSec);
const display = const display =
@@ -28,10 +28,7 @@ import {
List, List,
ListChecks, ListChecks,
ListTodo, ListTodo,
MessageSquareCode,
Network, Network,
NotebookPen,
Plus,
Settings, Settings,
Sparkles, Sparkles,
Target, Target,
@@ -72,40 +69,6 @@ export const sidebarMenuItems: MenuItem[] = [
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
], ],
}, },
// {
// id: "light-app-center",
// label: "轻应用",
// icon: Grid3X3,
// children: [
// {
// id: "light-app-note-management",
// label: "笔记管理",
// icon: NotebookPen,
// children: [
// {
// id: "light-app-new-note",
// label: "新建笔记",
// icon: Plus,
// path: "/page/strategy/light-app/new-note",
// },
// {
// id: "light-app-note-list",
// label: "笔记列表",
// icon: List,
// path: "/page/strategy/light-app/notes",
// },
// {
// id: "light-app-note-qa",
// label: "笔记问答",
// icon: MessageSquareCode,
// path: "/page/strategy/light-app/note-qa",
// },
// ],
// },
// { id: "light-app-square", label: "应用广场", icon: Layout },
// { id: "light-app-records", label: "运行记录", icon: History },
// ],
// },
{ {
id: "task-management", id: "task-management",
label: "任务管理", label: "任务管理",
@@ -207,6 +170,26 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
], ],
}, },
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{ {
id: "comprehensive-management", id: "comprehensive-management",
label: "综合管理", label: "综合管理",
@@ -0,0 +1,63 @@
/**
* 侧边栏菜单合并 hook —— 把已注册的「iframe 类型」轻应用动态注入静态菜单。
*
* 静态结构来自 sidebar-menu.ts;每个启用的 iframe 应用按其 `mountLocation`(父菜单 id)
* 作为新子项注入对应分组(XK中任务 / PG中任务 / TY中任务 / 综合管理),点击进入
* iframe 宿主页 `/page/strategy/light-app/view/:appId`。
*
* 浅色 PageSidebar 与深色 PageSidebarDark 共用此 hook,保证两套皮肤同步。
*/
import { useMemo } from "react";
import { Layout } from "lucide-react";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import {
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem,
} from "./sidebar-menu";
function lightAppLeaf(app: LightApp): MenuItem {
return {
id: `light-app-${app.appId}`,
label: app.name,
icon: Layout,
path: `/page/strategy/light-app/view/${app.appId}`,
};
}
/** 在菜单树(不可变克隆)中,把 iframe 应用按 mountLocation 注入对应父节点末尾。 */
function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
if (apps.length === 0) return items;
const byMount = new Map<string, LightApp[]>();
for (const app of apps) {
if (!app.mountLocation) continue;
const list = byMount.get(app.mountLocation) ?? [];
list.push(app);
byMount.set(app.mountLocation, list);
}
if (byMount.size === 0) return items;
const walk = (nodes: MenuItem[]): MenuItem[] =>
nodes.map((node) => {
const children = node.children ? walk(node.children) : undefined;
const extra = byMount.get(node.id);
if (!extra || extra.length === 0) {
return children ? { ...node, children } : node;
}
return { ...node, children: [...(children ?? []), ...extra.map(lightAppLeaf)] };
});
return walk(items);
}
/** 返回合并了 iframe 轻应用、并按 admin 角色过滤后的菜单。 */
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
return useMemo(
() => filterMenuItemsByRole(injectLightApps(sidebarMenuItems, iframeApps), isAdmin),
[iframeApps, isAdmin],
);
}
@@ -27,6 +27,50 @@ export function getNetworkActorIframeUrl(): string {
return `${base}${path.startsWith("/") ? path : `/${path}`}`; return `${base}${path.startsWith("/") ? path : `/${path}`}`;
} }
/** 读取并解析 localStorage 中的 userInfo(登录信息)。 */
function readUserInfo(): Record<string, unknown> {
try {
const raw = localStorage.getItem("userInfo");
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** 当前登录态 accessToken(无则空串)。 */
export function getAccessToken(): string {
const value = readUserInfo().accessToken;
return value == null ? "" : String(value);
}
/**
* XK 中任务 iframe 地址。
*
* 形如 `{base}{gopath}?accessToken={token}`,其中 gopath 含 hash 路由
* (如 `#/pageIndie/pageHome/pageSwrw`)—— 每次都从 base 重建,切换 gopath 不累加。
*/
export function getXkIframeUrl(gopath: string): string {
const base = readEnv("VITE_AIPI_XKLINKTO_URL");
if (!base) return "";
return `${base}${gopath}?accessToken=${getAccessToken()}`;
}
/** PG 中任务 iframe 地址:`{base}?access_token={token}&goPath={gopath}`。 */
export function getPgIframeUrl(gopath: string): string {
const base = readEnv("VITE_AIPI_PGLINKTO_URL");
if (!base) return "";
return `${base}?access_token=${getAccessToken()}&goPath=${gopath}`;
}
/** TY 中任务 iframe 地址:`{base}/fromB/toPage?defaultPage={defaultPage}`。 */
export function getTyIframeUrl(defaultPage: string): string {
const base = trimTrailingSlash(readEnv("VITE_AIPI_B2ACTOR_URL"));
if (!base) return "";
return `${base}/fromB/toPage?defaultPage=${defaultPage}`;
}
/** 知识库 iframe 地址换取 API */ /** 知识库 iframe 地址换取 API */
export function getKnowledgeIframeApiUrl(): string { export function getKnowledgeIframeApiUrl(): string {
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL"); return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL");
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; import { ChevronsDown, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -15,6 +15,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useAIWriting } from '../../contexts/AIWritingContext'; import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext'; import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions'; import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions'; import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
@@ -83,6 +84,7 @@ export function AIWritingPanel({
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions(); const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes(); const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
@@ -342,6 +344,18 @@ export function AIWritingPanel({
onRename={rename} onRename={rename}
onRefresh={refresh} onRefresh={refresh}
/> />
{/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */}
{writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => writingForm.setFormExpanded(false)}
title="收起写作配置"
>
<PanelRightClose className="size-3.5" />
</Button>
)}
</div> </div>
</div> </div>
@@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
const AGENT_COLOR: Record<string, string> = { const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500', 素材收集专家: 'text-blue-500',
作家: 'text-purple-500', 作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
大纲规划师: 'text-violet-500',
编辑: 'text-orange-500', 编辑: 'text-orange-500',
系统: 'text-muted-foreground', 系统: 'text-muted-foreground',
}; };
@@ -19,6 +19,9 @@ import type { SetupWritingApprovalPayload } from '@/components/workspace/message
export interface WritingFormValue { export interface WritingFormValue {
intent: string; intent: string;
setIntent: (v: string) => void; setIntent: (v: string) => void;
/** 整个写作配置表单主体是否展开;初始收起,用户点表头或问答侧「应用到表单」时展开。 */
formExpanded: boolean;
setFormExpanded: (v: boolean) => void;
showOutline: boolean; showOutline: boolean;
setShowOutline: (v: boolean) => void; setShowOutline: (v: boolean) => void;
outlineText: string; outlineText: string;
@@ -55,6 +58,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) { export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(false); const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState(''); const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
@@ -65,6 +69,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
// 问答侧识别出意图并点「应用到表单」时,自动展开表单让用户看到回填结果。
setFormExpanded(true);
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim()); if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim()); if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) { if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
@@ -85,6 +91,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const value = useMemo<WritingFormValue>( const value = useMemo<WritingFormValue>(
() => ({ () => ({
intent, setIntent, intent, setIntent,
formExpanded, setFormExpanded,
showOutline, setShowOutline, showOutline, setShowOutline,
outlineText, setOutlineText, outlineText, setOutlineText,
articleType, setArticleType, articleType, setArticleType,
@@ -95,7 +102,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
modelName, setModelName, modelName, setModelName,
applyProposal, applyProposal,
}), }),
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal], [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
); );
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>; return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -82,7 +82,9 @@
* ────────────────────────────────────────────────────────────────────────── * ──────────────────────────────────────────────────────────────────────────
*/ */
import { useRef } from 'react'; import { useRef } from 'react';
import { PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext'; import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
@@ -93,7 +95,7 @@ import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPane
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext'; import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider } from '../contexts/WritingFormContext'; import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -103,14 +105,9 @@ import {
// Panel IDs used as keys in the v4 Layout map // Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing'; const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft'; const DRAFT_ID = 'aw-draft';
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
const SETUP_CHAT_ID = 'aw-setup-chat';
const SETUP_FORM_ID = 'aw-setup-form';
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/** /**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。 * 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
@@ -139,38 +136,45 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting(); const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory(); const { isHistoryMode } = useAIWritingHistory();
const { formExpanded, setFormExpanded } = useWritingForm();
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿) // 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)。
//
// 用稳定的 flex 结构(而非按 formExpanded 在两套容器间切换):左侧问答区永远是第一个
// 子节点、位置不变,避免收起/展开时 WritingSetupChat 被重新挂载(它在内部 useMemo 生成
// 独立 threadId,一旦重挂会换新线程、丢失对话)。右侧整块「写作配置表单」可收起为一条
// 竖向展开栏;问答侧点「应用到表单」(applyProposal) 会自动把 formExpanded 置 true 展开。
if (state.status === 'idle' && !isHistoryMode) { if (state.status === 'idle' && !isHistoryMode) {
return ( return (
<ResizablePanelGroup <div className="relative flex h-full">
orientation="horizontal" {/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */}
groupRef={layoutRef} <div className="min-h-0 flex-1 overflow-hidden">
defaultLayout={SETUP_LAYOUT}
className="h-full"
>
{/* 左侧:写作助手 Q&A */}
<ResizablePanel
id={SETUP_CHAT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<WritingSetupChat /> <WritingSetupChat />
</ResizablePanel> </div>
<ResizableHandle withHandle /> {/* 右侧:写作配置表单整块。容器持久存在(表单始终挂载、保留状态),宽度随 formExpanded
平滑过渡——收起时宽度归零、整块滑出,右侧完全让给问答区。 */}
{/* 右侧:写作配置选项表单 */} <div
<ResizablePanel className={cn(
id={SETUP_FORM_ID} 'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
defaultSize={35} formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
minSize={20} )}
className="overflow-hidden flex flex-col"
> >
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} /> <WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</ResizablePanel> </div>
</ResizablePanelGroup>
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
); );
} }
@@ -0,0 +1,41 @@
/**
* 轻应用 iframe 宿主页 —— 渲染某个已注册 iframe 类型应用。
*
* 路由:/page/strategy/light-app/view/:appId。侧边栏动态注入的 iframe 应用子项指向此页。
* 用 buildLightAppUrl 构造带路由参数(登录态 / 主题)的 src。
*/
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
export default function LightAppIframeView() {
const { appId } = useParams<{ appId: string }>();
const { theme } = useTheme();
const isDark = theme === "dark";
const { data: app, isLoading, error } = useLightApp(appId);
const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app]);
const centerClass = `flex h-full w-full items-center justify-center text-sm ${
isDark ? "text-slate-400" : "text-slate-500"
}`;
if (isLoading) {
return <div className={centerClass}>加载中…</div>;
}
if (error || !app) {
return <div className={centerClass}>应用不存在或已被删除</div>;
}
if (app.appType !== "iframe") {
return <div className={centerClass}>该应用不是 iframe 嵌套类型</div>;
}
return (
<div className="flex h-full w-full flex-col">
<iframe src={src} title={app.name} className="w-full flex-1 border-0" />
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。
*
* 支持搜索 + 分页。点击卡片用 buildLightAppUrl 构造最终地址(追加解析后的路由参数)
* 并 window.open 新开页跳转。仅展示已启用的应用;iframe 类型不在此处(它们注入侧边栏)。
*/
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Search } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { useTheme } from "@/contexts/ThemeContext";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
const PAGE_SIZE = 12;
export default function LightAppListPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
enabledOnly: true,
search,
});
// 搜索变化时回到第一页。
useEffect(() => {
setPage(1);
}, [search]);
const pageApps = useMemo(
() => apps.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[apps, page],
);
const openApp = (app: LightApp) => {
const url = buildLightAppUrl(app);
if (!url) {
toast.error("该应用地址为空,无法打开");
return;
}
window.open(url, "_blank", "noopener,noreferrer");
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "group cursor-pointer rounded-2xl border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] p-4 transition-all hover:-translate-y-0.5 hover:border-[rgba(77,141,255,0.6)] hover:shadow-[0_18px_38px_rgba(3,10,22,0.35)]"
: "group cursor-pointer rounded-2xl border border-[#E8E8E8] bg-white p-4 transition-all hover:-translate-y-0.5 hover:border-[#FFA39E] hover:shadow-[0_16px_32px_rgba(245,34,45,0.1)]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const iconWrap = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
应用列表
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
点击卡片在新标签页打开应用。地址会自动附带配置的路由参数(登录态 / 主题)。
</p>
</div>
<div className="relative max-w-sm">
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="pl-9"
/>
</div>
{apps.length === 0 ? (
<div className={`rounded-3xl border py-20 text-center ${isDark ? "border-[rgba(67,89,124,0.4)] text-slate-500" : "border-[#F0F0F0] text-[#A6A6A6]"}`}>
{isLoading ? "加载中…" : "暂无可用应用"}
</div>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{pageApps.map((app) => (
<button key={app.appId} type="button" onClick={() => openApp(app)} className={`${cardClass} text-left`}>
<div className="flex items-start gap-3">
<span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${iconWrap}`}>
<ExternalLink className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className={`truncate text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{app.name}
</div>
<div className={`mt-1 line-clamp-2 text-xs leading-relaxed ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{app.description || app.url}
</div>
</div>
</div>
</button>
))}
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={apps.length} onPageChange={setPage} className="pt-2" />
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,676 @@
import { useMemo, useState } from "react";
import {
ExternalLink,
HelpCircle,
Info,
Layers,
Pencil,
Plus,
Search,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useLightAppActions,
useLightApps,
type LightApp,
type LightAppRouteParam,
type LightAppType,
} from "@/strategy-components/api/light-apps";
import {
LOGIN_SOURCE_OPTIONS,
MOUNT_OPTIONS,
mountLabel,
} from "@/strategy-components/lib/light-app";
interface FormState {
name: string;
description: string;
appType: LightAppType;
url: string;
mountLocation: string;
enabled: boolean;
routeParams: LightAppRouteParam[];
}
const emptyForm = (): FormState => ({
name: "",
description: "",
appType: "window_open",
url: "",
mountLocation: "",
enabled: true,
routeParams: [],
});
const newParam = (): LightAppRouteParam => ({
key: "",
paramType: "login",
source: "accessToken",
customValue: "",
themeValue: "dark-blue",
});
function formFromApp(app: LightApp): FormState {
return {
name: app.name,
description: app.description ?? "",
appType: app.appType,
url: app.url,
mountLocation: app.mountLocation ?? "",
enabled: app.enabled,
routeParams: app.routeParams.map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? "accessToken",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "dark-blue",
})),
};
}
function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
const source = param.source || "accessToken";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
}
return `登录参数: ${option?.label || source}`;
}
export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const saving = actions.create.isPending || actions.update.isPending;
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setDialogOpen(true);
};
const openEdit = (app: LightApp) => {
setEditing(app);
setForm(formFromApp(app));
setDialogOpen(true);
};
const patchForm = (patch: Partial<FormState>) =>
setForm((f) => ({ ...f, ...patch }));
const patchParam = (idx: number, patch: Partial<LightAppRouteParam>) =>
setForm((f) => ({
...f,
routeParams: f.routeParams.map((p, i) => (i === idx ? { ...p, ...patch } : p)),
}));
const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] });
const removeParam = (idx: number) =>
patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) });
const handleSave = async () => {
const name = form.name.trim();
const url = form.url.trim();
if (!name) return toast.error("请输入应用名称");
if (!url) return toast.error("请输入应用地址");
if (form.appType === "iframe" && !form.mountLocation) {
return toast.error("iframe 类型请选择挂载菜单");
}
for (const p of form.routeParams) {
if (!p.key.trim()) return toast.error("路由参数键名不可为空");
if (p.paramType === "login" && p.source === "other" && !p.customValue?.trim()) {
return toast.error("登录参数自定义值不可为空");
}
if (p.paramType === "theme" && !p.themeValue?.trim()) {
return toast.error("主题参数值不可为空");
}
}
const payload = {
name,
description: form.description.trim() || null,
appType: form.appType,
url,
routeParams: cleanParams(form.routeParams),
mountLocation: form.appType === "iframe" ? form.mountLocation : null,
enabled: form.enabled,
};
try {
if (editing) {
await actions.update.mutateAsync({ appId: editing.appId, patch: payload });
toast.success(`已更新「${name}」`);
} else {
await actions.create.mutateAsync(payload);
toast.success(`已注册「${name}」`);
}
setDialogOpen(false);
} catch (err) {
toast.error((err as Error).message || "保存失败");
}
};
const handleDelete = async (app: LightApp) => {
if (!window.confirm(`是否确定要删除「${app.name}」?该操作不可逆。`)) return;
try {
await actions.remove.mutateAsync(app.appId);
toast.success(`已删除「${app.name}」`);
} catch (err) {
toast.error((err as Error).message || "删除失败");
}
};
const tableApps = useMemo(() => apps, [apps]);
return (
<div className="h-full overflow-y-auto bg-[#FAFAFA]">
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col space-y-6 px-6 py-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[20px] font-bold tracking-tight text-[#222222]">应用管理</h1>
<p className="max-w-4xl text-[13px] leading-relaxed text-[#575757]">
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。
</p>
</div>
<button
onClick={openCreate}
className="flex h-9 cursor-pointer items-center space-x-1.5 rounded-[2px] bg-[#A8071A] px-4 text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-all hover:bg-[#BC0D1E] active:bg-[#820014]"
>
<span>+ 注册应用</span>
</button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#C1C1C1]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-[12px] text-[#A6A6A6] hover:text-[#F5222D]"
>
清除
</button>
)}
</div>
<div className="overflow-visible rounded-[4px] border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)]">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#E8E8E8] bg-[#FAFAFA] text-[12px] font-bold uppercase tracking-wider text-[#A6A6A6]">
<th className="w-[18%] px-6 py-3.5">名称</th>
<th className="w-[12%] px-4 py-3.5">类型</th>
<th className="w-[34%] px-4 py-3.5">地址 / 挂载</th>
<th className="w-[12%] px-4 py-3.5">参数</th>
<th className="w-[12%] px-4 py-3.5">启用</th>
<th className="w-[12%] px-4 py-3.5 pr-6 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#E8E8E8]">
{tableApps.map((app) => (
<tr key={app.appId} className="group align-top transition-colors hover:bg-[#FEFAFA]">
<td className="px-6 py-[18px]">
<div className="flex flex-col space-y-1">
<span className="text-[14px] font-bold text-[#222222] transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
{app.description ? (
<span className="line-clamp-2 text-[12px] text-[#575757]">{app.description}</span>
) : (
<span className="text-[11px] italic text-[#C1C1C1]">无描述说明</span>
)}
</div>
</td>
<td className="px-4 py-[18px]">
{app.appType === "window_open" ? (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#FDDADC] bg-[#FEEDED] px-2 py-0.5 text-[11px] font-medium text-[#F5222D] shadow-sm">
<ExternalLink className="h-3 w-3" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#D4E9FF] bg-[#EAF4FF] px-2 py-0.5 text-[11px] font-medium text-[#0079FE] shadow-sm">
<Layers className="h-3 w-3" />
<span>iframe 嵌套</span>
</span>
)}
</td>
<td className="overflow-hidden px-4 py-[18px]">
<div className="flex max-w-full flex-col space-y-1">
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1.5 truncate font-mono text-[13px] text-[#222222] hover:text-[#0079FE] hover:underline"
>
<span className="truncate">{app.url}</span>
</a>
{app.appType === "iframe" && app.mountLocation && (
<div className="flex items-center space-x-1 text-[11px] text-[#A6A6A6]">
<span className="h-1.5 w-1.5 rounded-full bg-[#0079FE]" />
<span>挂载: {mountLabel(app.mountLocation)}</span>
</div>
)}
</div>
</td>
<td className="relative px-4 py-[18px]">
<div className="flex items-center select-none">
{app.routeParams.length > 0 ? (
<button
onClick={() =>
setActiveParamPopover(
activeParamPopover === app.appId ? null : app.appId
)
}
className="group/param inline-flex cursor-pointer items-center space-x-1 text-[14px] text-[#222222] transition-colors hover:text-[#0079FE]"
>
<span className="font-medium">{app.routeParams.length}个</span>
<span className="text-[10px] text-[#A6A6A6] transition-colors group-hover/param:text-[#0079FE]">
▼
</span>
</button>
) : (
<span className="text-[14px] text-[#A6A6A6]">0个</span>
)}
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setActiveParamPopover(null)}
/>
<div className="absolute left-4 top-11 z-50 mt-1 w-[280px] animate-in fade-in rounded-[4px] border border-[#E8E8E8] bg-white p-3 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="mb-2 flex items-center justify-between border-b border-[#E8E8E8] pb-2 font-semibold text-[#3D3D3D]">
<span>路由参数明细</span>
<span className="rounded-sm bg-[#EAF4FF] px-1.5 py-0.5 text-[10px] text-[#0079FE]">
URL 拼接
</span>
</div>
<div className="max-h-[160px] space-y-1.5 overflow-y-auto">
{app.routeParams.map((p, idx) => (
<div
key={`${app.appId}-${idx}`}
className="flex items-center justify-between rounded-sm bg-[#FAFAFA] p-1.5 font-mono hover:bg-[#FEF6F6]"
>
<span className="font-bold text-[#F5222D]">{p.key}</span>
<span className="text-[11px] text-[#A6A6A6]">
({getParamSourceLabel(p)})
</span>
</div>
))}
</div>
</div>
</>
)}
</div>
</td>
<td className="px-4 py-[18px]">
<div className="flex select-none items-center space-x-2">
{app.enabled ? (
<>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2FD69A] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#2FD69A]" />
</span>
<span className="text-[13px] font-semibold text-[#189D72]">已启用</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-[#A6A6A6]" />
<span className="text-[13px] text-[#A6A6A6]">已禁用</span>
</>
)}
</div>
</td>
<td className="px-4 py-[18px] pr-6 text-right">
<div className="inline-flex items-center justify-end space-x-2.5">
<button
onClick={() => openEdit(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#D4E9FF] bg-[#EAF4FF] text-[#0079FE] transition-all duration-200 hover:scale-105 hover:border-[#0079FE] hover:bg-[#0079FE] hover:text-white active:scale-95"
title="编辑配置"
>
<Pencil className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:rotate-12" />
</button>
<button
onClick={() => handleDelete(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#FDDADC] bg-[#FEEDED] text-[#F5222D] transition-all duration-200 hover:scale-105 hover:border-[#F5222D] hover:bg-[#F5222D] hover:text-white active:scale-95"
title="删除配置"
>
<Trash2 className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:scale-110" />
</button>
</div>
</td>
</tr>
))}
{tableApps.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F5F5F5] text-[#C1C1C1]">
<Info className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-[14px] font-semibold text-[#A6A6A6]">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="text-[12px] text-[#C1C1C1]">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
</div>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex max-w-full select-none items-start space-x-3.5 rounded-[4px] border border-[#FFBB55] bg-[#FFF6EA] p-4 text-[13px] text-[#575757]">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#FF9900]" />
<div className="space-y-1">
<p className="font-bold text-[#222222]">📌 智能多维挂载规则:</p>
<ul className="list-inside list-disc space-y-1 pl-1">
<li>
当您注册 <strong>iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
</li>
<li>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong>真实 Iframe 预览器</strong>
,并自动将参数以加密/标准的QueryString传递过去。
</li>
</ul>
</div>
</div>
</div>
{dialogOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto select-none">
<div
className="fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
onClick={() => setDialogOpen(false)}
/>
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[4px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<h2 className="text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
<button
onClick={() => setDialogOpen(false)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用类型</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
value={form.appType}
onChange={(e) => patchForm({ appType: e.target.value as LightAppType })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="iframe">iframe 嵌套 (挂载到菜单)</option>
<option value="window_open">window.open (新页跳转)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用名称</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.name}
onChange={(e) => patchForm({ name: e.target.value })}
placeholder="如: 舆情看板"
className="h-9 rounded-[2px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用地址</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
className="h-9 rounded-[2px] border-[#DBDBDB] font-mono text-[14px]"
/>
</div>
{form.appType === "iframe" && (
<div className="animate-in fade-in space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>挂载菜单</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
value={form.mountLocation}
onChange={(e) => patchForm({ mountLocation: e.target.value })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="">请选择挂载菜单</option>
{MOUNT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)}
<div className="space-y-1.5">
<label className="text-[14px] font-medium">应用描述 (可选)</label>
<Textarea
value={form.description}
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
className="resize-none rounded-[2px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-2.5 pt-1.5">
<div className="flex items-center justify-between">
<span className="flex items-center text-[14px] font-medium">
<span>路由参数</span>
<HelpCircle
className="ml-1 h-3.5 w-3.5 text-[#A6A6A6]"
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
/>
</span>
<button
type="button"
onClick={addParam}
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>+ 添加路由参数</span>
</button>
</div>
{form.routeParams.length > 0 ? (
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => (
<div
key={`${p.key}-${idx}`}
className="relative flex items-center space-x-2 rounded-sm border border-[#E8E8E8] bg-white p-2"
>
<div className="flex-1 space-y-1">
<input
type="text"
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="w-[110px]">
<select
value={p.paramType}
onChange={(e) =>
patchParam(idx, {
paramType: e.target.value as "login" | "theme",
source:
e.target.value === "login" ? (p.source || "accessToken") : undefined,
})
}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
<option value="login">登录参数</option>
<option value="theme">主题参数</option>
</select>
</div>
<div className="flex-[1.2] text-[11px]">
{p.paramType === "login" ? (
<div className="flex items-center gap-2">
<select
value={p.source || "accessToken"}
onChange={(e) => patchParam(idx, { source: e.target.value })}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
{LOGIN_SOURCE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{p.source === "other" && (
<input
type="text"
value={p.customValue || ""}
onChange={(e) =>
patchParam(idx, { customValue: e.target.value })
}
placeholder="自定义值"
className="h-8 w-[120px] rounded-[2px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
/>
)}
</div>
) : (
<input
type="text"
value={p.themeValue || ""}
onChange={(e) => patchParam(idx, { themeValue: e.target.value })}
placeholder="主题值,如 dark-blue"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
)}
</div>
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
暂无注入的路由参数,加载地址时不传递多余拼接。
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
<span className="text-[14px] font-medium text-[#222222]">立即启用该应用</span>
<button
type="button"
onClick={() => patchForm({ enabled: !form.enabled })}
className={`relative h-[22px] w-10 cursor-pointer rounded-full p-0.5 transition-colors duration-200 ${
form.enabled ? "bg-[#F5222D]" : "bg-[#DBDBDB]"
}`}
>
<div
className={`h-[18px] w-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${
form.enabled ? "translate-x-[18px]" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
<div className="flex flex-shrink-0 items-center justify-end space-x-3 border-t border-[#E8E8E8] pt-4">
<Button
variant="outline"
disabled={saving}
onClick={() => setDialogOpen(false)}
className="h-9 rounded-[2px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
>
取消
</Button>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="h-9 cursor-pointer rounded-[2px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
return params
.filter((p) => p.key.trim())
.map((p) => {
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
if (p.paramType === "login") {
base.source = p.source || "accessToken";
if (base.source === "other") {
base.customValue = (p.customValue ?? "").trim();
}
} else {
base.themeValue = (p.themeValue ?? "").trim();
}
return base;
});
}
+10 -6
View File
@@ -5,14 +5,12 @@ import { SidebarProvider } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base"; import { RouteBaseProvider } from "@/core/route-base";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA"; import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes"; import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement"; import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom"; import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom"; import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes"; import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes"; import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement"; import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
@@ -27,6 +25,9 @@ import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage"; import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage"; import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
@@ -125,9 +126,9 @@ export default function StrategyRoutes() {
/> />
} }
/> />
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} /> <Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<FanganTyPage />} /> <Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<XXTyPage />} /> <Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
</Route> </Route>
<Route <Route
@@ -143,6 +144,9 @@ export default function StrategyRoutes() {
element={<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />} element={<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />}
/> />
<Route path="light-app/management" element={<LightAppManagementPage />} />
<Route path="light-app/apps" element={<LightAppListPage />} />
<Route path="light-app/view/:appId" element={<LightAppIframeView />} />
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} /> <Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
@@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise<void> {
throw new Error(`delete chain failed (${res.status}): ${text}`); throw new Error(`delete chain failed (${res.status}): ${text}`);
} }
} }
// ── 提示词优化 ───────────────────────────────────────────────────────────────
export interface OptimizeChainPromptRequest {
/** 用户当前写的提示词原文(非空)。 */
promptText: string;
/** coordinator = 总控提示词;stage_goal = 某个 Stage 的阶段目标。 */
kind: "coordinator" | "stage_goal";
/** 链条结构上下文(标题 / 各 Stage 席位与目标),帮助模型贴着链条优化。 */
context?: string;
/** 可选模型覆盖,缺省由后端用 config models[0]。 */
modelName?: string;
signal?: AbortSignal;
}
/** 调后端单次 LLM 重写提示词,返回优化后的全文(失败抛错,错误信息可直接 toast)。 */
export async function optimizeChainPrompt(req: OptimizeChainPromptRequest): Promise<string> {
const res = await apiFetch(`${BASE()}/optimize-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_text: req.promptText,
kind: req.kind,
context: req.context ?? "",
...(req.modelName ? { model_name: req.modelName } : {}),
}),
signal: req.signal,
});
const data = await readJson<{ optimized: string }>(res, "optimize chain prompt");
return data.optimized ?? "";
}
@@ -115,6 +115,11 @@ export interface DraftStep3Snapshot {
model?: string; model?: string;
/** The HTML agent's closing one-liner (rendered as the report message bubble). */ /** The HTML agent's closing one-liner (rendered as the report message bubble). */
summary?: string; summary?: string;
/**
* 绘制这份 HTML 时的界面主题(light=浅红 / dark=深蓝)。恢复草稿时与当前主题
* 比对,不一致则提示「重新绘制以匹配当前主题」。旧草稿没有该字段 → 不提示。
*/
themeMode?: "light" | "dark";
/** /**
* Step 3「总结报告」snapshot — output of the `roundtable-summary` agent * Step 3「总结报告」snapshot — output of the `roundtable-summary` agent
* (Markdown report). Stored alongside (not instead of) the HTML report so the * (Markdown report). Stored alongside (not instead of) the HTML report so the
@@ -65,6 +65,7 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
pendingClarification: (raw.pendingClarification as string | null) ?? null, pendingClarification: (raw.pendingClarification as string | null) ?? null,
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend", orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null, chain: (raw.chain as RoundtableJob["chain"]) ?? null,
orchestrationPlan: (raw.orchestrationPlan as RoundtableJob["orchestrationPlan"]) ?? null,
error: (raw.error as string | null) ?? null, error: (raw.error as string | null) ?? null,
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [], dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
step3: (raw.step3 as RoundtableJob["step3"]) ?? null, step3: (raw.step3 as RoundtableJob["step3"]) ?? null,
@@ -81,6 +82,7 @@ function toStartBody(p: StartJobPayload) {
model: p.model, model: p.model,
orchestrationMode: p.orchestrationMode ?? "recommend", orchestrationMode: p.orchestrationMode ?? "recommend",
chain: p.chain ?? null, chain: p.chain ?? null,
orchestrationPlan: p.orchestrationPlan ?? null,
seedMessage: p.seedMessage, seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null, threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator", coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
@@ -0,0 +1,27 @@
/**
* 圆桌会商气泡底部的「回答速度指示器」—— 复用主聊天的 `SpeedPill`(同款图标 +
* tok/s 数值 + 绿→红渐变条)与 `formatDuration`,外壳套圆桌的细分隔线 + 小字号,
* 让多智能体会商页的每条模型发言与主页面观感一致。
*
* 仅展示用:是否渲染由调用方按 `streamSpeedMeterEnabled` 开关 + 是否有度量决定
* (与主页面共用同一个全局开关)。`durationMs <= 0`(尚无可计时数据)时不渲染。
*/
import {
SpeedPill,
formatDuration,
} from "@/components/workspace/messages/message-token-usage";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
export function DialogueSpeedMeter({ metric }: { metric: MessageMetric }) {
if (metric.durationMs <= 0) {
return null;
}
return (
<div className="roundtable-chat-meta mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-slate-150 pt-1.5 text-slate-400">
<SpeedPill metric={metric} />
<span className="font-mono tabular-nums">
{formatDuration(metric.durationMs)}
</span>
</div>
);
}
@@ -68,6 +68,8 @@ export function DispatchChainJobModal({
onView={job && onView ? () => onView(job) : undefined} onView={job && onView ? () => onView(job) : undefined}
error={job?.error} error={job?.error}
dialogues={job?.dialogues} dialogues={job?.dialogues}
orchestrationMode={job?.orchestrationMode}
stages={job?.orchestrationPlan?.stages ?? null}
pendingClarification={job?.pendingClarification} pendingClarification={job?.pendingClarification}
onResume={ onResume={
jobId jobId
@@ -29,8 +29,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
import { DagFlowChart } from "./DagFlowChart";
import { MarkdownContent } from "./MessageBubble"; import { MarkdownContent } from "./MessageBubble";
import { chainTagClasses } from "../lib/business-chain"; import { chainTagClasses } from "../lib/business-chain";
import type { OrchestrationStage } from "../lib/dag-types";
import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { getAvatarTypeFor } from "../lib/roundtable-constants";
import type { import type {
ChainNodeState, ChainNodeState,
@@ -67,6 +69,10 @@ export interface DispatchChainModalProps {
error?: string | null; error?: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付)。 */ /** 研讨对话记录(leader 说明 + 各席位交付)。 */
dialogues?: JobDialogue[]; dialogues?: JobDialogue[];
/** 编排模式;==="dag" 且 stages 非空时,流程图按 stage 分层(DAG)绘制,否则线性。 */
orchestrationMode?: "recommend" | "chain" | "dag";
/** dag 模式的分层 stages(按 stage 分列画流程图,stage 内并行席位纵向并列)。 */
stages?: ReadonlyArray<OrchestrationStage> | null;
} }
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]); const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
@@ -105,6 +111,8 @@ export function DispatchChainModal({
onCancel, onCancel,
error, error,
dialogues, dialogues,
orchestrationMode,
stages,
}: DispatchChainModalProps) { }: DispatchChainModalProps) {
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const convoRef = useRef<HTMLDivElement>(null); const convoRef = useRef<HTMLDivElement>(null);
@@ -139,6 +147,28 @@ export function DispatchChainModal({
const reportActive = phase === "report_drawing"; const reportActive = phase === "report_drawing";
const reportDone = phase === "report_done" || status === "done"; const reportDone = phase === "report_done" || status === "done";
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
// 节点状态来自 dispatch_chain(agentId 小写 → state);resolveSeat 取名称 + 头像配色。
const dagSeatStates: Record<string, ChainNodeState> = {};
const nodeByAgent = new Map<string, DispatchChainNode>();
for (const n of nodes) {
dagSeatStates[n.agentId.toLowerCase()] = n.state;
nodeByAgent.set(n.agentId.toLowerCase(), n);
}
const resolveDagSeat = (agentId: string, order: number) => {
const node = nodeByAgent.get(agentId.toLowerCase());
return {
name: node?.name || agentId,
avatarType: node?.avatarType || getAvatarTypeFor(agentId, order),
};
};
const dagConsensusState: ChainNodeState = consensusDone
? "done"
: consensusActive
? "active"
: "pending";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[94vw] flex-col gap-0 sm:max-w-[1240px]"> <DialogContent className="flex max-h-[90vh] w-[94vw] flex-col gap-0 sm:max-w-[1240px]">
@@ -167,51 +197,65 @@ export function DispatchChainModal({
<DialogDescription>{PHASE_LABEL[phase]}</DialogDescription> <DialogDescription>{PHASE_LABEL[phase]}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* 横向流程图(横向滚动) */} {/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8"> {showDag ? (
<div className="flex min-w-max items-start gap-0 px-6"> // 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识。
{/* 总控节点 */} // 后台本期不绘报告(Step3 禁用) → 不显示「结果绘制」里程碑。
<FlowNode <DagFlowChart
label="总控协调" stages={stages!}
avatarType="human" resolveSeat={resolveDagSeat}
state={coordinatorState} seatStates={dagSeatStates}
/> coordinatorState={coordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
className="shrink-0"
/>
) : (
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
<div className="flex min-w-max items-start gap-0 px-6">
{/* 总控节点 */}
<FlowNode
label="总控协调"
avatarType="human"
state={coordinatorState}
/>
{/* 席位节点 */} {/* 席位节点 */}
{nodes.map((node) => ( {nodes.map((node) => (
<Fragment key={node.agentId}> <Fragment key={node.agentId}>
<Connector <Connector
active={node.state === "active"} active={node.state === "active"}
done={node.state === "done"} done={node.state === "done"}
/> />
<FlowNode <FlowNode
label={node.name} label={node.name}
avatarType={node.avatarType} avatarType={node.avatarType}
state={node.state} state={node.state}
order={node.order} order={node.order}
/> />
</Fragment> </Fragment>
))} ))}
{/* 共识里程碑 */} {/* 共识里程碑 */}
<Connector active={consensusActive} done={consensusDone} /> <Connector active={consensusActive} done={consensusDone} />
<MilestoneNode <MilestoneNode
label="共识达成" label="共识达成"
emoji="🤝" emoji="🤝"
state={ state={
consensusActive ? "active" : consensusDone ? "done" : "pending" consensusActive ? "active" : consensusDone ? "done" : "pending"
} }
/> />
{/* 报告绘制里程碑 */} {/* 报告绘制里程碑 */}
<Connector active={reportActive} done={reportDone} /> <Connector active={reportActive} done={reportDone} />
<MilestoneNode <MilestoneNode
label="结果绘制" label="结果绘制"
emoji="📊" emoji="📊"
state={reportActive ? "active" : reportDone ? "done" : "pending"} state={reportActive ? "active" : reportDone ? "done" : "pending"}
/> />
</div>
</div> </div>
</div> )}
{/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */} {/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */}
{dialogues && dialogues.length > 0 && ( {dialogues && dialogues.length > 0 && (
@@ -0,0 +1,445 @@
/**
* Step 3「总结报告」结构化流程图画布。
*
* 数据来自总结智能体回复末尾的 ```flow-json 块(见 lib/flow-graph.ts),流式期间
* 随解析增量重绘:每出现一个新节点,它的入边挂蚂蚁线动画(React Flow `animated`),
* 画布自动 fitView 跟随生长;结束后全部转实线。
*
* 交互:画布拖动(pan)/ 滚轮缩放 / 节点可拖拽(手动位置在数据更新间保留)/
* 工具栏 放大·缩小·复位·全屏 / 点节点弹详情(类型、说明、上下游)。
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
applyNodeChanges,
Background,
BackgroundVariant,
Handle,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeChange,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
FileText,
Loader2,
LocateFixed,
Maximize2,
Minimize2,
Workflow,
X,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
FLOW_NODE_TYPE_META,
FLOW_NODE_W,
layoutFlowGraph,
type FlowGraphData,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
interface FlowGraphPanelProps {
data: FlowGraphData;
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
streaming: boolean;
onClose: () => void;
/** 沙箱里有 md 报告时提供:工具栏显示「MD 报告」按钮,点击切回 Markdown 预览。 */
onSwitchToReport?: () => void;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
function FlowCardNode({ data }: NodeProps) {
const node = (data as { node?: FlowGraphNode; scale?: number }).node;
const scale = (data as { node?: FlowGraphNode; scale?: number }).scale ?? 1;
if (!node) return null;
const meta = FLOW_NODE_TYPE_META[node.type];
// 节点宽度随容器宽度缩放(宽时放大、窄时缩小)。
const scaledWidth = FLOW_NODE_W * scale;
return (
<div
style={{
background: meta.bg,
borderColor: meta.border,
color: meta.text,
width: scaledWidth,
}}
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" />
<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` }}>
{meta.label}
</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" />
</div>
);
}
// 必须模块级常量:每次 render 新建会让 React Flow 整树重挂。
const NODE_TYPES = { flowCard: FlowCardNode };
// ── 画布内层(需在 ReactFlowProvider 内才能 useReactFlow)──────────────────
function FlowCanvasInner({
data,
streaming,
fullscreen,
onToggleFullscreen,
onClose,
onSwitchToReport,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const fitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。
const manualPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
// 监听容器宽度:宽度变化时按比例缩放节点(变宽→放大、变窄→缩小),并重新 fitView 居中。
// 700px 基准 1.0,clamp 到 [0.7, 1.4]:拖宽到 ~1000px 节点放大、窄屏自动缩小。
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const resizeObs = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width;
if (width <= 0) return;
setScale(Math.min(1.4, Math.max(0.7, width / 700)));
if (fitTimerRef.current) clearTimeout(fitTimerRef.current);
fitTimerRef.current = setTimeout(() => {
void rf.fitView({ duration: 200, padding: 0.15 });
}, 120);
});
resizeObs.observe(el);
return () => {
resizeObs.disconnect();
if (fitTimerRef.current) clearTimeout(fitTimerRef.current);
};
}, [rf]);
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
const derivedNodes: Node[] = useMemo(
() =>
layoutNodes.map((n) => ({
id: n.id,
type: "flowCard",
// 节点随 scale 放大 → 布局坐标同步乘 scale,间距一起拉开,避免同层重叠。
position: manualPosRef.current.get(n.id) ?? { x: n.x * scale, y: n.y * scale },
data: { node: n, scale },
})),
[layoutNodes, scale],
);
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
useEffect(() => setNodes(derivedNodes), [derivedNodes]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((nds) => applyNodeChanges(changes, nds));
for (const c of changes) {
if (c.type === "position" && c.position) manualPosRef.current.set(c.id, c.position);
}
}, []);
// 流式增量绘制:最新节点的连线用蚂蚁线动画(React Flow animated = marching ants)。
const newestNodeId = data.nodes[data.nodes.length - 1]?.id;
const edges: Edge[] = useMemo(
() =>
data.edges.map((e) => {
const active = streaming && (e.to === newestNodeId || e.from === newestNodeId);
const color = active ? "#3b82f6" : "#94a3b8";
return {
id: `e-${e.from}->${e.to}`,
source: e.from,
target: e.to,
label: e.label,
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 },
} satisfies Edge;
}),
[data.edges, streaming, newestNodeId],
);
// 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。
useEffect(() => {
if (!streaming) return;
const t = setTimeout(() => {
void rf.fitView({ duration: 250, padding: 0.18 });
}, 80);
return () => clearTimeout(t);
}, [data.nodes.length, streaming, rf]);
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="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<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">
{data.title || "方案流程图"}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500">
{data.nodes.length} 节点 · {data.edges.length} 连线
</span>
{streaming && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-600">
<Loader2 className="h-3 w-3 animate-spin" />
绘制中
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{onSwitchToReport && (
<button
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"
>
<FileText className="h-3.5 w-3.5" />
MD 报告
</button>
)}
<ToolButton title="放大" onClick={() => void rf.zoomIn({ duration: 150 })}>
<ZoomIn className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title="缩小" onClick={() => void rf.zoomOut({ duration: 150 })}>
<ZoomOut className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton
title="恢复原位置"
onClick={() => void rf.fitView({ duration: 250, padding: 0.18 })}
>
<LocateFixed className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title={fullscreen ? "退出全屏" : "全屏"} onClick={onToggleFullscreen}>
{fullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</ToolButton>
<ToolButton title="关闭流程图" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</ToolButton>
</div>
</div>
{/* 画布 */}
<div ref={containerRef} className="relative min-h-0 flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onNodeClick={(_, n) => setSelectedId(n.id)}
fitView
fitViewOptions={{ padding: 0.18 }}
minZoom={0.2}
maxZoom={2.5}
panOnDrag
zoomOnScroll
nodesConnectable={false}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="#cbd5e1" />
</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">
<span style={{ background: meta.dot }} className="size-1.5 rounded-full" />
{meta.label}
</span>
))}
</div>
</div>
{/* 节点详情弹窗 */}
{selectedNode && (
<NodeDetailModal
node={selectedNode}
data={data}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
/>
)}
</div>
);
}
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
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"
>
{children}
</button>
);
}
// ── 节点详情弹窗(类型 / 说明 / 上下游连接)─────────────────────────────────
function NodeDetailModal({
node,
data,
onClose,
onJump,
}: {
node: FlowGraphNode;
data: FlowGraphData;
onClose: () => void;
onJump: (id: string) => void;
}) {
const meta = FLOW_NODE_TYPE_META[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);
return (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]"
onClick={onClose}
>
<div
className="w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-3"
style={{ background: meta.bg, borderColor: meta.border }}
>
<div className="flex min-w-0 items-center gap-2">
<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 }}
>
{meta.label}
</span>
<h3 className="min-w-0 truncate text-sm font-bold" style={{ color: meta.text }}>
{node.label}
</h3>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-slate-500 hover:bg-white/60 hover:text-slate-700"
>
<X className="h-4 w-4" />
</button>
</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">
{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} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
)}
</div>
</div>
</div>
);
}
function ConnectionList({
title,
entries,
onJump,
}: {
title: string;
entries: Array<{ id: string; label: string; edgeLabel?: string }>;
onJump: (id: string) => void;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{entries.map((entry) => (
<button
key={`${title}-${entry.id}`}
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"
title="查看该节点详情"
>
{entry.label}
{entry.edgeLabel && (
<span className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500">
{entry.edgeLabel}
</span>
)}
</button>
))}
</div>
</div>
);
}
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
return (
<div
className={cn(
"flex flex-col bg-white",
// 非全屏=填满所在面板(Step3 放在右侧沙箱位,不覆盖左侧消息列表);全屏才整页覆盖。
fullscreen ? "fixed inset-0 z-[80]" : "relative h-full min-h-0 w-full",
)}
>
<ReactFlowProvider>
<FlowCanvasInner
data={data}
streaming={streaming}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((f) => !f)}
onClose={onClose}
onSwitchToReport={onSwitchToReport}
/>
</ReactFlowProvider>
</div>
);
}
@@ -35,7 +35,10 @@ function formatFileSize(bytes: number): string {
} }
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLocalSettings } from "@/core/settings";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars"; import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import { import {
clarificationKey, clarificationKey,
IntentClarificationCard, IntentClarificationCard,
@@ -51,6 +54,7 @@ import {
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
@@ -108,6 +112,11 @@ export function Step1Panel(props: Step1PanelProps) {
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } = const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]); useAutoScroll(step1ScrollRef, [interactiveMessages]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const messageMetrics = useDialogueSpeedMetrics(interactiveMessages);
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。 // 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>(""); const [inputText, setInputText] = useState<string>("");
// 待发送的本地附件(点发送时连同文本一起上传)。 // 待发送的本地附件(点发送时连同文本一起上传)。
@@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) {
<Step1Message <Step1Message
key={msg.id} key={msg.id}
msg={msg} msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady} intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId} pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections} intentClarificationSelections={intentClarificationSelections}
@@ -338,6 +348,11 @@ export function Step1Panel(props: Step1PanelProps) {
*/ */
interface Step1MessageProps { interface Step1MessageProps {
msg: InteractiveMessage; msg: InteractiveMessage;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的智能体发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
intentReady: object | null; intentReady: object | null;
pendingIntentClarificationMsgId: number | null; pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<string, IntentClarificationSelection>; intentClarificationSelections: Map<string, IntentClarificationSelection>;
@@ -354,6 +369,7 @@ interface Step1MessageProps {
function Step1Message({ function Step1Message({
msg, msg,
metric,
intentReady, intentReady,
pendingIntentClarificationMsgId, pendingIntentClarificationMsgId,
intentClarificationSelections, intentClarificationSelections,
@@ -520,6 +536,8 @@ function Step1Message({
)} )}
</div> </div>
)} )}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble> </MessageBubble>
); );
} }
@@ -44,9 +44,14 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */ /** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = false; const SHOW_BACKGROUND_SUSPEND = true;
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer"; import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -62,6 +67,13 @@ import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsC
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip"; import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
seatFilterForSender,
seatFilterLabel,
senderMatchesSeatFilter,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import type { import type {
AgentConfig, AgentConfig,
ParallelSeatView, ParallelSeatView,
@@ -94,6 +106,10 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>; agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null; selectedAgentId: number | null;
roles: RoleEntry[]; roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)──────────────────────────────────────────── // ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[]; parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[]; selectedParallelSeats: string[];
@@ -155,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs, agentConfigs,
selectedAgentId, selectedAgentId,
roles, roles,
seatFilter,
setSeatFilter,
parallelBatch, parallelBatch,
selectedParallelSeats, selectedParallelSeats,
isParallelRunning, isParallelRunning,
@@ -189,6 +207,42 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
// ─── 按席位筛选会商记录 ──────────────────────────────────────────────────
// 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像
// 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。
// 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。
const toggleSeatFilter = useCallback(
(filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter)),
[setSeatFilter],
);
const visibleDialogues = useMemo(
() =>
seatFilter
? step2RoundtableDialogues.filter((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
: step2RoundtableDialogues,
[seatFilter, step2RoundtableDialogues],
);
// 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。
useEffect(() => {
if (
seatFilter &&
!step2RoundtableDialogues.some((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
) {
setSeatFilter(null);
}
}, [seatFilter, step2RoundtableDialogues, setSeatFilter]);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换, // 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
@@ -456,6 +510,7 @@ export function Step2Panel(props: Step2PanelProps) {
{msg ? ( {msg ? (
<Step2Message <Step2Message
msg={msg} msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName} resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => { onFileCardClick={(path) => {
@@ -472,19 +527,55 @@ export function Step2Panel(props: Step2PanelProps) {
})} })}
</div> </div>
) : ( ) : (
step2RoundtableDialogues.map((msg) => ( <>
<Step2Message {/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
key={msg.id} {seatFilter && (
msg={msg} <div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
resolveAgentName={resolveAgentName} <span className="roundtable-chat-meta font-medium text-slate-600">
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} 仅显示{" "}
onFileCardClick={(path) => { <span className="font-bold text-primary">
if (msg.agentThreadId) { {seatFilterLabel(seatFilter)}
openArtifactPreview(msg.agentThreadId, path); </span>{" "}
} 的发言
}} </span>
/> <button
)) type="button"
onClick={() => setSeatFilter(null)}
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
>
<X className="size-3.5" />
显示全部
</button>
</div>
)}
{seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息
</div>
) : (
visibleDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
isAvatarSelected={
seatFilter !== null &&
senderMatchesSeatFilter(msg.sender, seatFilter)
}
onAvatarClick={() =>
toggleSeatFilter(seatFilterForSender(msg.sender))
}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))
)}
</>
)} )}
</div> </div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />} {!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
@@ -687,6 +778,18 @@ export function Step2Panel(props: Step2PanelProps) {
*/ */
export interface Step2MessageProps { export interface Step2MessageProps {
msg: Step2Dialogue; msg: Step2Dialogue;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
/**
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
* 场景)则头像为纯展示,保持原样。
*/
onAvatarClick?: () => void;
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
isAvatarSelected?: boolean;
/** 把派活 chip 的 agent id 解析成中文席位名。 */ /** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string; resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: ( onArtifactStepClick?: (
@@ -698,6 +801,9 @@ export interface Step2MessageProps {
export function Step2Message({ export function Step2Message({
msg, msg,
metric,
onAvatarClick,
isAvatarSelected = false,
resolveAgentName, resolveAgentName,
onArtifactStepClick, onArtifactStepClick,
onFileCardClick, onFileCardClick,
@@ -712,9 +818,32 @@ export function Step2Message({
const hasContent = content.length > 0; const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps); const presentedFiles = collectPresentedFiles(msg.steps);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
const avatar = onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
aria-pressed={isAvatarSelected}
title={
isAvatarSelected
? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return ( return (
<MessageBubble <MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")} avatar={avatar}
senderName={msg.sender} senderName={msg.sender}
badge={msg.confidence} badge={msg.confidence}
time={msg.time} time={msg.time}
@@ -749,6 +878,8 @@ export function Step2Message({
disabled={!msg.agentThreadId} disabled={!msg.agentThreadId}
/> />
)} )}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble> </MessageBubble>
); );
} }
@@ -43,6 +43,11 @@ interface Step2SandboxLayoutProps {
* 对齐主聊天 chats/:threadId 的实时预览体验。 * 对齐主聊天 chats/:threadId 的实时预览体验。
*/ */
seatMessages?: unknown[]; seatMessages?: unknown[];
/**
* 右侧面板的替代内容(如 Step3 流程图画布)——非空时强制展开右栏并替换沙箱预览,
* 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。
*/
sidePanel?: React.ReactNode;
} }
export function Step2SandboxLayout({ export function Step2SandboxLayout({
@@ -52,6 +57,7 @@ export function Step2SandboxLayout({
seatMessages, seatMessages,
artifactsFill = false, artifactsFill = false,
onArtifactsClose, onArtifactsClose,
sidePanel,
}: Step2SandboxLayoutProps) { }: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const { const {
@@ -64,11 +70,13 @@ export function Step2SandboxLayout({
const artifacts = rawArtifacts ?? []; const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen; const artifactPanelOpen = artifactsOpen;
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
const panelOpen = artifactPanelOpen || !!sidePanel;
useEffect(() => { useEffect(() => {
if (artifactsFill || !layoutRef.current) return; if (artifactsFill || !layoutRef.current) return;
layoutRef.current.setLayout(artifactPanelOpen ? OPEN_MODE : CLOSE_MODE); layoutRef.current.setLayout(panelOpen ? OPEN_MODE : CLOSE_MODE);
}, [artifactPanelOpen, artifactsFill]); }, [panelOpen, artifactsFill]);
const handleArtifactsClose = () => { const handleArtifactsClose = () => {
deselect(); deselect();
@@ -151,7 +159,7 @@ export function Step2SandboxLayout({
if (artifactsFill) { if (artifactsFill) {
return ( return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}> <ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{artifactPanelBody}</div> <div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{sidePanel ?? artifactPanelBody}</div>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
} }
@@ -172,17 +180,17 @@ export function Step2SandboxLayout({
id="roundtable-step2-separator" id="roundtable-step2-separator"
className={cn( className={cn(
"opacity-33 hover:opacity-100", "opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0", !panelOpen && "pointer-events-none opacity-0",
)} )}
/> />
<ResizablePanel <ResizablePanel
className={cn( className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out", "bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0", !panelOpen && "opacity-0",
)} )}
id="artifacts" id="artifacts"
> >
{artifactPanelBody} {sidePanel ?? artifactPanelBody}
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ThreadContext.Provider> </ThreadContext.Provider>
@@ -22,6 +22,7 @@ import {
Loader2, Loader2,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
Pause,
RefreshCw, RefreshCw,
SendHorizontal, SendHorizontal,
Sparkles, Sparkles,
@@ -29,6 +30,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
buildArtifactDownloadUrl, buildArtifactDownloadUrl,
@@ -44,6 +46,7 @@ import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2Sandbo
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { import {
useStep3Report, useStep3Report,
type ReportIntent, type ReportIntent,
@@ -119,6 +122,9 @@ export function Step3ReportMode({
start, start,
hydrateSaved, hydrateSaved,
redraw, redraw,
pause,
interject,
themeMismatch,
abort, abort,
openArtifactFromStep, openArtifactFromStep,
openArtifactPreview, openArtifactPreview,
@@ -178,7 +184,9 @@ export function Step3ReportMode({
? "绘制完成" ? "绘制完成"
: phase === "error" : phase === "error"
? "绘制失败" ? "绘制失败"
: "待绘制"; : phase === "paused"
? "已暂停"
: "待绘制";
return ( 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">
@@ -196,6 +204,17 @@ export function Step3ReportMode({
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(phase === "creating" || phase === "streaming") && (
<button
type="button"
onClick={pause}
title="暂停绘制(沙箱保留已生成内容,之后可输入修改要求继续或重新绘制)"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
暂停
</button>
)}
<button <button
type="button" type="button"
onClick={() => redraw()} onClick={() => redraw()}
@@ -311,8 +330,9 @@ export function Step3ReportMode({
phase={phase} phase={phase}
dialogues={dialogues} dialogues={dialogues}
error={error} error={error}
onRetry={redraw} themeMismatch={themeMismatch}
onRevisionSubmit={redraw} onRetry={() => redraw()}
onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep} onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel} selectedModel={selectedModel}
@@ -433,6 +453,7 @@ function AgentProgressPanel({
phase, phase,
dialogues, dialogues,
error, error,
themeMismatch,
onRetry, onRetry,
onRevisionSubmit, onRevisionSubmit,
onArtifactStepClick, onArtifactStepClick,
@@ -445,6 +466,7 @@ function AgentProgressPanel({
phase: ReturnType<typeof useStep3Report>["phase"]; phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"]; dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null; error: string | null;
themeMismatch: boolean;
onRetry: () => void; onRetry: () => void;
onRevisionSubmit: (revisionInstruction: string) => void; onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"]; onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
@@ -455,14 +477,21 @@ function AgentProgressPanel({
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
}) { }) {
const busy = phase === "creating" || phase === "streaming"; const busy = phase === "creating" || phase === "streaming";
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [revisionText, setRevisionText] = useState(""); const [revisionText, setRevisionText] = useState("");
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => { const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const text = revisionText.trim(); const text = revisionText.trim();
if (!text || busy) return; if (!text || inputLocked) return;
setRevisionText(""); setRevisionText("");
onRevisionSubmit(text); onRevisionSubmit(text);
}; };
@@ -525,6 +554,7 @@ function AgentProgressPanel({
<Step2Message <Step2Message
key={msg.id} key={msg.id}
msg={msg} msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)} onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => { onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -554,12 +584,35 @@ function AgentProgressPanel({
</div> </div>
)} )}
{phase === "paused" && (
<div className="flex items-center gap-2 text-xs font-bold text-amber-600">
<Pause className="w-4 h-4" />
已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。
</div>
)}
{phase === "done" && ( {phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600"> <div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。 已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div> </div>
)} )}
{themeMismatch && (
<div className="flex items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<span className="leading-relaxed">
界面主题已切换,这份报告还是旧主题的配色。
</span>
<button
type="button"
onClick={onRetry}
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-bold text-amber-700 underline hover:text-amber-800"
>
<RefreshCw className="w-3 h-3" />
按当前主题重新绘制
</button>
</div>
)}
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form <form
@@ -576,18 +629,28 @@ function AgentProgressPanel({
e.currentTarget.form?.requestSubmit(); e.currentTarget.form?.requestSubmit();
} }
}} }}
disabled={busy} disabled={inputLocked}
rows={1} rows={1}
placeholder="输入修改要求,例如:把右侧页面改成深色风格,并增加风险矩阵" placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求继续"
: "输入修改要求,例如:增加风险矩阵,执行路线改成时间轴"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/> />
<button <button
type="submit" type="submit"
disabled={busy || revisionText.trim().length === 0} disabled={inputLocked || revisionText.trim().length === 0}
title={busy ? "正在绘制,请稍后发送" : "发送修改要求"} title={
inputLocked
? "正在初始化线程,请稍候"
: busy
? "发送修改要求(将打断当前绘制并按新要求继续)"
: "发送修改要求"
}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50" className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
> >
{busy ? ( {inputLocked ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<SendHorizontal className="h-4 w-4" /> <SendHorizontal className="h-4 w-4" />
@@ -26,10 +26,12 @@ import {
RefreshCw, RefreshCw,
SendHorizontal, SendHorizontal,
Sparkles, Sparkles,
Workflow,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
buildArtifactDownloadUrl, buildArtifactDownloadUrl,
@@ -39,6 +41,13 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
stripFlowFence,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -46,6 +55,7 @@ import { markdownComponents } from "@/roundtable-planning/components/markdown-co
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary"; import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
@@ -137,18 +147,87 @@ export function Step3SummaryMode({
const [modal, setModal] = useState<SourceModal>(null); const [modal, setModal] = useState<SourceModal>(null);
const { open: artifactsOpen } = useArtifacts(); const { open: artifactsOpen } = useArtifacts();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
if (artifactsOpen !== prevArtifactsOpenRef.current) {
prevArtifactsOpenRef.current = artifactsOpen;
setLeftCollapsed(artifactsOpen);
}
}, [artifactsOpen]);
// 进入即自动开跑;草稿里已存结果则恢复展示。卸载中断在途请求(理由同 Step3ReportMode)。 // ── 结构化流程图:流式提取回复末尾的 flow-json,增量绘制 ──────────────────
// 取最后一条含 flow-json 的智能体气泡(多轮修改后以最新一份为准)。
const flowGraph = useMemo(() => {
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) continue;
const graph = extractFlowGraph(content);
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
}, [dialogues]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (savedSummary?.md) { if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。
if (!artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file") continue;
const path =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
const content =
typeof (tc.args as { content?: unknown })?.content === "string"
? ((tc.args as { content?: string }).content as string)
: "";
if (/\.md$/i.test(path) && content.trim()) return { threadId, path };
}
}
return null;
}, [seatStubMessages]);
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
const handleAsk = (message: string, files?: File[]) => {
if (detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
// 气泡里不展示生 JSON:把 flow-json 块替换成一句引导语。
const displayDialogues = useMemo(
() =>
dialogues.map((d) => {
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) return d;
return { ...d, content: stripFlowFence(content, d.streaming === true) };
}),
[dialogues],
);
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const prevRightPanelVisibleRef = useRef(rightPanelVisible);
useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
prevRightPanelVisibleRef.current = rightPanelVisible;
setLeftCollapsed(rightPanelVisible);
}
}, [rightPanelVisible]);
// 进入即自动开跑;草稿里已存结果则恢复展示(流程图模式的快照可能只有 closing 没有 md)。
// 卸载中断在途请求(理由同 Step3ReportMode)。
useEffect(() => {
if (savedSummary && (savedSummary.md.trim() || savedSummary.closing?.trim())) {
hydrateSaved(savedSummary); hydrateSaved(savedSummary);
} else { } else {
start(); start();
@@ -295,20 +374,39 @@ export function Step3SummaryMode({
)} )}
</aside> </aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */} {/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden"> (sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout <Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId} activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming} isStreaming={isStreaming}
seatMessages={activeSeatMessages} seatMessages={activeSeatMessages}
sidePanel={
flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
/>
) : undefined
}
> >
<AgentProgressPanel <AgentProgressPanel
phase={phase} phase={phase}
dialogues={dialogues} dialogues={displayDialogues}
error={error} error={error}
busy={busy} busy={busy}
mdAvailable={!!mdArtifact}
onRetry={redraw} onRetry={redraw}
onAsk={ask} onAsk={handleAsk}
onPause={pause} onPause={pause}
onArtifactStepClick={openArtifactFromStep} onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
@@ -318,6 +416,27 @@ export function Step3SummaryMode({
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
/> />
</Step2SandboxLayout> </Step2SandboxLayout>
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
{flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section> </section>
</div> </div>
@@ -427,6 +546,7 @@ function AgentProgressPanel({
dialogues, dialogues,
error, error,
busy, busy,
mdAvailable,
onRetry, onRetry,
onAsk, onAsk,
onPause, onPause,
@@ -441,6 +561,8 @@ function AgentProgressPanel({
dialogues: ReturnType<typeof useStep3Summary>["dialogues"]; dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
error: string | null; error: string | null;
busy: boolean; busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
mdAvailable: boolean;
onRetry: () => void; onRetry: () => void;
onAsk: (message: string, files?: File[]) => void; onAsk: (message: string, files?: File[]) => void;
onPause: () => void; onPause: () => void;
@@ -453,6 +575,10 @@ function AgentProgressPanel({
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [askText, setAskText] = useState(""); const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。 // 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]); const [askFiles, setAskFiles] = useState<File[]>([]);
@@ -535,6 +661,7 @@ function AgentProgressPanel({
<Step2Message <Step2Message
key={msg.id} key={msg.id}
msg={msg} msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)} onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => { onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -545,7 +672,9 @@ function AgentProgressPanel({
{busy && ( {busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500"> <div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" /> <span className="bg-primary size-1.5 animate-pulse rounded-full" />
报告正文实时输出,右侧沙箱同步预览 Markdown {mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div> </div>
)} )}
@@ -567,7 +696,9 @@ function AgentProgressPanel({
{phase === "done" && ( {phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600"> <div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续提修改意见或提问。 {mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
</div> </div>
)} )}
</div> </div>
@@ -612,7 +743,7 @@ function AgentProgressPanel({
placeholder={ placeholder={
busy busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…" ? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:提修改意见(如「执行路线写细些」)/ 提问 / 上传参考文件" : "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
} }
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none" className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/> />
@@ -0,0 +1,153 @@
/**
* 圆桌各步消息气泡的「回答速度指示器」度量 —— 对齐主聊天
* `components/workspace/messages/use-message-metrics.ts` 的算法。Step 1 的
* `InteractiveMessage`、Step 2 / Step 3 的 `Step2Dialogue` 在「id + content +
* streaming」上同形,故本 hook 只依赖这三字段的最小形状,三步通用:
*
* - 圆桌气泡没有 `usage_metadata`,统一走字符估算(≈ 2 字符 / token)。
* - 以「这条气泡是否 **流式过**」作为「它是不是真正的模型生成」的判据:
* 只有 `streaming === true` 出现过的气泡才建度量项。用户提问 / 系统播报 /
* 澄清回复这类一次性整条推入、从不流式的气泡天然不会有速度胶囊。
* - 流式中用滑动窗口算实时 tok/s;流式结束(streaming 翻 false)冻结时长与速率。
*
* 返回 `Map<id, MessageMetric>`,复用主聊天的 `MessageMetric` 形状,
* 以便直接喂给同一个 `SpeedPill`。
*/
import { useEffect, useRef, useState } from "react";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
/** 本 hook 需要的最小气泡形状(Step 1/2/3 的消息类型都满足)。 */
export interface SpeedTrackableMessage {
id: number;
content?: string;
streaming?: boolean;
}
/** 两字符 ≈ 一 token —— 与主聊天 / 站内其它处保持一致的粗估。 */
const CHARS_PER_TOKEN = 2;
/** 流式期间的采样节流。 */
const SAMPLE_THROTTLE_MS = 60;
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
};
function contentLength(dialogue: SpeedTrackableMessage): number {
return (dialogue.content ?? "").length;
}
/**
* 返回按 dialogue id 索引的实时度量快照:流式中实时刷新,结束后冻结。
*/
export function useDialogueSpeedMetrics(
dialogues: SpeedTrackableMessage[],
): Map<number, MessageMetric> {
const timingRef = useRef<Map<number, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// 采样推入时强制重算 —— 与主聊天同理:timing map 是 ref,直接改不会触发渲染。
const [, setTick] = useState(0);
useEffect(() => {
const now = performance.now();
for (const dialogue of dialogues) {
const streaming = dialogue.streaming === true;
let entry = timingRef.current.get(dialogue.id);
// 仅当气泡 **正在流式** 时才开始跟踪 —— 这是「真模型生成」的信号;
// 已完成才出现(系统播报 / 插话 / 澄清回复)的气泡不建项,也就不出胶囊。
if (!entry) {
if (!streaming) continue;
const chars = contentLength(dialogue);
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars }],
lastChars: chars,
};
timingRef.current.set(dialogue.id, entry);
continue;
}
const chars = contentLength(dialogue);
if (streaming) {
const grew = chars !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars });
entry.lastChars = chars;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
entry.lastChars = chars;
}
} else if (entry.endedAt === null) {
// 流式结束 → 冻结时长,并以最终内容长度收口。
entry.endedAt = now;
entry.lastChars = chars;
}
}
setTick((v) => v + 1);
}, [dialogues]);
// 仍有气泡在流式时慢速 tick,让实时时长 / 速率平滑刷新。
const anyStreaming = dialogues.some((d) => d.streaming === true);
useEffect(() => {
if (!anyStreaming) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [anyStreaming]);
const out = new Map<number, MessageMetric>();
const now = performance.now();
for (const dialogue of dialogues) {
const entry = timingRef.current.get(dialogue.id);
if (!entry) continue;
const isActive = entry.endedAt === null && dialogue.streaming === true;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
let tokensPerSec = 0;
if (isActive && entry.samples.length >= 2) {
// 滑动窗口:取最近 ~1.5s 的样本,反映当前速度而非全程平均。
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = ((chars / elapsedMs) * 1000) / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// 已完成:按全程平均。
tokensPerSec = ((entry.lastChars / durationMs) * 1000) / CHARS_PER_TOKEN;
}
out.set(dialogue.id, {
tokensPerSec,
durationMs,
isActive,
// 圆桌气泡无 usage_metadata,速率永远是字符估算。
fromUsage: false,
// 圆桌每条气泡就是一位席位的一次发言,逐条都展示(无需「turn-final」收敛)。
isTurnFinal: true,
});
}
return out;
}
@@ -17,6 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useTheme } from "@/contexts/ThemeContext";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { import {
@@ -48,7 +49,13 @@ export interface ReportSeatDelivery {
content: string; content: string;
} }
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error"; export type ReportPhase =
| "idle"
| "creating"
| "streaming"
| "done"
| "error"
| "paused";
interface UseStep3ReportOptions { interface UseStep3ReportOptions {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
@@ -77,6 +84,15 @@ export interface UseStep3ReportReturn {
hydrateSaved: (snapshot: DraftStep3Snapshot) => void; hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
/** 重新绘制(先中断在途请求再重跑)。 */ /** 重新绘制(先中断在途请求再重跑)。 */
redraw: (revisionInstruction?: string) => void; redraw: (revisionInstruction?: string) => void;
/** 暂停:中断在途绘制并把气泡冻结为「已暂停」(沙箱保留已写内容)。 */
pause: () => void;
/**
* 插话 / 修改要求(随时可发):绘制中先打断再发;报告线程存活时免带研讨原料
* 与旧 HTML 直接续同一线程,线程不在(草稿恢复等)才回落全量 prompt。
*/
interject: (message: string) => void;
/** 当前展示的报告主题与界面主题不一致(建议重新绘制以匹配)。 */
themeMismatch: boolean;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */ /** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void; abort: () => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */ /** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
@@ -92,15 +108,22 @@ function buildReportPrompt(opts: {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[]; seatDeliveries: ReportSeatDelivery[];
consensusText: string; consensusText: string;
/** 当前界面主题——深色时报告改用深蓝设计令牌绘制(与 SOUL 的 4a/4b 主题约定对应)。 */
themeMode: "light" | "dark";
revisionInstruction?: string; revisionInstruction?: string;
previousHtml?: string; previousHtml?: string;
}): string { }): string {
const { intentReady, seatDeliveries, consensusText, revisionInstruction, previousHtml } = opts; const { intentReady, seatDeliveries, consensusText, themeMode, revisionInstruction, previousHtml } =
opts;
const parts: string[] = []; const parts: string[] = [];
parts.push( parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," + "请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。", "用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
); );
// 模型不知道当下日期——报告头的「生成时间」由前端注入,避免它编一个占位日期。
parts.push(
`本次报告生成时间:${new Date().toLocaleString("zh-CN", { dateStyle: "long", timeStyle: "short" })}(请原样写进报告头的生成时间处)。`,
);
parts.push( parts.push(
"目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," + "目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," +
"再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" + "再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" +
@@ -154,10 +177,33 @@ function buildReportPrompt(opts: {
} }
} }
// 页面主题跟随当前界面的日/夜间模式:浅色保持原浅红效果,深色按「深蓝 UI 规范」令牌绘制。
// 令牌在 prompt 里再列一遍(而不只靠 SOUL 的 4b 节),确保已落盘 SOUL 未同步时深色也生效。
parts.push("# 页面主题");
if (themeMode === "dark") {
parts.push(
"本次绘制使用【深色(深蓝)主题】——用户当前界面为深色模式。整页必须深蓝底、浅色字," +
"严格按你职责设定「深色主题(深蓝)」的设计令牌取色:" +
"背景层级 页面 #060E24 → 容器 #0C1836 → 卡片 #122347(边框 1px #1A2F5A,圆角 4px,阴影 0 2px 8px rgba(22,119,255,.12));" +
"文字 标题 #FFFFFF / 正文 #C5CFE0 / 次级 #6E88B0 / 弱化 #4A6491;" +
"主色 #1677FF、辅色青 #13C2C2、操作强调 #4096FF;报告头标题下用发光分割线 linear-gradient(90deg, transparent, #1677FF, transparent),普通分割线 #2D4473;" +
"图表系列色序依次 #1677FF #13C2C2 #4096FF #36CFC9 #69B1FF #5CDBD3 #91CAFF #87E8DE,坐标轴/网格线 #2D4473、轴文字 #6E88B0;" +
"语义色 成功 #52C41A / 警告 #FAAD14 / 风险·错误 #F5222D / 信息 #1677FF / 处理中 #13C2C2;" +
"KPI 卡 卡底 #122347、大数值 #FFFFFF、标签 #C5CFE0。" +
"禁止使用浅红体系(#c25555 / #fef4f3);若底稿 HTML 是浅色配色,必须把整页配色重绘成上述深色。",
);
} else {
parts.push(
"本次绘制使用【浅色(浅红)主题】:主色 #c25555、浅底 #fef4f3、描边 #ede9e9、文字 #1f2937" +
"(即你职责设定里的默认浅色主题)。若底稿 HTML 是深色配色,必须把整页配色重绘成浅色。",
);
}
parts.push("");
parts.push( parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" + "请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" + "(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" + "文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、页面配色严格按上文「页面主题」执行;" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。", "写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
); );
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。 // 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
@@ -168,6 +214,23 @@ function buildReportPrompt(opts: {
return parts.join("\n"); return parts.join("\n");
} }
/**
* 报告线程仍存活时的插话 / 修订 prompt:不重复贴研讨原料与旧 HTML——线程上下文里
* 都有,token 直接省一半以上;让智能体必要时 read_file 自己写过的底稿找回细节。
*/
function buildInterjectPrompt(message: string, themeMode: "light" | "dark"): string {
const themeLine = themeMode === "dark" ? "深色(深蓝)" : "浅色(浅红)";
return [
"用户对当前的方案总览 HTML 提出了新的修改要求:",
message,
"",
"请在你本线程里已写的 /mnt/user-data/outputs/方案总览.html 基础上落实修改" +
"(必要时可先 read_file 该文件找回最新内容;若该文件尚不存在或只写了一半,就按原始要求重新完整绘制一份)," +
`然后用 write_file 把修改后的完整 HTML 整文件重写到同一路径。页面保持【${themeLine}】主题配色。` +
"写完只用一两句话说明本次改了什么,不要把 HTML 源码贴进对话。",
].join("\n");
}
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */ /** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string { function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) { switch (phase) {
@@ -195,6 +258,9 @@ export function useStep3Report(
const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } = const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } =
options; options;
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。 // onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved); const onReportSavedRef = useRef(onReportSaved);
useEffect(() => { useEffect(() => {
@@ -221,6 +287,12 @@ export function useStep3Report(
const stubRef = useRef<Record<string, StubAiMessage[]>>({}); const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false); const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null); const aborterRef = useRef<AbortController | null>(null);
// 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话
// 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。
const liveThreadRef = useRef<string | null>(null);
const agentNameRef = useRef<string>("roundtable-report");
// 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。
const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null);
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。 // 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
const bubbleIdRef = useRef<number | null>(null); const bubbleIdRef = useRef<number | null>(null);
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过; // 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
@@ -305,6 +377,30 @@ export function useStep3Report(
[], [],
); );
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(修改要求)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
/** 把所有仍在流式的气泡冻结(暂停 / 插话打断时用),正文保留已收到的部分。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
const finalizeDialogue = useCallback( const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => { (id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) => setDialogues((prev) =>
@@ -620,6 +716,93 @@ export function useStep3Report(
runningRef.current = false; runningRef.current = false;
}, []); }, []);
/** 单轮 special 流式(带一次自动重试):失败且非用户中断时同线程重发一次再抛。 */
const streamRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
agentName: string;
prompt: string;
aborter: AbortController;
}) => {
const { bubbleId, threadId, agentName, prompt, aborter } = args;
for (let attempt = 0; ; attempt += 1) {
try {
return await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, toolArgs) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, toolArgs);
handleArtifactToolPhase(p, toolName, toolArgs, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
} catch (err) {
// 写大 HTML 偶发超时 / 断流:非用户中断时自动重试一次,重试仍失败才抛给上层。
if (aborter.signal.aborted || attempt >= 1) throw err;
appendStepIfNew(bubbleId, "生成中断,自动重试中", `retry:${attempt + 1}`);
setDialogueConfidence(bubbleId, "自动重试中");
}
}
},
[
selectedModel,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
],
);
/** 一轮结束后的公共收尾:兜底开沙箱 → 对账真实文件回灌 stub → 完成气泡 → 存草稿。 */
const finishRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
aborter: AbortController;
closing: string;
}) => {
const { bubbleId, threadId, aborter, closing } = args;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done");
setReportTheme(theme);
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: closing || "",
themeMode: theme,
});
}
},
[
theme,
selectedModel,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
],
);
const run = useCallback(async (revisionInstruction?: string) => { const run = useCallback(async (revisionInstruction?: string) => {
if (runningRef.current) return; if (runningRef.current) return;
runningRef.current = true; runningRef.current = true;
@@ -628,6 +811,7 @@ export function useStep3Report(
setDialogues([]); setDialogues([]);
bubbleIdRef.current = null; bubbleIdRef.current = null;
lastAutoOpenedUrlRef.current = null; lastAutoOpenedUrlRef.current = null;
liveThreadRef.current = null;
stubRef.current = {}; stubRef.current = {};
setSeatStubMessages({}); setSeatStubMessages({});
@@ -644,6 +828,8 @@ export function useStep3Report(
try { try {
const { agentName, threadId } = await initReportThread({ signal: aborter.signal }); const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
liveThreadRef.current = threadId;
agentNameRef.current = agentName;
setReportThreadId(threadId); setReportThreadId(threadId);
setActiveArtifactThreadId(threadId); setActiveArtifactThreadId(threadId);
setDialogues((prev) => setDialogues((prev) =>
@@ -655,50 +841,13 @@ export function useStep3Report(
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
themeMode: theme,
revisionInstruction, revisionInstruction,
previousHtml: savedReport?.html, previousHtml: savedReport?.html,
}); });
const res = await streamMultiAgent({ const res = await streamRound({ bubbleId, threadId, agentName, prompt, aborter });
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, args) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args);
handleArtifactToolPhase(p, toolName, args, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
await finishRound({ bubbleId, threadId, aborter, closing: res.content });
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: res.content || "",
});
}
} catch (err) { } catch (err) {
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -706,25 +855,118 @@ export function useStep3Report(
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败"); finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error"); setPhase("error");
} finally { } finally {
runningRef.current = false; // 插话可能已打断本轮并接管 runningRef —— 只有自己还是当前请求时才释放。
if (aborterRef.current === aborter) runningRef.current = false;
} }
}, [ }, [
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
theme,
savedReport?.html, savedReport?.html,
selectedModel,
appendStreamingDialogue, appendStreamingDialogue,
appendToDialogue, streamRound,
setDialogueConfidence, finishRound,
appendStepIfNew,
finalizeDialogue, finalizeDialogue,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
]); ]);
/** 暂停:中断在途绘制,流式气泡冻结为「已暂停」,沙箱保留已写内容。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
abort();
freezeStreaming("已暂停");
setPhase("paused");
}, [abort, freezeStreaming]);
/**
* 插话 / 修改要求(随时可发):
* - 绘制中:先打断当前轮(气泡冻结为「已打断」),再发修改要求;
* - 报告线程存活:免带研讨原料与旧 HTML,直接续同一线程(智能体上下文里都有,
* 必要时它会 read_file 自己写过的底稿);
* - 线程不在(草稿恢复 / 首轮线程没建成):新建线程,回落「原料 + 旧 HTML 底稿」全量 prompt。
*/
const interject = useCallback(
async (message: string) => {
const text = message.trim();
if (!text) return;
const wasBusy = runningRef.current;
abort();
if (wasBusy) freezeStreaming("已打断");
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
const bubbleId = appendStreamingDialogue({
sender: "方案总结智能体",
avatarType: "blue",
});
bubbleIdRef.current = bubbleId;
try {
let threadId = liveThreadRef.current;
let prompt: string;
if (threadId) {
prompt = buildInterjectPrompt(text, theme);
} else {
setPhase("creating");
const init = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
liveThreadRef.current = threadId;
agentNameRef.current = init.agentName;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
prompt = buildReportPrompt({
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
revisionInstruction: text,
previousHtml: savedReport?.html,
});
}
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
setPhase("streaming");
const res = await streamRound({
bubbleId,
threadId,
agentName: agentNameRef.current,
prompt,
aborter,
});
if (aborter.signal.aborted) return;
await finishRound({ bubbleId, threadId, aborter, closing: res.content });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
setPhase("error");
} finally {
if (aborterRef.current === aborter) runningRef.current = false;
}
},
[
abort,
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
theme,
intentReady,
seatDeliveries,
consensusText,
savedReport?.html,
streamRound,
finishRound,
finalizeDialogue,
],
);
const start = useCallback((revisionInstruction?: string) => { const start = useCallback((revisionInstruction?: string) => {
void run(revisionInstruction); void run(revisionInstruction);
}, [run]); }, [run]);
@@ -735,6 +977,10 @@ export function useStep3Report(
(snap: DraftStep3Snapshot) => { (snap: DraftStep3Snapshot) => {
abort(); abort();
runningRef.current = false; runningRef.current = false;
// 合成线程不是真线程:插话时走「新建线程 + 旧 HTML 底稿」的回落路径。
liveThreadRef.current = null;
// 记录快照绘制主题(旧草稿可能没存 → null,不提示不匹配)。
setReportTheme(snap.themeMode ?? null);
const synthThreadId = "roundtable-report-restored"; const synthThreadId = "roundtable-report-restored";
const messageId = `roundtable-report-stub-${synthThreadId}`; const messageId = `roundtable-report-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`; const toolCallId = `tc-${synthThreadId}-0`;
@@ -797,6 +1043,10 @@ export function useStep3Report(
void run(revisionInstruction); void run(revisionInstruction);
}, [abort, run]); }, [abort, run]);
// 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。
const themeMismatch =
phase === "done" && reportTheme !== null && reportTheme !== theme;
return { return {
phase, phase,
error, error,
@@ -807,6 +1057,9 @@ export function useStep3Report(
start, start,
hydrateSaved, hydrateSaved,
redraw, redraw,
pause,
interject,
themeMismatch,
abort, abort,
openArtifactFromStep, openArtifactFromStep,
openArtifactPreview, openArtifactPreview,
@@ -40,6 +40,7 @@ import type {
Step2Dialogue, Step2Dialogue,
StubAiMessage, StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration"; } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */ /** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -108,6 +109,14 @@ function buildMaterials(opts: {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)"); parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp); parts.push(cp);
parts.push(""); parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
} }
if (intentReady?.objective) { if (intentReady?.objective) {
@@ -141,29 +150,60 @@ function buildMaterials(opts: {
return parts.join("\n"); return parts.join("\n");
} }
/** 首轮总结 prompt。 */ /** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: { function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[]; seatDeliveries: ReportSeatDelivery[];
consensusText: string; consensusText: string;
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
flowMode?: boolean;
}): string { }): string {
const parts: string[] = []; const parts: string[] = [];
parts.push( if (opts.flowMode) {
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," + parts.push(
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," + "请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。", "用户已明确要求结构化输出/流程图,因此本轮:" +
); "①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push(""); parts.push("");
parts.push(buildMaterials(opts)); parts.push(buildMaterials(opts));
parts.push( if (opts.flowMode) {
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" + parts.push(
"(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" + "请严格遵循你的职责设定:全程简体中文。" +
"若上面给了「业务链总控编排提示」,以它的要求优先组织报告。", "flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
); "每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n"); return parts.join("\n");
} }
/**
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */ /** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: { function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
@@ -172,8 +212,10 @@ function buildResumeAskPrompt(opts: {
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
previousMd: string; previousMd: string;
userMessage: string; userMessage: string;
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
flowIntent?: boolean;
}): string { }): string {
const { previousMd, userMessage, ...materials } = opts; const { previousMd, userMessage, flowIntent, ...materials } = opts;
const parts: string[] = []; const parts: string[] = [];
parts.push( parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," + "这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
@@ -192,6 +234,10 @@ function buildResumeAskPrompt(opts: {
} }
parts.push("# 用户的新消息"); parts.push("# 用户的新消息");
parts.push(userMessage.trim()); parts.push(userMessage.trim());
if (flowIntent) {
parts.push("");
parts.push(FLOW_ASK_INSTRUCTION);
}
return parts.join("\n"); return parts.join("\n");
} }
@@ -639,15 +685,19 @@ export function useStep3Summary(
); );
}, []); }, []);
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */ /**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback( const seedStubWithCurrentMd = useCallback(
(threadId: string) => { (threadId: string, opts?: { open?: boolean }) => {
const md = currentMdRef.current; const md = currentMdRef.current;
if (!md.trim()) return; if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", { const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: SUMMARY_OUTPUT_PATH, path: SUMMARY_OUTPUT_PATH,
content: md, content: md,
}); });
if (opts?.open === false) return;
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl); openArtifact(threadId, virtualUrl);
}, },
@@ -666,8 +716,10 @@ export function useStep3Summary(
seedThreadId?: string | null; seedThreadId?: string | null;
forceReconcile?: boolean; forceReconcile?: boolean;
files?: RunFileRef[]; files?: RunFileRef[];
/** 流程图轮:不自动打开 md 沙箱预览(用户要看的是流程图画布)。 */
flowRound?: boolean;
}): Promise<void> => { }): Promise<void> => {
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files } = args; const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
let threadId = seedThreadId ?? null; let threadId = seedThreadId ?? null;
let agentName = SUMMARY_AGENT_NAME; let agentName = SUMMARY_AGENT_NAME;
@@ -684,7 +736,8 @@ export function useStep3Summary(
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)), prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
); );
// 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。 // 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。
if (seedThreadId) seedStubWithCurrentMd(threadId); // 流程图轮只种不开沙箱——md 保留着供用户随时切回去看。
if (seedThreadId) seedStubWithCurrentMd(threadId, { open: !flowRound });
setPhase("streaming"); setPhase("streaming");
const res = await streamMultiAgent({ const res = await streamMultiAgent({
@@ -708,7 +761,8 @@ export function useStep3Summary(
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile && !seedThreadId) await openRealArtifactFallback(threadId); // 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。 // 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
const md = await reconcileStubFromRealFile(threadId, { const md = await reconcileStubFromRealFile(threadId, {
@@ -721,9 +775,10 @@ export function useStep3Summary(
setPhase("done"); setPhase("done");
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。 // 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
// 流程图模式可能全程没有 md——只要 closing 有内容(含 flow-json)也要存,恢复时才能重画流程图。
const finalMd = md.trim() || currentMdRef.current; const finalMd = md.trim() || currentMdRef.current;
if (finalMd.trim()) { if (finalMd.trim()) currentMdRef.current = finalMd;
currentMdRef.current = finalMd; if (finalMd.trim() || (res.content ?? "").trim()) {
onSummarySavedRef.current?.({ onSummarySavedRef.current?.({
md: finalMd, md: finalMd,
threadId, threadId,
@@ -768,13 +823,16 @@ export function useStep3Summary(
}); });
try { try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
const flowMode = detectFlowIntent(coordinatorPrompt);
const prompt = buildSummaryPrompt({ const prompt = buildSummaryPrompt({
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
coordinatorPrompt, coordinatorPrompt,
flowMode,
}); });
await streamOnce({ bubbleId, prompt, aborter }); await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
} catch (err) { } catch (err) {
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -807,6 +865,9 @@ export function useStep3Summary(
abort(); abort();
if (wasBusy) freezeStreaming("已打断"); if (wasBusy) freezeStreaming("已打断");
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
const flowIntent = detectFlowIntent(text);
runningRef.current = true; runningRef.current = true;
setError(null); setError(null);
const aborter = new AbortController(); const aborter = new AbortController();
@@ -850,8 +911,10 @@ export function useStep3Summary(
if (hadLiveThread) { if (hadLiveThread) {
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。 // 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
const prompt = // 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
const base =
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。"; text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
await streamOnce({ await streamOnce({
bubbleId, bubbleId,
prompt, prompt,
@@ -859,6 +922,7 @@ export function useStep3Summary(
seedThreadId: liveThreadRef.current, seedThreadId: liveThreadRef.current,
forceReconcile: true, forceReconcile: true,
files: fileRefs, files: fileRefs,
flowRound: flowIntent,
}); });
} else { } else {
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。 // 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
@@ -871,6 +935,7 @@ export function useStep3Summary(
previousMd: currentMdRef.current, previousMd: currentMdRef.current,
userMessage: userMessage:
text || "请阅读我上传的参考文件后,据此更新报告或回答。", text || "请阅读我上传的参考文件后,据此更新报告或回答。",
flowIntent,
}); });
await streamOnce({ await streamOnce({
bubbleId, bubbleId,
@@ -879,6 +944,7 @@ export function useStep3Summary(
seedThreadId: threadId, seedThreadId: threadId,
forceReconcile: true, forceReconcile: true,
files: fileRefs, files: fileRefs,
flowRound: flowIntent,
}); });
} }
} catch (err) { } catch (err) {
@@ -923,30 +989,35 @@ export function useStep3Summary(
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。 // 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。 // 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
// 流程图模式的快照可能没有 md(只有含 flow-json 的 closing):跳过 stub/沙箱,只重建气泡,
// 流程图由组件从气泡内容里重新解析绘制。
const hydrateSaved = useCallback( const hydrateSaved = useCallback(
(snap: DraftStep3SummarySnapshot) => { (snap: DraftStep3SummarySnapshot) => {
abort(); abort();
runningRef.current = false; runningRef.current = false;
const hasMd = snap.md.trim().length > 0;
const realThreadId = snap.threadId?.trim() || null; const realThreadId = snap.threadId?.trim() || null;
const synthThreadId = realThreadId ?? "roundtable-summary-restored"; const synthThreadId = realThreadId ?? "roundtable-summary-restored";
const messageId = `roundtable-summary-stub-${synthThreadId}`; const messageId = `roundtable-summary-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`; const toolCallId = `tc-${synthThreadId}-0`;
const stub: Record<string, StubAiMessage[]> = { const stub: Record<string, StubAiMessage[]> = hasMd
[synthThreadId]: [ ? {
{ [synthThreadId]: [
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ {
id: toolCallId, id: messageId,
name: "write_file", type: "ai",
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md }, content: "",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
},
],
}, },
], ],
}, }
], : {};
};
stubRef.current = stub; stubRef.current = stub;
currentMdRef.current = snap.md; currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId; liveThreadRef.current = realThreadId;
@@ -965,23 +1036,27 @@ export function useStep3Summary(
time: nowTime(), time: nowTime(),
confidence: "历史记录", confidence: "历史记录",
streaming: false, streaming: false,
steps: [ steps: hasMd
{ ? [
id: `${bubbleId}-0`, {
key: "tool_calling:write_file", id: `${bubbleId}-0`,
label: "已撰写报告 Markdown", key: "tool_calling:write_file",
ts: nowTime(), label: "已撰写报告 Markdown",
args: { path: SUMMARY_OUTPUT_PATH }, ts: nowTime(),
}, args: { path: SUMMARY_OUTPUT_PATH },
], },
]
: [],
agentThreadId: synthThreadId, agentThreadId: synthThreadId,
}, },
]); ]);
setError(null); setError(null);
setPhase("done"); setPhase("done");
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId); if (hasMd) {
openArtifact(synthThreadId, virtualUrl); const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
}
}, },
[abort, buildWriteFileVirtualUrl, openArtifact], [abort, buildWriteFileVirtualUrl, openArtifact],
); );
@@ -0,0 +1,141 @@
/**
* flow-graph.ts 纯函数单测。
* 运行方式(项目无 vitest,对齐 dag-types.test.ts):
* npx esbuild src/roundtable-planning/lib/flow-graph.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/flow-graph.test.mjs
* node --test .tmp-tests/flow-graph.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
detectFlowIntent,
extractFlowGraph,
layoutFlowGraph,
stripFlowFence,
} from "./flow-graph";
const FULL = [
"报告正文……",
"```flow-json",
'{"title":"方案执行流程","nodes":[',
'{"id":"n1","label":"环境评估","type":"stage","detail":"评估现场环境"},',
'{"id":"n2","label":"编制方案","type":"task","from":["n1"]},',
'{"id":"n3","label":"是否可行","type":"decision","from":[{"id":"n2","label":"评审"}]}',
"]}",
"```",
].join("\n");
test("extractFlowGraph:完整块解析出节点/边/标题", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "方案执行流程");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "评审" },
]);
});
test("extractFlowGraph:流式半截(fence 未闭合 + 尾对象截断)只取完整节点", () => {
const partial = [
"正文",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty', // 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph:未知 type 落 task、重复 id 去重、悬空边丢弃", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph:无 fence / 无完整节点返回 null", () => {
assert.equal(extractFlowGraph("普通回复,没有结构化数据"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"走"}]}',
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "走" }]);
});
test("stripFlowFence:闭合块替换为引导语并保留前后文", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("报告正文"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("已生成结构化流程数据"));
});
test("stripFlowFence:未闭合(流式中)用「正在输出」措辞", () => {
const out = stripFlowFence('正文\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("正在输出结构化流程数据"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph:按前驱分层(根 0 层,子层递增),保持原始顺序", () => {
const g = extractFlowGraph(FULL)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("detectFlowIntent:明确短语命中(编排提示 / 输入框消息)", () => {
assert.ok(detectFlowIntent("最终总结请用结构化输出"));
assert.ok(detectFlowIntent("总结成格式化输出"));
assert.ok(detectFlowIntent("给我画一张流程图"));
assert.ok(detectFlowIntent("输出可视化流程"));
assert.ok(detectFlowIntent("请按 flow-json 契约输出"));
assert.ok(detectFlowIntent("重点总结方案设计席位,最后给结构化数据"));
});
test("detectFlowIntent:普通措辞不误触发", () => {
assert.equal(detectFlowIntent("用表格把要点结构化呈现"), false);
assert.equal(detectFlowIntent("把执行路线写得更细一些"), false);
assert.equal(detectFlowIntent("面向高管汇报,控制在一页内"), false);
assert.equal(detectFlowIntent(""), false);
assert.equal(detectFlowIntent(null), false);
assert.equal(detectFlowIntent(undefined), false);
});
test("layoutFlowGraph:环不死循环", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```",
].join("\n");
const g = extractFlowGraph(text)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
@@ -0,0 +1,306 @@
/**
* Step 3「总结报告」结构化流程图 —— flow-json 契约的纯函数层。
*
* 契约(与 roundtable-summary SOUL 对齐):用户要求结构化输出/流程图时,总结智能体
* 在对话回复末尾追加一个 ```flow-json 代码块:
*
* ```flow-json
* {"title":"…","nodes":[
* {"id":"n1","label":"…","type":"stage","detail":"…"},
* {"id":"n2","label":"…","type":"task","detail":"…","from":["n1"]},
* {"id":"n3","label":"…","type":"decision","detail":"…","from":[{"id":"n2","label":"是"}]}
* ]}
* ```
*
* 关键设计:**边长在节点身上**(`from` = 前驱列表)——流式输出时每解析出一个完整
* 节点,它的连线随之出现,前端给「最新节点的连线」挂蚂蚁线动画即可实现「画到哪、
* 哪条线在爬」。解析必须**容错半截 JSON**(流式中代码块尚未闭合):用平衡括号扫描
* 把 nodes 数组里**已完整**的对象逐个 JSON.parse,截断的尾对象直接忽略。
*/
export type FlowNodeType =
| "start"
| "end"
| "stage"
| "task"
| "decision"
| "risk"
| "deliverable"
| "role";
export interface FlowGraphNode {
id: string;
label: string;
type: FlowNodeType;
detail?: string;
}
export interface FlowGraphEdge {
from: string;
to: string;
label?: string;
}
export interface FlowGraphData {
title: string;
nodes: FlowGraphNode[];
edges: FlowGraphEdge[];
}
/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */
export const FLOW_NODE_TYPE_META: Record<
FlowNodeType,
{ label: string; bg: string; border: string; text: string; dot: string }
> = {
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" },
task: { label: "任务", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "判断", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "风险", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "交付物", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" },
};
const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的
* 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截)
* 时收集到哪算哪,截断的尾对象忽略。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out: Record<string, unknown>[] = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed as Record<string, unknown>);
}
} catch {
/* 截断/非法对象:跳过 */
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break; // 数组正常闭合
}
return out;
}
function asString(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
/** 把 raw 节点对象规范化;id/label 缺失返回 null。未知 type 落到 task。 */
function toNode(raw: Record<string, unknown>): FlowGraphNode | null {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
return { id, label, type, detail };
}
/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): FlowGraphEdge[] {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out: FlowGraphEdge[] = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || undefined;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
/** 独立 `edges` 数组(兼容形态:{from,to,label} / {source,target})→ 边。 */
function toEdge(raw: Record<string, unknown>): FlowGraphEdge | null {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || undefined;
return { from, to, label };
}
/**
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
* 没有 fence 或一个完整节点都没有 → null。
*/
export function extractFlowGraph(text: string): FlowGraphData | null {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`) as string;
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes: FlowGraphNode[] = [];
const seen = new Set<string>();
const edges: FlowGraphEdge[] = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
// 兼容独立 edges 数组(contract 主推 from-on-node,但模型可能两种都写)。
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
// 只保留两端节点都已出现的边 + 去重(流式中前驱未出现的边等下一轮解析再进来)。
const edgeKeys = new Set<string>();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
/**
* 识别文本(业务链编排提示 / 用户输入框消息)里是否明确要求了结构化输出 / 流程图。
* 命中 → 总结走「流程图模式」:跳过 .md 文件,报告用文字展示 + 末尾输出 flow-json。
* 只匹配较明确的短语,避免「用表格结构化呈现」这类普通措辞误触发。
*/
const FLOW_INTENT_PATTERNS = [
"流程图",
"可视化流程",
"结构化输出",
"结构化数据",
"结构化流程",
"结构化总结",
"格式化输出",
"格式化总结",
"flow-json",
"flowchart",
];
export function detectFlowIntent(text: string | null | undefined): boolean {
if (!text) return false;
const lower = text.toLowerCase();
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
}
/**
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
*/
export function stripFlowFence(text: string, streaming: boolean): string {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder =
close < 0 && streaming
? "\n\n> 📊 正在输出结构化流程数据,流程图画布同步绘制中…"
: "\n\n> 📊 已生成结构化流程数据,可在右侧「流程图」画布查看(点击节点看详情)。";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
// ── 分层布局(自上而下:前驱层 +1,同层横向铺开)────────────────────────────
export interface PositionedFlowNode extends FlowGraphNode {
x: number;
y: number;
}
export const FLOW_NODE_W = 176;
const X_GAP = 224;
const Y_GAP = 132;
/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */
export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
const preds = new Map<string, string[]>();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = new Map<string, number>();
const visiting = new Set<string>();
const layerOf = (id: string): number => {
const cached = memo.get(id);
if (cached !== undefined) return cached;
if (visiting.has(id)) return 0; // 环:斩断,按根层处理
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = new Map<number, FlowGraphNode[]>();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
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 });
});
}
// 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! }));
}
@@ -8,6 +8,8 @@
* `DispatchChainNode`,其余类型为后续阶段预留,先一并定义好避免反复改动。 * `DispatchChainNode`,其余类型为后续阶段预留,先一并定义好避免反复改动。
*/ */
import type { OrchestrationPlan } from "./dag-types";
/** 作业状态机(§2.1)。 */ /** 作业状态机(§2.1)。 */
export type JobStatus = export type JobStatus =
| "queued" // 已创建,等待 worker 拉起 | "queued" // 已创建,等待 worker 拉起
@@ -66,8 +68,10 @@ export interface RoundtableJob {
activeAgentId: string | null; activeAgentId: string | null;
consensusPercentage: number; consensusPercentage: number;
pendingClarification: string | null; pendingClarification: string | null;
orchestrationMode: "recommend" | "chain"; orchestrationMode: "recommend" | "chain" | "dag";
chain: { id: string; title: string } | null; chain: { id: string; title: string } | null;
/** dag 模式分层编排计划(流程图按 stage 分层绘制);非 dag 为 null。 */
orchestrationPlan: OrchestrationPlan | null;
error: string | null; error: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */ /** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
dialogues: JobDialogue[]; dialogues: JobDialogue[];
@@ -85,8 +89,10 @@ export interface StartJobPayload {
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */ /** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
agents: Array<{ agentId: string; name: string }>; agents: Array<{ agentId: string; name: string }>;
model?: string; model?: string;
orchestrationMode?: "recommend" | "chain"; orchestrationMode?: "recommend" | "chain" | "dag";
chain?: { id: string; title: string } | null; chain?: { id: string; title: string } | null;
/** dag 模式分层编排计划(stages / 阶段目标 / 总控提示词 / finalSynthesis)。非 dag 省略。 */
orchestrationPlan?: OrchestrationPlan | null;
/** 显式第一轮 leader prompt(覆盖 intent)。 */ /** 显式第一轮 leader prompt(覆盖 intent)。 */
seedMessage?: string; seedMessage?: string;
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */ /** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
@@ -0,0 +1,63 @@
/**
* 「按席位筛选会商记录」的匹配逻辑 —— 第二步左侧「参与角色 / 人工参与」卡片
* 与消息气泡头像共用同一套标识。
*
* 设计要点:把**任意一条气泡的 sender** 和**任意一张卡片**都归一化成同一个
* `SeatFilter` 字符串标识,于是「某气泡命中当前筛选」就等价于「两边标识相等」,
* 卡片点击与头像点击不再各写一套匹配规则。
*
* 三类标识:
* - `"coordinator"` —— 总控协调 / 系统 调度播报(对应「指挥员 / 主持人」卡片)
* - `"user"` —— 用户插话 / 澄清回复(对应「人工参与 · 用户」卡片)
* - `"agent:<显示名>"` —— 某 AI 席位的发言(对应该席位卡片)
*/
import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants";
export type SeatFilter = string;
/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */
export const COORDINATOR_SENDER = "总控协调";
export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator";
export const USER_SEAT_FILTER: SeatFilter = "user";
const AGENT_PREFIX = "agent:";
/** 把一条气泡的 sender 归一化成筛选标识。 */
export function seatFilterForSender(sender: string): SeatFilter {
if (sender === COORDINATOR_SENDER || sender === "系统") {
return COORDINATOR_SEAT_FILTER;
}
if (sender.startsWith("用户")) {
return USER_SEAT_FILTER;
}
// 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名,
// 让它与该席位的正常发言归到同一标识。
const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim();
return `${AGENT_PREFIX}${base}`;
}
/** 把「参与角色」里的一行(席位)映射成筛选标识。 */
export function seatFilterForRole(role: RoleEntry): SeatFilter {
// 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。
if (role.type === "human" || role.id === COMMANDER_SEAT_ID) {
return COORDINATOR_SEAT_FILTER;
}
return `${AGENT_PREFIX}${role.name}`;
}
/** 某 sender 是否命中当前筛选。 */
export function senderMatchesSeatFilter(
sender: string,
filter: SeatFilter,
): boolean {
return seatFilterForSender(sender) === filter;
}
/** 筛选提示条用的人类可读标签。 */
export function seatFilterLabel(filter: SeatFilter): string {
if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人";
if (filter === USER_SEAT_FILTER) return "用户";
if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length);
return filter;
}
@@ -39,6 +39,7 @@ import {
SplitSquareVertical, SplitSquareVertical,
Trash2, Trash2,
UserPlus, UserPlus,
Wand2,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -79,6 +80,7 @@ import {
createChain, createChain,
deleteChain, deleteChain,
listChains, listChains,
optimizeChainPrompt,
setChainPublic, setChainPublic,
updateChain, updateChain,
} from "@/roundtable-planning/api/chains"; } from "@/roundtable-planning/api/chains";
@@ -317,6 +319,80 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal)); setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
}, []); }, []);
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
// 把链条当前结构拼成上下文,让模型贴着「这条链有哪些阶段/席位」来优化。
const buildChainContextText = useCallback((): string => {
const lines: string[] = [];
if (title.trim()) lines.push(`链条标题:${title.trim()}`);
if (description.trim()) lines.push(`链条描述:${description.trim()}`);
stageGroups.forEach((g, i) => {
const seatNames = g.seats.map((s) => s.name || s.agent_id).join("、") || "(空)";
const goal = g.goal?.trim() ? `;阶段目标:${g.goal.trim()}` : "";
lines.push(`Stage ${i + 1}(${g.seats.length > 1 ? "并行" : "串行"}):${seatNames}${goal}`);
});
return lines.join("\n");
}, [title, description, stageGroups]);
const handleOptimizeCoordinatorPrompt = useCallback(async () => {
if (optimizingCoordinator) return;
const text = coordinatorPrompt.trim();
if (!text) {
toast.warning("请先写一版总控提示词,再让 AI 优化");
return;
}
setOptimizingCoordinator(true);
try {
const optimized = await optimizeChainPrompt({
promptText: text,
kind: "coordinator",
context: buildChainContextText(),
});
if (optimized.trim()) {
setCoordinatorPrompt(optimized.trim().slice(0, 4000));
toast.success("总控提示词已优化,可继续手动调整");
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setOptimizingCoordinator(false);
}
}, [optimizingCoordinator, coordinatorPrompt, buildChainContextText]);
const handleOptimizeStageGoal = useCallback(
async (stageIdx: number) => {
if (goalOptimizingIdx !== null) return;
const goal = stageGroups[stageIdx]?.goal?.trim() ?? "";
if (!goal) {
toast.warning("请先写一版本阶段目标,再让 AI 优化");
return;
}
setGoalOptimizingIdx(stageIdx);
try {
const optimized = await optimizeChainPrompt({
promptText: goal,
kind: "stage_goal",
context: `${buildChainContextText()}\n本次优化的是 Stage ${stageIdx + 1} 的阶段目标。`,
});
if (optimized.trim()) {
setStageGroups((prev) => setStageGoal(prev, stageIdx, optimized.trim().slice(0, 500)));
toast.success(`Stage ${stageIdx + 1} 阶段目标已优化`);
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setGoalOptimizingIdx(null);
}
},
[goalOptimizingIdx, stageGroups, buildChainContextText],
);
const splitSeat = useCallback((agentId: string) => { const splitSeat = useCallback((agentId: string) => {
setStageGroups((prev) => splitSeatToNewStage(prev, agentId)); setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
}, []); }, []);
@@ -759,9 +835,32 @@ export default function BusinessChainEditorPage() {
> >
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2"> <div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span> <span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<span className="select-none text-[10px] text-muted-foreground/70"> <div className="flex items-center gap-2">
写给总控:怎么派活、做什么(仅分层研讨生效,可选) <span className="select-none text-[10px] text-muted-foreground/70">
</span> 写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div> </div>
<textarea <textarea
value={coordinatorPrompt} value={coordinatorPrompt}
@@ -856,6 +955,8 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage} onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat} onSplitSeat={splitSeat}
onStageGoalChange={setGoal} onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal}
onSeatDetail={showDetail} onSeatDetail={showDetail}
/> />
</main> </main>
@@ -1049,6 +1150,8 @@ function StagesColumn({
onMergeSeat, onMergeSeat,
onSplitSeat, onSplitSeat,
onStageGoalChange, onStageGoalChange,
goalOptimizingIdx,
onOptimizeGoal,
onSeatDetail, onSeatDetail,
}: { }: {
stageGroups: StageGroups; stageGroups: StageGroups;
@@ -1062,6 +1165,9 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void; onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void; onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null;
onOptimizeGoal: (stageIdx: number) => void;
onSeatDetail: (agentId: string, name?: string) => void; onSeatDetail: (agentId: string, name?: string) => void;
}) { }) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。 // 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
@@ -1156,6 +1262,9 @@ function StagesColumn({
onMergeSeat={onMergeSeat} onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat} onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null}
onOptimizeGoal={() => onOptimizeGoal(stageIdx)}
onSeatDetail={onSeatDetail} onSeatDetail={onSeatDetail}
/> />
))} ))}
@@ -1181,6 +1290,9 @@ function StageSection({
onMergeSeat, onMergeSeat,
onSplitSeat, onSplitSeat,
onGoalChange, onGoalChange,
goalOptimizing,
anyGoalOptimizing,
onOptimizeGoal,
onSeatDetail, onSeatDetail,
}: { }: {
stageIdx: number; stageIdx: number;
@@ -1195,6 +1307,11 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void; onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void; onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean;
/** 任意 Stage 的优化在途(互斥,避免并发请求互相覆盖)。 */
anyGoalOptimizing: boolean;
onOptimizeGoal: () => void;
onSeatDetail: (agentId: string, name?: string) => void; onSeatDetail: (agentId: string, name?: string) => void;
}) { }) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx }); const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
@@ -1292,11 +1409,36 @@ function StageSection({
R_CTRL, R_CTRL,
)} )}
/> />
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && ( <div className="mt-1 flex items-start justify-between gap-2">
<p className="mt-1 text-[10px] text-muted-foreground"> {!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 ? (
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。 <p className="text-[10px] text-muted-foreground">
</p> 提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
)} </p>
) : (
<span />
)}
<button
type="button"
onClick={onOptimizeGoal}
disabled={anyGoalOptimizing || !(group.goal?.trim())}
title={
group.goal?.trim()
? "让 AI 重写本阶段目标(明确产出物与对上一阶段的承接)"
: "先写一版阶段目标,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{goalOptimizing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{goalOptimizing ? "优化中…" : "AI 优化"}
</button>
</div>
</div> </div>
)} )}
@@ -86,6 +86,11 @@ import {
type RoleEntry, type RoleEntry,
type SelectedAgent, type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants"; } from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import { import {
buildChainSeatSelection, buildChainSeatSelection,
type SeatSelectionResult, type SeatSelectionResult,
@@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() {
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]); const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null); const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
const [seatFilter, setSeatFilter] = useState<SeatFilter | null>(null);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入; // Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
@@ -923,10 +930,13 @@ export default function RoundtablePlanningPage() {
intent: buildInitialIntent(), intent: buildInitialIntent(),
agents, agents,
model: selectedModel || undefined, // 模型沿用第二步选择 model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎暂不支持 dag(分层 DAG 只在前端 runDagOrchestration 跑); // 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 挂起到后台时降级为 recommend(总控自由派活)。 // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode: orchestrationMode === "dag" ? "recommend" : orchestrationMode, orchestrationMode,
chain: chainMeta, chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程 // 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。 // (那些线程可能有活跃 run,复用会撞 409)。
}); });
@@ -944,6 +954,7 @@ export default function RoundtablePlanningPage() {
drafts, drafts,
selectedModel, selectedModel,
orchestrationMode, orchestrationMode,
orchestrationPlan,
chainMeta, chainMeta,
buildInitialIntent, buildInitialIntent,
resetToStep1, resetToStep1,
@@ -1149,6 +1160,8 @@ export default function RoundtablePlanningPage() {
step2Runs={step2Runs} step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId} activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run} onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
/> />
{/* MIDDLE VIEWPORT */} {/* MIDDLE VIEWPORT */}
@@ -1212,6 +1225,8 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs} agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId} selectedAgentId={step2.selectedAgentId}
roles={roles} roles={roles}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
parallelBatch={step2.parallelBatch} parallelBatch={step2.parallelBatch}
selectedParallelSeats={step2.selectedParallelSeats} selectedParallelSeats={step2.selectedParallelSeats}
isParallelRunning={step2.isParallelRunning} isParallelRunning={step2.isParallelRunning}
@@ -1619,6 +1634,9 @@ interface LeftSidebarProps {
step2Runs: DraftStep2RunSnapshot[]; step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null; activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void; onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
} }
function Step2SandboxSidebarSync({ function Step2SandboxSidebarSync({
@@ -1658,8 +1676,13 @@ function LeftSidebar({
step2Runs, step2Runs,
activeStep2RunId, activeStep2RunId,
onSelectStep2Run, onSelectStep2Run,
seatFilter,
setSeatFilter,
}: LeftSidebarProps) { }: LeftSidebarProps) {
const isStep2 = currentStep === 2; const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应 // Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。 // 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
@@ -1704,13 +1727,25 @@ function LeftSidebar({
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2> <h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div> </div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar"> <div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => ( {roles.map((role) => {
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} /> const filter = seatFilterForRole(role);
))} return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div> </div>
</div> </div>
<HumanParticipationCard /> <HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher <Step2RunSwitcher
runs={step2Runs} runs={step2Runs}
activeRunId={activeStep2RunId} activeRunId={activeStep2RunId}
@@ -1726,20 +1761,41 @@ function LeftSidebar({
<CollapsedSidebarSection title="参与角色"> <CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{roles.map((role) => ( {roles.map((role) => {
<RoleAvatarBadge const filter = seatFilterForRole(role);
key={role.id} return (
role={role} <RoleAvatarBadge
agentNameToRole={agentNameToRole} key={role.id}
size="h-8 w-8" role={role}
showSpeakingAnimation agentNameToRole={agentNameToRole}
/> size="h-8 w-8"
))} showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div> </div>
</CollapsedSidebarSection> </CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与"> <CollapsedSidebarSection title="人工参与">
<Man1Avatar size="h-8 w-8" /> <button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection> </CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览"> <CollapsedSidebarSection title="任务概览">
@@ -1893,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) {
); );
} }
function HumanParticipationCard() { function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
return ( return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]"> <div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5"> <div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} /> <Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2> <h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div> </div>
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3"> <div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" /> <Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span> <span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
@@ -2314,6 +2406,8 @@ function RoleAvatarBadge({
agentNameToRole, agentNameToRole,
size = "h-7 w-7", size = "h-7 w-7",
showSpeakingAnimation = false, showSpeakingAnimation = false,
active = false,
onSelect,
}: { }: {
role: RoleEntry; role: RoleEntry;
agentNameToRole: Record< agentNameToRole: Record<
@@ -2322,16 +2416,21 @@ function RoleAvatarBadge({
>; >;
size?: string; size?: string;
showSpeakingAnimation?: boolean; showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) { }) {
const isOngoing = role.status === "研讨中"; const isOngoing = role.status === "研讨中";
const animate = showSpeakingAnimation && isOngoing; const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
return ( const inner = (
<div <div
title={`${role.name} · ${role.status}`}
className={cn( className={cn(
"relative shrink-0 rounded-full", "relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white", animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)} )}
> >
{animate && ( {animate && (
@@ -2342,33 +2441,80 @@ function RoleAvatarBadge({
</div> </div>
</div> </div>
); );
if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
} }
function RoleRow({ function RoleRow({
role, role,
agentNameToRole, agentNameToRole,
active = false,
onSelect,
}: { }: {
role: RoleEntry; role: RoleEntry;
agentNameToRole: Record< agentNameToRole: Record<
string, string,
{ speakerId: number; avatarType: string; displayName: string } { speakerId: number; avatarType: string; displayName: string }
>; >;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) { }) {
const isActive = const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成"; role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中"; const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线"; const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return ( return (
<div <div
id={`role-item-${role.id}`} id={`role-item-${role.id}`}
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${ role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive isActive
? "bg-slate-50 border border-slate-100" ? "bg-slate-50 border border-slate-100"
: isBootingUp : isBootingUp
? "bg-amber-50/60 border border-amber-100" ? "bg-amber-50/60 border border-amber-100"
: "opacity-60" : "opacity-60",
}`} clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active &&
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
)}
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" /> <RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
@@ -1,12 +1,18 @@
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react"; import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications"; import { useUnreadCount } from "@/core/notifications";
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext"; import { useTheme } from "../contexts/ThemeContext";
import "@/components/page-sdebar.css";
const iconButtonClass = (isDark: boolean) => const iconButtonClass = (isDark: boolean) =>
cn( cn(
@@ -25,6 +31,49 @@ const Header = ({ collapsed }: HeaderProps) => {
const isDark = theme === "dark"; const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false); const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount(); const { data: unreadCount = 0 } = useUnreadCount();
const navigate = useNavigate();
const { pathname } = useLocation();
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const showTopSubmenu = isDark && !collapsed && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nestedPath = findFirstRoutePath(child.children);
if (nestedPath) {
return nestedPath;
}
}
return null;
};
const activeTopItem = visibleMenuItems.find(hasActiveChild);
const topSubNavItems = activeTopItem?.children ?? [];
const activeSecondItem = topSubNavItems.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const handleTopSubNavClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
return ( return (
<> <>
@@ -38,7 +87,7 @@ const Header = ({ collapsed }: HeaderProps) => {
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]", isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
)} )}
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex h-full min-w-0 flex-1 items-center gap-3">
<div <div
className={cn( className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg", "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
@@ -54,6 +103,26 @@ const Header = ({ collapsed }: HeaderProps) => {
CM助手 CM助手
</span> </span>
)} )}
{showTopSubmenu && topSubNavItems.length ? (
<nav className="top-submenu-bar" aria-label="二级菜单">
<div className="top-submenu-list">
{topSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
onClick={() => handleTopSubNavClick(item)}
>
{item.label}
</button>
);
})}
</div>
</nav>
) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
@@ -150,6 +219,26 @@ const Header = ({ collapsed }: HeaderProps) => {
</Button> </Button>
</div> </div>
</header> </header>
{showTopSubmenu && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`third-submenu-item ${isSelected ? "third-submenu-item--active" : ""}`}
onClick={() => handleTopSubNavClick(item)}
>
{item.label}
</button>
);
})}
</div>
</nav>
) : null}
</> </>
); );
}; };
@@ -0,0 +1,228 @@
/**
* 轻应用中心(应用管理 / 应用列表)API 客户端 + React Query hooks。
*
* 后端:`/api/light-apps`(见 app/gateway/routers/light_apps.py)。
* 全局共享、无用户隔离;读对所有登录用户开放,写仅 admin。
* snake_case ⇄ camelCase 在此边界收敛;路由参数项本身用 camelCase
* (key/paramType/source/customValue/themeValue),与后端 RouteParam 字段一致,原样透传。
*/
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type LightAppType = "window_open" | "iframe";
export type RouteParamType = "login" | "theme";
/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数用 themeValue。 */
export interface LightAppRouteParam {
key: string;
paramType: RouteParamType;
/** 登录参数:accessToken / yUserId / userName / "other"。 */
source?: string | null;
/** 登录参数且 source==="other" 时的字面值。 */
customValue?: string | null;
/** 主题参数的值(dark-blue / red / blue,可改)。 */
themeValue?: string | null;
}
export interface LightApp {
appId: string;
name: string;
description: string | null;
appType: LightAppType;
url: string;
routeParams: LightAppRouteParam[];
/** iframe 挂载的父菜单 id(window_open 为 null)。 */
mountLocation: string | null;
icon: string | null;
enabled: boolean;
sortOrder: number;
createdAt: string | null;
updatedAt: string | null;
}
export interface LightAppInput {
name: string;
description?: string | null;
appType: LightAppType;
url: string;
routeParams: LightAppRouteParam[];
mountLocation?: string | null;
icon?: string | null;
enabled?: boolean;
sortOrder?: number;
}
export interface ListLightAppsParams {
appType?: LightAppType;
mountLocation?: string;
enabledOnly?: boolean;
search?: string;
}
const BASE = () => `${getBackendBaseURL()}/api/light-apps`;
interface ServerRouteParam {
key: string;
paramType: RouteParamType;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
interface ServerApp {
app_id: string;
name: string;
description: string | null;
app_type: LightAppType;
url: string;
route_params: ServerRouteParam[];
mount_location: string | null;
icon: string | null;
enabled: boolean;
sort_order: number;
created_at: string | null;
updated_at: string | null;
}
function toApp(s: ServerApp): LightApp {
return {
appId: s.app_id,
name: s.name,
description: s.description ?? null,
appType: s.app_type,
url: s.url,
routeParams: (s.route_params ?? []).map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? null,
customValue: p.customValue ?? null,
themeValue: p.themeValue ?? null,
})),
mountLocation: s.mount_location ?? null,
icon: s.icon ?? null,
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
function toBody(input: Partial<LightAppInput>): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (input.name !== undefined) body.name = input.name;
if (input.description !== undefined) body.description = input.description;
if (input.appType !== undefined) body.app_type = input.appType;
if (input.url !== undefined) body.url = input.url;
if (input.routeParams !== undefined) body.route_params = input.routeParams;
if (input.mountLocation !== undefined) body.mount_location = input.mountLocation;
if (input.icon !== undefined) body.icon = input.icon;
if (input.enabled !== undefined) body.enabled = input.enabled;
if (input.sortOrder !== undefined) body.sort_order = input.sortOrder;
return body;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listLightApps(params: ListLightAppsParams = {}): Promise<LightApp[]> {
const qs = new URLSearchParams();
if (params.appType) qs.set("app_type", params.appType);
if (params.mountLocation) qs.set("mount_location", params.mountLocation);
if (params.enabledOnly) qs.set("enabled_only", "true");
if (params.search) qs.set("search", params.search);
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
return (data.apps ?? []).map(toApp);
}
export async function getLightApp(appId: string): Promise<LightApp> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "GET" });
return toApp(await readJson<ServerApp>(res, "get light app"));
}
export async function createLightApp(input: LightAppInput): Promise<LightApp> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
return toApp(await readJson<ServerApp>(res, "create light app"));
}
export async function updateLightApp(
appId: string,
patch: Partial<LightAppInput>,
): Promise<LightApp> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(patch)),
});
return toApp(await readJson<ServerApp>(res, "update light app"));
}
export async function deleteLightApp(appId: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
await readJson<{ success: boolean }>(res, "delete light app");
}
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
const LIGHT_APPS_KEY = "light-apps";
export function useLightApps(params: ListLightAppsParams = {}) {
return useQuery({
queryKey: [
LIGHT_APPS_KEY,
"list",
params.appType ?? "",
params.mountLocation ?? "",
params.enabledOnly ?? false,
params.search ?? "",
],
queryFn: () => listLightApps(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useLightApp(appId: string | undefined) {
return useQuery({
queryKey: [LIGHT_APPS_KEY, "detail", appId ?? ""],
queryFn: () => getLightApp(appId as string),
enabled: !!appId,
refetchOnWindowFocus: false,
});
}
export function useLightAppActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [LIGHT_APPS_KEY] });
return {
create: useMutation({ mutationFn: createLightApp, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ appId, patch }: { appId: string; patch: Partial<LightAppInput> }) =>
updateLightApp(appId, patch),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteLightApp, onSuccess: invalidate }),
};
}
@@ -0,0 +1,43 @@
/**
* 统一的外部 iframe 嵌套页渲染组件。
*
* XK / PG / TY 任务、资源管理等所有「把外部系统嵌进来」的页面共用此组件,
* 只负责把已构造好的 `src` 稳健地渲染出来。URL 构造逻辑收敛在
* `@/core/strategy/external-links`,各业务页只做薄封装。
*
* 关键点 —— `key={src}`:当地址变化(如 XK 在菜单间切换 gopath,只改 hash 片段)时,
* 强制 React 重挂载一个全新的 iframe,触发完整加载,避免「只改 hash 时浏览器不重载、
* 内嵌 SPA 来不及切换路由」导致的页面没变化问题。
*/
import { useTheme } from "@/contexts/ThemeContext";
import "../task-components/css/iframe.css";
interface ExternalIframePageProps {
/** 已构造好的完整地址;为空时展示加载占位(异步取址场景)。 */
src: string;
title?: string;
loadingText?: string;
}
export default function ExternalIframePage({
src,
title,
loadingText = "加载中…",
}: ExternalIframePageProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
if (!src) {
return (
<div className={`flex h-full w-full items-center justify-center text-sm ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{loadingText}
</div>
);
}
return (
<div className="iframe-container">
<iframe key={src} src={src} title={title} className="full-size-iframe" />
</div>
);
}
@@ -5,11 +5,12 @@ import {
getKnowledgeIframeSkin, getKnowledgeIframeSkin,
} from "@/core/strategy/external-links"; } from "@/core/strategy/external-links";
import { requestCrudService } from "@/service/request-axios-service"; import { requestCrudService } from "@/service/request-axios-service";
import "@/strategy-components/components/task-components/css/strategy-data.css"; import ExternalIframePage from "../common/ExternalIframePage";
const service = requestCrudService; const service = requestCrudService;
const KnowledgeBaseResourceManagement: React.FC = () => { const KnowledgeBaseResourceManagement: React.FC = () => {
// 知识库地址需异步调接口换取(区别于其它 env 直拼的 iframe 页)。
const [iframeUrl, setIframeUrl] = useState(""); const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => { useEffect(() => {
@@ -40,11 +41,7 @@ const KnowledgeBaseResourceManagement: React.FC = () => {
void initIframeUrl(); void initIframeUrl();
}, []); }, []);
return ( return <ExternalIframePage src={iframeUrl} title="知识库资源管理" />;
<div className="strategy-data iframe-container">
<iframe src={iframeUrl} className="full-size-iframe" title="知识库资源管理" />
</div>
);
}; };
export default KnowledgeBaseResourceManagement; export default KnowledgeBaseResourceManagement;
@@ -1,20 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useMemo } from "react";
import { getNetworkActorIframeUrl } from "@/core/strategy/external-links"; import { getNetworkActorIframeUrl } from "@/core/strategy/external-links";
import "@/strategy-components/components/task-components/css/strategy-data.css"; import ExternalIframePage from "../common/ExternalIframePage";
const NetworkActorResourceManagement: React.FC = () => { const NetworkActorResourceManagement: React.FC = () => {
const [iframeUrl, setIframeUrl] = useState(""); const src = useMemo(() => getNetworkActorIframeUrl(), []);
return <ExternalIframePage src={src} title="网络xwt资源管理" />;
useEffect(() => {
setIframeUrl(getNetworkActorIframeUrl());
}, []);
return (
<div className="strategy-data iframe-container">
<iframe src={iframeUrl} className="full-size-iframe" title="网络xwt资源管理" />
</div>
);
}; };
export default NetworkActorResourceManagement; export default NetworkActorResourceManagement;
@@ -1,27 +0,0 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const FanganTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeFaty`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default FanganTyPage;
@@ -1,24 +1,16 @@
import { Outlet, useNavigate } from 'react-router-dom'; import React, { useMemo } from "react";
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { getPgIframeUrl } from "@/core/strategy/external-links";
import ExternalIframePage from "../common/ExternalIframePage";
const userInfo = localStorage.getItem('userInfo');
const pgLinkUrl = import.meta.env.VITE_AIPI_PGLINKTO_URL;
interface TaskPgFromProps { interface TaskPgFromProps {
gopath: string gopath: string;
} }
const TaskPgFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [pgUrl, setPgUrl] = useState(pgLinkUrl); const TaskPgFrom: React.FC<TaskPgFromProps> = ({ gopath }) => {
useEffect(() => { // 每次从 base 重建(修复原先用状态做模板导致查询串累加的 bug);key={src} 强制重载。
if(userInfo) { const src = useMemo(() => getPgIframeUrl(gopath), [gopath]);
const userData = JSON.parse(userInfo); return <ExternalIframePage src={src} title="PG中任务" />;
setPgUrl(`${pgUrl}?access_token=${userData.accessToken}&goPath=${gopath}`); };
}
}, [gopath]); export default TaskPgFrom;
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={pgUrl}></iframe>
</div>
);
}
export default TaskPgFrom
@@ -0,0 +1,18 @@
import React, { useMemo } from "react";
import { getTyIframeUrl } from "@/core/strategy/external-links";
import ExternalIframePage from "../common/ExternalIframePage";
interface TaskTyFromProps {
/** TY 系统页标识:iframeXdty(行动)/ iframeFaty(方案)/ iframeSysystem(综合)。 */
defaultPage: string;
title?: string;
}
/** TY 中任务统一入口(原 XingdongTy / FanganTy / XXTy 合并)。 */
const TaskTyFrom: React.FC<TaskTyFromProps> = ({ defaultPage, title }) => {
const src = useMemo(() => getTyIframeUrl(defaultPage), [defaultPage]);
return <ExternalIframePage src={src} title={title} />;
};
export default TaskTyFrom;
@@ -1,23 +1,16 @@
import { Outlet, useNavigate } from 'react-router-dom'; import React, { useMemo } from "react";
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { getXkIframeUrl } from "@/core/strategy/external-links";
const userInfo = localStorage.getItem('userInfo'); import ExternalIframePage from "../common/ExternalIframePage";
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
interface TaskPgFromProps { interface TaskXkFromProps {
gopath: string gopath: string;
} }
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [xkUrl, setXkUrl] = useState(xkLinkUrl); const TaskXkFrom: React.FC<TaskXkFromProps> = ({ gopath }) => {
useEffect(() => { // gopath 变化(菜单切换)时从 base 重建地址;ExternalIframePage 的 key={src} 会强制重载。
if(userInfo) { const src = useMemo(() => getXkIframeUrl(gopath), [gopath]);
const userData: any = JSON.parse(userInfo); return <ExternalIframePage src={src} title="XK中任务" />;
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`); };
}
}, [gopath]); export default TaskXkFrom;
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
</div>
);
}
export default TaskXkFrom
@@ -1,27 +0,0 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const XXTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeSysystem`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default XXTyPage;
@@ -1,27 +0,0 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const XingdongTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeXdty`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default XingdongTyPage;
@@ -0,0 +1,84 @@
/**
* 轻应用共享常量 + 运行时 URL 构造(管理页预览 / 列表页跳转 / iframe 宿主共用)。
*
* 登录参数运行时从 `localStorage.getItem("userInfo")`(JSON) 解析;主题参数用配置值。
*/
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
{ label: "XK中任务", value: "xk-center-task" },
{ label: "PG中任务", value: "pg-center-task" },
{ label: "TY中任务", value: "ty-center-task" },
{ label: "资源管理", value: "resource-management" },
];
export function mountLabel(value: string | null | undefined): string {
if (!value) return "";
return MOUNT_OPTIONS.find((o) => o.value === value)?.label ?? value;
}
/** 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。 */
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
{ label: "accessToken", value: "accessToken" },
{ label: "yUserId", value: "yUserId" },
{ label: "userName", value: "userName" },
{ label: "其他(自定义值)", value: "other" },
];
/** 主题参数示例值(深蓝 / 红 / 蓝),可改。 */
export const THEME_EXAMPLE_OPTIONS: { label: string; value: string }[] = [
{ label: "深蓝色 dark-blue", value: "dark-blue" },
{ label: "红色 red", value: "red" },
{ label: "蓝色 blue", value: "blue" },
];
/** 读取并解析 localStorage 中的 userInfo。 */
export function readUserInfo(): Record<string, unknown> {
try {
const raw = localStorage.getItem("userInfo");
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** 解析单个路由参数在运行时的取值。 */
export function resolveRouteParamValue(
param: LightAppRouteParam,
userInfo: Record<string, unknown> = readUserInfo(),
): string {
if (param.paramType === "theme") {
return param.themeValue ?? "";
}
// login
if (param.source === "other") {
return param.customValue ?? "";
}
if (param.source) {
const value = userInfo[param.source];
return value == null ? "" : String(value);
}
return "";
}
/**
* 构造应用的最终访问 URL:在 `app.url` 上追加解析后的路由参数查询串。
*
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
*/
export function buildLightAppUrl(
app: Pick<LightApp, "url" | "routeParams">,
userInfo: Record<string, unknown> = readUserInfo(),
): string {
const pairs = (app.routeParams ?? [])
.filter((p) => p.key)
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo))}`);
if (pairs.length === 0) return app.url;
const sep = app.url.includes("?") ? "&" : "?";
return `${app.url}${sep}${pairs.join("&")}`;
}
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
"base":"./", "base":"./",
server: { server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。 // 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5174, port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。 // 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true, cors: true,
}, },
@@ -251,10 +251,20 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **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"`. | | **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"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Light Apps** (`/api/light-apps`) | Light-app registry (轻应用中心 / 应用管理), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the 应用列表 card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` — a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### AI Writing Built-in Functional Agents (四阶段内置智能体)
The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four stage "brains" — 素材收集专家 / 大纲规划师 / 作家 / 编辑 — that can each run in one of two modes, switched per-stage by `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` (default **false**, mtime hot-reloaded, new sessions pick up changes immediately):
- **Legacy (flag off)**: hard-coded prompts in `prompts/*.py`, direct LLM call (unchanged behavior).
- **Builtin-agent (flag on)**: the node loads the corresponding functional agent from `.deer-flow/agents/ai-writing-<stage>/` — `SOUL.md` becomes the system prompt and `config.yaml`'s `model` overrides the stage model (priority: `agent.model > node explicit fallback (keyword_model/rank_model) > state.model_name`). Admins edit SOUL/model from the agent management page. Runner: `nodes/_agent_runner.py` (`run_writing_agent` / `stream_writing_agent` / `parse_marker_json`). Output contracts (marker + ```json``` blocks, intent.py-style): researcher `[KEYWORDS_READY]` / `[MATERIALS_RANKED]`, outliner `[OUTLINE_READY]`, editor (per-dimension) `[REVIEW_READY]`; writer emits raw section Markdown (keeps per-section streaming) with the legacy `[[SECTION_BLOCKED]]` sentinel in strict mode. **Orchestration stays in the nodes** (search fan-out, per-section loop, 3-dimension review loop, all pause points) — the agent only does single structured generations, and a missing/empty SOUL silently falls back to the legacy path (`WritingAgentUnavailable`).
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`) ### 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. 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.
@@ -40,6 +40,7 @@ from app.gateway.routers import (
html_page_favorites, html_page_favorites,
intent, intent,
knowledge, knowledge,
light_apps,
llm_metrics, llm_metrics,
mcp, mcp,
memory, memory,
@@ -933,6 +934,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)" "Roundtable functional agent seeding failed at startup (non-fatal)"
) )
# AI 写作功能型 agent(researcher / outliner / writer / editor)缺失自动补建。
# 同圆桌:必须在 _sync_legacy_agents 之前,补建目录才能在同一次启动被
# upsert 进 agents 表,admin 启动后即可在智能体管理页编辑四个写作 agent。
try:
from app.gateway.routers._ai_writing_seed import (
ensure_ai_writing_functional_agents,
)
ensure_ai_writing_functional_agents()
except Exception:
logger.exception(
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。 # 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try: try:
business_mapping_store = getattr(app.state, "business_mapping_store", None) business_mapping_store = getattr(app.state, "business_mapping_store", None)
@@ -1307,6 +1322,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Global business → agent / chain mapping at /api/business-mapping # Global business → agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router) app.include_router(business_mapping.router)
# Light-app registry (轻应用中心) at /api/light-apps
app.include_router(light_apps.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks # Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router) app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router) app.include_router(parallel_agent_tasks.router)
@@ -204,6 +204,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.business_mapping_store = make_business_mapping_store(sf) app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.light_apps import make_light_app_store
app.state.light_app_store = make_light_app_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf) app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -173,6 +173,9 @@ class StartParams:
is_resume: bool = False is_resume: bool = False
# chain 模式来源链条(写进草稿 run 用,展示)。 # chain 模式来源链条(写进草稿 run 用,展示)。
chain: dict[str, Any] | None = None chain: dict[str, Any] | None = None
# dag 模式分层编排计划(前端 OrchestrationPlan dict):驱动引擎 _run_dag + 写进草稿 run
# 供前端流程图渲染。None = 非 dag(引擎按 mode 回退 chain/recommend)。
orchestration_plan: dict[str, Any] | None = None
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。 # 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
# 测试可显式传 True 覆盖以验证报告链路。 # 测试可显式传 True 覆盖以验证报告链路。
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
@@ -242,6 +245,7 @@ class RoundtableJobExecutor:
mode=params.mode, mode=params.mode,
intent_text=params.intent_text, intent_text=params.intent_text,
enable_report=params.enable_report, enable_report=params.enable_report,
plan=params.orchestration_plan,
) )
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。 # 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft( await self._write_run_to_draft(
@@ -323,6 +327,8 @@ class RoundtableJobExecutor:
"seatStubMessages": _seat_stub_messages(dialogues), "seatStubMessages": _seat_stub_messages(dialogues),
"orchestrationMode": params.mode, "orchestrationMode": params.mode,
"chain": params.chain, "chain": params.chain,
# dag 模式分层计划:让前端加载草稿后能像手动跑一样按 stage 画流程图。
"orchestrationPlan": params.orchestration_plan,
"step3": effective_step3, "step3": effective_step3,
"jobId": params.job_id, "jobId": params.job_id,
"jobStatus": status, "jobStatus": status,
@@ -0,0 +1,117 @@
"""AI 写作功能型 agent 的「调用前自检 + 缺失自动补建」工具。
适用场景
--------
``ai-writing-researcher`` / ``ai-writing-outliner`` / ``ai-writing-writer`` /
``ai-writing-editor`` 是 AI 写作四阶段(素材收集 → 大纲 → 写作 → 审核)的
**功能型**内置 agent。当 ``config.yaml`` 的 ``ai_writing.use_builtin_agents.*``
开关打开时,对应的图节点会从 ``.deer-flow/agents/<id>/`` 加载该 agent 的
SOUL.md(人设+结构化输出契约)与 config.yaml(可配 per-阶段模型)。
仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone``
之后这些目录**不会存在**。本模块在两个地方被调用:
1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失目录补齐,
紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
``/api/agents`` 列表里就有这四个 agent,admin 可直接在智能体管理页编辑
SOUL / 模型。
2. **AI 写作路由入口**(``ai_writing.list_sessions`` / ``list_article_types``,
即用户打开写作页必经的两个 GET):防御性兜底——若管理员在启动后手删了
目录,下一次打开写作页也能自愈。
与 ``_roundtable_seed.py`` 完全同构:资源文件
(``_ai_writing_seed_assets/<id>/``)是发布默认值的唯一可信源,seeder 只在
目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重运维/admin 现场手改)。
线上调优 SOUL 后请把改动写回对应资源 .md,否则新机器补建出来的还是旧版。
注意:graph 节点侧(harness 层)对 agent 目录缺失有独立兜底——加载失败时
自动回退到旧的硬编码 prompt 路径,所以 seeder 失败不会打断写作流程。
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id。
_ASSETS_DIR = Path(__file__).parent / "_ai_writing_seed_assets"
# 四个功能型内置 agent,任何部署都应存在;顺序无关紧要(逐个独立补建)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
RESEARCHER_AGENT_ID,
OUTLINER_AGENT_ID,
WRITER_AGENT_ID,
EDITOR_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
# log instead of silently creating an empty agent dir.
logger.error(
"Missing seed assets for ai-writing agent '%s' (expected at %s)",
agent_id,
src_dir,
)
return False
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
# 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
(agent_dir / fname).write_bytes(src.read_bytes())
return True
def ensure_ai_writing_functional_agents() -> list[str]:
"""Seed the four ai-writing functional agents if their on-disk directories
are missing. Idempotent — fast no-op when all exist.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the graph node falls back to the legacy
hard-coded prompt path when the agent really cannot be loaded.
"""
created: list[str] = []
for agent_id in _FUNCTIONAL_AGENT_IDS:
try:
if _ensure_one(agent_id):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created))
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(_FUNCTIONAL_AGENT_IDS)
@@ -0,0 +1,42 @@
# 编辑
你是 AI 写作流水线的「编辑」,一名资深主编,标准极高,要求严格。你在一家权威媒体担任主编十五年,见过各种质量的文章。
系统每次派给你**一个审查维度**(输入开头会写明:事实准确性 / 逻辑结构 / 语言文字),你只从该维度审查文章,给出该维度的评分(0-100 整数)和具体问题列表。你不能改写文章,只做当前指定维度的审查。综合评分与是否通过由系统汇总判定,不归你管。
---
## 0. 硬规则(最高优先级)
1. **只审当前维度**:输入指定哪个维度就只审哪个维度,不要跨维度挑问题。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释。`score` 是 0-100 的整数;每个问题的 `severity` 必须是 `"major"`(必须修改)或 `"minor"`(建议修改);该维度没有问题时 `issues` 填空数组 `[]`。
4. **意见要具体可执行**:`location` 指明位置(如"第二段"),`issue` 说清问题,`suggestion` 给出可操作的修改建议。
---
## 1. 结束协议:`[REVIEW_READY]`
审查完成后,**严格**按以下结构输出,前后不得有其它内容(`[REVIEW_READY]` 单独成行):
```
[REVIEW_READY]
```json
{
"score": 82,
"issues": [
{"severity": "major", "location": "第二段", "issue": "引用的增长数据没有素材支撑", "suggestion": "补充素材 m002 中的具体数据,并注明来源"},
{"severity": "minor", "location": "结尾段", "issue": "结论略显仓促", "suggestion": "增加一句对未来行业趋势的判断"}
]
}
```
```
---
## 2. 反例(请认真避开)
❌ 先输出"这篇文章整体质量不错,下面是我的审查意见:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加总结陈词 —— ```json``` 闭合后什么都不要写。
❌ `severity` 写成 `"high"` / `"严重"` —— 只允许 `"major"` / `"minor"`。
❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。
@@ -0,0 +1,3 @@
description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见
id: ai-writing-editor
name: 编辑
@@ -0,0 +1,80 @@
# 大纲规划师
你是 AI 写作流水线的「大纲规划师」。你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只做一件事:**根据素材和写作意图,产出/调整文章的三级大纲**(章节 → 小节 → 子要点)。你不写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
系统派给你的任务有两种模式(输入开头会写明):
- **新规划**:根据写作意图 + 素材,从零规划 3-5 个章节的大纲
- **修改模式**:输入里带「当前大纲」JSON + 用户修改要求 → 在当前大纲基础上调整
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止 `<think></think>` 标签及其中内容;禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲")——无论素材是否充分,都必须直接产出大纲。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释;`material_ids` 中的 ID 必须来自给定素材,没有合适素材时填 `[]`。
4. **修改模式只改用户明确提到的部分**:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变);章节/小节数量完全由用户要求决定,要加就加、要减就减。
---
## 1. 三级层级语义(决定文章结构)
- Level 1 = `section_title`(章节):渲染为 `##` 二级标题,统领一组内容。高度概括的短语,6-15 字。每篇 3-5 个章节(修改模式数量听用户的)。
- Level 2 = `point_title`(小节):渲染为 `###` 三级标题,下面会有一段 2-3 句的概述正文。名词性短语,15 字以内,不带句末标点。每章节 2-4 个小节。
- Level 3 = `sub_points`(子要点):渲染为 `####` 四级标题,**每个子要点都会展开成一段 3-5 句的独立正文**——不是 bullet 列表,所以要规划成「能独立成段展开」的小主题。名词性短语,≤15 字,不带句末标点。每小节 2-4 条;信息确实少到无法拆分时可留 `[]`。
兼容历史大纲:若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,输出时补成 `{"point_title": "<原 string>", "sub_points": []}`。
---
## 2. 结束协议:`[OUTLINE_READY]`
完成规划后,**严格**按以下结构输出,前后不得有其它内容(`[OUTLINE_READY]` 单独成行):
```
[OUTLINE_READY]
```json
{
"title": "新能源汽车出口为何高速增长",
"sections": [
{
"section_title": "引言:出口数据创新高",
"key_points": [
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
],
"material_ids": ["m001"]
},
{
"section_title": "增长背后的三大动因",
"key_points": [
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]}
],
"material_ids": ["m002", "m003"]
},
{
"section_title": "结论与展望",
"key_points": [
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
{"point_title": "行业趋势判断", "sub_points": []}
],
"material_ids": ["m004"]
}
]
}
```
```
---
## 3. 反例(请认真避开)
❌ 先输出"好的,我来规划大纲:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加"以上大纲覆盖了……如需调整请告诉我" —— ```json``` 闭合后什么都不要写。
❌ 修改模式下重写了用户没提到的章节标题。
❌ `material_ids` 里出现素材清单中不存在的编号。
❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。
@@ -0,0 +1,3 @@
description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲
id: ai-writing-outliner
name: 大纲规划师
@@ -0,0 +1,66 @@
# 素材收集专家
你是 AI 写作流水线的「素材收集专家」。你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
实际的网络/技能检索由系统执行,**你不联网**。你只承担两类结构化子任务,系统每次只派给你其中一类(输入开头会写明「任务:XXX」):
- **任务 A · 提取检索词**:从写作意图中提炼指定数量的搜索关键词
- **任务 B · 素材排序**:从已检索到的素材列表中,选出与写作意图最相关的素材 ID 并按相关度排序
你不写文章、不评价内容好坏、不输出任何与当前任务无关的内容。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:这是简单的结构化抽取任务。禁止输出任何思考、分析、推理或规划过程,禁止 `<think></think>` 标签及其中内容,禁止"让我想想""首先"之类过渡语。收到输入后立即输出结果。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:引号一律半角双引号 `"`(不要中文引号),不要尾随逗号,不要注释,后端用程序解析,任何变形都会导致降级。
4. **不要照抄占位符**:示例里的 `"..."`、`关键词1` 等都是占位示意,必须输出依据真实输入提炼的内容。
---
## 1. 任务 A · 提取检索词
输入会给出:写作意图、文章类型、目标读者、要求的关键词数量 N。
要求:
- 关键词必须具体、可搜索,避免过于宽泛的词。
- 数量必须正好 N 个,不多不少。
- 关键词依据写作意图提炼,贴合文章类型与目标读者。
输出格式(严格遵守,`[KEYWORDS_READY]` 单独成行):
```
[KEYWORDS_READY]
```json
{"keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场"]}
```
```
## 2. 任务 B · 素材排序
输入会给出:写作意图、素材列表(每条含 ID 和摘要)。
要求:
- 选出与写作意图最相关的素材 ID(最多 8 条),按相关度从高到低排序。
- `ranked_ids` 中的 ID **必须来自素材列表**,绝不编造 ID。
- `summary` 一句话概括检索结果(共搜索到 X 条,精选 Y 条)。
输出格式(严格遵守,`[MATERIALS_RANKED]` 单独成行):
```
[MATERIALS_RANKED]
```json
{"ranked_ids": ["m003", "m001", "m005"], "summary": "共搜索到 8 条素材,精选 3 条最相关的"}
```
```
---
## 3. 反例(请认真避开)
❌ 输出思考过程:"让我分析一下这个写作意图……" —— 直接输出 marker + JSON。
❌ marker 后追加说明:"以上关键词覆盖了……" —— ```json``` 闭合后什么都不要写。
❌ 编造素材 ID:`ranked_ids` 里出现列表中不存在的 `m099`。
❌ 照抄占位符:输出 `{"keywords": ["关键词1", "关键词2"]}`。
@@ -0,0 +1,3 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
@@ -0,0 +1,49 @@
# 作家
你是 AI 写作流水线的「作家」。你有十年内容创作经验,文字流畅,善于用事实说话。
系统按大纲**逐章**派活给你:每次输入一个章节(含文章整体结构、当前章节的小节/子要点、相关素材、写作模式),你写出**这一个章节**的正文。你不能修改大纲,不能写其他章节,只写当前这一段。
- 优先采用素材中的事实与数据;素材不足时按输入里的「写作模式」处理(宽松=可结合通用知识,严格=只准用素材)。
- 不要脱离主题杜撰具体的数字、人名、机构名与日期。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类过渡语,禁止 `<think></think>` 标签及其中内容。
2. **绝不拒绝、绝不元话语**:禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材"——这类话绝不允许出现在文章里。无论素材是否充分,都按「写作模式」的规则直接产出。
3. **直接输出 Markdown 正文**:不要 JSON、不要解释、不要"以下是正文"之类开场白。收到输入后立即开始写正文(含 `###` 小标题)。
4. **严格模式的唯一例外**:输入标明「写作模式:严格依据素材」且素材确实不足以支撑本章节时,**只输出一行**:`[[SECTION_BLOCKED]] 一句话说明缺什么素材`(例:`[[SECTION_BLOCKED]] 缺少关于XX的具体数据与案例`),此外不输出任何内容。只要素材能支撑哪怕部分要点,就正常写作,不要输出该标记。
---
## 1. 结构要求(三级结构,必须严格遵守)
「章节要点」用层级 bullet 列表给出:
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
**每个小节、每个子要点都要有自己的正文段,分两层展开**:
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
补充规则:
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
- 标题层级严格对应:文章 `#` / 章节 `##` 由系统注入,**你不要输出这两级**;你只输出 `###` 小节与 `####` 子要点。
- 不要输出 `#####` 或更深。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每一层,写满输入要求的篇幅。
---
## 2. 反例(请认真避开)
❌ 开头写"好的,下面是「XX」章节的内容:" —— 直接从 `###` 或正文开始。
❌ 输出 `## 章节标题` —— 章节标题由系统注入,你最高只到 `###`。
❌ 严格模式素材不足时硬凑无关内容、或写"素材与意图不匹配" —— 只输出一行 `[[SECTION_BLOCKED]] 原因`。
❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。
@@ -0,0 +1,3 @@
description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰
id: ai-writing-writer
name: 作家
@@ -31,7 +31,7 @@
| # | 章节 | 内容(文字) | 配图建议 | | # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------| |---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 | — | | ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) | | ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等);**凡是估算/定性推得的指标,必须在数值旁标注「估算」小角标**,不要让读者把估算当真实数据 |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 | | ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) | | ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 | | ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
@@ -48,8 +48,26 @@
常用图表素材库(按章节按需取用,能画哪些画哪些): 常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。 共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格 ### 4. 视觉风格(主题随用户消息指定)
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业; 用户消息会指明本次绘制的「页面主题」:**浅色(浅红)** 或 **深色(深蓝)**。未指明时默认浅色。两套主题只改配色,版式要求两者通用(见本节末尾)。
#### 4a. 浅色主题(默认 · 浅红)
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业。
#### 4b. 深色主题(深蓝 · 严格按以下设计令牌取色)
整页深蓝底、浅色字,**禁止**再使用浅红体系(`#c25555` / `#fef4f3`):
- **背景层级**:页面最外层 `#060E24` → 分栏/大区容器 `#0C1836` → 卡片 `#122347`(边框 `1px solid #1A2F5A`,圆角 4px,阴影 `0 2px 8px rgba(22,119,255,.12)`);
- **文字**:大标题 `#FFFFFF`,正文 `#C5CFE0`,次级说明 `#6E88B0`,弱化/禁用 `#4A6491`;
- **主色** `#1677FF`(hover `#0958D9`)、辅色青 `#13C2C2`、链接/操作强调 `#4096FF`;
- **报告头**标题下加发光分割线:`height:1px; background:linear-gradient(90deg, transparent, #1677FF, transparent)`;普通分割线弱 `#2D4473` / 默认 `#4A6491`;
- **KPI 数字卡**:卡底 `#122347`、大数值 `#FFFFFF`(个别可用 `#4096FF` 强调)、标签 `#C5CFE0`、单位说明 `#6E88B0`;
- **图表系列色序**(多系列按顺序取用):`#1677FF` `#13C2C2` `#4096FF` `#36CFC9` `#69B1FF` `#5CDBD3` `#91CAFF` `#87E8DE`;坐标轴/网格线 `#2D4473`,轴刻度文字 `#6E88B0`;
- **语义色**:成功 `#52C41A`、警告/进行中 `#FAAD14`、错误/高风险 `#F5222D`、信息 `#1677FF`、处理中 `#13C2C2`;涨 ▲`#13C2C2`、跌 ▼`#F5222D`、持平 `#6E88B0`;浅底徽标/告警条用前景色 16% 透明度底(如 `rgba(82,196,26,.16)`);
- **圆角与间距**:按钮/Tag 圆角 2px、卡片 4px;间距取 4/8/12/16/24/32px 阶梯。
> **主题与底稿不一致时**:修改/重绘时若底稿 HTML 的配色与本次指定主题不同,必须把整页配色重绘成本次指定的主题,不允许保留旧主题色。
通用版式要求(两主题共用):
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好; - 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。 - 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
@@ -60,9 +78,16 @@
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`; - **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`; - **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。 - 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。 3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话;**首轮写完后不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。 > **修改/返工规则**:同一轮内写完后发现问题要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 多轮修改(用户会在输入框里继续提修改要求)
报告生成后,用户可能在同一对话里继续发来修改要求(也可能在你绘制中途打断后发来)。处理规则:
1. 把新消息当作对 `方案总览.html` 的**修改要求**:在已有报告基础上做增量修改——只改用户提到的地方,其余内容保持不变;
2. **允许(且建议)先 `read_file /mnt/user-data/outputs/方案总览.html`** 找回当前文件全文,再在其基础上修改(多轮之后上下文可能被压缩,以文件为准);若文件不存在或只写了半截,就按原始要求重新完整绘制一份;
3. 修改后仍**用 `write_file` 一次性整文件重写**到同一路径,保持自包含离线、保持用户消息指定的主题配色;
4. 写完只用一两句话说明本次改了什么;若用户只是提问而非要求修改,就只用文字回答、**不要**改写文件。
## 禁止事项 ## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败) - ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
@@ -71,7 +96,7 @@
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板) - ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注) - ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`) - ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件 - ❌ 把 HTML 拆成多文件或分多次拼接;首轮写完后再去读取/校验文件(**多轮修改时允许先 `read_file` 底稿**)
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写) - ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据 - ❌ 编造与原料明显矛盾的数据
@@ -14,13 +14,23 @@
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1); 1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文; 2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**; 3. **总控最终共识结论**;
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。 4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**。其中往往还藏着用户对**最终总结报告本身**的明确要求,动笔前请先把它们**逐条提取**出来,常见四类:
- **输出结构 / 格式**:如「最后用表格汇总」「按 现状→问题→对策 三段输出」「给我编号清单 / 固定模板」——结构一经用户指定,**完全按用户结构输出、放弃默认章节结构**,不要把用户结构硬塞回默认章节里;
- **重点席位**:如「重点总结方案设计和风险审查的交付」——被点名的席位**前置并显著加大篇幅**(逐条展开其交付要点),未点名的席位压缩成一两句带过即可;
- **口径 / 风格**:如「面向高管汇报」「控制在一页内」「不要技术细节」——全篇遵守;
- **详略 / 取舍**:如「只要结论和执行计划」——未被要求的章节直接省略,不要因为默认结构里有就硬写。
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。 请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
## 产出要求(最重要) ## 产出要求(最重要)
你每一轮都要做**两件事**,缺一不可: ### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
当用户**明确要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息),本轮进入**流程图模式**:
- **跳过下面的 A**——不要调用 `write_file`、不要生成任何 .md 文件;
- 把**精炼的文字版总结报告**直接写在对话回复里(文字展示给用户读,结构遵循用户要求);
- 在回复**最末尾必须**输出一个完整的 ```flow-json 代码块(契约见「结构化流程数据」节)——这是本轮的**主产物**,绝不能省略。
未进入流程图模式时,每一轮都要做 A + B **两件事**,缺一不可:
### A. 写一个 Markdown 文件 ### A. 写一个 Markdown 文件
- 用 `write_file` **一次性**把完整报告写入 `/mnt/user-data/outputs/方案总结报告.md`; - 用 `write_file` **一次性**把完整报告写入 `/mnt/user-data/outputs/方案总结报告.md`;
@@ -34,7 +44,7 @@
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。 - 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
## 报告结构(叙事型,文字为主线) ## 报告结构(叙事型,文字为主线)
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**: **下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
| # | 章节 | 内容 | | # | 章节 | 内容 |
|---|------|------| |---|------|------|
@@ -47,19 +57,47 @@
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。 善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
## 多轮交互(用户会在输入框里继续和你对话) ## 结构化流程数据(flow-json 契约)
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理: 进入「流程图模式」时(见产出要求第 ⓪ 条:用户在编排提示或对话里明确要求了结构化输出 / 流程图),**不写 .md 文件**,在对话回复里给出精炼文字总结后,在**回复最末尾**追加一个 ```flow-json 代码块,前端会据此**实时绘制可交互流程图**(边流式输出边画):
1. **提出修改意见**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」): ```flow-json
{"title":"方案执行流程","nodes":[
{"id":"n1","label":"环境评估","type":"stage","detail":"点击节点弹窗里显示的一两句详细说明"},
{"id":"n2","label":"编制方案","type":"task","detail":"…","from":["n1"]},
{"id":"n3","label":"方案是否可行","type":"decision","detail":"…","from":["n2"]},
{"id":"n4","label":"实施落地","type":"task","detail":"…","from":[{"id":"n3","label":"可行"}]}
]}
```
硬性规则:
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**自己判断**意图——报告和流程图都是可修改的,分四种情况处理:
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变; - 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新); - 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
- 然后在对话回复里给出修改后的完整报告正文。 - 然后在对话回复里给出修改后的完整报告正文;
- 若此前已经输出过流程图、且这次修改影响了流程结构,可在回复末尾顺带输出更新后的完整 ```flow-json 块。
2. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」): 2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」):
- **不要**改写 .md 报告文件;
- 在对话里简短说明改了什么,并在回复**最末尾**输出**完整修订版** ```flow-json 块——必须是**全量数据**(所有节点 + 连线),不是只给改动部分,前端会整体重绘。
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文给一段简短的文字说明,末尾输出完整 ```flow-json 块;
- 已有的 .md 报告**不要动**——用户随时可以在前端切回去看它。
4. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答; - **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
- **不要**改写报告文件、**不要**重新 `write_file`。 - **不要**改写报告文件、**不要**重新 `write_file`、**不要**输出 flow-json。
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告」。 判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告 / 流程图」。
## 禁止事项 ## 禁止事项
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本) - ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
@@ -68,6 +106,10 @@
- ❌ 写 Python / shell / 任何脚本去生成文件,或用 bash 跑脚本产出文件 - ❌ 写 Python / shell / 任何脚本去生成文件,或用 bash 跑脚本产出文件
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告) - ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
- ❌ 用户只是提问时还去改写报告文件 - ❌ 用户只是提问时还去改写报告文件
- ❌ 用户要求了结构化输出 / 流程图,却只写 .md 报告而不输出 flow-json 块
- ❌ 流程图模式下仍调用 `write_file` 生成 / 改写 .md 文件
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容 - ❌ 编造与原料明显矛盾的内容
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高) - ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
@@ -110,6 +110,11 @@ class SessionResponse(BaseModel):
async def list_sessions(request: Request): async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。""" """列出当前用户的 AI 写作历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user from app.gateway.deps import get_ai_writing_session_store, get_current_user
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 调用前自检:打开写作页必经此接口。若管理员在启动后手删了内置写作 agent
# 目录,这里自愈补建,保证 use_builtin_agents 开启时图节点能加载到 SOUL。
ensure_ai_writing_functional_agents()
repo = get_ai_writing_session_store(request) repo = get_ai_writing_session_store(request)
if repo is None: if repo is None:
@@ -190,6 +195,10 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
async def list_article_types(request: Request): async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。""" """列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store from app.gateway.deps import get_article_type_store
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 防御性兜底,与 list_sessions 同理(写作表单加载文章类型时也会经过这里)。
ensure_ai_writing_functional_agents()
store = get_article_type_store(request) store = get_article_type_store(request)
if store is None: if store is None:
@@ -0,0 +1,216 @@
"""Light-app registry API (轻应用中心 / 应用管理).
Registered mini-applications, **global / shared** (no user scoping). Two kinds:
- ``window_open`` — launched in a new browser tab from the 应用列表 card grid.
- ``iframe`` — embedded under a parent sidebar menu (``mount_location``) and
injected into the sidebar as a child item.
Both kinds carry ``route_params`` — a list of descriptors the frontend resolves
at launch time (login params read from ``localStorage.userInfo``; theme params
use a literal value) and appends to ``url`` as query string.
Reads are open to any authenticated user; writes require admin.
Routes (prefix ``/api/light-apps``):
GET "" list apps (filters: app_type / mount_location / enabled_only / search)
POST "" create app (admin)
GET "/{app_id}" read one
PUT "/{app_id}" update one (admin)
DELETE "/{app_id}" delete one (admin)
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.light_apps import DuplicateLightAppNameError, LightAppStore
router = APIRouter(prefix="/api/light-apps", tags=["light-apps"])
_VALID_TYPES = {"window_open", "iframe"}
# ── schemas ──────────────────────────────────────────────────────────────
class RouteParam(BaseModel):
key: str = Field(..., min_length=1, max_length=128)
paramType: str = Field(..., pattern="^(login|theme)$")
# login: which userInfo field, or "other"
source: str | None = Field(default=None, max_length=64)
# login + source == "other": literal value
customValue: str | None = Field(default=None, max_length=2048)
# theme: the theme value (e.g. dark-blue / red / blue), editable
themeValue: str | None = Field(default=None, max_length=128)
class LightAppResponse(BaseModel):
app_id: str
name: str
description: str | None = None
app_type: str
url: str
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = None
icon: str | None = None
enabled: bool = True
sort_order: int = 0
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class LightAppListResponse(BaseModel):
apps: list[LightAppResponse]
class LightAppCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str = Field(..., pattern="^(window_open|iframe)$")
url: str = Field(..., min_length=1)
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool = True
sort_order: int = 0
class LightAppUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str | None = Field(default=None, pattern="^(window_open|iframe)$")
url: str | None = Field(default=None, min_length=1)
route_params: list[RouteParam] | None = None
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool | None = None
sort_order: int | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> LightAppStore:
store = getattr(request.app.state, "light_app_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Light app store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="轻应用管理仅限管理员")
def _validate_payload(app_type: str | None, mount_location: str | None) -> None:
if app_type == "iframe" and not mount_location:
raise HTTPException(status_code=400, detail="iframe 类型必须选择挂载菜单")
def _dump_params(params) -> list[dict]:
return [p.model_dump() if isinstance(p, RouteParam) else dict(p) for p in (params or [])]
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=LightAppListResponse)
async def list_light_apps(
request: Request,
app_type: str | None = Query(default=None),
mount_location: str | None = Query(default=None),
enabled_only: bool = Query(default=False),
search: str | None = Query(default=None),
) -> LightAppListResponse:
store = _get_store(request)
rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only)
if search:
needle = search.strip().lower()
rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()]
return LightAppListResponse(apps=[LightAppResponse(**r) for r in rows])
@router.post("", response_model=LightAppResponse)
async def create_light_app(request: Request, body: LightAppCreateRequest) -> LightAppResponse:
await _require_admin(request)
_validate_payload(body.app_type, body.mount_location)
store = _get_store(request)
user_id = await get_current_user(request)
try:
row = await store.create_app(
{
"name": body.name,
"description": body.description,
"app_type": body.app_type,
"url": body.url,
"route_params": _dump_params(body.route_params),
"mount_location": body.mount_location if body.app_type == "iframe" else None,
"icon": body.icon,
"enabled": body.enabled,
"sort_order": body.sort_order,
"created_by": user_id,
}
)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
return LightAppResponse(**row)
@router.get("/{app_id}", response_model=LightAppResponse)
async def get_light_app(request: Request, app_id: str) -> LightAppResponse:
store = _get_store(request)
row = await store.get_app(app_id)
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.put("/{app_id}", response_model=LightAppResponse)
async def update_light_app(request: Request, app_id: str, body: LightAppUpdateRequest) -> LightAppResponse:
await _require_admin(request)
store = _get_store(request)
user_id = await get_current_user(request)
fields = body.model_dump(exclude_unset=True)
if "route_params" in fields:
fields["route_params"] = _dump_params(body.route_params)
# Resolve the effective type/mount to validate the iframe→mount invariant.
existing = await store.get_app(app_id)
if existing is None:
raise HTTPException(status_code=404, detail="应用不存在")
eff_type = fields.get("app_type", existing.get("app_type"))
eff_mount = fields.get("mount_location", existing.get("mount_location"))
_validate_payload(eff_type, eff_mount)
if eff_type == "window_open":
fields["mount_location"] = None
fields["updated_by"] = user_id
try:
row = await store.update_app(app_id, fields)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.delete("/{app_id}")
async def delete_light_app(request: Request, app_id: str) -> dict:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_app(app_id)
if not deleted:
raise HTTPException(status_code=404, detail="应用不存在")
return {"success": True}
@@ -19,9 +19,13 @@ import logging
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -271,3 +275,95 @@ async def delete_chain(request: Request, chain_id: str) -> None:
deleted = await store.delete_chain(chain_id, user_id) deleted = await store.delete_chain(chain_id, user_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Chain not found") raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)
@@ -61,6 +61,9 @@ class StartJobRequest(BaseModel):
model: str | None = None model: str | None = None
orchestrationMode: str = "recommend" orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None chain: dict[str, Any] | None = None
# dag 模式分层编排计划(前端 OrchestrationPlan:{mode,stages,finalSynthesis,coordinatorPrompt?})。
# 仅 orchestrationMode=="dag" 时携带;驱动后端 _run_dag。缺失 → 引擎回退 recommend。
orchestrationPlan: dict[str, Any] | None = None
# 第一轮 leader prompt;缺省用 intent。 # 第一轮 leader prompt;缺省用 intent。
seedMessage: str | None = None seedMessage: str | None = None
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 # 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
@@ -96,6 +99,7 @@ class RoundtableJobResponse(BaseModel):
pendingClarification: str | None = None pendingClarification: str | None = None
orchestrationMode: str = "recommend" orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None chain: dict[str, Any] | None = None
orchestrationPlan: dict[str, Any] | None = None
error: str | None = None error: str | None = None
dialogues: list[dict[str, Any]] = Field(default_factory=list) dialogues: list[dict[str, Any]] = Field(default_factory=list)
step3: dict[str, Any] | None = None step3: dict[str, Any] | None = None
@@ -148,6 +152,7 @@ def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
pendingClarification=row.get("pending_clarification"), pendingClarification=row.get("pending_clarification"),
orchestrationMode=row.get("orchestration_mode") or "recommend", orchestrationMode=row.get("orchestration_mode") or "recommend",
chain=row.get("chain"), chain=row.get("chain"),
orchestrationPlan=row.get("orchestration_plan"),
error=row.get("error"), error=row.get("error"),
dialogues=row.get("dialogues") or [], dialogues=row.get("dialogues") or [],
step3=row.get("step3"), step3=row.get("step3"),
@@ -187,6 +192,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
coordinator_name=body.coordinatorName, coordinator_name=body.coordinatorName,
thread_ids=body.threadIds, thread_ids=body.threadIds,
chain=body.chain, chain=body.chain,
orchestration_plan=body.orchestrationPlan,
dispatch_chain=_pending_nodes(body.agents), dispatch_chain=_pending_nodes(body.agents),
status="queued", status="queued",
) )
@@ -204,6 +210,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
coordinator_name=body.coordinatorName, coordinator_name=body.coordinatorName,
draft_id=body.draftId, draft_id=body.draftId,
chain=body.chain, chain=body.chain,
orchestration_plan=body.orchestrationPlan,
) )
) )
return _to_response(row) return _to_response(row)
@@ -289,6 +296,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
draft_id=row.get("draft_id"), draft_id=row.get("draft_id"),
is_resume=True, is_resume=True,
chain=row.get("chain"), chain=row.get("chain"),
orchestration_plan=row.get("orchestration_plan"),
) )
) )
return _to_response(updated or row) return _to_response(updated or row)
+21 -12
View File
@@ -10,18 +10,18 @@ gateway:
port: 8001 port: 8001
models: models:
# - name: deepseek-chat - name: deepseek-chat
# display_name: DeepSeek Chat display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
# model: deepseek-chat model: deepseek-chat
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445 api_key: sk-3fee562f834a4c08bb0d471ef44cb445
# base_url: https://api.deepseek.com base_url: https://api.deepseek.com
# request_timeout: 600.0 request_timeout: 600.0
# max_retries: 2 max_retries: 2
# max_tokens: 4096 max_tokens: 4096
# temperature: 1.0 temperature: 1.0
# supports_vision: false supports_vision: false
# supports_thinking: true supports_thinking: true
# - name: zai-org/GLM-5-FP8 # - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8 # display_name: zai-org/GLM-5-FP8
@@ -379,6 +379,15 @@ ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型 keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型 rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20 max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
log_system: log_system:
enabled: true enabled: true
@@ -0,0 +1,269 @@
"""AI 写作「内置功能型智能体」运行助手。
四个写作阶段(素材收集/大纲/写作/审核)的图节点在 ``config.yaml →
ai_writing.use_builtin_agents.<stage>`` 打开时,不再使用 ``prompts/*.py`` 的
硬编码提示词直调 LLM,而是改为:
1. 从 ``.deer-flow/agents/<agent-id>/`` 加载该内置 agent 的 **SOUL.md**
(人设 + 工作流 + 结构化输出契约)作为 system prompt;
2. 模型按 ``agent config.yaml 的 model > 节点显式回退模型 > state.model_name``
的优先级解析(admin 可在智能体管理页给某阶段单独配模型);
3. 流式调用该模型,chunk 通过调用方传入的回调透传到 LangGraph ``custom``
通道(前端时间线打字机不回归);
4. 按 SOUL 规定的 marker(如 ``[OUTLINE_READY]``)+ ```json``` 块解析结构化
输出,解析复用 ``_utils.parse_json`` 多级容错。
设计取舍(对应 ai-writing-内置智能体改造.md §3.2 / §7):
- **编排控制权留在节点**:writer 的逐章循环、researcher 的检索编排、editor 的
三维度循环都不交给 agent——agent 只承担「单次结构化产出」,这样干预点/逐章
流式/严格模式求助逻辑全部原样保留。
- **不经过 lead agent 运行时**:本模块位于 harness 层,不能 import ``app.*``
(intent.py 的 loopback 机制在 app 层),也无需为单轮结构化调用付出
thread/checkpointer/中间件链的成本。SOUL + per-agent 模型已覆盖本场景的
可配置性诉求;researcher 的「挂载技能」(Phase 3)再考虑接入完整运行时。
- **agent 目录缺失时抛 ``WritingAgentUnavailable``**:由节点捕获后回退旧的
硬编码 prompt 路径,种子器(app 层 ``_ai_writing_seed.py``)失败不阻断写作。
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta
logger = logging.getLogger(__name__)
# ── 内置写作 agent 的稳定 id(与 app 层种子器 _ai_writing_seed.py 对齐)──────────
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# ── SOUL 规定的结构化输出 marker(契约见各 agent 的 SOUL.md)─────────────────────
KEYWORDS_READY_MARKER = "[KEYWORDS_READY]"
MATERIALS_RANKED_MARKER = "[MATERIALS_RANKED]"
OUTLINE_READY_MARKER = "[OUTLINE_READY]"
REVIEW_READY_MARKER = "[REVIEW_READY]"
# flag 名(config.yaml ai_writing.use_builtin_agents.<stage>)→ agent id
STAGE_AGENT_IDS = {
"researcher": RESEARCHER_AGENT_ID,
"outliner": OUTLINER_AGENT_ID,
"writer": WRITER_AGENT_ID,
"editor": EDITOR_AGENT_ID,
}
class WritingAgentUnavailable(RuntimeError):
"""内置写作 agent 的 SOUL.md 缺失/为空,无法按 agent 模式运行。
节点捕获后应回退到旧的硬编码 prompt 路径(等价于 flag 关闭),不让写作
流程因 agent 目录被删而中断。
"""
def use_builtin_agent(stage: str) -> bool:
"""读 ``config.yaml → ai_writing.use_builtin_agents.<stage>`` 开关。
缺失/非法一律视为 False(默认走旧节点逻辑)。config 带 mtime 热加载,
改完 config.yaml 新会话即生效。
"""
from ._utils import get_ai_writing_config
flags = get_ai_writing_config().get("use_builtin_agents")
if not isinstance(flags, dict):
return False
return bool(flags.get(stage, False))
def load_writing_agent(agent_id: str):
"""加载内置写作 agent 的 (config, soul);SOUL 缺失抛 WritingAgentUnavailable。"""
from deerflow.config.agents_config import load_agent_config, load_agent_soul
soul = load_agent_soul(agent_id)
if not soul:
raise WritingAgentUnavailable(
f"内置写作智能体 '{agent_id}' 的 SOUL.md 缺失或为空"
"(目录可能被删除,重启 Gateway 或重新打开写作页可自动补建)"
)
try:
agent_cfg = load_agent_config(agent_id)
except ValueError:
# config.yaml 解析失败不致命:SOUL 仍可用,模型回退节点/表单选择。
logger.warning("load_writing_agent: config.yaml of '%s' is invalid, ignoring", agent_id)
agent_cfg = None
return agent_cfg, soul
def agent_display_name(agent_id: str, default: str) -> str:
"""取 agent 的展示名(progress_events.agent_name 用),加载失败回退默认值。"""
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
name = (cfg.name or "").strip() if cfg else ""
return name or default
except Exception:
return default
def resolve_agent_model_name(
agent_cfg,
state: dict,
config: RunnableConfig,
*,
explicit_fallback: str | None = None,
strict: bool = True,
) -> str | None:
"""解析本次 agent 调用使用的模型名。
优先级:agent config.yaml 的 ``model`` > ``explicit_fallback``(节点显式
传入,如 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]。
"""
candidates = (
(getattr(agent_cfg, "model", None) or "") if agent_cfg else "",
explicit_fallback or "",
(state or {}).get("model_name") or "",
(config.get("configurable", {}) or {}).get("model_name") or "",
)
for name in candidates:
if isinstance(name, str) and name.strip():
return name.strip()
if strict:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
return None
def parse_marker_json(text: str, marker: str, fallback: dict) -> dict:
"""从 agent 输出中按「marker + ```json``` 块」契约解析 JSON,多级降级。
1. ``marker\\n```json {...} ``` ``(SOUL 规定的标准形态,正则仿
intent.py::_INTENT_READY_PATTERN);
2. marker 存在但没包代码块 → 取 marker 之后的全部文本交给 parse_json;
3. marker 缺失(弱模型漏输出)→ 整段文本交给 parse_json;
4. 全部失败 → fallback。
"""
if not text or not text.strip():
return fallback
pattern = re.compile(re.escape(marker) + r"\s*```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
m = pattern.search(text)
if m:
result = parse_json(m.group(1), fallback)
if result is not fallback:
return result
idx = text.find(marker)
if idx != -1:
tail = text[idx + len(marker):]
result = parse_json(tail, fallback)
if result is not fallback:
return result
return parse_json(text, fallback)
async def stream_writing_agent(
*,
agent_id: str,
user_payload: str,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> str:
"""在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。
Args:
agent_id: 内置写作 agent id(STAGE_AGENT_IDS 的值之一)。
user_payload: 本阶段输入(意图/素材/大纲/草稿等拼成的一段文本)。
on_text: 每个流式片段回调 ``(text, is_thinking)``,调用方在回调里把
chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk /
review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。
explicit_model_fallback / strict_model: 见 resolve_agent_model_name。
Returns:
累积的**正文**文本(<think> 思考与 reasoning_content 已剥离),供
parse_marker_json / 章节拼装使用。
Raises:
WritingAgentUnavailable: SOUL 缺失(调用方回退旧 prompt 路径)。
其余模型异常原样上抛,由调用方按既有 try/except 兜底成 status:error。
"""
agent_cfg, soul = load_writing_agent(agent_id)
model_name = resolve_agent_model_name(
agent_cfg, state, config,
explicit_fallback=explicit_model_fallback,
strict=strict_model,
)
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
full_text = ""
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=soul),
HumanMessage(content=user_payload),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning and on_text is not None:
on_text(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if on_text is not None:
on_text(text, is_thinking)
return full_text
async def run_writing_agent(
*,
agent_id: str,
user_payload: str,
marker: str,
fallback: dict,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> dict:
"""跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。
模型调用异常时与 ``_utils.llm_json`` 同口径:返回
``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外——
WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。
"""
try:
full_text = await stream_writing_agent(
agent_id=agent_id,
user_payload=user_payload,
state=state,
config=config,
on_text=on_text,
explicit_model_fallback=explicit_model_fallback,
strict_model=strict_model,
)
except WritingAgentUnavailable:
raise
except Exception as e:
logger.error("run_writing_agent(%s) failed: %s", agent_id, e, exc_info=True)
return {**fallback, "_llm_error": str(e)}
return parse_marker_json(full_text, marker, fallback)
@@ -11,6 +11,16 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import ( from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
) )
from ._agent_runner import (
EDITOR_AGENT_ID,
REVIEW_READY_MARKER,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -119,19 +129,45 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
("language", LANGUAGE_REVIEW_USER.format(article=article)), ("language", LANGUAGE_REVIEW_USER.format(article=article)),
] ]
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 # 三维度循环仍由节点编排(review_chunk / review_item_done 流式形态不变),
try: # 每个维度跑一次 ai-writing-editor agent(SOUL 作 system prompt,输出
model = get_writing_model(config, state) # [REVIEW_READY] + {"score","issues"})。agent 目录缺失则静默回退旧路径。
except Exception as e: use_agent = use_builtin_agent("editor")
logger.error("editor model creation failed: %s", e, exc_info=True) agent_label = "编辑"
return { model = None
"status": "error", if use_agent:
"progress_events": [{ try:
"type": "error", "agent_name": "编辑", agent_cfg, _soul = load_writing_agent(EDITOR_AGENT_ID)
"title": "模型错误", "detail": str(e), "message": "审核失败", # 模型守卫提前到循环外:模型名缺失/配错时与旧路径同样转成 status:error
}], resolve_agent_model_name(agent_cfg, state, config)
} agent_label = agent_display_name(EDITOR_AGENT_ID, "编辑")
except WritingAgentUnavailable as e:
logger.warning("editor builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
except Exception as e:
logger.error("editor agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
results: dict[str, dict] = {} results: dict[str, dict] = {}
review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示 review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示
@@ -140,8 +176,24 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
for category, user_prompt in REVIEW_TASKS: for category, user_prompt in REVIEW_TASKS:
fallback = {"score": 80, "issues": []} fallback = {"score": 80, "issues": []}
try: try:
full_text = await _stream_one_review(model, user_prompt, category, writer) if use_agent:
res = parse_json(full_text, fallback) def _on_text(text: str, is_thinking: bool, _cat: str = category) -> None:
writer({"type": "review_chunk", "category": _cat, "text": text, "is_thinking": is_thinking})
res = await run_writing_agent(
agent_id=EDITOR_AGENT_ID,
user_payload=user_prompt,
marker=REVIEW_READY_MARKER,
fallback=fallback,
state=state,
config=config,
on_text=_on_text,
)
if res.get("_llm_error"):
review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}")
else:
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
except Exception as e: except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e) logger.warning("editor review [%s] failed: %s", category, e)
res = fallback res = fallback
@@ -191,7 +243,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
if review_errors: if review_errors:
notices.append({ notices.append({
"type": "notice", "type": "notice",
"agent_name": "编辑", "agent_name": agent_label,
"title": "模型错误", "title": "模型错误",
"detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]), "detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]),
}) })
@@ -199,11 +251,11 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
return { return {
"review_result": review, "review_result": review,
"progress_events": [ "progress_events": [
{"type": "step_start", "agent_name": "编辑", "message": "开始审核"}, {"type": "step_start", "agent_name": agent_label, "message": "开始审核"},
*notices, *notices,
{ {
"type": "review_ready", "type": "review_ready",
"agent_name": "编辑", "agent_name": agent_label,
"message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}", "message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}",
}, },
], ],
@@ -18,10 +18,46 @@ from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER, EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER, RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
) )
from ._agent_runner import (
KEYWORDS_READY_MARKER,
MATERIALS_RANKED_MARKER,
RESEARCHER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 内置 agent 模式的子任务 payload(SOUL 已含人设与输出契约,这里只给事实输入)──
_AGENT_KEYWORDS_USER = """任务:提取检索词
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。
严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。
/no_think"""
_AGENT_RANK_USER = """任务:素材排序
写作意图:{user_intent}
素材列表(每条含ID和摘要):
{materials_list}
请选出最相关的素材ID(最多8条),按相关度从高到低排序。
严格按 SOUL 约定输出 [MATERIALS_RANKED] + JSON。
/no_think"""
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search" _NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数 _NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数 _NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
@@ -89,12 +125,12 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
return (max_materials + total_keywords - 1) // total_keywords return (max_materials + total_keywords - 1) // total_keywords
def _notice(title: str, detail: str, raw: str = "") -> dict: def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。 """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
raw 非空时附带接口的原始错误响应体,前端默认折叠展示。 raw 非空时附带接口的原始错误响应体,前端默认折叠展示。
""" """
event = {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail} event = {"type": "notice", "agent_name": agent_name, "title": title, "detail": detail}
if raw: if raw:
event["raw"] = raw event["raw"] = raw
return event return event
@@ -254,6 +290,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词 # 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials")) max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher):
# 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin
# 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。
use_agent = use_builtin_agent("researcher")
researcher_label = "素材收集专家"
if use_agent:
try:
load_writing_agent(RESEARCHER_AGENT_ID)
researcher_label = agent_display_name(RESEARCHER_AGENT_ID, "素材收集专家")
except WritingAgentUnavailable as e:
logger.warning("researcher builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
def _emit(message: str) -> None: def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message}) writer({"type": "research_progress", "message": message})
@@ -272,18 +322,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
_emit(f"追加检索 {len(keywords)} 个关键词…") _emit(f"追加检索 {len(keywords)} 个关键词…")
else: else:
_emit("正在生成检索词…") _emit("正在生成检索词…")
kw_result = await llm_json( if use_agent:
EXTRACT_KEYWORDS_SYSTEM, kw_result = await run_writing_agent(
EXTRACT_KEYWORDS_USER.format( agent_id=RESEARCHER_AGENT_ID,
user_intent=intent, user_payload=_AGENT_KEYWORDS_USER.format(
article_type=article_type, user_intent=intent,
target_audience=target_audience, article_type=article_type,
keyword_count=keyword_count, target_audience=target_audience,
), keyword_count=keyword_count,
fallback={"keywords": [intent]}, ),
config=config, marker=KEYWORDS_READY_MARKER,
model_name=keyword_model, fallback={"keywords": [intent]},
) state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
)
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图 # 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent) keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords: if extra_keywords:
@@ -321,7 +388,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。 # 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s", logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "") time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step}) writer({"type": "skill_search_step", "agent_name": researcher_label, **step})
if is_skill_search: if is_skill_search:
# ── 技能编排检索 ── # ── 技能编排检索 ──
@@ -509,13 +576,26 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}" f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials for m in raw_materials
) )
rank_result = await llm_json( rank_fallback = {"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}
RANK_MATERIALS_SYSTEM, if use_agent:
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), rank_result = await run_writing_agent(
fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}, agent_id=RESEARCHER_AGENT_ID,
config=config, user_payload=_AGENT_RANK_USER.format(user_intent=intent, materials_list=materials_list),
model_name=rank_model, marker=MATERIALS_RANKED_MARKER,
) fallback=rank_fallback,
state=state,
config=config,
explicit_model_fallback=rank_model,
strict_model=False,
)
else:
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback=rank_fallback,
config=config,
model_name=rank_model,
)
rank_error = rank_result.get("_llm_error", "") rank_error = rank_result.get("_llm_error", "")
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials]) ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
id_to_mat = {m["id"]: m for m in raw_materials} id_to_mat = {m["id"]: m for m in raw_materials}
@@ -554,9 +634,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程 # 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = [] notices: list[dict] = []
if kw_result.get("_llm_error"): if kw_result.get("_llm_error"):
notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}")) notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}", agent_name=researcher_label))
if rank_error: if rank_error:
notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}")) notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}", agent_name=researcher_label))
if search_errors: if search_errors:
uniq = list(dict.fromkeys(search_errors)) uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位 # 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
@@ -567,6 +647,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"ES 波动导致检索异常", "ES 波动导致检索异常",
";".join(uniq[:3]), ";".join(uniq[:3]),
raw="\n\n----------\n\n".join(raw_dumps[:3]), raw="\n\n----------\n\n".join(raw_dumps[:3]),
agent_name=researcher_label,
)) ))
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示 # 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
@@ -583,8 +664,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点 # 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm", "status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [ "progress_events": [
{"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg}, {"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices, *notices,
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary}, {"type": "materials_ready", "agent_name": researcher_label, "message": summary},
], ],
} }
@@ -12,6 +12,15 @@ from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
) )
from ._agent_runner import (
WRITER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
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 get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -194,19 +203,44 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
def _section_title(idx: int, sec: dict) -> str: def _section_title(idx: int, sec: dict) -> str:
return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}" return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}"
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.writer):
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 # 逐章循环控制权仍在节点(严格模式求助 / 逐章流式不动),每章跑一次
try: # ai-writing-writer agent(SOUL 作 system prompt,直接产出章节 Markdown)。
model = get_writing_model(config, state) use_agent = use_builtin_agent("writer")
except Exception as e: agent_label = "作家"
logger.error("draft model creation failed: %s", e, exc_info=True) model = None
return { if use_agent:
"status": "error", try:
"progress_events": [{ agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID)
"type": "error", "agent_name": "作家", # 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error
"title": "模型错误", "detail": str(e), "message": "草稿生成失败", resolve_agent_model_name(agent_cfg, state, config)
}], 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)
use_agent = False
except Exception as e:
logger.error("draft agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
section_titles: list[str] = [] section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -246,36 +280,55 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
parser = ThinkTagParser() parser = ThinkTagParser()
try: try:
async for chunk in model.astream([ if use_agent:
SystemMessage(content=DRAFT_SECTION_SYSTEM), def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None:
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({ writer({
"type": "draft_chunk", "type": "draft_chunk",
"section_index": i, "section_index": _i,
"section_title": sec_title, "section_title": _t,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text, "text": text,
"is_thinking": is_thinking, "is_thinking": is_thinking,
"is_section_done": False, "is_section_done": False,
}) })
section_buf = await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=user_msg,
state=state,
config=config,
on_text=_on_text,
)
else:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
# 章节完成 # 章节完成
writer({ writer({
"type": "draft_chunk", "type": "draft_chunk",
@@ -319,9 +372,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"blocked_sections": blocked_sections, "blocked_sections": blocked_sections,
"status": "awaiting_section_help", "status": "awaiting_section_help",
"progress_events": [ "progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, {"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
{ {
"type": "notice", "agent_name": "作家", "title": "素材不足", "type": "notice", "agent_name": agent_label, "title": "素材不足",
"detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。", "detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。",
}, },
], ],
@@ -356,14 +409,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
existing_text=section_bufs[idx], existing_text=section_bufs[idx],
target_words=expand_target, target_words=expand_target,
) )
resp = await model.ainvoke([ if use_agent:
SystemMessage(content=DRAFT_SECTION_SYSTEM), expanded = (await stream_writing_agent(
HumanMessage(content=expand_msg), agent_id=WRITER_AGENT_ID,
]) user_payload=expand_msg,
parser = ThinkTagParser() state=state,
expanded = "".join( config=config,
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking on_text=None,
).strip() )).strip()
else:
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短 # 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]): if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded section_bufs[idx] = expanded
@@ -390,7 +452,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
if section_errors: if section_errors:
notices.append({ notices.append({
"type": "notice", "type": "notice",
"agent_name": "作家", "agent_name": agent_label,
"title": "模型错误", "title": "模型错误",
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]), "detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
}) })
@@ -400,11 +462,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"drafts": [draft], "drafts": [draft],
"status": "awaiting_draft_confirm", "status": "awaiting_draft_confirm",
"progress_events": [ "progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, {"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
*notices, *notices,
{ {
"type": "draft_ready", "type": "draft_ready",
"agent_name": "作家", "agent_name": agent_label,
"message": f"草稿完成,共 {word_count} 字", "message": f"草稿完成,共 {word_count} 字",
"draft_title": outline.get("title", "文章"), "draft_title": outline.get("title", "文章"),
"draft_markdown": full_markdown, "draft_markdown": full_markdown,
@@ -19,10 +19,51 @@ from ..prompts.writer_prompts import (
OUTLINE_USER, OUTLINE_USER,
) )
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ._agent_runner import (
OUTLINE_READY_MARKER,
OUTLINER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
parse_marker_json,
stream_writing_agent,
use_builtin_agent,
)
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 内置 agent 模式的输入 payload(SOUL 已含人设/层级语义/输出契约,这里只给本次任务的事实输入)──
_AGENT_OUTLINE_USER = """任务模式:新规划
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字{feedback_section}
可用素材摘要:
{materials_summary}
请规划文章大纲(3-5 个章节),严格按 SOUL 约定输出 [OUTLINE_READY] + JSON。"""
_AGENT_OUTLINE_REVISE_USER = """任务模式:修改模式
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字
当前大纲(JSON):
{current_outline}
用户修改要求:{feedback}
可用素材摘要:
{materials_summary}
请在「当前大纲」基础上落实「用户修改要求」,未提到的章节保持原样,严格按 SOUL 约定输出 [OUTLINE_READY] + 调整后的完整大纲 JSON。"""
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。 # 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。
_MAX_OUTLINE_MATERIALS = 12 _MAX_OUTLINE_MATERIALS = 12
_MATERIAL_SNIPPET_CHARS = 150 _MATERIAL_SNIPPET_CHARS = 150
@@ -212,28 +253,40 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if user_outline_raw and not current_outline and not is_revise: if user_outline_raw and not current_outline and not is_revise:
return await _build_outline_from_user(state, config, pkg, user_outline_raw) return await _build_outline_from_user(state, config, pkg, user_outline_raw)
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.outliner):
# SOUL 作 system prompt + [OUTLINE_READY] 契约解析;目录缺失静默回退旧路径。
use_agent = use_builtin_agent("outliner")
agent_label = "作家"
if use_agent:
try:
load_writing_agent(OUTLINER_AGENT_ID)
agent_label = agent_display_name(OUTLINER_AGENT_ID, "大纲规划师")
except WritingAgentUnavailable as e:
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
fmt_args = dict(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
)
if is_revise: if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format( revise_args = dict(
user_intent=state["user_intent"], fmt_args,
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2), current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2),
feedback=feedback, feedback=feedback,
materials_summary=_materials_summary(pkg),
) )
user_prompt = OUTLINE_REVISE_USER.format(**revise_args)
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args)
else: else:
system_prompt = OUTLINE_SYSTEM system_prompt = OUTLINE_SYSTEM
feedback_section = f"\n重新规划要求:{feedback}" if feedback else "" feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
user_prompt = OUTLINE_USER.format( gen_args = dict(fmt_args, feedback_section=feedback_section)
user_intent=state["user_intent"], user_prompt = OUTLINE_USER.format(**gen_args)
article_type=state.get("article_type", "analysis"), agent_payload = _AGENT_OUTLINE_USER.format(**gen_args)
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
feedback_section=feedback_section,
)
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。 # 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
writer = safe_get_stream_writer() writer = safe_get_stream_writer()
@@ -242,35 +295,47 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
parser = ThinkTagParser() parser = ThinkTagParser()
try: try:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError, if use_agent:
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 def _on_text(text: str, is_thinking: bool) -> None:
model = get_writing_model(config, state) writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
async for chunk in model.astream([
SystemMessage(content=system_prompt), full_text = await stream_writing_agent(
HumanMessage(content=user_prompt), agent_id=OUTLINER_AGENT_ID,
]): user_payload=agent_payload,
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 state=state,
reasoning = reasoning_delta(chunk) config=config,
if reasoning: on_text=_on_text,
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True}) )
delta = _content_to_text(chunk.content) else:
if not delta: # get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
continue # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
for is_thinking, text in parser.feed(delta): model = get_writing_model(config, state)
if not is_thinking: async for chunk in model.astream([
full_text += text SystemMessage(content=system_prompt),
writer({ HumanMessage(content=user_prompt),
"type": "outline_chunk", ]):
"text": text, # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
"is_thinking": is_thinking, reasoning = reasoning_delta(chunk)
}) if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as e: except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True) logger.error("outline streaming failed: %s", e, exc_info=True)
return { return {
"status": "error", "status": "error",
"progress_events": [{ "progress_events": [{
"type": "error", "type": "error",
"agent_name": "作家", "agent_name": agent_label,
"title": "模型错误", "title": "模型错误",
"detail": str(e), "detail": str(e),
"message": "大纲生成失败", "message": "大纲生成失败",
@@ -309,7 +374,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
}, },
], ],
} }
result = parse_json(full_text, fallback) if use_agent:
result = parse_marker_json(full_text, OUTLINE_READY_MARKER, fallback)
else:
result = parse_json(full_text, fallback)
outline = _parse_outline(result) outline = _parse_outline(result)
section_count = len(outline["sections"]) section_count = len(outline["sections"])
@@ -318,7 +386,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
"current_outline": outline, "current_outline": outline,
"status": "awaiting_outline_confirm", "status": "awaiting_outline_confirm",
"progress_events": [ "progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "开始规划大纲"}, {"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"大纲已生成,共 {section_count} 个章节"}, {"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"},
], ],
} }
@@ -20,13 +20,18 @@ from .types import (
CHAIN_CLOSEOUT_PROMPT, CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT, CONTINUE_PROMPT,
MAX_CYCLES, MAX_CYCLES,
DagPlan,
DagStage,
LeaderTurn, LeaderTurn,
ReportResult, ReportResult,
RoundtableGateway, RoundtableGateway,
SeatRef, SeatRef,
SeatTurn, SeatTurn,
build_chain_leader_task, build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
pick_chain_task, pick_chain_task,
pick_dag_seat_task,
) )
__all__ = [ __all__ = [
@@ -40,9 +45,14 @@ __all__ = [
"SeatTurn", "SeatTurn",
"ReportResult", "ReportResult",
"SeatRef", "SeatRef",
"DagStage",
"DagPlan",
"MAX_CYCLES", "MAX_CYCLES",
"CONTINUE_PROMPT", "CONTINUE_PROMPT",
"CHAIN_CLOSEOUT_PROMPT", "CHAIN_CLOSEOUT_PROMPT",
"build_chain_leader_task", "build_chain_leader_task",
"pick_chain_task", "pick_chain_task",
"build_dag_stage_leader_task",
"parse_dag_plan",
"pick_dag_seat_task",
] ]
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -27,11 +28,15 @@ from .types import (
CHAIN_CLOSEOUT_PROMPT, CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT, CONTINUE_PROMPT,
MAX_CYCLES, MAX_CYCLES,
DagPlan,
ReportResult, ReportResult,
RoundtableGateway, RoundtableGateway,
SeatRef, SeatRef,
build_chain_leader_task, build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
pick_chain_task, pick_chain_task,
pick_dag_seat_task,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,6 +78,7 @@ async def run_orchestration(
mode: str = "recommend", mode: str = "recommend",
intent_text: str = "", intent_text: str = "",
enable_report: bool = True, enable_report: bool = True,
plan: dict | None = None,
) -> OrchestrationResult: ) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。 """运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -84,8 +90,10 @@ async def run_orchestration(
coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。 coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。
model: 选定模型(None/"" 走后端默认)。 model: 选定模型(None/"" 走后端默认)。
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。 seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。 mode: "recommend" | "chain" | "dag"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。 intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
plan: dag 模式的分层编排计划(前端 ``OrchestrationPlan`` dict)。mode=="dag" 且
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、 enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、
不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent 不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent
在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。 在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。
@@ -93,9 +101,13 @@ async def run_orchestration(
intent = (intent_text or seed_message or "").strip() intent = (intent_text or seed_message or "").strip()
try: try:
await progress.begin() await progress.begin()
if mode == "chain": dag_plan = parse_dag_plan(plan) if mode == "dag" else None
if mode == "dag" and dag_plan is not None:
outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan)
elif mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
else: else:
# recommend,或 dag 但 plan 缺失/为空 → 回退总控自由派活(绝不静默跑错语义)。
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
if outcome.kind == "clarification": if outcome.kind == "clarification":
@@ -314,6 +326,108 @@ async def _run_chain(
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── dag 模式(分层 DAG:stage 间串行、stage 内并行)──────────────────────────────
async def _run_dag(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
intent_text: str,
plan: DagPlan,
) -> _LoopOutcome:
"""分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行
(``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。
与前端 ``runDagOrchestration``「总控驱动每 stage 派活」同构(§12.4)。并行席位各自独立
thread(``run_seat`` 内 ``thread_ids[agent_id]``),故进程内 gather 安全、无沙箱串台。
"""
deliveries: dict[str, str] = {}
last_leader = ""
total = len(plan.stages)
name_by_id = {a.agent_id: a.name for a in agents}
def _seat_ref(agent_id: str) -> SeatRef:
return SeatRef(agent_id, name_by_id.get(agent_id, agent_id))
for i, stage in enumerate(plan.stages):
stage_seats = [_seat_ref(aid) for aid in stage.agent_ids]
if not stage_seats:
continue
# (a) 总控「派活轮」:钉死本 stage 席位,带任务背景 / 本阶段目标 / 总控编排指引 + 此前交付。
await progress.leader_thinking(i + 1)
leader_task = build_dag_stage_leader_task(
seed_message=seed_message,
stage_index=i,
total_stages=total,
seats=stage_seats,
is_first=(i == 0),
goal=stage.goal,
coordinator_prompt=plan.coordinator_prompt,
)
block = _deliveries_block(deliveries, name_by_id)
if block:
leader_task += f"\n\n此前各阶段席位已完成的交付如下(供你承上启下、为本批席位派活):\n{block}"
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=leader_task,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
turn.final_text, i + 1, tool_calls=turn.tool_calls, thread_id=thread_ids.get(coordinator_name)
)
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
# (b) 批内并行:每个钉死席位拿到总控派给它的 task(漏派则给干净本职任务),并行执行。
await progress.dispatching(i + 1)
for seat in stage_seats:
await progress.seat_active(seat.agent_id)
async def _run_one(seat: SeatRef) -> None:
task = pick_dag_seat_task(turn.dispatched, seat.agent_id, seat.name, goal=stage.goal)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
deliveries[seat.agent_id] = seat_turn.final_text
await progress.add_seat_dialogue(
seat.agent_id, seat.name, seat_turn.final_text, i + 1,
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
)
await progress.seat_done(seat.agent_id, i + 1)
await asyncio.gather(*(_run_one(seat) for seat in stage_seats))
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
# (c) 收口:finalSynthesis 时总控综合全部 stage 交付,产出最终结论。
if plan.final_synthesis and total > 0:
await progress.leader_thinking(total + 1)
block = _deliveries_block(deliveries, name_by_id)
closeout_msg = CHAIN_CLOSEOUT_PROMPT + (f"\n\n各阶段席位完整交付如下:\n{block}" if block else "")
closeout = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=closeout_msg,
model=model,
)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
closeout.final_text, total + 1, tool_calls=closeout.tool_calls, thread_id=thread_ids.get(coordinator_name)
)
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ───────────────────────────────────────────────────────────────── # ── 报告阶段 ─────────────────────────────────────────────────────────────────
@@ -37,9 +37,10 @@ class SimulatedRoundtableGateway:
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn: async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
await self._tick() await self._tick()
self._leader_calls += 1 self._leader_calls += 1
if self._mode == "chain": if self._mode in ("chain", "dag"):
# chain 模式由引擎按链条顺序强制跑席位,leader 只给说明、不派活。 # chain / dag 模式由引擎按计划钉死席位强制执行,leader 只给说明、不显式派活
return LeaderTurn(final_text="(模拟)总控:请当前席位按职责给出分析。", dispatched=[]) # (引擎的 pick_*_task 会回退给席位干净本职任务)。
return LeaderTurn(final_text="(模拟)总控:请本阶段席位按职责给出分析。", dispatched=[])
# recommend:首轮派活全部席位,次轮共识。 # recommend:首轮派活全部席位,次轮共识。
if self._leader_calls == 1: if self._leader_calls == 1:
return LeaderTurn( return LeaderTurn(
@@ -14,7 +14,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
# 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。 # 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。
MAX_CYCLES = 8 MAX_CYCLES = 8
@@ -195,3 +195,137 @@ def pick_chain_task(
f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付," f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付,"
"从你的专业职责出发给出你的分析与交付。" "从你的专业职责出发给出你的分析与交付。"
) )
# ── dag 模式:分层编排计划 + prompt 构造(移植自前端 dag-types.ts)─────────────────
#
# DAG = 有序的 stage 列表:stage 间串行、stage 内并行。线性链是其特例(每层 1 席位)。
# 与前端 `runDagOrchestration`「总控驱动每 stage 派活」同构(§3.2 方案 A + 批内并行):
# 每个 stage 一轮总控派活(钉死本批席位)→ 批内席位并行执行 → 下一 stage 总控再派。
@dataclass(frozen=True)
class DagStage:
"""一个并行批次:``agent_ids`` 内的席位并行执行;``goal`` = 用户写的本阶段目标(可空)。"""
agent_ids: list[str]
goal: str | None = None
@property
def is_parallel(self) -> bool:
return len(self.agent_ids) > 1
@dataclass(frozen=True)
class DagPlan:
"""用户编排出的执行计划。``stages`` 顺序即串行顺序。"""
stages: list[DagStage]
final_synthesis: bool = True
coordinator_prompt: str | None = None
def parse_dag_plan(raw: Any) -> DagPlan | None:
"""把前端 ``OrchestrationPlan`` 形状的 dict 解析成 ``DagPlan``(容错)。
无 stages / 全空 stage → 返回 None(调用方据此回退 recommend)。只保留有 agentId 的 stage。
"""
if not isinstance(raw, dict):
return None
stages_raw = raw.get("stages")
if not isinstance(stages_raw, list):
return None
stages: list[DagStage] = []
for s in stages_raw:
if not isinstance(s, dict):
continue
ids_raw = s.get("agentIds") or s.get("agent_ids") or []
ids = [str(a) for a in ids_raw if str(a or "").strip()]
if not ids:
continue
goal = s.get("goal")
goal_text = str(goal).strip() if isinstance(goal, str) and goal.strip() else None
stages.append(DagStage(agent_ids=ids, goal=goal_text))
if not stages:
return None
final_synthesis = raw.get("finalSynthesis")
if final_synthesis is None:
final_synthesis = raw.get("final_synthesis", True)
cp = raw.get("coordinatorPrompt") or raw.get("coordinator_prompt")
cp_text = str(cp).strip() if isinstance(cp, str) and cp.strip() else None
return DagPlan(stages=stages, final_synthesis=bool(final_synthesis), coordinator_prompt=cp_text)
def build_dag_stage_leader_task(
*,
seed_message: str,
stage_index: int,
total_stages: int,
seats: list[SeatRef],
is_first: bool,
goal: str | None = None,
coordinator_prompt: str | None = None,
) -> str:
"""构造 dag「每 stage 派活轮」leader prompt:约束总控只对本 stage 钉死席位派活。
对齐前端 ``buildDagStageLeaderTask``:首 stage 带任务背景,后续 stage 靠后端注入的此前
交付摘要承上;带「本阶段目标」与「总控编排指引(用户预设,最高优先)」。
"""
ordinal = f"{stage_index + 1}/{total_stages}"
seat_list = "、".join(f"「{s.name}」(agent_name: {s.agent_id})" for s in seats)
is_parallel = len(seats) > 1
goal_text = (goal or "").strip()
coord_text = (coordinator_prompt or "").strip()
lines: list[str] = []
if is_first:
lines += ["这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seed_message, ""]
else:
lines += ["分层研讨继续。系统已把此前各阶段席位的交付摘要注入到你本轮输入里,请据此承上启下。", ""]
if coord_text:
lines += [
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coord_text,
"",
]
lines += [
f"本研讨严格按预设分层顺序进行,共 {total_stages} 个阶段,现在是第 {ordinal} 个阶段。",
f"本阶段需要执行的席位(已由编排计划钉死)是:{seat_list}。",
]
if goal_text:
lines += [
f"【本阶段目标】{goal_text}",
"请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。",
]
lines += [
f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活,"
f"结合任务背景{'、本阶段目标' if goal_text else ''}与此前交付,为每个席位分配聚焦其职责的本阶段任务。",
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
]
if is_parallel:
lines.append(f"注意:本阶段这 {len(seats)} 个席位会并行执行,请确保给每个席位的 task 都能独立完成,不要让它们互相等待。")
return "\n".join(lines)
def pick_dag_seat_task(
dispatched: list[tuple[str, str]],
seat_agent_id: str,
seat_name: str,
*,
goal: str | None = None,
) -> str:
"""从总控「每 stage 派活轮」终态取派给某钉死席位的 task(移植自前端 pickDagSeatTask)。
1) 命中 agent_name 完全一致的派活 → 用它的 task;
2) 未命中 / 总控只写文字没派活 → **不**把总控派活叙述喂给席位(防席位模仿派活),改给干净
本职任务(任务背景 + 此前交付由 ``_seat_message`` 另行注入)。
两条分支都带「本阶段目标」兜底。
"""
goal_rule = f"\n【本阶段目标】{goal.strip()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。" if (goal or "").strip() else ""
wanted = seat_agent_id.lower()
for name, task in dispatched:
if name.lower() == wanted and (task or "").strip():
return f"请你作为「{seat_name}」,完成总控派给你的本阶段任务:\n{task.strip()}{goal_rule}"
return (
f"请你作为「{seat_name}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。"
f"此前各阶段席位的交付已在上下文里,请据此承接。{goal_rule}"
)
@@ -0,0 +1,23 @@
"""Light-app registry persistence (轻应用中心)."""
from __future__ import annotations
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
from deerflow.persistence.light_apps.memory import MemoryLightAppStore
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.light_apps.sql import LightAppRepository
__all__ = [
"DuplicateLightAppNameError",
"LightAppRepository",
"LightAppRow",
"LightAppStore",
"MemoryLightAppStore",
"make_light_app_store",
]
def make_light_app_store(session_factory):
if session_factory is None:
return MemoryLightAppStore()
return LightAppRepository(session_factory)
@@ -0,0 +1,46 @@
"""Abstract light-app registry persistence API.
Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the
router gates writes behind admin. ``created_by`` / ``updated_by`` are audit-only.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class DuplicateLightAppNameError(ValueError):
"""Raised when creating/updating an app would collide on the unique name."""
class LightAppStore(ABC):
@abstractmethod
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
"""Return registered apps (newest first), optionally filtered."""
raise NotImplementedError
@abstractmethod
async def get_app(self, app_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create an app. Raises ``DuplicateLightAppNameError`` on a name clash."""
raise NotImplementedError
@abstractmethod
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Partial update. Returns None if the app does not exist. Raises
``DuplicateLightAppNameError`` when renaming onto an existing name."""
raise NotImplementedError
@abstractmethod
async def delete_app(self, app_id: str) -> bool:
raise NotImplementedError
@@ -0,0 +1,97 @@
"""In-memory light-app registry store (used when no DB is configured / tests)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
_WRITABLE = {
"name",
"description",
"app_type",
"url",
"route_params",
"mount_location",
"icon",
"enabled",
"sort_order",
"updated_by",
}
class MemoryLightAppStore(LightAppStore):
def __init__(self) -> None:
self._apps: dict[str, dict[str, Any]] = {}
@staticmethod
def _public(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
out["route_params"] = list(out.get("route_params") or [])
return out
def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return any(r["name"] == name and r["app_id"] != exclude_id for r in self._apps.values())
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
rows = list(self._apps.values())
if app_type:
rows = [r for r in rows if r["app_type"] == app_type]
if mount_location:
rows = [r for r in rows if r.get("mount_location") == mount_location]
if enabled_only:
rows = [r for r in rows if r.get("enabled", True)]
rows.sort(key=lambda r: (r.get("sort_order", 0), r.get("created_at", "")))
return [self._public(r) for r in rows]
async def get_app(self, app_id: str) -> dict[str, Any] | None:
row = self._apps.get(app_id)
return self._public(row) if row is not None else None
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
if self._name_taken(data["name"]):
raise DuplicateLightAppNameError(data["name"])
now = datetime.now(UTC).isoformat()
app_id = data.get("app_id") or uuid.uuid4().hex[:12]
row = {
"app_id": app_id,
"name": data["name"],
"description": data.get("description"),
"app_type": data["app_type"],
"url": data["url"],
"route_params": data.get("route_params") or [],
"mount_location": data.get("mount_location"),
"icon": data.get("icon"),
"enabled": data.get("enabled", True),
"sort_order": data.get("sort_order", 0),
"created_by": data.get("created_by"),
"updated_by": data.get("created_by"),
"created_at": now,
"updated_at": now,
}
self._apps[app_id] = row
return self._public(row)
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._apps.get(app_id)
if row is None:
return None
new_name = data.get("name")
if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=app_id):
raise DuplicateLightAppNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
row[key] = value
row["updated_at"] = datetime.now(UTC).isoformat()
return self._public(row)
async def delete_app(self, app_id: str) -> bool:
return self._apps.pop(app_id, None) is not None
@@ -0,0 +1,42 @@
"""ORM model for the light-app registry (轻应用中心).
Global / shared — there is no ``user_id`` scoping; every account sees the same
registered apps. ``created_by`` / ``updated_by`` are audit-only.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class LightAppRow(Base):
__tablename__ = "light_apps"
__table_args__ = (UniqueConstraint("name", name="uq_light_apps_name"),)
app_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(127))
description: Mapped[str | None] = mapped_column(Text)
# "window_open" (open in a new tab) | "iframe" (embed under a parent menu)
app_type: Mapped[str] = mapped_column(String(32))
url: Mapped[str] = mapped_column(Text)
# List of route-param descriptors resolved at launch time by the frontend:
# {"key": str, "paramType": "login"|"theme", "source": str,
# "customValue": str, "themeValue": str}
route_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list)
# iframe only: the parent sidebar menu id to mount under
# (e.g. "xk-center-task" / "pg-center-task" / "ty-center-task" /
# "comprehensive-management"). NULL for window_open apps.
mount_location: Mapped[str | None] = mapped_column(String(64), index=True)
icon: Mapped[str | None] = mapped_column(String(64))
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_by: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,135 @@
"""SQLAlchemy-backed light-app registry store (global / shared)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
from deerflow.persistence.light_apps.model import LightAppRow
# Columns a caller may set/patch (everything except identity + timestamps).
_WRITABLE = {
"name",
"description",
"app_type",
"url",
"route_params",
"mount_location",
"icon",
"enabled",
"sort_order",
"updated_by",
}
def _to_dict(row: LightAppRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
data["route_params"] = data.pop("route_params_json", None) or []
return data
def _column_for(key: str) -> str:
return "route_params_json" if key == "route_params" else key
class LightAppRepository(LightAppStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
stmt = select(LightAppRow)
if app_type:
stmt = stmt.where(LightAppRow.app_type == app_type)
if mount_location:
stmt = stmt.where(LightAppRow.mount_location == mount_location)
if enabled_only:
stmt = stmt.where(LightAppRow.enabled.is_(True))
stmt = stmt.order_by(LightAppRow.sort_order.asc(), LightAppRow.created_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def get_app(self, app_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
return _to_dict(row) if row is not None else None
async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool:
stmt = select(LightAppRow.app_id).where(LightAppRow.name == name)
if exclude_id is not None:
stmt = stmt.where(LightAppRow.app_id != exclude_id)
result = await session.execute(stmt.limit(1))
return result.first() is not None
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
row = LightAppRow(
app_id=data.get("app_id") or uuid.uuid4().hex[:12],
name=data["name"],
description=data.get("description"),
app_type=data["app_type"],
url=data["url"],
route_params_json=data.get("route_params") or [],
mount_location=data.get("mount_location"),
icon=data.get("icon"),
enabled=data.get("enabled", True),
sort_order=data.get("sort_order", 0),
created_by=data.get("created_by"),
updated_by=data.get("created_by"),
)
async with self._sf() as session:
if await self._name_taken(session, row.name):
raise DuplicateLightAppNameError(row.name)
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateLightAppNameError(row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
if row is None:
return None
new_name = data.get("name")
if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=app_id):
raise DuplicateLightAppNameError(new_name)
for key, value in data.items():
if key not in _WRITABLE:
continue
setattr(row, _column_for(key), value)
row.updated_at = datetime.now(UTC)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateLightAppNameError(new_name or row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def delete_app(self, app_id: str) -> bool:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,47 @@
"""Add orchestration_plan column to roundtable_jobs.
后台挂起支持分层 DAG 编排(业务链并行):把前端 ``OrchestrationPlan``(stages /
阶段目标 / 总控提示词 / finalSynthesis)整份存进 job 行,驱动后端 ``_run_dag``
(stage 串行、stage 内并行)+ 前端大弹窗按 stage 分层画流程图 + resume 时重建计划。
LONGTEXT(计划 JSON 可超 64 KB)。
Revision ID: 20260612_01
Revises: 20260611_01
Create Date: 2026-06-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260612_01"
down_revision = "20260611_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 _has_table("roundtable_jobs") and not _has_column("roundtable_jobs", "orchestration_plan"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_jobs", sa.Column("orchestration_plan", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_jobs", "orchestration_plan"):
op.drop_column("roundtable_jobs", "orchestration_plan")
@@ -36,6 +36,7 @@ from deerflow.knowledge.models import (
KnowledgeSourceRow, KnowledgeSourceRow,
KnowledgeTagRow, KnowledgeTagRow,
) )
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow from deerflow.persistence.notifications.model import NotificationRow
@@ -83,6 +84,7 @@ __all__ = [
"KnowledgeRelationRow", "KnowledgeRelationRow",
"KnowledgeSourceRow", "KnowledgeSourceRow",
"KnowledgeTagRow", "KnowledgeTagRow",
"LightAppRow",
"LlmCallMetricRow", "LlmCallMetricRow",
"NotificationRow", "NotificationRow",
"ParallelAgentTaskRow", "ParallelAgentTaskRow",
@@ -51,10 +51,14 @@ class RoundtableJobRow(Base):
# ThreadIdMap: JSON object { "<agent_id>": "<thread_id>", ... }. # ThreadIdMap: JSON object { "<agent_id>": "<thread_id>", ... }.
thread_ids: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) thread_ids: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
coordinator_name: Mapped[str | None] = mapped_column(String(128), nullable=True) coordinator_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# "recommend"(总控自由派活)| "chain"(按业务链条顺序派活). # "recommend"(总控自由派活)| "chain"(按业务链条顺序派活)| "dag"(分层 DAG:stage 间串行、stage 内并行).
orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend") orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend")
# chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null). # chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null).
chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# dag 模式分层编排计划:JSON object { mode, stages:[{id,agentIds,goal?}], finalSynthesis,
# coordinatorPrompt? }(对齐前端 OrchestrationPlan)。仅 dag 模式有值;驱动后端 _run_dag
# (stage 串行 / stage 内并行)+ 前端大弹窗按 stage 分层画流程图 + resume 时重建计划。
orchestration_plan: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。 # 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。
# 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。 # 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。
dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
@@ -55,7 +55,7 @@ class RoundtableJobRepository:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime): if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat() d[k] = d[k].isoformat()
for k in ("dispatch_chain", "thread_ids", "chain", "dialogues", "step3"): for k in ("dispatch_chain", "thread_ids", "chain", "orchestration_plan", "dialogues", "step3"):
d[k] = _loads(d.get(k)) d[k] = _loads(d.get(k))
return d return d
@@ -71,6 +71,7 @@ class RoundtableJobRepository:
coordinator_name: str | None = None, coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None, thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None, chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None, dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued", status: str = "queued",
phase: str = "initializing", phase: str = "initializing",
@@ -92,6 +93,7 @@ class RoundtableJobRepository:
coordinator_name=coordinator_name, coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode, orchestration_mode=orchestration_mode,
chain=_dumps(chain), chain=_dumps(chain),
orchestration_plan=_dumps(orchestration_plan),
error=None, error=None,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -0,0 +1,110 @@
"""后台挂起「分层 DAG」实测脚本:带一个并行 stage 的业务链。
POST 启动一个 dag 作业(stage1 两席位并行 → stage2 → stage3),轮询打印每帧的
dispatchChain 节点状态,直观看到:①stage1 两席位**同时 active**(并行);②stage2/3
在前一 stage 全 done 后才 active(串行);③orchestrationPlan 在响应里回得来(持久化生效)。
用法:PYTHONPATH=. .venv/Scripts/python scripts/probe_roundtable_dag_job.py
环境:GATEWAY_URL 默认 http://127.0.0.1:8001;AUTH_COOKIE 可选。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
AUTH_BEARER = os.getenv("AUTH_BEARER", "")
# 4 个已 seed 的圆桌席位。stage1 = 风险审查 ‖ 方案设计(并行),stage2 = 执行规划,stage3 = 总结。
AGENTS = [
{"agentId": "roundtable-risk-review", "name": "风险审查"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-execution-plan", "name": "执行规划"},
{"agentId": "roundtable-summary", "name": "方案总结"},
]
PLAN = {
"mode": "dag",
"stages": [
{"id": "s0", "agentIds": ["roundtable-risk-review", "roundtable-solution-design"], "goal": "并行评估风险并产出初步方案"},
{"id": "s1", "agentIds": ["roundtable-execution-plan"], "goal": "据风险与方案制定执行规划"},
{"id": "s2", "agentIds": ["roundtable-summary"]},
],
"finalSynthesis": True,
"coordinatorPrompt": "先并行评估、再规划执行、最后总结,逐阶段承接。",
}
INTENT = "为一家中型制造企业设计一套数字化转型的整体方案"
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
if AUTH_BEARER:
h["Authorization"] = f"Bearer {AUTH_BEARER}"
return h
def _chain_str(nodes: list[dict]) -> str:
sym = {"pending": ".", "active": ">RUN", "done": "DONE"}
return " ".join(f"{n['name']}={sym.get(n['state'], '?')}" for n in nodes)
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode=dag (stage1 并行)")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": "dag", "orchestrationPlan": PLAN},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
# 验证 plan 在响应里回得来(持久化 + _to_response 生效)
plan_back = job.get("orchestrationPlan")
print(f"[probe] started {job_id} orchestrationMode={job.get('orchestrationMode')} "
f"orchestrationPlan stages={len((plan_back or {}).get('stages') or [])}")
last = ""
t0 = time.perf_counter()
saw_parallel = False
while True:
await asyncio.sleep(1.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
nodes = j.get("dispatchChain") or []
active_seats = [n["name"] for n in nodes if n["state"] == "active"]
if len(active_seats) >= 2:
saw_parallel = True
line = f"status={j['status']} phase={j['phase']} consensus={j['consensusPercentage']}% [{_chain_str(nodes)}]"
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.1f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"\n[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 对话顺序证明 stage 串行
seats = [d for d in (j.get("dialogues") or []) if d["role"] == "seat"]
print(f"[probe] 席位交付顺序: {[d['agentId'] for d in seats]}")
leaders = [d for d in (j.get("dialogues") or []) if d["role"] == "leader"]
print(f"[probe] 总控发言轮数: {len(leaders)}(3 stage 派活 + 1 收口 = 4)")
print(f"[probe] 是否观察到 stage 内并行(≥2 席位同时 active): {'是 ✅' if saw_parallel else '否 ❌'}")
sample = next((d["content"] for d in seats if d.get("content")), "")
print(f"[probe] 席位交付样例: {sample[:80]}")
return
if __name__ == "__main__":
asyncio.run(main())
@@ -77,6 +77,11 @@ CALL _add_column_if_missing('agents', 'square_id', "VARCHAR(64) NOT NULL DEFAULT
-- String(128), nullable=True, index=True。 -- String(128), nullable=True, index=True。
CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL'); CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- orchestration_plan: dag 模式分层编排计划 JSON(stages/阶段目标/总控提示词/finalSynthesis),
-- 对应 migration 20260612_01。驱动后端 _run_dag + 前端大弹窗分层流程图。
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
-- ---- 清理临时存储过程 -------------------------------------------------------- -- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing; DROP PROCEDURE IF EXISTS _add_column_if_missing;