Merge branch 'dhk-1' into 'master'

轻应用主题参数:随当前主题色映射传值

See merge request knowledge-base-bj/deerflow-server!118
This commit is contained in:
董宏凯 committed 2026-06-13 10:01:30 +00:00
commit 12360c6f3f
89 files changed
+10286 -1485

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 注入「任务背景 + 前序交付 + 别提问」。
+6 -1
View File
@@ -30,6 +30,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
@@ -120,10 +121,14 @@ export function App() {
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
}
@@ -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 列表,空数组表示检索整个空间 */
@@ -9,7 +9,7 @@ import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, type AppThemeColor } from "@/strategy-components/lib/light-app";
export default function LightAppIframeView() {
const { appId } = useParams<{ appId: string }>();
@@ -17,7 +17,14 @@ export default function LightAppIframeView() {
const isDark = theme === "dark";
const { data: app, isLoading, error } = useLightApp(appId);
const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app]);
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
// TODO: 后续接入 blue 主题后,这里区分 blue 与 dark-blue。
const themeColor: AppThemeColor = isDark ? "dark-blue" : "red";
const src = useMemo(
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
[app, themeColor],
);
const centerClass = `flex h-full w-full items-center justify-center text-sm ${
isDark ? "text-slate-400" : "text-slate-500"
+255 -52
View File
@@ -1,27 +1,71 @@
/**
* 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。
*
* 支持搜索 + 分页。点击卡片用 buildLightAppUrl 构造最终地址(追加解析后的路由参数)
* 并 window.open 新开页跳转。仅展示已启用的应用;iframe 类型不在此处(它们注入侧边栏)。
* 只读启动器:搜索 + 分页;每张卡片预览「拼接后最终路由」(buildLightAppUrl 追加解析后的
* 登录态 / 主题路由参数),并提供「一键复制URL」「进入应用」(window.open 新开页) 两个动作。
* 增删改不在本页,走独立的「应用管理」页(admin);iframe 类型也不在此(它们注入侧边栏)。
*
* 配色(与「业务链条配置」页统一):
* - 浅色:品牌红 `#CF1322`(hover `#A8071A`)。
* - 深蓝:按《深蓝色 UI 规范》——页面 `#0C1836`、卡片 `#122347`、边框 `#1A2F5A`、
* 正文 `#C5CFE0`、次级 `#6E88B0`;主操作改用规范主色蓝 `#1677FF`(hover `#0958D9`)。
*
* 后端 LightApp 无「分类」字段,故不做分类筛选;卡片图标 / 主题色按 appId 哈希派生
* (`icon` 字段当前未用),仅作视觉区分,不承载语义。
*/
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Search } from "lucide-react";
import {
ArrowUpRight,
Bot,
Check,
Code,
Copy,
FileText,
Globe,
Layers,
Notebook,
Search,
SlidersHorizontal,
Terminal,
X,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
const PAGE_SIZE = 12;
export default function LightAppListPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
/** 卡片图标池(按 appId 哈希取一个,仅作视觉区分)。 */
const ICON_POOL: LucideIcon[] = [Bot, Notebook, Terminal, SlidersHorizontal, Globe, Code, Layers, FileText];
/** 卡片主题色池:浅色用对应色阶,深蓝统一落到 `#1A2F5A` 容器底 + 规范强调色。 */
const THEME_POOL = [
{ iconBg: "bg-rose-100 text-rose-600 dark:bg-[#1A2F5A] dark:text-[#FF7875]" },
{ iconBg: "bg-indigo-100 text-indigo-600 dark:bg-[#1A2F5A] dark:text-[#6388FF]" },
{ iconBg: "bg-emerald-100 text-emerald-600 dark:bg-[#1A2F5A] dark:text-[#52C41A]" },
{ iconBg: "bg-amber-100 text-amber-600 dark:bg-[#1A2F5A] dark:text-[#FAAD14]" },
{ iconBg: "bg-blue-100 text-blue-600 dark:bg-[#1A2F5A] dark:text-[#4096FF]" },
{ iconBg: "bg-purple-100 text-purple-600 dark:bg-[#1A2F5A] dark:text-[#9C7BFF]" },
] as const;
/** 稳定字符串哈希 → [0, mod) 下标。 */
function hashIndex(str: string, mod: number): number {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = (h * 31 + str.charCodeAt(i)) >>> 0;
}
return mod > 0 ? h % mod : 0;
}
export default function LightAppListPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
enabledOnly: true,
@@ -47,67 +91,226 @@ export default function LightAppListPage() {
window.open(url, "_blank", "noopener,noreferrer");
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "group cursor-pointer rounded-2xl border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] p-4 transition-all hover:-translate-y-0.5 hover:border-[rgba(77,141,255,0.6)] hover:shadow-[0_18px_38px_rgba(3,10,22,0.35)]"
: "group cursor-pointer rounded-2xl border border-[#E8E8E8] bg-white p-4 transition-all hover:-translate-y-0.5 hover:border-[#FFA39E] hover:shadow-[0_16px_32px_rgba(245,34,45,0.1)]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const iconWrap = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const copyUrl = async (app: LightApp) => {
const url = buildLightAppUrl(app);
if (!url) {
toast.error("该应用地址为空,无法复制");
return;
}
try {
await navigator.clipboard.writeText(url);
setCopiedId(app.appId);
setTimeout(() => setCopiedId((id) => (id === app.appId ? null : id)), 2000);
} catch {
toast.error("复制失败,请手动选择地址");
}
};
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="h-full overflow-y-auto bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-6 px-4 py-7 sm:px-6 lg:px-8">
{/* 标题 */}
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
应用列表
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
点击卡片在新标签页打开应用。地址会自动附带配置的路由参数(登录态 / 主题)。
<div className="flex items-center gap-2.5">
<span className="relative flex size-3">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[#CF1322] opacity-75 dark:bg-[#1677FF]" />
<span className="relative inline-flex size-3 rounded-full bg-[#CF1322] dark:bg-[#1677FF]" />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className="rounded-full border border-[#CF1322]/15 bg-[#FFF1F0] px-2 py-0.5 font-mono text-xs font-medium text-[#CF1322] dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]">
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
</p>
</div>
<div className="relative max-w-sm">
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="pl-9"
/>
{/* 搜索 */}
<div className="rounded-2xl border border-zinc-200/80 bg-white p-5 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="relative w-full">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4.5 -translate-y-1/2 text-zinc-400 dark:text-[#6E88B0]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:border-[#CF1322]/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-[#CF1322]/20 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title="清除搜索"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 transition hover:text-zinc-600 dark:hover:text-[#C5CFE0]"
>
<X className="size-4" />
</button>
)}
</div>
</div>
{/* 结果计数 */}
<div className="-mt-2 flex items-center justify-between">
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
检索到最终结果:{" "}
<span className="font-bold text-[#CF1322] dark:text-[#4096FF]">{apps.length}</span> 个关联应用
</div>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="text-xs font-medium text-[#CF1322] underline hover:text-[#A8071A] dark:text-[#4096FF] dark:hover:text-[#1677FF]"
>
重置过滤条件
</button>
)}
</div>
{/* 卡片网格 / 空态 */}
{apps.length === 0 ? (
<div className={`rounded-3xl border py-20 text-center ${isDark ? "border-[rgba(67,89,124,0.4)] text-slate-500" : "border-[#F0F0F0] text-[#A6A6A6]"}`}>
{isLoading ? "加载中…" : "暂无可用应用"}
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white p-16 text-center shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full border border-zinc-100 bg-zinc-50 text-zinc-300 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
<Search className="size-6" />
</div>
<h4 className="mb-1 text-base font-medium text-zinc-800 dark:text-[#C5CFE0]">
{isLoading ? "正在加载应用…" : search ? "未找到匹配的应用" : "暂无可用应用"}
</h4>
<p className="mx-auto max-w-md text-sm text-zinc-400 dark:text-[#6E88B0]">
{isLoading
? "请稍候。"
: search
? "尝试更换关键词,或清除搜索查看全部应用。"
: "应用由管理员在「应用管理」中登记后会出现在这里。"}
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{pageApps.map((app) => (
<button key={app.appId} type="button" onClick={() => openApp(app)} className={`${cardClass} text-left`}>
<div className="flex items-start gap-3">
<span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${iconWrap}`}>
<ExternalLink className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className={`truncate text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{app.name}
</div>
<div className={`mt-1 line-clamp-2 text-xs leading-relaxed ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{app.description || app.url}
</div>
</div>
</div>
</button>
<AppCard
key={app.appId}
app={app}
copied={copiedId === app.appId}
onCopy={() => void copyUrl(app)}
onOpen={() => openApp(app)}
/>
))}
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={apps.length} onPageChange={setPage} className="pt-2" />
{apps.length > PAGE_SIZE && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={apps.length}
onPageChange={setPage}
className="pt-2"
/>
)}
</>
)}
{/* 页脚 */}
<footer className="mt-auto pt-8 text-center text-xs text-zinc-400 dark:text-[#4A6491]">
应用导航工作台 • 优雅可靠的任务中转入口
</footer>
</div>
</div>
);
}
// ── 单张应用卡 ───────────────────────────────────────────────────────────────
function AppCard({
app,
copied,
onCopy,
onOpen,
}: {
app: LightApp;
copied: boolean;
onCopy: () => void;
onOpen: () => void;
}) {
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
const theme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
const parsedUrl = buildLightAppUrl(app);
const hasParams = (app.routeParams ?? []).some((p) => p.key) && parsedUrl !== app.url;
return (
<div className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-zinc-100 bg-white p-5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition-all duration-300 hover:-translate-y-1 hover:border-zinc-200/90 hover:shadow-[0_12px_24px_rgba(0,0,0,0.04)] dark:border-[#1A2F5A] dark:bg-[#122347] dark:shadow-none dark:hover:border-[#2D4473]">
{/* 上:图标 + 标题 + 描述 */}
<div>
<div className="mb-3.5 flex items-start gap-4">
<div className={cn("rounded-xl border border-white/60 p-3 shadow-sm dark:border-transparent", theme.iconBg)}>
<Icon className="size-5.5" />
</div>
</div>
<h4 className="mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]">
{app.name}
</h4>
<p className="mb-4 line-clamp-2 min-h-[2.5rem] text-xs leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
{app.description || "暂无应用详细描述说明,点击进入下方链接查看详情。"}
</p>
</div>
{/* 下:URL 预览 + 动作 */}
<div className="mt-auto border-t border-zinc-100 pt-4 dark:border-[#1A2F5A]">
<div className="mb-4 rounded-xl border border-zinc-100 bg-zinc-50/70 p-3 dark:border-[#1A2F5A] dark:bg-[#0C1836]">
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
<span>拼接后目标路由终端:</span>
<span className="text-[9px] font-bold uppercase tracking-wider text-[#CF1322] dark:text-[#4096FF]">
自动拼接已激活参
</span>
</div>
<div className="line-clamp-1 select-all break-all font-mono text-[11px] font-medium text-zinc-600 dark:text-[#C5CFE0]">
{parsedUrl || "(地址为空)"}
</div>
{hasParams && (
<div className="mt-1 flex items-center gap-1 text-[9px] text-zinc-400 dark:text-[#6E88B0]">
<span className="inline-block size-1 rounded-full bg-amber-400 dark:bg-[#FAAD14]" />
<span>包含动态拼接参数</span>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={onCopy}
className={cn(
"flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-semibold transition",
copied
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-[#52C41A]/30 dark:bg-[#52C41A]/15 dark:text-[#52C41A]"
: "border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 hover:text-zinc-800 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]",
)}
>
{copied ? (
<>
<Check className="size-3.5" />
<span>已复制!</span>
</>
) : (
<>
<Copy className="size-3.5" />
<span>一键复制URL</span>
</>
)}
</button>
<button
type="button"
onClick={onOpen}
className="flex items-center justify-center gap-1.5 rounded-lg bg-[#CF1322] px-3 py-2 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(207,19,34,0.2)] transition hover:bg-[#A8071A] active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]"
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
</button>
</div>
</div>
</div>
);
@@ -23,9 +23,12 @@ import {
type LightAppType,
} from "@/strategy-components/api/light-apps";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
MOUNT_OPTIONS,
mountLabel,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
interface FormState {
@@ -53,7 +56,7 @@ const newParam = (): LightAppRouteParam => ({
paramType: "login",
source: "accessToken",
customValue: "",
themeValue: "dark-blue",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
});
function formFromApp(app: LightApp): FormState {
@@ -69,7 +72,7 @@ function formFromApp(app: LightApp): FormState {
paramType: p.paramType,
source: p.source ?? "accessToken",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "dark-blue",
themeValue: p.themeValue ?? "",
})),
};
}
@@ -512,10 +515,12 @@ export default function LightAppManagementPage() {
<div className="flex items-center justify-between">
<span className="flex items-center text-[14px] font-medium">
<span>路由参数</span>
<HelpCircle
className="ml-1 h-3.5 w-3.5 text-[#A6A6A6]"
<span
className="ml-1 inline-flex"
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
/>
>
<HelpCircle className="h-3.5 w-3.5 text-[#A6A6A6]" />
</span>
</span>
<button
type="button"
@@ -523,47 +528,50 @@ export default function LightAppManagementPage() {
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>+ 添加路由参数</span>
<span>添加路由参数</span>
</button>
</div>
{form.routeParams.length > 0 ? (
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => (
{form.routeParams.map((p, idx) => {
const themeMap =
p.paramType === "theme" ? parseThemeMap(p.themeValue) : {};
return (
<div
key={`${p.key}-${idx}`}
className="relative flex items-center space-x-2 rounded-sm border border-[#E8E8E8] bg-white p-2"
key={idx}
className="relative rounded-sm border border-[#E8E8E8] bg-white p-2"
>
<div className="flex-1 space-y-1">
<input
type="text"
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="flex items-center space-x-2">
<div className="flex-1">
<input
type="text"
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="w-[110px]">
<select
value={p.paramType}
onChange={(e) =>
patchParam(idx, {
paramType: e.target.value as "login" | "theme",
source:
e.target.value === "login" ? (p.source || "accessToken") : undefined,
})
}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
<option value="login">登录参数</option>
<option value="theme">主题参数</option>
</select>
</div>
<div className="w-[110px]">
<select
value={p.paramType}
onChange={(e) =>
patchParam(idx, {
paramType: e.target.value as "login" | "theme",
source:
e.target.value === "login" ? (p.source || "accessToken") : undefined,
})
}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
<option value="login">登录参数</option>
<option value="theme">主题参数</option>
</select>
</div>
<div className="flex-[1.2] text-[11px]">
{p.paramType === "login" ? (
<div className="flex items-center gap-2">
{p.paramType === "login" && (
<div className="flex flex-[1.2] items-center gap-2 text-[11px]">
<select
value={p.source || "accessToken"}
onChange={(e) => patchParam(idx, { source: e.target.value })}
@@ -587,26 +595,56 @@ export default function LightAppManagementPage() {
/>
)}
</div>
) : (
<input
type="text"
value={p.themeValue || ""}
onChange={(e) => patchParam(idx, { themeValue: e.target.value })}
placeholder="主题值,如 dark-blue"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
)}
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
{/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */}
{p.paramType === "theme" && (
<div className="mt-2 space-y-1.5 rounded-sm bg-[#FAFAFA] p-2">
<p className="text-[11px] leading-relaxed text-[#A6A6A6]">
各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改)
</p>
{APP_THEME_COLORS.map((c) => (
<div key={c.key} className="flex items-center gap-2">
<span className="flex w-[72px] shrink-0 items-center gap-1.5 text-[12px] text-[#575757]">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: c.dot }}
/>
{c.label}
</span>
<span className="shrink-0 font-mono text-[11px] text-[#A6A6A6]">
{c.key} =
</span>
<input
type="text"
value={themeMap[c.key] ?? c.key}
onChange={(e) =>
patchParam(idx, {
themeValue: stringifyThemeMap({
...themeMap,
[c.key]: e.target.value,
}),
})
}
placeholder={c.key}
className="h-7 flex-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
))}
</div>
)}
</div>
))}
);
})}
</div>
) : (
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
@@ -669,7 +707,8 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
base.customValue = (p.customValue ?? "").trim();
}
} else {
base.themeValue = (p.themeValue ?? "").trim();
// theme:规范化为完整映射串(red=..;blue=..;dark-blue=..),运行时按当前主题取值
base.themeValue = stringifyThemeMap(parseThemeMap(p.themeValue));
}
return base;
});
@@ -72,6 +72,7 @@ interface ServerChain {
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -88,6 +89,7 @@ function toChain(s: ServerChain): BusinessChain {
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
@@ -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" });
@@ -16,6 +16,7 @@ import {
LayersIcon,
Loader2Icon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -227,6 +228,8 @@ function ChainRow({
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
@@ -274,6 +277,16 @@ function ChainRow({
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{chain.description && (
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
)}
@@ -0,0 +1,507 @@
/**
* 业务链条「精美流程图」(编辑器第一步右侧预览)。
*
* 把分层编排画成一张可交互的画布:
* - 列 = Stage(层间串行),列内节点 = 席位(同层并行);首列是「总控协调」。
* - 节点之间用**直角折线**(H→V→H 肘形)连接,末端带箭头。
* - 每个「步骤之间」的连线上挂一枚依赖标签 = 目标 Stage 的「本阶段目标」(用户填了才显示)。
* - 点击节点卡片 → `onNodeClick`(外层打开 `AgentDetailSheet` 抽屉看详情)。
* - 画布支持:鼠标滚轮缩放(以光标为锚点)、按住拖动平移、一键适应、全屏。
* - **全屏时**右下角显示缩略图(minimap):整图概览 + 当前视口框,点击可快速定位。
*
* 纯展示 / 交互组件,不持有业务数据;坐标布局在 `useMemo` 里一次算好,缩放平移只改
* 外层 transform,所以重绘极轻。席位配色复用 `getAvatarTypeFor` + `getAvatar`。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Crosshair,
Maximize2,
Minimize2,
Minus,
Plus,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface ChainFlowSeat {
agent_id: string;
name: string;
description?: string;
}
export interface ChainFlowStage {
seats: ChainFlowSeat[];
/** 「本阶段目标」——作为进入本阶段那根连线的依赖标签;空 = 不显示。 */
goal?: string | null;
}
export interface ChainFlowDiagramProps {
stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void;
className?: string;
}
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
const NODE_W = 206;
const NODE_H = 84;
const COL_GAP = 116;
const ROW_GAP = 22;
const PAD = 52;
const COORDINATOR_ID = "__coordinator__";
// 节点卡左侧描边(hex,内联上色避免被 border-border 覆盖)/ 角色行配色(按头像配色)。
// 参考「业务链条配置器」设计:彩色左描边 + 同色角色行。
const NODE_COLORS: Record<string, { hex: string; role: string }> = {
blue: { hex: "#3b82f6", role: "text-blue-600 dark:text-blue-300" },
purple: { hex: "#a855f7", role: "text-purple-600 dark:text-purple-300" },
emerald: { hex: "#10b981", role: "text-emerald-600 dark:text-emerald-300" },
amber: { hex: "#f59e0b", role: "text-amber-600 dark:text-amber-300" },
pink: { hex: "#ec4899", role: "text-pink-600 dark:text-pink-300" },
human: { hex: "#8b5cf6", role: "text-violet-600 dark:text-violet-300" },
};
const nodeColorOf = (t: string) =>
NODE_COLORS[t] ?? { hex: "#94a3b8", role: "text-muted-foreground" };
const MIN_SCALE = 0.25;
const MAX_SCALE = 2.6;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
interface PlacedNode {
id: string;
name: string;
description?: string;
avatarType: string;
x: number;
y: number;
cy: number;
leftX: number;
rightX: number;
}
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => {
// 列:总控 + 各 Stage。
const rawCols: Array<{ kind: "coordinator" | "stage"; goal: string | null; nodes: Omit<PlacedNode, "x" | "y" | "cy" | "leftX" | "rightX">[] }> = [];
rawCols.push({
kind: "coordinator",
goal: null,
nodes: [{ id: COORDINATOR_ID, name: "总控协调", avatarType: "human" }],
});
let order = 0;
for (const st of stages) {
const nodes = st.seats.map((s) => ({
id: s.agent_id,
name: s.name || s.agent_id,
description: s.description,
avatarType: getAvatarTypeFor(s.agent_id, order++),
}));
rawCols.push({ kind: "stage", goal: (st.goal ?? "").trim() || null, nodes });
}
const heights = rawCols.map((c) => c.nodes.length * NODE_H + Math.max(0, c.nodes.length - 1) * ROW_GAP);
const contentH = Math.max(NODE_H, ...heights);
const worldW = PAD * 2 + rawCols.length * NODE_W + Math.max(0, rawCols.length - 1) * COL_GAP;
const worldH = PAD * 2 + contentH;
const centerY = PAD + contentH / 2;
const cols = rawCols.map((c, k) => {
const x = PAD + k * (NODE_W + COL_GAP);
const h = heights[k];
const yStart = PAD + (contentH - h) / 2;
const nodes: PlacedNode[] = c.nodes.map((n, i) => {
const y = yStart + i * (NODE_H + ROW_GAP);
return { ...n, x, y, cy: y + NODE_H / 2, leftX: x, rightX: x + NODE_W };
});
return { ...c, nodes };
});
const edges: Array<{ id: string; d: string }> = [];
const labels: Array<{ id: string; x: number; y: number; text: string }> = [];
for (let k = 0; k < cols.length - 1; k++) {
const a = cols[k];
const b = cols[k + 1];
const midX = (a.nodes[0].rightX + b.nodes[0].leftX) / 2;
for (const na of a.nodes) {
for (const nb of b.nodes) {
edges.push({
id: `${k}:${na.id}->${nb.id}`,
// 直角折线:右出 → 到中线 → 竖直 → 进左侧(尖角,不抹圆)。
d: `M ${na.rightX} ${na.cy} H ${midX} V ${nb.cy} H ${nb.leftX}`,
});
}
}
if (b.goal) labels.push({ id: `lbl-${k}`, x: midX, y: centerY, text: b.goal });
}
const allNodes = cols.flatMap((c) => c.nodes);
return { cols, edges, labels, worldW, worldH, allNodes };
}, [stages]);
// ── 视口 / 缩放平移状态 ──────────────────────────────────────────────────
const viewportRef = useRef<HTMLDivElement | null>(null);
const [vp, setVp] = useState({ w: 0, h: 0 });
const [view, setView] = useState({ scale: 1, tx: 0, ty: 0 });
const [fullscreen, setFullscreen] = useState(false);
const [grabbing, setGrabbing] = useState(false);
const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
// 视口尺寸跟踪(全屏切换会换挂载节点,故依赖 fullscreen 重新绑定)。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setVp({ w: el.clientWidth, h: el.clientHeight });
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [fullscreen]);
// 适应视图:把整图缩放居中到视口内。
const fit = useCallback(() => {
if (!vp.w || !vp.h) return;
const s = clamp(Math.min(vp.w / layout.worldW, vp.h / layout.worldH) * 0.9, MIN_SCALE, 1.4);
setView({ scale: s, tx: (vp.w - layout.worldW * s) / 2, ty: (vp.h - layout.worldH * s) / 2 });
}, [vp.w, vp.h, layout.worldW, layout.worldH]);
// 视口尺寸 / 图结构 / 全屏变化时自动适应一次。
useEffect(() => {
fit();
}, [fit]);
// 全屏时 Esc 退出。
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
// 滚轮缩放(以光标为锚点)。native 监听以便 preventDefault。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setView((v) => {
const next = clamp(v.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12), MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
return { scale: next, tx: mx - k * (mx - v.tx), ty: my - k * (my - v.ty) };
});
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [fullscreen]);
// 以视口中心为锚点的按钮缩放。
const zoomBy = useCallback(
(f: number) => {
setView((v) => {
const next = clamp(v.scale * f, MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
const cx = vp.w / 2;
const cy = vp.h / 2;
return { scale: next, tx: cx - k * (cx - v.tx), ty: cy - k * (cy - v.ty) };
});
},
[vp.w, vp.h],
);
// ── 缩略图(minimap,仅全屏)─────────────────────────────────────────────
const MM_W = 196;
const MM_H = 132;
const mmScale = Math.min(MM_W / layout.worldW, MM_H / layout.worldH);
const mmW = layout.worldW * mmScale;
const mmH = layout.worldH * mmScale;
// 当前视口在世界坐标里的矩形(再乘 mmScale 投到缩略图)。
const viewRect = {
x: (-view.tx / view.scale) * mmScale,
y: (-view.ty / view.scale) * mmScale,
w: (vp.w / view.scale) * mmScale,
h: (vp.h / view.scale) * mmScale,
};
const onMinimapClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const wx = (e.clientX - rect.left) / mmScale;
const wy = (e.clientY - rect.top) / mmScale;
setView((v) => ({ ...v, tx: vp.w / 2 - wx * v.scale, ty: vp.h / 2 - wy * v.scale }));
},
[mmScale, vp.w, vp.h],
);
// ── 画布渲染 ─────────────────────────────────────────────────────────────
const canvas = (
<div
ref={viewportRef}
className={cn(
"relative h-full w-full overflow-hidden bg-white outline-none dark:bg-[#0C1836]",
grabbing ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundImage:
"radial-gradient(circle, rgba(148,163,184,0.18) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
panRef.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
setGrabbing(true);
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerMove={(e) => {
const p = panRef.current;
if (!p) return;
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.x), ty: p.ty + (e.clientY - p.y) }));
}}
onPointerUp={() => {
panRef.current = null;
setGrabbing(false);
}}
onPointerCancel={() => {
panRef.current = null;
setGrabbing(false);
}}
>
{/* 世界层:所有节点 / 连线随 transform 一起缩放平移 */}
<div
className="absolute left-0 top-0 origin-top-left"
style={{
width: layout.worldW,
height: layout.worldH,
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
}}
>
<svg
width={layout.worldW}
height={layout.worldH}
className="pointer-events-none absolute inset-0 overflow-visible text-slate-300 dark:text-[#3a4d72]"
>
<defs>
<marker
id="cfd-arrow"
markerWidth="9"
markerHeight="9"
refX="6.5"
refY="3"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L6.5,3 L0,6 Z" fill="currentColor" />
</marker>
</defs>
{layout.edges.map((edge) => (
<path
key={edge.id}
d={edge.d}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
markerEnd="url(#cfd-arrow)"
/>
))}
</svg>
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
{layout.cols.flatMap((col, colIdx) =>
col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID;
const nc = nodeColorOf(n.avatarType);
return (
<div
key={n.id}
className={cn("absolute", !isCoordinator && "cursor-pointer")}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
if (isCoordinator) return;
// 全屏时先退出(详情抽屉 z-50 会被 z-[100] 全屏层盖住),再打开抽屉。
if (fullscreen) setFullscreen(false);
onNodeClick?.(n.id, n.name);
}}
>
<div
className={cn(
"flex h-full w-full flex-col justify-between rounded-[5px] border bg-card p-3 shadow-[0_2px_10px_rgba(15,23,42,0.05)] transition",
isCoordinator
? "border-border bg-primary/[0.04]"
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
)}
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
>
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
<div className="flex items-start gap-2 overflow-hidden">
<div className="shrink-0">{getAvatar(n.avatarType, "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[11px] font-bold text-foreground">{n.name}</span>
<span className="shrink-0 rounded border border-border bg-muted px-1 py-0.5 text-[7.5px] font-bold uppercase leading-none text-muted-foreground">
{isCoordinator ? "核心" : `L-${colIdx}`}
</span>
</div>
<p className={cn("mt-0.5 truncate text-[9px] font-medium", nc.role)}>
{isCoordinator ? "按阶段派活 · 综合收口" : n.description || n.id}
</p>
</div>
</div>
{/* 下:身份 + Ready 心跳条 */}
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
</div>
</div>
</div>
);
}),
)}
{/* 依赖标签(步骤之间的连线上) */}
{layout.labels.map((l) => (
<div
key={l.id}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: l.x, top: l.y, maxWidth: COL_GAP + 40 }}
>
<div
className="line-clamp-3 rounded-md border border-primary/30 bg-card px-2 py-1 text-center text-[10px] font-medium leading-snug text-primary shadow-sm"
title={l.text}
>
{l.text}
</div>
</div>
))}
</div>
{/* 左上状态条(参考设计的「拓扑画布」浮条)。全屏时让位给退出按钮。 */}
{!fullscreen && (
<div className="absolute left-3 top-3 flex items-center gap-2 rounded-full border border-border bg-card/90 px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-sm backdrop-blur">
<span className="size-1.5 animate-pulse rounded-full bg-primary" />
<span className="font-semibold text-foreground">拓扑画布</span>
<span className="h-3 w-px bg-border" />
<span>节点 {layout.allNodes.length}</span>
</div>
)}
{/* 工具条:缩小 / 放大 / 适应 / 全屏 */}
<div className="absolute right-3 top-3 flex flex-col gap-1.5">
<ToolButton title="放大" onClick={() => zoomBy(1.2)}>
<Plus className="size-4" />
</ToolButton>
<ToolButton title="缩小" onClick={() => zoomBy(1 / 1.2)}>
<Minus className="size-4" />
</ToolButton>
<ToolButton title="适应窗口" onClick={fit}>
<Crosshair className="size-4" />
</ToolButton>
<ToolButton
title={fullscreen ? "退出全屏" : "全屏"}
onClick={() => setFullscreen((f) => !f)}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</ToolButton>
</div>
{/* 缩放比例徽标 */}
<div className="absolute bottom-3 left-3 select-none rounded-md border border-border bg-card/90 px-2 py-0.5 text-[10px] font-semibold tabular-nums text-muted-foreground shadow-sm">
{Math.round(view.scale * 100)}%
</div>
{/* 全屏退出按钮(左上) */}
{fullscreen && (
<button
type="button"
onClick={() => setFullscreen(false)}
title="退出全屏(Esc)"
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
退出全屏
</button>
)}
{/* 缩略图:仅全屏时右下角 */}
{fullscreen && (
<div className="absolute bottom-3 right-3 rounded-lg border border-border bg-card/95 p-1.5 shadow-lg backdrop-blur">
<div
className="relative cursor-pointer overflow-hidden rounded bg-muted/40"
style={{ width: mmW, height: mmH }}
onClick={onMinimapClick}
>
{/* 节点缩略块 */}
{layout.allNodes.map((n) => (
<div
key={n.id}
className={cn(
"absolute rounded-[1px]",
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
))}
{/* 当前视口框 */}
<div
className="absolute border-2 border-primary bg-primary/10"
style={{
left: clamp(viewRect.x, 0, mmW),
top: clamp(viewRect.y, 0, mmH),
width: clamp(viewRect.w, 4, mmW),
height: clamp(viewRect.h, 4, mmH),
}}
/>
</div>
</div>
)}
</div>
);
// 全屏:固定定位铺满视口(position:fixed 自带脱离文档流,无需 portal)。
if (fullscreen) {
return <div className="fixed inset-0 z-[100] bg-background">{canvas}</div>;
}
return <div className={cn("relative", className)}>{canvas}</div>;
}
// ── 工具按钮 ────────────────────────────────────────────────────────────────
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex size-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground active:scale-95"
>
{children}
</button>
);
}
@@ -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>
);
};
@@ -2,7 +2,7 @@
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
* 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮
* 1) 顶部 Headline + 推理模型下拉 + 暂停/恢复按钮 + 后台挂起按钮(查看历史时隐藏)
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
@@ -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 {
@@ -155,6 +149,8 @@ export interface Step2PanelProps {
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
}
@@ -200,6 +196,7 @@ export function Step2Panel(props: Step2PanelProps) {
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
} = props;
@@ -275,8 +272,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 +298,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 +340,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);
@@ -409,6 +465,19 @@ export function Step2Panel(props: Step2PanelProps) {
暂停会商
</button>
)}
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
>
<Network className="w-3.5 h-3.5" />
后台挂起
</button>
)}
</div>
</div>
@@ -430,43 +499,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 && (
@@ -718,21 +781,6 @@ export function Step2Panel(props: Step2PanelProps) {
干预并发送
</button>
{SHOW_BACKGROUND_SUSPEND && (
<>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
后台挂起
</button>
</>
)}
<button
type="button"
onClick={onProceedToStep3}
@@ -0,0 +1,453 @@
/**
* Step3「结果绘制 · 大屏多页」—— 两视图:
*
* ① 交互区 (view="chat"):左侧大屏绘制智能体的聊天记录 + 输入框(模型问答改页面),
* 右侧复用 `Step2SandboxLayout` 沙箱「代码预览」实时展示数据抽取过程(report-json
* 边出边以代码态显示)。生成完成自动切到 ②。
* ② 大屏页面 (view="page"):应用内直接渲染参考大屏 React 组件(`DashboardApp`,真实
* 数据经 `adaptReportData` 注入)。顶部按钮:重新绘制(回交互区并重画)、新窗口打开
* (把自包含 HTML 大屏在新标签页打开)、单页看板、返回报告。
*
* 数据抽取/聊天/沙箱链路全部复用既有 `useStep3Dashboard`(流式问答、stub 沙箱、续看),
* 只是把「完成后的产物」从 iframe HTML 预览换成应用内 React 大屏;HTML 沙箱那条路保留,
* 供「新窗口打开」与并行的经典模式使用。
*/
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import {
ArrowLeft,
CheckCircle,
ExternalLink,
LayoutPanelTop,
Loader2,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
} from "lucide-react";
import { useLocalSettings } from "@/core/settings";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
interface Step3DashboardModeProps {
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
savedReport?: DraftStep3Snapshot | null;
dashboard: UseStep3DashboardReturn;
/** 切回经典「单页看板」绘制模式。 */
onSwitchClassic: () => void;
/** 返回最终方案展示页。 */
onBack: () => 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())}`;
}
type DashView = "chat" | "page";
export function Step3DashboardMode({
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
dashboard,
onSwitchClassic,
onBack,
}: Step3DashboardModeProps) {
const {
phase,
dialogues,
seatStubMessages,
activeArtifactThreadId,
html,
busy,
redraw,
ask,
abort,
setPreviewEnabled,
openDashboardPreview,
} = dashboard;
// 真实数据 → 大屏数据:优先本轮实时生成,其次草稿快照;都没有则 undefined(用 DEMO)。
const dashboardData = useMemo(() => {
const report = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return report ? adaptReportData(report, formatNow()) : undefined;
}, [dashboard.data, savedReport?.dashboardJson]);
const hasResult = !!dashboardData || !!html;
// 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。
const [view, setView] = useState<DashView>(() => (hasResult ? "page" : "chat"));
// 完成即自动切到大屏页面(含问答修订后的再次完成)。
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (phase === "done" && prevPhaseRef.current !== "done") setView("page");
prevPhaseRef.current = phase;
}, [phase]);
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
useEffect(() => {
setPreviewEnabled(view === "chat");
return () => setPreviewEnabled(false);
}, [view, setPreviewEnabled]);
// 新窗口打开:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏
// (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。
const handleOpenNewWindow = () => {
if (typeof window === "undefined") return;
const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`;
try {
// 先清掉历史键,避免 localStorage 堆积;再写本次数据。
for (let i = window.localStorage.length - 1; i >= 0; i -= 1) {
const k = window.localStorage.key(i);
if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k);
}
if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData));
} catch {
// localStorage 不可用(隐私模式/超额):忽略,新窗口将回落 DEMO 数据。
}
// HashRouter:在当前页基址上拼 hash 路由,带上数据键。
const base = `${window.location.origin}${window.location.pathname}`;
const url = `${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`;
window.open(url, "_blank", "noopener,noreferrer");
};
const handleRedraw = () => {
setView("chat");
redraw();
};
const isStreaming =
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
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 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-4.5 h-4.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" />}
{view === "chat" ? "交互绘制中" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
{view === "page" ? (
<>
<button
type="button"
onClick={handleRedraw}
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"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={handleOpenNewWindow}
title="在新标签页打开 React 大屏页面(全屏,无侧边栏)"
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"
>
<ExternalLink className="w-3.5 h-3.5 text-slate-500" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
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"
>
<LayoutPanelTop className="w-3.5 h-3.5 text-slate-500" />
查看大屏
</button>
)
)}
<button
type="button"
onClick={onSwitchClassic}
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"
>
<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>
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
{view === "page" ? (
<div className="flex-1 min-h-0 overflow-auto">
<DashboardApp data={dashboardData} />
</div>
) : (
<div className="flex-1 min-h-0 p-3">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isStreaming}
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
>
<DashboardChatPanel
phase={phase}
dialogues={dialogues}
busy={busy}
error={dashboard.error}
onAsk={ask}
onRedraw={redraw}
onPause={abort}
onOpenPreview={openDashboardPreview}
hasResult={hasResult}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
</section>
</div>
)}
</div>
);
}
// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ──────────────────────────
function DashboardChatPanel({
phase,
dialogues,
busy,
error,
onAsk,
onRedraw,
onPause,
onOpenPreview,
hasResult,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3DashboardReturn["phase"];
dialogues: UseStep3DashboardReturn["dialogues"];
busy: boolean;
error: string | null;
onAsk: (message: string) => void;
onRedraw: () => void;
onPause: () => void;
onOpenPreview: () => void;
hasResult: boolean;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [text, setText] = useState("");
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{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={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
数据已就绪,已切换到大屏页面。
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -14,8 +14,11 @@ 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";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
@@ -42,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;
@@ -66,6 +73,9 @@ export function Step3Panel(props: Step3PanelProps) {
coordinatorPrompt,
savedSummary,
onSummarySaved,
summaryFlow,
reportFlow,
dashboardFlow,
onReset,
openArtifactPreview,
seatStubMessages,
@@ -76,7 +86,9 @@ export function Step3Panel(props: Step3PanelProps) {
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
content: String(d.content ?? ""),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
}));
const seatNameMap = Object.fromEntries(
@@ -101,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;
@@ -0,0 +1,170 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*
* 大屏主视图(源自参考 App.tsx)。2026-06-13 改造:
* - 接受 `data` prop(圆桌真实数据,adaptReportData 产出),用 DashboardDataProvider 注入;
* - 标题/副标题/导航 Tab 由数据动态生成(原为写死的 6 个固定席位);
* - 不传 data 时回落参考 demo(DEMO_DATA)。
*/
import './dashboard.css';
import { useEffect, useState } from 'react';
import { TabType } from './types';
import {
DashboardDataProvider,
DEMO_DATA,
type DashboardDataValue,
} from './data-context';
import { MetricsHeader } from './components/MetricsHeader';
import { ConsensusPanel } from './components/ConsensusPanel';
import { BrandScores } from './components/BrandScores';
import { RiskDonut } from './components/RiskDonut';
import { SeatDeliveryOverview } from './components/SeatDeliveryOverview';
import { Roadmap } from './components/Roadmap';
import { AnalyticsCenter } from './components/AnalyticsCenter';
import { DetailView } from './components/DetailView';
import { motion, AnimatePresence } from 'motion/react';
import {
Compass, LayoutGrid, Users, Megaphone, ShieldAlert, FileText,
Clock, Activity,
} from 'lucide-react';
const TAB_ICONS = [LayoutGrid, Users, Megaphone, ShieldAlert, FileText, Activity];
export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// 数据切换(切历史记录/重绘 → data 引用变化)时复位到总览:避免停留在上一份数据
// 钻入的某个席位详情页,而新数据里无同名席位导致「加载席位数据失败」。
useEffect(() => {
setActiveTab('overview');
}, [data]);
// 导航 Tab:总览 + 各席位(由数据动态生成,原参考为写死的固定 6 项)。
const tabs: TabType[] = ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
const getTabLabel = (tab: TabType) =>
tab === 'overview' ? '总览' : data.SEAT_CARDS.find((c) => c.id === tab)?.title ?? tab;
const getTabIcon = (tab: TabType) => {
const cls = 'w-4 h-4';
if (tab === 'overview') return <Compass className={cls} />;
const idx = data.SEAT_CARDS.findIndex((c) => c.id === tab);
const Ico = TAB_ICONS[(idx >= 0 ? idx : 0) % TAB_ICONS.length];
return <Ico className={cls} />;
};
return (
<DashboardDataProvider value={data}>
<div className="dash-root min-h-screen bg-white dark:bg-[var(--dash-page)] font-sans text-slate-800 dark:text-[var(--dash-text)] relative pb-16 [background-image:radial-gradient(#f1f5f9_1.2px,transparent_1.2px)] dark:[background-image:radial-gradient(#1a2f5a_1.2px,transparent_1.2px)] [background-size:20px_20px]">
{/* 顶部彩条 */}
<div className="h-1 bg-gradient-to-r from-rose-600 via-amber-500 to-sky-600 dark:from-[var(--dash-primary)] dark:via-[var(--dash-accent)] dark:to-[#4096ff] w-full" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 space-y-6">
{/* Header */}
<header className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black tracking-tight font-display flex items-center gap-1.5 text-rose-900 dark:text-[var(--dash-text-strong)]">
{data.meta.title}
</h1>
</div>
{data.meta.subtitle && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide">
{data.meta.subtitle}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
</header>
{/* 导航 Tab */}
<nav className="flex items-center justify-start overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex space-x-1.5 bg-slate-100/80 dark:bg-[var(--dash-elev)] backdrop-blur-xs p-1 border border-slate-200 dark:border-[var(--dash-border)] rounded-md shrink-0">
{tabs.map((tab) => {
const active = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-sm transition-all duration-200 cursor-pointer whitespace-nowrap ${
active
? 'bg-white dark:bg-[var(--dash-card)] text-rose-700 dark:text-[var(--dash-primary)] shadow-sm border border-rose-100 dark:border-[var(--dash-primary)] font-bold scale-[1.01]'
: 'text-slate-600 dark:text-[var(--dash-text-sec)] hover:text-slate-900 dark:hover:text-[var(--dash-text-strong)] hover:bg-slate-50 dark:hover:bg-[var(--dash-card)]'
}`}
>
{getTabIcon(tab)}
<span>{getTabLabel(tab)}</span>
</button>
);
})}
</div>
</nav>
{/* Body */}
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
<motion.div
key="overview-dashboard"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
className="space-y-6"
>
<MetricsHeader />
{/* 上半部分:总控最终共识 + 多维核心竞争力排行(核心风险分布下移到数据研判中心之后) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<div className="lg:col-span-7 h-full">
<ConsensusPanel onExploreClick={() => setActiveTab(tabs[tabs.length - 1] ?? 'overview')} />
</div>
<div className="lg:col-span-5 h-full">
<BrandScores />
</div>
</div>
<AnalyticsCenter />
{/* 核心风险分类分布:移到数据研判中心下方,整行展示 */}
<RiskDonut />
<SeatDeliveryOverview onSeatSelect={setActiveTab} activeSeatId={activeTab} />
<Roadmap />
</motion.div>
) : (
<motion.div
key="seat-details"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
>
<DetailView
activeTab={activeTab}
onBackToOverview={() => setActiveTab('overview')}
onNavigate={setActiveTab}
/>
</motion.div>
)}
</AnimatePresence>
<footer className="pt-8 border-t border-slate-200/60 dark:border-[var(--dash-border)] mt-12 text-center text-xs text-slate-400 dark:text-[var(--dash-text-dim)] font-sans flex flex-col sm:flex-row items-center justify-between gap-4">
<span>圆桌多智能体研讨 · 方案大屏</span>
<div className="flex gap-4 font-mono text-[10px] tracking-wider uppercase">
<span>Engineering Grid: True</span>
<span>Roundtable Console</span>
</div>
</footer>
</div>
</div>
</DashboardDataProvider>
);
}
@@ -0,0 +1,286 @@
/**
* 数据研判中心 —— 读 context.CHARTS(最多 3 个),按 type 通用渲染(line/bar/donut/radar)。
* 参考项目原是写死的 3 张新能源图表;这里改成数据驱动,SVG 数学沿用参考/report-template
* 的画法(折线带渐变面积、环形分段、雷达多边形、水平条),雷达/环形的标签走 HTML 旁注
* 避免 SVG 文字溢出。
*/
import { useState } from "react";
import { useDashboardData, ACCENTS } from "../data-context";
import { DashChart } from "../types";
import { DraftingCard } from "./DraftingCard";
import { Activity, TrendingUp, PieChart, Award, BarChart3 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
const PRIMARY = ACCENTS[0];
// ── 折线(趋势):单序列,归一化到画布 ──────────────────────────────────────────────
function LineChartSvg({ items }: { items: DashChart["items"] }) {
const [hover, setHover] = useState<number | null>(null);
const W = 460;
const H = 180;
const padL = 36;
const padR = 16;
const padT = 16;
const padB = 30;
const data = items.slice(0, 10);
const max = Math.max(...data.map((i) => i.value), 1);
const min = Math.min(...data.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / Math.max(1, data.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = data.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
return (
<div className="relative w-full h-[180px] bg-slate-50/30 dark:bg-[var(--dash-inset)] rounded border border-slate-100 dark:border-[var(--dash-border)] p-1 flex items-center justify-center">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-full">
{[0, 0.25, 0.5, 0.75, 1].map((k, i) => {
const gy = padT + k * (H - padT - padB);
return <line key={i} x1={padL} y1={gy} x2={W - padR} y2={gy} stroke="#F1F5F9" strokeWidth="1.2" strokeDasharray="2 2" />;
})}
<polygon
points={`${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}`}
fill={`${PRIMARY}14`}
/>
<polyline points={pts} fill="none" stroke={PRIMARY} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
{data.map((it, i) => {
const isH = hover === i;
return (
<g key={i}>
{isH && <line x1={x(i)} y1={padT} x2={x(i)} y2={H - padB} stroke="#E2E8F0" strokeWidth="1" />}
<circle
cx={x(i)}
cy={y(it.value)}
r={isH ? 5.5 : 3.5}
fill="#fff"
stroke={PRIMARY}
strokeWidth="2"
className="cursor-pointer transition-all"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
/>
<text x={x(i)} y={H - 8} textAnchor="middle" className={`text-[8.5px] font-mono ${isH ? "fill-slate-900" : "fill-slate-400"}`}>
{it.label}
</text>
</g>
);
})}
</svg>
<AnimatePresence>
{hover !== null && (
<motion.div
initial={{ opacity: 0, scale: 0.93, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.93, y: 10 }}
className="absolute bg-slate-950/90 text-white rounded p-2 text-[10px] space-y-1 z-20 pointer-events-none border border-slate-700 shadow-lg left-1/2 -translate-x-1/2 bottom-12"
>
<div className="font-bold text-amber-400 border-b border-slate-800 pb-1">{data[hover].label}</div>
<div className="font-mono font-bold text-sky-400">{data[hover].value}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ── 环形(占比) ───────────────────────────────────────────────────────────────────
function DonutChartSvg({ items }: { items: DashChart["items"] }) {
const [hover, setHover] = useState<number | null>(null);
const data = items.slice(0, 6);
const total = data.reduce((s, i) => s + Math.max(0, i.value), 0) || 1;
const R = 50;
const SW = 14;
const C = 2 * Math.PI * R;
let acc = 0;
const segs = data.map((it, idx) => {
const len = (Math.max(0, it.value) / total) * C;
const seg = { len, offset: -acc, color: ACCENTS[idx % ACCENTS.length], idx };
acc += len;
return seg;
});
return (
<div className="flex flex-row items-center justify-between gap-2.5 h-[180px]">
<div className="relative w-36 h-36 flex items-center justify-center shrink-0">
<svg viewBox="0 0 140 140" className="w-full h-full select-none">
<circle cx="70" cy="70" r={R} fill="none" stroke="#E2E8F0" strokeWidth={SW} transform="rotate(-90 70 70)" />
{segs.map((s) => (
<circle
key={s.idx}
cx="70"
cy="70"
r={R}
fill="none"
stroke={s.color}
strokeWidth={hover === s.idx ? SW + 3 : SW}
strokeDasharray={`${s.len.toFixed(2)} ${(C - s.len).toFixed(2)}`}
strokeDashoffset={s.offset.toFixed(2)}
transform="rotate(-90 70 70)"
className="cursor-pointer transition-all duration-300"
onMouseEnter={() => setHover(s.idx)}
onMouseLeave={() => setHover(null)}
/>
))}
<text x="70" y="68" textAnchor="middle" className="fill-slate-800 text-[22px] font-extrabold font-mono">
{data.length}
</text>
<text x="70" y="82" textAnchor="middle" className="fill-slate-400 text-[9px]">
类
</text>
</svg>
</div>
<div className="flex-1 space-y-2 max-h-[170px] overflow-y-auto pr-1">
{data.map((item, idx) => (
<div
key={item.label}
className={`flex items-center justify-between p-1 rounded-sm cursor-pointer transition-all border-l-[3px] ${
hover === idx ? "bg-slate-50 dark:bg-[var(--dash-elev)] font-bold" : "border-transparent"
}`}
style={{ borderLeftColor: hover === idx ? ACCENTS[idx % ACCENTS.length] : undefined }}
onMouseEnter={() => setHover(idx)}
onMouseLeave={() => setHover(null)}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: ACCENTS[idx % ACCENTS.length] }} />
<span className="text-[10px] text-slate-700 dark:text-[var(--dash-text)] truncate">{item.label}</span>
</div>
<span className="text-[10.5px] font-bold font-mono text-slate-900 dark:text-[var(--dash-text-strong)]">{item.value}</span>
</div>
))}
</div>
</div>
);
}
// ── 雷达(多维):标签 HTML 旁注 ─────────────────────────────────────────────────────
function RadarChartSvg({ items }: { items: DashChart["items"] }) {
const data = items.slice(0, 8);
const n = Math.max(data.length, 3);
const cx = 100;
const cy = 100;
const R = 65;
const max = Math.max(...data.map((i) => i.value), 1);
const pt = (i: number, r: number): [number, number] => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
const ptS = (i: number, r: number) => pt(i, r).map((v) => v.toFixed(1)).join(",");
return (
<div className="flex items-center gap-2">
<div className="relative w-40 h-40 flex items-center justify-center p-1 bg-slate-50/40 dark:bg-[var(--dash-inset)] rounded border border-slate-100 dark:border-[var(--dash-border)] shrink-0">
<svg viewBox="0 0 200 200" className="w-40 h-40">
{[0.25, 0.5, 0.75, 1].map((k, i) => (
<polygon
key={i}
points={Array.from({ length: n }, (_, idx) => ptS(idx, R * k)).join(" ")}
fill="none"
stroke="#E2E8F0"
strokeWidth="1"
/>
))}
{Array.from({ length: n }, (_, i) => {
const [px, py] = pt(i, R);
return <line key={i} x1={cx} y1={cy} x2={px.toFixed(1)} y2={py.toFixed(1)} stroke="#E2E8F0" strokeWidth="1" />;
})}
<polygon
points={data.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}
fill={`${PRIMARY}26`}
stroke={PRIMARY}
strokeWidth="2"
/>
{data.map((it, i) => {
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
return <circle key={i} cx={px.toFixed(1)} cy={py.toFixed(1)} r="2.6" fill={PRIMARY} />;
})}
</svg>
</div>
<div className="flex-1 space-y-1 text-[10px]">
{data.map((it, i) => (
<div key={i} className="flex justify-between items-center gap-1 border-b border-slate-50 dark:border-[var(--dash-border)] pb-0.5">
<span className="text-slate-500 dark:text-[var(--dash-text-sec)] truncate">{it.label}</span>
<b className="font-mono text-slate-800 dark:text-[var(--dash-text)]">{it.value}</b>
</div>
))}
</div>
</div>
);
}
// ── 水平条(排名) ──────────────────────────────────────────────────────────────────
function BarChartSvg({ items }: { items: DashChart["items"] }) {
const data = items.slice(0, 8);
const max = Math.max(...data.map((i) => i.value), 1);
return (
<div className="space-y-2.5 py-1">
{data.map((it, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10.5px]">
<span className="text-slate-600 dark:text-[var(--dash-text-sec)] truncate">{it.label}</span>
<b className="font-mono text-slate-900 dark:text-[var(--dash-text-strong)]">{it.value}</b>
</div>
<div className="w-full h-2 bg-slate-100 dark:bg-[var(--dash-elev)] rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: ACCENTS[i % ACCENTS.length] }}
initial={{ width: 0 }}
animate={{ width: `${(it.value / max) * 100}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
</div>
</div>
))}
</div>
);
}
function ChartCard({ chart }: { chart: DashChart }) {
const Icon = chart.type === "line" ? TrendingUp : chart.type === "donut" ? PieChart : chart.type === "radar" ? Award : BarChart3;
return (
<DraftingCard className="flex flex-col">
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-slate-100 dark:border-[var(--dash-border)] pb-2.5">
<div className="p-1.5 bg-rose-50/80 dark:bg-[var(--dash-primary-soft)] border border-rose-100 dark:border-[var(--dash-border)] rounded-sm text-rose-600 dark:text-[var(--dash-primary)]">
<Icon className="w-3.5 h-3.5" />
</div>
<h4 className="text-xs font-black text-slate-800 dark:text-[var(--dash-text)] tracking-wide font-sans">{chart.title || "图表"}</h4>
</div>
{chart.type === "line" ? (
<LineChartSvg items={chart.items} />
) : chart.type === "donut" ? (
<DonutChartSvg items={chart.items} />
) : chart.type === "radar" ? (
<RadarChartSvg items={chart.items} />
) : (
<BarChartSvg items={chart.items} />
)}
</div>
</DraftingCard>
);
}
export function AnalyticsCenter() {
const { CHARTS } = useDashboardData();
if (CHARTS.length === 0) return null;
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100/90 dark:border-[var(--dash-border)]">
<h3 className="text-base font-extrabold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
<Activity className="w-5 h-5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
数据研判中心
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans hidden sm:inline">多维度数据可视化研判</span>
</h3>
<span className="text-[10px] px-2 py-0.5 bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] text-slate-500 dark:text-[var(--dash-text-sec)] font-mono rounded">
STATUS: REAL-TIME CONSOLE
</span>
</div>
<div className={`grid grid-cols-1 gap-5 ${CHARTS.length >= 3 ? "lg:grid-cols-3" : CHARTS.length === 2 ? "lg:grid-cols-2" : ""}`}>
{CHARTS.map((chart, i) => (
<ChartCard key={i} chart={chart} />
))}
</div>
</div>
);
}
@@ -0,0 +1,249 @@
/**
* 多维核心竞争力排行榜 —— 读 context.BRAND_SCORES(真实数据由 bar/radar 图表或各席位
* 达成度推导)。marketShare/strengths/vehicles 真实数据常缺省,按存在性条件渲染:
* - 仅当有任一 brand 带 marketShare 时显示“综合评分/市场份额”切换与份额列;
* - 展开抽屉里 strengths/vehicles 为空则给占位说明。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { BrandScore } from "../types";
import { motion, AnimatePresence } from "motion/react";
import { Trophy, ChevronRight, Zap, Car } from "lucide-react";
interface BrandScoresProps {
onBrandSelect?: (brand: BrandScore) => void;
}
const shareNum = (s?: string) => (s ? parseFloat(s.replace("%", "")) || 0 : 0);
export function BrandScores({ onBrandSelect }: BrandScoresProps) {
const { BRAND_SCORES } = useDashboardData();
const hasShare = BRAND_SCORES.some((b) => !!b.marketShare);
const [hoveredBrand, setHoveredBrand] = useState<string | null>(null);
const [selectedBrand, setSelectedBrand] = useState<BrandScore | null>(BRAND_SCORES[0] ?? null);
const [viewType, setViewType] = useState<"score" | "share">("score");
if (BRAND_SCORES.length === 0) {
return (
<DraftingCard className="h-full flex items-center justify-center" id="brand-scores-card">
<span className="text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">暂无可对比的竞争力排行数据。</span>
</DraftingCard>
);
}
const handleBrandClick = (brand: BrandScore) => {
setSelectedBrand(selectedBrand?.name === brand.name ? null : brand);
onBrandSelect?.(brand);
};
const effectiveView = hasShare ? viewType : "score";
const maxShare = Math.max(...BRAND_SCORES.map((b) => shareNum(b.marketShare)), 1);
const sortedBrands = [...BRAND_SCORES].sort((a, b) =>
effectiveView === "score" ? b.score - a.score : shareNum(b.marketShare) - shareNum(a.marketShare),
);
const getRankBadge = (rankIndex: number) => {
if (rankIndex === 0)
return (
<div className="relative flex items-center justify-center w-7 h-7 rounded-sm bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold border border-amber-200 dark:border-amber-500/30 ring-2 ring-amber-100/50">
01
<Trophy className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500 fill-amber-500" />
</div>
);
if (rankIndex === 1)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50 dark:bg-[var(--dash-elev)] text-slate-600 dark:text-[var(--dash-text-sec)] font-mono text-xs font-bold border border-slate-200 dark:border-[var(--dash-border)] ring-2 ring-slate-100/50">
02
</div>
);
if (rankIndex === 2)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-300 font-mono text-xs font-bold border border-rose-200 dark:border-rose-500/30 ring-2 ring-rose-100/30">
03
</div>
);
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50/80 dark:bg-[var(--dash-elev)] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono text-xs font-medium border border-slate-200 dark:border-[var(--dash-border)]">
{String(rankIndex + 1).padStart(2, "0")}
</div>
);
};
return (
<DraftingCard className="h-full flex flex-col justify-between" id="brand-scores-card">
<div>
<div className="flex flex-col md:flex-row md:items-center justify-between pb-4 border-b border-rose-100/85 dark:border-[var(--dash-border)] mb-5 gap-3">
<div className="flex items-center gap-2">
<div className="p-1 px-2 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200 dark:border-[var(--dash-primary)] rounded text-rose-700 dark:text-[var(--dash-primary)]">
<Trophy className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-black text-slate-900 dark:text-[var(--dash-text-strong)] tracking-tight font-sans">多维核心竞争力排行榜</h3>
<p className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-sans tracking-wide">多角度研讨加权估算指数</p>
</div>
</div>
{hasShare && (
<div className="flex bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200/60 dark:border-[var(--dash-border)] p-0.5 rounded-sm select-none shrink-0 self-start md:self-auto">
{(["score", "share"] as const).map((vt) => (
<button
key={vt}
onClick={() => setViewType(vt)}
className={`px-2.5 py-1 text-[11px] font-bold rounded-xs cursor-pointer transition-all ${
viewType === vt ? "bg-white dark:bg-[var(--dash-card)] text-rose-700 dark:text-[var(--dash-primary)] border border-slate-200/40 dark:border-[var(--dash-border)]" : "text-slate-500 dark:text-[var(--dash-text-sec)] hover:text-slate-800 dark:hover:text-[var(--dash-text)]"
}`}
>
{vt === "score" ? "综合评分" : "市场份额"}
</button>
))}
</div>
)}
</div>
<div className="space-y-4">
{sortedBrands.map((brand, index) => {
const isSelected = selectedBrand?.name === brand.name;
const currentPercent =
effectiveView === "score" ? brand.score : (shareNum(brand.marketShare) / maxShare) * 100;
return (
<div
key={brand.name}
className={`group rounded-sm border transition-all duration-300 p-2.5 cursor-pointer relative ${
isSelected
? "bg-gradient-to-b from-rose-50/15 to-white dark:from-[var(--dash-primary-soft)] dark:to-[var(--dash-card)] border-rose-300 dark:border-[var(--dash-primary)] shadow-sm ring-1 ring-rose-200/40 dark:ring-[var(--dash-primary)]/30"
: "bg-white dark:bg-[var(--dash-card)] border-slate-200 dark:border-[var(--dash-border)] hover:bg-slate-50/40 dark:hover:bg-[var(--dash-elev)] hover:border-slate-300 dark:hover:border-[var(--dash-border-soft)]"
}`}
onClick={() => handleBrandClick(brand)}
onMouseEnter={() => setHoveredBrand(brand.name)}
onMouseLeave={() => setHoveredBrand(null)}
>
{isSelected && <div className="absolute left-0 top-0 bottom-0 w-[3px] bg-rose-600 dark:bg-[var(--dash-primary)] rounded-l-xs" />}
<div className="flex items-center gap-3">
{getRankBadge(index)}
<div
className="flex items-center justify-center w-8 h-8 rounded-sm font-extrabold text-xs select-none shrink-0 border"
style={{ backgroundColor: `${brand.color}15`, color: brand.color, borderColor: `${brand.color}40` }}
>
{brand.name.substring(0, 1)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1 text-xs">
<span className="font-extrabold text-slate-800 dark:text-[var(--dash-text)] flex items-center gap-1.5 group-hover:text-rose-950 dark:group-hover:text-[var(--dash-primary)] transition-colors truncate">
{brand.name}
{brand.score >= 85 && (
<span className="text-[9px] bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 text-amber-700 dark:text-amber-300 px-1 rounded-xs font-mono shrink-0">
LEADING
</span>
)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{hasShare && (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono">
{effectiveView === "score" ? `份额: ${brand.marketShare ?? "—"}` : `指数: ${brand.score}`}
</span>
)}
<span className="font-bold text-slate-900 dark:text-[var(--dash-text-strong)] font-mono text-sm tracking-tight">
{effectiveView === "score" ? brand.score : brand.marketShare}
</span>
</div>
</div>
<div className="w-full h-2.5 bg-slate-100 dark:bg-[var(--dash-inset)] rounded-sm overflow-hidden relative flex items-center p-0.5 border border-slate-200/50 dark:border-[var(--dash-border)]">
<div className="absolute inset-0 flex justify-between pointer-events-none px-1">
{[...Array(10)].map((_, i) => (
<div key={i} className="w-[1.2px] h-full bg-slate-200/80 dark:bg-[var(--dash-border-soft)]/80 last:hidden" />
))}
</div>
<motion.div
className="h-full rounded-xs relative"
style={{ background: `linear-gradient(90deg, ${brand.color}b0 0%, ${brand.color} 100%)` }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(0, Math.min(100, currentPercent))}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
<div className="absolute inset-x-0 top-0 h-[40%] bg-white/10 rounded-sm" />
</motion.div>
</div>
</div>
<ChevronRight
className={`w-3.5 h-3.5 text-slate-400 dark:text-[var(--dash-text-dim)] group-hover:text-rose-600 dark:group-hover:text-[var(--dash-primary)] transition-all ${
isSelected ? "rotate-90 text-rose-500 dark:text-[var(--dash-primary)]" : ""
}`}
/>
</div>
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="pt-3.5 mt-2.5 border-t border-slate-100 dark:border-[var(--dash-border)] grid grid-cols-1 md:grid-cols-2 gap-3.5">
<div className="bg-slate-50/80 dark:bg-[var(--dash-elev)] p-2.5 rounded-sm border border-slate-200/50 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-1.5 mb-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<span className="text-[10px] font-bold text-slate-700 dark:text-[var(--dash-text)] tracking-wider">竞争优势 / 亮点</span>
</div>
{brand.strengths && brand.strengths.length > 0 ? (
<ul className="space-y-1.5">
{brand.strengths.map((str, i) => (
<li key={i} className="text-[11px] text-slate-600 dark:text-[var(--dash-text-sec)] flex items-start gap-1">
<span className="text-emerald-500 dark:text-emerald-300 font-bold shrink-0 mt-0.5">✓</span>
<span className="leading-tight">{str}</span>
</li>
))}
</ul>
) : (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] italic">综合指数 {brand.score} 分</span>
)}
</div>
<div className="bg-slate-50/80 dark:bg-[var(--dash-elev)] p-2.5 rounded-sm border border-slate-200/50 dark:border-[var(--dash-border)] flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 mb-2">
<Car className="w-3.5 h-3.5 text-sky-500 dark:text-sky-300" />
<span className="text-[10px] font-bold text-slate-700 dark:text-[var(--dash-text)] tracking-wider">代表性产物 / 车型</span>
</div>
{brand.vehicles && brand.vehicles.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-1">
{brand.vehicles.map((v, i) => (
<span
key={i}
className="text-[10px] px-2 py-0.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] text-slate-700 dark:text-[var(--dash-text)] rounded-sm font-semibold"
>
{v}
</span>
))}
</div>
) : (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] italic block mt-1">详情见对应席位专页。</span>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
<div className="mt-6 border-t border-dashed border-slate-200/85 dark:border-[var(--dash-border-soft)] pt-3 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] flex items-center justify-between gap-2">
<span className="italic">* 排序基于多维综合指标评议。</span>
<span className="px-1.5 py-0.5 bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] rounded-xs font-mono text-[9px] text-slate-500 dark:text-[var(--dash-text-sec)]">
AUTO CO-RANK
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,60 @@
/**
* 总控最终共识 —— 读 context.consensus(headline 进玫瑰红竖条摘要,points 列表,
* 每条按序号循环配图标/色,替换参考里写死的 5 条新能源结论)。
*/
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { Target, CheckCircle2, AlertCircle, HelpCircle, Activity } from "lucide-react";
interface ConsensusPanelProps {
onExploreClick?: () => void;
}
const POINT_ICONS = [Target, Activity, CheckCircle2, AlertCircle, HelpCircle];
const POINT_COLORS = ["text-rose-600", "text-blue-600", "text-emerald-600", "text-amber-600", "text-sky-600"];
export function ConsensusPanel({ onExploreClick }: ConsensusPanelProps) {
const { consensus } = useDashboardData();
return (
<DraftingCard className="h-full flex flex-col justify-between" id="consensus-panel">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="w-2.5 h-2.5 bg-rose-600 dark:bg-[var(--dash-primary)] rounded-xs animate-pulse" />
<h2 className="text-lg font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">总控最终共识</h2>
</div>
{consensus.headline && (
<p className="mt-4 text-sm font-semibold text-slate-700 dark:text-[var(--dash-text)] leading-relaxed bg-rose-50/40 dark:bg-[var(--dash-primary-soft)] p-3.5 rounded-sm border-l-2 border-rose-500 dark:border-[var(--dash-primary)]">
{consensus.headline}
</p>
)}
<ul className="mt-5 space-y-4">
{consensus.points.map((pt, idx) => {
const Icon = POINT_ICONS[idx % POINT_ICONS.length];
return (
<li key={idx} className="flex gap-3 text-xs leading-relaxed group">
<Icon className={`w-4.5 h-4.5 mt-0.5 shrink-0 ${POINT_COLORS[idx % POINT_COLORS.length]}`} />
<div className="text-slate-600 dark:text-[var(--dash-text-sec)] group-hover:text-slate-900 dark:group-hover:text-[var(--dash-text-strong)] transition-colors">{pt}</div>
</li>
);
})}
</ul>
</div>
<div className="mt-6 pt-4 border-t border-slate-100 dark:border-[var(--dash-border)] flex justify-between items-center bg-slate-50/50 dark:bg-[var(--dash-elev)] -mx-5 -mb-5 px-5 py-4 rounded-b-sm">
<span className="text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono tracking-wider uppercase">
Roundtable Final Consensus
</span>
<button
onClick={onExploreClick}
className="text-xs text-rose-600 dark:text-[var(--dash-primary)] hover:text-rose-700 dark:hover:text-[var(--dash-primary)] font-semibold cursor-pointer flex items-center gap-1 group transition-all"
>
查看研讨详情
<span className="group-hover:translate-x-1 transition-transform">→</span>
</button>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,202 @@
/**
* 席位专页 —— 读 context.SEAT_CARDS 按 activeTab 取当前席位,数据驱动渲染:
* - 左栏:使命概述 + 结构化小节(sections);
* - 右栏:席位评定量化得分(metrics)+ 风险(risks)+ 席位依赖双向 chips(dependsOn /
* 反查谁承接本席位)+ 协同席位推荐。
* 参考项目原本是每个固定 tab 写死一套交互沙盘,真实数据下改为通用结构化展示。
*/
import { useDashboardData, ACCENTS } from "../data-context";
import { TabType, SeatCard } from "../types";
import { DraftingCard } from "./DraftingCard";
import { SeatShowcase, SHOWCASE_VARIANTS, type ShowcaseVariant } from "./SeatShowcase";
import { Settings, ShieldAlert } from "lucide-react";
interface DetailViewProps {
activeTab: TabType;
onBackToOverview?: () => void;
/** 跳到其它席位(依赖 chips / 协同推荐)。缺省回落到返回总览。 */
onNavigate?: (id: TabType) => void;
}
const RISK_STYLE = {
high: "border-red-200 bg-red-50/30 text-red-800",
medium: "border-amber-200 bg-amber-50/30 text-amber-800",
low: "border-emerald-200 bg-emerald-50/30 text-emerald-800",
} as const;
const RISK_LABEL = { high: "高风险阻碍点", medium: "中风险偏离项", low: "低风险关注项" } as const;
export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailViewProps) {
const { SEAT_CARDS } = useDashboardData();
const seat = SEAT_CARDS.find((s) => s.id === activeTab);
const go = (id: TabType) => (onNavigate ? onNavigate(id) : onBackToOverview?.());
if (!seat) {
return <div className="p-8 text-center text-slate-500">加载席位数据失败,请返回总览页。</div>;
}
const idx = SEAT_CARDS.findIndex((s) => s.id === seat.id);
const color = ACCENTS[(idx >= 0 ? idx : 0) % ACCENTS.length];
// 主体展示样式:总结席位固定「发布声明」,其余按「非总结席位序号」轮转分配,
// 让相邻席位专页换不同款式(框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对)。
const analystIdx = SEAT_CARDS.filter((s) => !s.isSummary).findIndex((s) => s.id === seat.id);
const variant: ShowcaseVariant = seat.isSummary
? "release"
: SHOWCASE_VARIANTS[(analystIdx >= 0 ? analystIdx : 0) % SHOWCASE_VARIANTS.length];
// 依赖双向:本席位依赖谁 / 谁承接本席位。
const byId = new Map(SEAT_CARDS.map((s) => [s.id, s] as const));
const deps = seat.dependsOn.map((d) => byId.get(d)).filter((s): s is SeatCard => !!s);
const supports = SEAT_CARDS.filter((s) => s.dependsOn.includes(seat.id));
const recommended = SEAT_CARDS.filter((s) => s.id !== seat.id).slice(0, 3);
return (
<div className="space-y-6">
{/* 顶栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-3 border-b border-slate-200 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-3">
<button
onClick={onBackToOverview}
className="text-xs text-slate-400 hover:text-rose-600 transition-colors font-medium cursor-pointer border border-slate-200 bg-white px-2.5 py-1.5 rounded-sm dark:text-[var(--dash-text-dim)] dark:hover:text-[var(--dash-primary)] dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)]"
>
← 返回总览研讨看盘
</button>
<div className="h-4 w-[1px] bg-slate-200 hidden sm:block dark:bg-[var(--dash-border)]" />
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-bold text-slate-900 tracking-tight font-sans dark:text-[var(--dash-text-strong)]">{seat.title} · 席位专页</h2>
{seat.completeness > 0 && (
<span className="text-[10px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1.5 rounded-sm font-sans dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/30">
达成度: {seat.completeness}%
</span>
)}
</div>
{seat.analyst && <span className="text-xs text-slate-400 font-sans block mt-0.5 dark:text-[var(--dash-text-dim)]">席任分析官:{seat.analyst}</span>}
</div>
</div>
<span className="text-xs font-mono text-slate-400 uppercase tracking-widest hidden md:inline dark:text-[var(--dash-text-dim)]">
Seat node: {String(seat.id).toUpperCase()}
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左栏:使命概述 + 按变体定制的主体展示区(发布声明变体自带声明书,不重复概述卡) */}
<div className="lg:col-span-2 space-y-6">
{variant !== "release" && (
<DraftingCard hoverEffect={false}>
<div className="flex items-center gap-2 pb-3.5 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<Settings className="w-4 h-4 text-rose-600 animate-spin-slow dark:text-[var(--dash-primary)]" />
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">此席位核心交付 · 使命概述</h3>
</div>
<p className="text-xs text-slate-600 leading-relaxed font-sans bg-slate-50 p-3.5 rounded-sm border border-slate-200/40 dark:text-[var(--dash-text-sec)] dark:bg-[var(--dash-elev)] dark:border-[var(--dash-border)]">
{seat.description || "(无摘要)"}
</p>
</DraftingCard>
)}
<SeatShowcase key={seat.id} seat={seat} variant={variant} color={color} />
</div>
{/* 右栏:得分 + 风险 + 依赖 + 推荐 */}
<div className="space-y-6">
{seat.metrics.length > 0 && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">席位评定量化得分</h4>
</div>
<div className="space-y-4">
{seat.metrics.map((met, i) => (
<div key={i} className="p-4 bg-slate-50 border border-slate-100 rounded-md dark:bg-[var(--dash-elev)] dark:border-[var(--dash-border)]">
<div className="flex justify-between items-baseline mb-2">
<span className="text-[11px] text-slate-500 dark:text-[var(--dash-text-sec)]">{met.label}</span>
<span className="text-2xl font-black font-mono tracking-tight" style={{ color }}>
{met.value}
<small className="text-xs font-normal text-slate-400 ml-0.5">分</small>
</span>
</div>
<div className="w-full h-1.5 bg-slate-200/70 rounded-full overflow-hidden dark:bg-[var(--dash-border)]">
<div className="h-full rounded-full" style={{ width: `${met.value}%`, backgroundColor: color }} />
</div>
</div>
))}
</div>
</DraftingCard>
)}
{seat.risks.length > 0 && (
<DraftingCard hoverEffect={false} className="border-amber-200 bg-amber-50/5 dark:border-amber-500/30 dark:bg-amber-500/5">
<div className="flex items-center gap-1.5 pb-3 border-b border-amber-100 mb-4 dark:border-amber-500/30">
<ShieldAlert className="w-4 h-4 text-amber-600 dark:text-amber-300" />
<h4 className="text-xs font-bold text-amber-800 uppercase tracking-wider dark:text-amber-300">此专项下属风险与关注点</h4>
</div>
<div className="space-y-3">
{seat.risks.map((risk) => (
<div key={risk.id} className={`p-3 rounded border text-xs text-left leading-relaxed ${RISK_STYLE[risk.level]}`}>
<div className="flex items-center gap-1.5 mb-1 font-bold">
<span
className={`w-1.5 h-1.5 rounded-full ${
risk.level === "high" ? "bg-red-500 animate-ping" : risk.level === "low" ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
<span>{RISK_LABEL[risk.level]}</span>
</div>
{risk.text}
</div>
))}
</div>
</DraftingCard>
)}
{(deps.length > 0 || supports.length > 0) && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">席位依赖关系</h4>
</div>
<div className="flex flex-wrap gap-2">
{deps.map((d) => (
<button
key={d.id}
onClick={() => go(d.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-rose-400 text-rose-600 rounded-sm hover:bg-rose-50 transition-colors dark:border-[var(--dash-primary)] dark:text-[var(--dash-primary)] dark:hover:bg-[var(--dash-primary-soft)]"
>
依赖 → {d.title}
</button>
))}
{supports.map((s) => (
<button
key={s.id}
onClick={() => go(s.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-sky-400 text-sky-600 rounded-sm hover:bg-sky-50 transition-colors dark:border-sky-500/50 dark:text-sky-300 dark:hover:bg-sky-500/10"
>
{s.title} ← 承接本席位
</button>
))}
</div>
</DraftingCard>
)}
{recommended.length > 0 && (
<DraftingCard hoverEffect={false} className="bg-slate-50/50 dark:bg-[var(--dash-elev)]">
<div className="pb-2.5 border-b border-slate-200 mb-3.5 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-wider dark:text-[var(--dash-text)]">研讨协同席位推荐</h4>
</div>
<div className="space-y-2">
{recommended.map((other) => (
<button
key={other.id}
onClick={() => go(other.id)}
className="w-full p-2 bg-white hover:bg-slate-50 border border-slate-200 rounded cursor-pointer flex justify-between items-center text-xs text-slate-700 hover:text-rose-600 hover:border-slate-300 transition-all dark:bg-[var(--dash-card)] dark:hover:bg-[var(--dash-elev)] dark:border-[var(--dash-border)] dark:text-[var(--dash-text)] dark:hover:text-[var(--dash-primary)]"
>
<span className="font-semibold truncate">{other.title}</span>
<span className="text-[10px] text-slate-400 shrink-0 dark:text-[var(--dash-text-dim)]">点击切出 →</span>
</button>
))}
</div>
</DraftingCard>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* 蓝图工程图风卡片(源自参考项目,原样保留):白底 + 四角玫瑰红角标 + 极淡绘图网格 overlay。
*/
import type React from "react";
interface DraftingCardProps {
children: React.ReactNode;
className?: string;
id?: string;
hoverEffect?: boolean;
onClick?: () => void;
}
export function DraftingCard({
children,
className = "",
id,
hoverEffect = true,
onClick,
}: DraftingCardProps) {
return (
<div
id={id || undefined}
onClick={onClick}
className={`relative bg-white dark:bg-[var(--dash-card)] rounded-sm p-5 border border-slate-200/80 dark:border-[var(--dash-border)] transition-all duration-300 shadow-[0_3px_10px_rgba(15,23,42,0.04)] dark:shadow-[0_2px_8px_rgba(6,14,36,0.5)] ${
hoverEffect
? "hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:hover:shadow-[0_4px_16px_rgba(22,119,255,0.24)] hover:-translate-y-[2px] hover:border-slate-300 dark:hover:border-[var(--dash-border-soft)]"
: ""
} ${className}`}
>
{/* 左上角标(浅色品牌正红 #F5222D / 深色主蓝,取色见 dashboard.css 的 .dash-corner) */}
<div className="dash-corner absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 -mt-[1px] -ml-[1px]" />
{/* 右下角标 */}
<div className="dash-corner absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 -mb-[1px] -mr-[1px]" />
{/* 极淡绘图网格 overlay */}
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,white,rgba(255,255,255,0.6))] pointer-events-none rounded-sm opacity-30" />
<div className="relative z-10">{children}</div>
</div>
);
}
@@ -0,0 +1,59 @@
/**
* 顶部 KPI 带 —— 读 context.METRIC_DATA(参考原本写死 6 个 + 按 id 配图标,
* 改成按序号循环取图标 + 用十六进制色 inline 着色,适配任意数量真实 KPI)。
*/
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { TrendingUp, Users, AlertTriangle, Layers, Award, Calendar } from "lucide-react";
interface MetricsHeaderProps {
onCardClick?: (metricId: string) => void;
}
const ICONS = [Users, TrendingUp, AlertTriangle, Layers, Award, Calendar];
export function MetricsHeader({ onCardClick }: MetricsHeaderProps) {
const { METRIC_DATA } = useDashboardData();
if (METRIC_DATA.length === 0) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{METRIC_DATA.map((metric, idx) => {
const Icon = ICONS[idx % ICONS.length];
return (
<DraftingCard
key={metric.id}
className="flex flex-col justify-between py-4 px-5 min-h-[115px] cursor-pointer"
onClick={() => onCardClick?.(metric.id)}
>
<div className="flex justify-between items-start gap-2">
<span className="text-xs text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide font-medium">
{metric.label}
</span>
<Icon className="w-5 h-5 shrink-0" style={{ color: metric.color }} />
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-3xl font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">
{metric.value}
</span>
{metric.unit && <span className="text-xs text-slate-400 dark:text-[var(--dash-text-dim)] font-medium ml-0.5">{metric.unit}</span>}
{metric.trend && (
<span
className={`inline-flex items-center text-xs font-semibold px-1 rounded-sm ml-2 ${
metric.trend.type === "up"
? "text-emerald-600 bg-emerald-50 dark:text-emerald-300 dark:bg-emerald-500/10"
: "text-rose-600 bg-rose-50 dark:text-rose-300 dark:bg-rose-500/10"
}`}
>
{metric.trend.type === "up" ? "▲" : "▼"}
</span>
)}
</div>
</DraftingCard>
);
})}
</div>
);
}
@@ -0,0 +1,115 @@
/**
* 核心风险分类分布 —— 读 context.RISK_CATEGORIES(真实数据由各席位风险按 高/中/低
* 聚合)+ riskTotal(镂空中心大数字)。无风险时给友好空态。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { RiskCategory } from "../types";
import { ShieldAlert } from "lucide-react";
interface RiskDonutProps {
onCategoryHover?: (category: RiskCategory | null) => void;
}
export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
const { RISK_CATEGORIES, riskTotal } = useDashboardData();
const [activeCategory, setActiveCategory] = useState<RiskCategory | null>(null);
const handleMouseEnter = (cat: RiskCategory) => {
setActiveCategory(cat);
onCategoryHover?.(cat);
};
const handleMouseLeave = () => {
setActiveCategory(null);
onCategoryHover?.(null);
};
const empty = RISK_CATEGORIES.length === 0;
return (
<DraftingCard className="h-full flex flex-col justify-between" id="risk-donut-card">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)] mb-5">
<ShieldAlert className="w-5 h-5 text-rose-600 dark:text-[var(--dash-primary)]" />
<h3 className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">核心风险分类分布</h3>
</div>
{empty ? (
<div className="flex flex-col items-center justify-center gap-2 text-slate-400 dark:text-[var(--dash-text-dim)] py-12">
<ShieldAlert className="w-8 h-8 text-emerald-400" />
<span className="text-xs">本轮研讨未识别到显式风险项。</span>
</div>
) : (
<>
<div className="flex flex-row items-center gap-5 py-2">
{/* 风险总数(去掉百分比圆环,保留大数字,对齐设计原图) */}
<div className="flex flex-col items-center justify-center shrink-0 px-3 py-2 border-r border-slate-100 dark:border-[var(--dash-border)]">
<span className="text-4xl font-extrabold text-slate-800 dark:text-[var(--dash-text)] font-mono leading-none">
{activeCategory ? `${activeCategory.percentage}%` : riskTotal}
</span>
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-medium tracking-wider mt-1.5 text-center max-w-[60px] leading-tight">
{activeCategory ? activeCategory.name : "项核心风险"}
</span>
</div>
{/* 图例 */}
<div className="space-y-3 flex-1 min-w-0">
{RISK_CATEGORIES.map((cat) => {
const isSelected = activeCategory?.name === cat.name;
return (
<div
key={cat.name}
className={`flex items-start gap-2.5 p-1.5 rounded-xs transition-colors cursor-pointer ${
isSelected ? "bg-slate-50 dark:bg-[var(--dash-elev)] border-l-2" : "border-l-2 border-transparent"
}`}
style={{ borderLeftColor: isSelected ? cat.color : undefined }}
onMouseEnter={() => handleMouseEnter(cat)}
onMouseLeave={handleMouseLeave}
>
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0" style={{ backgroundColor: cat.color }} />
<div className="text-left">
<div className="flex justify-between items-baseline gap-2">
<span className="text-xs font-semibold text-slate-800 dark:text-[var(--dash-text)]">{cat.name}</span>
<span className="text-xs font-bold text-slate-900 dark:text-[var(--dash-text-strong)] font-mono">{cat.value}%</span>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 悬停细项抽屉 */}
<div className="mt-4 min-h-[90px] bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-100 dark:border-[var(--dash-border)] rounded-sm p-3 flex flex-col justify-center">
{activeCategory ? (
<div className="space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider block" style={{ color: activeCategory.color }}>
{activeCategory.name}包含细项:
</span>
<ul className="text-[11px] text-slate-600 dark:text-[var(--dash-text-sec)] list-disc list-inside space-y-0.5">
{activeCategory.details.slice(0, 3).map((det, i) => (
<li key={i} className="truncate">{det}</li>
))}
</ul>
</div>
) : (
<div className="flex items-center gap-2.5 text-slate-400 dark:text-[var(--dash-text-dim)] justify-center">
<ShieldAlert className="w-4 h-4" />
<span className="text-xs">悬停上方分类标签,查看各项底层细节。</span>
</div>
)}
</div>
</>
)}
</div>
<div className="mt-5 border-t border-dashed border-slate-100 dark:border-[var(--dash-border-soft)] pt-3 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] flex items-center justify-between">
<span>风险按高/中/低等级聚合占比</span>
<span className="font-semibold text-rose-600 dark:text-[var(--dash-primary)] px-1 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200 dark:border-[var(--dash-border)] rounded-xs">
共 {riskTotal} 项
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,98 @@
/**
* 执行路线 —— 读 context.ROADMAP_PHASES(参考按空格拆 title 取 tag/name,改成 id 作 tag、
* title 作 name;period/highlights 真实数据可能缺省,分别条件渲染,无 highlights 不展开)。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
import { motion } from "motion/react";
export function Roadmap() {
const { ROADMAP_PHASES } = useDashboardData();
const [activePhaseId, setActivePhaseId] = useState<string | null>(null);
if (ROADMAP_PHASES.length === 0) return null;
const togglePhase = (id: string) => setActivePhaseId(activePhaseId === id ? null : id);
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100 dark:border-[var(--dash-border)]">
<h3 className="text-base font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
执行路线
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans">点击阶段卡片展开关键里程碑</span>
</h3>
<span className="text-[10px] text-rose-600 dark:text-[var(--dash-primary)] font-mono font-medium px-2 py-0.5 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200/50 dark:border-[var(--dash-border)]">
共 {ROADMAP_PHASES.length} 个阶段
</span>
</div>
<div className={`grid grid-cols-1 ${autoGridColsClass(ROADMAP_PHASES.length)} gap-4`}>
{ROADMAP_PHASES.map((phase) => {
const hasHighlights = phase.highlights.length > 0;
const isExpanded = activePhaseId === phase.id;
return (
<DraftingCard
key={phase.id}
className={`flex flex-col justify-between transition-all duration-300 text-left overflow-hidden group min-h-[160px] border-l-[5px] border-l-rose-600 hover:border-l-rose-700 dark:border-l-[var(--dash-primary)] dark:hover:border-l-[var(--dash-primary)] ${
hasHighlights ? "cursor-pointer" : ""
} ${isExpanded ? "bg-slate-50 border-rose-400 shadow-md ring-1 ring-rose-300/40 dark:bg-[var(--dash-elev)] dark:border-[var(--dash-primary)] dark:ring-[var(--dash-primary)]/30" : ""}`}
onClick={hasHighlights ? () => togglePhase(phase.id) : undefined}
>
<div>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="space-y-0.5">
<span className="text-xs font-extrabold text-rose-600 dark:text-[var(--dash-primary)] font-sans block uppercase tracking-wide">
{phase.id}
</span>
<h4 className="text-sm font-extrabold text-slate-800 dark:text-[var(--dash-text)] font-sans">{phase.title}</h4>
</div>
{phase.period && (
<span className="text-[11px] font-bold text-slate-400 dark:text-[var(--dash-text-dim)] font-mono bg-slate-100 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] px-2 py-0.5 rounded-sm shrink-0">
{phase.period}
</span>
)}
</div>
<p className="text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed font-sans mt-2.5">{phase.description}</p>
{hasHighlights && isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mt-4 pt-3.5 border-t border-slate-200/80 dark:border-[var(--dash-border)] space-y-2 overflow-hidden"
>
<span className="text-[10px] font-bold text-rose-600 dark:text-[var(--dash-primary)] uppercase tracking-widest block mb-1">
此阶段关键里程碑:
</span>
<ul className="space-y-2">
{phase.highlights.map((hlt, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-slate-700 dark:text-[var(--dash-text)]">
<CheckCircle2 className="w-3.5 h-3.5 text-rose-500 dark:text-[var(--dash-primary)] shrink-0 mt-0.5" />
<span>{hlt}</span>
</li>
))}
</ul>
</motion.div>
)}
</div>
<div className="mt-4 pt-2.5 border-t border-slate-100 dark:border-[var(--dash-border)] flex items-center justify-between text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">
<span className="font-mono uppercase tracking-wider">Timeline Segment {phase.id}</span>
{hasHighlights && (
<div className="flex items-center gap-1 font-semibold text-rose-600 hover:text-rose-700 dark:text-[var(--dash-primary)] dark:hover:text-[var(--dash-primary)]">
<span>{isExpanded ? "收起" : "详细指标"}</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</div>
)}
</div>
</DraftingCard>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
/**
* 席位交付总览 —— 读 context.SEAT_CARDS。参考按固定 id 配图标/颜色并写死“总结”全宽行;
* 这里改成:按序号循环取强调色,isSummary 席位全宽置底(无则全部进网格)。
*/
import { useDashboardData, ACCENTS } from "../data-context";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { TabType } from "../types";
import { ArrowRight, AlertTriangle, AlertCircle, FileText } from "lucide-react";
interface SeatDeliveryOverviewProps {
onSeatSelect: (seatId: TabType) => void;
activeSeatId?: TabType;
}
export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliveryOverviewProps) {
const { SEAT_CARDS } = useDashboardData();
const analystSeats = SEAT_CARDS.filter((c) => !c.isSummary);
const summarySeat = SEAT_CARDS.find((c) => c.isSummary);
if (SEAT_CARDS.length === 0) return null;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-baseline justify-between gap-2">
<h3 className="text-base font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
席位交付总览
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans">点击卡片快速切入对应专属席位页</span>
</h3>
</div>
<div className={`grid grid-cols-1 ${autoGridColsClass(analystSeats.length)} gap-4`}>
{analystSeats.map((seat, idx) => {
const color = ACCENTS[idx % ACCENTS.length];
const isActive = activeSeatId === seat.id;
return (
<DraftingCard
key={seat.id}
className={`flex flex-col justify-between h-full cursor-pointer relative ${
isActive ? "ring-2 ring-rose-500/70 bg-rose-50/5 border-rose-300 dark:ring-[var(--dash-primary)]/60 dark:bg-[var(--dash-primary)]/5 dark:border-[var(--dash-primary)]" : ""
}`}
onClick={() => onSeatSelect(seat.id)}
>
<div>
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-2 min-w-0">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text)] font-sans truncate">{seat.title}</span>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-sans hover:text-slate-600 dark:hover:text-[var(--dash-text-sec)] shrink-0">
<span>席位页</span>
<ArrowRight className="w-3 h-3" />
</div>
</div>
{seat.analyst && (
<div className="mt-3 text-slate-400 dark:text-[var(--dash-text-dim)] text-[10px] uppercase font-mono font-bold tracking-wider truncate">
汇报人: {seat.analyst}
</div>
)}
<p className="mt-2 text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed line-clamp-3">{seat.description}</p>
{seat.metrics.length > 0 && (
<div className="mt-4 space-y-2.5">
{seat.metrics.slice(0, 2).map((met, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10px] font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">
<span className="truncate">{met.label}</span>
<span className="font-bold text-slate-800 dark:text-[var(--dash-text)] font-mono shrink-0">{met.value}</span>
</div>
<div className="w-full h-1.5 bg-slate-100 dark:bg-[var(--dash-inset)] rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${met.value}%`, backgroundColor: ACCENTS[i % ACCENTS.length] }}
/>
</div>
</div>
))}
</div>
)}
</div>
{seat.risks.length > 0 && (
<div className="mt-5 pt-3.5 border-t border-slate-100 dark:border-[var(--dash-border)] space-y-1.5">
{seat.risks.slice(0, 3).map((risk) => (
<div
key={risk.id}
className={`text-[9px] px-2 py-0.5 rounded-sm flex items-center gap-1 border-l-2 ${
risk.level === "high"
? "bg-red-50 text-red-700 border-red-500 border border-red-200/50 dark:bg-red-500/10 dark:text-red-300 dark:border-red-500/40"
: risk.level === "low"
? "bg-emerald-50 text-emerald-700 border-emerald-500 border border-emerald-200/50 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/40"
: "bg-amber-50 text-amber-700 border-amber-500 border border-amber-200/50 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/40"
}`}
>
{risk.level === "high" ? (
<AlertCircle className="w-3 h-3 text-red-500 shrink-0" />
) : (
<AlertTriangle className={`w-3 h-3 shrink-0 ${risk.level === "low" ? "text-emerald-500" : "text-amber-500"}`} />
)}
<span className="truncate">{risk.text}</span>
</div>
))}
</div>
)}
</DraftingCard>
);
})}
</div>
{summarySeat && (
<DraftingCard
className={`cursor-pointer ${activeSeatId === summarySeat.id ? "ring-2 ring-rose-500 bg-rose-50/10 dark:ring-[var(--dash-primary)] dark:bg-[var(--dash-primary)]/10" : ""}`}
onClick={() => onSeatSelect(summarySeat.id)}
>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-start md:items-center gap-3">
<div className="p-2.5 bg-sky-100 border border-sky-300 rounded-sm dark:bg-sky-500/15 dark:border-sky-500/40">
<FileText className="w-5 h-5 text-sky-600 dark:text-sky-300" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-500" />
<h4 className="text-sm font-bold text-slate-950 dark:text-[var(--dash-text-strong)] font-sans">{summarySeat.title}</h4>
<span className="text-[10px] px-2 py-0.5 bg-sky-100/50 text-sky-800 rounded-full font-mono border border-sky-200/40 font-semibold uppercase tracking-wider dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-500/30">
决策中心终审
</span>
</div>
<p className="mt-1.5 text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed max-w-4xl">{summarySeat.description}</p>
</div>
</div>
<div className="shrink-0 flex items-center gap-1 text-xs text-sky-600 hover:text-sky-700 font-semibold group bg-sky-50 px-3.5 py-2.5 rounded-sm border border-sky-100 dark:bg-sky-500/10 dark:text-sky-300 dark:border-sky-500/30">
<span>进入 {summarySeat.title} 专页</span>
<span className="group-hover:translate-x-1 transition-transform">→</span>
</div>
</div>
</DraftingCard>
)}
</div>
);
}
@@ -0,0 +1,336 @@
/**
* 席位专页「主体展示区」的多样式变体 —— 对齐参考项目 DetailView 里按席位定制的 5 种子页面
* (框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对 / 发布声明),但全部改成**数据驱动**:
* 不再写死维度/NPS/审计清单等内容,而是消费当前席位真实的 `sections` / `details` /
* `description`,按所选变体换一套排版,从而让每个席位专页「长得不一样」。
*
* 变体由 DetailView 按席位序号轮转分配(总结席位固定用 release),见 `SHOWCASE_VARIANTS`。
* 量化得分 / 风险 / 依赖 / 协同推荐仍由 DetailView 的右栏统一承载,这里只负责主体内容。
*/
import { useState } from "react";
import {
Award,
Building2,
Check,
ClipboardList,
GitCompare,
LayoutGrid,
Sparkles,
} from "lucide-react";
import { DraftingCard } from "./DraftingCard";
import type { SeatCard, SeatSection } from "../types";
export type ShowcaseVariant = "matrix" | "gauge" | "compare" | "audit" | "release";
/** 非总结席位的变体轮转序列(按席位序号取模分配,保证相邻席位不同款)。 */
export const SHOWCASE_VARIANTS: ShowcaseVariant[] = ["matrix", "gauge", "compare", "audit"];
/** 把席位内容归一成「分组小节」:优先 sections,回落 details 合成一组。 */
function toGroups(seat: SeatCard): SeatSection[] {
if (seat.sections.length > 0) return seat.sections.filter((s) => s.bullets.length > 0 || s.heading);
if (seat.details.length > 0) return [{ heading: "汇报要点支撑明细", bullets: seat.details }];
return [];
}
function allBullets(groups: SeatSection[]): string[] {
return groups.flatMap((g) => g.bullets);
}
function EmptyShowcase() {
return (
<DraftingCard hoverEffect={false}>
<p className="py-6 text-center text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">该席位暂无结构化汇报内容。</p>
</DraftingCard>
);
}
export function SeatShowcase({
seat,
variant,
color,
}: {
seat: SeatCard;
variant: ShowcaseVariant;
color: string;
}) {
const groups = toGroups(seat);
const bullets = allBullets(groups);
if (variant !== "release" && bullets.length === 0) return <EmptyShowcase />;
switch (variant) {
case "matrix":
return <MatrixShowcase groups={groups} color={color} />;
case "gauge":
return <GaugeShowcase seat={seat} groups={groups} color={color} />;
case "compare":
return <CompareShowcase groups={groups} color={color} />;
case "audit":
return <AuditShowcase bullets={bullets} color={color} />;
case "release":
return <ReleaseShowcase seat={seat} bullets={bullets} />;
default:
return <EmptyShowcase />;
}
}
// ── 变体一:框架矩阵(ref scheme)—— 维度卡片网格 + 要点对标表 ──────────────────────────
function MatrixShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
const cols = groups.length >= 3 ? "md:grid-cols-3" : groups.length === 2 ? "md:grid-cols-2" : "md:grid-cols-1";
let rowNo = 0;
return (
<div className="space-y-5">
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-center gap-1.5 border-b border-rose-100 pb-3.5 text-xs font-bold uppercase tracking-widest text-rose-700 dark:border-[var(--dash-border)] dark:text-[var(--dash-primary)]">
<Building2 className="h-4 w-4 text-emerald-600" />
<span>结构化框架 · 维度拆解</span>
</div>
<div className={`grid grid-cols-1 gap-3 ${cols}`}>
{groups.map((g, i) => (
<div
key={i}
className="rounded-sm border border-slate-200 bg-slate-50/60 p-3 text-center dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]"
style={{ borderTopColor: color, borderTopWidth: 2 }}
>
<div className="mb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-[var(--dash-text-dim)]">
维度 {i + 1}
</div>
<div className="truncate text-xs font-semibold text-slate-800 dark:text-[var(--dash-text)]">{g.heading || `小节 ${i + 1}`}</div>
<span className="mt-1.5 inline-block rounded-full border border-current bg-white/70 px-1.5 py-0.5 font-mono text-[9px] text-slate-500 dark:bg-[var(--dash-card)] dark:text-[var(--dash-text-sec)]">
{g.bullets.length} 项要点
</span>
</div>
))}
</div>
</DraftingCard>
<DraftingCard hoverEffect={false}>
<div className="overflow-hidden rounded-md border border-slate-200/60 bg-slate-50 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 p-3 dark:border-[var(--dash-border)] dark:bg-[var(--dash-inset)]">
<span className="text-xs font-bold text-slate-700 dark:text-[var(--dash-text)]">各维度关键交付要点对标</span>
<span className="rounded-sm bg-slate-200/50 px-2 py-0.5 font-mono text-[10px] text-slate-600 dark:bg-[var(--dash-inset)] dark:text-[var(--dash-text-sec)]">
共 {groups.reduce((n, g) => n + g.bullets.length, 0)} 项
</span>
</div>
<table className="w-full text-left text-xs">
<thead className="border-b border-slate-200 bg-slate-50 font-sans text-[10px] uppercase tracking-wider text-slate-500 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)] dark:text-[var(--dash-text-sec)]">
<tr>
<th className="px-3 py-2.5">序号</th>
<th className="px-4 py-2.5">所属维度</th>
<th className="px-4 py-2.5">关键交付要点</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700 dark:divide-[var(--dash-border)] dark:text-[var(--dash-text)]">
{groups.flatMap((g, gi) =>
g.bullets.map((b, bi) => {
rowNo += 1;
return (
<tr key={`${gi}-${bi}`} className={rowNo % 2 === 0 ? "bg-white/50 dark:bg-[var(--dash-card)]" : ""}>
<td className="px-3 py-2.5 font-mono font-semibold text-slate-400 dark:text-[var(--dash-text-dim)]">{String(rowNo).padStart(2, "0")}</td>
<td className="px-4 py-2.5 font-semibold" style={{ color }}>
{bi === 0 ? g.heading || `维度 ${gi + 1}` : ""}
</td>
<td className="px-4 py-2.5 leading-relaxed">{b}</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
</DraftingCard>
</div>
);
}
// ── 变体二:洞察卡阵(ref ux)—— 描述领读 + 要点卡片网格 ───────────────────────────────
function GaugeShowcase({ seat, groups, color }: { seat: SeatCard; groups: SeatSection[]; color: string }) {
const bullets = allBullets(groups);
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5" style={{ color }} />
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text)]">核心洞察聚焦 · 关键论点拆解</span>
</div>
<div
className="mb-5 rounded-md border p-4"
style={{ backgroundColor: `${color}0d`, borderColor: `${color}33` }}
>
<span className="mb-1 block text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-[var(--dash-text-dim)]">本席位核心论断</span>
<p className="text-xs font-medium leading-relaxed text-slate-700 dark:text-[var(--dash-text)]">{seat.description || "(无摘要)"}</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{bullets.map((b, i) => (
<div key={i} className="overflow-hidden rounded-sm border border-slate-200 bg-white dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)]">
<div className="h-1 w-full" style={{ backgroundColor: color }} />
<div className="flex gap-2.5 p-3">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] font-bold text-white"
style={{ backgroundColor: color }}
>
{i + 1}
</span>
<p className="text-[11px] leading-relaxed text-slate-600 dark:text-[var(--dash-text-sec)]">{b}</p>
</div>
</div>
))}
</div>
</DraftingCard>
);
}
// ── 变体三:双栏对比(ref ad)—— 小节分两列彩色面板 ────────────────────────────────────
function CompareShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
// ≥2 组:每组一面板;仅 1 组:把要点对半拆成两面板。
const panels: SeatSection[] =
groups.length >= 2
? groups
: (() => {
const b = groups[0]?.bullets ?? [];
const mid = Math.ceil(b.length / 2);
return [
{ heading: `${groups[0]?.heading || "汇报要点"} · 上`, bullets: b.slice(0, mid) },
{ heading: `${groups[0]?.heading || "汇报要点"} · 下`, bullets: b.slice(mid) },
].filter((p) => p.bullets.length > 0);
})();
const tints = [
{ bg: "bg-emerald-50/40", border: "border-emerald-100", dot: "bg-emerald-500", title: "text-emerald-800" },
{ bg: "bg-indigo-50/40", border: "border-indigo-100", dot: "bg-indigo-500", title: "text-indigo-800" },
{ bg: "bg-amber-50/40", border: "border-amber-100", dot: "bg-amber-500", title: "text-amber-800" },
{ bg: "bg-sky-50/40", border: "border-sky-100", dot: "bg-sky-500", title: "text-sky-800" },
];
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{panels.map((p, i) => {
const t = tints[i % tints.length];
return (
<div key={i} className={`rounded-md border p-4 ${t.bg} ${t.border}`}>
<h4 className={`mb-3 flex items-center gap-1.5 text-xs font-bold ${t.title}`}>
<GitCompare className="h-3.5 w-3.5" style={{ color }} />
<span className="truncate">{p.heading || `对比维度 ${i + 1}`}</span>
</h4>
<ul className="space-y-3 text-xs leading-relaxed">
{p.bullets.map((b, j) => (
<li key={j} className="flex gap-2">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${t.dot}`} />
<span className="text-slate-600 dark:text-[var(--dash-text-sec)]">{b}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
// ── 变体四:清单核对(ref risk)—— 要点可勾选核对台 ───────────────────────────────────
function AuditShowcase({ bullets, color }: { bullets: string[]; color: string }) {
const [done, setDone] = useState<number[]>([]);
const toggle = (i: number) => setDone((p) => (p.includes(i) ? p.filter((x) => x !== i) : [...p, i]));
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-baseline justify-between border-b border-slate-100 pb-3 dark:border-[var(--dash-border)]">
<span className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-[var(--dash-text)]">
<ClipboardList className="h-4 w-4" style={{ color }} />
要点核对台
</span>
<span className="font-mono text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">
已确认 {done.length}/{bullets.length}
</span>
</div>
<div className="space-y-2.5">
{bullets.map((b, i) => {
const checked = done.includes(i);
return (
<div
key={i}
onClick={() => toggle(i)}
className={`flex cursor-pointer select-none items-start gap-3 rounded border p-3 transition-all duration-300 ${
checked ? "border-emerald-300 bg-emerald-50/40 dark:border-emerald-500/40 dark:bg-emerald-500/10" : "border-slate-200 bg-white hover:border-slate-300 dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)] dark:hover:border-[var(--dash-border-soft)]"
}`}
>
<div
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors ${
checked ? "border-emerald-600 bg-emerald-500 text-white" : "border-slate-300 bg-white dark:border-[var(--dash-border-soft)] dark:bg-[var(--dash-card)]"
}`}
>
{checked && <Check className="h-3.5 w-3.5" />}
</div>
<span
className={`text-xs leading-relaxed ${
checked ? "text-slate-400 line-through dark:text-[var(--dash-text-dim)]" : "text-slate-700 dark:text-[var(--dash-text)]"
}`}
>
{b}
</span>
</div>
);
})}
</div>
</DraftingCard>
);
}
// ── 变体五:发布声明(ref summary)—— 声明书 + 要点核验网格 + 签署 ─────────────────────
function ReleaseShowcase({ seat, bullets }: { seat: SeatCard; bullets: string[] }) {
const [signed, setSigned] = useState(false);
return (
<div className="space-y-4">
<DraftingCard hoverEffect={false} className="border-sky-200/65 bg-sky-50/40 dark:border-sky-500/30 dark:bg-sky-500/10">
<h4 className="mb-2.5 flex items-center gap-1.5 text-xs font-bold text-sky-800 dark:text-sky-300">
<Award className="h-4 w-4 text-sky-600 dark:text-sky-300" />
研讨结果最终发布声明书 (Executive Release)
</h4>
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text-sec)]">{seat.description || "(无总结陈述)"}</p>
{bullets.length > 0 && (
<div className="mt-4 space-y-2 border-t border-sky-100 pt-3 dark:border-sky-500/30">
<span className="block text-[10px] font-bold uppercase tracking-widest text-sky-700 dark:text-sky-300">关键结论与调和事项:</span>
<div className="grid grid-cols-1 gap-2 text-[11px] text-slate-600 md:grid-cols-2 dark:text-[var(--dash-text-sec)]">
{bullets.map((b, i) => (
<div key={i} className="flex items-start gap-1.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<span className="leading-relaxed">{b}</span>
</div>
))}
</div>
</div>
)}
</DraftingCard>
<div className="flex flex-col items-center justify-center space-y-4 rounded-lg border border-dashed border-slate-200 bg-white/50 p-5 text-center dark:border-[var(--dash-border-soft)] dark:bg-[var(--dash-card)]">
<div className="max-w-md">
<LayoutGrid className="mx-auto mb-2 h-8 w-8 text-sky-600 dark:text-sky-300" />
<h5 className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text)]">总台最终数字签署确认栏</h5>
<p className="mt-1 text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">勾选此项即代表会议全席专家一致同意此交付终稿,封盘归档。</p>
</div>
<button
type="button"
onClick={() => setSigned((s) => !s)}
className={`flex items-center gap-2 rounded-sm border px-5 py-2.5 text-xs font-semibold transition-all ${
signed
? "border-emerald-700 bg-emerald-600 text-white hover:bg-emerald-700"
: "border-slate-300 bg-white text-slate-700 hover:border-rose-400 hover:text-rose-600 dark:bg-[var(--dash-card)] dark:border-[var(--dash-border-soft)] dark:text-[var(--dash-text)] dark:hover:border-[var(--dash-primary)] dark:hover:text-[var(--dash-primary)]"
}`}
>
{signed ? (
<>
<Check className="h-4 w-4" />
<span>【已电子签发:执委会终审确认】</span>
</>
) : (
<span>确认签名并完成终稿归档发布</span>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* 大屏多页(蓝图工程图风)自定义工具类 —— 补齐参考项目里依赖的、本仓 Tailwind v4
* 默认不带的几个 utility(其余 rose/slate/font-mono 等均走 Tailwind 原生)。
* 仅在 DashboardApp 子树内通过 import 引入,作用域不污染全局。
*/
/* ─────────────────────────────────────────────────────────────────────────
* 深色主题 token(《深蓝色 UI 规范》§1 取值)—— 挂在 DashboardApp 根 `.dash-root`,
* 组件保留浅色 Tailwind 类、仅追加 `dark:bg-[var(--dash-*)]` 变体引用本表,
* 浅色模式不受影响、深色模式整体切到深蓝规范色(色值集中此处,组件不散落 HEX)。
* ──────────────────────────────────────────────────────────────────────── */
.dash-root {
--dash-page: #0c1836; /* 页面背景 zs-004-09 */
--dash-card: #122347; /* 卡片背景 zs-004-08 */
--dash-elev: #1a2f5a; /* 容器/内嵌面板 zs-004-07 */
--dash-inset: #0c1836; /* 更深内嵌块(表头/代码态) */
--dash-border: #1a2f5a; /* 常规边框/分隔 zs-004-07 */
--dash-border-soft: #2d4473; /* 次级/虚线/控件边框 zs-004-06 */
--dash-text-strong: #ffffff; /* 标题/强调 */
--dash-text: #c5cfe0; /* 正文 zs-004-02 */
--dash-text-sec: #6e88b0; /* 次级 zs-004-04 */
--dash-text-dim: #4a6491; /* 禁用/弱 zs-004-05 */
--dash-primary: #1677ff; /* 主操作/品牌强调(替代浅色 rose)zs-002-06 */
--dash-primary-soft: rgba(22, 119, 255, 0.16); /* 主色浅底 */
--dash-accent: #13c2c2; /* 辅色 zs-003-06 */
}
/* 深色下网格 overlay 改为极淡深蓝描线(浅色仍走上方 .bg-grid-slate-100)。 */
.dark .dash-root .bg-grid-slate-100 {
background-image:
linear-gradient(to right, rgba(45, 68, 115, 0.35) 1px, transparent 1px),
linear-gradient(to bottom, rgba(45, 68, 115, 0.35) 1px, transparent 1px);
}
/* DraftingCard 四角工程角标取色:浅色品牌正红 #F5222D,深色切深蓝规范主蓝。
* (角标元素只设单边 border 宽度,用专用类直接给 border-color,避开 Tailwind
* 任意值色类在单边宽度场景下的不生效问题。) */
.dash-corner {
border-color: #f5222d;
}
.dark .dash-root .dash-corner {
border-color: var(--dash-primary);
}
/* DraftingCard 的极淡绘图网格 overlay(参考 `bg-grid-slate-100`)。 */
.bg-grid-slate-100 {
background-image:
linear-gradient(to right, rgba(241, 245, 249, 0.9) 1px, transparent 1px),
linear-gradient(to bottom, rgba(241, 245, 249, 0.9) 1px, transparent 1px);
background-size: 16px 16px;
}
/* 缓慢自转(Settings/Sliders 图标,参考 `animate-spin-slow`)。 */
@keyframes rt-dash-spin-slow {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: rt-dash-spin-slow 6s linear infinite;
}
/* 隐藏横向 Tab 滚动条(参考 `scrollbar-none`)。 */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* range 滑块在白底蓝图风下的细节微调(DetailView 权重沙盘若启用)。 */
.rt-dash input[type="range"] {
accent-color: currentColor;
}
@@ -0,0 +1,439 @@
/**
* 大屏多页数据上下文 —— DashboardApp 通过 Provider 注入,各组件用 `useDashboardData()`
* 读取,避免把同一份数据逐层穿透 props(对齐参考项目用静态 `data.ts` 的写法,但改成
* 运行时可注入真实圆桌数据)。
*
* 三块内容:
* 1) `DashboardDataValue` 契约 + Context/Provider/hook;
* 2) `DEMO_DATA`:不传真实数据时的回落演示数据(≈ 参考项目 data.ts,验证视觉用);
* 3) `adaptReportData(ReportData → DashboardDataValue)`:把 Step3 的 report-json 真实
* 数据映射成大屏组件消费的形状(KPI/共识/图表/席位/路线/风险/排行)。
*/
import { createContext, useContext, type ReactNode } from "react";
import type { ReportData, ReportSeat } from "@/roundtable-planning/lib/report-template";
import type {
BrandScore,
ConsensusData,
DashChart,
MetricItem,
RiskCategory,
RoadmapPhase,
SeatCard,
} from "./types";
export interface DashboardDataValue {
meta: { title: string; subtitle: string; generatedAt: string };
/** 顶部 KPI 带。 */
METRIC_DATA: MetricItem[];
/** 总控最终共识。 */
consensus: ConsensusData;
/** 多维竞争力排行(真实数据由图表/席位推导,可为空)。 */
BRAND_SCORES: BrandScore[];
/** 核心风险分类分布(真实数据由各席位风险按等级聚合)。 */
RISK_CATEGORIES: RiskCategory[];
/** 识别到的风险总数(环形图镂空中心的大数字)。 */
riskTotal: number;
/** 数据研判中心图表(最多 3 个)。 */
CHARTS: DashChart[];
/** 席位交付。 */
SEAT_CARDS: SeatCard[];
/** 执行路线阶段。 */
ROADMAP_PHASES: RoadmapPhase[];
}
// ── 取色:图表/KPI/席位强调色序(对齐 report-template 的 light accents) ──────────────
export const ACCENTS = ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"];
const RISK_COLORS = { high: "#DC2626", medium: "#D97706", low: "#059669" } as const;
const Ctx = createContext<DashboardDataValue | null>(null);
export function DashboardDataProvider({
value,
children,
}: {
value: DashboardDataValue;
children: ReactNode;
}) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useDashboardData(): DashboardDataValue {
const v = useContext(Ctx);
if (!v) throw new Error("useDashboardData 必须在 <DashboardDataProvider> 内使用");
return v;
}
// ── adaptReportData:真实 report-json → 大屏数据 ────────────────────────────────────
/** 一个席位是否“总结/总控”性质(总览页全宽置底)。 */
function looksLikeSummary(name: string): boolean {
return /总结|总控|结论|综述|summary|总览/i.test(name);
}
/** 从席位指标取一个“达成度”代表值(首个指标,缺省 0)。 */
function seatCompleteness(seat: ReportSeat): number {
return seat.metrics.length > 0 ? Math.round(seat.metrics[0].value) : 0;
}
function adaptSeat(seat: ReportSeat, idx: number): SeatCard {
const details = seat.sections.flatMap((s) => s.bullets).slice(0, 6);
return {
id: seat.id,
title: seat.name,
completeness: seatCompleteness(seat),
description: seat.summary,
sections: seat.sections.map((s) => ({ heading: s.heading, bullets: s.bullets })),
details,
metrics: seat.metrics.map((m) => ({ label: m.label, value: Math.round(m.value) })),
risks: seat.risks.map((r, i) => ({
id: `${seat.id}-r${i}`,
text: r.label,
level: r.level === "mid" ? "medium" : r.level,
})),
dependsOn: [...seat.dependsOn],
isSummary: looksLikeSummary(seat.name),
};
}
/** 排行榜:优先用 bar/radar 图表,否则按各席位达成度推导,保证中列总有内容。 */
function deriveBrandScores(report: ReportData): BrandScore[] {
const ranking = report.charts.find((c) => c.type === "bar" || c.type === "radar");
if (ranking && ranking.items.length > 0) {
return ranking.items.slice(0, 8).map((it, i) => ({
name: it.label,
score: Math.max(0, Math.min(100, Math.round(it.value))),
color: ACCENTS[i % ACCENTS.length],
}));
}
return report.seats
.filter((s) => !looksLikeSummary(s.name))
.map((s, i) => ({
name: s.name,
score: seatCompleteness(s),
color: ACCENTS[i % ACCENTS.length],
}))
.filter((b) => b.score > 0);
}
/** 风险分布:把所有席位风险按 高/中/低 聚合成三类(环形图 + 图例)。 */
function deriveRiskCategories(report: ReportData): RiskCategory[] {
const buckets: Record<"high" | "medium" | "low", string[]> = { high: [], medium: [], low: [] };
for (const s of report.seats) {
for (const r of s.risks) {
const lv = r.level === "mid" ? "medium" : r.level;
buckets[lv].push(r.label);
}
}
const total = buckets.high.length + buckets.medium.length + buckets.low.length;
if (total === 0) return [];
const defs: Array<{ key: "high" | "medium" | "low"; name: string }> = [
{ key: "high", name: "高风险" },
{ key: "medium", name: "中风险" },
{ key: "low", name: "低风险" },
];
return defs
.filter((d) => buckets[d.key].length > 0)
.map((d) => {
const pct = Math.round((buckets[d.key].length / total) * 100);
return {
name: d.name,
value: pct,
percentage: pct,
color: RISK_COLORS[d.key],
details: buckets[d.key],
};
});
}
/** report-json → 大屏数据。generatedAt 由调用方传当前时间字符串。 */
export function adaptReportData(report: ReportData, generatedAt = ""): DashboardDataValue {
const seats = report.seats.map(adaptSeat);
const riskCount = report.seats.reduce((n, s) => n + s.risks.length, 0);
const METRIC_DATA: MetricItem[] = report.kpis.map((k, i) => ({
id: String(i + 1),
label: k.label,
value: k.value,
unit: k.unit ?? "",
trend: k.trend === "up" ? { type: "up", value: "" } : k.trend === "down" ? { type: "down", value: "" } : undefined,
color: ACCENTS[i % ACCENTS.length],
}));
return {
meta: { title: report.title, subtitle: report.subtitle, generatedAt },
METRIC_DATA,
consensus: { headline: report.consensus.headline, points: [...report.consensus.points] },
BRAND_SCORES: deriveBrandScores(report),
RISK_CATEGORIES: deriveRiskCategories(report),
riskTotal: riskCount,
CHARTS: report.charts.slice(0, 3).map((c) => ({ type: c.type, title: c.title, items: [...c.items] })),
SEAT_CARDS: seats,
ROADMAP_PHASES: report.timeline.map((t, i) => ({
id: `S${i + 1}`,
title: t.label,
description: t.desc,
highlights: [],
})),
};
}
// ── DEMO_DATA:参考项目演示数据(无真实数据时回落,验证视觉用) ─────────────────────────
const DEMO_SEATS: SeatCard[] = [
{
id: "scheme",
title: "方案设计",
analyst: "战略研究部组长",
completeness: 92,
description:
"提出四阶段×五维度分析框架(市场总量、品牌格局、车型梯队、价格带分布、技术路线),为全研讨提供方法论骨架。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
},
],
details: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
metrics: [
{ label: "框架完整性评分", value: 92 },
{ label: "指标覆盖度评分", value: 88 },
],
risks: [
{ id: "s1", text: "缺少独立政策/监管维度,难评估双积分变动。", level: "medium" },
{ id: "s2", text: "四阶段转折判断(2025 直接成熟)可能过早。", level: "medium" },
],
dependsOn: [],
},
{
id: "ux",
title: "用户体验分析师",
analyst: "用户体验与产品定义专家",
completeness: 90,
description: "揭示用户需求三级跳(能跑就行 → 智能体验决胜 → 场景化生态决胜),量化智驾/座舱对忠诚度的拉动。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
},
],
details: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
metrics: [{ label: "体验洞察深度", value: 90 }],
risks: [
{ id: "u1", text: "NPS 估算非全量官方普查,缺乏跨品牌同一标准。", level: "high" },
{ id: "u2", text: "用户体验画像缺乏大跨度样本与消费分层解读。", level: "medium" },
],
dependsOn: ["scheme"],
},
{
id: "ad",
title: "广告规划",
analyst: "数字营销与渠道设计总监",
completeness: 85,
description: "洞察从功能教育向心智共鸣与强渠道体系变迁的三级跳,定量对比销售渠道重塑的竞争力量。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
},
],
details: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
metrics: [{ label: "营销策略覆盖度", value: 85 }],
risks: [
{ id: "a1", text: "营销费用保密性高,华为问界联合体费用拆分困难。", level: "high" },
{ id: "a2", text: "各品牌生命周期不同,直接拉平效率易产生偏差。", level: "medium" },
],
dependsOn: ["scheme", "ux"],
},
{
id: "risk",
title: "风险审查",
analyst: "合规与风险首席防线官",
completeness: 88,
description: "识别预测中的系统性不确定点,严防幸存者偏差与线性推算,将宏观政策与供应链黑天鹅嵌入共识。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
},
],
details: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
metrics: [{ label: "风险识别全面度", value: 88 }],
risks: [
{ id: "r1", text: "海外芯片制裁与底层传感器半导体对冲考量不足。", level: "high" },
{ id: "r2", text: "2026 购置税退坡的心理压力与提前释放未被消化。", level: "high" },
],
dependsOn: ["scheme", "ux", "ad"],
},
{
id: "summary",
title: "总结陈述",
analyst: "战略决策终审发言人",
completeness: 0,
description:
"整合各席位共识与冲突,拉平决策共识,向执委会交付高战略敏感度、高实用度、防抖性强的演变总账。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
},
],
details: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
metrics: [],
risks: [],
dependsOn: ["scheme", "ux", "ad", "risk"],
isSummary: true,
},
];
export const DEMO_DATA: DashboardDataValue = {
meta: {
title: "新能源乘用车市场格局与竞争分析",
subtitle: "四阶段 × 五维度框架,覆盖品牌/车型销量、市场份额及竞争态势演变(2019-2026)",
generatedAt: "2026-06-13 01:15",
},
METRIC_DATA: [
{ id: "1", label: "研讨参与席位", value: 5, unit: "个", color: ACCENTS[0] },
{ id: "2", label: "共识达成度", value: 92, unit: "%", trend: { type: "up", value: "" }, color: ACCENTS[4] },
{ id: "3", label: "识别关键风险", value: 12, unit: "项", color: ACCENTS[2] },
{ id: "4", label: "分析框架维度", value: 5, unit: "个", color: ACCENTS[3] },
{ id: "5", label: "覆盖品牌", value: 8, unit: "个", color: ACCENTS[1] },
{ id: "6", label: "覆盖时间跨度", value: 8, unit: "年", color: ACCENTS[5] },
],
consensus: {
headline:
"中国新能源乘用车市场经历四阶段演变,已形成比亚迪/特斯拉领跑、理想/问界追赶的三层阵营格局,竞争主线从功能转向智能体验与品牌心智。",
points: [
"三层阵营清晰定格:第一梯队比亚迪/特斯拉领跑,第二梯队理想/问界深度追赶,第三梯队蔚来/小鹏/小米差异化拼杀。",
"智能体验主导决策权重:智驾 + 座舱合占购车决策权重约 55%,用户体验完成“能跑就行 → 场景化生态”升维。",
"渠道与品牌三级跳跃:营销重塑历经功能教育 → 产品差异化 → 品牌心智驱动,直营/智选渠道成为最锋利切割手段。",
"宏观与微观风险需加条件限定:方法论与数据置信度中等,地缘政治与购置税退坡波动强烈,结论需声明边界。",
"核心争执调和:调降蔚来换电保值乐观度,小米长期出货改为多情景上限,半导体与地缘加入测试环境。",
],
},
BRAND_SCORES: [
{ name: "比亚迪", score: 92, color: "#E11D48", marketShare: "28.5%", strengths: ["垂直整合产业链", "混动纯电双轮驱动", "规模效应极致成本"], vehicles: ["秦L DM-i", "宋PLUS", "海鸥", "仰望U8"] },
{ name: "理想", score: 82, color: "#0D9488", marketShare: "8.4%", strengths: ["奶爸车定位精准", "增程解决里程焦虑", "全家共享车内生态"], vehicles: ["理想L9", "理想L8", "理想L7", "理想MEGA"] },
{ name: "问界", score: 80, color: "#D97706", marketShare: "7.8%", strengths: ["华为智选智能生态", "领先 ADS 智驾算法", "华为渠道深度导入"], vehicles: ["问界M9", "问界M7", "问界M5"] },
{ name: "特斯拉", score: 78, color: "#2563EB", marketShare: "12.2%", strengths: ["全球顶尖科技属性", "标准化极简生产", "超充网络自建"], vehicles: ["Model Y", "Model 3"] },
{ name: "蔚来", score: 68, color: "#0EA5E9", marketShare: "4.5%", strengths: ["换电护城河", "VIP 会员社交圈", "多品牌矩阵发力"] },
{ name: "小鹏", score: 65, color: "#64748B", marketShare: "3.9%", strengths: ["端到端智驾自研", "MONA 高性价比", "出海欧洲先锋"] },
],
RISK_CATEGORIES: [
{ name: "方法论/数据风险", value: 35, percentage: 35, color: "#E11D48", details: ["NPS 估算缺乏统一标准", "营销费用披露模糊难拆分", "座舱权重打分偏差大"] },
{ name: "外部环境/地缘风险", value: 30, percentage: 30, color: "#0D9488", details: ["欧美反补贴冲击出口链", "2026 购置税退坡转折", "电池关键矿物贸易壁垒"] },
{ name: "格局时效性风险", value: 20, percentage: 20, color: "#2563EB", details: ["小米等新势力爆发超预期", "智驾端到端可能绝杀长尾", "燃油车反攻引发价格下探"] },
{ name: "营销效率偏差风险", value: 15, percentage: 15, color: "#D97706", details: ["忽略品牌生命周期差异", "问界借母品牌 IP 难核算", "传统经销商转型评估不足"] },
],
riskTotal: 12,
CHARTS: [
{
type: "line",
title: "核心渗透率走势 (2019-2026)",
items: [
{ label: "2019", value: 4.7 },
{ label: "2020", value: 5.4 },
{ label: "2021", value: 14.8 },
{ label: "2022", value: 25.6 },
{ label: "2023", value: 35.7 },
{ label: "2024", value: 38.6 },
{ label: "2025", value: 42.5 },
{ label: "2026", value: 48.5 },
],
},
{
type: "donut",
title: "2026 阵营市场份额沙盘",
items: [
{ label: "比亚迪", value: 28.5 },
{ label: "特斯拉", value: 12.2 },
{ label: "理想汽车", value: 8.4 },
{ label: "华为问界", value: 7.8 },
{ label: "蔚来汽车", value: 4.5 },
{ label: "其他品牌", value: 38.6 },
],
},
{
type: "radar",
title: "比亚迪五维核心战力",
items: [
{ label: "性价比", value: 95 },
{ label: "三电技术", value: 92 },
{ label: "智能座舱", value: 75 },
{ label: "营销渠道", value: 95 },
{ label: "用户忠诚", value: 82 },
],
},
],
SEAT_CARDS: DEMO_SEATS,
ROADMAP_PHASES: [
{
id: "S1",
title: "政策驱动期",
period: "2019-2020",
description: "补贴退坡与突发事件交织,特斯拉上海工厂国产化落地,新势力在生死边缘探索。",
highlights: ["年销量约 120 万辆", "整体渗透率 <5%", "多针对 B 端或尝鲜消费者", "特斯拉催促全产业链启动"],
},
{
id: "S2",
title: "爆发成长期",
period: "2021-2022",
description: "市场由政策拉动转为市场驱动,比亚迪 DM-i 大爆发,家庭智能 SUV 与微型车两极爆发。",
highlights: ["比亚迪月销攀升至 20 万辆", "渗透率冲破 25%+", "宏光 MINI、理想 ONE 大单品", "锂价飞天、芯片荒逼迫自主替代"],
},
{
id: "S3",
title: "淘汰洗牌期",
period: "2023-2024",
description: "价格战见骨,合资燃油主力败退,长尾品牌因资金链断裂出局,智驾开城热潮席卷行业。",
highlights: ["合资折扣创历史新高", "多品牌停产裁员", "城市 NOA 爆发式落地", "渗透率切入 35%"],
},
{
id: "S4",
title: "成熟分化期",
period: "2025-2026",
description: "增速中枢向下,进入健康毛利质量竞争,大出海进入深水区,AI 深度决定品牌宿命。",
highlights: ["月渗透向 45-50% 冲击", "AI 端到端成基本标配", "弱势车企转代工或解体", "智能座舱与底盘融合"],
},
],
};
@@ -0,0 +1,21 @@
/**
* 大屏卡片网格列数自适应 —— 一行最多 4 个;不足 4 个时撑满整行(2 个就各占半行),
* 避免固定 `lg:grid-cols-4` 在卡片少时右侧大片留白。席位交付总览 / 执行路线共用。
*
* 返回的列数类全部为字面量,确保 Tailwind JIT 能在编译期扫描到。配合外层固定
* `grid grid-cols-1 ... gap-4` 使用(base 单列,md/lg 按数量铺开)。
*/
export function autoGridColsClass(count: number): string {
switch (Math.min(Math.max(count, 0), 4)) {
case 1:
return "lg:grid-cols-1";
case 2:
return "md:grid-cols-2 lg:grid-cols-2";
case 3:
return "md:grid-cols-2 lg:grid-cols-3";
case 4:
return "md:grid-cols-2 lg:grid-cols-4";
default:
return "";
}
}
@@ -0,0 +1,104 @@
/**
* 大屏多页(蓝图工程图风)数据类型 —— 源自参考项目 `types.ts`,针对圆桌真实数据改造:
* - `TabType` 由写死的 6 个固定席位联合类型,放开成 `string`('overview' + 任意席位 id);
* - `MetricItem.color` 由 Tailwind class 串改成十六进制色(适配“按主题/序号自动取色”);
* - `SeatCard` 增加 `sections`/`dependsOn`/`isSummary`,承接真实数据的结构化小节与依赖;
* - `SeatRisk.level` 增补 `'low'`(真实契约是 high|mid|low,mid→medium)。
*/
/** 'overview' = 总览页;其余为席位 id(真实数据里是任意 string)。 */
export type TabType = string;
export interface MetricItem {
id: string;
label: string;
value: string | number;
unit: string;
trend?: {
type: "up" | "down";
value: string;
};
/** 十六进制强调色(图标/描边)。 */
color: string;
}
/** 总控共识:一句话结论 + 若干要点。 */
export interface ConsensusData {
headline: string;
points: string[];
}
export interface BrandScore {
name: string;
/** 0-100 综合指数。 */
score: number;
color: string;
/** 市场份额(如 "28.5%");真实数据可能缺省。 */
marketShare?: string;
/** 竞争优势/亮点;真实数据可能缺省。 */
strengths?: string[];
/** 代表车型/产物;真实数据可能缺省。 */
vehicles?: string[];
}
export interface RiskCategory {
name: string;
/** 在环形图里的占比权重(0-100)。 */
value: number;
percentage: number;
color: string;
details: string[];
}
/** 通用图表(数据研判中心):line=趋势 / bar=排名 / donut=占比 / radar=多维。 */
export interface DashChart {
type: "line" | "bar" | "donut" | "radar";
title: string;
items: { label: string; value: number }[];
}
export interface SeatRisk {
id: string;
text: string;
level: "high" | "medium" | "low";
}
export interface SeatMetric {
label: string;
/** 0-100。 */
value: number;
}
export interface SeatSection {
heading: string;
bullets: string[];
}
export interface SeatCard {
id: TabType;
title: string;
analyst?: string;
/** 达成度/完整度 0-100(取主指标),用于卡片进度条。 */
completeness: number;
/** 一两句结论摘要。 */
description: string;
/** 结构化小节(席位专页主体)。 */
sections: SeatSection[];
/** 扁平要点(总览卡片速览 / 兼容旧渲染)。 */
details: string[];
risks: SeatRisk[];
metrics: SeatMetric[];
/** 依赖的其它席位 id(产物承接),渲染成互跳 chips。 */
dependsOn: string[];
/** 是否“总结/总控”席位(总览页里全宽置底展示)。 */
isSummary?: boolean;
}
export interface RoadmapPhase {
id: string;
title: string;
/** 时间区间(如 "2019-2020");真实数据可能缺省。 */
period?: string;
description: string;
highlights: string[];
}
@@ -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;
}
@@ -1201,8 +1218,9 @@ export function useStep2Orchestration(
if (speakerId !== null && item.id === speakerId) {
return { ...item, status: "研讨中" };
}
// 发言结束(不再研讨中)的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") {
return { ...item, status: "在线" };
return { ...item, status: "已完成" };
}
return item;
}),
@@ -1215,7 +1233,7 @@ export function useStep2Orchestration(
(speakerId: number) => {
// 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释),
// 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中",
// 又要把上一轮 / 历史残留下来的"研讨中"统一清成"在线",保证右侧"参与角色"
// 又要把上一轮 / 历史残留下来的"研讨中"统一标记成"已完成",保证右侧"参与角色"
// 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的
// 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部
// 消费方,真正的"高亮排他"靠 roles 的 status reset。
@@ -1223,7 +1241,8 @@ export function useStep2Orchestration(
setRoles((prev) =>
prev.map((item) => {
if (item.id === speakerId) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
// 上一轮发言已结束的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") return { ...item, status: "已完成" };
return item;
}),
);
@@ -1241,8 +1260,9 @@ export function useStep2Orchestration(
});
setRoles((prev) =>
prev.map((item) =>
// 该席位本轮输出完成 —— 标记为「已完成」(带勾选样式),而非回退到「在线」。
item.id === speakerId && item.status === "研讨中"
? { ...item, status: "在线" }
? { ...item, status: "已完成" }
: item,
),
);
@@ -1254,14 +1274,15 @@ export function useStep2Orchestration(
setActiveSpeakerSet(new Set());
setRoles((prev) =>
prev.map((item) =>
item.status === "研讨中" ? { ...item, status: "在线" } : item,
// 研讨收尾:仍在研讨中的席位置为「已完成」,而非回退到「在线」。
item.status === "研讨中" ? { ...item, status: "已完成" } : item,
),
);
}, [setRoles]);
/**
* DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"标记成"已完成"。
* activeSpeakerSet 天然是 Set,这里放整批 speakerId。
*/
const markParallelSpeakers = useCallback(
@@ -1271,7 +1292,8 @@ export function useStep2Orchestration(
setRoles((prev) =>
prev.map((item) => {
if (wanted.has(item.id)) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
// 上一并行批次发言已结束的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") return { ...item, status: "已完成" };
return item;
}),
);
@@ -1335,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 {
@@ -1440,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;
@@ -1528,8 +1643,14 @@ export function useStep2Orchestration(
Math.min(95, parseFloat((prev + 8).toFixed(1))),
);
// 优化3:续轮强化收敛指引——信息足够就**明确收口**,结论开头标注【已达成共识】,
// 减少弱模型该收口时还乱派 / 该继续时草草收尾的两类偏差。
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
PARALLEL_JUDGE_NOTE;
}
if (safetyCounter >= MAX_CYCLES) {
@@ -1549,11 +1670,15 @@ export function useStep2Orchestration(
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
setDialogueContent,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
markParallelSpeakers,
ingestParallelDelta,
resetParallelStreams,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
@@ -2526,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();
@@ -2603,6 +2748,7 @@ export function useStep2Orchestration(
handleBudgetChange,
handleSaveAgentAndRecalculate,
hydrateFromDraft,
applyLiveJobFrame,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
@@ -0,0 +1,508 @@
/**
* 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,
type ReportData,
} 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;
/** 当前大屏的结构化数据(report-json 解析结果)—— 供应用内 React 大屏直接渲染。 */
data: ReportData | 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 [data, setData] = useState<ReportData | 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;
// 进入大屏「交互区」即视为用户想看抽取过程:有已记录的预览锚点(流式 stub /
// 已渲染 HTML)就**强制接管沙箱**展示本流产物(之前只在面板已开时接管,会导致
// 中途进交互区看不到抽取过程的空白)。离开交互区(enabled=false)只让出预览权,
// 不主动关面板(沙箱可继续停留,切回经典/其它流时各自接管)。
const last = lastAutoOpenRef.current;
if (enabled && last) {
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);
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
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);
// 纯 HTML 恢复路径(旧版 marker 存稿):清掉上一条草稿残留的结构化 data,
// 否则应用内 React 大屏的 dashboardData memo 会优先用旧 data、不随切换刷新。
// (hydrateFromJson 会在调用本函数后再 setData(parsed) 覆盖回正确数据。)
setData(null);
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 parsed = parseReportJson(jsonStr);
if (!parsed) return;
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
},
[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,
data,
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,
};
@@ -52,6 +52,8 @@ export interface BusinessChain {
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
ownerName?: string;
createdAt?: string;
updatedAt?: string;
}
@@ -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,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { splitInlineReasoning, stripReasoning } from "./reasoning";
test("空输入返回空正文与 null 推理", () => {
assert.deepEqual(splitInlineReasoning(""), { content: "", reasoning: null });
});
test("无 think 标签时原样返回正文", () => {
assert.deepEqual(splitInlineReasoning("纯正文,无推理"), {
content: "纯正文,无推理",
reasoning: null,
});
});
test("形态 1:完整 <think>…</think> 提取推理,保留正文", () => {
const { content, reasoning } = splitInlineReasoning("<think>先想一下</think>这是答案");
assert.equal(content, "这是答案");
assert.equal(reasoning, "先想一下");
});
test("形态 2:多段 think 合并推理", () => {
const { content, reasoning } = splitInlineReasoning(
"<think>第一段</think>正文A<think>第二段</think>正文B",
);
assert.equal(content, "正文A正文B");
assert.equal(reasoning, "第一段\n\n第二段");
});
test("形态 3:流式未闭合 <think> 尾段算推理", () => {
const { content, reasoning } = splitInlineReasoning("正文<think>还在想…");
assert.equal(content, "正文");
assert.equal(reasoning, "还在想…");
});
test("形态 4:模板注入式只吐闭合 </think>,前导内容算推理", () => {
const { content, reasoning } = splitInlineReasoning(
"我需要分析一下需求</think>这是最终方案",
);
assert.equal(content, "这是最终方案");
assert.equal(reasoning, "我需要分析一下需求");
});
test("形态 4 流式:尚未吐出 </think> 时全部当正文(后续闭合再归位)", () => {
// 未出现任何标签 → 无从区分,先按正文渲染。
const { content, reasoning } = splitInlineReasoning("正在推理中,还没收到闭合标签");
assert.equal(content, "正在推理中,还没收到闭合标签");
assert.equal(reasoning, null);
});
test("安全兜底:正文里绝不残留任何 think 标签字面量", () => {
const { content } = splitInlineReasoning("答案前</think>答案中<think>");
assert.ok(!content.includes("<think>"));
assert.ok(!content.includes("</think>"));
});
test("stripReasoning 只返回正文", () => {
assert.equal(stripReasoning("<think>推理</think>交付正文"), "交付正文");
assert.equal(stripReasoning("前导推理</think>交付正文"), "交付正文");
});
@@ -1,15 +1,18 @@
/**
* 把模型流式文本里的 `<think>...</think>` 块拆出来,作为「思考过程」单独展示。
*
* 兼容三种形态:
* 兼容四种形态:
* 1) **完整** `<think>foo</think>bar` —— 提取 `foo` 作 reasoning,正文为 `bar`
* 2) **多段** `<think>a</think>x<think>b</think>y` —— reasoning 合并为 `a\n\nb`,正文为 `xy`
* 3) **流式未闭合** `bar<think>partial...` —— reasoning 为 `partial...`(尾段),正文为 `bar`
* 4) **模板注入式(只吐闭合)** `foo</think>bar` —— 开标签 `<think>` 被 chat template
* 注入到 prompt 里、模型只生成闭合标签(DeepSeek-R1 / QwQ 等推理模型的典型行为)。
* 此时首个 `</think>` 之前没有配对的 `<think>`,把它之前的内容整体算 reasoning,正文为 `bar`。
*
* 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但
* 这里**额外处理了未闭合的 `<think>`** —— 流式渲染时如果模型还没吐出 `</think>`,
* 主聊天那份实现会把 `<think>` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段
* 也算作 reasoning。
* 这里**额外处理了未闭合的 `<think>`(形态 3)和只吐闭合的 `</think>`(形态 4)** ——
* 主聊天靠后端 `additional_kwargs.reasoning_content` 单列推理,而圆桌多智能体流式只取
* `content` 文本,推理标签会内联在正文里,故这里需要把这两种内联形态都兜住。
*
* Inline `<think>foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头:
* 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等
@@ -18,6 +21,9 @@
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
const OPEN_TAG = "<think>";
const CLOSE_TAG = "</think>";
// 兜底:正文里绝不应残留任何 think 标签字面量,清掉任何孤立的 `<think>` / `</think>`。
const ANY_THINK_TAG_RE = /<\/?think>/gi;
export interface SplitReasoningResult {
/** 正文(去掉所有 `<think>...</think>` 块以及尾段未闭合 `<think>` 之后内容)。 */
@@ -30,14 +36,27 @@ export function splitInlineReasoning(text: string): SplitReasoningResult {
if (!text) return { content: "", reasoning: null };
const reasoningParts: string[] = [];
// 先处理所有完整 `<think>...</think>` 闭合块。
const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => {
// 形态 4(模板注入式,只吐闭合):首个 `</think>` 出现在任何 `<think>` 之前(或根本
// 没有 `<think>`),说明开标签被 chat template 注入、模型只生成了闭合标签。把第一个
// `</think>` 之前的内容整体算作 reasoning,正文从闭合标签之后继续解析。
let working = text;
const firstClose = working.indexOf(CLOSE_TAG);
const firstOpen = working.indexOf(OPEN_TAG);
if (firstClose >= 0 && (firstOpen < 0 || firstClose < firstOpen)) {
const lead = working.slice(0, firstClose).trim();
if (lead) reasoningParts.push(lead);
working = working.slice(firstClose + CLOSE_TAG.length);
}
// 形态 1/2:处理所有完整 `<think>...</think>` 闭合块。
const closed = working.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) reasoningParts.push(normalized);
return "";
});
// 处理尾段未闭合 `<think>` —— 流式情况。
// 形态 3:尾段未闭合 `<think>` —— 流式情况。
// 如果 closed 里还残留 `<think>` 字面量,说明这一段还没收到 `</think>`,
// 把它之后的所有内容视为 reasoning-in-progress,正文截到 `<think>` 之前。
let finalContent = closed;
@@ -48,8 +67,16 @@ export function splitInlineReasoning(text: string): SplitReasoningResult {
finalContent = closed.slice(0, openIdx);
}
// 安全兜底:无论上面是否完全配对,正文里都不该再出现 think 标签字面量。
finalContent = finalContent.replace(ANY_THINK_TAG_RE, "");
return {
content: finalContent.trim(),
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n").trim() : null,
};
}
/** 只要正文、丢弃推理过程的便捷封装(用于「交付内容」类只需主体输出的场景)。 */
export function stripReasoning(text: string): string {
return splitInlineReasoning(text).content;
}
@@ -0,0 +1,160 @@
/**
* 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" });
// 蓝图工程图风(参考设计图):页面 #F8FAFC / 玫瑰红主色 #E11D48 / slate 文字
assert.ok(light.includes("#F8FAFC"));
assert.ok(light.includes("#E11D48"));
assert.ok(light.includes("#1E293B"));
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,975 @@
/**
* 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" | "line";
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 {
// 优先 ```report-json,其次 ```json(模型常用);都没有则裸 JSON 对象(含契约关键键)。
let start = text.lastIndexOf(FENCE);
let fenceLen = FENCE.length;
if (start < 0) {
const j = text.toLowerCase().lastIndexOf("```json");
if (j >= 0) {
start = j;
fenceLen = "```json".length;
}
}
if (start >= 0) {
const after = text.slice(start + fenceLen);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
return `${text.slice(0, start).trimEnd()}${close < 0 && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
}
// 裸 JSON(无围栏):首个 { 起,看起来是本契约才替换成提示。
const first = text.indexOf("{");
if (first >= 0 && looksLikeReportJson(text.slice(first))) {
const last = text.lastIndexOf("}");
const closed = last > first;
const tail = closed ? text.slice(last + 1) : "";
return `${text.slice(0, first).trimEnd()}${!closed && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
}
return text;
}
// ── 解析(容错:取最后一个 report-json 代码块;缺字段给安全默认) ─────────────────
const FENCE = "```report-json";
const STREAM_MSG = "\n\n> 📊 正在输出大屏结构化数据,右侧沙箱稍后自动渲染…";
const DONE_MSG = "\n\n> 📊 已生成大屏数据,右侧沙箱已渲染预览(可继续输入修改要求调整)。";
/** 文本是否像本契约 JSON(含关键键),避免把无关 {} 误当数据。 */
function looksLikeReportJson(s: string): boolean {
return /"(seats|consensus|kpis|charts|timeline)"\s*:/.test(s);
}
/** 收集所有 ```lang\n...``` 代码块(lang 可空)。 */
function collectFencedBlocks(text: string): Array<{ lang: string; body: string }> {
const blocks: Array<{ lang: string; body: string }> = [];
const re = /```([^\n`]*)\r?\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
blocks.push({ lang: m[1].trim().toLowerCase(), body: m[2].trim() });
}
return blocks;
}
/** 扫描出所有顶层平衡的 {…} 对象(字符串内的花括号不计),用于从夹杂文字里捞裸 JSON。 */
function balancedJsonObjects(text: string): string[] {
const out: string[] = [];
let depth = 0;
let start = -1;
let inStr = false;
let esc = false;
for (let i = 0; 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;
else if (ch === "{") {
if (depth === 0) start = i;
depth++;
} else if (ch === "}" && depth > 0) {
depth--;
if (depth === 0 && start >= 0) {
out.push(text.slice(start, i + 1));
start = -1;
}
}
}
return out;
}
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)。
* 容错:模型未必严格用 ```report-json 围栏(DeepSeek 等常用 ```json 甚至裸 JSON)——
* 依次尝试 report-json / json / 无标注 代码块(同级取最后一个),再退到未闭合围栏内容、
* 整段裸 JSON 对象。每个候选都经 coerce 校验,命中契约形状才返回。
*/
export function extractReportData(text: string | null | undefined): ReportData | null {
if (!text) return null;
const candidates: string[] = [];
const rank = (lang: string) =>
lang === "report-json" ? 0 : lang === "json" || lang === "jsonc" ? 1 : lang === "" ? 2 : 3;
collectFencedBlocks(text)
.map((b, i) => ({ b, i }))
.sort((x, y) => rank(x.b.lang) - rank(y.b.lang) || y.i - x.i)
.forEach(({ b }) => candidates.push(b.body));
// 未闭合围栏(流式截断):取 report-json fence 之后到结尾。
const f = text.lastIndexOf(FENCE);
if (f >= 0) candidates.push(text.slice(f + FENCE.length).trim());
// 裸 JSON(无围栏 / 夹在文字里):所有顶层平衡 {…} 对象,靠后的先试,契约形状的优先。
const objs = balancedJsonObjects(text);
for (const o of objs.filter(looksLikeReportJson).reverse()) candidates.push(o);
for (const o of objs.reverse()) candidates.push(o);
for (const body of candidates) {
const data = body ? parseReportJson(body) : null;
if (data) return data;
}
return null;
}
/** 解析裸 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 === "line"
? type
: "bar") as "bar" | "donut" | "radar" | "line",
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":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
' "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)",
},
// 蓝图/工程图风(参考大屏设计图):白底 + slate 网格、玫瑰红(rose-600)强调、
// slate 中性文字、卡片四角红色角标、席位色序(scheme红/ux靛/ad绿/risk琥珀/summary天蓝)。
light: {
bgPage: "#F8FAFC",
bgGradient:
"radial-gradient(1100px 520px at 78% -12%,rgba(225,29,72,.05),transparent),linear-gradient(180deg,#FFFFFF,#F8FAFC)",
gridLine: "rgba(148,163,184,.16)",
card: "#FFFFFF",
cardBorder: "rgba(226,232,240,.9)",
cardGlow: "0 3px 10px rgba(15,23,42,.04)",
bracket: "#E11D48",
text: "#475569",
textSecondary: "#64748B",
textStrong: "#1E293B",
heading: "#1E293B",
primary: "#E11D48",
link: "#E11D48",
titleColor: "linear-gradient(90deg,#1E293B,#334155)",
navBg: "rgba(255,255,255,.95)",
trackBg: "#F1F5F9",
accents: ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"],
risk: { high: "#DC2626", mid: "#D97706", low: "#059669" },
modalMask: "rgba(15,23,42,.42)",
},
};
// ── 渲染(确定性模板,全部文本 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 = 50;
const SW = 14;
const C = 2 * Math.PI * R;
let acc = 0;
const segs = items
.map((it, i) => {
const len = (Math.max(0, it.value) / total) * C;
const seg = `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="${SW}" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
acc += len;
return seg;
})
.join("");
// 中心:总分类数 + 标签(对齐设计图环形图的镂空中心)。
const center = `<text x="70" y="68" text-anchor="middle" class="dc-num">${items.length}</text><text x="70" y="82" text-anchor="middle" class="dc-cap">类</text>`;
return `<svg viewBox="0 0 140 140" role="img"><circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--track)" stroke-width="${SW}"/>${segs}${center}</svg>`;
}
// 折线图(趋势):多取前 8 点;自动归一化到画布。设计图的销量趋势用折线。
function svgLine(chart: ReportChart): string {
const items = chart.items.slice(0, 8);
if (items.length < 2) return svgBar(chart);
const W = 460;
const H = 180;
const padL = 36;
const padR = 16;
const padT = 16;
const padB = 30;
const max = Math.max(...items.map((i) => i.value), 1);
const min = Math.min(...items.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / (items.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = items.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
const grid = [0, 0.25, 0.5, 0.75, 1]
.map((k) => {
const gy = (padT + k * (H - padT - padB)).toFixed(1);
return `<line x1="${padL}" y1="${gy}" x2="${W - padR}" y2="${gy}" class="gr"/>`;
})
.join("");
const area = `<polygon points="${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}" fill="${P.primary}12"/>`;
const dots = items
.map((it, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(it.value).toFixed(1)}" r="3" fill="#fff" stroke="${P.primary}" stroke-width="2"/>`)
.join("");
const labels = items
.map((it, i) => `<text x="${x(i).toFixed(1)}" y="${H - 10}" text-anchor="middle" class="cl">${esc(it.label)}</text>`)
.join("");
return `<svg viewBox="0 0 ${W} ${H}" role="img">${grid}${area}<polyline points="${pts}" fill="none" stroke="${P.primary}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>${dots}${labels}</svg>`;
}
// 雷达:只画网格 + 数据多边形 + 顶点(**不在 SVG 内放文字标签**,标签由 chartLegend
// 作 HTML 旁注,避免 SVG 文字溢出/重叠——这是参考实现不错乱的关键)。
function svgRadar(chart: ReportChart): string {
const items = chart.items.slice(0, 6);
const n = Math.max(items.length, 3);
const cx = 100;
const cy = 100;
const R = 72;
const max = Math.max(...items.map((i) => i.value), 1);
const pt = (i: number, r: number): [number, number] => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
const ptS = (i: number, r: number) => pt(i, r).map((v) => v.toFixed(1)).join(",");
const grid = [0.25, 0.5, 0.75, 1]
.map((k) => `<polygon points="${Array.from({ length: n }, (_, i) => ptS(i, R * k)).join(" ")}" class="gr"/>`)
.join("");
const axes = Array.from({ length: n }, (_, i) => {
const [px, py] = pt(i, R);
return `<line x1="${cx}" y1="${cy}" x2="${px.toFixed(1)}" y2="${py.toFixed(1)}" class="gr"/>`;
}).join("");
const poly = `<polygon points="${items.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}26" stroke="${P.primary}" stroke-width="2"/>`;
const dots = items
.map((it, i) => {
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
return `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="${P.primary}"/>`;
})
.join("");
return `<svg viewBox="0 0 200 200" role="img">${grid}${axes}${poly}${dots}</svg>`;
}
// 图表旁注(色点 + 名称 + 值):donut 用分类色点、radar 用主色点。
function chartLegend(chart: ReportChart, singleColor: boolean): string {
return `<div class="clist">${chart.items
.slice(0, 6)
.map(
(it, i) =>
`<div class="cli"><span class="cdot" style="background:${singleColor ? P.primary : P.accents[i % P.accents.length]}"></span><span class="cnm">${esc(it.label)}</span><b>${esc(String(it.value))}</b></div>`,
)
.join("")}</div>`;
}
function chartHtml(chart: ReportChart): string {
const t = `<h4 class="ctitle">${esc(chart.title || "图表")}</h4>`;
if (chart.type === "donut")
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cdonut">${svgDonut(chart)}</div>${chartLegend(chart, false)}</div></div>`;
if (chart.type === "radar")
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cradar">${svgRadar(chart)}</div>${chartLegend(chart, true)}</div></div>`;
if (chart.type === "line")
return `<div class="card chart">${t}<div class="cgraph wide">${svgLine(chart)}</div></div>`;
return `<div class="card chart">${t}<div class="cgraph wide">${svgBar(chart)}</div></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"><div class="mrow"><span>${esc(m.label)}</span><b>${m.value}</b></div><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div></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;
// 导航图标(内联 SVG,currentColor 跟随选中态变红):总览=指南针,席位按序循环。
const SVG = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
const NAV_COMPASS = SVG(
'<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
);
const NAV_ICONS = [
SVG('<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>'),
SVG('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
SVG('<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>'),
SVG('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
SVG('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/>'),
SVG('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'),
];
const nav =
`<a class="tab" href="#overview">${NAV_COMPASS}<span>总览</span></a>` +
seats
.map(
(s, i) =>
`<a class="tab" href="#${esc(s.id)}">${NAV_ICONS[i % NAV_ICONS.length]}<span>${esc(s.name)}</span></a>`,
)
.join("");
// 导航当前页高亮(纯 CSS):用 :has 反查 #page:target → 高亮对应 tab;
// overview 在「无任何 :target」时默认高亮。每个 seat 生成一组规则。
const sel = (id: string) => `.tab[href="#${id}"]`;
const ovBase = `body:not(:has(.page:target)) ${sel("overview")},body:has(#overview:target) ${sel("overview")}`;
const seatBase = seats.map((s) => `body:has(#${esc(s.id)}:target) ${sel(s.id)}`).join(",");
const allActive = seatBase ? `${ovBase},${seatBase}` : ovBase;
const navActiveCss = `${allActive}{background:#fff;color:var(--primary);box-shadow:0 1px 5px rgba(15,23,42,.1);font-weight:700;border-color:${P.primary}33}`;
const KPI_ICONS = ["📊", "📈", "⚠️", "🧩", "🏆", "📅"];
const kpis = data.kpis
.map((k, i) => {
const ac = P.accents[i % P.accents.length];
return `<div class="card kpi">
<div class="khead"><span class="klabel">${esc(k.label)}</span><span class="kico" style="background:${ac}1a;color:${ac}">${KPI_ICONS[i % KPI_ICONS.length]}</span></div>
<div class="kval">${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 console = data.charts.length
? `<section class="console">
<div class="chead"><h2 class="sec">📊 数据研判中心</h2><span class="status">STATUS: REAL-TIME CONSOLE</span></div>
<div class="charts3">${data.charts.map(chartHtml).join("")}</div>
</section>`
: "";
const overview = `<section class="page" id="overview">
<div class="kpis">${kpis}</div>
<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>
${console}
<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("");
// 右栏:席位评定量化得分(大数字"X分",对齐设计图右侧栏)。
const scores = s.metrics.length
? `<div class="card"><h3>席位评定量化得分</h3><div class="scores">${s.metrics
.map(
(m) =>
`<div class="score"><span class="slbl">${esc(m.label)}</span><div class="sbig" style="color:${color}">${m.value}<small>分</small></div><div class="track"><div class="fill" style="width:${m.value}%;background:${color}"></div></div></div>`,
)
.join("")}</div></div>`
: "";
const risksCard = s.risks.length
? `<div class="card"><h3>风险提示 / 关注点</h3>${riskChips(s.risks)}</div>`
: "";
const depCard = `<div class="card"><h3>席位依赖关系</h3>${depChips(s, seats) || '<p class="empty">无显式依赖</p>'}</div>`;
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)} <span class="ptag">SEAT · ${esc(s.id.toUpperCase())}</span></h2><p>${esc(s.summary)}</p></div></header>
<div class="dgrid">
<div class="dmain">
<div class="card"><h3>席位核心交付 · 使命概述</h3><p class="lead">${esc(s.summary || "(无摘要)")}</p></div>
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
</div>
<div class="dside">
${scores}
${risksCard}
${depCard}
</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=参考大屏设计图 / dark=深蓝 token,渲染时注入 palette) */
: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};--bracket:${P.bracket}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Inter","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
font-size:13px;background:${P.bgPage}}
/* 蓝图圆点网格底纹(对齐设计原图的 radial dots) */
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
background-image:radial-gradient(${P.gridLine} 1.3px,transparent 1.3px);background-size:20px 20px}
.accentbar{position:relative;z-index:2;height:4px;background:linear-gradient(90deg,${P.accents[0]},${P.accents[3]} 50%,${P.accents[4]})}
main{position:relative;z-index:1;padding:20px 26px 48px;max-width:1500px;margin:0 auto}
/* Header:标题 + 副标题(换行)+ 生成时间药丸(非吸顶,居中容器内) */
.top{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:14px;
padding-bottom:16px;margin-bottom:20px;border-bottom:1px solid ${P.primary}24}
.top .ttl{min-width:0}
.top h1{font-size:22px;font-weight:800;letter-spacing:.3px;color:var(--strong);line-height:1.25}
.top .sub{margin-top:6px;font-size:12.5px;color:var(--dim);letter-spacing:.3px}
.top .meta{font-size:11px;color:var(--dim);font-family:"JetBrains Mono",Consolas,monospace;letter-spacing:.5px;
background:#fff;border:1px solid var(--line);border-radius:6px;padding:6px 12px;white-space:nowrap}
/* Nav:浅灰圆角胶囊容器 + 图标文字 tab(对齐设计原图) */
nav{position:relative;z-index:1;margin-bottom:22px}
.navwrap{display:inline-flex;flex-wrap:wrap;gap:6px;background:var(--track);border:1px solid var(--line);border-radius:10px;padding:5px}
.tab{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 12px;
border:1px solid transparent;border-radius:7px;color:var(--dim);text-decoration:none;transition:.15s;background:transparent}
.tab:hover{color:var(--strong);background:rgba(255,255,255,.6)}
.tab svg{width:14px;height:14px;flex:none}
.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(8px)}to{opacity:1}}
/* 蓝图卡片:白底 + 四角玫瑰红角标 + 极淡网格 overlay */
.card{position:relative;border:1px solid var(--line);border-radius:6px;padding:17px 19px;
background:${P.card};box-shadow:${P.cardGlow};transition:box-shadow .3s,transform .3s,border-color .3s}
.card::before,.card::after{content:"";position:absolute;width:12px;height:12px;border-color:var(--bracket);border-style:solid;z-index:2}
.card::before{top:-1px;left:-1px;border-width:2px 0 0 2px}
.card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0}
.card>*{position:relative;z-index:1}
.card h3{font-size:13.5px;font-weight:700;letter-spacing:.3px;color:var(--strong);margin-bottom:12px;
display:flex;align-items:center;gap:7px;padding-bottom:11px;border-bottom:1px solid var(--line)}
.card h3::before{content:"";width:9px;height:9px;border-radius:2px;background:var(--primary);flex:none}
/* KPI */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:13px;margin-bottom:18px}
.kpi{padding:15px 17px;min-height:104px;display:flex;flex-direction:column;justify-content:space-between}
.kpi .khead{display:flex;justify-content:space-between;align-items:flex-start;gap:6px}
.kpi .klabel{font-size:11.5px;color:var(--dim);font-weight:500;letter-spacing:.3px}
.kpi .kico{width:30px;height:30px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;flex:none}
.kpi .kval{font-size:30px;font-weight:800;letter-spacing:-.5px;color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;line-height:1;display:flex;align-items:baseline;gap:3px;margin-top:8px}
.kpi small{font-size:12px;font-weight:500;color:var(--dim)}
.kpi .tr{font-size:11px;font-weight:700;padding:1px 5px;border-radius:3px;margin-left:4px}
.tr.up{color:${P.risk.low};background:${P.risk.low}1a}.tr.dn{color:${P.risk.high};background:${P.risk.high}1a}
.ovgrid{display:grid;grid-template-columns:1.25fr 1fr;gap:13px;margin-bottom:20px;align-items:start}
@media(max-width:980px){.ovgrid{grid-template-columns:1fr}}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin-bottom:14px}
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
/* 共识:玫瑰红竖条摘要 + 要点列表 */
.consensus .headline{font-size:13.5px;font-weight:600;color:var(--strong);line-height:1.7;margin-bottom:13px;
background:${P.primary}0d;padding:12px 14px;border-radius:4px;border-left:3px solid var(--primary)}
.consensus ul{list-style:none;display:grid;gap:11px}
.consensus li{display:flex;gap:9px;font-size:12px;line-height:1.65;color:var(--txt)}
.consensus li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:6px;flex:none}
.consensus .more{display:inline-block;margin-top:13px;font-size:11.5px;font-weight:600;color:var(--primary);text-decoration:none}
.consensus .more:hover{text-decoration:underline}
.charts{display:grid;gap:13px}
.chart svg{width:100%;height:auto;display:block}
.cl{fill:var(--dim);font-size:9.5px}.cv{fill:var(--strong);font-size:11px;font-weight:700;font-family:monospace}
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.gridLine.replace(/[\d.]+\)$/, "0.6)")};stroke:var(--line);stroke-width:1}
.dc-num{fill:var(--strong);font-size:26px;font-weight:800;font-family:monospace}
.dc-cap{fill:var(--dim);font-size:9px}
.donut{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:center}.donut svg{width:128px;flex:none}
.lgs{display:grid;gap:7px;min-width:130px}.lg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
.lg span{flex:1}.lg b{color:var(--strong);font-family:monospace;font-weight:700}
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;flex:none}
.sec{font-size:15px;font-weight:700;color:var(--strong);margin:8px 0 13px;display:flex;align-items:center;gap:8px}
.sec small{font-size:11.5px;color:var(--dim);font-weight:400}
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:13px;margin-bottom:20px}
.seat{display:flex;flex-direction:column;text-decoration:none;color:inherit}
.seat:hover{border-color:var(--ac);box-shadow:0 8px 22px rgba(15,23,42,.09);transform:translateY(-2px)}
.shead{display:flex;align-items:center;gap:7px;padding-bottom:10px;border-bottom:1px solid var(--line);margin-bottom:0}
.shead::before{display:none}
.shead i{width:9px;height:9px;border-radius:50%;background:var(--ac);flex:none}
.shead h3{margin:0;padding:0;border:0;font-size:12.5px;flex:1}
.shead h3::before{display:none}
.go{font-size:10px;color:var(--dim);display:flex;align-items:center;gap:2px}
.seat:hover .go{color:var(--ac)}
.seat .anly{font-size:10px;color:var(--dim);font-family:monospace;text-transform:uppercase;letter-spacing:.5px;margin:11px 0 6px}
.seat p{font-size:11.5px;color:var(--dim);line-height:1.65;margin-bottom:12px;flex:1;
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.metrics{display:grid;gap:9px}
.metric span{font-size:10.5px;color:var(--dim);font-weight:500}
.metric .mrow{display:flex;justify-content:space-between;margin-bottom:4px}
.metric b{font-size:11px;color:var(--strong);font-family:monospace;font-weight:700}
.track{height:6px;border-radius:99px;background:var(--track);overflow:hidden}
.fill{height:100%;border-radius:99px}
.chips{display:flex;flex-direction:column;gap:6px;margin-top:12px;padding-top:11px;border-top:1px solid var(--line)}
.chip{font-size:10px;padding:3px 8px;border-radius:4px;border-left:2px solid;display:flex;align-items:center;gap:5px}
.chip::before{content:"⚠";font-size:9px}
.tl{margin-top:2px}
.tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:11px}
.tl li{position:relative;padding:12px 14px;border:1px solid var(--line);border-left:3px solid var(--primary);background:var(--track);border-radius:0 5px 5px 0}
.tl b{display:block;font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:5px}
.tl span{font-size:11px;color:var(--dim);line-height:1.6}
/* 席位专页 */
.pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;font-size:12px}
.pbar a{color:var(--primary);text-decoration:none;font-weight:600}.pnav{display:flex;gap:16px}.pnav a{color:var(--dim);font-weight:500}.pnav a:hover{color:var(--primary)}
.phead{display:flex;align-items:center;gap:13px;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.phead i{width:6px;height:38px;border-radius:3px;background:var(--ac);flex:none}
.phead h2{font-size:20px;font-weight:800;color:var(--strong)}
.phead p{font-size:12px;color:var(--dim);margin-top:4px;line-height:1.6}
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}
.dep{font-size:11px;font-weight:600;padding:4px 11px;border:1px dashed var(--primary);border-radius:4px;color:var(--primary);text-decoration:none}
.dep.sup{border-color:${P.accents[4]};color:${P.accents[4]}}
.dep:hover{background:var(--primary)12}
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:13px;margin-bottom:14px}
.sect ul{list-style:none;display:grid;gap:8px;font-size:12px;line-height:1.65}
.sect li{display:flex;gap:8px;color:var(--txt)}
.sect li::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
.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:700px;width:100%;max-height:84vh;overflow:auto}
.mbody ul{list-style:none;display:grid;gap:9px;font-size:13px;margin-top:6px}
.mbody li{display:flex;gap:9px;line-height:1.65}.mbody li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
.mclose{position:absolute;top:10px;right:16px;font-size:22px;color:var(--dim);text-decoration:none;line-height:1}
.mclose:hover{color:var(--primary)}
/* ── 导航当前页高亮(注入纯 CSS :target 规则) ── */
${navActiveCss}
/* ── 数据研判中心:三列网格 + 图表尺寸约束(修每行一个、巨大) ── */
.console{margin-bottom:20px}
.chead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:13px}
.chead .sec{margin:0}
.status{font-size:10px;font-family:"JetBrains Mono",Consolas,monospace;color:var(--dim);background:var(--track);border:1px solid var(--line);padding:3px 8px;border-radius:4px;letter-spacing:.5px;white-space:nowrap}
.charts3{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}
@media(max-width:1100px){.charts3{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.charts3{grid-template-columns:1fr}}
.chart{display:flex;flex-direction:column}
.ctitle{font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}
.ctitle::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--primary);flex:none}
.cbody{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.cgraph{flex:none}
.cgraph svg{display:block}
.cgraph.cdonut svg{width:128px;height:128px}
.cgraph.cradar svg{width:150px;height:150px}
.cgraph.wide{width:100%}
.cgraph.wide svg{width:100%;height:auto;max-height:210px}
.clist{flex:1;min-width:120px;display:grid;gap:7px;align-content:center}
.cli{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
.cli .cdot{width:9px;height:9px;border-radius:2px;flex:none}
.cli .cnm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cli b{color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;font-weight:700}
/* ── 席位专页:左右网格 + 卡片纵向间距(修上下无间距) ── */
.dgrid{display:grid;grid-template-columns:2fr 1fr;gap:14px;align-items:start}
@media(max-width:980px){.dgrid{grid-template-columns:1fr}}
.dmain{display:grid;gap:14px;align-content:start}
.dside{display:grid;gap:14px;align-content:start}
.lead{font-size:12.5px;color:var(--txt);line-height:1.7;margin:0}
.scores{display:grid;gap:16px}
.score .slbl{font-size:11px;color:var(--dim);display:block;margin-bottom:6px}
.sbig{font-size:30px;font-weight:800;font-family:"JetBrains Mono",Consolas,monospace;line-height:1;margin-bottom:8px}
.sbig small{font-size:12px;font-weight:500;color:var(--dim);margin-left:2px}
.empty{font-size:12px;color:var(--dim)}
</style></head>
<body>
<div class="accentbar"></div>
<main>
<header class="top"><div class="ttl"><h1>${esc(data.title)}</h1>${
data.subtitle ? `<p class="sub">${esc(data.subtitle)}</p>` : ""
}</div>${generated ? `<span class="meta">⏱ ${generated}</span>` : ""}</header>
<nav><div class="navwrap">${nav}</div></nav>
${overview}
${seatPages}
</main>
${modal}
</body></html>`;
}
@@ -79,3 +79,15 @@ test("coordinatorName 为 null 时不剔除任何席位", () => {
const result = collectRoundtableDeliverables(dialogues, null);
assert.equal(result.length, 2);
});
test("交付正文剥离 <think>…</think> 推理过程", () => {
const dialogues = [
bubble({ sender: "方案设计", content: "<think>盘算一下</think>方案正文" }),
bubble({ sender: "风险审查", content: "前导推理</think>风险正文" }),
];
const result = collectRoundtableDeliverables(dialogues, null);
assert.deepEqual(result, [
{ agentName: "方案设计", content: "方案正文", threadId: null },
{ agentName: "风险审查", content: "风险正文", threadId: null },
]);
});
@@ -1,4 +1,5 @@
import type { Step2Dialogue } from "../hooks/useStep2Orchestration";
import { stripReasoning } from "./reasoning";
/**
* 回传父页面(无界)的单个智能体交付。
@@ -29,7 +30,8 @@ export function collectRoundtableDeliverables(
}
return Array.from(lastBySeat.values()).map((d) => ({
agentName: d.sender,
content: d.content,
// 回传宿主的交付正文剥离 `<think>...</think>` 推理过程,只留主体输出。
content: stripReasoning(d.content),
threadId: d.agentThreadId ?? null,
}));
}
@@ -18,7 +18,7 @@
*
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
@@ -36,6 +36,7 @@ import {
Network,
Plus,
Save,
Sliders,
SplitSquareVertical,
Trash2,
UserPlus,
@@ -62,7 +63,6 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -113,6 +113,7 @@ import {
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
const POOL_PREFIX = "pool::";
@@ -161,6 +162,8 @@ export default function BusinessChainEditorPage() {
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
// 两步向导:1=基本信息(标题/描述/总控提示词),2=链条编排(双栏拖拽编辑器)。
const [step, setStep] = useState<1 | 2>(1);
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
const showDetail = useCallback((id: string, name?: string) => {
@@ -205,6 +208,16 @@ export default function BusinessChainEditorPage() {
[seatNameById],
);
// 步骤一右侧「精美流程图」的数据源:过滤空 stage,带上各阶段目标作为依赖标签。
const flowStages = useMemo(
() =>
stageGroups
.filter((g) => g.seats.length > 0)
.map((g) => ({ seats: g.seats, goal: g.goal ?? null })),
[stageGroups],
);
const hasFlowContent = flowStages.length > 0;
// ─── 链条列表(一次性)─────────────────────────────────────────────────
const refreshChains = useCallback(async () => {
try {
@@ -252,6 +265,7 @@ export default function BusinessChainEditorPage() {
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain));
setStep(1);
}, []);
const startNewChain = useCallback(() => {
@@ -262,6 +276,7 @@ export default function BusinessChainEditorPage() {
setDescription("");
setCoordinatorPrompt("");
setStageGroups([]);
setStep(1);
}, []);
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
@@ -615,6 +630,119 @@ export default function BusinessChainEditorPage() {
// ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
// 步骤一左侧「基本信息」表单(标题 / 描述 / 总控提示词,描述与总控均为多行文本)。
// 抽成元素:无编排内容时居中单列,有内容时与右侧流程图并排(左列)。
// 样式按设计稿「配置参数面板」还原:label 在上、圆角输入;按钮配色沿用现有 primary。
const FIELD =
"w-full rounded-md border border-border bg-muted/30 px-3.5 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground/55 hover:bg-muted/50 focus:border-primary focus:bg-card";
const basicInfoForm = (
<div className="space-y-5">
{/* 面板头:配置参数面板 / 实时编译中 */}
<div className="flex items-center justify-between">
<span className="flex select-none items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Sliders className="size-3" />
配置参数面板
</span>
<span className="flex select-none items-center gap-1 text-[11px] font-medium text-primary">
实时编译中
<span className="ml-1 size-1.5 animate-pulse rounded-full bg-emerald-500" />
</span>
</div>
{/* 标题 */}
<div className="space-y-1.5">
<label htmlFor="chain-title" className="flex items-center gap-1 text-xs font-semibold text-foreground">
标题
<span className="text-destructive">*</span>
</label>
<input
id="chain-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className={cn(FIELD, "font-medium")}
/>
</div>
{/* 描述 */}
<div className="space-y-1.5">
<label htmlFor="chain-desc" className="text-xs font-semibold text-foreground">
描述
</label>
<textarea
id="chain-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={2}
placeholder="链条用途说明:解决什么问题、适用什么场景(可选)…"
className={cn(FIELD, "resize-none leading-relaxed")}
/>
</div>
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label htmlFor="chain-coord" className="flex items-center gap-1 text-xs font-semibold text-foreground">
总控提示词
<span className="text-[10px] font-normal text-muted-foreground">(写给总控怎么分发、汇总)</span>
</label>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2.5 py-1 text-[11px] font-semibold text-primary transition hover:bg-primary/10 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化提示词"}
</button>
</div>
<div className="relative">
<textarea
id="chain-coord"
value={coordinatorPrompt}
onChange={(e) => setCoordinatorPrompt(e.target.value)}
maxLength={4000}
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
className={cn(
FIELD,
"h-[260px] resize-y p-4 font-mono text-[12px] leading-relaxed",
)}
/>
<div className="pointer-events-none absolute bottom-2 right-2 rounded-md border border-border bg-card/90 px-2 py-1 font-mono text-[9px] text-muted-foreground">
Markdown 格式可用
</div>
</div>
</div>
{/* 提示:分层并行调度规则 */}
<div className={cn("space-y-2 border border-primary/15 bg-primary/[0.05] p-4", R_CARD)}>
<div className="flex items-center gap-1.5 text-xs font-semibold text-primary">
<Info className="size-4" />
分层并行调度规则
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
处于同一<strong className="font-semibold text-foreground">阶段(Stage)</strong>的席位会被并行下发任务以压缩时延;阶段之间串行,由总控在下一阶段承接并合并上一阶段产出。
</p>
</div>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
@@ -786,94 +914,70 @@ export default function BusinessChainEditorPage() {
</div>
</header>
{/* 标题 + 描述 */}
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
maxLength={500}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
</div>
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
<div
className={cn(
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<div className="flex items-center gap-2">
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
{/* 步骤导航:① 基本信息 → ② 链条编排(点击可切换)。 */}
<nav className="flex shrink-0 items-center justify-center gap-3 border-b border-border bg-card px-6 py-2.5">
{([
{ n: 1 as const, label: "基本信息" },
{ n: 2 as const, label: "链条编排" },
]).map((s, i) => (
<Fragment key={s.n}>
{i > 0 && <span className="h-px w-10 bg-border" />}
<button
type="button"
onClick={() => setStep(s.n)}
className={cn(
"flex items-center gap-2 border px-3 py-1 text-sm font-medium transition",
R_CTRL,
step === s.n
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground",
)}
>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
"flex size-5 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
step === s.n ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
<textarea
value={coordinatorPrompt}
onChange={(e) => setCoordinatorPrompt(e.target.value)}
maxLength={4000}
rows={3}
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
</section>
{s.n}
</span>
{s.label}
</button>
</Fragment>
))}
</nav>
{/* 双栏拖拽编辑器 */}
{/* 步骤一:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
{step === 1 && (
<div className="flex min-h-0 flex-1 overflow-hidden">
{hasFlowContent ? (
<>
{/* 左:基本信息(这几个描述信息) */}
<section className="w-[400px] shrink-0 overflow-y-auto border-r border-border bg-card px-6 py-5">
{basicInfoForm}
</section>
{/* 右:编排流程图(直角连线 / 点节点看详情 / 连线显示依赖 / 缩放平移全屏) */}
<section className="relative min-w-0 flex-1">
<ChainFlowDiagram
stages={flowStages}
onNodeClick={showDetail}
className="h-full w-full"
/>
</section>
</>
) : (
<div className="mx-auto w-full max-w-3xl overflow-y-auto px-6 py-6">
{basicInfoForm}
<p className="mt-4 text-center text-xs text-muted-foreground">
还没有编排内容。点击上方「② 链条编排」去配置链条,保存后这里会显示流程图预览。
</p>
</div>
)}
</div>
)}
{/* 双栏拖拽编辑器(步骤二)*/}
{step === 2 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -975,10 +1079,11 @@ export default function BusinessChainEditorPage() {
) : null}
</DragOverlay>
</DndContext>
)}
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{previewStages.length > 0 && (
{step === 2 && previewStages.length > 0 && (
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
@@ -1028,10 +1133,12 @@ export default function BusinessChainEditorPage() {
}}
/>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
{/* 页脚(步骤二):分层编排说明。步骤切换走顶部步骤导航。 */}
{step === 2 && (
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,36 @@
/**
* 大屏多页「新窗口打开」的独立全屏宿主页 —— 渲染应用内 React 大屏(`DashboardApp`)。
*
* 浏览器新标签页是一份全新文档,React 数据无法经 props 直接穿过去,故走 localStorage:
* - Step3 在点「新窗口打开」时把当前 `DashboardDataValue` 写进 localStorage(键由 URL `?k=` 带过来);
* - 本页读出该键、解析后注入 `<DashboardApp data={...} />`;读不到则回落 DEMO(与应用内一致)。
*
* 路由挂在 `App.tsx` 顶层(`PageRoutes` 之外、`AppProviders` 之下),天然无页面侧边栏 /
* workspace 导航,整屏只渲染大屏本体——正是「新窗口打开」想要的纯净大屏视图。
*/
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import type { DashboardDataValue } from "@/roundtable-planning/dashboard/data-context";
/** localStorage 键前缀:Step3 写入、本页读取;前缀统一便于清理历史键。 */
export const DASHBOARD_VIEW_STORAGE_PREFIX = "rt-dash-view:";
export default function DashboardStandalonePage() {
const [searchParams] = useSearchParams();
const data = useMemo<DashboardDataValue | undefined>(() => {
const key = searchParams.get("k");
if (!key || typeof window === "undefined") return undefined;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return undefined;
return JSON.parse(raw) as DashboardDataValue;
} catch {
return undefined;
}
}, [searchParams]);
return <DashboardApp data={data} />;
}
@@ -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[]>([]);
@@ -706,6 +732,9 @@ export default function RoundtablePlanningPage() {
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// 这是从第一步确认席位、开启的一轮**实时**研讨(非查看历史/草稿) → 清掉
// “查看历史”闸门,否则之前看过历史记录留下的 true 会一直把「后台挂起」按钮藏掉。
drafts.loadedFromDraftRef.current = false;
// 新一轮研讨 → 允许共识达成后再次向父页面回传。
consensusEmittedRef.current = false;
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
@@ -724,7 +753,7 @@ export default function RoundtablePlanningPage() {
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
},
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
@@ -869,6 +898,176 @@ 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);
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
const step3ReportRef = useRef(step3Report);
step3ReportRef.current = step3Report;
// 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次
// (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变,
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
const prevDraftIdRef = useRef(drafts.currentDraftId);
if (prevDraftIdRef.current !== drafts.currentDraftId) {
prevDraftIdRef.current = drafts.currentDraftId;
step3EnteredRef.current = false;
}
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
const hydrateStep3Flows = useCallback(() => {
step3EnteredRef.current = true;
const rep = step3ReportRef.current;
// ① 方案总结报告
const savedSum = rep?.summaryReport;
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
summaryFlow.hydrateSaved(savedSum);
} else {
summaryFlow.start();
}
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
// 那不是经典产物 → 经典照常启动。
if (rep?.html && !isDashboardHtml(rep.html)) {
reportFlow.hydrateSaved(rep);
} else {
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
}
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
if (rep?.dashboardJson) {
dashboardFlow.hydrateFromJson(rep.dashboardJson);
} else if (rep?.html && isDashboardHtml(rep.html)) {
dashboardFlow.hydrateSaved(rep.html);
} else {
dashboardFlow.start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
useEffect(() => {
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drafts.currentDraftId]);
// 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。
useEffect(() => {
if (currentStep !== 3 || step3EnteredRef.current) return;
hydrateStep3Flows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, 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 +1081,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 +1097,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 +1113,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 +1154,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 +1306,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);
}}
/>
@@ -1263,6 +1537,7 @@ export default function RoundtablePlanningPage() {
);
}}
onBackgroundSuspend={handleBackgroundSuspend}
isViewingHistory={drafts.loadedFromDraftRef.current}
onTriggerToast={triggerToast}
/>
)}
@@ -1281,30 +1556,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}
@@ -2422,6 +2682,7 @@ function RoleAvatarBadge({
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const isDone = role.status === "已完成";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
@@ -2439,6 +2700,12 @@ function RoleAvatarBadge({
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
{renderRoleAvatar(role, agentNameToRole, size)}
</div>
{/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */}
{isDone && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 ring-2 ring-white">
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
</span>
)}
</div>
);
@@ -16,11 +16,12 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app";
export type LightAppType = "window_open" | "iframe";
export type RouteParamType = "login" | "theme";
/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数用 themeValue。 */
/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数随当前系统主题自动注入。 */
export interface LightAppRouteParam {
key: string;
paramType: RouteParamType;
@@ -28,7 +29,7 @@ export interface LightAppRouteParam {
source?: string | null;
/** 登录参数且 source==="other" 时的字面值。 */
customValue?: string | null;
/** 主题参数的值(dark-blue / red / blue,可改)。 */
/** 主题参数配置:映射串 `light=red;dark=darkBlue`(或纯值);运行时按当前系统主题取对应值(见 resolveThemeParamValue)。 */
themeValue?: string | null;
}
@@ -104,7 +105,12 @@ function toApp(s: ServerApp): LightApp {
paramType: p.paramType,
source: p.source ?? null,
customValue: p.customValue ?? null,
themeValue: p.themeValue ?? null,
// 主题参数读取时规范化为完整映射串(red=..;blue=..;dark-blue=..),
// 让旧的纯值/部分映射也能随主题切换,无需重新保存。
themeValue:
p.paramType === "theme"
? stringifyThemeMap(parseThemeMap(p.themeValue))
: (p.themeValue ?? null),
})),
mountLocation: s.mount_location ?? null,
icon: s.icon ?? null,
@@ -27,13 +27,82 @@ export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
{ label: "其他(自定义值)", value: "other" },
];
/** 主题参数示例值(深蓝 / 红 / 蓝),可改。 */
export const THEME_EXAMPLE_OPTIONS: { label: string; value: string }[] = [
{ label: "深蓝色 dark-blue", value: "dark-blue" },
{ label: "红色 red", value: "red" },
{ label: "蓝色 blue", value: "blue" },
/**
* 我方主题色键。当前启用 red(浅色) / dark-blue(深色);blue 为后续将新增的主题。
* 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。
*/
export type AppThemeColor = "red" | "blue" | "dark-blue";
/** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */
export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [
{ key: "red", label: "红色", dot: "#F5222D" },
{ key: "blue", label: "蓝色", dot: "#1677FF" },
{ key: "dark-blue", label: "深蓝色", dot: "#002B5C" },
];
/**
* 主题键别名:兼容历史/手填里用 light/dark 等写法的映射键,归一到当前主题色键。
* 查映射时按别名顺序尝试,避免「键对不上 → 回退到 red」的恒红 bug。
*/
const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = {
red: ["red", "light"],
blue: ["blue"],
"dark-blue": ["dark-blue", "darkblue", "dark"],
};
/**
* 读取我方当前主题色,以 ThemeContext 写在 <html> 上的 `dark` class 为准
* (含嵌入场景的强制主题)。当前:深色=dark-blue,浅色=red。
* TODO: 后续新增 blue 主题后,在此区分 blue 与 dark-blue。
*/
export function getCurrentTheme(): AppThemeColor {
if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) {
return "dark-blue";
}
return "red";
}
/** 解析主题映射串(`red=red;dark-blue=darkBlue`)为 { 主题色键: 目标值 }。值不做 trim,便于表单回显。 */
export function parseThemeMap(raw: string | null | undefined): Record<string, string> {
const map: Record<string, string> = {};
for (const pair of (raw ?? "").split(/[;,]/)) {
const idx = pair.search(/[=:]/);
if (idx <= 0) continue;
const k = pair.slice(0, idx).trim().toLowerCase();
map[k] = pair.slice(idx + 1);
}
return map;
}
/** 把主题映射对象序列化为映射串,按 APP_THEME_COLORS 顺序输出,缺省走同名(identity)。 */
export function stringifyThemeMap(map: Record<string, string>): string {
return APP_THEME_COLORS.map((c) => `${c.key}=${map[c.key] ?? c.key}`).join(";");
}
/**
* 按我方当前主题,从配置中取目标轻应用对应的值(跳转 / iframe 嵌套时注入)。
*
* 配置支持两种写法(管理员在「应用管理」主题参数里配置):
* 1) 映射串:`red=red;blue=blue;dark-blue=darkBlue` —— 按当前主题(含别名)取对应值;
* 取不到时回退到主题色名本身(identity),而非盲目回退首个值(会恒为 red)。
* 2) 纯值:`red` —— 不区分主题,原样返回。
*/
export function resolveThemeParamValue(
raw: string | null | undefined,
theme: AppThemeColor = getCurrentTheme(),
): string {
const value = (raw ?? "").trim();
if (!value) return theme;
// 不含键值分隔符 → 纯值,不区分主题原样返回
if (!/[=:]/.test(value)) return value;
const map = parseThemeMap(value);
for (const key of THEME_KEY_ALIASES[theme] ?? [theme]) {
if (map[key] != null) return map[key].trim();
}
// 映射里没有当前主题(含别名)→ 退回主题色名本身,避免恒为 red
return theme;
}
/** 读取并解析 localStorage 中的 userInfo。 */
export function readUserInfo(): Record<string, unknown> {
try {
@@ -50,9 +119,12 @@ export function readUserInfo(): Record<string, unknown> {
export function resolveRouteParamValue(
param: LightAppRouteParam,
userInfo: Record<string, unknown> = readUserInfo(),
theme?: AppThemeColor,
): string {
if (param.paramType === "theme") {
return param.themeValue ?? "";
// 主题参数:按我方当前主题,从管理员配置的映射中取目标应用对应的值。
// theme 显式传入时优先(避免切主题瞬间 DOM 的 dark class 还没更新导致读旧值)。
return resolveThemeParamValue(param.themeValue, theme);
}
// login
if (param.source === "other") {
@@ -74,10 +146,11 @@ export function resolveRouteParamValue(
export function buildLightAppUrl(
app: Pick<LightApp, "url" | "routeParams">,
userInfo: Record<string, unknown> = readUserInfo(),
theme?: AppThemeColor,
): string {
const pairs = (app.routeParams ?? [])
.filter((p) => p.key)
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo))}`);
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
if (pairs.length === 0) return app.url;
const sep = app.url.includes("?") ? "&" : "?";
return `${app.url}${sep}${pairs.join("&")}`;
+2 -1
View File
@@ -60,5 +60,6 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
# 圆桌后台作业网关:inprocess=进程内驱动真模型;simulated=模拟(默认)
# 圆桌后台作业网关:缺省即 inprocess(进程内驱动真模型,后台挂起开箱即用);
# 仅前端联调时显式设 simulated(真实进度但不调真模型)。本行可删,留作文档。
ROUNDTABLE_JOB_GATEWAY=inprocess
@@ -265,6 +265,8 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -38,6 +38,17 @@ if TYPE_CHECKING:
T = TypeVar("T")
def resolve_roundtable_gateway_mode(raw: str | None) -> str:
"""解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
只有显式写 ``simulated`` 才用模拟网关(前端联调用:真实进度、不调真模型);
缺省 / 空串 / 其它值一律落到 ``inprocess`` —— 部署时无需在 .env 里配置任何值,
后台挂起开箱即用(Phase 3b 已在 Windows 本地与内网 Docker 验证)。
"""
mode = (raw or "").strip().lower()
return "simulated" if mode == "simulated" else "inprocess"
def get_config(request: Request) -> AppConfig:
"""Return the app-scoped ``AppConfig`` stored on ``app.state``."""
config = getattr(request.app.state, "config", None)
@@ -225,25 +236,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择:
# - "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report);
# - 其它/缺省(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
# 默认 simulated —— 真网关需在能起完整后端栈 + 真模型的环境验证后再开。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode):
# - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**,
# 部署时无需任何配置即可用后台挂起;
# - 显式 "simulated"(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
if app.state.roundtable_job_store is not None:
import os
from app.gateway.roundtable_job_executor import RoundtableJobExecutor
gateway_mode = (os.getenv("ROUNDTABLE_JOB_GATEWAY") or "simulated").strip().lower()
if gateway_mode == "inprocess":
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app, user_id=p.user_id, agents=p.agents, model=p.model
)
logger.info("[roundtable-jobs] gateway = inprocess (real models)")
else:
gateway_mode = resolve_roundtable_gateway_mode(os.getenv("ROUNDTABLE_JOB_GATEWAY"))
if gateway_mode == "simulated":
from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
# delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
@@ -251,6 +254,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
p.agents, mode=p.mode, delay=3.0
)
logger.info("[roundtable-jobs] gateway = simulated")
else:
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app, user_id=p.user_id, agents=p.agents, model=p.model
)
logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
app.state.roundtable_job_executor = RoundtableJobExecutor(
app.state.roundtable_job_store,
@@ -3,7 +3,7 @@
实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types),
但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent,
跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。
不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request,
不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app, state=...)`` 当虚拟 request,
``set_current_user`` 由执行器设好)。
本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议——
@@ -14,6 +14,7 @@
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
@@ -26,6 +27,7 @@ from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.agents.roundtable_orchestrator.types import (
@@ -33,6 +35,7 @@ from deerflow.agents.roundtable_orchestrator.types import (
ReportResult,
SeatRef,
SeatTurn,
SummaryResult,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.serialization import serialize_channel_values
@@ -41,6 +44,7 @@ logger = logging.getLogger(__name__)
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
_REPORT_FILE = "方案总览.html"
_SUMMARY_FILE = "方案总结报告.md"
# 各角色的工具裁剪 / thinking / reasoning 全部取自单一数据源 roundtable_run_policy
# (与 multi_agent 的 _leader_run / _special_run 同源),不再在本文件硬编码——历史上
@@ -192,7 +196,9 @@ async def _run_agent_turn(
on_disconnect="continue",
multitask_strategy="reject",
)
request = SimpleNamespace(app=app)
# 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth;
# 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。
request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None))
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
if getattr(record, "task", None) is not None:
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
@@ -225,6 +231,92 @@ def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
return None
def _read_summary_md(app, thread_id: str, user_id: str) -> str | None:
"""从 summary thread 的 outputs 目录读出总结报告 Markdown。
优先 ``方案总结报告.md``;找不到就扫目录里**任意 .md**(取体积最大的那个),
容忍总结 agent 用了别的文件名。镜像 ``_read_report_html``。
"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
preferred = outputs_dir / _SUMMARY_FILE
if preferred.exists():
return preferred.read_text(encoding="utf-8")
if outputs_dir.exists():
mds = sorted(
outputs_dir.glob("*.md"),
key=lambda p: p.stat().st_size,
reverse=True,
)
if mds:
logger.info("summary md: 方案总结报告.md not found, using %s", mds[0].name)
return mds[0].read_text(encoding="utf-8")
logger.warning("summary md: no .md found in %s", outputs_dir)
except Exception: # noqa: BLE001
logger.warning("read summary md failed for thread %s", thread_id, exc_info=True)
return None
# Step3 产物自愈重试次数(首轮之外的纠偏轮数)。
# 实测痛点(2026-06-12,h:\xiugai\后台日志.txt):deepseek 报告 agent 说「let me write
# it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、
# run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。
# 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。
_ARTIFACT_RETRY_LIMIT = 2
_REPORT_RETRY_MISSING = (
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
"(最常见原因:试图一次写入整份长 HTML,输出超长被截断,工具调用没有真正落盘)。\n"
"请立即重新写入,并**严格分段**:\n"
f"1. 先用 write_file 写入 /mnt/user-data/outputs/{_REPORT_FILE} 的开头部分(<!doctype html> 起,约 200-300 行);\n"
"2. 再用 write_file 的 append=true 参数分多次续写后续片段,每段同样控制在 300 行以内;\n"
"3. 直到文档以 </html> 收尾。\n"
"不要在对话里粘贴 HTML 源码,写完用一两句话总结即可。"
)
_REPORT_RETRY_TRUNCATED = (
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 已写入但**不完整**(没有 </html> 收尾,疑似上一轮输出被截断)。\n"
"请用 write_file 的 append=true 参数从中断处**继续补全剩余内容**(不要重写已有部分),"
"每段控制在 300 行以内,分多次续写直到文档以 </html> 收尾。不要在对话里粘贴源码。"
)
_SUMMARY_RETRY_MISSING = (
f"检测到 /mnt/user-data/outputs/{_SUMMARY_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
"(可能是单次输出过长被截断)。请立即用 write_file 重新写入完整报告;若内容很长,"
"先写前半部分、再用 write_file 的 append=true 参数分段续写。写完后把报告正文也输出到对话回复里。"
)
_DASHBOARD_FENCE = "```report-json"
_DASHBOARD_RETRY = (
"你刚才没有输出有效的 ```report-json 代码块(或其中的 JSON 无法解析)。"
"请重新在回复**最末尾**输出一个完整且**严格合法**的 ```report-json 代码块"
"(标准 JSON:无注释、无尾逗号、字符串用双引号),代码块之外只允许一两句说明;"
"不要调用任何工具、不要写文件、不要输出 HTML。"
)
def _extract_report_json(text: str) -> str | None:
"""从模型回复里提取最后一个 ```report-json 代码块的 JSON 字符串(须可解析)。"""
start = text.rfind(_DASHBOARD_FENCE)
if start < 0:
return None
after = text[start + len(_DASHBOARD_FENCE) :]
close = after.find("```")
body = (after[:close] if close >= 0 else after).strip()
if not body:
return None
try:
json.loads(body)
except Exception: # noqa: BLE001
return None
return body
def _html_complete(html: str | None) -> bool:
"""报告 HTML 是否完整可用(非空 + 有 </html> 收尾,截断检测)。"""
return bool(html and html.strip()) and "</html>" in html.lower()
def _build_seat_intro(agents: list[SeatRef]) -> str:
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
@@ -326,18 +418,111 @@ class InProcessRoundtableGateway:
return await _create_thread(self._app)
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
"""跑报告 agent,**带产物自愈**:首轮后 HTML 缺失/截断(无 </html>)→ 同线程追加
纠偏消息(分段 + append 续写)重试,至多 ``_ARTIFACT_RETRY_LIMIT`` 轮。"""
policy = roundtable_run_policy("report")
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=prompt,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
summary = _latest_ai_text(messages)
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
return ReportResult(html=html, summary=summary, model=model or self._model)
summary = ""
html: str | None = None
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id)
if _html_complete(html):
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _REPORT_RETRY_TRUNCATED if (html and html.strip()) else _REPORT_RETRY_MISSING
logger.warning(
"report html %s after attempt %d for thread %s; retrying with corrective prompt",
"truncated" if (html and html.strip()) else "missing",
attempt + 1,
report_thread_id,
)
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
final_html = html if (html and html.strip()) else summary
return ReportResult(html=final_html, summary=summary, model=model or self._model)
async def create_summary_thread(self) -> str:
ensure_roundtable_functional_agents()
return await _create_thread(self._app)
async def run_summary(self, *, summary_thread_id, prompt, model) -> SummaryResult:
"""进程内驱动 roundtable-summary 写「方案总结报告.md」+ 对话正文(与前端
useStep3Summary 同一智能体 / 同一 run policy / 同款产物),返回与
DraftStep3SummarySnapshot 对齐的结果。线程是真实持久线程——加载草稿后可续问。
带产物自愈:md 文件缺失时同线程追加纠偏消息重试(同 ``run_report``)。"""
policy = roundtable_run_policy("summary")
closing = ""
md: str | None = None
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
if md and md.strip():
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _SUMMARY_RETRY_MISSING
logger.warning(
"summary md missing after attempt %d for thread %s; retrying with corrective prompt",
attempt + 1,
summary_thread_id,
)
final_md = md if (md and md.strip()) else closing
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
"""大屏多页(模式二)的结构化数据:进程内驱动 roundtable-report 输出
```report-json(prompt 已声明禁工具/禁文件),返回可解析的 JSON 字符串。
缺失 / 坏 JSON → 同线程纠偏重试(同 run_report/run_summary 的自愈语义)。"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread(self._app)
policy = roundtable_run_policy("report")
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
thread_id=thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
)
body = _extract_report_json(_latest_ai_text(messages))
if body:
return body
if attempt >= _ARTIFACT_RETRY_LIMIT:
break
message = _DASHBOARD_RETRY
logger.warning(
"dashboard report-json missing/invalid after attempt %d for thread %s; retrying",
attempt + 1,
thread_id,
)
return None
@@ -16,9 +16,10 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Callable
from typing import Any
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
@@ -29,14 +30,13 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
# ⚠️ 临时禁用「后台挂起」的 Step3 结果绘制(报告 HTML 生成)。
# 进程内驱动报告 agent 在真模型环境下仍无法稳定写出 HTML(前端手动调
# /api/multi-agent/report/init + run/stream 可以,但后台 start_run 路径不行,
# 根因尚未定位)。临时方案:后台只跑完 Step2 研讨即收尾,不自动画报告——用户需要
# 报告时可在前端手动跑 Step3(那条路是通的)。
# 待修复后把本开关改回 True 即恢复后台自动报告。
# 详见 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md「后台报告 HTML」。
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = False
# 「后台挂起」的 Step3 自动产出(方案总结报告 md + 结果绘制 HTML)总开关。
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
# 杀死编排,见 tests/test_roundtable_inprocess_request.py),修复后恢复开启。
# 现 Step2 收口后自动跑:① roundtable-summary 写「方案总结报告.md」② roundtable-report
# 画「方案总览.html」,两段故障隔离(engine._run_step3),产物按手动同款形状写回草稿。
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True
class _JobUser:
@@ -176,8 +176,8 @@ class StartParams:
# dag 模式分层编排计划(前端 OrchestrationPlan dict):驱动引擎 _run_dag + 写进草稿 run
# 供前端流程图渲染。None = 非 dag(引擎按 mode 回退 chain/recommend)。
orchestration_plan: dict[str, Any] | None = None
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
# 测试可显式传 True 覆盖以验证报告链路。
# 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True,
# 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
@@ -58,6 +58,11 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
# 内置「知识库检索」技能:素材收集专家通用检索的默认技能。skills/ 目录被
# .gitignore 排除,所以与 agent 目录一样需要 seeder 在缺失时从资源补建。
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
_SKILL_ASSETS_DIR = _ASSETS_DIR / "skills"
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
@@ -91,10 +96,69 @@ def _ensure_one(agent_id: str) -> bool:
return True
def _skills_public_dir() -> Path:
"""skills/public 物理目录(config.yaml skills.path 解析);独立成函数便于测试替身。"""
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
def _ensure_default_search_skill() -> bool:
"""Seed the built-in knowledge-base-search skill into ``skills/public/``
when missing. Never overwrites an existing skill directory.
Returns True if the skill was just created.
"""
src = _SKILL_ASSETS_DIR / KNOWLEDGE_SEARCH_SKILL_NAME / "SKILL.md"
if not src.exists():
logger.error("Missing seed asset for skill '%s' (expected at %s)", KNOWLEDGE_SEARCH_SKILL_NAME, src)
return False
skill_dir = _skills_public_dir() / KNOWLEDGE_SEARCH_SKILL_NAME
if skill_dir.exists():
return False
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_bytes(src.read_bytes())
return True
def _ensure_researcher_default_skills() -> bool:
"""给**已存在**的 researcher agent config.yaml 补默认技能配置(就地升级)。
seeder 对已存在目录绝不整体覆盖,但旧版种子没有 ``skills`` 字段——通用
检索改为技能驱动后,老部署的智能体管理页会显示"未配置技能"(运行时虽有
knowledge-base-search 兜底,但用户看不见、也没法在它基础上增删)。只在
``skills`` 键**完全缺失**时补默认值;用户显式改过(包括清空成 ``[]``)
的配置不动。Returns True if the file was patched.
"""
import yaml
config_file = get_paths().agent_dir(RESEARCHER_AGENT_ID) / "config.yaml"
if not config_file.exists():
return False
try:
data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("researcher config.yaml unparsable, skip default-skills patch")
return False
if not isinstance(data, dict) or "skills" in data:
return False
data["skills"] = [KNOWLEDGE_SEARCH_SKILL_NAME]
config_file.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return True
def ensure_ai_writing_functional_agents() -> list[str]:
"""Seed the four ai-writing functional agents if their on-disk directories
are missing. Idempotent — fast no-op when all exist.
Also seeds the built-in knowledge-base-search skill (the researcher's
default retrieval skill) and patches a pre-existing researcher config.yaml
that predates the ``skills`` field.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the graph node falls back to the legacy
@@ -109,6 +173,16 @@ def ensure_ai_writing_functional_agents() -> list[str]:
logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created))
try:
if _ensure_default_search_skill():
logger.info("Seeded built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
except Exception:
logger.exception("Failed to seed built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
try:
if _ensure_researcher_default_skills():
logger.info("Patched researcher config.yaml with default skills")
except Exception:
logger.exception("Failed to patch researcher default skills")
return created
@@ -1,3 +1,7 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、用配置的技能编排检索、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
# 通用检索使用的技能(按顺序编排)。默认配置内置「知识库检索」;
# admin/用户可在智能体管理页替换为其它检索技能或追加多个。
skills:
- knowledge-base-search
@@ -25,9 +25,10 @@
## 产出要求(最重要)
### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
当用户**明确要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息),本轮进入**流程图模式**:
当用户**要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」之类时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息;**按语义理解,不要求逐字命中这些词**),本轮进入**流程图模式**:
- **跳过下面的 A**——不要调用 `write_file`、不要生成任何 .md 文件;
- 把**精炼的文字版总结报告**直接写在对话回复里(文字展示给用户读,结构遵循用户要求);
- 文字版总结的**默认结构**(用户只要求了结构化/格式化、**没指定**具体结构时):**对各席位交付内容逐席位格式化输出**——每个席位一节:`席位名` → 核心结论(一句话)→ 关键交付要点(编号列表,逐条提炼)→ 风险/建议(如有),最后加一节「总控共识」。用户指定了结构就完全按用户的来;
- 在回复**最末尾必须**输出一个完整的 ```flow-json 代码块(契约见「结构化流程数据」节)——这是本轮的**主产物**,绝不能省略。
未进入流程图模式时,每一轮都要做 A + B **两件事**,缺一不可:
@@ -77,27 +78,33 @@
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**自己判断**意图——报告和流程图都是可修改的,分四种情况处理:
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**按语义自己判断**意图——**绝不要求用户使用特定关键词或固定句式**。用户不会说「请重新结构化输出」这种话,他只会说「把验收环节加上」「改一下」「太长了精简点」「不对,重来」——这些都是有效的修改指令,你必须直接理解并动手,不要反问用户「您是想要结构化输出吗」。
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
**意图判断的两条核心规则**:
- **修改类消息默认作用于上一轮的主产物**:用户没说明改哪个产物时——上一轮你输出的是流程图(flow-json),那「加一个环节」「调整一下」「精简点」就是改流程图;上一轮主产物是 .md 报告,就是改报告。不要因为用户没提「流程图 / 结构化」字眼就把改流程图的意图当成改报告(反之亦然)。
- **意图实在太模糊、无法判断要改什么**(如只发「不行」「再改改」「不是这个意思」,且上下文也推不出指向)→ **不要修改任何产物、不要乱猜**,在对话正文里向用户提出一个**简短的澄清问题**并给出可选方向(如「您是想调整流程图的节点结构,还是修改文字报告的某个章节?可以具体说说想改哪里」),等用户回复后再动手。注意:澄清就写在正文里(你没有专门的澄清工具)。
报告和流程图都是可修改的,分四种情况处理:
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」,或上一轮主产物是报告时的笼统修改意见):
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
- 然后在对话回复里给出修改后的完整报告正文;
- 若此前已经输出过流程图、且这次修改影响了流程结构,可在回复末尾顺带输出更新后的完整 ```flow-json 块。
2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」):
2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」;**只要上一轮主产物是流程图,笼统的修改意见也默认按本条处理**,无需用户提到「流程图 / 结构化」字眼):
- **不要**改写 .md 报告文件;
- 在对话里简短说明改了什么,并在回复**最末尾**输出**完整修订版** ```flow-json 块——必须是**全量数据**(所有节点 + 连线),不是只给改动部分,前端会整体重绘。
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文给一段简短的文字说明,末尾输出完整 ```flow-json 块;
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」,或语义等价的说法):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文按第 ⓪ 条的默认结构(逐席位格式化各席位交付内容;用户指定结构则按用户的)给出文字总结,末尾输出完整 ```flow-json 块;
- 已有的 .md 报告**不要动**——用户随时可以在前端切回去看它。
4. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
- **不要**改写报告文件、**不要**重新 `write_file`、**不要**输出 flow-json。
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告 / 流程图」。
修改意图明确但细节有歧义时(能判断改哪个产物、只是改法有多种)→ 按最合理的理解直接改,并在回复里说明你的理解,邀请用户继续纠正;完全判断不了才走上面的「澄清提问」。
## 禁止事项
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
@@ -107,6 +114,8 @@
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
- ❌ 用户只是提问时还去改写报告文件
- ❌ 用户要求了结构化输出 / 流程图,却只写 .md 报告而不输出 flow-json 块
- ❌ 要求用户必须说出「结构化输出 / 流程图」等关键词才肯修改流程图(上一轮主产物是流程图时,笼统的修改意见就是改流程图)
- ❌ 用户意图模糊时不提澄清问题、自行乱猜乱改一通
- ❌ 流程图模式下仍调用 `write_file` 生成 / 改写 .md 文件
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
@@ -23,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from app.gateway.deps import get_config, get_local_provider
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
@@ -61,6 +61,10 @@ class ChainResponse(BaseModel):
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
# 用 LocalAuthProvider 解析;解析失败时为 None,前端回退到短 id 展示。
owner_id: str | None = None
owner_name: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -103,6 +107,31 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
async def _enrich_owner_names(rows: list[dict]) -> None:
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
与 knowledge 路由的 ``_enrich_actor_names`` 同思路:批量解析、按 id 缓存、best-effort
(解析失败 / provider 不可用时 ``owner_name`` 留空,前端回退到短 id)。
"""
try:
provider = get_local_provider()
except Exception:
return
cache: dict[str, str | None] = {}
for row in rows:
uid = row.get("owner_id")
if not uid:
row["owner_name"] = None
continue
if uid not in cache:
try:
user = await provider.get_user(uid)
cache[uid] = user.email if user else None
except Exception:
cache[uid] = None
row["owner_name"] = cache[uid]
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
@@ -211,6 +240,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope)
await _enrich_owner_names(rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -233,6 +263,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
},
)
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -243,6 +274,7 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse:
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -265,6 +297,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -29,11 +29,10 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.roundtable_job_executor import StartParams
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
from app.gateway.roundtable_job_executor import StartParams
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
@@ -182,6 +181,15 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
# 防重复挂起(服务端幂等闸门):同一草稿已有未终态作业(queued/running/awaiting_input)
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
if body.draftId:
existing = await store.get_by_draft(body.draftId, user_id=user_id)
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
return _to_response(existing)
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
job_id = uuid4().hex
row = await store.create(
@@ -493,7 +493,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None:
user_id = get_effective_user_id()
# Admins see every agent in the admin console listing (?admin=true), so
# they may also start a run against any of them.
is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin"
# ``request`` may be an in-process virtual request (SimpleNamespace) from
# the roundtable job gateway / scheduled-task runtime — those carry no
# ``state`` attribute, so the whole chain must be getattr-guarded
# (a bare ``request.state`` raises AttributeError and kills the run).
is_admin = getattr(getattr(getattr(request, "state", None), "user", None), "system_role", None) == "admin"
for agent_id in agent_ids:
try:
normalized = validate_agent_id(agent_id)
@@ -69,18 +69,20 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
# enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
# enable_skill_search:旧版「启用技能检索」按钮的兼容入口——通用检索现在
# 本来就按 agent 配置技能编排,researcher 把它按 user_query 追加检索处理
if action == "enable_skill_search":
msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新检索中…"
return {
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
# re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
# re_search:带 user_query = 在配置技能中匹配检索并**追加**素材;
# 不带输入 = 重新生成检索词、覆盖式重检(兼容旧版 extraKeywords 数组追加)
extra_kws = user_input.get("extraKeywords") or []
msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新搜索中..."
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"status": "researching",
@@ -1,4 +1,17 @@
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
"""素材收集专家节点:关键词提取 → 配置技能编排检索(含笔记) → 相关度排序。
检索类型(material_source)只有两种:
- ``general``(通用检索,默认):按素材收集专家 agent(ai-writing-researcher)
config.yaml 配置的技能顺序编排检索。内置「知识库检索」技能
(knowledge-base-search)走原生 web/intranet 关键词并发检索;其它技能用
其 description 生成定向 query 后调真实 provider 检索。
- ``notebook``(我的空间):仅用笔记 API 素材。
旧值 ``knowledge_base`` / ``skill`` 一律按 ``general`` 兼容处理。
PAUSE-1 素材确认卡的单输入框:用户用自然语言描述想补充检索的内容
(如「检索 A 的数据」)时,让模型从已配置技能中按相关度匹配,逐个检索,
把新素材**追加**到现有素材列表(URL 去重、保持原有顺序、不重排不丢弃)。
"""
from __future__ import annotations
import asyncio
@@ -6,8 +19,7 @@ import logging
import os
import re
import time
import uuid
from typing import List
from types import SimpleNamespace
from langchain_core.runnables import RunnableConfig
@@ -71,11 +83,63 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环、最多 5 个技能,
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
# 内置「知识库检索」技能名:素材收集专家 agent 的默认技能。researcher 对它
# 特殊处理——不用 description 拼 query,而是对 Step 1 生成的全部检索词做
# 原生 web/intranet 并发检索(即旧版 knowledge_base 模式的行为)。
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
_KNOWLEDGE_SKILL_DISPLAY = "知识库检索"
_KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材"
def _fallback_knowledge_skill() -> SimpleNamespace:
"""技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。"""
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
def _load_configured_skills() -> list:
"""解析素材收集专家 agent(ai-writing-researcher)配置的技能列表。
config.yaml 的 ``skills`` 字段是权威来源(admin/用户在智能体管理页可改);
未配置 / 解析失败 / 技能文件全部缺失时,一律回退为内置「知识库检索」,
保证通用检索永远可用。返回的元素带 ``name`` / ``description`` 属性。
"""
names: list[str] = []
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(RESEARCHER_AGENT_ID)
if cfg is not None:
names = [n.strip() for n in (cfg.skills or []) if isinstance(n, str) and n.strip()]
except Exception:
logger.warning("researcher: 读取 agent 配置技能失败,回退知识库检索", exc_info=True)
if not names:
return [_fallback_knowledge_skill()]
by_name: dict[str, object] = {}
try:
from deerflow.skills.storage import get_or_new_skill_storage
by_name = {s.name: s for s in get_or_new_skill_storage().load_skills(enabled_only=False)}
except Exception:
logger.warning("researcher: 加载技能目录失败", exc_info=True)
resolved: list = []
for n in names:
sk = by_name.get(n)
if sk is None and n == KNOWLEDGE_SEARCH_SKILL_NAME:
sk = _fallback_knowledge_skill()
if sk is None:
logger.warning("researcher: 配置的技能 '%s' 不存在于技能目录,已跳过", n)
continue
resolved.append(sk)
return resolved or [_fallback_knowledge_skill()]
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
@@ -247,32 +311,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
# PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
# PAUSE-1 单输入框:用户写的自然语言(想补充检索的内容),优先级最高
user_query: str = (intervention.get("user_query") or "").strip()
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
# PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
# PAUSE-1 素材确认卡用户在输入框填了自然语言(如「检索 A 的数据」):
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
# - 直接用 user_query 作为唯一检索词
# - 保留 existing material_package,按 URL 去重合并
is_user_query_search = action == "re_search" and bool(user_query)
# - 从 agent 配置的技能中匹配最相关的技能,逐个检索
# - **追加**:保留 existing material_package,新素材按 URL 去重后追加在末尾
# 旧版「启用技能检索」按钮(action=enable_skill_search)按同一语义兼容处理。
is_user_query_search = action in ("re_search", "enable_skill_search") and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# 用户在写作表单选择的素材来源:knowledge_base(默认 web/intranet 检索)/ skill(技能检索)。
# 首次检索(没有用户干预)时按它决定检索方式;缺省 knowledge_base。
material_source = (state.get("material_source") or "knowledge_base").strip()
is_first_run = not intervention
is_initial_skill_source = is_first_run and material_source == "skill"
# 技能检索触发条件(任一即可):
# - 首次检索且表单选了「技能检索」来源
# - PAUSE-1 素材确认卡用户点「启用技能检索」按钮
# 技能检索流程:
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
is_skill_search = action == "enable_skill_search" or is_initial_skill_source
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
material_source = "notebook" if raw_source == "notebook" else "general"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -291,7 +347,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher):
# 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# 检索编排(技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin
# 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。
use_agent = use_builtin_agent("researcher")
@@ -308,14 +364,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
kw_result: dict = {} # 走"用户输入 / 追加 / 技能"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_skill_search:
# 技能模式:每个 skill 会自己拼 query(user_query + skill.description),这里不用 LLM 生成关键词
keywords: list[str] = [user_query or intent]
elif is_user_query_search:
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords = [user_query]
_emit(f"按你的输入「{user_query[:30]}」检索…")
keywords: list[str] = [user_query]
_emit(f"按你的输入「{user_query[:30]}」匹配技能检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
@@ -359,7 +412,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
notebook_materials: list[Material] = []
if notebook_search_space_id:
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
notebook_token: str = cfg.get("notebook_token") or ""
@@ -373,12 +426,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if notebook_materials:
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
# Step 2: 搜索
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
# b. 按顺序遍历 skills:每个 skill 用其 description 让 LLM 生成针对性关键词
# c. 调真实 web/intranet provider 执行检索,推 invoke_skill + skill_result 事件
# d. 累积素材到 max_materials 即 break,推 stop_early
# Step 2: 检索
# 通用检索 = 配置技能编排。技能不是素材,是"检索策略 / 视角":
# a. 从 ai-writing-researcher agent config.yaml 读取配置技能(默认知识库检索)
# b. 「知识库检索」技能 → 对 Step 1 检索词做原生并发 web/intranet 检索
# c. 其它技能 → 用 skill.description 拼定向 query 调真实 provider
# d. user_query 模式(PAUSE-1 输入框)→ 先让模型按用户输入匹配技能再检索
# e. 累积素材到 max_materials 即 break,推 stop_early
skill_steps: list[dict] = []
search_errors: list[str] = []
@@ -390,188 +444,208 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": researcher_label, **step})
if is_skill_search:
# ── 技能编排检索 ──
# 技能选择走流式 LLM(推 skill_select_chunk)+ <think> 分离,前端打字机展示
picker = SkillPicker(
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
)
selected = await picker.pick(user_intent=intent)
search_errors.extend(picker.errors)
# 本轮新检索到的原始结果(统一在下方合并段转 Material、与旧素材去重)
new_results: list[SearchResult] = []
provider = None
# 真实 web/intranet provider(按 config.yaml 配置)
web_provider = get_search_provider(ai_writing_config)
# 每个 skill 尽量多取(不分摊),第一个就达到 max_materials 即可早停;
# 这才贴近"第一个技能够了就停,否则下一个补充"的语义。
per_skill = max(1, max_materials)
raw_materials_pre: list[Material] = []
seen_urls_pre: set[str] = set()
stopped_early = False
for entry in selected:
sk = entry["skill"]
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
# invoke_skill 步骤带上实际检索词(截断防过长),让用户在每次长 await 之前
# 就看到"这一步在用技能 X 检索什么",配合前端 spinner 体现进行中。
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
try:
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
res = await asyncio.wait_for(
web_provider.search(skill_query, max_results=per_skill),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", sk.name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{sk.name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
res = []
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
res = []
# 去重累积
new_count = 0
for r in res or []:
if r.url and r.url in seen_urls_pre:
continue
if r.url:
seen_urls_pre.add(r.url)
raw_materials_pre.append(Material(
id=f"m{len(raw_materials_pre)+1:03d}",
title=r.title,
content=r.content[:500],
source=f"{r.source}(via 技能 {sk.name})",
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
new_count += 1
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{sk.name}」检索到 {new_count} 条新素材",
"skill_name": sk.name,
"matched_count": new_count,
})
# 早停:累积素材已达上限
if len(raw_materials_pre) >= max_materials:
stopped_early = True
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(raw_materials_pre)} 条素材,达到上限,停止调用后续技能",
"matched_count": len(raw_materials_pre),
})
break
# 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索
all_results_nested: list = []
# 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去
elif material_source == "notebook" and notebook_search_space_id:
if material_source == "notebook" and notebook_search_space_id:
# ── 纯笔记模式 ──
# 用户在表单选了「我的空间」作为素材来源 → 跳过 web/intranet 检索,
# 用户在表单选了「我的空间」作为检索类型 → 跳过技能编排,
# 仅使用 Step 1.5 已拉取的 notebook_materials。
# 与 UI 文案"从空间中检索素材"对齐。
_emit(f"仅使用笔记素材,跳过 web/intranet 检索({len(notebook_materials)} 条)")
all_results_nested = []
else:
# ── 常规 web/intranet 模式 ──
_emit(f"仅使用笔记素材,跳过技能检索({len(notebook_materials)} 条)")
elif is_append_only:
# ── 兼容旧版追加关键词模式 ──
# 直接对用户给的关键词做原生并发检索(不走技能编排),结果追加到旧素材。
provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断。
# 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
if is_user_query_search or is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
# - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(编排循环已经把素材累积到 raw_materials_pre)
existing_pkg = state.get("material_package")
if is_skill_search:
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
# 笔记素材追加在技能素材之后
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
raw_materials.extend(notebook_materials)
else:
seen_urls: set[str] = set()
# 笔记素材优先排在最前面,赋予高相关度
raw_materials = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
for results in all_results_nested:
per_keyword = _per_keyword_limit(max_materials, len(keywords))
_emit(f"开始追加检索 {len(keywords)} 个方向…")
gathered = await asyncio.gather(
*[provider.search(kw, max_results=per_keyword) for kw in keywords],
return_exceptions=True,
)
for results in gathered:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
for r in results:
if r.url and r.url in seen_urls:
new_results.extend(results or [])
search_errors.extend(getattr(provider, "errors", []))
else:
# ── 通用检索:配置技能编排 ──
configured = _load_configured_skills()
provider = get_search_provider(ai_writing_config)
per_skill = max(1, max_materials)
ordered = configured
if is_user_query_search and len(configured) > 1:
# 用户输入了自然语言:让模型在配置技能中按相关度匹配排序
# (技能选择走流式 LLM,推 skill_select_chunk,前端打字机展示)
picker = SkillPicker(
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
candidates=configured,
)
ranked = await picker.pick(user_intent=intent)
search_errors.extend(picker.errors)
ordered = [entry["skill"] for entry in ranked] or configured
else:
_emit_skill_step({
"type": "list_skills",
"message": f"素材收集专家已配置 {len(configured)} 个检索技能",
"skill_names": [s.name for s in configured],
})
seen_urls_new: set[str] = set()
def _collect(results: list[SearchResult]) -> int:
added = 0
for r in results or []:
if r.url and r.url in seen_urls_new:
continue
if r.url:
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
seen_urls_new.add(r.url)
new_results.append(r)
added += 1
return added
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
_emit("检索完成,正在筛选排序素材…")
async def _timed_search(skill_name: str, query: str, limit: int) -> list[SearchResult]:
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
try:
return await asyncio.wait_for(
provider.search(query, max_results=limit),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
) or []
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{skill_name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
return []
except Exception as e:
logger.warning("skill[%s] search failed: %s", skill_name, e)
search_errors.append(f"skill[{skill_name}]: {e}")
return []
for sk in ordered:
is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME
if is_knowledge and not is_user_query_search:
# 原生知识库检索:对全部检索词并发检索(旧版默认路径)。
# 用户带 extra_keywords 时全部检索,否则按 keyword_count 截断。
search_keywords = keywords if extra_keywords else keywords[:keyword_count]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{_KNOWLEDGE_SKILL_DISPLAY}」检索 {total} 个方向:{('、'.join(search_keywords[:3]))[:60]}",
"skill_name": sk.name,
})
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True)
added = 0
for results in gathered:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
added += _collect(results)
search_errors.extend(getattr(provider, "errors", []))
else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
else:
skill_query = f"{intent} {sk.description or ''}".strip() or intent
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
added = _collect(await _timed_search(sk.name, skill_query, per_skill))
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材",
"skill_name": sk.name,
"matched_count": added,
})
# 早停:累积素材已达上限
if len(new_results) >= max_materials:
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(new_results)} 条素材,达到上限,停止调用后续技能",
"matched_count": len(new_results),
})
break
# 合并去重
# - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 输入框 is_user_query_search:保留旧素材 + **追加**新素材(核心交互)
# - 旧版追加关键词 is_append_only:保留旧素材 + 仅检索新关键词
existing_pkg = state.get("material_package")
preserve_existing = bool(existing_pkg) and (is_supplement or is_append_only or is_user_query_search)
seen_urls: set[str] = set()
# 笔记素材优先排在最前面,赋予高相关度
raw_materials: list[Material] = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
appended_count = 0
for r in new_results:
if r.url and r.url in seen_urls:
continue
if r.url:
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
appended_count += 1
# Step 3: 相关度排序
# 用户主动追加(输入框 / 追加关键词):保持旧素材原有顺序,新素材追加在
# 末尾,不调 LLM 重排、不按上限丢弃旧素材(只做 100 条硬上限防爆)。
rank_error = ""
if len(raw_materials) > max_materials:
is_user_append = is_user_query_search or is_append_only
if is_user_append:
materials = raw_materials[:_MAX_MAX_MATERIALS]
dropped = len(raw_materials) - len(materials)
summary = f"追加检索到 {appended_count} 条新素材,当前共 {len(materials)} 条"
if dropped > 0:
summary += f"(超出 {_MAX_MAX_MATERIALS} 条硬上限,丢弃末尾 {dropped} 条)"
elif len(raw_materials) > max_materials:
_emit("检索完成,正在筛选排序素材…")
materials_list = "\n".join(
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
@@ -608,26 +682,29 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
materials = sorted_materials[:max_materials]
summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条")
else:
_emit("检索完成,正在筛选排序素材…")
materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:max_materials]
summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条"
# 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项
if not materials:
summary = "未检索到相关素材,可重新搜索或在无素材的情况下继续写作"
elif is_user_append and appended_count == 0:
summary = f"本次未检索到新素材(已有 {len(materials)} 条保持不变),可换个说法再试"
# 追加模式下,pkg.keywords 体现到目前为止累积的检索词(旧 + 新追加),
# 让前端列表 / 历史回显能看到完整的搜索路径。
pkg_keywords = keywords
if is_append_only and existing_pkg:
if is_user_append and existing_pkg:
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
# 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if is_skill_search:
# 技能编排过程步骤放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if skill_steps:
pkg["skill_search_used"] = True
pkg["skill_search_steps"] = skill_steps
elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
# 保留之前一次技能检索的痕迹(不污染纯 web 检索)
elif existing_pkg and existing_pkg.get("skill_search_used") and preserve_existing:
# 保留之前一次技能检索的痕迹
pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
@@ -640,9 +717,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if search_errors:
uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
# (技能模式下 provider 局部变量没暴露,所以 raw_dumps 兜底为空列表)
provider_obj = locals().get("provider") or locals().get("web_provider")
raw_dumps = list(dict.fromkeys(getattr(provider_obj, "raw_responses", []) or []))
raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or [])) if provider is not None else []
notices.append(_notice(
"ES 波动导致检索异常",
";".join(uniq[:3]),
@@ -650,13 +725,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
agent_name=researcher_label,
))
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
if is_skill_search:
step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
elif is_user_query_search:
step_start_msg = f"按你的输入检索:{user_query[:40]}"
# 进度事件文案:用户输入 / 追加模式下让用户看到差异化提示
if is_user_query_search:
step_start_msg = f"按你的输入匹配技能检索并追加:{user_query[:40]}"
elif is_append_only:
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
elif skill_steps:
step_start_msg = f"通用检索(技能编排 {len(skill_steps)} 步):{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
return {
@@ -70,6 +70,8 @@ class SkillPicker:
emit_step: 推送时间线步骤事件(list_skills / select_skills)
max_select: LLM 单次最多挑几个技能
user_query: 用户在素材确认卡输入框写的自然语言
candidates: 候选技能池。None = 全部启用的技能(旧行为);
传入列表(如素材收集专家 agent 配置的技能)则只在其中匹配排序
"""
def __init__(
@@ -80,12 +82,14 @@ class SkillPicker:
emit_step: Optional[EmitStepFn] = None,
max_select: int = 5,
user_query: str = "",
candidates: Optional[list] = None,
) -> None:
self._config = config
self._model_name = model_name
self._emit_step = emit_step or (lambda _step: None)
self._max_select = max_select
self._user_query = (user_query or "").strip()
self._candidates = candidates
self._errors: list[str] = []
@property
@@ -93,6 +97,14 @@ class SkillPicker:
return list(self._errors)
def _load_enabled_skills(self) -> list:
if self._candidates is not None:
skills = list(self._candidates)
self._emit_step({
"type": "list_skills",
"message": f"素材收集专家已配置 {len(skills)} 个检索技能",
"skill_names": [s.name for s in skills],
})
return skills
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
@@ -135,8 +135,9 @@ class AIWritingState(TypedDict):
keyword_count: int
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str
# 素材来源:knowledge_base=知识库(默认 web/intranet 检索)/ skill=技能检索。
# 用户在写作表单选择,首次检索时由 researcher 节点据此决定检索方式(缺省 knowledge_base)。
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
material_source: str
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
@@ -13,7 +13,13 @@ Phase 3 会提供具体的 ``RoundtableGateway`` 实现(复用 multi_agent.py
from __future__ import annotations
from .engine import OrchestrationResult, build_report_prompt, run_orchestration
from .engine import (
OrchestrationResult,
build_dashboard_prompt,
build_report_prompt,
build_summary_prompt,
run_orchestration,
)
from .progress import JobProgressWriter
from .simulated_gateway import SimulatedRoundtableGateway
from .types import (
@@ -27,6 +33,7 @@ from .types import (
RoundtableGateway,
SeatRef,
SeatTurn,
SummaryResult,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
@@ -38,12 +45,15 @@ __all__ = [
"run_orchestration",
"OrchestrationResult",
"build_report_prompt",
"build_summary_prompt",
"build_dashboard_prompt",
"JobProgressWriter",
"SimulatedRoundtableGateway",
"RoundtableGateway",
"LeaderTurn",
"SeatTurn",
"ReportResult",
"SummaryResult",
"SeatRef",
"DagStage",
"DagPlan",
@@ -32,6 +32,7 @@ from .types import (
ReportResult,
RoundtableGateway,
SeatRef,
SummaryResult,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
@@ -94,9 +95,8 @@ async def run_orchestration(
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
plan: dag 模式的分层编排计划(前端 ``OrchestrationPlan`` dict)。mode=="dag" 且
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、
不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent
在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。
enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完
Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。
"""
intent = (intent_text or seed_message or "").strip()
try:
@@ -120,11 +120,11 @@ async def run_orchestration(
dialogues=progress.dialogues(),
)
# consensus 或 max_cycles:均收口。报告绘制按 enable_report 开关执行。
# consensus 或 max_cycles:均收口。Step3(总结报告 + 结果绘制)按 enable_report 开关执行。
await progress.consensus(100)
step3 = None
if enable_report:
step3 = await _run_report(
step3 = await _run_step3(
gateway,
progress,
agents=agents,
@@ -132,6 +132,7 @@ async def run_orchestration(
intent_text=intent_text or seed_message,
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None),
)
await progress.done()
return OrchestrationResult(
@@ -428,7 +429,96 @@ async def _run_dag(
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
# ── Step3 阶段(总结报告 + 结果绘制)────────────────────────────────────────────
async def _run_step3(
gateway: RoundtableGateway,
progress: JobProgressWriter,
*,
agents: list[SeatRef],
model: str | None,
intent_text: str,
consensus_text: str,
deliveries: dict[str, str],
coordinator_prompt: str | None = None,
) -> dict | None:
"""Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」
(roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行,
Step3 总耗时 ≈ max(两段) 而非两段之和)。
返回与前端 ``DraftStep3Snapshot`` 同形状的 dict:
``{summaryReport?: {md,threadId,generatedAt,model,closing}, html?, generatedAt?, model?, summary?}``。
**故障隔离**:两段各自 try/except —— 一段失败不拖垮另一段,跑成多少交付多少;
两段都失败时抛出第一个异常(由上层标记 job error)。两段各跑在独立线程上
(gateway 内部各建独立 thread),进程内并行安全(与 ``_run_dag`` 批内并行同理)。"""
step3: dict = {}
errors: list[Exception] = []
async def _summary_task() -> None:
# ① 方案总结报告(Markdown)—— 与前端 useStep3Summary 同源 prompt / 同款快照形状。
try:
await progress.summary_writing()
summary_thread_id = await gateway.create_summary_thread()
summary_prompt = build_summary_prompt(
intent_text, agents, deliveries, consensus_text, coordinator_prompt=coordinator_prompt
)
summary: SummaryResult = await gateway.run_summary(
summary_thread_id=summary_thread_id, prompt=summary_prompt, model=model
)
if (summary.md or "").strip() or (summary.closing or "").strip():
step3["summaryReport"] = {
"md": summary.md or "",
"threadId": summary.thread_id or summary_thread_id,
"generatedAt": summary.generated_at or datetime.now(UTC).isoformat(),
"model": summary.model or (model or ""),
"closing": summary.closing or "",
}
except Exception as exc: # noqa: BLE001 — 总结失败不阻塞结果绘制
logger.exception("roundtable step3 summary failed")
errors.append(exc)
async def _report_task() -> None:
# ② 结果绘制(HTML 看板)。
try:
report = await _run_report(
gateway,
progress,
agents=agents,
model=model,
intent_text=intent_text,
consensus_text=consensus_text,
deliveries=deliveries,
)
step3.update(report)
except Exception as exc: # noqa: BLE001 — 绘制失败时保留已写出的总结报告
logger.exception("roundtable step3 report failed")
errors.append(exc)
async def _dashboard_task() -> None:
# ③ 大屏多页(模式二)的结构化数据(report-json 字符串)。后端**只产数据**,
# 前端查看时用 renderDashboardHtml 按当前主题渲染(与手动并行绘制同源)。
# 旧网关没有 run_dashboard_data 则静默跳过(向后兼容)。
try:
run_dash = getattr(gateway, "run_dashboard_data", None)
if run_dash is None:
return
prompt = build_dashboard_prompt(intent_text, agents, deliveries, consensus_text)
data_json = await run_dash(prompt=prompt, model=model)
if data_json and data_json.strip():
step3["dashboardJson"] = data_json
except Exception as exc: # noqa: BLE001 — 大屏失败不拖垮另两段
logger.exception("roundtable step3 dashboard failed")
errors.append(exc)
await asyncio.gather(_summary_task(), _report_task(), _dashboard_task())
if not step3:
if errors:
raise errors[0]
return None
return step3
async def _run_report(
@@ -453,6 +543,108 @@ async def _run_report(
}
def build_dashboard_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
) -> str:
"""拼装「大屏多页(模式二)」的 report-json 数据提炼 prompt —— 对齐前端
``report-template.buildDashboardPrompt``:模型只输出 1-2KB 结构化数据
(不调工具、不写文件、不输出 HTML),前端按当前主题模板渲染。"""
name_by_id = {a.agent_id: a.name for a in agents}
lines: list[str] = [
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
"请通读以下圆桌研讨成果,把它们提炼成一份大屏展示数据,在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
"",
"# 一、任务意图",
(intent_text or "").strip() or "(未提供)",
"",
"# 二、各席位最终交付",
]
idx = 0
for a in agents:
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
idx += 1
lines.append(f"## {idx}. {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("")
lines.append(
"\n".join(
[
"```report-json 契约(严格 JSON,全程简体中文):",
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表(风险分布/资源占比/方案评分等),3-6 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
]
)
)
return "\n".join(lines)
def build_summary_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
*,
coordinator_prompt: str | None = None,
) -> str:
"""拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt``
(非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。"""
name_by_id = {a.agent_id: a.name for a in agents}
lines: list[str] = [
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」,"
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md,"
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
"",
]
cp = (coordinator_prompt or "").strip()
if cp:
lines += [
"# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)",
cp,
"",
]
lines += ["# 一、任务意图", (intent_text or "").strip() or "(未提供)", ""]
lines.append("# 二、各席位最终交付")
has_delivery = False
for i, a in enumerate(agents):
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
has_delivery = True
lines.append(f"## {i + 1}. {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
if not has_delivery:
lines.append("(各席位暂无文字交付,请基于任务意图与总控结论合理展开成报告。)")
lines.append("")
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("")
lines.append(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。"
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);"
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略),"
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。"
)
return "\n".join(lines)
def build_report_prompt(
intent_text: str,
agents: list[SeatRef],
@@ -501,6 +693,9 @@ def build_report_prompt(
"文字为主图表点睛、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。"
)
lines.append(
'硬性要求:① 全程使用简体中文;② HTML 必须是完整文档,<head> 最前面声明 <meta charset="utf-8">。'
'硬性要求:① 全程使用简体中文;② HTML 必须是完整文档,<head> 最前面声明 <meta charset="utf-8">;'
"③ 整份 HTML 较长时**不要试图一次性写入**——先用 write_file 写入开头部分(约 200-300 行),"
"再用 write_file 的 append=true 参数分多次续写(每段 ≤300 行),直到文档以 </html> 收尾"
"(单次超长输出会被截断,导致文件根本写不进去)。"
)
return "\n".join(lines)
@@ -10,8 +10,9 @@
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable
from typing import Any
from .types import SeatRef
@@ -140,6 +141,10 @@ class JobProgressWriter:
self._consensus = int(pct)
await self._persist(phase="consensus", active_agent_id=None)
async def summary_writing(self) -> None:
"""Step3 第一段:方案总结报告(roundtable-summary 写 Markdown)撰写中。"""
await self._persist(phase="summary_writing", active_agent_id=None)
async def report_drawing(self) -> None:
await self._persist(phase="report_drawing", active_agent_id=None)
@@ -13,8 +13,9 @@ multi_agent 的纯解析函数 + ``set_current_user`` 进程内调 agent runtime
from __future__ import annotations
import asyncio
import json
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn, SummaryResult
class SimulatedRoundtableGateway:
@@ -63,3 +64,44 @@ class SimulatedRoundtableGateway:
"<body><h1>方案总览(模拟)</h1><p>这是模拟网关生成的占位报告,用于前端联调。</p></body></html>"
)
return ReportResult(html=html, summary="(模拟)方案已生成", model=model)
async def create_summary_thread(self) -> str:
return "sim-summary-thread"
async def run_summary(self, *, summary_thread_id, prompt, model) -> SummaryResult:
await self._tick()
md = "# 方案总结报告(模拟)\n\n这是模拟网关生成的占位总结报告,用于前端联调。"
return SummaryResult(md=md, closing="(模拟)总结报告已生成", model=model, thread_id=summary_thread_id)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
await self._tick()
seats = [
{
"id": f"seat-{i + 1}",
"name": a.name,
"summary": f"(模拟){a.name} 的结论摘要。",
"sections": [{"heading": "要点", "bullets": ["模拟要点一", "模拟要点二"]}],
"metrics": [{"label": "完成度", "value": 90}],
"risks": [],
"dependsOn": [f"seat-{i}"] if i > 0 else [],
}
for i, a in enumerate(self._agents)
]
return json.dumps(
{
"title": "方案研讨大屏(模拟)",
"subtitle": "模拟网关占位数据",
"kpis": [{"label": "席位数", "value": str(len(self._agents)), "unit": "个"}],
"consensus": {"headline": "(模拟)推荐方案已达成共识", "points": ["要点一", "要点二"]},
"charts": [
{
"type": "bar",
"title": "席位贡献(模拟)",
"items": [{"label": a.name, "value": 60 + i * 10} for i, a in enumerate(self._agents)],
}
],
"timeline": [{"label": "阶段一", "desc": "模拟阶段说明"}],
"seats": seats,
},
ensure_ascii=False,
)
@@ -85,6 +85,22 @@ class ReportResult:
generated_at: str | None = None
@dataclass
class SummaryResult:
"""Step3「方案总结报告」结果(由 gateway.run_summary 产出)。
与前端 ``DraftStep3SummarySnapshot`` 一一对应:``md`` = 写入
``outputs/方案总结报告.md`` 的完整 Markdown;``closing`` = 智能体在对话里
回显的报告正文/收尾语;``thread_id`` = 真总结线程 id(加载草稿后可续问)。
"""
md: str = ""
closing: str | None = None
model: str | None = None
thread_id: str | None = None
generated_at: str | None = None
# ── 网关协议 ────────────────────────────────────────────────────────────────
@@ -130,6 +146,23 @@ class RoundtableGateway(Protocol):
model: str | None,
) -> ReportResult: ...
async def create_summary_thread(self) -> str: ...
async def run_summary(
self,
*,
summary_thread_id: str,
prompt: str,
model: str | None,
) -> SummaryResult: ...
async def run_dashboard_data(
self,
*,
prompt: str,
model: str | None,
) -> str | None: ...
# ── chain 模式 prompt 构造(移植自前端 buildChainLeaderTask / pickChainTask)────
@@ -59,6 +59,8 @@ class RoundtableChainRepository:
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
@@ -1408,7 +1408,9 @@ class ScheduledTaskService:
token = set_current_user(_RuntimeUser(id=task["user_id"]))
try:
request = SimpleNamespace(app=self.app)
# 虚拟 request:带空 state(真请求由 auth 中间件写 state.user/state.auth;
# 进程内调用没有这层,下游读 request.state.X 必须拿到 None 而非 AttributeError)。
request = SimpleNamespace(app=self.app, state=SimpleNamespace(user=None, auth=None))
execution_context = dict(task.get("execution_context") or {})
stored_agent_ref = task.get("execution_agent_name")
if stored_agent_ref: