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}