Merge branch 'dhk-1' into 'master'
轻应用主题参数:随当前主题色映射传值 See merge request knowledge-base-bj/deerflow-server!118
This commit is contained in:
commit
12360c6f3f
89 files changed
+10286
-1485
No files matched your search
@@ -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 注入「任务背景 + 前序交付 + 别提问」。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<script>bad()</script>"));
|
||||
});
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 渲染期间的当前色板: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("&")}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
|
||||
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、用配置的技能编排检索、并对检索回来的素材按相关度排序筛选
|
||||
id: ai-writing-researcher
|
||||
name: 素材收集专家
|
||||
# 通用检索使用的技能(按顺序编排)。默认配置内置「知识库检索」;
|
||||
# admin/用户可在智能体管理页替换为其它检索技能或追加多个。
|
||||
skills:
|
||||
- knowledge-base-search
|
||||
+16
-7
@@ -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)
|
||||
|
||||
+6
-4
@@ -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",
|
||||
|
||||
+295
-220
@@ -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 {
|
||||
|
||||
+12
@@ -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()
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+11
-1
@@ -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",
|
||||
|
||||
+202
-7
@@ -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)
|
||||
+6
-1
@@ -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)
|
||||
|
||||
|
||||
+43
-1
@@ -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,
|
||||
)
|
||||
+33
@@ -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)────
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
}
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user