feat(圆桌): Step3 三产物并行 + 大屏多页模式 + 后台挂起查看恢复流程

后台挂起 Step3:
- engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离
- request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告
- ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏)
- 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染

结果绘制大屏多页模式(模式二):
- report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随
- 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮

最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱

后台挂起查看=恢复实时流程:
- handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随
- saveDraftNow 强制存(发起前+查看前各一次)防丢

第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、
peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
This commit is contained in:
董宏凯 committed 2026-06-13 10:36:46 +08:00
1 parent a7596001c5
commit d5f319d9cb
51 files changed
+5016 -1186

No files matched your search

-319
View File
@@ -1,319 +0,0 @@
// 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)));
});
@@ -223,3 +223,31 @@ result = await run_writing_agent(
### 已知存量测试失败(与本次无关)
`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 文案),改造前即失败,待另行清理。
## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施)
本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。
### 10.1 检索类型(material_source)收敛为两项
- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。
- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。
### 10.2 通用检索 = 素材收集专家配置的技能编排
- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。
- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。
- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。
- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。
### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加**
- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。
- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。
- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。
- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。
### 10.4 测试
- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。
- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。
@@ -54,21 +54,22 @@ roundtable-report 真调 deepseek、真搜索、跑到 report run success)。
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)✅ 已于 2026-06-12 修复并恢复
- **根因(2026-06-12 定位)**:`dc8fca6`(2026-06-11 智能体可见性对齐)给 `services._enforce_agent_access`
加 admin 判断时**裸访问 `request.state`** —— 后台进程内虚拟 request(`SimpleNamespace(app=app)`)
没有 `state` 属性 → `AttributeError` 杀死整个编排(前端真 HTTP 请求有 auth 中间件写的 `state.user`
所以手动路径一直正常)。修复:getattr 全链防御 + 虚拟 request 补空 `state`(含 scheduler 同款),
回归测试 `tests/test_roundtable_inprocess_request.py`。
- **已恢复**:`ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True`。后台收口后自动跑 Step3 双产物:
① `roundtable-summary` 写「方案总结报告.md」(新增 `gateway.run_summary` + `engine._run_step3`,
prompt 对齐 `useStep3Summary.buildSummaryPrompt`)→ ② `roundtable-report` 画「方案总览.html」。
两段故障隔离(一段失败保留另一段产物);step3 形状对齐前端 `DraftStep3Snapshot`
(`summaryReport` 嵌套),写草稿 `draft.step3` 与手动调用同款。测试
`tests/test_roundtable_step3_pipeline.py`。
- **待真机验证**:早期(06-05,回归引入前)曾观察到 deepseek-chat 在后台不调 `write_file`,
当时根因可能就是更早版本的同类访问崩溃,也可能另有 prompt 层问题——恢复后跑一单真模型确认
outputs 下两份产物都落盘;若报告仍不写 HTML,按旧排查清单对比前端 run 参数(run policy 现已
由 `roundtable_run_policy.py` 单源统一,差异面只剩 prompt/上下文)。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
@@ -4,15 +4,16 @@ import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
@@ -37,11 +38,11 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
@@ -512,7 +513,22 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleDownloadWriteFile = useCallback(() => {
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
* 给新窗口足够的加载时间。 */
const handleOpenInNewWindow = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
@@ -544,7 +560,7 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
@@ -574,7 +590,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
</div>
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
{isSupportPreview && (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -617,8 +633,8 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
@@ -677,6 +693,15 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{language === "html" && (
<ArtifactAction
icon={ExternalLinkIcon}
label="新窗口打开"
tooltip="在新窗口全屏打开 HTML"
disabled={!displayContent?.trim()}
onClick={handleOpenInNewWindow}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -756,6 +756,9 @@ const WRITING_MODE_LABELS: Record<string, string> = {
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
general: "通用检索",
notebook: "我的空间",
// 旧值(历史会话回显兼容)
knowledge_base: "知识库",
skill: "技能检索",
};
@@ -39,12 +39,11 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
type MaterialSource = 'general' | 'notebook';
const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
@@ -279,14 +278,14 @@ export function AIWritingForm({
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>素材来源</label>
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择素材来源" />
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen, Sparkles,
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -140,19 +140,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
async function handleReSearch() {
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
@@ -163,7 +157,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -189,16 +183,9 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />按输入检索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
@@ -217,8 +204,8 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
</p>
</div>
</div>
@@ -251,21 +238,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />检索并追加素材</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
disabled={disabled}
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
/>
@@ -257,7 +257,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
await stream.submit(
@@ -34,8 +34,8 @@ export interface WritingFormValue {
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill' | 'notebook';
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -56,7 +56,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
const [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(false);
@@ -65,7 +65,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -79,8 +79,9 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
// 旧值 skill / knowledge_base 已合并为通用检索 general
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
@@ -103,8 +103,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
case 'intervention_submitted': {
const ns = action.nextStatus;
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
const enablingSkillSearch = action.record.action === 'enable_skill_search';
// 点击瞬间标记技能检索 pending,让 SkillStepsCard 占位卡立即挂载。
// 通用检索现在统一走技能编排:素材确认卡上任何回到 researching 的动作
// (重新搜索 / 按输入追加检索)都会产生 skill_search_step 流。
const enablingSkillSearch =
action.record.action === 'enable_skill_search' ||
(ns === 'researching' && action.record.pausePoint === 'material_confirm');
return {
...prev,
currentPause: null,
@@ -220,7 +220,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
editedOutline?: AIOutline;
outlineFeedback?: string;
@@ -246,8 +246,8 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: "strict" | "loose";
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
materialSource: "knowledge_base" | "skill" | "notebook";
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
@@ -127,6 +127,12 @@ export interface DraftStep3Snapshot {
* blob, so the backend needs no schema change. Optional.
*/
summaryReport?: DraftStep3SummarySnapshot | null;
/**
* Step 3「大屏多页(模式二)」的结构化数据(report-json 字符串)。**只存数据不存
* HTML** —— 前端查看时用 renderDashboardHtml 按当前主题渲染,主题切换天然适配;
* 与经典 `html`(模式一)并存互不覆盖。后台挂起与手动并行绘制都写这里。Optional.
*/
dashboardJson?: string | null;
}
/**
@@ -101,6 +101,25 @@ export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/**
* 启动后台作业(``keepalive`` 版)—— 给 ``beforeunload``(刷新 / 关标签页)兜底用:
* keepalive fetch 在页面卸载后浏览器仍会把请求发完(payload ≤64KB),保证「正在
* 运行的研讨」在用户刷新 / 关页时也能转入后台挂起。fire-and-forget,不读响应;
* 重复触发由后端「同草稿未终态作业幂等返回」兜住,不会重复建作业。
*/
export function startJobKeepalive(payload: StartJobPayload): void {
try {
void apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
keepalive: true,
}).catch(() => {});
} catch {
/* 卸载途中同步异常也不能抛 */
}
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
@@ -48,6 +48,8 @@ export interface DagFlowChartProps {
coordinatorState?: ChainNodeState;
/** 共识里程碑状态(默认 pending)。 */
consensusState?: ChainNodeState;
/** 总结报告里程碑状态(Step3 第一段,roundtable-summary 写 md;默认 pending)。 */
summaryState?: ChainNodeState;
/** 结果绘制里程碑状态(默认 pending)。 */
reportState?: ChainNodeState;
/** 是否显示末尾里程碑(共识 / 结果绘制)。默认 true。 */
@@ -85,6 +87,7 @@ export function DagFlowChart({
seatStates,
coordinatorState = "pending",
consensusState = "pending",
summaryState = "pending",
reportState = "pending",
showMilestones = true,
showReportMilestone = true,
@@ -157,11 +160,22 @@ export function DagFlowChart({
{showReportMilestone && (
<>
<Connector
active={reportState === "active"}
done={reportState === "done"}
active={summaryState === "active" || reportState === "active"}
done={summaryState === "done" && reportState === "done"}
compact={compact}
/>
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
{/* Step3 三产物并行纵列(总结/绘制/大屏,共识后三流齐跑) */}
<div
className={cn(
"flex shrink-0 flex-col items-center rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04]",
compact ? "gap-1 px-1.5 pb-1.5 pt-1" : "gap-1.5 px-2 pb-2 pt-1.5",
)}
>
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
<MilestoneNode label="总结报告" emoji="📝" state={summaryState} compact={compact} />
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
<MilestoneNode label="大屏多页" emoji="🖥️" state={reportState} compact={compact} />
</div>
</>
)}
</>
@@ -0,0 +1,469 @@
/**
* Step2 消息列表右上角的「编排流程图小窗」—— 取代原全屏弹窗(2026-06-12)。
*
* 竖向流程图、直角连线(org-chart 风格):
* - 总控协调在最顶;
* - 每个 stage 一行:并行席位横向并列,由上方单点经「竖线 → 横向母线 → 各列竖线」
* 的**直角边**分叉连接(3 个并行席位就分 3 路,以此类推);
* - stage 之间再以直角边汇聚回中线,串行接入下一行;
* - 最后汇聚到 🤝 共识达成。
*
* 悬浮窗交互(2026-06-13,参考第三步结构化输出流程图 FlowGraphPanel 的画布体验):
* - **画布**:鼠标滚轮缩放(0.5–2.5,以光标为锚点)、按住拖动平移、双击复位;
* - **窗口**:拖 header 移动位置;拖左/右边缘调宽、下边缘调高、左下/右下角同时调宽高;
* - 位置/尺寸为会话内状态,不持久化。
*
* 与 `DagFlowChart`(横向,弹窗/总览用)共享 ChainNodeState / resolveSeat 语义,
* 但布局完全独立。沙箱打开时由 Step2Panel 自动关闭本窗(腾出注意力给沙箱),
* 「编排流程图」按钮可随时重开。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { chainTagClasses } from "../lib/business-chain";
import type { ChainNodeState } from "../lib/job-types";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface DagFlowMiniPanelProps {
/** 分层计划的 stages(只取 agentIds)。 */
stages: ReadonlyArray<{ id?: string; agentIds: string[] }>;
/** agentId(小写)+ 全局序号 → {name, avatarType}。 */
resolveSeat?: (agentId: string, order: number) => { name: string; avatarType: string };
/** agentId(小写)→ 节点状态。 */
seatStates?: Record<string, ChainNodeState>;
coordinatorState?: ChainNodeState;
consensusState?: ChainNodeState;
/** 右上角 × 关闭。 */
onClose: () => void;
className?: string;
}
/** 连线颜色(浅色/深色主题都可见)。 */
const LINE = "bg-slate-300 dark:bg-border";
// 窗口尺寸边界(px)。
const MIN_W = 220;
const MAX_W = 640;
const MIN_H = 240;
const MAX_H = 760;
// 画布缩放边界。
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2.5;
/**
* 直角分叉/汇聚连接段。
* `fanOut=true`:上方单点 → 竖线 → 横向母线 → 每列一条竖线落到下方 count 个节点;
* `fanOut=false`:count 个节点各出一条竖线 → 横向母线 → 中线竖线汇聚到下方单点。
* count<=1 时退化成一条居中竖线(串行段)。
*/
function OrthoConnector({ count, fanOut }: { count: number; fanOut: boolean }) {
if (count <= 1) {
return <div className={cn("mx-auto h-5 w-px", LINE)} />;
}
const busRow = (busAt: "top" | "bottom") => (
<div
className="grid w-full"
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="relative h-5">
{/* 横向母线:首列只画右半、末列只画左半、中间列贯通 —— 拼成一条首列中心到末列中心的横线 */}
<span
className={cn(
"absolute h-px",
LINE,
busAt === "top" ? "top-0" : "bottom-0",
i === 0 ? "left-1/2 right-0" : i === count - 1 ? "left-0 right-1/2" : "inset-x-0",
)}
/>
{/* 各列竖线(母线 ↔ 节点) */}
<span className={cn("absolute left-1/2 top-0 bottom-0 w-px -translate-x-1/2", LINE)} />
</div>
))}
</div>
);
return fanOut ? (
<div className="w-full">
{/* 单点竖线(来自上方节点)→ 母线在行顶 → 各列下落 */}
<div className={cn("mx-auto h-4 w-px", LINE)} />
{busRow("top")}
</div>
) : (
<div className="w-full">
{/* 各列上行 → 母线贴行底 → 中线竖线(汇向下方节点) */}
{busRow("bottom")}
<div className={cn("mx-auto h-4 w-px", LINE)} />
</div>
);
}
/** 小窗节点:头像 + 状态徽标 + 名称标签(竖排紧凑版,对齐 DagFlowChart 的状态语义)。 */
function MiniNode({
label,
avatarType,
state,
emoji,
}: {
label: string;
avatarType?: string;
state: ChainNodeState;
emoji?: string;
}) {
const tag = avatarType ? chainTagClasses(avatarType) : "";
return (
<div className="flex w-full min-w-0 flex-col items-center gap-1 text-center">
<div className="relative flex size-11 items-center justify-center">
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/25" />
)}
<span
className={cn(
"relative flex size-11 items-center justify-center rounded-full border-2 bg-white text-lg transition-all dark:bg-card",
state === "active" && "border-primary shadow-[0_0_0_3px_rgba(22,119,255,0.12)]",
state === "done" && "border-emerald-400/70",
state === "pending" && "border-border opacity-45 grayscale",
)}
>
{emoji ?? (avatarType ? getAvatar(avatarType, "h-9 w-9") : null)}
{state === "done" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"max-w-full truncate rounded border px-1 py-px text-[10px] font-medium leading-tight",
state === "pending" || !tag ? "border-border bg-muted text-muted-foreground" : tag,
)}
title={label}
>
{label}
</span>
</div>
);
}
// ── 悬浮窗几何(位置 / 尺寸 / 画布变换)────────────────────────────────────────
interface WindowGeom {
/** null = 尚未拖过,沿用初始右上角锚定(right:16 / top:48)。 */
left: number | null;
top: number;
w: number;
h: number;
}
type ResizeMode = "move" | "e" | "w" | "s" | "se" | "sw";
export function DagFlowMiniPanel({
stages,
resolveSeat,
seatStates,
coordinatorState = "pending",
consensusState = "pending",
onClose,
className,
}: DagFlowMiniPanelProps) {
const resolve = (agentId: string, order: number) =>
resolveSeat?.(agentId, order) ?? {
name: agentId,
avatarType: getAvatarTypeFor(agentId, order),
};
const panelRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [geom, setGeom] = useState<WindowGeom>({ left: null, top: 48, w: 256, h: 400 });
// 画布变换(平移 px + 缩放),transform-origin 固定 0 0。
const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
const viewRef = useRef(view);
viewRef.current = view;
// ── 窗口移动 / 边缘缩放(pointer capture,统一一套手势状态机)────────────────
const gestureRef = useRef<{
mode: ResizeMode;
startX: number;
startY: number;
start: WindowGeom & { left: number };
} | null>(null);
const beginWindowGesture = useCallback(
(mode: ResizeMode) => (e: React.PointerEvent) => {
const el = panelRef.current;
if (!el) return;
e.preventDefault();
e.stopPropagation();
// 首次手势把右锚定折算成 left(offsetParent = 消息区 relative 容器)。
const left = geom.left ?? el.offsetLeft;
gestureRef.current = {
mode,
startX: e.clientX,
startY: e.clientY,
start: { ...geom, left },
};
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[geom],
);
const onWindowGestureMove = useCallback((e: React.PointerEvent) => {
const g = gestureRef.current;
if (!g) return;
e.preventDefault();
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
const s = g.start;
const clampW = (w: number) => Math.min(MAX_W, Math.max(MIN_W, w));
const clampH = (h: number) => Math.min(MAX_H, Math.max(MIN_H, h));
setGeom(() => {
switch (g.mode) {
case "move":
return { ...s, left: Math.max(0, s.left + dx), top: Math.max(0, s.top + dy) };
case "e":
return { ...s, w: clampW(s.w + dx) };
case "w": {
const w = clampW(s.w - dx);
return { ...s, w, left: Math.max(0, s.left + (s.w - w)) };
}
case "s":
return { ...s, h: clampH(s.h + dy) };
case "se":
return { ...s, w: clampW(s.w + dx), h: clampH(s.h + dy) };
case "sw": {
const w = clampW(s.w - dx);
return { ...s, w, left: Math.max(0, s.left + (s.w - w)), h: clampH(s.h + dy) };
}
default:
return s;
}
});
}, []);
const endWindowGesture = useCallback((e: React.PointerEvent) => {
if (!gestureRef.current) return;
gestureRef.current = null;
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* 已释放 */
}
}, []);
// ── 画布滚轮缩放(以光标为锚点)────────────────────────────────────────────
// React 17+ 在根上以 passive 注册 wheel,onWheel 里 preventDefault 无效 →
// 必须原生 addEventListener({passive:false}),否则缩放的同时页面会跟着滚。
useEffect(() => {
const vp = viewportRef.current;
if (!vp) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const { tx, ty, scale } = viewRef.current;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
if (next === scale) return;
// 以光标为锚点:保持光标下的内容点不动。
const rect = vp.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const k = next / scale;
setView({ tx: cx - (cx - tx) * k, ty: cy - (cy - ty) * k, scale: next });
};
vp.addEventListener("wheel", onWheel, { passive: false });
return () => vp.removeEventListener("wheel", onWheel);
}, []);
// ── 画布拖动平移(与窗口手势互不干扰:画布手势只在 viewport 内启动)──────────
const panRef = useRef<{ startX: number; startY: number; tx: number; ty: number } | null>(null);
const [panning, setPanning] = useState(false);
const onCanvasDown = useCallback((e: React.PointerEvent) => {
// 只响应主键 / 触摸。
if (e.button !== 0) return;
const { tx, ty } = viewRef.current;
panRef.current = { startX: e.clientX, startY: e.clientY, tx, ty };
setPanning(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onCanvasMove = useCallback((e: React.PointerEvent) => {
const p = panRef.current;
if (!p) return;
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.startX), ty: p.ty + (e.clientY - p.startY) }));
}, []);
const onCanvasUp = useCallback((e: React.PointerEvent) => {
if (!panRef.current) return;
panRef.current = null;
setPanning(false);
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* 已释放 */
}
}, []);
// 双击复位画布。
const resetView = useCallback(() => setView({ tx: 0, ty: 0, scale: 1 }), []);
let runningOrder = 0;
// 边缘 / 边角把手的公共类。
const handleBase = "absolute z-10 touch-none";
return (
<div
ref={panelRef}
className={cn(
"absolute flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-xl backdrop-blur",
"dark:border-border dark:bg-card/95",
className,
)}
style={{
width: geom.w,
height: geom.h,
top: geom.top,
...(geom.left == null ? { right: 16 } : { left: geom.left }),
}}
>
{/* header:拖动移动窗口 */}
<header
onPointerDown={beginWindowGesture("move")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className="flex shrink-0 cursor-move select-none items-center justify-between gap-2 border-b border-slate-100 px-3 py-2 dark:border-border"
title="拖动移动小窗"
>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-600 dark:text-foreground/80">
<Network className="size-3.5 text-primary" />
编排流程图
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] text-slate-400 dark:bg-muted">
{Math.round(view.scale * 100)}%
</span>
</span>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={onClose}
title="关闭流程图小窗"
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-accent"
>
<X className="size-3.5" />
</button>
</header>
{/* 画布 viewport:滚轮缩放 / 拖动平移 / 双击复位 */}
<div
ref={viewportRef}
onPointerDown={onCanvasDown}
onPointerMove={onCanvasMove}
onPointerUp={onCanvasUp}
onPointerCancel={onCanvasUp}
onDoubleClick={resetView}
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden",
panning ? "cursor-grabbing" : "cursor-grab",
)}
title="滚轮缩放 · 拖动平移 · 双击复位"
>
<div
className="w-full px-3 py-3"
style={{
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
transformOrigin: "0 0",
}}
>
{/* 总控协调(顶) */}
<MiniNode label="总控协调" avatarType="human" state={coordinatorState} />
{stages.map((stage, stageIdx) => {
const count = stage.agentIds.length;
if (count === 0) return null;
return (
<div className="contents" key={stage.id ?? `stage-${stageIdx}`}>
{/* 直角分叉:上方单点 → 本行各席位 */}
<OrthoConnector count={count} fanOut />
<div
className="grid w-full gap-1"
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
>
{stage.agentIds.map((agentId) => {
const order = runningOrder++;
const meta = resolve(agentId, order);
const state = seatStates?.[agentId.toLowerCase()] ?? "pending";
return (
<MiniNode
key={agentId}
label={meta.name}
avatarType={meta.avatarType}
state={state}
/>
);
})}
</div>
{/* 直角汇聚:本行各席位 → 中线(接下一行 / 共识) */}
<OrthoConnector count={count} fanOut={false} />
</div>
);
})}
{/* 共识达成(底) */}
<MiniNode label="共识达成" emoji="🤝" state={consensusState} />
</div>
</div>
{/* 边缘 / 边角缩放把手:左右边调宽、下边调高、左下/右下角同时调宽高 */}
<div
onPointerDown={beginWindowGesture("e")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "right-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
title="拖动调整宽度"
/>
<div
onPointerDown={beginWindowGesture("w")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "left-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
title="拖动调整宽度"
/>
<div
onPointerDown={beginWindowGesture("s")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 left-3 right-3 h-1.5 cursor-ns-resize")}
title="拖动调整高度"
/>
<div
onPointerDown={beginWindowGesture("se")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 right-0 size-3.5 cursor-nwse-resize")}
title="拖动整体缩放窗口"
>
{/* 角部抓手视觉提示 */}
<span className="absolute bottom-0.5 right-0.5 block size-2 rounded-br border-b-2 border-r-2 border-slate-300 dark:border-border" />
</div>
<div
onPointerDown={beginWindowGesture("sw")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 left-0 size-3.5 cursor-nesw-resize")}
title="拖动整体缩放窗口"
>
<span className="absolute bottom-0.5 left-0.5 block size-2 rounded-bl border-b-2 border-l-2 border-slate-300 dark:border-border" />
</div>
</div>
);
}
@@ -91,8 +91,11 @@ const PHASE_LABEL: Record<JobPhase, string> = {
leader_thinking: "总控决策派活中…",
dispatching: "席位发言中…",
consensus: "共识达成",
report_drawing: "结果绘制中…",
report_done: "绘制完成",
// Step3 三产物(总结报告 / 结果绘制 / 大屏数据)并行生成,两个阶段值都代表
// 「并行进行中」(后端 gather 并发推进、phase 交错落库,不区分先后)。
summary_writing: "Step3 并行生成中(总结/绘制/大屏)…",
report_drawing: "Step3 并行生成中(总结/绘制/大屏)…",
report_done: "Step3 完成",
};
export function DispatchChainModal({
@@ -138,14 +141,17 @@ export function DispatchChainModal({
const consensusDone =
consensusPercentage >= 100 ||
phase === "consensus" ||
phase === "summary_writing" ||
phase === "report_drawing" ||
phase === "report_done" ||
status === "done";
const consensusActive = phase === "consensus";
// 报告绘制里程碑
const reportActive = phase === "report_drawing";
const reportDone = phase === "report_done" || status === "done";
// Step3 三产物**并行**(2026-06-13:后端 gather 并发跑总结/绘制/大屏,phase 交错
// 落库不分先后)→ 统一成一组并行里程碑状态。
const step3Active = phase === "summary_writing" || phase === "report_drawing";
const step3Done = phase === "report_done" || status === "done";
const step3State: ChainNodeState = step3Done ? "done" : step3Active ? "active" : "pending";
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
@@ -199,15 +205,17 @@ export function DispatchChainModal({
{/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
{showDag ? (
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识。
// 后台本期不绘报告(Step3 禁用) → 不显示「结果绘制」里程碑。
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识 → 📊结果绘制。
// 后台 Step3(总结报告 + 结果绘制)已恢复 → 显示「结果绘制」里程碑。
<DagFlowChart
stages={stages!}
resolveSeat={resolveDagSeat}
seatStates={dagSeatStates}
coordinatorState={coordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
summaryState={step3State}
reportState={step3State}
showReportMilestone
className="shrink-0"
/>
) : (
@@ -246,13 +254,14 @@ export function DispatchChainModal({
}
/>
{/* 报告绘制里程碑 */}
<Connector active={reportActive} done={reportDone} />
<MilestoneNode
label="结果绘制"
emoji="📊"
state={reportActive ? "active" : reportDone ? "done" : "pending"}
/>
{/* Step3:总结报告 / 结果绘制 / 大屏多页 —— **并行**纵列(共识后三流齐跑) */}
<Connector active={step3Active} done={step3Done} />
<div className="flex shrink-0 flex-col items-center gap-1.5 rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04] px-2 pb-2 pt-1.5">
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
<MilestoneNode label="总结报告" emoji="📝" state={step3State} />
<MilestoneNode label="结果绘制" emoji="📊" state={step3State} />
<MilestoneNode label="大屏多页" emoji="🖥️" state={step3State} />
</div>
</div>
</div>
)}
@@ -334,15 +343,13 @@ export function DispatchChainModal({
停止
</Button>
)}
{/* 只对「运行中」任务隐藏入口——它正在后台真跑,进入会暂停前端编排、
与后台作业双跑或借此重跑。其它状态(排队 / 待澄清 / 已完成 / 已取消 /
出错)都不是正在跑的后台作业,可正常查看。 */}
{status !== "running" && (
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
)}
{/* 「查看」对所有状态开放——运行中点击进入的是**只读 hydrate 视图**
(onView 先暂停本地编排再加载草稿,纯展示绝不 resume,不会与后台
作业双跑;草稿由后台逐轮回写,进入即可看到当前进度)。 */}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
</div>
</DialogFooter>
</DialogContent>
@@ -1,19 +1,30 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
CheckCircle,
ChevronDown,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Download,
Eye,
FileText,
Loader2,
RefreshCw,
Sparkles,
Users,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import { apiFetch } from "@/core/api";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -21,7 +32,6 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
@@ -32,7 +42,9 @@ import type {
DraftStep3Snapshot,
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
@@ -72,6 +84,10 @@ interface HighFidelityReportProps {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的三个 Step3 生成流(切走不断流,进入续看)。 */
summaryFlow: UseStep3SummaryReturn;
reportFlow: UseStep3ReportReturn;
dashboardFlow: UseStep3DashboardReturn;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
@@ -116,6 +132,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
coordinatorPrompt,
savedSummary,
onSummarySaved,
summaryFlow,
reportFlow,
dashboardFlow,
onReset,
openArtifactPreview,
seatStubMessages,
@@ -132,7 +151,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false);
const [fileSandboxMode, setFileSandboxMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
// (不再整页切走)。target 在关闭后保留,供退出过渡动画期间继续渲染。
const [fileSandboxOpen, setFileSandboxOpen] = useState(false);
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
threadId: string;
file: SessionArtifactFile;
@@ -225,17 +246,77 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
};
}, [seatThreadEntries, reloadKey]);
const totalFileCount = useMemo(
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
[artifactsBySeat],
);
// ── 统一席位总览(2026-06-13 大改):交付内容 + 该席位生成的报告文件合并到一起 ──
// 以「有线程的席位」为主(带文件清单),无线程时回退到交付列表;key = displayName。
const seatOverviews = useMemo(() => {
const avatarByDisplay = new Map<string, string>();
for (const d of seatDeliveries) {
if (!avatarByDisplay.has(d.sender)) avatarByDisplay.set(d.sender, d.avatarType);
}
type Overview = {
key: string;
displayName: string;
avatarType: string;
delivery: string | null;
threadId: string | null;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
};
const out: Overview[] = [];
const seen = new Set<string>();
artifactsBySeat.forEach((s, i) => {
seen.add(s.displayName);
out.push({
key: s.displayName,
displayName: s.displayName,
avatarType:
avatarByDisplay.get(s.displayName) ?? getAvatarTypeFor(s.agentName, i),
delivery: deliveryByDisplay.get(s.displayName) ?? null,
threadId: s.threadId,
files: s.files,
loading: s.loading,
error: s.error,
});
});
// 没有线程映射(恢复的旧草稿等)时,纯交付席位也要出现在列表里。
seatDeliveries.forEach((d, i) => {
if (seen.has(d.sender)) return;
seen.add(d.sender);
out.push({
key: d.sender,
displayName: d.sender,
avatarType: d.avatarType || getAvatarTypeFor(d.sender, i),
delivery: d.content,
threadId: null,
files: [],
loading: false,
error: null,
});
});
return out;
}, [artifactsBySeat, seatDeliveries, deliveryByDisplay]);
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
const seatsWithFiles = useMemo(
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
[artifactsBySeat],
);
// 左侧席位栏(像第二步的参与角色栏):可收起成头像列;点卡片/头像 → 展开右侧
// 对应区块并平滑滚动过去(短暂高亮提示落点)。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [focusedSeat, setFocusedSeat] = useState<string | null>(null);
const seatSectionRefs = useRef<Map<string, HTMLElement>>(new Map());
// 总控最终结论:支持展开/收起(默认展开);左侧总控头像点击 → 展开并滚动到这里。
const CONSENSUS_KEY = "__consensus__";
const [consensusExpanded, setConsensusExpanded] = useState(true);
const consensusSectionRef = useRef<HTMLElement | null>(null);
const focusConsensus = useCallback(() => {
setConsensusExpanded(true);
setFocusedSeat(CONSENSUS_KEY);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
consensusSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
window.setTimeout(() => setFocusedSeat((cur) => (cur === CONSENSUS_KEY ? null : cur)), 1800);
}, []);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
@@ -246,16 +327,94 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
});
}, []);
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxMode(true);
const focusSeat = useCallback((key: string) => {
setExpandedSeats((prev) => new Set(prev).add(key));
setFocusedSeat(key);
// 等展开渲染完成后再滚动,落点更准。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
seatSectionRefs.current
.get(key)
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
window.setTimeout(
() => setFocusedSeat((cur) => (cur === key ? null : cur)),
1800,
);
}, []);
const handleBackFromFileSandbox = useCallback(() => {
setFileSandboxMode(false);
setFileSandboxTarget(null);
deselectArtifact();
}, [deselectArtifact]);
// 文本类扩展名 → 拉文本走虚拟 stub 预览;其余(图片/PDF 等二进制)直接 iframe。
const TEXT_FILE_RE = /\.(md|markdown|html?|txt|csv|json|js|ts|tsx|py|xml|yml|yaml|log|mmd|css)$/i;
const [fileSandboxContent, setFileSandboxContent] = useState<string | null>(null);
const [fileSandboxIsText, setFileSandboxIsText] = useState(true);
const fileFetchSeqRef = useRef(0);
// 点文件卡片:右侧滑入内联沙箱 + 左侧席位栏收起(不消失),页面不切走。
// 内容**不走** threads artifacts hook(这些圆桌线程在那条路由上 404,曾表现为
// 预览区只显示 {"detail":"Not Found"})——改用与「下载」同源、已验证可用的
// buildArtifactDownloadUrl 自取文本,再以 write_file 虚拟 stub 喂给预览组件。
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxOpen(true);
setSidebarCollapsed(true);
const isText = TEXT_FILE_RE.test(file.name);
setFileSandboxIsText(isText);
setFileSandboxContent(null);
if (!isText) return; // 二进制走 iframe(src 即下载路由),无需取文本
const seq = ++fileFetchSeqRef.current;
void apiFetch(buildArtifactDownloadUrl(threadId, file.path))
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (fileFetchSeqRef.current === seq) setFileSandboxContent(text);
})
.catch((err) => {
if (fileFetchSeqRef.current === seq) {
setFileSandboxContent(`> 读取文件失败:${(err as Error).message}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCloseFileSandbox = useCallback(() => {
setFileSandboxOpen(false); // target 保留到过渡结束,面板滑出更顺
}, []);
// 虚拟 stub(write-file URL → 预览组件直接读 tool_call.args.content,零 HTTP)。
const fileSandboxStubPath = fileSandboxTarget
? fileSandboxTarget.file.path.startsWith("/")
? fileSandboxTarget.file.path
: `/${fileSandboxTarget.file.path}`
: "";
const fileSandboxVirtualUrl = fileSandboxTarget
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
: "";
const fileSandboxThreadStub = useMemo(() => {
const messages =
fileSandboxTarget && fileSandboxContent != null
? [
{
id: "hfr-file-stub",
type: "ai",
content: "",
tool_calls: [
{
id: "tc-hfr-0",
name: "write_file",
args: { path: fileSandboxStubPath, content: fileSandboxContent },
},
],
},
]
: [];
return {
messages,
isLoading: false,
isThreadLoading: false,
values: { title: "", messages, artifacts: [] },
} as unknown as BaseStream<AgentThreadState>;
}, [fileSandboxTarget, fileSandboxContent, fileSandboxStubPath]);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
@@ -283,28 +442,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
onSummarySaved={onSummarySaved}
summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
/>
);
}
if (fileSandboxMode && fileSandboxTarget) {
return (
<Step3FileBrowseMode
consensusText={consensusText}
hasConsensus={hasConsensus}
consensusDocTitle={consensusDocTitle}
seatDeliveries={seatDeliveries}
artifactsBySeat={artifactsBySeat}
seatStubMessages={seatStubMessages}
openArtifactPreview={openArtifactPreview}
initialFile={fileSandboxTarget}
onBack={handleBackFromFileSandbox}
/>
);
}
if (drawMode) {
return (
<Step3ReportMode
@@ -320,14 +463,15 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
report={reportFlow}
dashboard={dashboardFlow}
onBack={() => setDrawMode(false)}
/>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
<div className="flex-1 flex flex-col min-h-0 text-slate-800">
{/* Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-2.5">
@@ -402,179 +546,476 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
</div>
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">总控最终结论</span>
</h4>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
{/* 主体:左侧参与席位栏(像第二步) + 右侧滚动内容(2026-06-13 大改) */}
<div className="flex flex-1 min-h-0">
{/* ── 左侧参与席位栏:可收起成头像列;有交付内容的席位头像点亮 ── */}
<aside
className={cn(
"shrink-0 flex flex-col border-r border-slate-100 bg-slate-50/50 transition-all",
sidebarCollapsed ? "w-14" : "w-52",
)}
</section>
{/* Section 2: Per-seat deliveries (collapsible) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{seatDeliveries.length}
</span>
</h4>
{seatDeliveries.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
</p>
) : (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const key = `${seat.sender}-${idx}`;
const expanded = expandedSeats.has(key);
return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggleSeat(key)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{expanded && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。
仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */}
{seatsWithFiles.length > 0 && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
>
<div
className={cn(
"flex shrink-0 items-center border-b border-slate-100 px-2 py-2",
sidebarCollapsed ? "justify-center" : "justify-between",
)}
>
{!sidebarCollapsed && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest text-slate-500">
<Users className="w-3.5 h-3.5 text-primary" />
参与席位
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] font-semibold text-slate-400">
{seatOverviews.length}
</span>
</span>
</h4>
)}
<button
type="button"
onClick={() => setSidebarCollapsed((v) => !v)}
title={sidebarCollapsed ? "展开席位栏" : "收起席位栏"}
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
>
{sidebarCollapsed ? (
<ChevronsRight className="w-3.5 h-3.5" />
) : (
<ChevronsLeft className="w-3.5 h-3.5" />
)}
</button>
</div>
<div className="space-y-3">
{seatsWithFiles.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
{/* 总控协调(顶置):点击 → 展开「总控最终结论」并滚动过去 */}
{sidebarCollapsed ? (
<button
type="button"
onClick={focusConsensus}
title={`总控协调${consensusText ? "(点击查看最终结论)" : "(暂无结论)"}`}
className="mx-auto block rounded-full transition hover:scale-105"
>
<span
className={cn(
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
consensusText
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar("human", "h-7 w-7")}
{!!consensusText && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
</button>
) : (
<button
type="button"
onClick={focusConsensus}
className={cn(
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
consensusText
? "border-emerald-200/70 bg-emerald-50/40 hover:border-emerald-400/60 hover:bg-emerald-50"
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
)}
title={consensusText ? "点击查看总控最终结论" : "暂无总控结论"}
>
<span
className={cn(
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
consensusText
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar("human", "h-7 w-7")}
{!!consensusText && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[11.5px] font-bold",
consensusText ? "text-slate-800" : "text-slate-400",
)}
>
总控协调
</span>
<span className="block truncate text-[9.5px] font-mono text-slate-400">
{consensusText ? "最终结论" : "无结论"}
</span>
</span>
</button>
)}
{seatOverviews.map((seat) => {
const lit = !!seat.delivery?.trim();
return sidebarCollapsed ? (
// 收起态:只剩头像列;点头像同样展开右侧对应交付并滚过去。
<button
key={seat.key}
type="button"
onClick={() => focusSeat(seat.key)}
title={`${seat.displayName}${lit ? "(点击查看交付与文件)" : "(暂无交付)"}`}
className="mx-auto block rounded-full transition hover:scale-105"
>
<span
className={cn(
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
lit
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar(seat.avatarType, "h-7 w-7")}
{lit && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
</button>
) : (
// 展开态:席位卡片(头像 + 名称 + 交付/文件徽标)。
<button
key={seat.key}
type="button"
onClick={() => focusSeat(seat.key)}
className={cn(
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
lit
? "border-slate-200 bg-white hover:border-primary/40 hover:bg-primary/5"
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
)}
title={lit ? "点击查看该席位的交付与文件" : "该席位暂无交付"}
>
<span
className={cn(
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
lit
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar(seat.avatarType, "h-7 w-7")}
{lit && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[11.5px] font-bold",
lit ? "text-slate-800" : "text-slate-400",
)}
>
{seat.displayName}
</span>
</div>
<span className="block truncate text-[9.5px] font-mono text-slate-400">
{lit ? `${seat.delivery!.length} 字` : "无交付"}
{seat.files.length > 0 ? ` · ${seat.files.length} 文件` : ""}
</span>
</span>
</button>
);
})}
{seatOverviews.length === 0 && !sidebarCollapsed && (
<p className="px-1 pt-2 text-[10px] italic text-slate-400">
暂无参与席位
</p>
)}
</div>
</aside>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
读取失败:{seat.error}
</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
该席位未产生输出文件
{/* ── 右侧内容:总控结论 + 每席位一个区块(交付内容 + 报告文件卡片合并) ── */}
<div className="custom-scrollbar flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
{/* Section 1: Coordinator consensus conclusion(可收起,带过渡动画) */}
<section
ref={consensusSectionRef}
className={cn(
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
focusedSeat === CONSENSUS_KEY
? "border-primary/50 ring-2 ring-primary/30"
: "border-slate-200",
)}
>
<div className="flex items-center justify-between gap-2 px-5 py-3.5 bg-slate-50/40">
<button
type="button"
onClick={() => setConsensusExpanded((v) => !v)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
title={consensusExpanded ? "收起结论" : "展开结论"}
>
{consensusExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate text-xs font-black text-slate-900 uppercase tracking-widest">
总控最终结论
</span>
</button>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
consensusExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="border-t border-slate-100 px-5 py-4">
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li
key={f.path}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
<button
type="button"
onClick={() => handleViewInSandbox(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="在沙箱中查看"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
<p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
)}
</div>
))}
</div>
</div>
</section>
)}
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<span className="text-xs text-slate-400 font-medium">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</span>
<button
type="button"
onClick={onReset}
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
>
重新起草新规划
</button>
{/* Section 2: 每席位一个区块 —— 交付内容 + 该席位生成的报告文件(卡片,点击开沙箱) */}
{seatOverviews.length === 0 ? (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,这里会按席位展示交付与产出文件。
</p>
</section>
) : (
seatOverviews.map((seat) => {
const expanded = expandedSeats.has(seat.key);
const lit = !!seat.delivery?.trim();
return (
<section
key={seat.key}
ref={(el) => {
if (el) seatSectionRefs.current.set(seat.key, el);
else seatSectionRefs.current.delete(seat.key);
}}
className={cn(
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
focusedSeat === seat.key
? "border-primary/50 ring-2 ring-primary/30"
: "border-slate-200",
)}
>
{/* 区块头:头像 + 席位名 + 徽标,点击折叠/展开 */}
<button
type="button"
onClick={() => toggleSeat(seat.key)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 bg-slate-50/70 hover:bg-slate-100/70 transition text-left"
>
<div className="flex items-center gap-2.5 min-w-0">
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full border-2 bg-white",
lit ? "border-emerald-400/70" : "border-slate-200 opacity-50 grayscale",
)}
>
{getAvatar(seat.avatarType, "h-5.5 w-5.5")}
</span>
<span className="text-xs font-black text-slate-800 truncate">
{seat.displayName}
</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{lit ? `${seat.delivery!.length} 字` : "无交付"}
{seat.loading
? " · 文件加载中…"
: seat.files.length > 0
? ` · ${seat.files.length} 个文件`
: ""}
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="border-t border-slate-100">
{/* 交付内容 */}
<div className="px-5 py-4 text-xs text-slate-700 leading-relaxed">
{lit ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{seat.delivery!}
</ReactMarkdown>
) : (
<p className="text-[11px] text-slate-400 italic">该席位暂无交付内容。</p>
)}
</div>
{/* 该席位生成的报告文件 —— 卡片,点击在沙箱中打开 */}
{(seat.loading || seat.error || seat.files.length > 0) && (
<div className="border-t border-slate-100 bg-slate-50/40 px-5 py-3">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-500">
<Download className="w-3 h-3 text-primary" />
生成的报告文件
</div>
{seat.loading ? (
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{seat.files.map((f) => (
<div
key={f.path}
role="button"
tabIndex={0}
onClick={() =>
seat.threadId && handleViewInSandbox(seat.threadId, f)
}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && seat.threadId) {
e.preventDefault();
handleViewInSandbox(seat.threadId, f);
}
}}
title="点击在沙箱中查看"
className="group flex cursor-pointer items-center justify-between gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2.5 transition hover:border-primary/40 hover:bg-primary/5 hover:shadow-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileText className="w-3.5 h-3.5" />
</span>
<div className="min-w-0">
<span
className="block truncate text-[11.5px] font-bold text-slate-800"
title={f.name}
>
{f.name}
</span>
<span className="block text-[9.5px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (seat.threadId) handleDownload(seat.threadId, f);
}}
className="rounded p-1 text-slate-400 transition hover:bg-slate-100 hover:text-primary"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</section>
);
})
)}
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<span className="text-xs text-slate-400 font-medium">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</span>
<button
type="button"
onClick={onReset}
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
>
重新起草新规划
</button>
</div>
</div>
{/* ── 内联文件沙箱(2026-06-13):点文件卡片从右侧滑入,左侧席位栏只收起不消失。
width 0↔46% 过渡 + 面板内容平移,关闭后 target 保留到动画结束。 ── */}
<div
className={cn(
"shrink-0 min-h-0 overflow-hidden transition-all duration-300 ease-in-out",
fileSandboxOpen && fileSandboxTarget ? "w-[46%]" : "w-0",
)}
>
{fileSandboxTarget && (
<div
className={cn(
"h-full w-full border-l border-slate-100 bg-[#fffdfa] p-3 transition-transform duration-300 ease-in-out",
fileSandboxOpen ? "translate-x-0" : "translate-x-full",
)}
>
{!fileSandboxIsText ? (
// 二进制(图片 / PDF 等):直接 iframe 下载路由(浏览器原生渲染)。
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-slate-100 px-3 py-2">
<span className="truncate text-xs font-bold text-slate-700">
{fileSandboxTarget.file.name}
</span>
<button
type="button"
onClick={handleCloseFileSandbox}
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
title="关闭预览"
>
×
</button>
</div>
<iframe
title={fileSandboxTarget.file.name}
src={buildArtifactDownloadUrl(
fileSandboxTarget.threadId,
fileSandboxTarget.file.path,
)}
className="min-h-0 flex-1 w-full border-0 bg-white"
/>
</div>
) : fileSandboxContent == null ? (
<div className="flex h-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-xs text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
正在读取文件内容…
</div>
) : (
<ThreadContext.Provider value={{ thread: fileSandboxThreadStub, isMock: false }}>
<ArtifactFileDetail
className="size-full"
filepath={fileSandboxVirtualUrl}
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
/>
</ThreadContext.Provider>
)}
</div>
)}
</div>
</div>
</div>
);
};
@@ -37,13 +37,6 @@ function formatFileSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
@@ -65,7 +58,8 @@ import {
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
@@ -275,8 +269,22 @@ export function Step2Panel(props: Step2PanelProps) {
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
const showDagFlow =
orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0;
// 优化7(2026-06-13):推荐模式总控判定的**并行批**也能看流程图——把当前并行批
// 构造成一个临时单 stage 计划喂给小窗(dag 模式仍用真实 orchestrationPlan)。
const effectivePlan = useMemo<OrchestrationPlan | null>(() => {
if (orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0) {
return orchestrationPlan;
}
if (isParallelRunning && parallelBatch.length > 1) {
return {
mode: "dag",
stages: [{ id: "recommend-parallel", agentIds: parallelBatch.map((s) => s.agentId) }],
finalSynthesis: true,
} as OrchestrationPlan;
}
return null;
}, [orchestrationMode, orchestrationPlan, isParallelRunning, parallelBatch]);
const showDagFlow = (effectivePlan?.stages.length ?? 0) > 0;
const resolveDagSeat = useCallback(
(agentId: string, order: number) => {
const meta = agentNameToRole[agentId.toLowerCase()];
@@ -287,9 +295,32 @@ export function Step2Panel(props: Step2PanelProps) {
},
[agentNameToRole],
);
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
const effectiveDagNodeStates = useMemo(() => {
const merged: Record<string, ChainNodeState> = {};
const deliveredNames = new Set(
step2RoundtableDialogues
.filter(
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
}
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
for (const s of parallelBatch) {
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
}
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
return merged;
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch]);
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(dagNodeStates);
const states = Object.values(effectiveDagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
@@ -306,10 +337,32 @@ export function Step2Panel(props: Step2PanelProps) {
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [dagNodeStates, isOrchestrating, hasConsensus]);
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
const [dagFlowOpen, setDagFlowOpen] = useState(false);
const { open: artifactsOpenForFlow } = useArtifacts();
const dagFlowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!showDagFlow) {
dagFlowAutoOpenedRef.current = false;
return;
}
if (!dagFlowAutoOpenedRef.current) {
dagFlowAutoOpenedRef.current = true;
// 沙箱已占着右侧就不抢(保持关闭,按钮可开)。
if (!artifactsOpenForFlow) setDagFlowOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showDagFlow]);
// 沙箱打开(出现产物预览)→ 自动关闭小窗腾出注意力;**边沿触发**,
// 不会在沙箱保持打开期间反复压制用户手动重开的小窗。
const prevArtifactsOpenRef = useRef(artifactsOpenForFlow);
useEffect(() => {
if (artifactsOpenForFlow && !prevArtifactsOpenRef.current) setDagFlowOpen(false);
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
@@ -430,43 +483,37 @@ export function Step2Panel(props: Step2PanelProps) {
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
{showDagFlow && orchestrationPlan && (
{/* 流程图小窗开关条(dag 编排 / 推荐并行批都显示)。 */}
{showDagFlow && effectivePlan && (
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
<span className="text-xs font-medium text-slate-500">
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
{orchestrationMode === "dag"
? `分层编排执行中 · 共 ${effectivePlan.stages.length} 个阶段`
: `并行执行中 · ${effectivePlan.stages[0]?.agentIds.length ?? 0} 个席位`}
</span>
<button
type="button"
onClick={() => setDagFlowOpen(true)}
onClick={() => setDagFlowOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
查看编排流程图
{dagFlowOpen ? "收起流程图" : "编排流程图"}
</button>
</div>
)}
{showDagFlow && orchestrationPlan && (
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-primary" />
分层编排流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto">
<DagFlowChart
stages={orchestrationPlan.stages}
resolveSeat={resolveDagSeat}
seatStates={dagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
{/* 流程图小窗:消息列表右上角(开关条下方)悬浮,竖向直角连线流程图。
支持滚轮缩放/拖动平移/移动窗口/边缘缩放(组件内自管几何)。
沙箱打开时自动关闭(见上方互斥 effect),点按钮可随时重开。 */}
{showDagFlow && effectivePlan && dagFlowOpen && (
<DagFlowMiniPanel
stages={effectivePlan.stages}
resolveSeat={resolveDagSeat}
seatStates={effectiveDagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
onClose={() => setDagFlowOpen(false)}
className="z-30"
/>
)}
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
{isParallelRunning && (
@@ -0,0 +1,312 @@
/**
* Step3「结果绘制 · 大屏多页模式(模式二)」—— 与模式一同款交互(2026-06-13 重构)。
*
* 布局/交互对齐模式一(Step3ReportMode):左侧流式对话气泡 + 右侧沙箱(复用
* `Step2SandboxLayout`,大屏 HTML 以 write_file 虚拟 stub 进沙箱 → 代码/预览切换、
* 下载、新窗口打开全部可用)+ 底部修改要求输入框(同线程续轮修订全量 report-json)。
*
* 与模式一的差别只在产物管线:模型输出 1-2KB ```report-json,前端模板渲染
* (lib/report-template)—— 快一个数量级、零截断;链路(initReportThread +
* streamMultiAgent)与模式一完全同源。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
ArrowLeft,
CheckCircle,
Eye,
LayoutDashboard,
Loader2,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml, stripReportFence } from "@/roundtable-planning/lib/report-template";
interface Step3DashboardModeProps {
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
savedReport?: DraftStep3Snapshot | null;
/** 页面层常驻的大屏生成流(2026-06-13 提升:切走不断流,进入续看)。 */
dashboard: UseStep3DashboardReturn;
/** 切回经典「单页看板」绘制模式。 */
onSwitchClassic: () => void;
/** 返回最终方案展示页。 */
onBack: () => void;
}
export function Step3DashboardMode({
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
dashboard,
onSwitchClassic,
onBack,
}: Step3DashboardModeProps) {
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
dashThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
html,
start,
redraw,
ask,
abort,
hydrateSaved,
openDashboardPreview,
} = dashboard;
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
// 产物/进行中会立即接管沙箱续看。
useEffect(() => {
dashboard.setPreviewEnabled(true);
return () => dashboard.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 续看语义:只兜底"从未启动过"(idle);**卸载不再 abort**(切走不断流)。
// 优先 dashboardJson(结构化数据,按当前主题渲染);旧版 marker html 兜底。
useEffect(() => {
if (phase !== "idle") return;
if (savedReport?.dashboardJson) {
dashboard.hydrateFromJson(savedReport.dashboardJson);
} else if (savedReport?.html && isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport.html);
} else {
start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [revision, setRevision] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useAutoScroll(scrollRef, [dialogues]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const text = revision.trim();
if (!text) return;
setRevision("");
ask(text);
};
// 气泡显示:report-json 生 JSON 替换成一句人话(对齐 flow-json 的处理)。
const displayDialogues = useMemo(
() =>
dialogues.map((d) => {
const content = d.content ?? "";
if (!content.includes("```report-json")) return d;
return { ...d, content: stripReportFence(content, d.streaming === true) };
}),
[dialogues],
);
const isStreaming = busy || dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "大屏数据提炼中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏(对齐模式一样式) */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<LayoutDashboard className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 大屏多页</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title="切换大屏绘制使用的推理模型"
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary disabled:opacity-50"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
{busy && (
<button
type="button"
onClick={abort}
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>
)}
{html && (
<button
type="button"
onClick={openDashboardPreview}
title="在右侧沙箱打开大屏预览"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Eye className="w-3.5 h-3.5 text-slate-500" />
打开大屏预览
</button>
)}
<button
type="button"
onClick={() => redraw()}
disabled={busy}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={onSwitchClassic}
title="切回经典单页看板绘制(模型自由发挥整页 HTML)"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
单页看板
</button>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:对话 + 沙箱(复用第 2 步分栏,与模式一一致) */}
<div className="flex-1 min-h-0 p-3">
<section className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? dashThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col">
{/* 对话流 */}
<div
ref={scrollRef}
className="custom-scrollbar flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4"
>
{displayDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
<Loader2 className="w-7 h-7 animate-spin text-primary" />
<span className="text-xs font-medium">正在初始化大屏绘制…</span>
</div>
)}
{displayDialogues.map((d) => (
<div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(d.avatarType ?? "blue", "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-2 text-xs font-semibold text-slate-700">
{d.sender}
<span
className={cn(
"rounded-full px-1.5 py-px text-[9.5px] font-bold",
d.streaming
? "bg-primary/10 text-primary"
: "bg-slate-100 text-slate-400",
)}
>
{d.confidence}
</span>
<span className="text-[9.5px] font-mono text-slate-300">{d.time}</span>
</div>
<div className="prose prose-sm max-w-none break-words text-[12.5px] leading-relaxed text-slate-700">
<MarkdownContent>{d.content || "…"}</MarkdownContent>
{d.streaming && (
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse rounded bg-primary align-middle" />
)}
</div>
</div>
</div>
))}
{error && phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-600">
{error}
</div>
)}
</div>
{/* 修改要求输入框(与模式一同款交互:随时可发,busy 时先打断再续) */}
<form
onSubmit={handleSubmit}
className="shrink-0 border-t border-slate-100 bg-slate-50/60 px-4 py-3"
>
<div className="flex items-end gap-2">
<textarea
value={revision}
onChange={(e) => setRevision(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
rows={2}
placeholder="输入修改要求,例如:把 KPI 换成成本相关指标 / 风险图改成雷达图 / 增加一个验收阶段…"
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs outline-none transition focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<button
type="submit"
disabled={!revision.trim()}
className="shrink-0 flex h-9 items-center gap-1.5 rounded-xl bg-primary px-4 text-xs font-bold text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="size-3.5" />
发送
</button>
</div>
</form>
</div>
</Step2SandboxLayout>
</section>
</div>
</div>
);
}
@@ -14,7 +14,9 @@ import type {
} from "@/roundtable-planning/api/drafts";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
@@ -43,6 +45,10 @@ export interface Step3PanelProps {
/** 草稿里已存的上次总结结果 + 完成回调 —— 透传给「总结报告」做持久化。 */
savedSummary?: DraftStep3SummarySnapshot | null;
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的三个 Step3 生成流(2026-06-13 提升:切走不断流,进入续看)。 */
summaryFlow: UseStep3SummaryReturn;
reportFlow: UseStep3ReportReturn;
dashboardFlow: UseStep3DashboardReturn;
onReset: () => void;
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
@@ -67,6 +73,9 @@ export function Step3Panel(props: Step3PanelProps) {
coordinatorPrompt,
savedSummary,
onSummarySaved,
summaryFlow,
reportFlow,
dashboardFlow,
onReset,
openArtifactPreview,
seatStubMessages,
@@ -104,6 +113,9 @@ export function Step3Panel(props: Step3PanelProps) {
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
onSummarySaved={onSummarySaved}
summaryFlow={summaryFlow}
reportFlow={reportFlow}
dashboardFlow={dashboardFlow}
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
@@ -1,4 +1,4 @@
/**
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
@@ -18,6 +18,7 @@ import {
Download,
Eye,
FileText,
LayoutDashboard,
ListOrdered,
Loader2,
PanelLeftClose,
@@ -45,11 +46,14 @@ import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
useStep3Report,
type ReportIntent,
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type {
ReportIntent,
UseStep3ReportReturn,
} from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
@@ -77,8 +81,10 @@ interface Step3ReportModeProps {
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** 页面层常驻的经典绘制流(2026-06-13 提升:切走不断流,进入续看)。 */
report: UseStep3ReportReturn;
/** 页面层常驻的大屏多页流(模式二)。 */
dashboard: UseStep3DashboardReturn;
onBack: () => void;
}
@@ -97,7 +103,8 @@ export function Step3ReportMode({
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
report,
dashboard,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
@@ -112,6 +119,7 @@ export function Step3ReportMode({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
@@ -125,17 +133,9 @@ export function Step3ReportMode({
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Report({
intentReady,
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
savedReport,
onReportSaved,
});
} = report;
const [modal, setModal] = useState<SourceModal>(null);
@@ -151,18 +151,31 @@ export function Step3ReportMode({
}
}, [artifactsOpen]);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
// 若草稿里已存上次绘制结果(savedReport)→ 直接恢复展示,不重新跑模型;用户可点「重新绘制」覆盖。
// ── 绘制模式(2026-06-13):classic = 经典单页看板(模型自由写整页 HTML);
// dashboard = 大屏多页(模型只出 report-json,模板渲染)。两条流现在**并行常驻**
// (页面进入第三步即三流齐跑),进入本页默认落经典视图;仅旧版草稿「只有大屏 HTML
// 存在 html 槽位(带 marker)」时直接落大屏。
const [dashboardMode, setDashboardMode] = useState(() => isDashboardHtml(savedReport?.html));
// 预览开关:经典绘制视图在前台(非大屏模式)时本流才允许自动打开沙箱(并行流防抢);
// 进入时若已有产物/进行中会立即接管沙箱续看。
useEffect(() => {
if (savedReport?.html) {
if (dashboardMode) return;
report.setPreviewEnabled(true);
return () => report.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardMode]);
// 续看语义(2026-06-13):hook 常驻页面层,进入第三步时页面已统一编排(总结 +
// 经典绘制并行启动)。这里只兜底"从未启动过"(idle)的场景,**卸载不再 abort**——
// 切回总结报告 / 回第二步,绘制流照跑,回头继续看。
useEffect(() => {
if (dashboardMode || phase !== "idle") return;
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -188,6 +201,33 @@ export function Step3ReportMode({
? "已暂停"
: "待绘制";
// ── 模式二:大屏多页(模板渲染)。经典模式的所有 hooks 已在上方调用完毕,
// 这里早退渲染不违反 hooks 规则。 ──
if (dashboardMode) {
return (
<Step3DashboardMode
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
dashboard={dashboard}
onSwitchClassic={() => {
// 切回经典:**不打断**大屏流(切走不断流);经典从未启动则补一次。
setDashboardMode(false);
if (phase === "idle") {
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
}
}}
onBack={onBack}
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
@@ -224,6 +264,15 @@ export function Step3ReportMode({
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={() => setDashboardMode(true)}
title="大屏多页模式:模型只输出结构化数据、模板渲染总览页 + 每席位一页(更快,支持席位间跳转)。经典绘制若在进行中不会被打断,切回可续看。"
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shadow-sm shadow-primary/10"
>
<LayoutDashboard className="w-3.5 h-3.5" />
大屏多页
</button>
<button
type="button"
onClick={onBack}
@@ -463,14 +512,14 @@ function AgentProgressPanel({
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
phase: UseStep3ReportReturn["phase"];
dialogues: UseStep3ReportReturn["dialogues"];
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
@@ -1,4 +1,4 @@
/**
/**
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
*
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
@@ -56,7 +56,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
@@ -81,8 +81,8 @@ interface Step3SummaryModeProps {
coordinatorPrompt?: string | null;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
}
@@ -103,13 +103,10 @@ export function Step3SummaryMode({
setSelectedModel,
coordinatorPrompt,
savedSummary,
onSummarySaved,
summary,
onViewStatic,
}: Step3SummaryModeProps) {
const summarySeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
@@ -118,6 +115,7 @@ export function Step3SummaryMode({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
@@ -131,18 +129,9 @@ export function Step3SummaryMode({
redraw,
ask,
pause,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Summary({
intentReady,
seatDeliveries: summarySeatDeliveries,
consensusText,
selectedModel,
coordinatorPrompt,
savedSummary,
onSummarySaved,
});
} = summary;
const [modal, setModal] = useState<SourceModal>(null);
@@ -224,15 +213,24 @@ export function Step3SummaryMode({
}
}, [rightPanelVisible]);
// 进入即自动开跑;草稿里已存结果则恢复展示(流程图模式的快照可能只有 closing 没有 md)。
// 卸载中断在途请求(理由同 Step3ReportMode)。
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
// 产物/进行中会立即接管沙箱续看。
useEffect(() => {
if (savedSummary && (savedSummary.md.trim() || savedSummary.closing?.trim())) {
summary.setPreviewEnabled(true);
return () => summary.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 续看语义(2026-06-13):hook 常驻页面层,页面在进入第三步时已统一编排启动。
// 这里只做"从未启动过"的兜底(idle 才动手),**卸载不再 abort** —— 用户切去
// 结果绘制 / 回第二步,总结流照跑,回头继续看进行中过程或结果。
useEffect(() => {
if (phase !== "idle") return;
if (savedSummary && (savedSummary.md.trim() || (savedSummary.closing ?? "").trim())) {
hydrateSaved(savedSummary);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -557,8 +555,8 @@ function AgentProgressPanel({
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Summary>["phase"];
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
@@ -566,8 +564,8 @@ function AgentProgressPanel({
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
onArtifactStepClick: ReturnType<typeof useStep3Summary>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Summary>["openArtifactPreview"];
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
@@ -103,7 +103,7 @@ export interface UseDraftPersistenceReturn {
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
handleLoadDraft: (id: string) => Promise<boolean>;
/** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
loadLatestByTask: (taskId: string) => Promise<boolean>;
handleDeleteDraft: (id: string) => void;
@@ -115,6 +115,11 @@ export interface UseDraftPersistenceReturn {
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/**
* **强制**把当前会话内容存进草稿(不同于 ensureDraft 的"已有就跳过")——后台挂起
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
*/
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
@@ -271,6 +276,17 @@ export function useDraftPersistence(
[currentStep, enqueueSave, refreshList],
);
// 强制存当前内容(与 ensureDraft 不同:已有草稿也照存,把最新对话回写进去)。
const saveDraftNow = useCallback(
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
const hadId = currentDraftIdRef.current;
const id = await enqueueSave(furthestStep);
if (id && !hadId) await refreshList();
return id;
},
[currentStep, enqueueSave, refreshList],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
@@ -381,23 +397,26 @@ export function useDraftPersistence(
],
);
// 返回 Promise<是否成功 hydrate>:调用方(如"查看运行中作业")需在 hydrate 完成
// **之后**再订阅 SSE 实时流,否则草稿快照会覆盖实时帧(竞态)。
const handleLoadDraft = useCallback(
(id: string) => {
void (async () => {
(id: string): Promise<boolean> => {
return (async () => {
let draft;
try {
draft = await getDraft(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
return;
return false;
}
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
await refreshList();
return;
return false;
}
applyLoadedDraft(draft, `📂 已加载草稿:${draft.title}`);
return true;
})();
},
[applyLoadedDraft, refreshList, triggerToast],
@@ -515,6 +534,7 @@ export function useDraftPersistence(
handleCommitRename,
handleCancelRename,
ensureDraft,
saveDraftNow,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
@@ -330,6 +330,17 @@ export interface UseStep2OrchestrationReturn {
handleSaveAgentAndRecalculate: (id: number) => void;
/** 从草稿加载;同时把 step2InitRef 置为「已 init」跳过自动 SSE。 */
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
/**
* 「查看=恢复流程」(2026-06-13):后台作业 SSE 每帧调用,把作业实时对话/产物 stub/
* 共识进度灌进第二步画面 —— 消息列表像本地编排一样实时长出,而不是静态聊天记录。
*/
applyLiveJobFrame: (frame: {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
consensusPercentage: number;
hasConsensus: boolean;
lastLeaderContent: string;
}) => void;
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
resetStep2State: () => void;
/** 当前沙箱预览绑定的席位 thread id。 */
@@ -1129,20 +1140,26 @@ export function useStep2Orchestration(
}
}, [selectedParallelSeats, setDialogueContent]);
// 推荐模式并行批进行中标记(2026-06-13):总控一轮派给多个席位 → 判定可并行,
// 批内并行执行 + 显示走「业务链 sandbox 单列可写」同款(含下方沙箱跟随)。
const recommendParallelRef = useRef(false);
// sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时,
// 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。
// 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact
// 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。
// 推荐模式并行批(recommendParallelRef)同样按单列沙箱跟随处理。
useEffect(() => {
const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : "";
const singleColumn = dagExecMode === "sandbox" || recommendParallelRef.current;
const seat = singleColumn ? selectedParallelSeats[0] ?? "" : "";
// 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact /
// setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱
// 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。
const key = `${dagExecMode}:${seat}`;
const key = `${dagExecMode}:${recommendParallelRef.current ? "rp" : ""}:${seat}`;
if (lastReconciledSeatRef.current === key) return;
lastReconciledSeatRef.current = key;
if (dagExecMode !== "sandbox" || !seat) {
if (!singleColumn || !seat) {
sandboxFollowThreadIdRef.current = null;
return;
}
@@ -1340,7 +1357,89 @@ export function useStep2Orchestration(
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
let nextLeaderTask = seedMessage;
// 串/并行交给**总控判断**(2026-06-13):各席位子任务相互独立 → 同一轮一次性
// 派给多个席位(前端并行执行,单列可写显示);有先后依赖 → 每轮只派一个。
const PARALLEL_JUDGE_NOTE =
"\n\n【派活方式】派活前请先判断各席位子任务之间的依赖关系:相互独立的子任务请在" +
"**同一轮**通过 agent_orchestration 一次性派给多个席位(系统会并行执行、显著提速);" +
"有先后依赖的子任务则每轮只派一个席位,等其交付后再派下一个。";
// 优化1+2(2026-06-13):每个席位任务都拼上「直接产出别提问 + 承接前序 + 结构化交付模板」。
// 提升交付质量,并让 Step3 三产物(尤其大屏 seats.sections)的提炼有稳定结构可循。
const SEAT_DELIVERY_GUIDE =
"\n\n【交付要求】请直接基于已知信息做出合理假设、给出完整交付,**不要向用户提问或等待确认**;" +
"承接此前各席位的交付(需要其它席位产物全文时用 read_peer_delivery 获取),不要重复或推翻已有结论。" +
"交付请按以下结构组织:① 一句话核心结论(先行);② 关键要点(分条列出);" +
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
// 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run
// (后端注入 context,席位用 read_peer_delivery 按需取全文,不进 prompt 不耗 token)。
const priorDeliveries: Array<{ name: string; content: string }> = [];
// 共享的「单席位交付执行」——串行/并行分支共用。含**失败自动重试一次**(优化5,
// 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。
const runSeatDelivery = async (a: {
agentId: string;
displayName: string;
task: string;
bubbleId: number;
seatThreadId: string | undefined;
isParallel: boolean;
}): Promise<{ content: string; aborted: boolean }> => {
const meta = agentNameToRoleRef.current[a.agentId.toLowerCase()];
const perSeatModel = meta ? agentConfigsRef.current[meta.speakerId]?.model : undefined;
const fullTask = a.task + SEAT_DELIVERY_GUIDE;
for (let attempt = 0; attempt < 2; attempt += 1) {
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: a.agentId,
displayName: a.displayName,
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label);
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
finalizeDialogue(a.bubbleId, specRes.content || "(已停止)", "已停止");
return { content: "", aborted: true };
}
finalizeDialogue(a.bubbleId, specRes.content, "子智能体交付");
return { content: specRes.content ?? "", aborted: false };
} catch (err) {
activeAbortersRef.current.delete(subAborter);
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(a.bubbleId, "(已停止)", "已停止");
return { content: "", aborted: true };
}
if (attempt === 0) {
// 第一次失败 → 清掉半截内容,自动重试一次。
console.warn(`[special:${a.agentId}] 第一次失败,自动重试:`, err);
if (a.isParallel) parallelStreamRef.current.delete(a.bubbleId);
else setDialogueContent(a.bubbleId, "");
setDialogueConfidence(a.bubbleId, "失败重试中…");
continue;
}
console.error(`[special:${a.agentId}] error:`, err);
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
return { content: "", aborted: false };
}
}
return { content: "", aborted: false };
};
let nextLeaderTask = seedMessage + PARALLEL_JUDGE_NOTE;
let safetyCounter = 0;
try {
@@ -1445,86 +1544,97 @@ export function useStep2Orchestration(
break;
}
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
displayName: meta?.displayName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(
subBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
seatThreadId,
);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, seatThreadId),
const dispatched = leaderRes.dispatched as LeaderDispatch[];
if (dispatched.length > 1) {
// ── 总控一轮派给多个席位 = 它判定子任务相互独立 → **批内并行**执行
// (2026-06-13)。显示走「业务链 sandbox 单列可写」同款:头像条 + 单列
// 消息流 + 沙箱跟随选中席位;席位可正常写文件(seat policy)。
recommendParallelRef.current = true;
const seatPrep = dispatched.map(([subName, task]) => {
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
const seatName = meta?.displayName ?? subName;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, subName);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return { agentId: subName, task, meta, seatName, avatarType, seatThreadId, bubbleId };
});
markParallelSpeakers(
seatPrep
.map((p) => p.meta?.speakerId)
.filter((v): v is number => typeof v === "number"),
);
setParallelBatch(
seatPrep.map((p) => ({
agentId: p.agentId,
displayName: p.seatName,
avatarType: p.avatarType,
status: "running" as const,
bubbleId: p.bubbleId,
})),
);
// 单列(对齐业务链 sandbox 模式):默认选中第一个,点头像切换。
setSelectedParallelSeats(seatPrep.slice(0, 1).map((p) => p.agentId));
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
setParallelBatch((prev) =>
prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)),
);
const batchResults = await Promise.all(
seatPrep.map(async ({ agentId, task, meta, seatName, seatThreadId, bubbleId }) => {
const r = await runSeatDelivery({
agentId,
displayName: seatName,
task,
bubbleId,
seatThreadId,
isParallel: true,
});
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return r.content.trim() ? { name: seatName, content: r.content } : null;
}),
);
// 批结束:收起并行显示,回普通流式列表。
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
recommendParallelRef.current = false;
// 累积本批交付(供后续轮席位 read_peer_delivery 取全文)。
for (const r of batchResults) if (r) priorDeliveries.push(r);
} else {
// **顺序**派活(单席位 / 总控判定有依赖)—— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of dispatched) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const r = await runSeatDelivery({
agentId: subName,
displayName: meta?.displayName ?? subName,
task,
bubbleId: subBubbleId,
seatThreadId,
isParallel: false,
});
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
"已停止",
);
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (
subAborter.signal.aborted ||
(err as Error).name === "AbortError"
) {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[special:${subName}] error:`, err);
finalizeDialogue(
subBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
if (r.content.trim()) priorDeliveries.push({ name: meta?.displayName ?? subName, content: r.content });
if (r.aborted) break;
}
if (aborted) break;
}
if (isStale()) break;
@@ -1533,8 +1643,14 @@ export function useStep2Orchestration(
Math.min(95, parseFloat((prev + 8).toFixed(1))),
);
// 优化3:续轮强化收敛指引——信息足够就**明确收口**,结论开头标注【已达成共识】,
// 减少弱模型该收口时还乱派 / 该继续时草草收尾的两类偏差。
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
PARALLEL_JUDGE_NOTE;
}
if (safetyCounter >= MAX_CYCLES) {
@@ -1554,11 +1670,15 @@ export function useStep2Orchestration(
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
setDialogueContent,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
markParallelSpeakers,
ingestParallelDelta,
resetParallelStreams,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
@@ -2531,6 +2651,26 @@ export function useStep2Orchestration(
[],
);
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
// 线程/init 闸门 —— 那些由先行的 hydrateFromDraft 建好)。
const applyLiveJobFrame = useCallback(
(frame: {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
consensusPercentage: number;
hasConsensus: boolean;
lastLeaderContent: string;
}) => {
setStep2RoundtableDialogues(frame.dialogues);
seatStubMessagesRef.current = frame.seatStubMessages;
setSeatStubMessages(frame.seatStubMessages);
setConsensusPercentage(frame.consensusPercentage);
setHasConsensus(frame.hasConsensus);
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
},
[],
);
const resetStep2State = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
@@ -2608,6 +2748,7 @@ export function useStep2Orchestration(
handleBudgetChange,
handleSaveAgentAndRecalculate,
hydrateFromDraft,
applyLiveJobFrame,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
@@ -0,0 +1,496 @@
/**
* Step 3「结果绘制 · 大屏多页(模式二)」编排 hook —— 与模式一同款交互(2026-06-13)。
*
* 链路与模式一/总结报告完全同源(`initReportThread` + `streamMultiAgent`,
* 已验证可用):流式对话气泡 + 右侧沙箱预览 + 输入框继续修改。区别在产物:
* - 模型只输出 ```report-json 结构化数据(buildDashboardPrompt,禁工具禁文件);
* - 前端 `renderDashboardHtml` 模板渲染成大屏 HTML,**种成 write_file 虚拟 stub**
* (path = DASHBOARD_OUTPUT_PATH)→ 走与模式一相同的沙箱 HTML 预览(含代码/预览
* 切换、下载、新窗口打开);
* - `ask(message)`:同线程续轮,要求模型输出**修订后的全量** report-json → 重渲染
* → 更新 stub 与草稿快照。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
initReportThread,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
buildDashboardPrompt,
DASHBOARD_OUTPUT_PATH,
extractReportData,
isDashboardHtml,
parseReportJson,
renderDashboardHtml,
} from "@/roundtable-planning/lib/report-template";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
const DASH_AGENT_FALLBACK = "roundtable-report";
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3DashboardOptions {
intentReady: ReportIntent | null;
seatDeliveries: Array<{ sender: string; content: string }>;
consensusText: string;
selectedModel: string;
/** 当前界面主题 —— 大屏按它取色(light=浅红色 UI 规范 / dark=深蓝色 UI 规范)。 */
themeMode: "light" | "dark";
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
}
export interface UseStep3DashboardReturn {
phase: DashboardPhase;
error: string | null;
dashThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null;
busy: boolean;
/** 当前大屏 HTML(已渲染;无则 null)。 */
html: string | null;
start: () => void;
redraw: () => void;
/** 用户输入修改要求 → 同线程续轮,输出修订版全量数据并重渲染。 */
ask: (message: string) => void;
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
/** 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 完成气泡,不自动开沙箱。 */
hydrateSaved: (html: string) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
openDashboardPreview: () => void;
}
function formatNow(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function dashPhaseLabel(phase: StreamPhase): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "streaming":
return "正在提炼大屏数据";
default:
return "正在输出…";
}
}
export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3DashboardReturn {
const {
intentReady,
seatDeliveries,
consensusText,
selectedModel,
themeMode,
savedReport,
onReportSaved,
} = options;
const onSavedRef = useRef(onReportSaved);
useEffect(() => {
onSavedRef.current = onReportSaved;
}, [onReportSaved]);
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<DashboardPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [dashThreadId, setDashThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<Record<string, StubAiMessage[]>>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(null);
const [html, setHtml] = useState<string | null>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
const liveThreadRef = useRef<string | null>(null);
const agentNameRef = useRef<string>(DASH_AGENT_FALLBACK);
const htmlRef = useRef<string | null>(null);
// ── 气泡三段式(append → delta → finalize),对齐 useStep3Summary ──
const appendStreamingDialogue = useCallback((threadId?: string): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "大屏绘制智能体",
avatarType: "blue",
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: threadId,
},
]);
return id;
}, []);
const appendUserDialogue = useCallback((text: string) => {
setDialogues((prev) => [
...prev,
{
id: Date.now() + Math.random(),
sender: "指挥官(修改要求)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, confidence } : d)));
}, []);
const finalizeDialogue = useCallback((id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next = finalContent?.trim() ? finalContent : d.content || "(无文本输出)";
return { ...d, content: next, confidence: confidence ?? d.confidence, streaming: false };
}),
);
}, []);
// ── 大屏 stub(write_file 虚拟产物 → 沙箱 HTML 预览) ──
const upsertDashStub = useCallback((threadId: string, htmlContent: string) => {
const messageId = `roundtable-dashboard-stub-${threadId}`;
const toolCallId = `tc-${threadId}-0`;
const msg: StubAiMessage = {
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ id: toolCallId, name: "write_file", args: { path: DASHBOARD_OUTPUT_PATH, content: htmlContent } },
],
};
const nextMap = { ...stubRef.current, [threadId]: [msg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
return { messageId, toolCallId };
}, []);
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
const previewEnabledRef = useRef(false);
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
null,
);
const openDashboardArtifact = useCallback(
(threadId: string, messageId: string, toolCallId: string, opts?: { force?: boolean }) => {
lastAutoOpenRef.current = { threadId, messageId, toolCallId };
if (!opts?.force && !previewEnabledRef.current) return; // 不在前台只记录不抢
const virtualUrl = new URL(
`write-file:${DASHBOARD_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台——
// 面板停留在其它流的虚拟文件上时本视图查不到内容、显示空白);面板关着
// 则保持关闭(尊重"进第三步默认关沙箱"),用户点按钮再开。
const last = lastAutoOpenRef.current;
if (enabled && last && artifactsOpenRef.current) {
openDashboardArtifact(last.threadId, last.messageId, last.toolCallId, { force: true });
}
},
[openDashboardArtifact],
);
const openDashboardPreview = useCallback(() => {
const tid = liveThreadRef.current ?? activeArtifactThreadId ?? "roundtable-dashboard-restored";
const current = htmlRef.current;
if (!current) return;
const { messageId, toolCallId } = upsertDashStub(tid, current);
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 一轮流式:发 message 给大屏绘制线程,**生成过程实时进沙箱**(结构化数据边出
* 边以代码态显示,对齐模式一"看着 HTML 被写出来"的体验),结束后解析
* report-json → 按当前主题渲染 → 沙箱自动切预览。 */
const streamOnce = useCallback(
async (args: { bubbleId: number; message: string; aborter: AbortController }) => {
const { bubbleId, message, aborter } = args;
let threadId = liveThreadRef.current;
if (!threadId) {
const init = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
liveThreadRef.current = threadId;
setDashThreadId(threadId);
}
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
);
setPhase("streaming");
// 生成开始就打开沙箱:流式期间 stub 内容=模型已输出的结构化数据(代码态展示),
// 完成后替换成渲染好的大屏 HTML(isLoading 翻 false → 预览自动切换)。
let acc = "";
let lastFlush = 0;
const initial = upsertDashStub(threadId, "(正在连接模型,结构化数据稍后在此实时显示…)");
openDashboardArtifact(threadId, initial.messageId, initial.toolCallId);
const flushStub = (force = false) => {
const now = Date.now();
if (!force && now - lastFlush < 200) return;
lastFlush = now;
upsertDashStub(threadId!, acc);
};
const res = await streamMultiAgent({
agentType: "special",
agentName: agentNameRef.current,
dAgentThreadId: { [agentNameRef.current]: threadId },
displayName: "大屏绘制",
newMessage: message,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => {
appendToDialogue(bubbleId, text);
acc += text;
flushStub();
},
onPhaseChange: (p) => setDialogueConfidence(bubbleId, dashPhaseLabel(p)),
});
if (aborter.signal.aborted) return;
acc = res.content || acc;
flushStub(true);
const data = extractReportData(res.content);
if (!data) {
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
finalizeDialogue(
bubbleId,
(res.content || "").trim() ||
"(模型没有输出有效的 report-json 数据,请点「重新绘制」或输入更明确的修改要求重试)",
"数据解析失败",
);
setError("模型没有输出有效的 report-json 结构化数据");
setPhase("error");
return;
}
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
htmlRef.current = rendered;
setHtml(rendered);
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
openDashboardArtifact(threadId, messageId, toolCallId);
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
setPhase("done");
setError(null);
// **只存结构化数据**(dashboardJson),不写 step3.html —— html 槽位归经典模式一,
// 两者并行绘制互不覆盖;查看时按当前主题重新渲染,主题切换天然适配。
onSavedRef.current?.({ dashboardJson: JSON.stringify(data) });
},
[
selectedModel,
themeMode,
appendToDialogue,
setDialogueConfidence,
finalizeDialogue,
upsertDashStub,
openDashboardArtifact,
],
);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setError(null);
setPhase("creating");
setDialogues([]);
stubRef.current = {};
setSeatStubMessages({});
liveThreadRef.current = null;
const aborter = new AbortController();
aborterRef.current = aborter;
const bubbleId = appendStreamingDialogue();
try {
const intentText = [
intentReady?.objective ?? "",
...(intentReady?.constraints ?? []).map((c) => `约束:${c}`),
...(intentReady?.assumptions ?? []).map((a) => `假设:${a}`),
]
.filter(Boolean)
.join("\n");
const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
await streamOnce({ bubbleId, message: prompt, aborter });
} 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;
}
}, [intentReady, seatDeliveries, consensusText, appendStreamingDialogue, streamOnce, finalizeDialogue]);
const ask = useCallback(
async (message: string) => {
const text = message.trim();
if (!text) return;
const wasBusy = runningRef.current;
abort();
if (wasBusy) {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: "已打断" } : d)),
);
}
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
const bubbleId = appendStreamingDialogue(liveThreadRef.current ?? undefined);
setPhase("streaming");
try {
const prompt =
`${text}\n\n(系统指令:请基于上一版数据按用户要求修订,在回复最末尾输出**修订后的完整全量** ` +
"```report-json 代码块(不是增量),正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
await streamOnce({ bubbleId, message: prompt, aborter });
} 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, appendUserDialogue, appendStreamingDialogue, streamOnce, finalizeDialogue],
);
const hydrateSaved = useCallback(
(savedHtml: string) => {
abort();
runningRef.current = false;
htmlRef.current = savedHtml;
setHtml(savedHtml);
const synthThreadId = "roundtable-dashboard-restored";
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
setError(null);
setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
},
[abort, upsertDashStub],
);
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
const hydrateFromJson = useCallback(
(jsonStr: string) => {
const data = parseReportJson(jsonStr);
if (!data) return;
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered);
},
[themeMode, hydrateSaved],
);
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
void savedReport;
void isDashboardHtml;
const start = useCallback(() => {
void run();
}, [run]);
const redraw = useCallback(() => {
abort();
liveThreadRef.current = null; // 全新线程重画(避免旧上下文把修订当底稿)
void run();
}, [abort, run]);
const busy = phase === "creating" || phase === "streaming";
return {
phase,
error,
dashThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
html,
start,
redraw,
ask: (m: string) => void ask(m),
abort,
setPreviewEnabled,
hydrateSaved,
hydrateFromJson,
openDashboardPreview,
};
}
@@ -95,6 +95,8 @@ export interface UseStep3ReportReturn {
themeMismatch: boolean;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
openArtifactFromStep: (
dialogue: Step2Dialogue,
@@ -271,7 +273,12 @@ export function useStep3Report(
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<ReportPhase>("idle");
const [error, setError] = useState<string | null>(null);
@@ -494,12 +501,38 @@ export function useStep3Report(
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后总结/绘制并行共用全局沙箱):只有用户
// 当前所在视图的流允许**自动**打开沙箱;显式用户点击不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
/**
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
*/
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
lastAutoOpenedUrlRef.current = virtualUrl; // 只记录,回到视图时接管
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
@@ -649,6 +682,7 @@ export function useStep3Report(
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file =
@@ -1001,7 +1035,13 @@ export function useStep3Report(
],
};
stubRef.current = stub;
lastAutoOpenedUrlRef.current = null;
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 HTML(防串台)。
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
REPORT_OUTPUT_PATH,
messageId,
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
setSeatStubMessages(stub);
setReportThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
@@ -1031,11 +1071,11 @@ export function useStep3Report(
]);
setError(null);
setPhase("done");
const virtualUrl = buildWriteFileVirtualUrl(REPORT_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl, openArtifact],
[abort, buildWriteFileVirtualUrl],
);
const redraw = useCallback((revisionInstruction?: string) => {
@@ -1061,6 +1101,7 @@ export function useStep3Report(
interject,
themeMismatch,
abort,
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
};
@@ -40,7 +40,7 @@ import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -87,6 +87,8 @@ export interface UseStep3SummaryReturn {
pause: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
@@ -164,7 +166,8 @@ function buildSummaryPrompt(opts: {
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
@@ -201,9 +204,35 @@ function buildSummaryPrompt(opts: {
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
*/
const FLOW_CONTEXT_ASK_INSTRUCTION =
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
@@ -212,10 +241,10 @@ function buildResumeAskPrompt(opts: {
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
flowIntent?: boolean;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, flowIntent, ...materials } = opts;
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
@@ -234,9 +263,9 @@ function buildResumeAskPrompt(opts: {
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (flowIntent) {
if (askInstruction) {
parts.push("");
parts.push(FLOW_ASK_INSTRUCTION);
parts.push(askInstruction);
}
return parts.join("\n");
}
@@ -283,7 +312,12 @@ export function useStep3Summary(
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
@@ -303,6 +337,10 @@ export function useStep3Summary(
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
@@ -483,8 +521,36 @@ export function useStep3Summary(
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
@@ -618,6 +684,7 @@ export function useStep3Summary(
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
@@ -697,7 +764,7 @@ export function useStep3Summary(
path: SUMMARY_OUTPUT_PATH,
content: md,
});
if (opts?.open === false) return;
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
@@ -720,6 +787,8 @@ export function useStep3Summary(
flowRound?: boolean;
}): Promise<void> => {
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
// 本轮开始前的报告底稿——结束后对比判断「这一轮的主产物是报告还是流程图」。
const mdBefore = currentMdRef.current;
let threadId = seedThreadId ?? null;
let agentName = SUMMARY_AGENT_NAME;
@@ -771,6 +840,14 @@ export function useStep3Summary(
});
if (aborter.signal.aborted) return;
// 跟踪本轮主产物:回复带 flow-json → 流程图轮;md 有实质更新 → 报告轮;
// 都没有(纯问答轮)→ 维持上一轮状态。供下一次 ask() 选意图引导指令。
if (hasFlowFence(res.content)) {
flowActiveRef.current = true;
} else if (md.trim() && md !== mdBefore) {
flowActiveRef.current = false;
}
finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done");
@@ -825,6 +902,7 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
const flowMode = detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
seatDeliveries,
@@ -865,8 +943,19 @@ export function useStep3Summary(
abort();
if (wasBusy) freezeStreaming("已打断");
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
const flowIntent = detectFlowIntent(text);
const flowContext = !flowIntent && flowActiveRef.current;
const askInstruction = flowIntent
? FLOW_ASK_INSTRUCTION
: flowContext
? FLOW_CONTEXT_ASK_INSTRUCTION
: SEMANTIC_ASK_INSTRUCTION;
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
const flowRound = flowIntent || flowContext;
runningRef.current = true;
setError(null);
@@ -910,11 +999,11 @@ export function useStep3Summary(
setPhase("streaming");
if (hadLiveThread) {
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
// 真线程仍在 → 续问,发原始消息 + 按语境选好的意图引导指令,
// 靠线程上下文 + SOUL 让智能体按语义自判改图 / 改报告 / 问答 / 反问澄清。
const base =
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
const prompt = `${base}\n\n${askInstruction}`;
await streamOnce({
bubbleId,
prompt,
@@ -922,7 +1011,7 @@ export function useStep3Summary(
seedThreadId: liveThreadRef.current,
forceReconcile: true,
files: fileRefs,
flowRound: flowIntent,
flowRound,
});
} else {
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
@@ -935,7 +1024,7 @@ export function useStep3Summary(
previousMd: currentMdRef.current,
userMessage:
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
flowIntent,
askInstruction,
});
await streamOnce({
bubbleId,
@@ -944,7 +1033,7 @@ export function useStep3Summary(
seedThreadId: threadId,
forceReconcile: true,
files: fileRefs,
flowRound: flowIntent,
flowRound,
});
}
} catch (err) {
@@ -1021,7 +1110,20 @@ export function useStep3Summary(
stubRef.current = stub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
lastAutoOpenedUrlRef.current = null;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
flowActiveRef.current = hasFlowFence(snap.closing);
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 md(防串台)。
if (hasMd) {
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
SUMMARY_OUTPUT_PATH,
messageId,
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
} else {
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(stub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
@@ -1052,13 +1154,11 @@ export function useStep3Summary(
]);
setError(null);
setPhase("done");
if (hasMd) {
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
}
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl, openArtifact],
[abort, buildWriteFileVirtualUrl],
);
const busy = phase === "creating" || phase === "streaming";
@@ -1077,6 +1177,7 @@ export function useStep3Summary(
ask,
pause,
abort,
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
};
@@ -11,6 +11,7 @@ import test from "node:test";
import {
detectFlowIntent,
extractFlowGraph,
hasFlowFence,
layoutFlowGraph,
stripFlowFence,
} from "./flow-graph";
@@ -128,6 +129,16 @@ test("detectFlowIntent:普通措辞不误触发", () => {
assert.equal(detectFlowIntent(undefined), false);
});
test("hasFlowFence:判定回复主产物是否为流程图(流式半截也算)", () => {
assert.ok(hasFlowFence(FULL)); // 完整闭合块
assert.ok(hasFlowFence('说明文字\n```flow-json\n{"nodes":[')); // 流式半截、未闭合
assert.equal(hasFlowFence("纯文字报告正文,没有流程图"), false);
assert.equal(hasFlowFence("```json\n{}\n```"), false); // 普通 json 块不算
assert.equal(hasFlowFence(""), false);
assert.equal(hasFlowFence(null), false);
assert.equal(hasFlowFence(undefined), false);
});
test("layoutFlowGraph:环不死循环", () => {
const text = [
"```flow-json",
@@ -236,6 +236,11 @@ export function detectFlowIntent(text: string | null | undefined): boolean {
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
}
/** 文本里是否带 flow-json 代码块(判定某轮回复的主产物是不是流程图)。 */
export function hasFlowFence(text: string | null | undefined): boolean {
return !!text && text.includes(FLOW_FENCE_OPEN);
}
/**
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
@@ -0,0 +1,129 @@
/**
* 后台作业 → Step2 实时画面映射(2026-06-13「查看=恢复流程」)。
*
* 用户点「查看」一个**运行中**的后台作业时:加载草稿后订阅 `streamJob` SSE,
* 把每帧 `RoundtableJob.dialogues`(执行器落库的中性对话记录)映射成前端
* `Step2Dialogue` + `seatStubMessages`,灌进 useStep2Orchestration ——
* 第二步消息列表像本地编排一样**实时长出**新气泡/步骤卡,而不是静态聊天记录。
*
* 映射规则与后端 `roundtable_job_executor._to_step2_dialogue` / `_seat_stub_messages`
* 逐字段对齐(id/steps/threadId/time/stub 约定一致),保证 SSE 实时帧与作业终态
* 写进草稿的 run 形状无缝衔接(终态重载草稿不会跳变)。
*/
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { JobDialogue, RoundtableJob } from "@/roundtable-planning/lib/job-types";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
/** 工具步骤卡回退文案(对齐执行器 _STEP_LABEL;前端 getStepDisplay 主要按 key 推导)。 */
const STEP_LABEL: Record<string, string> = {
agent_orchestration: "派活给子智能体",
write_file: "写入文件",
str_replace: "写入文件",
read_file: "读取文件",
ls: "列出文件夹",
bash: "执行命令",
web_search: "搜索相关信息",
ask_clarification: "需要你的协助",
present_files: "展示文件",
};
function stepsFromToolCalls(
toolCalls: NonNullable<JobDialogue["toolCalls"]>,
dialogueId: number,
ts: string,
): NonNullable<Step2Dialogue["steps"]> {
const steps: NonNullable<Step2Dialogue["steps"]> = [];
toolCalls.forEach((tc, i) => {
const name = tc?.name ?? "";
if (!name) return;
steps.push({
id: `${dialogueId}-${i}`,
key: `tool_calling:${name}`,
label: STEP_LABEL[name] ?? `使用 “${name}” 工具`,
ts,
args: (tc.args ?? {}) as Record<string, unknown>,
});
});
return steps;
}
function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2Dialogue {
const time = d.time ?? "";
const base: Step2Dialogue = {
id: d.id,
sender: "",
avatarType: "blue",
content: d.content ?? "",
time,
confidence: "",
streaming: false,
steps: stepsFromToolCalls(d.toolCalls ?? [], d.id, time),
};
if (d.threadId) base.agentThreadId = d.threadId;
if (d.role === "leader") {
return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" };
}
const aid = d.agentId ?? "";
return {
...base,
sender: d.name || aid,
avatarType: getAvatarTypeFor(aid, idxByAgent.get(aid.toLowerCase()) ?? 0),
confidence: "子智能体交付",
};
}
/** 从各对话的 write_file 重建 seatStubMessages(同 thread 跨轮按 path 去重、后写覆盖)。 */
function seatStubsFromDialogues(dialogues: JobDialogue[]): Record<string, StubAiMessage[]> {
const byTid = new Map<string, Map<string, NonNullable<JobDialogue["toolCalls"]>[number]>>();
for (const d of dialogues) {
const tid = d.threadId;
if (!tid) continue;
for (const tc of d.toolCalls ?? []) {
if (tc?.name !== "write_file") continue;
const path = String((tc.args as { path?: unknown } | undefined)?.path ?? "");
let m = byTid.get(tid);
if (!m) {
m = new Map();
byTid.set(tid, m);
}
m.set(path, tc); // 后写覆盖
}
}
const out: Record<string, StubAiMessage[]> = {};
for (const [tid, byPath] of byTid) {
const toolCalls = Array.from(byPath.values()).map((tc, i) => ({
id: `tc-${tid}-${i}`,
name: "write_file",
args: (tc.args ?? {}) as Record<string, unknown>,
}));
out[tid] = [{ id: `roundtable-stub-${tid}`, type: "ai", content: "", tool_calls: toolCalls }];
}
return out;
}
export interface LiveJobFrame {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
consensusPercentage: number;
hasConsensus: boolean;
lastLeaderContent: string;
}
/** 把一帧作业快照映射成 Step2 实时画面数据。 */
export function mapJobToLiveStep2(job: RoundtableJob): LiveJobFrame {
const idxByAgent = new Map<string, number>();
job.dispatchChain.forEach((n, i) => idxByAgent.set(n.agentId.toLowerCase(), i));
const dialogues = job.dialogues.map((d) => toStep2Dialogue(d, idxByAgent));
const lastLeader =
[...job.dialogues].reverse().find((d) => d.role === "leader")?.content ?? "";
return {
dialogues,
seatStubMessages: seatStubsFromDialogues(job.dialogues),
consensusPercentage: job.consensusPercentage,
hasConsensus: job.status === "done",
lastLeaderContent: lastLeader,
};
}
@@ -25,8 +25,9 @@ export type JobPhase =
| "leader_thinking" // 总控在决策派活
| "dispatching" // 正在顺序派活给席位(配合 activeAgentId)
| "consensus" // 共识达成(dispatched=[])
| "report_drawing" // Step3 报告绘制中
| "report_done"; // 绘制完成
| "summary_writing" // Step3 方案总结报告撰写中(roundtable-summary 写 md)
| "report_drawing" // Step3 结果绘制中(roundtable-report 画 HTML)
| "report_done"; // Step3 完成
/** 派活链路单节点状态(驱动颜色 / 动画)。 */
export type ChainNodeState = "pending" | "active" | "done";
@@ -55,6 +56,12 @@ export interface JobDialogue {
name: string;
content: string;
cycle: number;
/** 本轮工具调用({name, args, id})—— 映射成前端步骤卡 / write_file stub。 */
toolCalls?: Array<{ name: string; args?: Record<string, unknown>; id?: string }>;
/** 该气泡对应的 leader/seat 线程 id(点开产物用)。 */
threadId?: string | null;
/** 气泡时间戳(HH:MM:SS,后端创建时盖章)。 */
time?: string;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
@@ -75,8 +82,23 @@ export interface RoundtableJob {
error: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
dialogues: JobDialogue[];
/** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */
step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null;
/** Step3 快照(done 时有):结果绘制 { html, … } + 嵌套的方案总结报告 summaryReport。 */
step3: {
html?: string;
generatedAt?: string;
model?: string;
summary?: string;
/** 方案总结报告(roundtable-summary 的 md)——与草稿 DraftStep3SummarySnapshot 同形状。 */
summaryReport?: {
md: string;
threadId?: string;
generatedAt?: string;
model?: string;
closing?: string;
} | null;
/** 大屏多页(模式二)的 report-json 数据字符串(前端按当前主题渲染)。 */
dashboardJson?: string | null;
} | null;
createdAt: string | null;
updatedAt: string | null;
}
@@ -0,0 +1,159 @@
/**
* report-template.ts 纯函数单测(大屏多页模式的契约层)。
* 运行方式(项目无 vitest,对齐 flow-graph.test.ts):
* npx esbuild src/roundtable-planning/lib/report-template.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/report-template.test.mjs
* node --test .tmp-tests/report-template.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDashboardPrompt,
extractReportData,
isDashboardHtml,
renderDashboardHtml,
RT_DASHBOARD_MARKER,
stripReportFence,
} from "./report-template";
const SAMPLE = [
"下面是大屏数据。",
"```report-json",
JSON.stringify({
title: "智能机房选址方案大屏",
subtitle: "三地候选 · 综合评审",
kpis: [
{ label: "总投资", value: "1.2", unit: "亿元", trend: "up" },
{ label: "工期", value: "14", unit: "个月" },
],
consensus: { headline: "推荐 B 地块方案", points: ["供电冗余最高", "扩容空间充足"] },
charts: [
{ type: "bar", title: "候选地块评分", items: [{ label: "A", value: 72 }, { label: "B", value: 89 }] },
{ type: "donut", title: "投资构成", items: [{ label: "土建", value: 40 }, { label: "设备", value: 60 }] },
],
timeline: [{ label: "阶段一", desc: "勘察与立项" }],
seats: [
{ id: "seat-1", name: "情报收集", summary: "完成三地基础数据采集", sections: [{ heading: "数据来源", bullets: ["政务公开", "实地走访"] }], metrics: [{ label: "覆盖率", value: 92 }], risks: [], dependsOn: [] },
{ id: "seat-2", name: "方案设计", summary: "B 地块双回路供电方案", sections: [], metrics: [], risks: [{ label: "供电瓶颈", level: "high" }], dependsOn: ["seat-1"] },
],
}),
"```",
].join("\n");
test("extractReportData:完整块解析 + 字段归一化", () => {
const d = extractReportData(SAMPLE)!;
assert.equal(d.title, "智能机房选址方案大屏");
assert.equal(d.kpis.length, 2);
assert.equal(d.consensus.headline, "推荐 B 地块方案");
assert.equal(d.charts.length, 2);
assert.equal(d.seats.length, 2);
assert.deepEqual(d.seats[1].dependsOn, ["seat-1"]);
});
test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除模型编造/自引用)", () => {
const text = [
"```report-json",
JSON.stringify({
title: "t",
seats: [
{ id: "s1", name: "甲", dependsOn: ["s1", "s2", "ghost"] },
{ id: "s2", name: "乙", dependsOn: [] },
],
}),
"```",
].join("\n");
const d = extractReportData(text)!;
assert.deepEqual(d.seats[0].dependsOn, ["s2"]);
});
test("extractReportData:无 fence / 坏 JSON / 空数据返回 null", () => {
assert.equal(extractReportData("没有数据块"), null);
assert.equal(extractReportData("```report-json\n{broken\n```"), null);
assert.equal(extractReportData("```report-json\n{}\n```"), null);
assert.equal(extractReportData(null), null);
});
test("renderDashboardHtml:完整文档 + marker + 总览/席位多页 + :target 导航", () => {
const d = extractReportData(SAMPLE)!;
const html = renderDashboardHtml(d, { generatedAt: "2026-06-13 10:00" });
assert.ok(html.startsWith("<!doctype html>"));
assert.ok(html.includes(RT_DASHBOARD_MARKER));
assert.ok(html.trimEnd().endsWith("</html>"));
assert.ok(isDashboardHtml(html));
// 多页结构:总览 + 每席位一页
assert.ok(html.includes('id="overview"'));
assert.ok(html.includes('id="seat-1"'));
assert.ok(html.includes('id="seat-2"'));
// 纯 CSS :target 导航(零 JS:无 allow-scripts 沙箱也能切页)
assert.ok(html.includes(".page:target"));
assert.ok(!html.includes("<script"));
// 席位卡片跳转 + 依赖互相跳转(方案设计 依赖→ 情报收集)
assert.ok(html.includes('href="#seat-2"'));
assert.ok(html.includes('class="dep" href="#seat-1"'));
// 反向「被依赖」chip(情报收集页里出现 方案设计 ← 承接)
assert.ok(html.includes("承接本席位"));
// 共识详情纯 CSS 弹窗
assert.ok(html.includes('id="consensus-modal"'));
// 图表 SVG 由模板生成
assert.ok(html.includes("<svg"));
});
test("renderDashboardHtml:主题跟随 —— dark=深蓝规范 / light=浅红规范", () => {
const d = extractReportData(SAMPLE)!;
const dark = renderDashboardHtml(d, { theme: "dark" });
// 深蓝规范:页面 #0C1836 / 卡片 #122347 / 主色 #1677FF(tokens.css §1)
assert.ok(dark.includes("#0C1836"));
assert.ok(dark.includes("#122347"));
assert.ok(dark.includes("#1677FF"));
const light = renderDashboardHtml(d, { theme: "light" });
// 浅红规范:页面 #FAFAFA / 容器 #FFFFFF / 主色 #F5222D(§1 色彩)
assert.ok(light.includes("#FAFAFA"));
assert.ok(light.includes("#F5222D"));
assert.ok(!light.includes("#0C1836"));
// 缺省 = dark(向后兼容)
const def = renderDashboardHtml(d);
assert.ok(def.includes("#0C1836"));
});
test("renderDashboardHtml:模型文本全部 HTML 转义(防注入/破版)", () => {
const text = [
"```report-json",
JSON.stringify({
title: '<img src=x onerror=alert(1)>',
seats: [{ id: "s1", name: "甲<b>", summary: 'a"b<c>', dependsOn: [] }],
consensus: { headline: "<script>bad()</script>", points: [] },
}),
"```",
].join("\n");
const d = extractReportData(text)!;
const html = renderDashboardHtml(d);
assert.ok(!html.includes("<img src=x"));
assert.ok(!html.includes("<script>bad"));
assert.ok(html.includes("&lt;script&gt;bad()&lt;/script&gt;"));
});
test("stripReportFence:闭合块替换为引导语;未闭合(流式中)用「正在输出」措辞", () => {
const done = stripReportFence(SAMPLE, false);
assert.ok(done.includes("已生成大屏数据"));
assert.ok(!done.includes("```report-json"));
const streaming = stripReportFence('说明\n```report-json\n{"title":', true);
assert.ok(streaming.includes("正在输出大屏结构化数据"));
assert.ok(!streaming.includes("```report-json"));
// 无 fence 原样返回
assert.equal(stripReportFence("普通文字", false), "普通文字");
});
test("buildDashboardPrompt:禁工具声明 + 原料齐全 + 契约说明", () => {
const prompt = buildDashboardPrompt({
intentText: "选址决策",
seatDeliveries: [{ sender: "情报收集", content: "三地数据" }],
consensusText: "推荐 B 地块",
});
assert.ok(prompt.includes("不要调用任何工具"));
assert.ok(prompt.includes("选址决策"));
assert.ok(prompt.includes("情报收集") && prompt.includes("三地数据"));
assert.ok(prompt.includes("推荐 B 地块"));
assert.ok(prompt.includes("```report-json"));
assert.ok(prompt.includes("dependsOn"));
});
@@ -0,0 +1,722 @@
/**
* Step3「结果绘制 · 大屏多页模式(模式二)」—— report-json 契约 + 模板渲染器。
*
* 设计动机(2026-06-13):模式一让模型从零写整份 50KB+ 自包含 HTML,慢(1-3 分钟)
* 且有超长截断风险。模式二把 HTML 骨架/样式/图表**全部预置在本模板里**,模型只输出
* 1-2KB 的结构化 ```report-json 数据(与 Step3 总结报告的 flow-json 同一套路),
* 前端代码确定性地渲染成完整大屏 HTML —— 快一个数量级、零截断、风格稳定。
*
* ## report-json 契约(模型输出)
* ```report-json
* {"title":"…","subtitle":"…",
* "kpis":[{"label":"总投资","value":"1.2","unit":"亿元","trend":"up"}],
* "consensus":{"headline":"一句话最终结论","points":["…"]},
* "charts":[{"type":"bar|donut|radar","title":"…","items":[{"label":"…","value":30}]}],
* "timeline":[{"label":"阶段一","desc":"…"}],
* "seats":[{"id":"seat-1","name":"方案设计","summary":"一两句结论",
* "sections":[{"heading":"…","bullets":["…"]}],
* "metrics":[{"label":"可行性","value":86}],
* "risks":[{"label":"…","level":"high|mid|low"}],
* "dependsOn":["seat-0"]}]}
* ```
*
* ## 生成的页面结构(单文件 · 零 JS · 离线自包含)
* - 大屏展示风:深海军蓝渐变底 + 网格底纹 + 青色霓虹描边玻璃卡片 + 四角装饰括号;
* - **总览页**(KPI 带 + 共识结论 + 总览图表 + 席位卡片矩阵 + 执行时间线)
* + **每席位一页**(该席位交付的结构化展示 + 指标条 + 风险徽标 + 依赖面板);
* - 导航纯 CSS `:target`:页签/席位卡片/依赖 chips 都是 `#seat-x` 锚点跳转,
* `.page:target` 显示目标页、`body:has(.page:target)` 隐藏默认总览 ——
* **无 allow-scripts 的沙箱 iframe / 公开分享页都能正常切页**;
* - 「弹窗」同样用 :target 纯 CSS overlay(共识详情);
* - 席位间依赖(dependsOn)渲染成双向 chips(依赖 → / ← 支撑),点击互相跳转。
*
* 文件头嵌入 `RT_DASHBOARD_MARKER` 注释,恢复草稿时据此识别「这是模板大屏」。
*/
// ── 契约类型 ─────────────────────────────────────────────────────────────────
export interface ReportKpi {
label: string;
value: string;
unit?: string;
trend?: "up" | "down" | "flat";
}
export interface ReportChartItem {
label: string;
value: number;
}
export interface ReportChart {
type: "bar" | "donut" | "radar";
title: string;
items: ReportChartItem[];
}
export interface ReportSeatSection {
heading: string;
bullets: string[];
}
export interface ReportSeatMetric {
label: string;
/** 0-100。 */
value: number;
}
export interface ReportSeatRisk {
label: string;
level: "high" | "mid" | "low";
}
export interface ReportSeat {
id: string;
name: string;
summary: string;
sections: ReportSeatSection[];
metrics: ReportSeatMetric[];
risks: ReportSeatRisk[];
/** 依赖的其它席位 id(产物承接关系),渲染成互相跳转 chips。 */
dependsOn: string[];
}
export interface ReportData {
title: string;
subtitle: string;
kpis: ReportKpi[];
consensus: { headline: string; points: string[] };
charts: ReportChart[];
timeline: Array<{ label: string; desc: string }>;
seats: ReportSeat[];
}
/** 模板大屏 HTML 的识别标记(恢复草稿时区分模式一/二)。 */
export const RT_DASHBOARD_MARKER = "<!-- rt-dashboard-v1 -->";
/** 大屏 HTML 在沙箱预览里的虚拟产物路径(write_file stub 用,前端渲染、非真实文件)。 */
export const DASHBOARD_OUTPUT_PATH = "/mnt/user-data/outputs/方案研讨大屏.html";
export function isDashboardHtml(html: string | null | undefined): boolean {
return !!html && html.includes(RT_DASHBOARD_MARKER);
}
/**
* 把回复里的 report-json 块替换成一句人话(气泡里不展示生 JSON,对齐 flow-json 的
* stripFlowFence)。块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导看右侧沙箱。
*/
export function stripReportFence(text: string, streaming: boolean): string {
const start = text.lastIndexOf(FENCE);
if (start < 0) return text;
const after = text.slice(start + FENCE.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}`;
}
// ── 解析(容错:取最后一个 report-json 代码块;缺字段给安全默认) ─────────────────
const FENCE = "```report-json";
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function num(v: unknown): number {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
/** 从模型回复里提取并归一化 report-json(找不到 / 解析失败 → null)。 */
export function extractReportData(text: string | null | undefined): ReportData | null {
if (!text) return null;
const start = text.lastIndexOf(FENCE);
if (start < 0) return null;
const after = text.slice(start + FENCE.length);
const close = after.indexOf("```");
const body = (close >= 0 ? after.slice(0, close) : after).trim();
if (!body) return null;
return parseReportJson(body);
}
/** 解析裸 JSON 字符串(草稿/后台作业存的 step3.dashboardJson)并归一化。 */
export function parseReportJson(jsonStr: string | null | undefined): ReportData | null {
if (!jsonStr?.trim()) return null;
let raw: unknown;
try {
raw = JSON.parse(jsonStr);
} catch {
return null;
}
return coerceReportData(raw);
}
/** 把已 JSON.parse 的对象归一化成 ReportData(缺字段给安全默认;非法 → null)。 */
export function coerceReportData(raw: unknown): ReportData | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
const seats: ReportSeat[] = arr(o.seats)
.map((s, i) => {
const so = (s ?? {}) as Record<string, unknown>;
const name = str(so.name).trim();
if (!name) return null;
const id = str(so.id).trim() || `seat-${i + 1}`;
return {
id,
name,
summary: str(so.summary),
sections: arr(so.sections)
.map((sec) => {
const co = (sec ?? {}) as Record<string, unknown>;
return {
heading: str(co.heading),
bullets: arr(co.bullets).map((b) => str(b)).filter(Boolean),
};
})
.filter((sec) => sec.heading || sec.bullets.length > 0),
metrics: arr(so.metrics)
.map((m) => {
const mo = (m ?? {}) as Record<string, unknown>;
return { label: str(mo.label), value: Math.min(100, Math.max(0, num(mo.value))) };
})
.filter((m) => m.label),
risks: arr(so.risks)
.map((r) => {
const ro = (r ?? {}) as Record<string, unknown>;
const level = str(ro.level);
return {
label: str(ro.label),
level: (level === "high" || level === "mid" || level === "low" ? level : "mid") as
| "high"
| "mid"
| "low",
};
})
.filter((r) => r.label),
dependsOn: arr(so.dependsOn).map((d) => str(d).trim()).filter(Boolean),
} as ReportSeat;
})
.filter((s): s is ReportSeat => s !== null);
// dependsOn 只保留真实存在的席位 id(模型可能编 id)。
const idSet = new Set(seats.map((s) => s.id));
for (const s of seats) s.dependsOn = s.dependsOn.filter((d) => idSet.has(d) && d !== s.id);
const consensusRaw = (o.consensus ?? {}) as Record<string, unknown>;
const data: ReportData = {
title: str(o.title, "方案研讨大屏"),
subtitle: str(o.subtitle),
kpis: arr(o.kpis)
.map((k) => {
const ko = (k ?? {}) as Record<string, unknown>;
const trend = str(ko.trend);
return {
label: str(ko.label),
value: str(ko.value, String(ko.value ?? "")),
unit: str(ko.unit) || undefined,
trend: (trend === "up" || trend === "down" || trend === "flat" ? trend : undefined) as
| "up"
| "down"
| "flat"
| undefined,
};
})
.filter((k) => k.label && k.value)
.slice(0, 6),
consensus: {
headline: str(consensusRaw.headline),
points: arr(consensusRaw.points).map((p) => str(p)).filter(Boolean),
},
charts: arr(o.charts)
.map((c) => {
const co = (c ?? {}) as Record<string, unknown>;
const type = str(co.type);
return {
type: (type === "bar" || type === "donut" || type === "radar" ? type : "bar") as
| "bar"
| "donut"
| "radar",
title: str(co.title),
items: arr(co.items)
.map((it) => {
const io = (it ?? {}) as Record<string, unknown>;
return { label: str(io.label), value: num(io.value) };
})
.filter((it) => it.label),
};
})
.filter((c) => c.items.length > 0)
.slice(0, 3),
timeline: arr(o.timeline)
.map((t) => {
const to = (t ?? {}) as Record<string, unknown>;
return { label: str(to.label), desc: str(to.desc) };
})
.filter((t) => t.label),
seats,
};
if (data.seats.length === 0 && !data.consensus.headline) return null;
return data;
}
// ── 模型 prompt(只要 report-json,不调工具、不写文件) ───────────────────────────
export function buildDashboardPrompt(opts: {
intentText: string;
seatDeliveries: Array<{ sender: string; content: string }>;
consensusText: string;
}): string {
const parts: string[] = [
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
"请通读以下圆桌研讨成果,把它们提炼成一份大屏展示数据,在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
"",
"# 一、任务意图",
(opts.intentText || "").trim() || "(未提供)",
"",
"# 二、各席位最终交付",
];
opts.seatDeliveries.forEach((d, i) => {
parts.push(`## ${i + 1}. ${d.sender}`);
parts.push((d.content || "").trim() || "(无内容)");
parts.push("");
});
parts.push("# 三、总控最终共识结论");
parts.push((opts.consensusText || "").trim() || "(未提供)");
parts.push("");
parts.push(
[
"```report-json 契约(严格 JSON,全程简体中文):",
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表(风险分布/资源占比/方案评分等),3-6 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
].join("\n"),
);
return parts.join("\n");
}
// ── 主题(2026-06-13):跟随应用当前主题绘制 ─────────────────────────────────────
// light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、
// 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE;
// dark = 《深蓝色 UI 规范》(docs/ui规范-深蓝/tokens.css):页面#0C1836、卡片#122347、
// 主色#1677FF、辅色#13C2C2、文字#C5CFE0、语义 成功#52C41A/警告#FAAD14/错误#F5222D。
export type DashboardTheme = "light" | "dark";
interface Palette {
bgPage: string;
bgGradient: string;
gridLine: string;
card: string;
cardBorder: string;
cardGlow: string;
bracket: string;
text: string;
textSecondary: string;
textStrong: string;
heading: string;
primary: string;
link: string;
titleColor: string;
navBg: string;
trackBg: string;
/** 图表 / KPI / 席位强调色序(规范图表色序)。 */
accents: string[];
risk: { high: string; mid: string; low: string };
modalMask: string;
}
const PALETTES: Record<DashboardTheme, Palette> = {
dark: {
bgPage: "#0C1836",
bgGradient:
"radial-gradient(1200px 600px at 70% -10%,rgba(22,119,255,.14),transparent),linear-gradient(160deg,#060E24,#0C1836)",
gridLine: "rgba(64,150,255,.06)",
card: "linear-gradient(160deg,#1A2F5A,#122347)",
cardBorder: "rgba(64,150,255,.28)",
cardGlow: "0 0 24px rgba(6,14,36,.5) inset,0 4px 18px rgba(6,14,36,.35)",
bracket: "#4096FF",
text: "#C5CFE0",
textSecondary: "#6E88B0",
textStrong: "#FFFFFF",
heading: "#91CAFF",
primary: "#1677FF",
link: "#4096FF",
titleColor: "linear-gradient(90deg,#91CAFF,#4096FF 45%,#13C2C2)",
navBg: "rgba(6,14,36,.88)",
trackBg: "rgba(110,136,176,.22)",
accents: ["#4096FF", "#13C2C2", "#52C41A", "#FAAD14", "#FF7875", "#69B1FF"],
risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" },
modalMask: "rgba(6,14,36,.78)",
},
light: {
bgPage: "#FAFAFA",
bgGradient:
"radial-gradient(1200px 600px at 70% -10%,rgba(245,34,45,.05),transparent),linear-gradient(160deg,#FFFFFF,#FAFAFA)",
gridLine: "rgba(245,34,45,.035)",
card: "#FFFFFF",
cardBorder: "#E8E8E8",
cardGlow: "0 2px 8px rgba(0,0,0,.04)",
bracket: "#F5222D",
text: "#222222",
textSecondary: "#575757",
textStrong: "#222222",
heading: "#A8071A",
primary: "#F5222D",
link: "#0079FE",
titleColor: "linear-gradient(90deg,#F5222D,#CF1322 60%,#A8071A)",
navBg: "rgba(255,255,255,.92)",
trackBg: "#F0F0F0",
accents: ["#F5222D", "#0079FE", "#189D72", "#FF9900", "#45C3F3", "#7F7F7F"],
risk: { high: "#F5222D", mid: "#FF9900", low: "#189D72" },
modalMask: "rgba(34,34,34,.45)",
},
};
// ── 渲染(确定性模板,全部文本 HTML 转义) ────────────────────────────────────────
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 渲染期间的当前色板:renderDashboardHtml 开头按主题设置(同步渲染,无并发竞争),
// 各 SVG/片段帮助函数直接读,避免把 palette 穿透每个函数签名。
let P: Palette = PALETTES.dark;
function svgBar(chart: ReportChart): string {
const items = chart.items.slice(0, 6);
const max = Math.max(...items.map((i) => i.value), 1);
const W = 300;
const rowH = 30;
const H = items.length * rowH + 6;
const rows = items
.map((it, i) => {
const w = Math.max(4, (it.value / max) * (W - 110));
const y = i * rowH + 6;
const color = P.accents[i % P.accents.length];
return (
`<text x="0" y="${y + 13}" class="cl">${esc(it.label)}</text>` +
`<rect x="92" y="${y + 3}" width="${W - 110}" height="14" rx="7" class="ct"/>` +
`<rect x="92" y="${y + 3}" width="${w}" height="14" rx="7" fill="${color}" opacity="0.85"/>` +
`<text x="${W - 12}" y="${y + 14}" class="cv" text-anchor="end">${esc(String(it.value))}</text>`
);
})
.join("");
return `<svg viewBox="0 0 ${W} ${H}" role="img">${rows}</svg>`;
}
function svgDonut(chart: ReportChart): string {
const items = chart.items.slice(0, 6);
const total = items.reduce((s, i) => s + Math.max(0, i.value), 0) || 1;
const R = 56;
const C = 2 * Math.PI * R;
let offset = 0;
const segs = items
.map((it, i) => {
const frac = Math.max(0, it.value) / total;
const seg = `<circle cx="80" cy="80" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="20" stroke-dasharray="${(frac * C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-offset * C).toFixed(2)}" transform="rotate(-90 80 80)" opacity="0.9"/>`;
offset += frac;
return seg;
})
.join("");
const legend = items
.map(
(it, i) =>
`<div class="lg"><i style="background:${P.accents[i % P.accents.length]}"></i>${esc(it.label)} <b>${esc(String(it.value))}</b></div>`,
)
.join("");
return `<div class="donut"><svg viewBox="0 0 160 160" role="img">${segs}</svg><div class="lgs">${legend}</div></div>`;
}
function svgRadar(chart: ReportChart): string {
const items = chart.items.slice(0, 6);
const n = Math.max(items.length, 3);
const cx = 110;
const cy = 95;
const R = 70;
const max = Math.max(...items.map((i) => i.value), 1);
const pt = (i: number, r: number) => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
};
const grid = [0.33, 0.66, 1]
.map(
(k) =>
`<polygon points="${Array.from({ length: n }, (_, i) => pt(i, R * k)).join(" ")}" class="gr"/>`,
)
.join("");
const axes = Array.from(
{ length: n },
(_, i) => `<line x1="${cx}" y1="${cy}" x2="${pt(i, R).replace(",", '" y2="')}" class="gr"/>`,
).join("");
const poly = `<polygon points="${items.map((it, i) => pt(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}33" stroke="${P.primary}" stroke-width="2"/>`;
const labels = items
.map((it, i) => {
const [x, y] = pt(i, R + 16).split(",");
return `<text x="${x}" y="${y}" class="cl" text-anchor="middle">${esc(it.label)}</text>`;
})
.join("");
return `<svg viewBox="0 0 220 200" role="img">${grid}${axes}${poly}${labels}</svg>`;
}
function chartHtml(chart: ReportChart): string {
const body =
chart.type === "donut" ? svgDonut(chart) : chart.type === "radar" ? svgRadar(chart) : svgBar(chart);
return `<div class="card chart"><h3>${esc(chart.title || "图表")}</h3>${body}</div>`;
}
const RISK_LABEL = { high: "高", mid: "中", low: "低" } as const;
function riskChips(risks: ReportSeatRisk[]): string {
if (risks.length === 0) return "";
return `<div class="chips">${risks
.map((r) => {
const color = P.risk[r.level]; // 主题语义色:错误/警告/成功
return `<span class="chip" style="border-color:${color};color:${color};background:${color}14">${RISK_LABEL[r.level]}风险 · ${esc(r.label)}</span>`;
})
.join("")}</div>`;
}
function metricBars(metrics: ReportSeatMetric[]): string {
if (metrics.length === 0) return "";
return `<div class="metrics">${metrics
.map(
(m, i) =>
`<div class="metric"><span>${esc(m.label)}</span><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div><b>${m.value}</b></div>`,
)
.join("")}</div>`;
}
/** 依赖双向 chips:deps = 本席位依赖谁;supports = 谁依赖本席位(反向推导)。 */
function depChips(seat: ReportSeat, all: ReportSeat[]): string {
const byId = new Map(all.map((s) => [s.id, s]));
const supports = all.filter((s) => s.dependsOn.includes(seat.id));
const dep = seat.dependsOn
.map((d) => byId.get(d))
.filter((s): s is ReportSeat => !!s)
.map((s) => `<a class="dep" href="#${esc(s.id)}">依赖 → ${esc(s.name)}</a>`)
.join("");
const sup = supports
.map((s) => `<a class="dep sup" href="#${esc(s.id)}">${esc(s.name)} ← 承接本席位</a>`)
.join("");
if (!dep && !sup) return "";
return `<div class="deps">${dep}${sup}</div>`;
}
/** 渲染大屏多页 HTML(自包含 · 零 JS · :target 导航 · 跟随应用主题)。 */
export function renderDashboardHtml(
data: ReportData,
opts?: { generatedAt?: string; theme?: DashboardTheme },
): string {
P = PALETTES[opts?.theme ?? "dark"];
const seats = data.seats;
const nav =
`<a class="tab" href="#overview">总览</a>` +
seats.map((s) => `<a class="tab" href="#${esc(s.id)}">${esc(s.name)}</a>`).join("");
const kpis = data.kpis
.map(
(k, i) =>
`<div class="card kpi"><span class="klabel">${esc(k.label)}</span><div class="kval" style="color:${P.accents[i % P.accents.length]}">${esc(k.value)}<small>${esc(k.unit ?? "")}</small>${
k.trend === "up" ? '<i class="tr up">▲</i>' : k.trend === "down" ? '<i class="tr dn">▼</i>' : ""
}</div></div>`,
)
.join("");
const seatCards = seats
.map((s, i) => {
const color = P.accents[i % P.accents.length];
return `<a class="card seat" href="#${esc(s.id)}" style="--ac:${color}">
<div class="shead"><i></i><h3>${esc(s.name)}</h3><span class="go">进入席位页 →</span></div>
<p>${esc(s.summary || "(无摘要)")}</p>
${metricBars(s.metrics.slice(0, 2))}
${riskChips(s.risks.slice(0, 2))}
</a>`;
})
.join("");
const timeline = data.timeline.length
? `<div class="card tl"><h3>执行路线</h3><ol>${data.timeline
.map((t) => `<li><b>${esc(t.label)}</b><span>${esc(t.desc)}</span></li>`)
.join("")}</ol></div>`
: "";
const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join("");
const overview = `<section class="page" id="overview">
<div class="kpis">${kpis}</div>
<div class="grid2">
<div class="card consensus"><h3>总控最终共识</h3>
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
<ul>${consensusPoints}</ul>
<a class="more" href="#consensus-modal">查看详情</a>
</div>
<div class="charts">${data.charts.map(chartHtml).join("")}</div>
</div>
<h2 class="sec">席位交付总览 <small>点击卡片进入席位专页</small></h2>
<div class="seats">${seatCards}</div>
${timeline}
</section>`;
const seatPages = seats
.map((s, i) => {
const color = P.accents[i % P.accents.length];
const prev = seats[(i - 1 + seats.length) % seats.length];
const next = seats[(i + 1) % seats.length];
const sections = s.sections
.map(
(sec) =>
`<div class="card sect"><h3>${esc(sec.heading)}</h3><ul>${sec.bullets
.map((b) => `<li>${esc(b)}</li>`)
.join("")}</ul></div>`,
)
.join("");
return `<section class="page" id="${esc(s.id)}" style="--ac:${color}">
<div class="pbar">
<a class="back" href="#overview">← 返回总览</a>
<div class="pnav"><a href="#${esc(prev.id)}">‹ ${esc(prev.name)}</a><a href="#${esc(next.id)}">${esc(next.name)} ›</a></div>
</div>
<header class="phead"><i></i><div><h2>${esc(s.name)}</h2><p>${esc(s.summary)}</p></div></header>
${depChips(s, seats)}
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
<div class="grid2">
${s.metrics.length ? `<div class="card"><h3>量化指标</h3>${metricBars(s.metrics)}</div>` : ""}
${s.risks.length ? `<div class="card"><h3>风险提示</h3>${riskChips(s.risks)}</div>` : ""}
</div>
</section>`;
})
.join("");
const modal = `<div class="modal" id="consensus-modal"><div class="mbody card">
<a class="mclose" href="#overview">×</a>
<h3>总控最终共识 · 全部要点</h3>
<p class="headline">${esc(data.consensus.headline)}</p>
<ul>${consensusPoints}</ul>
</div></div>`;
const generated = opts?.generatedAt ? `生成于 ${esc(opts.generatedAt)}` : "";
return `<!doctype html>${RT_DASHBOARD_MARKER}
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(data.title)}</title>
<style>
/* 主题色板(light=浅红色 UI 规范 / dark=深蓝色 UI 规范,渲染时注入) */
:root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong};
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Noto Sans S Chinese","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
background:${P.bgGradient};background-color:${P.bgPage};background-attachment:fixed}
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.6;
background:linear-gradient(${P.gridLine} 1px,transparent 1px),linear-gradient(90deg,${P.gridLine} 1px,transparent 1px);
background-size:42px 42px}
.top{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;align-items:center;gap:12px;
padding:14px 26px;background:${P.navBg};backdrop-filter:blur(6px);border-bottom:1px solid var(--line)}
.top h1{font-size:21px;letter-spacing:2px;background:${P.titleColor};
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
.top .sub{font-size:12px;color:var(--dim)}
.top .meta{margin-left:auto;font-size:11px;color:var(--dim)}
nav{display:flex;flex-wrap:wrap;gap:8px;padding:10px 26px;border-bottom:1px solid var(--line)}
.tab{font-size:12px;padding:5px 14px;border:1px solid var(--line);border-radius:2px;color:var(--dim);text-decoration:none;transition:.15s}
.tab:hover{color:${P.primary};border-color:${P.primary};box-shadow:0 0 10px ${P.primary}40}
main{padding:20px 26px 40px;max-width:1480px;margin:0 auto}
.page{display:none;animation:fi .25s ease}
#overview{display:block}
.page:target{display:block}
body:has(.page:target) #overview:not(:target){display:none}
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1}}
/* 卡片:规范圆角 4px;浅色实底白卡 + 轻阴影,深色深蓝渐变 + 内发光 */
.card{position:relative;border:1px solid var(--line);border-radius:4px;padding:16px 18px;
background:${P.card};box-shadow:${P.cardGlow}}
.card::before,.card::after{content:"";position:absolute;width:14px;height:14px;border-color:${P.bracket};border-style:solid;opacity:.55}
.card::before{top:-1px;left:-1px;border-width:2px 0 0 2px;border-radius:4px 0 0 0}
.card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0;border-radius:0 0 4px 0}
.card h3{font-size:13px;letter-spacing:1.5px;color:var(--heading);margin-bottom:10px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}
.kpi .klabel{font-size:11px;color:var(--dim);letter-spacing:1px}
.kpi .kval{font-size:30px;font-weight:800;margin-top:4px;font-family:"D-DIN",Consolas,monospace}
.kpi small{font-size:12px;margin-left:4px;color:var(--dim)}
.kpi .tr{font-style:normal;font-size:12px;margin-left:6px}.tr.up{color:${P.risk.low}}.tr.dn{color:${P.risk.high}}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}
@media(max-width:980px){.grid2{grid-template-columns:1fr}}
.consensus .headline{font-size:15px;font-weight:700;color:var(--strong);margin-bottom:8px}
.consensus ul{padding-left:18px;display:grid;gap:5px;font-size:12.5px;color:var(--txt)}
.consensus .more{display:inline-block;margin-top:10px;font-size:11px;color:${P.link};text-decoration:none;border-bottom:1px dashed ${P.link}}
.charts{display:grid;gap:14px}
.chart svg{width:100%;height:auto}
.cl{fill:var(--dim);font-size:11px}.cv{fill:var(--txt);font-size:11px;font-weight:700}
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.primary}2e;stroke-width:1}
.donut{display:flex;align-items:center;gap:14px}.donut svg{width:130px;flex:none}
.lgs{display:grid;gap:5px}.lg{font-size:11.5px;color:var(--dim)}.lg b{color:var(--txt);margin-left:4px}
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px}
.sec{font-size:14px;letter-spacing:2px;color:var(--heading);margin:6px 0 12px}
.sec small{font-size:11px;color:var(--dim);font-weight:400;margin-left:8px}
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px}
.seat{display:block;text-decoration:none;color:inherit;transition:.15s}
.seat:hover{border-color:var(--ac);box-shadow:0 0 16px ${P.primary}30;transform:translateY(-2px)}
.shead{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.shead i{width:8px;height:8px;border-radius:50%;background:var(--ac);box-shadow:0 0 8px var(--ac)}
.shead h3{margin:0;flex:1}
.go{font-size:10.5px;color:var(--dim)}
.seat:hover .go{color:var(--ac)}
.seat p{font-size:12px;color:var(--dim);line-height:1.6;margin-bottom:8px}
.metrics{display:grid;gap:7px}
.metric{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
.metric span{width:72px;flex:none}.metric b{width:26px;text-align:right;color:var(--txt)}
.track{flex:1;height:7px;border-radius:4px;background:var(--track);overflow:hidden}
.fill{height:100%;border-radius:4px}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{font-size:10.5px;padding:2px 9px;border:1px solid;border-radius:2px}
.tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;counter-reset:tl}
.tl li{position:relative;padding:10px 12px 10px 14px;border-left:2px solid ${P.primary};background:${P.primary}10;border-radius:0 4px 4px 0}
.tl b{display:block;font-size:12.5px;color:var(--heading);margin-bottom:3px}
.tl span{font-size:11px;color:var(--dim);line-height:1.5}
.pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;font-size:12px}
.pbar a{color:${P.link};text-decoration:none}.pnav{display:flex;gap:14px}.pnav a{color:var(--dim)}.pnav a:hover{color:${P.link}}
.phead{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.phead i{width:10px;height:34px;border-radius:3px;background:var(--ac);box-shadow:0 0 10px var(--ac)}
.phead h2{font-size:19px;letter-spacing:2px;color:var(--strong)}
.phead p{font-size:12px;color:var(--dim);margin-top:3px}
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.dep{font-size:11px;padding:3px 11px;border:1px dashed ${P.link};border-radius:2px;color:${P.link};text-decoration:none}
.dep.sup{border-color:${P.risk.low};color:${P.risk.low}}
.dep:hover{background:${P.link}1a}
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-bottom:14px}
.sect ul{padding-left:16px;display:grid;gap:6px;font-size:12.5px;line-height:1.6}
.sect .empty{font-size:12px;color:var(--dim)}
.modal{display:none;position:fixed;inset:0;z-index:20;background:${P.modalMask};padding:6vh 16px}
.modal:target{display:flex;justify-content:center;align-items:flex-start}
.mbody{max-width:680px;width:100%;max-height:84vh;overflow:auto}
.mbody ul{padding-left:18px;display:grid;gap:6px;font-size:13px}
.mclose{position:absolute;top:8px;right:14px;font-size:20px;color:var(--dim);text-decoration:none}
.mclose:hover{color:${P.risk.high}}
</style></head>
<body>
<header class="top"><h1>${esc(data.title)}</h1><span class="sub">${esc(data.subtitle)}</span><span class="meta">${generated}</span></header>
<nav>${nav}</nav>
<main>
${overview}
${seatPages}
</main>
${modal}
</body></html>`;
}
@@ -79,6 +79,11 @@ import {
type Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import {
BUILTIN_AGENT_META,
makeRolesFromTemplates,
@@ -97,7 +102,13 @@ import {
} from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
import {
getJobByDraft,
startJob,
startJobKeepalive,
streamJob,
} from "@/roundtable-planning/api/roundtable-jobs";
import { mapJobToLiveStep2 } from "@/roundtable-planning/lib/job-live";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
@@ -233,6 +244,21 @@ export default function RoundtablePlanningPage() {
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
const sidebar = useSidebarSafe();
// ─── 沙箱默认关闭策略(2026-06-12)────────────────────────────
// 步骤切换(1↔2↔3 任意方向)时**默认关闭**沙箱预览:
// - 进第三步不再自动弹出旧产物预览(只有 live write_file 开始写文件时才自动打开);
// - 第三步回第二步不再遗留一个「没有文件的空沙箱」(步骤切换前选中的产物在新步骤
// 的 stub 映射里多半不存在 → 表现为空白沙箱)。
// 用 deselect(raw 关闭)而**不是** wrapped setOpen(false) —— 后者会把 autoOpen 置
// false,导致新步骤里 live 写文件时自动打开被守卫拦掉(见 useStep2Orchestration 注释)。
const { deselect: deselectArtifactOnStepChange } = useArtifacts();
const prevStepRef = useRef(currentStep);
useEffect(() => {
if (prevStepRef.current === currentStep) return;
prevStepRef.current = currentStep;
deselectArtifactOnStepChange();
}, [currentStep, deselectArtifactOnStepChange]);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
const [roles, setRoles] = useState<RoleEntry[]>([]);
@@ -869,6 +895,153 @@ export default function RoundtablePlanningPage() {
emitConsensusToParent();
}, [step2.hasConsensus, inWujieEmbed, emitConsensusToParent]);
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
// 回头进入对应视图即可续看进行中的过程或最终结果。
const step3SeatDeliveries = useMemo(
() =>
step2.step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
content: stripReasoning(String(d.content ?? "")),
})),
[step2.step2RoundtableDialogues],
);
const handleReportSaved = useCallback((snap: DraftStep3Snapshot) => {
// **合并**而非整体替换:snap 只带 HTML 报告字段,整体替换会把已存的
// summaryReport(总结报告快照)抹掉 —— 重新绘制后总结就丢了。
setStep3Report((prev) => ({ ...(prev ?? {}), ...snap }));
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current
? { ...run, step3: { ...(run.step3 ?? {}), ...snap } }
: run,
),
);
setStep3SavedTick((t) => t + 1);
}, []);
const handleSummarySaved = useCallback((snap: NonNullable<DraftStep3Snapshot["summaryReport"]>) => {
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
: run,
),
);
setStep3SavedTick((t) => t + 1);
}, []);
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: step2.lastLeaderContent,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
});
const reportFlow = useStep3Report({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: step2.lastLeaderContent,
selectedModel,
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
const dashboardFlow = useStep3Dashboard({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: step2.lastLeaderContent,
selectedModel,
themeMode: isDark ? "dark" : "light",
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
const step3EnteredRef = useRef(false);
useEffect(() => {
if (currentStep !== 3 || step3EnteredRef.current) return;
step3EnteredRef.current = true;
// ① 方案总结报告
const savedSum = step3Report?.summaryReport;
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
summaryFlow.hydrateSaved(savedSum);
} else {
summaryFlow.start();
}
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
// 那不是经典产物 → 经典照常启动。
if (step3Report?.html && !isDashboardHtml(step3Report.html)) {
reportFlow.hydrateSaved(step3Report);
} else {
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
}
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
if (step3Report?.dashboardJson) {
dashboardFlow.hydrateFromJson(step3Report.dashboardJson);
} else if (step3Report?.html && isDashboardHtml(step3Report.html)) {
dashboardFlow.hydrateSaved(step3Report.html);
} else {
dashboardFlow.start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
// 切换草稿:停掉旧草稿的在途 Step3 流(防串台),并复位入口闸门让新草稿重新编排。
useEffect(() => {
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step3EnteredRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drafts.currentDraftId]);
// ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ──
// 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地
// 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。
const jobFollowUnsubRef = useRef<(() => void) | null>(null);
const stopJobFollow = useCallback(() => {
jobFollowUnsubRef.current?.();
jobFollowUnsubRef.current = null;
}, []);
// 「查看运行中作业」:先 await 草稿 hydrate(建好席位/线程基础 + 切第二步),**再**
// 订阅 SSE —— 保证实时帧总是最后权威,不被草稿快照覆盖(竞态根因)。强制停在第二步
// 看研讨流程(作业可能已到 Step3,但用户点查看就是要看实时研讨过程)。
const followRunningJob = useCallback(
async (jobId: string, draftId: string) => {
stopJobFollow();
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
// 点查看前**立刻存一次**当前聊天(若用户在本地还跑着研讨,保住其记录再切去看作业)。
await drafts.saveDraftNow(2).catch(() => null);
await drafts.handleLoadDraft(draftId);
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步看流程
jobFollowUnsubRef.current = streamJob(jobId, {
onProgress: (j) => {
step2.applyLiveJobFrame(mapJobToLiveStep2(j));
},
onDone: (j) => {
stopJobFollow();
// 终态:作业已把最终 run + step3 写回草稿 → 重载草稿无缝衔接(含报告)。
if (j.draftId) void drafts.handleLoadDraft(j.draftId);
void drafts.refresh();
},
onError: () => {
/* 断流静默:画面保留最后一帧;用户可重新点「查看」再续 */
},
});
},
[stopJobFollow, step2, drafts, setCurrentStep],
);
// 卸载兜底:离开页面停止跟随(后台作业本身继续跑)。
useEffect(() => stopJobFollow, [stopJobFollow]);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
@@ -882,6 +1055,13 @@ export default function RoundtablePlanningPage() {
setOrchestrationPlan(null);
setDagExecMode("fast");
setStep3Report(null);
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
stopJobFollow();
step3EnteredRef.current = false;
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
drafts.setCurrentDraftId(null);
@@ -891,6 +1071,7 @@ export default function RoundtablePlanningPage() {
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
// currentStep 不变,useEffect 不会 re-fire。
step1.runStep1Init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drafts, step1, step2]);
const handleStartNewTask = useCallback(() => {
@@ -906,49 +1087,39 @@ export default function RoundtablePlanningPage() {
});
}, [resetToStep1]);
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
if (agents.length === 0) return false;
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
}
try {
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
const draftId = await drafts.ensureDraft(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
void drafts.refresh();
return true;
}, [
step2,
drafts,
@@ -957,10 +1128,86 @@ export default function RoundtablePlanningPage() {
orchestrationPlan,
chainMeta,
buildInitialIntent,
resetToStep1,
triggerToast,
]);
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
await startBackgroundJob();
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
void drafts.refresh();
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
// 不能被旧渲染的闭包钉死。
const autoSuspendRef = useRef<{ running: boolean; fire: () => void; beacon: () => void }>({
running: false,
fire: () => {},
beacon: () => {},
});
useEffect(() => {
autoSuspendRef.current = {
// 只认 isOrchestrating(本地编排循环**真正在生成中**的锁)——
// isRoundTableRunning 是「会商进行」的 UI 开关,查看聊天记录(加载草稿 hydrate)
// 后它可能为 true,曾导致看历史时切页/刷新被误触发后台挂起、从头重跑一单。
running: step2.isOrchestrating,
// 路由卸载用:可走完整异步链(ensureDraft → 去重 → startJob),fetch 卸载后照常完成。
fire: () => {
void startBackgroundJob().catch(() => {});
},
// beforeunload 用:无法 await —— 用 keepalive fetch 同步发出(浏览器保证发完)。
// draftId 取当前已存在的草稿(研讨启动时已 ensureDraft,见下方 effect)。
beacon: () => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) return;
startJobKeepalive({
draftId: drafts.currentDraftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
});
},
};
});
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
useEffect(() => {
if (step2.isOrchestrating) void drafts.ensureDraft(2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step2.isOrchestrating]);
// 路由卸载(切到其它页面):正在研讨 → 自动转后台挂起。
useEffect(
() => () => {
const snap = autoSuspendRef.current;
if (snap.running) snap.fire();
},
[],
);
// 刷新 / 关标签页:keepalive 兜底。后端幂等闸门保证与其它触发源并发也不重复建作业。
useEffect(() => {
const handler = () => {
const snap = autoSuspendRef.current;
if (snap.running) snap.beacon();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
const handleSkipQuestion = useCallback(() => {
step1.appendLocalMessage({
@@ -1033,15 +1280,16 @@ export default function RoundtablePlanningPage() {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
// 「查看」(2026-06-13 恢复流程语义):done → 加载草稿(含 step3)看报告;
// **运行中/待澄清 → 草稿 hydrate 完成后 SSE 实时跟随**——第二步消息列表像
// 本地编排一样实时长出新气泡/步骤卡,不再是静态聊天记录。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
void followRunningJob(job.id, job.draftId);
return;
}
drafts.handleLoadDraft(job.draftId);
void drafts.handleLoadDraft(job.draftId);
}}
/>
@@ -1281,30 +1529,15 @@ export default function RoundtablePlanningPage() {
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
),
);
setStep3SavedTick((t) => t + 1);
}}
onReportSaved={handleReportSaved}
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
savedSummary={step3Report?.summaryReport ?? null}
onSummarySaved={(snap) => {
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
: run,
),
);
setStep3SavedTick((t) => t + 1);
}}
onSummarySaved={handleSummarySaved}
// 页面层常驻的三个生成流(切走不断流,进入视图续看)。
summaryFlow={summaryFlow}
reportFlow={reportFlow}
dashboardFlow={dashboardFlow}
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
@@ -265,6 +265,8 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
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`.
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -14,6 +14,7 @@
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
@@ -26,6 +27,7 @@ from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.agents.roundtable_orchestrator.types import (
@@ -33,6 +35,7 @@ from deerflow.agents.roundtable_orchestrator.types import (
ReportResult,
SeatRef,
SeatTurn,
SummaryResult,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.serialization import serialize_channel_values
@@ -41,6 +44,7 @@ logger = logging.getLogger(__name__)
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
_REPORT_FILE = "方案总览.html"
_SUMMARY_FILE = "方案总结报告.md"
# 各角色的工具裁剪 / thinking / reasoning 全部取自单一数据源 roundtable_run_policy
# (与 multi_agent 的 _leader_run / _special_run 同源),不再在本文件硬编码——历史上
@@ -227,6 +231,92 @@ def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
return None
def _read_summary_md(app, thread_id: str, user_id: str) -> str | None:
"""从 summary thread 的 outputs 目录读出总结报告 Markdown。
优先 ``方案总结报告.md``;找不到就扫目录里**任意 .md**(取体积最大的那个),
容忍总结 agent 用了别的文件名。镜像 ``_read_report_html``。
"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
preferred = outputs_dir / _SUMMARY_FILE
if preferred.exists():
return preferred.read_text(encoding="utf-8")
if outputs_dir.exists():
mds = sorted(
outputs_dir.glob("*.md"),
key=lambda p: p.stat().st_size,
reverse=True,
)
if mds:
logger.info("summary md: 方案总结报告.md not found, using %s", mds[0].name)
return mds[0].read_text(encoding="utf-8")
logger.warning("summary md: no .md found in %s", outputs_dir)
except Exception: # noqa: BLE001
logger.warning("read summary md failed for thread %s", thread_id, exc_info=True)
return None
# Step3 产物自愈重试次数(首轮之外的纠偏轮数)。
# 实测痛点(2026-06-12,h:\xiugai\后台日志.txt):deepseek 报告 agent 说「let me write
# it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、
# run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。
# 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。
_ARTIFACT_RETRY_LIMIT = 2
_REPORT_RETRY_MISSING = (
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
"(最常见原因:试图一次写入整份长 HTML,输出超长被截断,工具调用没有真正落盘)。\n"
"请立即重新写入,并**严格分段**:\n"
f"1. 先用 write_file 写入 /mnt/user-data/outputs/{_REPORT_FILE} 的开头部分(<!doctype html> 起,约 200-300 行);\n"
"2. 再用 write_file 的 append=true 参数分多次续写后续片段,每段同样控制在 300 行以内;\n"
"3. 直到文档以 </html> 收尾。\n"
"不要在对话里粘贴 HTML 源码,写完用一两句话总结即可。"
)
_REPORT_RETRY_TRUNCATED = (
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 已写入但**不完整**(没有 </html> 收尾,疑似上一轮输出被截断)。\n"
"请用 write_file 的 append=true 参数从中断处**继续补全剩余内容**(不要重写已有部分),"
"每段控制在 300 行以内,分多次续写直到文档以 </html> 收尾。不要在对话里粘贴源码。"
)
_SUMMARY_RETRY_MISSING = (
f"检测到 /mnt/user-data/outputs/{_SUMMARY_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
"(可能是单次输出过长被截断)。请立即用 write_file 重新写入完整报告;若内容很长,"
"先写前半部分、再用 write_file 的 append=true 参数分段续写。写完后把报告正文也输出到对话回复里。"
)
_DASHBOARD_FENCE = "```report-json"
_DASHBOARD_RETRY = (
"你刚才没有输出有效的 ```report-json 代码块(或其中的 JSON 无法解析)。"
"请重新在回复**最末尾**输出一个完整且**严格合法**的 ```report-json 代码块"
"(标准 JSON:无注释、无尾逗号、字符串用双引号),代码块之外只允许一两句说明;"
"不要调用任何工具、不要写文件、不要输出 HTML。"
)
def _extract_report_json(text: str) -> str | None:
"""从模型回复里提取最后一个 ```report-json 代码块的 JSON 字符串(须可解析)。"""
start = text.rfind(_DASHBOARD_FENCE)
if start < 0:
return None
after = text[start + len(_DASHBOARD_FENCE) :]
close = after.find("```")
body = (after[:close] if close >= 0 else after).strip()
if not body:
return None
try:
json.loads(body)
except Exception: # noqa: BLE001
return None
return body
def _html_complete(html: str | None) -> bool:
"""报告 HTML 是否完整可用(非空 + 有 </html> 收尾,截断检测)。"""
return bool(html and html.strip()) and "</html>" in html.lower()
def _build_seat_intro(agents: list[SeatRef]) -> str:
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
@@ -328,18 +418,111 @@ class InProcessRoundtableGateway:
return await _create_thread(self._app)
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
"""跑报告 agent,**带产物自愈**:首轮后 HTML 缺失/截断(无 </html>)→ 同线程追加
纠偏消息(分段 + append 续写)重试,至多 ``_ARTIFACT_RETRY_LIMIT`` 轮。"""
policy = roundtable_run_policy("report")
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=prompt,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
summary = _latest_ai_text(messages)
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
return ReportResult(html=html, summary=summary, model=model or self._model)
summary = ""
html: str | None = None
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id)
if _html_complete(html):
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _REPORT_RETRY_TRUNCATED if (html and html.strip()) else _REPORT_RETRY_MISSING
logger.warning(
"report html %s after attempt %d for thread %s; retrying with corrective prompt",
"truncated" if (html and html.strip()) else "missing",
attempt + 1,
report_thread_id,
)
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
final_html = html if (html and html.strip()) else summary
return ReportResult(html=final_html, summary=summary, model=model or self._model)
async def create_summary_thread(self) -> str:
ensure_roundtable_functional_agents()
return await _create_thread(self._app)
async def run_summary(self, *, summary_thread_id, prompt, model) -> SummaryResult:
"""进程内驱动 roundtable-summary 写「方案总结报告.md」+ 对话正文(与前端
useStep3Summary 同一智能体 / 同一 run policy / 同款产物),返回与
DraftStep3SummarySnapshot 对齐的结果。线程是真实持久线程——加载草稿后可续问。
带产物自愈:md 文件缺失时同线程追加纠偏消息重试(同 ``run_report``)。"""
policy = roundtable_run_policy("summary")
closing = ""
md: str | None = None
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
if md and md.strip():
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _SUMMARY_RETRY_MISSING
logger.warning(
"summary md missing after attempt %d for thread %s; retrying with corrective prompt",
attempt + 1,
summary_thread_id,
)
final_md = md if (md and md.strip()) else closing
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
"""大屏多页(模式二)的结构化数据:进程内驱动 roundtable-report 输出
```report-json(prompt 已声明禁工具/禁文件),返回可解析的 JSON 字符串。
缺失 / 坏 JSON → 同线程纠偏重试(同 run_report/run_summary 的自愈语义)。"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread(self._app)
policy = roundtable_run_policy("report")
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
body = _extract_report_json(_latest_ai_text(messages))
if body:
return body
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _DASHBOARD_RETRY
logger.warning(
"dashboard report-json missing/invalid after attempt %d for thread %s; retrying",
attempt + 1,
thread_id,
)
return None
@@ -16,9 +16,10 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Callable
from typing import Any
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
@@ -29,14 +30,13 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
# ⚠️ 临时禁用「后台挂起」的 Step3 结果绘制(报告 HTML 生成)。
# 进程内驱动报告 agent 在真模型环境下仍无法稳定写出 HTML(前端手动调
# /api/multi-agent/report/init + run/stream 可以,但后台 start_run 路径不行,
# 根因尚未定位)。临时方案:后台只跑完 Step2 研讨即收尾,不自动画报告——用户需要
# 报告时可在前端手动跑 Step3(那条路是通的)。
# 待修复后把本开关改回 True 即恢复后台自动报告。
# 详见 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md「后台报告 HTML」。
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = False
# 「后台挂起」的 Step3 自动产出(方案总结报告 md + 结果绘制 HTML)总开关。
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
# 杀死编排,见 tests/test_roundtable_inprocess_request.py),修复后恢复开启。
# 现 Step2 收口后自动跑:① roundtable-summary 写「方案总结报告.md」② roundtable-report
# 画「方案总览.html」,两段故障隔离(engine._run_step3),产物按手动同款形状写回草稿。
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True
class _JobUser:
@@ -176,8 +176,8 @@ class StartParams:
# dag 模式分层编排计划(前端 OrchestrationPlan dict):驱动引擎 _run_dag + 写进草稿 run
# 供前端流程图渲染。None = 非 dag(引擎按 mode 回退 chain/recommend)。
orchestration_plan: dict[str, Any] | None = None
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
# 测试可显式传 True 覆盖以验证报告链路。
# 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True,
# 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
@@ -58,6 +58,11 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
# 内置「知识库检索」技能:素材收集专家通用检索的默认技能。skills/ 目录被
# .gitignore 排除,所以与 agent 目录一样需要 seeder 在缺失时从资源补建。
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
_SKILL_ASSETS_DIR = _ASSETS_DIR / "skills"
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
@@ -91,10 +96,69 @@ def _ensure_one(agent_id: str) -> bool:
return True
def _skills_public_dir() -> Path:
"""skills/public 物理目录(config.yaml skills.path 解析);独立成函数便于测试替身。"""
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
def _ensure_default_search_skill() -> bool:
"""Seed the built-in knowledge-base-search skill into ``skills/public/``
when missing. Never overwrites an existing skill directory.
Returns True if the skill was just created.
"""
src = _SKILL_ASSETS_DIR / KNOWLEDGE_SEARCH_SKILL_NAME / "SKILL.md"
if not src.exists():
logger.error("Missing seed asset for skill '%s' (expected at %s)", KNOWLEDGE_SEARCH_SKILL_NAME, src)
return False
skill_dir = _skills_public_dir() / KNOWLEDGE_SEARCH_SKILL_NAME
if skill_dir.exists():
return False
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_bytes(src.read_bytes())
return True
def _ensure_researcher_default_skills() -> bool:
"""给**已存在**的 researcher agent config.yaml 补默认技能配置(就地升级)。
seeder 对已存在目录绝不整体覆盖,但旧版种子没有 ``skills`` 字段——通用
检索改为技能驱动后,老部署的智能体管理页会显示"未配置技能"(运行时虽有
knowledge-base-search 兜底,但用户看不见、也没法在它基础上增删)。只在
``skills`` 键**完全缺失**时补默认值;用户显式改过(包括清空成 ``[]``)
的配置不动。Returns True if the file was patched.
"""
import yaml
config_file = get_paths().agent_dir(RESEARCHER_AGENT_ID) / "config.yaml"
if not config_file.exists():
return False
try:
data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("researcher config.yaml unparsable, skip default-skills patch")
return False
if not isinstance(data, dict) or "skills" in data:
return False
data["skills"] = [KNOWLEDGE_SEARCH_SKILL_NAME]
config_file.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
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.
Also seeds the built-in knowledge-base-search skill (the researcher's
default retrieval skill) and patches a pre-existing researcher config.yaml
that predates the ``skills`` field.
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
@@ -109,6 +173,16 @@ def ensure_ai_writing_functional_agents() -> list[str]:
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))
try:
if _ensure_default_search_skill():
logger.info("Seeded built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
except Exception:
logger.exception("Failed to seed built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
try:
if _ensure_researcher_default_skills():
logger.info("Patched researcher config.yaml with default skills")
except Exception:
logger.exception("Failed to patch researcher default skills")
return created
@@ -1,3 +1,7 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、用配置的技能编排检索、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
# 通用检索使用的技能(按顺序编排)。默认配置内置「知识库检索」;
# admin/用户可在智能体管理页替换为其它检索技能或追加多个。
skills:
- knowledge-base-search
@@ -25,9 +25,10 @@
## 产出要求(最重要)
### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
当用户**明确要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息),本轮进入**流程图模式**:
当用户**要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」之类时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息;**按语义理解,不要求逐字命中这些词**),本轮进入**流程图模式**:
- **跳过下面的 A**——不要调用 `write_file`、不要生成任何 .md 文件;
- 把**精炼的文字版总结报告**直接写在对话回复里(文字展示给用户读,结构遵循用户要求);
- 文字版总结的**默认结构**(用户只要求了结构化/格式化、**没指定**具体结构时):**对各席位交付内容逐席位格式化输出**——每个席位一节:`席位名` → 核心结论(一句话)→ 关键交付要点(编号列表,逐条提炼)→ 风险/建议(如有),最后加一节「总控共识」。用户指定了结构就完全按用户的来;
- 在回复**最末尾必须**输出一个完整的 ```flow-json 代码块(契约见「结构化流程数据」节)——这是本轮的**主产物**,绝不能省略。
未进入流程图模式时,每一轮都要做 A + B **两件事**,缺一不可:
@@ -77,27 +78,33 @@
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**自己判断**意图——报告和流程图都是可修改的,分四种情况处理:
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**按语义自己判断**意图——**绝不要求用户使用特定关键词或固定句式**。用户不会说「请重新结构化输出」这种话,他只会说「把验收环节加上」「改一下」「太长了精简点」「不对,重来」——这些都是有效的修改指令,你必须直接理解并动手,不要反问用户「您是想要结构化输出吗」。
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
**意图判断的两条核心规则**:
- **修改类消息默认作用于上一轮的主产物**:用户没说明改哪个产物时——上一轮你输出的是流程图(flow-json),那「加一个环节」「调整一下」「精简点」就是改流程图;上一轮主产物是 .md 报告,就是改报告。不要因为用户没提「流程图 / 结构化」字眼就把改流程图的意图当成改报告(反之亦然)。
- **意图实在太模糊、无法判断要改什么**(如只发「不行」「再改改」「不是这个意思」,且上下文也推不出指向)→ **不要修改任何产物、不要乱猜**,在对话正文里向用户提出一个**简短的澄清问题**并给出可选方向(如「您是想调整流程图的节点结构,还是修改文字报告的某个章节?可以具体说说想改哪里」),等用户回复后再动手。注意:澄清就写在正文里(你没有专门的澄清工具)。
报告和流程图都是可修改的,分四种情况处理:
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」,或上一轮主产物是报告时的笼统修改意见):
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
- 然后在对话回复里给出修改后的完整报告正文;
- 若此前已经输出过流程图、且这次修改影响了流程结构,可在回复末尾顺带输出更新后的完整 ```flow-json 块。
2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」):
2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」;**只要上一轮主产物是流程图,笼统的修改意见也默认按本条处理**,无需用户提到「流程图 / 结构化」字眼):
- **不要**改写 .md 报告文件;
- 在对话里简短说明改了什么,并在回复**最末尾**输出**完整修订版** ```flow-json 块——必须是**全量数据**(所有节点 + 连线),不是只给改动部分,前端会整体重绘。
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文给一段简短的文字说明,末尾输出完整 ```flow-json 块;
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」,或语义等价的说法):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文按第 ⓪ 条的默认结构(逐席位格式化各席位交付内容;用户指定结构则按用户的)给出文字总结,末尾输出完整 ```flow-json 块;
- 已有的 .md 报告**不要动**——用户随时可以在前端切回去看它。
4. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
- **不要**改写报告文件、**不要**重新 `write_file`、**不要**输出 flow-json。
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告 / 流程图」。
修改意图明确但细节有歧义时(能判断改哪个产物、只是改法有多种)→ 按最合理的理解直接改,并在回复里说明你的理解,邀请用户继续纠正;完全判断不了才走上面的「澄清提问」。
## 禁止事项
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
@@ -107,6 +114,8 @@
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
- ❌ 用户只是提问时还去改写报告文件
- ❌ 用户要求了结构化输出 / 流程图,却只写 .md 报告而不输出 flow-json 块
- ❌ 要求用户必须说出「结构化输出 / 流程图」等关键词才肯修改流程图(上一轮主产物是流程图时,笼统的修改意见就是改流程图)
- ❌ 用户意图模糊时不提澄清问题、自行乱猜乱改一通
- ❌ 流程图模式下仍调用 `write_file` 生成 / 改写 .md 文件
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
@@ -29,11 +29,10 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.roundtable_job_executor import StartParams
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
from app.gateway.roundtable_job_executor import StartParams
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
@@ -182,6 +181,15 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
# 防重复挂起(服务端幂等闸门):同一草稿已有未终态作业(queued/running/awaiting_input)
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
if body.draftId:
existing = await store.get_by_draft(body.draftId, user_id=user_id)
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
return _to_response(existing)
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
job_id = uuid4().hex
row = await store.create(
@@ -69,18 +69,20 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
# enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
# enable_skill_search:旧版「启用技能检索」按钮的兼容入口——通用检索现在
# 本来就按 agent 配置技能编排,researcher 把它按 user_query 追加检索处理
if action == "enable_skill_search":
msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新检索中…"
return {
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
# re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
# re_search:带 user_query = 在配置技能中匹配检索并**追加**素材;
# 不带输入 = 重新生成检索词、覆盖式重检(兼容旧版 extraKeywords 数组追加)
extra_kws = user_input.get("extraKeywords") or []
msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新搜索中..."
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"status": "researching",
@@ -1,4 +1,17 @@
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
"""素材收集专家节点:关键词提取 → 配置技能编排检索(含笔记) → 相关度排序。
检索类型(material_source)只有两种:
- ``general``(通用检索,默认):按素材收集专家 agent(ai-writing-researcher)
config.yaml 配置的技能顺序编排检索。内置「知识库检索」技能
(knowledge-base-search)走原生 web/intranet 关键词并发检索;其它技能用
其 description 生成定向 query 后调真实 provider 检索。
- ``notebook``(我的空间):仅用笔记 API 素材。
旧值 ``knowledge_base`` / ``skill`` 一律按 ``general`` 兼容处理。
PAUSE-1 素材确认卡的单输入框:用户用自然语言描述想补充检索的内容
(如「检索 A 的数据」)时,让模型从已配置技能中按相关度匹配,逐个检索,
把新素材**追加**到现有素材列表(URL 去重、保持原有顺序、不重排不丢弃)。
"""
from __future__ import annotations
import asyncio
@@ -6,8 +19,7 @@ import logging
import os
import re
import time
import uuid
from typing import List
from types import SimpleNamespace
from langchain_core.runnables import RunnableConfig
@@ -71,11 +83,63 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环、最多 5 个技能,
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
# 内置「知识库检索」技能名:素材收集专家 agent 的默认技能。researcher 对它
# 特殊处理——不用 description 拼 query,而是对 Step 1 生成的全部检索词做
# 原生 web/intranet 并发检索(即旧版 knowledge_base 模式的行为)。
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
_KNOWLEDGE_SKILL_DISPLAY = "知识库检索"
_KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材"
def _fallback_knowledge_skill() -> SimpleNamespace:
"""技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。"""
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
def _load_configured_skills() -> list:
"""解析素材收集专家 agent(ai-writing-researcher)配置的技能列表。
config.yaml 的 ``skills`` 字段是权威来源(admin/用户在智能体管理页可改);
未配置 / 解析失败 / 技能文件全部缺失时,一律回退为内置「知识库检索」,
保证通用检索永远可用。返回的元素带 ``name`` / ``description`` 属性。
"""
names: list[str] = []
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(RESEARCHER_AGENT_ID)
if cfg is not None:
names = [n.strip() for n in (cfg.skills or []) if isinstance(n, str) and n.strip()]
except Exception:
logger.warning("researcher: 读取 agent 配置技能失败,回退知识库检索", exc_info=True)
if not names:
return [_fallback_knowledge_skill()]
by_name: dict[str, object] = {}
try:
from deerflow.skills.storage import get_or_new_skill_storage
by_name = {s.name: s for s in get_or_new_skill_storage().load_skills(enabled_only=False)}
except Exception:
logger.warning("researcher: 加载技能目录失败", exc_info=True)
resolved: list = []
for n in names:
sk = by_name.get(n)
if sk is None and n == KNOWLEDGE_SEARCH_SKILL_NAME:
sk = _fallback_knowledge_skill()
if sk is None:
logger.warning("researcher: 配置的技能 '%s' 不存在于技能目录,已跳过", n)
continue
resolved.append(sk)
return resolved or [_fallback_knowledge_skill()]
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
@@ -247,32 +311,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
# PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
# PAUSE-1 单输入框:用户写的自然语言(想补充检索的内容),优先级最高
user_query: str = (intervention.get("user_query") or "").strip()
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
# PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
# PAUSE-1 素材确认卡用户在输入框填了自然语言(如「检索 A 的数据」):
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
# - 直接用 user_query 作为唯一检索词
# - 保留 existing material_package,按 URL 去重合并
is_user_query_search = action == "re_search" and bool(user_query)
# - 从 agent 配置的技能中匹配最相关的技能,逐个检索
# - **追加**:保留 existing material_package,新素材按 URL 去重后追加在末尾
# 旧版「启用技能检索」按钮(action=enable_skill_search)按同一语义兼容处理。
is_user_query_search = action in ("re_search", "enable_skill_search") and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# 用户在写作表单选择的素材来源:knowledge_base(默认 web/intranet 检索)/ skill(技能检索)。
# 首次检索(没有用户干预)时按它决定检索方式;缺省 knowledge_base。
material_source = (state.get("material_source") or "knowledge_base").strip()
is_first_run = not intervention
is_initial_skill_source = is_first_run and material_source == "skill"
# 技能检索触发条件(任一即可):
# - 首次检索且表单选了「技能检索」来源
# - PAUSE-1 素材确认卡用户点「启用技能检索」按钮
# 技能检索流程:
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
is_skill_search = action == "enable_skill_search" or is_initial_skill_source
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
material_source = "notebook" if raw_source == "notebook" else "general"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -291,7 +347,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher):
# 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# 检索编排(技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin
# 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。
use_agent = use_builtin_agent("researcher")
@@ -308,14 +364,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
kw_result: dict = {} # 走"用户输入 / 追加 / 技能"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_skill_search:
# 技能模式:每个 skill 会自己拼 query(user_query + skill.description),这里不用 LLM 生成关键词
keywords: list[str] = [user_query or intent]
elif is_user_query_search:
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords = [user_query]
_emit(f"按你的输入「{user_query[:30]}」检索…")
keywords: list[str] = [user_query]
_emit(f"按你的输入「{user_query[:30]}」匹配技能检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
@@ -359,7 +412,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
notebook_materials: list[Material] = []
if notebook_search_space_id:
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
notebook_token: str = cfg.get("notebook_token") or ""
@@ -373,12 +426,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if notebook_materials:
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
# Step 2: 搜索
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
# b. 按顺序遍历 skills:每个 skill 用其 description 让 LLM 生成针对性关键词
# c. 调真实 web/intranet provider 执行检索,推 invoke_skill + skill_result 事件
# d. 累积素材到 max_materials 即 break,推 stop_early
# Step 2: 检索
# 通用检索 = 配置技能编排。技能不是素材,是"检索策略 / 视角":
# a. 从 ai-writing-researcher agent config.yaml 读取配置技能(默认知识库检索)
# b. 「知识库检索」技能 → 对 Step 1 检索词做原生并发 web/intranet 检索
# c. 其它技能 → 用 skill.description 拼定向 query 调真实 provider
# d. user_query 模式(PAUSE-1 输入框)→ 先让模型按用户输入匹配技能再检索
# e. 累积素材到 max_materials 即 break,推 stop_early
skill_steps: list[dict] = []
search_errors: list[str] = []
@@ -390,188 +444,208 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": researcher_label, **step})
if is_skill_search:
# ── 技能编排检索 ──
# 技能选择走流式 LLM(推 skill_select_chunk)+ <think> 分离,前端打字机展示
picker = SkillPicker(
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
)
selected = await picker.pick(user_intent=intent)
search_errors.extend(picker.errors)
# 本轮新检索到的原始结果(统一在下方合并段转 Material、与旧素材去重)
new_results: list[SearchResult] = []
provider = None
# 真实 web/intranet provider(按 config.yaml 配置)
web_provider = get_search_provider(ai_writing_config)
# 每个 skill 尽量多取(不分摊),第一个就达到 max_materials 即可早停;
# 这才贴近"第一个技能够了就停,否则下一个补充"的语义。
per_skill = max(1, max_materials)
raw_materials_pre: list[Material] = []
seen_urls_pre: set[str] = set()
stopped_early = False
for entry in selected:
sk = entry["skill"]
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
# invoke_skill 步骤带上实际检索词(截断防过长),让用户在每次长 await 之前
# 就看到"这一步在用技能 X 检索什么",配合前端 spinner 体现进行中。
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
try:
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
res = await asyncio.wait_for(
web_provider.search(skill_query, max_results=per_skill),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", sk.name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{sk.name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
res = []
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
res = []
# 去重累积
new_count = 0
for r in res or []:
if r.url and r.url in seen_urls_pre:
continue
if r.url:
seen_urls_pre.add(r.url)
raw_materials_pre.append(Material(
id=f"m{len(raw_materials_pre)+1:03d}",
title=r.title,
content=r.content[:500],
source=f"{r.source}(via 技能 {sk.name})",
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
new_count += 1
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{sk.name}」检索到 {new_count} 条新素材",
"skill_name": sk.name,
"matched_count": new_count,
})
# 早停:累积素材已达上限
if len(raw_materials_pre) >= max_materials:
stopped_early = True
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(raw_materials_pre)} 条素材,达到上限,停止调用后续技能",
"matched_count": len(raw_materials_pre),
})
break
# 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索
all_results_nested: list = []
# 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去
elif material_source == "notebook" and notebook_search_space_id:
if material_source == "notebook" and notebook_search_space_id:
# ── 纯笔记模式 ──
# 用户在表单选了「我的空间」作为素材来源 → 跳过 web/intranet 检索,
# 用户在表单选了「我的空间」作为检索类型 → 跳过技能编排,
# 仅使用 Step 1.5 已拉取的 notebook_materials。
# 与 UI 文案"从空间中检索素材"对齐。
_emit(f"仅使用笔记素材,跳过 web/intranet 检索({len(notebook_materials)} 条)")
all_results_nested = []
else:
# ── 常规 web/intranet 模式 ──
_emit(f"仅使用笔记素材,跳过技能检索({len(notebook_materials)} 条)")
elif is_append_only:
# ── 兼容旧版追加关键词模式 ──
# 直接对用户给的关键词做原生并发检索(不走技能编排),结果追加到旧素材。
provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断。
# 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
if is_user_query_search or is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
# - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(编排循环已经把素材累积到 raw_materials_pre)
existing_pkg = state.get("material_package")
if is_skill_search:
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
# 笔记素材追加在技能素材之后
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
raw_materials.extend(notebook_materials)
else:
seen_urls: set[str] = set()
# 笔记素材优先排在最前面,赋予高相关度
raw_materials = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
for results in all_results_nested:
per_keyword = _per_keyword_limit(max_materials, len(keywords))
_emit(f"开始追加检索 {len(keywords)} 个方向…")
gathered = await asyncio.gather(
*[provider.search(kw, max_results=per_keyword) for kw in keywords],
return_exceptions=True,
)
for results in gathered:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
for r in results:
if r.url and r.url in seen_urls:
new_results.extend(results or [])
search_errors.extend(getattr(provider, "errors", []))
else:
# ── 通用检索:配置技能编排 ──
configured = _load_configured_skills()
provider = get_search_provider(ai_writing_config)
per_skill = max(1, max_materials)
ordered = configured
if is_user_query_search and len(configured) > 1:
# 用户输入了自然语言:让模型在配置技能中按相关度匹配排序
# (技能选择走流式 LLM,推 skill_select_chunk,前端打字机展示)
picker = SkillPicker(
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
candidates=configured,
)
ranked = await picker.pick(user_intent=intent)
search_errors.extend(picker.errors)
ordered = [entry["skill"] for entry in ranked] or configured
else:
_emit_skill_step({
"type": "list_skills",
"message": f"素材收集专家已配置 {len(configured)} 个检索技能",
"skill_names": [s.name for s in configured],
})
seen_urls_new: set[str] = set()
def _collect(results: list[SearchResult]) -> int:
added = 0
for r in results or []:
if r.url and r.url in seen_urls_new:
continue
if r.url:
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
seen_urls_new.add(r.url)
new_results.append(r)
added += 1
return added
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
_emit("检索完成,正在筛选排序素材…")
async def _timed_search(skill_name: str, query: str, limit: int) -> list[SearchResult]:
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
try:
return await asyncio.wait_for(
provider.search(query, max_results=limit),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
) or []
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{skill_name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
return []
except Exception as e:
logger.warning("skill[%s] search failed: %s", skill_name, e)
search_errors.append(f"skill[{skill_name}]: {e}")
return []
for sk in ordered:
is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME
if is_knowledge and not is_user_query_search:
# 原生知识库检索:对全部检索词并发检索(旧版默认路径)。
# 用户带 extra_keywords 时全部检索,否则按 keyword_count 截断。
search_keywords = keywords if extra_keywords else keywords[:keyword_count]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{_KNOWLEDGE_SKILL_DISPLAY}」检索 {total} 个方向:{('、'.join(search_keywords[:3]))[:60]}",
"skill_name": sk.name,
})
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True)
added = 0
for results in gathered:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
added += _collect(results)
search_errors.extend(getattr(provider, "errors", []))
else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
else:
skill_query = f"{intent} {sk.description or ''}".strip() or intent
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
added = _collect(await _timed_search(sk.name, skill_query, per_skill))
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材",
"skill_name": sk.name,
"matched_count": added,
})
# 早停:累积素材已达上限
if len(new_results) >= max_materials:
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(new_results)} 条素材,达到上限,停止调用后续技能",
"matched_count": len(new_results),
})
break
# 合并去重
# - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 输入框 is_user_query_search:保留旧素材 + **追加**新素材(核心交互)
# - 旧版追加关键词 is_append_only:保留旧素材 + 仅检索新关键词
existing_pkg = state.get("material_package")
preserve_existing = bool(existing_pkg) and (is_supplement or is_append_only or is_user_query_search)
seen_urls: set[str] = set()
# 笔记素材优先排在最前面,赋予高相关度
raw_materials: list[Material] = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
appended_count = 0
for r in new_results:
if r.url and r.url in seen_urls:
continue
if r.url:
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
appended_count += 1
# Step 3: 相关度排序
# 用户主动追加(输入框 / 追加关键词):保持旧素材原有顺序,新素材追加在
# 末尾,不调 LLM 重排、不按上限丢弃旧素材(只做 100 条硬上限防爆)。
rank_error = ""
if len(raw_materials) > max_materials:
is_user_append = is_user_query_search or is_append_only
if is_user_append:
materials = raw_materials[:_MAX_MAX_MATERIALS]
dropped = len(raw_materials) - len(materials)
summary = f"追加检索到 {appended_count} 条新素材,当前共 {len(materials)} 条"
if dropped > 0:
summary += f"(超出 {_MAX_MAX_MATERIALS} 条硬上限,丢弃末尾 {dropped} 条)"
elif len(raw_materials) > max_materials:
_emit("检索完成,正在筛选排序素材…")
materials_list = "\n".join(
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
@@ -608,26 +682,29 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
materials = sorted_materials[:max_materials]
summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条")
else:
_emit("检索完成,正在筛选排序素材…")
materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:max_materials]
summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条"
# 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项
if not materials:
summary = "未检索到相关素材,可重新搜索或在无素材的情况下继续写作"
elif is_user_append and appended_count == 0:
summary = f"本次未检索到新素材(已有 {len(materials)} 条保持不变),可换个说法再试"
# 追加模式下,pkg.keywords 体现到目前为止累积的检索词(旧 + 新追加),
# 让前端列表 / 历史回显能看到完整的搜索路径。
pkg_keywords = keywords
if is_append_only and existing_pkg:
if is_user_append and existing_pkg:
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
# 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if is_skill_search:
# 技能编排过程步骤放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if skill_steps:
pkg["skill_search_used"] = True
pkg["skill_search_steps"] = skill_steps
elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
# 保留之前一次技能检索的痕迹(不污染纯 web 检索)
elif existing_pkg and existing_pkg.get("skill_search_used") and preserve_existing:
# 保留之前一次技能检索的痕迹
pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
@@ -640,9 +717,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if search_errors:
uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
# (技能模式下 provider 局部变量没暴露,所以 raw_dumps 兜底为空列表)
provider_obj = locals().get("provider") or locals().get("web_provider")
raw_dumps = list(dict.fromkeys(getattr(provider_obj, "raw_responses", []) or []))
raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or [])) if provider is not None else []
notices.append(_notice(
"ES 波动导致检索异常",
";".join(uniq[:3]),
@@ -650,13 +725,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
agent_name=researcher_label,
))
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
if is_skill_search:
step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
elif is_user_query_search:
step_start_msg = f"按你的输入检索:{user_query[:40]}"
# 进度事件文案:用户输入 / 追加模式下让用户看到差异化提示
if is_user_query_search:
step_start_msg = f"按你的输入匹配技能检索并追加:{user_query[:40]}"
elif is_append_only:
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
elif skill_steps:
step_start_msg = f"通用检索(技能编排 {len(skill_steps)} 步):{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
return {
@@ -70,6 +70,8 @@ class SkillPicker:
emit_step: 推送时间线步骤事件(list_skills / select_skills)
max_select: LLM 单次最多挑几个技能
user_query: 用户在素材确认卡输入框写的自然语言
candidates: 候选技能池。None = 全部启用的技能(旧行为);
传入列表(如素材收集专家 agent 配置的技能)则只在其中匹配排序
"""
def __init__(
@@ -80,12 +82,14 @@ class SkillPicker:
emit_step: Optional[EmitStepFn] = None,
max_select: int = 5,
user_query: str = "",
candidates: Optional[list] = None,
) -> None:
self._config = config
self._model_name = model_name
self._emit_step = emit_step or (lambda _step: None)
self._max_select = max_select
self._user_query = (user_query or "").strip()
self._candidates = candidates
self._errors: list[str] = []
@property
@@ -93,6 +97,14 @@ class SkillPicker:
return list(self._errors)
def _load_enabled_skills(self) -> list:
if self._candidates is not None:
skills = list(self._candidates)
self._emit_step({
"type": "list_skills",
"message": f"素材收集专家已配置 {len(skills)} 个检索技能",
"skill_names": [s.name for s in skills],
})
return skills
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
@@ -135,8 +135,9 @@ class AIWritingState(TypedDict):
keyword_count: int
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str
# 素材来源:knowledge_base=知识库(默认 web/intranet 检索)/ skill=技能检索。
# 用户在写作表单选择,首次检索时由 researcher 节点据此决定检索方式(缺省 knowledge_base)。
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
material_source: str
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
@@ -13,7 +13,13 @@ Phase 3 会提供具体的 ``RoundtableGateway`` 实现(复用 multi_agent.py
from __future__ import annotations
from .engine import OrchestrationResult, build_report_prompt, run_orchestration
from .engine import (
OrchestrationResult,
build_dashboard_prompt,
build_report_prompt,
build_summary_prompt,
run_orchestration,
)
from .progress import JobProgressWriter
from .simulated_gateway import SimulatedRoundtableGateway
from .types import (
@@ -27,6 +33,7 @@ from .types import (
RoundtableGateway,
SeatRef,
SeatTurn,
SummaryResult,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
@@ -38,12 +45,15 @@ __all__ = [
"run_orchestration",
"OrchestrationResult",
"build_report_prompt",
"build_summary_prompt",
"build_dashboard_prompt",
"JobProgressWriter",
"SimulatedRoundtableGateway",
"RoundtableGateway",
"LeaderTurn",
"SeatTurn",
"ReportResult",
"SummaryResult",
"SeatRef",
"DagStage",
"DagPlan",
@@ -32,6 +32,7 @@ from .types import (
ReportResult,
RoundtableGateway,
SeatRef,
SummaryResult,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
@@ -94,9 +95,8 @@ async def run_orchestration(
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
plan: dag 模式的分层编排计划(前端 ``OrchestrationPlan`` dict)。mode=="dag" 且
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、
不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent
在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。
enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完
Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。
"""
intent = (intent_text or seed_message or "").strip()
try:
@@ -120,11 +120,11 @@ async def run_orchestration(
dialogues=progress.dialogues(),
)
# consensus 或 max_cycles:均收口。报告绘制按 enable_report 开关执行。
# consensus 或 max_cycles:均收口。Step3(总结报告 + 结果绘制)按 enable_report 开关执行。
await progress.consensus(100)
step3 = None
if enable_report:
step3 = await _run_report(
step3 = await _run_step3(
gateway,
progress,
agents=agents,
@@ -132,6 +132,7 @@ async def run_orchestration(
intent_text=intent_text or seed_message,
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None),
)
await progress.done()
return OrchestrationResult(
@@ -428,7 +429,96 @@ async def _run_dag(
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
# ── Step3 阶段(总结报告 + 结果绘制)────────────────────────────────────────────
async def _run_step3(
gateway: RoundtableGateway,
progress: JobProgressWriter,
*,
agents: list[SeatRef],
model: str | None,
intent_text: str,
consensus_text: str,
deliveries: dict[str, str],
coordinator_prompt: str | None = None,
) -> dict | None:
"""Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」
(roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行,
Step3 总耗时 ≈ max(两段) 而非两段之和)。
返回与前端 ``DraftStep3Snapshot`` 同形状的 dict:
``{summaryReport?: {md,threadId,generatedAt,model,closing}, html?, generatedAt?, model?, summary?}``。
**故障隔离**:两段各自 try/except —— 一段失败不拖垮另一段,跑成多少交付多少;
两段都失败时抛出第一个异常(由上层标记 job error)。两段各跑在独立线程上
(gateway 内部各建独立 thread),进程内并行安全(与 ``_run_dag`` 批内并行同理)。"""
step3: dict = {}
errors: list[Exception] = []
async def _summary_task() -> None:
# ① 方案总结报告(Markdown)—— 与前端 useStep3Summary 同源 prompt / 同款快照形状。
try:
await progress.summary_writing()
summary_thread_id = await gateway.create_summary_thread()
summary_prompt = build_summary_prompt(
intent_text, agents, deliveries, consensus_text, coordinator_prompt=coordinator_prompt
)
summary: SummaryResult = await gateway.run_summary(
summary_thread_id=summary_thread_id, prompt=summary_prompt, model=model
)
if (summary.md or "").strip() or (summary.closing or "").strip():
step3["summaryReport"] = {
"md": summary.md or "",
"threadId": summary.thread_id or summary_thread_id,
"generatedAt": summary.generated_at or datetime.now(UTC).isoformat(),
"model": summary.model or (model or ""),
"closing": summary.closing or "",
}
except Exception as exc: # noqa: BLE001 — 总结失败不阻塞结果绘制
logger.exception("roundtable step3 summary failed")
errors.append(exc)
async def _report_task() -> None:
# ② 结果绘制(HTML 看板)。
try:
report = await _run_report(
gateway,
progress,
agents=agents,
model=model,
intent_text=intent_text,
consensus_text=consensus_text,
deliveries=deliveries,
)
step3.update(report)
except Exception as exc: # noqa: BLE001 — 绘制失败时保留已写出的总结报告
logger.exception("roundtable step3 report failed")
errors.append(exc)
async def _dashboard_task() -> None:
# ③ 大屏多页(模式二)的结构化数据(report-json 字符串)。后端**只产数据**,
# 前端查看时用 renderDashboardHtml 按当前主题渲染(与手动并行绘制同源)。
# 旧网关没有 run_dashboard_data 则静默跳过(向后兼容)。
try:
run_dash = getattr(gateway, "run_dashboard_data", None)
if run_dash is None:
return
prompt = build_dashboard_prompt(intent_text, agents, deliveries, consensus_text)
data_json = await run_dash(prompt=prompt, model=model)
if data_json and data_json.strip():
step3["dashboardJson"] = data_json
except Exception as exc: # noqa: BLE001 — 大屏失败不拖垮另两段
logger.exception("roundtable step3 dashboard failed")
errors.append(exc)
await asyncio.gather(_summary_task(), _report_task(), _dashboard_task())
if not step3:
if errors:
raise errors[0]
return None
return step3
async def _run_report(
@@ -453,6 +543,108 @@ async def _run_report(
}
def build_dashboard_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
) -> str:
"""拼装「大屏多页(模式二)」的 report-json 数据提炼 prompt —— 对齐前端
``report-template.buildDashboardPrompt``:模型只输出 1-2KB 结构化数据
(不调工具、不写文件、不输出 HTML),前端按当前主题模板渲染。"""
name_by_id = {a.agent_id: a.name for a in agents}
lines: list[str] = [
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
"请通读以下圆桌研讨成果,把它们提炼成一份大屏展示数据,在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
"",
"# 一、任务意图",
(intent_text or "").strip() or "(未提供)",
"",
"# 二、各席位最终交付",
]
idx = 0
for a in agents:
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
idx += 1
lines.append(f"## {idx}. {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("")
lines.append(
"\n".join(
[
"```report-json 契约(严格 JSON,全程简体中文):",
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表(风险分布/资源占比/方案评分等),3-6 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
]
)
)
return "\n".join(lines)
def build_summary_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
*,
coordinator_prompt: str | None = None,
) -> str:
"""拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt``
(非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。"""
name_by_id = {a.agent_id: a.name for a in agents}
lines: list[str] = [
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」,"
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md,"
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
"",
]
cp = (coordinator_prompt or "").strip()
if cp:
lines += [
"# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)",
cp,
"",
]
lines += ["# 一、任务意图", (intent_text or "").strip() or "(未提供)", ""]
lines.append("# 二、各席位最终交付")
has_delivery = False
for i, a in enumerate(agents):
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
has_delivery = True
lines.append(f"## {i + 1}. {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
if not has_delivery:
lines.append("(各席位暂无文字交付,请基于任务意图与总控结论合理展开成报告。)")
lines.append("")
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("")
lines.append(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。"
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);"
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略),"
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。"
)
return "\n".join(lines)
def build_report_prompt(
intent_text: str,
agents: list[SeatRef],
@@ -501,6 +693,9 @@ def build_report_prompt(
"文字为主图表点睛、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。"
)
lines.append(
'硬性要求:① 全程使用简体中文;② HTML 必须是完整文档,<head> 最前面声明 <meta charset="utf-8">。'
'硬性要求:① 全程使用简体中文;② HTML 必须是完整文档,<head> 最前面声明 <meta charset="utf-8">;'
"③ 整份 HTML 较长时**不要试图一次性写入**——先用 write_file 写入开头部分(约 200-300 行),"
"再用 write_file 的 append=true 参数分多次续写(每段 ≤300 行),直到文档以 </html> 收尾"
"(单次超长输出会被截断,导致文件根本写不进去)。"
)
return "\n".join(lines)
@@ -10,8 +10,9 @@
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable
from typing import Any
from .types import SeatRef
@@ -140,6 +141,10 @@ class JobProgressWriter:
self._consensus = int(pct)
await self._persist(phase="consensus", active_agent_id=None)
async def summary_writing(self) -> None:
"""Step3 第一段:方案总结报告(roundtable-summary 写 Markdown)撰写中。"""
await self._persist(phase="summary_writing", active_agent_id=None)
async def report_drawing(self) -> None:
await self._persist(phase="report_drawing", active_agent_id=None)
@@ -13,8 +13,9 @@ multi_agent 的纯解析函数 + ``set_current_user`` 进程内调 agent runtime
from __future__ import annotations
import asyncio
import json
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn, SummaryResult
class SimulatedRoundtableGateway:
@@ -63,3 +64,44 @@ class SimulatedRoundtableGateway:
"<body><h1>方案总览(模拟)</h1><p>这是模拟网关生成的占位报告,用于前端联调。</p></body></html>"
)
return ReportResult(html=html, summary="(模拟)方案已生成", model=model)
async def create_summary_thread(self) -> str:
return "sim-summary-thread"
async def run_summary(self, *, summary_thread_id, prompt, model) -> SummaryResult:
await self._tick()
md = "# 方案总结报告(模拟)\n\n这是模拟网关生成的占位总结报告,用于前端联调。"
return SummaryResult(md=md, closing="(模拟)总结报告已生成", model=model, thread_id=summary_thread_id)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
await self._tick()
seats = [
{
"id": f"seat-{i + 1}",
"name": a.name,
"summary": f"(模拟){a.name} 的结论摘要。",
"sections": [{"heading": "要点", "bullets": ["模拟要点一", "模拟要点二"]}],
"metrics": [{"label": "完成度", "value": 90}],
"risks": [],
"dependsOn": [f"seat-{i}"] if i > 0 else [],
}
for i, a in enumerate(self._agents)
]
return json.dumps(
{
"title": "方案研讨大屏(模拟)",
"subtitle": "模拟网关占位数据",
"kpis": [{"label": "席位数", "value": str(len(self._agents)), "unit": "个"}],
"consensus": {"headline": "(模拟)推荐方案已达成共识", "points": ["要点一", "要点二"]},
"charts": [
{
"type": "bar",
"title": "席位贡献(模拟)",
"items": [{"label": a.name, "value": 60 + i * 10} for i, a in enumerate(self._agents)],
}
],
"timeline": [{"label": "阶段一", "desc": "模拟阶段说明"}],
"seats": seats,
},
ensure_ascii=False,
)
@@ -85,6 +85,22 @@ class ReportResult:
generated_at: str | None = None
@dataclass
class SummaryResult:
"""Step3「方案总结报告」结果(由 gateway.run_summary 产出)。
与前端 ``DraftStep3SummarySnapshot`` 一一对应:``md`` = 写入
``outputs/方案总结报告.md`` 的完整 Markdown;``closing`` = 智能体在对话里
回显的报告正文/收尾语;``thread_id`` = 真总结线程 id(加载草稿后可续问)。
"""
md: str = ""
closing: str | None = None
model: str | None = None
thread_id: str | None = None
generated_at: str | None = None
# ── 网关协议 ────────────────────────────────────────────────────────────────
@@ -130,6 +146,23 @@ class RoundtableGateway(Protocol):
model: str | None,
) -> ReportResult: ...
async def create_summary_thread(self) -> str: ...
async def run_summary(
self,
*,
summary_thread_id: str,
prompt: str,
model: str | None,
) -> SummaryResult: ...
async def run_dashboard_data(
self,
*,
prompt: str,
model: str | None,
) -> str | None: ...
# ── chain 模式 prompt 构造(移植自前端 buildChainLeaderTask / pickChainTask)────