@
feat(圆桌): Step3 三产物并行 + 大屏多页模式 + 后台挂起查看恢复流程 后台挂起 Step3: - engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离 - request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告 - ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏) - 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染 结果绘制大屏多页模式(模式二): - report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随 - 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮 最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱 后台挂起查看=恢复实时流程: - handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随 - saveDraftNow 强制存(发起前+查看前各一次)防丢 第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、 peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> @
This commit is contained in:
1 parent
a7596001c5
commit
d5f319d9cb
51 files changed
+5016
-1186
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 注入「任务背景 + 前序交付 + 别提问」。
|
||||
|
||||
@@ -4,15 +4,16 @@ import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
UploadCloudIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
UploadCloudIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
@@ -37,11 +38,11 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
@@ -512,7 +513,22 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
const handleDownloadWriteFile = useCallback(() => {
|
||||
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
|
||||
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
|
||||
* 给新窗口足够的加载时间。 */
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}, [displayContent]);
|
||||
|
||||
const handleDownloadWriteFile = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法下载");
|
||||
@@ -544,7 +560,7 @@ export function ArtifactFileDetail({
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
|
||||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||||
@@ -574,7 +590,7 @@ export function ArtifactFileDetail({
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||
{isSupportPreview && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleGroup
|
||||
@@ -617,8 +633,8 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
||||
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
||||
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
||||
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
||||
{canPublishToKnowledge && (
|
||||
<ArtifactAction
|
||||
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
|
||||
@@ -677,6 +693,15 @@ export function ArtifactFileDetail({
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
{language === "html" && (
|
||||
<ArtifactAction
|
||||
icon={ExternalLinkIcon}
|
||||
label="新窗口打开"
|
||||
tooltip="在新窗口全屏打开 HTML"
|
||||
disabled={!displayContent?.trim()}
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
|
||||
@@ -756,6 +756,9 @@ const WRITING_MODE_LABELS: Record<string, string> = {
|
||||
loose: "可结合通用知识",
|
||||
};
|
||||
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
|
||||
general: "通用检索",
|
||||
notebook: "我的空间",
|
||||
// 旧值(历史会话回显兼容)
|
||||
knowledge_base: "知识库",
|
||||
skill: "技能检索",
|
||||
};
|
||||
|
||||
@@ -39,12 +39,11 @@ const INPUT_CLASS =
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
|
||||
type MaterialSource = 'general' | 'notebook';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
|
||||
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
|
||||
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
|
||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
@@ -279,14 +278,14 @@ export function AIWritingForm({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>素材来源</label>
|
||||
<label className={FIELD_LABEL_CLASS}>检索类型</label>
|
||||
<Select
|
||||
value={materialSource}
|
||||
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder="选择素材来源" />
|
||||
<SelectValue placeholder="选择检索类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import {
|
||||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
|
||||
Trash2, BookOpen, Sparkles,
|
||||
Trash2, BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -140,19 +140,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
|
||||
|
||||
async function handleReSearch() {
|
||||
setSubmitting(true);
|
||||
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
|
||||
// 无输入 = 重新生成检索词、覆盖式重检
|
||||
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleEnableSkillSearch() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasQuery = userQuery.trim().length > 0;
|
||||
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
|
||||
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
|
||||
|
||||
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
|
||||
if (materials.length === 0) {
|
||||
@@ -163,7 +157,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
|
||||
<div>
|
||||
<p className="text-xs font-medium">未检索到任何素材</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
|
||||
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,16 +183,9 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasQuery
|
||||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||||
? <><Search className="size-3.5" />按输入检索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||||
>
|
||||
<Sparkles className="size-3.5" />技能检索
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
|
||||
@@ -217,8 +204,8 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
|
||||
<div>
|
||||
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
|
||||
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
|
||||
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
|
||||
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,21 +238,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
|
||||
</Button>
|
||||
{!isSkillResult && (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||||
>
|
||||
<Sparkles className="size-3.5" />技能检索
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasQuery
|
||||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||||
? <><Search className="size-3.5" />检索并追加素材</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
|
||||
{/* 单输入框 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
|
||||
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
|
||||
value={userQuery}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
|
||||
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
|
||||
/>
|
||||
|
||||
@@ -257,7 +257,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
if (!stream) return;
|
||||
try {
|
||||
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
|
||||
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
|
||||
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
|
||||
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
|
||||
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
|
||||
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
|
||||
await stream.submit(
|
||||
|
||||
@@ -34,8 +34,8 @@ export interface WritingFormValue {
|
||||
setAudience: (v: string) => void;
|
||||
strictMode: boolean;
|
||||
setStrictMode: (v: boolean) => void;
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
|
||||
materialSource: 'general' | 'notebook';
|
||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||
modelName: string;
|
||||
setModelName: (v: string) => void;
|
||||
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
|
||||
@@ -56,7 +56,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [formExpanded, setFormExpanded] = useState(false);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
@@ -65,7 +65,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
|
||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
@@ -79,8 +79,9 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
|
||||
if (payload.writing_mode === 'strict') setStrictMode(true);
|
||||
else if (payload.writing_mode === 'loose') setStrictMode(false);
|
||||
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
|
||||
setMaterialSource(payload.material_source);
|
||||
// 旧值 skill / knowledge_base 已合并为通用检索 general
|
||||
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base' || payload.material_source === 'general') {
|
||||
setMaterialSource('general');
|
||||
}
|
||||
if (payload.outline?.trim()) {
|
||||
setOutlineText(payload.outline);
|
||||
|
||||
@@ -103,8 +103,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
|
||||
case 'intervention_submitted': {
|
||||
const ns = action.nextStatus;
|
||||
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
|
||||
const enablingSkillSearch = action.record.action === 'enable_skill_search';
|
||||
// 点击瞬间标记技能检索 pending,让 SkillStepsCard 占位卡立即挂载。
|
||||
// 通用检索现在统一走技能编排:素材确认卡上任何回到 researching 的动作
|
||||
// (重新搜索 / 按输入追加检索)都会产生 skill_search_step 流。
|
||||
const enablingSkillSearch =
|
||||
action.record.action === 'enable_skill_search' ||
|
||||
(ns === 'researching' && action.record.pausePoint === 'material_confirm');
|
||||
return {
|
||||
...prev,
|
||||
currentPause: null,
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface UserInterventionPayload {
|
||||
approvedMaterialIds?: string[];
|
||||
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
||||
extraKeywords?: string[];
|
||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
|
||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
||||
userQuery?: string;
|
||||
editedOutline?: AIOutline;
|
||||
outlineFeedback?: string;
|
||||
@@ -246,8 +246,8 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: "strict" | "loose";
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
|
||||
materialSource: "knowledge_base" | "skill" | "notebook";
|
||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
|
||||
materialSource: "general" | "notebook";
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
|
||||
|
||||
@@ -127,6 +127,12 @@ export interface DraftStep3Snapshot {
|
||||
* blob, so the backend needs no schema change. Optional.
|
||||
*/
|
||||
summaryReport?: DraftStep3SummarySnapshot | null;
|
||||
/**
|
||||
* Step 3「大屏多页(模式二)」的结构化数据(report-json 字符串)。**只存数据不存
|
||||
* HTML** —— 前端查看时用 renderDashboardHtml 按当前主题渲染,主题切换天然适配;
|
||||
* 与经典 `html`(模式一)并存互不覆盖。后台挂起与手动并行绘制都写这里。Optional.
|
||||
*/
|
||||
dashboardJson?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,6 +101,25 @@ export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
|
||||
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动后台作业(``keepalive`` 版)—— 给 ``beforeunload``(刷新 / 关标签页)兜底用:
|
||||
* keepalive fetch 在页面卸载后浏览器仍会把请求发完(payload ≤64KB),保证「正在
|
||||
* 运行的研讨」在用户刷新 / 关页时也能转入后台挂起。fire-and-forget,不读响应;
|
||||
* 重复触发由后端「同草稿未终态作业幂等返回」兜住,不会重复建作业。
|
||||
*/
|
||||
export function startJobKeepalive(payload: StartJobPayload): void {
|
||||
try {
|
||||
void apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toStartBody(payload)),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* 卸载途中同步异常也不能抛 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 查作业快照。404 → null。 */
|
||||
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface DagFlowChartProps {
|
||||
coordinatorState?: ChainNodeState;
|
||||
/** 共识里程碑状态(默认 pending)。 */
|
||||
consensusState?: ChainNodeState;
|
||||
/** 总结报告里程碑状态(Step3 第一段,roundtable-summary 写 md;默认 pending)。 */
|
||||
summaryState?: ChainNodeState;
|
||||
/** 结果绘制里程碑状态(默认 pending)。 */
|
||||
reportState?: ChainNodeState;
|
||||
/** 是否显示末尾里程碑(共识 / 结果绘制)。默认 true。 */
|
||||
@@ -85,6 +87,7 @@ export function DagFlowChart({
|
||||
seatStates,
|
||||
coordinatorState = "pending",
|
||||
consensusState = "pending",
|
||||
summaryState = "pending",
|
||||
reportState = "pending",
|
||||
showMilestones = true,
|
||||
showReportMilestone = true,
|
||||
@@ -157,11 +160,22 @@ export function DagFlowChart({
|
||||
{showReportMilestone && (
|
||||
<>
|
||||
<Connector
|
||||
active={reportState === "active"}
|
||||
done={reportState === "done"}
|
||||
active={summaryState === "active" || reportState === "active"}
|
||||
done={summaryState === "done" && reportState === "done"}
|
||||
compact={compact}
|
||||
/>
|
||||
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
|
||||
{/* Step3 三产物并行纵列(总结/绘制/大屏,共识后三流齐跑) */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04]",
|
||||
compact ? "gap-1 px-1.5 pb-1.5 pt-1" : "gap-1.5 px-2 pb-2 pt-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
|
||||
<MilestoneNode label="总结报告" emoji="📝" state={summaryState} compact={compact} />
|
||||
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
|
||||
<MilestoneNode label="大屏多页" emoji="🖥️" state={reportState} compact={compact} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Step2 消息列表右上角的「编排流程图小窗」—— 取代原全屏弹窗(2026-06-12)。
|
||||
*
|
||||
* 竖向流程图、直角连线(org-chart 风格):
|
||||
* - 总控协调在最顶;
|
||||
* - 每个 stage 一行:并行席位横向并列,由上方单点经「竖线 → 横向母线 → 各列竖线」
|
||||
* 的**直角边**分叉连接(3 个并行席位就分 3 路,以此类推);
|
||||
* - stage 之间再以直角边汇聚回中线,串行接入下一行;
|
||||
* - 最后汇聚到 🤝 共识达成。
|
||||
*
|
||||
* 悬浮窗交互(2026-06-13,参考第三步结构化输出流程图 FlowGraphPanel 的画布体验):
|
||||
* - **画布**:鼠标滚轮缩放(0.5–2.5,以光标为锚点)、按住拖动平移、双击复位;
|
||||
* - **窗口**:拖 header 移动位置;拖左/右边缘调宽、下边缘调高、左下/右下角同时调宽高;
|
||||
* - 位置/尺寸为会话内状态,不持久化。
|
||||
*
|
||||
* 与 `DagFlowChart`(横向,弹窗/总览用)共享 ChainNodeState / resolveSeat 语义,
|
||||
* 但布局完全独立。沙箱打开时由 Step2Panel 自动关闭本窗(腾出注意力给沙箱),
|
||||
* 「编排流程图」按钮可随时重开。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
import { chainTagClasses } from "../lib/business-chain";
|
||||
import type { ChainNodeState } from "../lib/job-types";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
export interface DagFlowMiniPanelProps {
|
||||
/** 分层计划的 stages(只取 agentIds)。 */
|
||||
stages: ReadonlyArray<{ id?: string; agentIds: string[] }>;
|
||||
/** agentId(小写)+ 全局序号 → {name, avatarType}。 */
|
||||
resolveSeat?: (agentId: string, order: number) => { name: string; avatarType: string };
|
||||
/** agentId(小写)→ 节点状态。 */
|
||||
seatStates?: Record<string, ChainNodeState>;
|
||||
coordinatorState?: ChainNodeState;
|
||||
consensusState?: ChainNodeState;
|
||||
/** 右上角 × 关闭。 */
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 连线颜色(浅色/深色主题都可见)。 */
|
||||
const LINE = "bg-slate-300 dark:bg-border";
|
||||
|
||||
// 窗口尺寸边界(px)。
|
||||
const MIN_W = 220;
|
||||
const MAX_W = 640;
|
||||
const MIN_H = 240;
|
||||
const MAX_H = 760;
|
||||
// 画布缩放边界。
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 2.5;
|
||||
|
||||
/**
|
||||
* 直角分叉/汇聚连接段。
|
||||
* `fanOut=true`:上方单点 → 竖线 → 横向母线 → 每列一条竖线落到下方 count 个节点;
|
||||
* `fanOut=false`:count 个节点各出一条竖线 → 横向母线 → 中线竖线汇聚到下方单点。
|
||||
* count<=1 时退化成一条居中竖线(串行段)。
|
||||
*/
|
||||
function OrthoConnector({ count, fanOut }: { count: number; fanOut: boolean }) {
|
||||
if (count <= 1) {
|
||||
return <div className={cn("mx-auto h-5 w-px", LINE)} />;
|
||||
}
|
||||
const busRow = (busAt: "top" | "bottom") => (
|
||||
<div
|
||||
className="grid w-full"
|
||||
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="relative h-5">
|
||||
{/* 横向母线:首列只画右半、末列只画左半、中间列贯通 —— 拼成一条首列中心到末列中心的横线 */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute h-px",
|
||||
LINE,
|
||||
busAt === "top" ? "top-0" : "bottom-0",
|
||||
i === 0 ? "left-1/2 right-0" : i === count - 1 ? "left-0 right-1/2" : "inset-x-0",
|
||||
)}
|
||||
/>
|
||||
{/* 各列竖线(母线 ↔ 节点) */}
|
||||
<span className={cn("absolute left-1/2 top-0 bottom-0 w-px -translate-x-1/2", LINE)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return fanOut ? (
|
||||
<div className="w-full">
|
||||
{/* 单点竖线(来自上方节点)→ 母线在行顶 → 各列下落 */}
|
||||
<div className={cn("mx-auto h-4 w-px", LINE)} />
|
||||
{busRow("top")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{/* 各列上行 → 母线贴行底 → 中线竖线(汇向下方节点) */}
|
||||
{busRow("bottom")}
|
||||
<div className={cn("mx-auto h-4 w-px", LINE)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 小窗节点:头像 + 状态徽标 + 名称标签(竖排紧凑版,对齐 DagFlowChart 的状态语义)。 */
|
||||
function MiniNode({
|
||||
label,
|
||||
avatarType,
|
||||
state,
|
||||
emoji,
|
||||
}: {
|
||||
label: string;
|
||||
avatarType?: string;
|
||||
state: ChainNodeState;
|
||||
emoji?: string;
|
||||
}) {
|
||||
const tag = avatarType ? chainTagClasses(avatarType) : "";
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col items-center gap-1 text-center">
|
||||
<div className="relative flex size-11 items-center justify-center">
|
||||
{state === "active" && (
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/25" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-11 items-center justify-center rounded-full border-2 bg-white text-lg transition-all dark:bg-card",
|
||||
state === "active" && "border-primary shadow-[0_0_0_3px_rgba(22,119,255,0.12)]",
|
||||
state === "done" && "border-emerald-400/70",
|
||||
state === "pending" && "border-border opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{emoji ?? (avatarType ? getAvatar(avatarType, "h-9 w-9") : null)}
|
||||
{state === "done" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||||
<CheckIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{state === "active" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<Loader2Icon className="size-2.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-full truncate rounded border px-1 py-px text-[10px] font-medium leading-tight",
|
||||
state === "pending" || !tag ? "border-border bg-muted text-muted-foreground" : tag,
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 悬浮窗几何(位置 / 尺寸 / 画布变换)────────────────────────────────────────
|
||||
|
||||
interface WindowGeom {
|
||||
/** null = 尚未拖过,沿用初始右上角锚定(right:16 / top:48)。 */
|
||||
left: number | null;
|
||||
top: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
type ResizeMode = "move" | "e" | "w" | "s" | "se" | "sw";
|
||||
|
||||
export function DagFlowMiniPanel({
|
||||
stages,
|
||||
resolveSeat,
|
||||
seatStates,
|
||||
coordinatorState = "pending",
|
||||
consensusState = "pending",
|
||||
onClose,
|
||||
className,
|
||||
}: DagFlowMiniPanelProps) {
|
||||
const resolve = (agentId: string, order: number) =>
|
||||
resolveSeat?.(agentId, order) ?? {
|
||||
name: agentId,
|
||||
avatarType: getAvatarTypeFor(agentId, order),
|
||||
};
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [geom, setGeom] = useState<WindowGeom>({ left: null, top: 48, w: 256, h: 400 });
|
||||
// 画布变换(平移 px + 缩放),transform-origin 固定 0 0。
|
||||
const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
|
||||
// ── 窗口移动 / 边缘缩放(pointer capture,统一一套手势状态机)────────────────
|
||||
const gestureRef = useRef<{
|
||||
mode: ResizeMode;
|
||||
startX: number;
|
||||
startY: number;
|
||||
start: WindowGeom & { left: number };
|
||||
} | null>(null);
|
||||
|
||||
const beginWindowGesture = useCallback(
|
||||
(mode: ResizeMode) => (e: React.PointerEvent) => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 首次手势把右锚定折算成 left(offsetParent = 消息区 relative 容器)。
|
||||
const left = geom.left ?? el.offsetLeft;
|
||||
gestureRef.current = {
|
||||
mode,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
start: { ...geom, left },
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[geom],
|
||||
);
|
||||
|
||||
const onWindowGestureMove = useCallback((e: React.PointerEvent) => {
|
||||
const g = gestureRef.current;
|
||||
if (!g) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - g.startX;
|
||||
const dy = e.clientY - g.startY;
|
||||
const s = g.start;
|
||||
const clampW = (w: number) => Math.min(MAX_W, Math.max(MIN_W, w));
|
||||
const clampH = (h: number) => Math.min(MAX_H, Math.max(MIN_H, h));
|
||||
setGeom(() => {
|
||||
switch (g.mode) {
|
||||
case "move":
|
||||
return { ...s, left: Math.max(0, s.left + dx), top: Math.max(0, s.top + dy) };
|
||||
case "e":
|
||||
return { ...s, w: clampW(s.w + dx) };
|
||||
case "w": {
|
||||
const w = clampW(s.w - dx);
|
||||
return { ...s, w, left: Math.max(0, s.left + (s.w - w)) };
|
||||
}
|
||||
case "s":
|
||||
return { ...s, h: clampH(s.h + dy) };
|
||||
case "se":
|
||||
return { ...s, w: clampW(s.w + dx), h: clampH(s.h + dy) };
|
||||
case "sw": {
|
||||
const w = clampW(s.w - dx);
|
||||
return { ...s, w, left: Math.max(0, s.left + (s.w - w)), h: clampH(s.h + dy) };
|
||||
}
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endWindowGesture = useCallback((e: React.PointerEvent) => {
|
||||
if (!gestureRef.current) return;
|
||||
gestureRef.current = null;
|
||||
try {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* 已释放 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── 画布滚轮缩放(以光标为锚点)────────────────────────────────────────────
|
||||
// React 17+ 在根上以 passive 注册 wheel,onWheel 里 preventDefault 无效 →
|
||||
// 必须原生 addEventListener({passive:false}),否则缩放的同时页面会跟着滚。
|
||||
useEffect(() => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { tx, ty, scale } = viewRef.current;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
|
||||
if (next === scale) return;
|
||||
// 以光标为锚点:保持光标下的内容点不动。
|
||||
const rect = vp.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const k = next / scale;
|
||||
setView({ tx: cx - (cx - tx) * k, ty: cy - (cy - ty) * k, scale: next });
|
||||
};
|
||||
vp.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => vp.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
// ── 画布拖动平移(与窗口手势互不干扰:画布手势只在 viewport 内启动)──────────
|
||||
const panRef = useRef<{ startX: number; startY: number; tx: number; ty: number } | null>(null);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const onCanvasDown = useCallback((e: React.PointerEvent) => {
|
||||
// 只响应主键 / 触摸。
|
||||
if (e.button !== 0) return;
|
||||
const { tx, ty } = viewRef.current;
|
||||
panRef.current = { startX: e.clientX, startY: e.clientY, tx, ty };
|
||||
setPanning(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
const onCanvasMove = useCallback((e: React.PointerEvent) => {
|
||||
const p = panRef.current;
|
||||
if (!p) return;
|
||||
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.startX), ty: p.ty + (e.clientY - p.startY) }));
|
||||
}, []);
|
||||
const onCanvasUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!panRef.current) return;
|
||||
panRef.current = null;
|
||||
setPanning(false);
|
||||
try {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* 已释放 */
|
||||
}
|
||||
}, []);
|
||||
// 双击复位画布。
|
||||
const resetView = useCallback(() => setView({ tx: 0, ty: 0, scale: 1 }), []);
|
||||
|
||||
let runningOrder = 0;
|
||||
|
||||
// 边缘 / 边角把手的公共类。
|
||||
const handleBase = "absolute z-10 touch-none";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"absolute flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-xl backdrop-blur",
|
||||
"dark:border-border dark:bg-card/95",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: geom.w,
|
||||
height: geom.h,
|
||||
top: geom.top,
|
||||
...(geom.left == null ? { right: 16 } : { left: geom.left }),
|
||||
}}
|
||||
>
|
||||
{/* header:拖动移动窗口 */}
|
||||
<header
|
||||
onPointerDown={beginWindowGesture("move")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className="flex shrink-0 cursor-move select-none items-center justify-between gap-2 border-b border-slate-100 px-3 py-2 dark:border-border"
|
||||
title="拖动移动小窗"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-600 dark:text-foreground/80">
|
||||
<Network className="size-3.5 text-primary" />
|
||||
编排流程图
|
||||
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] text-slate-400 dark:bg-muted">
|
||||
{Math.round(view.scale * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
title="关闭流程图小窗"
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-accent"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 画布 viewport:滚轮缩放 / 拖动平移 / 双击复位 */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
onPointerDown={onCanvasDown}
|
||||
onPointerMove={onCanvasMove}
|
||||
onPointerUp={onCanvasUp}
|
||||
onPointerCancel={onCanvasUp}
|
||||
onDoubleClick={resetView}
|
||||
className={cn(
|
||||
"relative min-h-0 flex-1 touch-none overflow-hidden",
|
||||
panning ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
title="滚轮缩放 · 拖动平移 · 双击复位"
|
||||
>
|
||||
<div
|
||||
className="w-full px-3 py-3"
|
||||
style={{
|
||||
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
{/* 总控协调(顶) */}
|
||||
<MiniNode label="总控协调" avatarType="human" state={coordinatorState} />
|
||||
|
||||
{stages.map((stage, stageIdx) => {
|
||||
const count = stage.agentIds.length;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div className="contents" key={stage.id ?? `stage-${stageIdx}`}>
|
||||
{/* 直角分叉:上方单点 → 本行各席位 */}
|
||||
<OrthoConnector count={count} fanOut />
|
||||
<div
|
||||
className="grid w-full gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{stage.agentIds.map((agentId) => {
|
||||
const order = runningOrder++;
|
||||
const meta = resolve(agentId, order);
|
||||
const state = seatStates?.[agentId.toLowerCase()] ?? "pending";
|
||||
return (
|
||||
<MiniNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 直角汇聚:本行各席位 → 中线(接下一行 / 共识) */}
|
||||
<OrthoConnector count={count} fanOut={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 共识达成(底) */}
|
||||
<MiniNode label="共识达成" emoji="🤝" state={consensusState} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 边缘 / 边角缩放把手:左右边调宽、下边调高、左下/右下角同时调宽高 */}
|
||||
<div
|
||||
onPointerDown={beginWindowGesture("e")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className={cn(handleBase, "right-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={beginWindowGesture("w")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className={cn(handleBase, "left-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={beginWindowGesture("s")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className={cn(handleBase, "bottom-0 left-3 right-3 h-1.5 cursor-ns-resize")}
|
||||
title="拖动调整高度"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={beginWindowGesture("se")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className={cn(handleBase, "bottom-0 right-0 size-3.5 cursor-nwse-resize")}
|
||||
title="拖动整体缩放窗口"
|
||||
>
|
||||
{/* 角部抓手视觉提示 */}
|
||||
<span className="absolute bottom-0.5 right-0.5 block size-2 rounded-br border-b-2 border-r-2 border-slate-300 dark:border-border" />
|
||||
</div>
|
||||
<div
|
||||
onPointerDown={beginWindowGesture("sw")}
|
||||
onPointerMove={onWindowGestureMove}
|
||||
onPointerUp={endWindowGesture}
|
||||
onPointerCancel={endWindowGesture}
|
||||
className={cn(handleBase, "bottom-0 left-0 size-3.5 cursor-nesw-resize")}
|
||||
title="拖动整体缩放窗口"
|
||||
>
|
||||
<span className="absolute bottom-0.5 left-0.5 block size-2 rounded-bl border-b-2 border-l-2 border-slate-300 dark:border-border" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,8 +91,11 @@ const PHASE_LABEL: Record<JobPhase, string> = {
|
||||
leader_thinking: "总控决策派活中…",
|
||||
dispatching: "席位发言中…",
|
||||
consensus: "共识达成",
|
||||
report_drawing: "结果绘制中…",
|
||||
report_done: "绘制完成",
|
||||
// Step3 三产物(总结报告 / 结果绘制 / 大屏数据)并行生成,两个阶段值都代表
|
||||
// 「并行进行中」(后端 gather 并发推进、phase 交错落库,不区分先后)。
|
||||
summary_writing: "Step3 并行生成中(总结/绘制/大屏)…",
|
||||
report_drawing: "Step3 并行生成中(总结/绘制/大屏)…",
|
||||
report_done: "Step3 完成",
|
||||
};
|
||||
|
||||
export function DispatchChainModal({
|
||||
@@ -138,14 +141,17 @@ export function DispatchChainModal({
|
||||
const consensusDone =
|
||||
consensusPercentage >= 100 ||
|
||||
phase === "consensus" ||
|
||||
phase === "summary_writing" ||
|
||||
phase === "report_drawing" ||
|
||||
phase === "report_done" ||
|
||||
status === "done";
|
||||
const consensusActive = phase === "consensus";
|
||||
|
||||
// 报告绘制里程碑
|
||||
const reportActive = phase === "report_drawing";
|
||||
const reportDone = phase === "report_done" || status === "done";
|
||||
// Step3 三产物**并行**(2026-06-13:后端 gather 并发跑总结/绘制/大屏,phase 交错
|
||||
// 落库不分先后)→ 统一成一组并行里程碑状态。
|
||||
const step3Active = phase === "summary_writing" || phase === "report_drawing";
|
||||
const step3Done = phase === "report_done" || status === "done";
|
||||
const step3State: ChainNodeState = step3Done ? "done" : step3Active ? "active" : "pending";
|
||||
|
||||
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
|
||||
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
|
||||
@@ -199,15 +205,17 @@ export function DispatchChainModal({
|
||||
|
||||
{/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
|
||||
{showDag ? (
|
||||
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识。
|
||||
// 后台本期不绘报告(Step3 禁用) → 不显示「结果绘制」里程碑。
|
||||
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识 → 📊结果绘制。
|
||||
// 后台 Step3(总结报告 + 结果绘制)已恢复 → 显示「结果绘制」里程碑。
|
||||
<DagFlowChart
|
||||
stages={stages!}
|
||||
resolveSeat={resolveDagSeat}
|
||||
seatStates={dagSeatStates}
|
||||
coordinatorState={coordinatorState}
|
||||
consensusState={dagConsensusState}
|
||||
showReportMilestone={false}
|
||||
summaryState={step3State}
|
||||
reportState={step3State}
|
||||
showReportMilestone
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
@@ -246,13 +254,14 @@ export function DispatchChainModal({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 报告绘制里程碑 */}
|
||||
<Connector active={reportActive} done={reportDone} />
|
||||
<MilestoneNode
|
||||
label="结果绘制"
|
||||
emoji="📊"
|
||||
state={reportActive ? "active" : reportDone ? "done" : "pending"}
|
||||
/>
|
||||
{/* Step3:总结报告 / 结果绘制 / 大屏多页 —— **并行**纵列(共识后三流齐跑) */}
|
||||
<Connector active={step3Active} done={step3Done} />
|
||||
<div className="flex shrink-0 flex-col items-center gap-1.5 rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04] px-2 pb-2 pt-1.5">
|
||||
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
|
||||
<MilestoneNode label="总结报告" emoji="📝" state={step3State} />
|
||||
<MilestoneNode label="结果绘制" emoji="📊" state={step3State} />
|
||||
<MilestoneNode label="大屏多页" emoji="🖥️" state={step3State} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,15 +343,13 @@ export function DispatchChainModal({
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{/* 只对「运行中」任务隐藏入口——它正在后台真跑,进入会暂停前端编排、
|
||||
与后台作业双跑或借此重跑。其它状态(排队 / 待澄清 / 已完成 / 已取消 /
|
||||
出错)都不是正在跑的后台作业,可正常查看。 */}
|
||||
{status !== "running" && (
|
||||
<Button onClick={onView} disabled={!onView} className="gap-1.5">
|
||||
<EyeIcon className="size-4" />
|
||||
{status === "done" ? "查看报告" : "查看"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 「查看」对所有状态开放——运行中点击进入的是**只读 hydrate 视图**
|
||||
(onView 先暂停本地编排再加载草稿,纯展示绝不 resume,不会与后台
|
||||
作业双跑;草稿由后台逐轮回写,进入即可看到当前进度)。 */}
|
||||
<Button onClick={onView} disabled={!onView} className="gap-1.5">
|
||||
<EyeIcon className="size-4" />
|
||||
{status === "done" ? "查看报告" : "查看"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
@@ -21,7 +32,6 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
Step3ReportMode,
|
||||
@@ -32,7 +42,9 @@ import type {
|
||||
DraftStep3Snapshot,
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
|
||||
interface SeatDelivery {
|
||||
/** Display name of the seat (e.g. "情报收集"). */
|
||||
@@ -72,6 +84,10 @@ interface HighFidelityReportProps {
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 页面层常驻的三个 Step3 生成流(切走不断流,进入续看)。 */
|
||||
summaryFlow: UseStep3SummaryReturn;
|
||||
reportFlow: UseStep3ReportReturn;
|
||||
dashboardFlow: UseStep3DashboardReturn;
|
||||
/** Resets state and navigates back to Step 1. */
|
||||
onReset: () => void;
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
@@ -116,6 +132,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
summaryFlow,
|
||||
reportFlow,
|
||||
dashboardFlow,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -132,7 +151,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
|
||||
const [summaryMode, setSummaryMode] = useState(true);
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
const [fileSandboxMode, setFileSandboxMode] = useState(false);
|
||||
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
|
||||
// (不再整页切走)。target 在关闭后保留,供退出过渡动画期间继续渲染。
|
||||
const [fileSandboxOpen, setFileSandboxOpen] = useState(false);
|
||||
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
|
||||
threadId: string;
|
||||
file: SessionArtifactFile;
|
||||
@@ -225,17 +246,77 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
};
|
||||
}, [seatThreadEntries, reloadKey]);
|
||||
|
||||
const totalFileCount = useMemo(
|
||||
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
|
||||
[artifactsBySeat],
|
||||
);
|
||||
// ── 统一席位总览(2026-06-13 大改):交付内容 + 该席位生成的报告文件合并到一起 ──
|
||||
// 以「有线程的席位」为主(带文件清单),无线程时回退到交付列表;key = displayName。
|
||||
const seatOverviews = useMemo(() => {
|
||||
const avatarByDisplay = new Map<string, string>();
|
||||
for (const d of seatDeliveries) {
|
||||
if (!avatarByDisplay.has(d.sender)) avatarByDisplay.set(d.sender, d.avatarType);
|
||||
}
|
||||
type Overview = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
avatarType: string;
|
||||
delivery: string | null;
|
||||
threadId: string | null;
|
||||
files: SessionArtifactFile[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
const out: Overview[] = [];
|
||||
const seen = new Set<string>();
|
||||
artifactsBySeat.forEach((s, i) => {
|
||||
seen.add(s.displayName);
|
||||
out.push({
|
||||
key: s.displayName,
|
||||
displayName: s.displayName,
|
||||
avatarType:
|
||||
avatarByDisplay.get(s.displayName) ?? getAvatarTypeFor(s.agentName, i),
|
||||
delivery: deliveryByDisplay.get(s.displayName) ?? null,
|
||||
threadId: s.threadId,
|
||||
files: s.files,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
});
|
||||
});
|
||||
// 没有线程映射(恢复的旧草稿等)时,纯交付席位也要出现在列表里。
|
||||
seatDeliveries.forEach((d, i) => {
|
||||
if (seen.has(d.sender)) return;
|
||||
seen.add(d.sender);
|
||||
out.push({
|
||||
key: d.sender,
|
||||
displayName: d.sender,
|
||||
avatarType: d.avatarType || getAvatarTypeFor(d.sender, i),
|
||||
delivery: d.content,
|
||||
threadId: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}, [artifactsBySeat, seatDeliveries, deliveryByDisplay]);
|
||||
|
||||
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
|
||||
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
|
||||
const seatsWithFiles = useMemo(
|
||||
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
|
||||
[artifactsBySeat],
|
||||
);
|
||||
// 左侧席位栏(像第二步的参与角色栏):可收起成头像列;点卡片/头像 → 展开右侧
|
||||
// 对应区块并平滑滚动过去(短暂高亮提示落点)。
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [focusedSeat, setFocusedSeat] = useState<string | null>(null);
|
||||
const seatSectionRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
// 总控最终结论:支持展开/收起(默认展开);左侧总控头像点击 → 展开并滚动到这里。
|
||||
const CONSENSUS_KEY = "__consensus__";
|
||||
const [consensusExpanded, setConsensusExpanded] = useState(true);
|
||||
const consensusSectionRef = useRef<HTMLElement | null>(null);
|
||||
const focusConsensus = useCallback(() => {
|
||||
setConsensusExpanded(true);
|
||||
setFocusedSeat(CONSENSUS_KEY);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
consensusSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => setFocusedSeat((cur) => (cur === CONSENSUS_KEY ? null : cur)), 1800);
|
||||
}, []);
|
||||
|
||||
const toggleSeat = useCallback((key: string) => {
|
||||
setExpandedSeats((prev) => {
|
||||
@@ -246,16 +327,94 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
|
||||
setFileSandboxTarget({ threadId, file });
|
||||
setFileSandboxMode(true);
|
||||
const focusSeat = useCallback((key: string) => {
|
||||
setExpandedSeats((prev) => new Set(prev).add(key));
|
||||
setFocusedSeat(key);
|
||||
// 等展开渲染完成后再滚动,落点更准。
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
seatSectionRefs.current
|
||||
.get(key)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
window.setTimeout(
|
||||
() => setFocusedSeat((cur) => (cur === key ? null : cur)),
|
||||
1800,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleBackFromFileSandbox = useCallback(() => {
|
||||
setFileSandboxMode(false);
|
||||
setFileSandboxTarget(null);
|
||||
deselectArtifact();
|
||||
}, [deselectArtifact]);
|
||||
// 文本类扩展名 → 拉文本走虚拟 stub 预览;其余(图片/PDF 等二进制)直接 iframe。
|
||||
const TEXT_FILE_RE = /\.(md|markdown|html?|txt|csv|json|js|ts|tsx|py|xml|yml|yaml|log|mmd|css)$/i;
|
||||
const [fileSandboxContent, setFileSandboxContent] = useState<string | null>(null);
|
||||
const [fileSandboxIsText, setFileSandboxIsText] = useState(true);
|
||||
const fileFetchSeqRef = useRef(0);
|
||||
|
||||
// 点文件卡片:右侧滑入内联沙箱 + 左侧席位栏收起(不消失),页面不切走。
|
||||
// 内容**不走** threads artifacts hook(这些圆桌线程在那条路由上 404,曾表现为
|
||||
// 预览区只显示 {"detail":"Not Found"})——改用与「下载」同源、已验证可用的
|
||||
// buildArtifactDownloadUrl 自取文本,再以 write_file 虚拟 stub 喂给预览组件。
|
||||
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
|
||||
setFileSandboxTarget({ threadId, file });
|
||||
setFileSandboxOpen(true);
|
||||
setSidebarCollapsed(true);
|
||||
const isText = TEXT_FILE_RE.test(file.name);
|
||||
setFileSandboxIsText(isText);
|
||||
setFileSandboxContent(null);
|
||||
if (!isText) return; // 二进制走 iframe(src 即下载路由),无需取文本
|
||||
const seq = ++fileFetchSeqRef.current;
|
||||
void apiFetch(buildArtifactDownloadUrl(threadId, file.path))
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (fileFetchSeqRef.current === seq) setFileSandboxContent(text);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (fileFetchSeqRef.current === seq) {
|
||||
setFileSandboxContent(`> 读取文件失败:${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCloseFileSandbox = useCallback(() => {
|
||||
setFileSandboxOpen(false); // target 保留到过渡结束,面板滑出更顺
|
||||
}, []);
|
||||
|
||||
// 虚拟 stub(write-file URL → 预览组件直接读 tool_call.args.content,零 HTTP)。
|
||||
const fileSandboxStubPath = fileSandboxTarget
|
||||
? fileSandboxTarget.file.path.startsWith("/")
|
||||
? fileSandboxTarget.file.path
|
||||
: `/${fileSandboxTarget.file.path}`
|
||||
: "";
|
||||
const fileSandboxVirtualUrl = fileSandboxTarget
|
||||
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
|
||||
: "";
|
||||
const fileSandboxThreadStub = useMemo(() => {
|
||||
const messages =
|
||||
fileSandboxTarget && fileSandboxContent != null
|
||||
? [
|
||||
{
|
||||
id: "hfr-file-stub",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tc-hfr-0",
|
||||
name: "write_file",
|
||||
args: { path: fileSandboxStubPath, content: fileSandboxContent },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
messages,
|
||||
isLoading: false,
|
||||
isThreadLoading: false,
|
||||
values: { title: "", messages, artifacts: [] },
|
||||
} as unknown as BaseStream<AgentThreadState>;
|
||||
}, [fileSandboxTarget, fileSandboxContent, fileSandboxStubPath]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(threadId: string, file: SessionArtifactFile) => {
|
||||
@@ -283,28 +442,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
summary={summaryFlow}
|
||||
onViewStatic={() => setSummaryMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileSandboxMode && fileSandboxTarget) {
|
||||
return (
|
||||
<Step3FileBrowseMode
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
consensusDocTitle={consensusDocTitle}
|
||||
seatDeliveries={seatDeliveries}
|
||||
artifactsBySeat={artifactsBySeat}
|
||||
seatStubMessages={seatStubMessages}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
initialFile={fileSandboxTarget}
|
||||
onBack={handleBackFromFileSandbox}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (drawMode) {
|
||||
return (
|
||||
<Step3ReportMode
|
||||
@@ -320,14 +463,15 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={savedReport}
|
||||
onReportSaved={onReportSaved}
|
||||
report={reportFlow}
|
||||
dashboard={dashboardFlow}
|
||||
onBack={() => setDrawMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
|
||||
<div className="flex-1 flex flex-col min-h-0 text-slate-800">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
@@ -402,179 +546,476 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Section 1: Coordinator consensus conclusion */}
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<span className="truncate">总控最终结论</span>
|
||||
</h4>
|
||||
<ConsensusDocxDownloadButton
|
||||
markdown={consensusText}
|
||||
title={consensusDocTitle}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
{consensusText ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
|
||||
</p>
|
||||
{/* 主体:左侧参与席位栏(像第二步) + 右侧滚动内容(2026-06-13 大改) */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* ── 左侧参与席位栏:可收起成头像列;有交付内容的席位头像点亮 ── */}
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col border-r border-slate-100 bg-slate-50/50 transition-all",
|
||||
sidebarCollapsed ? "w-14" : "w-52",
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 2: Per-seat deliveries (collapsible) */}
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-primary" />
|
||||
各席位最终交付
|
||||
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{seatDeliveries.length}
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
{seatDeliveries.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{seatDeliveries.map((seat, idx) => {
|
||||
const key = `${seat.sender}-${idx}`;
|
||||
const expanded = expandedSeats.has(key);
|
||||
return (
|
||||
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSeat(key)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
{seat.content.length} 字
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。
|
||||
仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */}
|
||||
{seatsWithFiles.length > 0 && (
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-primary" />
|
||||
生成的报告文件
|
||||
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{totalFileCount}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center border-b border-slate-100 px-2 py-2",
|
||||
sidebarCollapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest text-slate-500">
|
||||
<Users className="w-3.5 h-3.5 text-primary" />
|
||||
参与席位
|
||||
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] font-semibold text-slate-400">
|
||||
{seatOverviews.length}
|
||||
</span>
|
||||
</span>
|
||||
</h4>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed((v) => !v)}
|
||||
title={sidebarCollapsed ? "展开席位栏" : "收起席位栏"}
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronsRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronsLeft className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{seatsWithFiles.map((seat) => (
|
||||
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
|
||||
{/* 总控协调(顶置):点击 → 展开「总控最终结论」并滚动过去 */}
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusConsensus}
|
||||
title={`总控协调${consensusText ? "(点击查看最终结论)" : "(暂无结论)"}`}
|
||||
className="mx-auto block rounded-full transition hover:scale-105"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
|
||||
consensusText
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar("human", "h-7 w-7")}
|
||||
{!!consensusText && (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusConsensus}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
|
||||
consensusText
|
||||
? "border-emerald-200/70 bg-emerald-50/40 hover:border-emerald-400/60 hover:bg-emerald-50"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
|
||||
)}
|
||||
title={consensusText ? "点击查看总控最终结论" : "暂无总控结论"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
consensusText
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar("human", "h-7 w-7")}
|
||||
{!!consensusText && (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[11.5px] font-bold",
|
||||
consensusText ? "text-slate-800" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
总控协调
|
||||
</span>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400">
|
||||
{consensusText ? "最终结论" : "无结论"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{seatOverviews.map((seat) => {
|
||||
const lit = !!seat.delivery?.trim();
|
||||
return sidebarCollapsed ? (
|
||||
// 收起态:只剩头像列;点头像同样展开右侧对应交付并滚过去。
|
||||
<button
|
||||
key={seat.key}
|
||||
type="button"
|
||||
onClick={() => focusSeat(seat.key)}
|
||||
title={`${seat.displayName}${lit ? "(点击查看交付与文件)" : "(暂无交付)"}`}
|
||||
className="mx-auto block rounded-full transition hover:scale-105"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
|
||||
lit
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar(seat.avatarType, "h-7 w-7")}
|
||||
{lit && (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
// 展开态:席位卡片(头像 + 名称 + 交付/文件徽标)。
|
||||
<button
|
||||
key={seat.key}
|
||||
type="button"
|
||||
onClick={() => focusSeat(seat.key)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
|
||||
lit
|
||||
? "border-slate-200 bg-white hover:border-primary/40 hover:bg-primary/5"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
|
||||
)}
|
||||
title={lit ? "点击查看该席位的交付与文件" : "该席位暂无交付"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
lit
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar(seat.avatarType, "h-7 w-7")}
|
||||
{lit && (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[11.5px] font-bold",
|
||||
lit ? "text-slate-800" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{seat.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
{seat.files.length > 0 ? ` · ${seat.files.length} 文件` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{seatOverviews.length === 0 && !sidebarCollapsed && (
|
||||
<p className="px-1 pt-2 text-[10px] italic text-slate-400">
|
||||
暂无参与席位
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{seat.loading ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
<span>正在查询 outputs 目录…</span>
|
||||
</div>
|
||||
) : seat.error ? (
|
||||
<div className="px-3 py-3 text-[11px] text-rose-600">
|
||||
读取失败:{seat.error}
|
||||
</div>
|
||||
) : seat.files.length === 0 ? (
|
||||
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
|
||||
该席位未产生输出文件
|
||||
{/* ── 右侧内容:总控结论 + 每席位一个区块(交付内容 + 报告文件卡片合并) ── */}
|
||||
<div className="custom-scrollbar flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
|
||||
{/* Section 1: Coordinator consensus conclusion(可收起,带过渡动画) */}
|
||||
<section
|
||||
ref={consensusSectionRef}
|
||||
className={cn(
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
|
||||
focusedSeat === CONSENSUS_KEY
|
||||
? "border-primary/50 ring-2 ring-primary/30"
|
||||
: "border-slate-200",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-3.5 bg-slate-50/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsensusExpanded((v) => !v)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
title={consensusExpanded ? "收起结论" : "展开结论"}
|
||||
>
|
||||
{consensusExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
)}
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<span className="truncate text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
总控最终结论
|
||||
</span>
|
||||
</button>
|
||||
<ConsensusDocxDownloadButton
|
||||
markdown={consensusText}
|
||||
title={consensusDocTitle}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
consensusExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-slate-100 px-5 py-4">
|
||||
{consensusText ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{seat.files.map((f) => (
|
||||
<li
|
||||
key={f.path}
|
||||
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">
|
||||
{formatBytes(f.sizeBytes)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewInSandbox(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="在沙箱中查看"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
|
||||
>
|
||||
重新起草新规划
|
||||
</button>
|
||||
{/* Section 2: 每席位一个区块 —— 交付内容 + 该席位生成的报告文件(卡片,点击开沙箱) */}
|
||||
{seatOverviews.length === 0 ? (
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,这里会按席位展示交付与产出文件。
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
seatOverviews.map((seat) => {
|
||||
const expanded = expandedSeats.has(seat.key);
|
||||
const lit = !!seat.delivery?.trim();
|
||||
return (
|
||||
<section
|
||||
key={seat.key}
|
||||
ref={(el) => {
|
||||
if (el) seatSectionRefs.current.set(seat.key, el);
|
||||
else seatSectionRefs.current.delete(seat.key);
|
||||
}}
|
||||
className={cn(
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
|
||||
focusedSeat === seat.key
|
||||
? "border-primary/50 ring-2 ring-primary/30"
|
||||
: "border-slate-200",
|
||||
)}
|
||||
>
|
||||
{/* 区块头:头像 + 席位名 + 徽标,点击折叠/展开 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSeat(seat.key)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 bg-slate-50/70 hover:bg-slate-100/70 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
lit ? "border-emerald-400/70" : "border-slate-200 opacity-50 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar(seat.avatarType, "h-5.5 w-5.5")}
|
||||
</span>
|
||||
<span className="text-xs font-black text-slate-800 truncate">
|
||||
{seat.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
{seat.loading
|
||||
? " · 文件加载中…"
|
||||
: seat.files.length > 0
|
||||
? ` · ${seat.files.length} 个文件`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-slate-100">
|
||||
{/* 交付内容 */}
|
||||
<div className="px-5 py-4 text-xs text-slate-700 leading-relaxed">
|
||||
{lit ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{seat.delivery!}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">该席位暂无交付内容。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 该席位生成的报告文件 —— 卡片,点击在沙箱中打开 */}
|
||||
{(seat.loading || seat.error || seat.files.length > 0) && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/40 px-5 py-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||
<Download className="w-3 h-3 text-primary" />
|
||||
生成的报告文件
|
||||
</div>
|
||||
{seat.loading ? (
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
<span>正在查询 outputs 目录…</span>
|
||||
</div>
|
||||
) : seat.error ? (
|
||||
<div className="text-[11px] text-rose-600">读取失败:{seat.error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{seat.files.map((f) => (
|
||||
<div
|
||||
key={f.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
seat.threadId && handleViewInSandbox(seat.threadId, f)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && seat.threadId) {
|
||||
e.preventDefault();
|
||||
handleViewInSandbox(seat.threadId, f);
|
||||
}
|
||||
}}
|
||||
title="点击在沙箱中查看"
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2.5 transition hover:border-primary/40 hover:bg-primary/5 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="block truncate text-[11.5px] font-bold text-slate-800"
|
||||
title={f.name}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="block text-[9.5px] font-mono text-slate-400">
|
||||
{formatBytes(f.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (seat.threadId) handleDownload(seat.threadId, f);
|
||||
}}
|
||||
className="rounded p-1 text-slate-400 transition hover:bg-slate-100 hover:text-primary"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
|
||||
>
|
||||
重新起草新规划
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 内联文件沙箱(2026-06-13):点文件卡片从右侧滑入,左侧席位栏只收起不消失。
|
||||
width 0↔46% 过渡 + 面板内容平移,关闭后 target 保留到动画结束。 ── */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 min-h-0 overflow-hidden transition-all duration-300 ease-in-out",
|
||||
fileSandboxOpen && fileSandboxTarget ? "w-[46%]" : "w-0",
|
||||
)}
|
||||
>
|
||||
{fileSandboxTarget && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full border-l border-slate-100 bg-[#fffdfa] p-3 transition-transform duration-300 ease-in-out",
|
||||
fileSandboxOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{!fileSandboxIsText ? (
|
||||
// 二进制(图片 / PDF 等):直接 iframe 下载路由(浏览器原生渲染)。
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-slate-100 px-3 py-2">
|
||||
<span className="truncate text-xs font-bold text-slate-700">
|
||||
{fileSandboxTarget.file.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseFileSandbox}
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
title="关闭预览"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
title={fileSandboxTarget.file.name}
|
||||
src={buildArtifactDownloadUrl(
|
||||
fileSandboxTarget.threadId,
|
||||
fileSandboxTarget.file.path,
|
||||
)}
|
||||
className="min-h-0 flex-1 w-full border-0 bg-white"
|
||||
/>
|
||||
</div>
|
||||
) : fileSandboxContent == null ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-xs text-slate-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
正在读取文件内容…
|
||||
</div>
|
||||
) : (
|
||||
<ThreadContext.Provider value={{ thread: fileSandboxThreadStub, isMock: false }}>
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={fileSandboxVirtualUrl}
|
||||
threadId={fileSandboxTarget.threadId}
|
||||
onClose={handleCloseFileSandbox}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,13 +37,6 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
|
||||
const SHOW_BACKGROUND_SUSPEND = true;
|
||||
|
||||
@@ -65,7 +58,8 @@ import {
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
|
||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import {
|
||||
@@ -275,8 +269,22 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
|
||||
|
||||
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
|
||||
const showDagFlow =
|
||||
orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0;
|
||||
// 优化7(2026-06-13):推荐模式总控判定的**并行批**也能看流程图——把当前并行批
|
||||
// 构造成一个临时单 stage 计划喂给小窗(dag 模式仍用真实 orchestrationPlan)。
|
||||
const effectivePlan = useMemo<OrchestrationPlan | null>(() => {
|
||||
if (orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0) {
|
||||
return orchestrationPlan;
|
||||
}
|
||||
if (isParallelRunning && parallelBatch.length > 1) {
|
||||
return {
|
||||
mode: "dag",
|
||||
stages: [{ id: "recommend-parallel", agentIds: parallelBatch.map((s) => s.agentId) }],
|
||||
finalSynthesis: true,
|
||||
} as OrchestrationPlan;
|
||||
}
|
||||
return null;
|
||||
}, [orchestrationMode, orchestrationPlan, isParallelRunning, parallelBatch]);
|
||||
const showDagFlow = (effectivePlan?.stages.length ?? 0) > 0;
|
||||
const resolveDagSeat = useCallback(
|
||||
(agentId: string, order: number) => {
|
||||
const meta = agentNameToRole[agentId.toLowerCase()];
|
||||
@@ -287,9 +295,32 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
},
|
||||
[agentNameToRole],
|
||||
);
|
||||
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
|
||||
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
|
||||
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
|
||||
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
|
||||
const effectiveDagNodeStates = useMemo(() => {
|
||||
const merged: Record<string, ChainNodeState> = {};
|
||||
const deliveredNames = new Set(
|
||||
step2RoundtableDialogues
|
||||
.filter(
|
||||
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
|
||||
)
|
||||
.map((d) => String(d.sender ?? "")),
|
||||
);
|
||||
for (const [aid, meta] of Object.entries(agentNameToRole)) {
|
||||
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
|
||||
}
|
||||
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
|
||||
for (const s of parallelBatch) {
|
||||
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
|
||||
}
|
||||
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
|
||||
return merged;
|
||||
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch]);
|
||||
|
||||
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
|
||||
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
|
||||
const states = Object.values(dagNodeStates);
|
||||
const states = Object.values(effectiveDagNodeStates);
|
||||
const anyActive = states.includes("active");
|
||||
const anyStarted = states.some((s) => s !== "pending");
|
||||
const allDone = states.length > 0 && states.every((s) => s === "done");
|
||||
@@ -306,10 +337,32 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
? "active"
|
||||
: "pending";
|
||||
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
|
||||
}, [dagNodeStates, isOrchestrating, hasConsensus]);
|
||||
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
|
||||
|
||||
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
|
||||
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
|
||||
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
|
||||
const [dagFlowOpen, setDagFlowOpen] = useState(false);
|
||||
const { open: artifactsOpenForFlow } = useArtifacts();
|
||||
const dagFlowAutoOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!showDagFlow) {
|
||||
dagFlowAutoOpenedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!dagFlowAutoOpenedRef.current) {
|
||||
dagFlowAutoOpenedRef.current = true;
|
||||
// 沙箱已占着右侧就不抢(保持关闭,按钮可开)。
|
||||
if (!artifactsOpenForFlow) setDagFlowOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showDagFlow]);
|
||||
// 沙箱打开(出现产物预览)→ 自动关闭小窗腾出注意力;**边沿触发**,
|
||||
// 不会在沙箱保持打开期间反复压制用户手动重开的小窗。
|
||||
const prevArtifactsOpenRef = useRef(artifactsOpenForFlow);
|
||||
useEffect(() => {
|
||||
if (artifactsOpenForFlow && !prevArtifactsOpenRef.current) setDagFlowOpen(false);
|
||||
prevArtifactsOpenRef.current = artifactsOpenForFlow;
|
||||
}, [artifactsOpenForFlow]);
|
||||
|
||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
@@ -430,43 +483,37 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
|
||||
{showDagFlow && orchestrationPlan && (
|
||||
{/* 流程图小窗开关条(dag 编排 / 推荐并行批都显示)。 */}
|
||||
{showDagFlow && effectivePlan && (
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
|
||||
{orchestrationMode === "dag"
|
||||
? `分层编排执行中 · 共 ${effectivePlan.stages.length} 个阶段`
|
||||
: `并行执行中 · ${effectivePlan.stages[0]?.agentIds.length ?? 0} 个席位`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDagFlowOpen(true)}
|
||||
onClick={() => setDagFlowOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
|
||||
>
|
||||
<Network className="size-3.5" />
|
||||
查看编排流程图
|
||||
{dagFlowOpen ? "收起流程图" : "编排流程图"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showDagFlow && orchestrationPlan && (
|
||||
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
|
||||
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Network className="size-5 text-primary" />
|
||||
分层编排流程图
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<DagFlowChart
|
||||
stages={orchestrationPlan.stages}
|
||||
resolveSeat={resolveDagSeat}
|
||||
seatStates={dagNodeStates}
|
||||
coordinatorState={dagCoordinatorState}
|
||||
consensusState={dagConsensusState}
|
||||
showReportMilestone={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 流程图小窗:消息列表右上角(开关条下方)悬浮,竖向直角连线流程图。
|
||||
支持滚轮缩放/拖动平移/移动窗口/边缘缩放(组件内自管几何)。
|
||||
沙箱打开时自动关闭(见上方互斥 effect),点按钮可随时重开。 */}
|
||||
{showDagFlow && effectivePlan && dagFlowOpen && (
|
||||
<DagFlowMiniPanel
|
||||
stages={effectivePlan.stages}
|
||||
resolveSeat={resolveDagSeat}
|
||||
seatStates={effectiveDagNodeStates}
|
||||
coordinatorState={dagCoordinatorState}
|
||||
consensusState={dagConsensusState}
|
||||
onClose={() => setDagFlowOpen(false)}
|
||||
className="z-30"
|
||||
/>
|
||||
)}
|
||||
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
|
||||
{isParallelRunning && (
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Step3「结果绘制 · 大屏多页模式(模式二)」—— 与模式一同款交互(2026-06-13 重构)。
|
||||
*
|
||||
* 布局/交互对齐模式一(Step3ReportMode):左侧流式对话气泡 + 右侧沙箱(复用
|
||||
* `Step2SandboxLayout`,大屏 HTML 以 write_file 虚拟 stub 进沙箱 → 代码/预览切换、
|
||||
* 下载、新窗口打开全部可用)+ 底部修改要求输入框(同线程续轮修订全量 report-json)。
|
||||
*
|
||||
* 与模式一的差别只在产物管线:模型输出 1-2KB ```report-json,前端模板渲染
|
||||
* (lib/report-template)—— 快一个数量级、零截断;链路(initReportThread +
|
||||
* streamMultiAgent)与模式一完全同源。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import { isDashboardHtml, stripReportFence } from "@/roundtable-planning/lib/report-template";
|
||||
|
||||
interface Step3DashboardModeProps {
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
/** 页面层常驻的大屏生成流(2026-06-13 提升:切走不断流,进入续看)。 */
|
||||
dashboard: UseStep3DashboardReturn;
|
||||
/** 切回经典「单页看板」绘制模式。 */
|
||||
onSwitchClassic: () => void;
|
||||
/** 返回最终方案展示页。 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function Step3DashboardMode({
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
dashboard,
|
||||
onSwitchClassic,
|
||||
onBack,
|
||||
}: Step3DashboardModeProps) {
|
||||
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
dashThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
html,
|
||||
start,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
hydrateSaved,
|
||||
openDashboardPreview,
|
||||
} = dashboard;
|
||||
|
||||
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
|
||||
// 产物/进行中会立即接管沙箱续看。
|
||||
useEffect(() => {
|
||||
dashboard.setPreviewEnabled(true);
|
||||
return () => dashboard.setPreviewEnabled(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 续看语义:只兜底"从未启动过"(idle);**卸载不再 abort**(切走不断流)。
|
||||
// 优先 dashboardJson(结构化数据,按当前主题渲染);旧版 marker html 兜底。
|
||||
useEffect(() => {
|
||||
if (phase !== "idle") return;
|
||||
if (savedReport?.dashboardJson) {
|
||||
dashboard.hydrateFromJson(savedReport.dashboardJson);
|
||||
} else if (savedReport?.html && isDashboardHtml(savedReport.html)) {
|
||||
hydrateSaved(savedReport.html);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [revision, setRevision] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll(scrollRef, [dialogues]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = revision.trim();
|
||||
if (!text) return;
|
||||
setRevision("");
|
||||
ask(text);
|
||||
};
|
||||
|
||||
// 气泡显示:report-json 生 JSON 替换成一句人话(对齐 flow-json 的处理)。
|
||||
const displayDialogues = useMemo(
|
||||
() =>
|
||||
dialogues.map((d) => {
|
||||
const content = d.content ?? "";
|
||||
if (!content.includes("```report-json")) return d;
|
||||
return { ...d, content: stripReportFence(content, d.streaming === true) };
|
||||
}),
|
||||
[dialogues],
|
||||
);
|
||||
|
||||
const isStreaming = busy || dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
? "正在初始化绘制线程…"
|
||||
: phase === "streaming"
|
||||
? "大屏数据提炼中…"
|
||||
: phase === "done"
|
||||
? "绘制完成"
|
||||
: phase === "error"
|
||||
? "绘制失败"
|
||||
: "待绘制";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏(对齐模式一样式) */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutDashboard className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 大屏多页</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={modelsLoading || availableModels.length === 0}
|
||||
title="切换大屏绘制使用的推理模型"
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary disabled:opacity-50"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={abort}
|
||||
title="暂停生成"
|
||||
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
{html && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDashboardPreview}
|
||||
title="在右侧沙箱打开大屏预览"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-slate-500" />
|
||||
打开大屏预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={busy}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchClassic}
|
||||
title="切回经典单页看板绘制(模型自由发挥整页 HTML)"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
|
||||
单页看板
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
|
||||
返回报告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:对话 + 沙箱(复用第 2 步分栏,与模式一一致) */}
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<section className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? dashThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 对话流 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="custom-scrollbar flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4"
|
||||
>
|
||||
{displayDialogues.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
|
||||
<Loader2 className="w-7 h-7 animate-spin text-primary" />
|
||||
<span className="text-xs font-medium">正在初始化大屏绘制…</span>
|
||||
</div>
|
||||
)}
|
||||
{displayDialogues.map((d) => (
|
||||
<div key={d.id} className="flex gap-2.5">
|
||||
<div className="shrink-0">{getAvatar(d.avatarType ?? "blue", "h-7 w-7")}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex items-center gap-2 text-xs font-semibold text-slate-700">
|
||||
{d.sender}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-px text-[9.5px] font-bold",
|
||||
d.streaming
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
>
|
||||
{d.confidence}
|
||||
</span>
|
||||
<span className="text-[9.5px] font-mono text-slate-300">{d.time}</span>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none break-words text-[12.5px] leading-relaxed text-slate-700">
|
||||
<MarkdownContent>{d.content || "…"}</MarkdownContent>
|
||||
{d.streaming && (
|
||||
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse rounded bg-primary align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && phase === "error" && (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 修改要求输入框(与模式一同款交互:随时可发,busy 时先打断再续) */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="shrink-0 border-t border-slate-100 bg-slate-50/60 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={revision}
|
||||
onChange={(e) => setRevision(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="输入修改要求,例如:把 KPI 换成成本相关指标 / 风险图改成雷达图 / 增加一个验收阶段…"
|
||||
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs outline-none transition focus:border-primary focus:ring-1 focus:ring-primary/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!revision.trim()}
|
||||
className="shrink-0 flex h-9 items-center gap-1.5 rounded-xl bg-primary px-4 text-xs font-bold text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendHorizontal className="size-3.5" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import type {
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
|
||||
@@ -43,6 +45,10 @@ export interface Step3PanelProps {
|
||||
/** 草稿里已存的上次总结结果 + 完成回调 —— 透传给「总结报告」做持久化。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 页面层常驻的三个 Step3 生成流(2026-06-13 提升:切走不断流,进入续看)。 */
|
||||
summaryFlow: UseStep3SummaryReturn;
|
||||
reportFlow: UseStep3ReportReturn;
|
||||
dashboardFlow: UseStep3DashboardReturn;
|
||||
onReset: () => void;
|
||||
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
@@ -67,6 +73,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
summaryFlow,
|
||||
reportFlow,
|
||||
dashboardFlow,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -104,6 +113,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
summaryFlow={summaryFlow}
|
||||
reportFlow={reportFlow}
|
||||
dashboardFlow={dashboardFlow}
|
||||
onReset={onReset}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
seatStubMessages={seatStubMessages}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Step 3「结果绘制」模式。
|
||||
*
|
||||
* 布局对齐第 2 步:
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
@@ -45,11 +46,14 @@ import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import {
|
||||
useStep3Report,
|
||||
type ReportIntent,
|
||||
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type {
|
||||
ReportIntent,
|
||||
UseStep3ReportReturn,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
@@ -77,8 +81,10 @@ interface Step3ReportModeProps {
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
/** 页面层常驻的经典绘制流(2026-06-13 提升:切走不断流,进入续看)。 */
|
||||
report: UseStep3ReportReturn;
|
||||
/** 页面层常驻的大屏多页流(模式二)。 */
|
||||
dashboard: UseStep3DashboardReturn;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
@@ -97,7 +103,8 @@ export function Step3ReportMode({
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
report,
|
||||
dashboard,
|
||||
onBack,
|
||||
}: Step3ReportModeProps) {
|
||||
const reportSeatDeliveries = useMemo(
|
||||
@@ -112,6 +119,7 @@ export function Step3ReportMode({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
@@ -125,17 +133,9 @@ export function Step3ReportMode({
|
||||
pause,
|
||||
interject,
|
||||
themeMismatch,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
} = useStep3Report({
|
||||
intentReady,
|
||||
seatDeliveries: reportSeatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
});
|
||||
} = report;
|
||||
|
||||
const [modal, setModal] = useState<SourceModal>(null);
|
||||
|
||||
@@ -151,18 +151,31 @@ export function Step3ReportMode({
|
||||
}
|
||||
}, [artifactsOpen]);
|
||||
|
||||
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
|
||||
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
|
||||
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
|
||||
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
|
||||
// 若草稿里已存上次绘制结果(savedReport)→ 直接恢复展示,不重新跑模型;用户可点「重新绘制」覆盖。
|
||||
// ── 绘制模式(2026-06-13):classic = 经典单页看板(模型自由写整页 HTML);
|
||||
// dashboard = 大屏多页(模型只出 report-json,模板渲染)。两条流现在**并行常驻**
|
||||
// (页面进入第三步即三流齐跑),进入本页默认落经典视图;仅旧版草稿「只有大屏 HTML
|
||||
// 存在 html 槽位(带 marker)」时直接落大屏。
|
||||
const [dashboardMode, setDashboardMode] = useState(() => isDashboardHtml(savedReport?.html));
|
||||
|
||||
// 预览开关:经典绘制视图在前台(非大屏模式)时本流才允许自动打开沙箱(并行流防抢);
|
||||
// 进入时若已有产物/进行中会立即接管沙箱续看。
|
||||
useEffect(() => {
|
||||
if (savedReport?.html) {
|
||||
if (dashboardMode) return;
|
||||
report.setPreviewEnabled(true);
|
||||
return () => report.setPreviewEnabled(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardMode]);
|
||||
|
||||
// 续看语义(2026-06-13):hook 常驻页面层,进入第三步时页面已统一编排(总结 +
|
||||
// 经典绘制并行启动)。这里只兜底"从未启动过"(idle)的场景,**卸载不再 abort**——
|
||||
// 切回总结报告 / 回第二步,绘制流照跑,回头继续看。
|
||||
useEffect(() => {
|
||||
if (dashboardMode || phase !== "idle") return;
|
||||
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
|
||||
hydrateSaved(savedReport);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return () => abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -188,6 +201,33 @@ export function Step3ReportMode({
|
||||
? "已暂停"
|
||||
: "待绘制";
|
||||
|
||||
// ── 模式二:大屏多页(模板渲染)。经典模式的所有 hooks 已在上方调用完毕,
|
||||
// 这里早退渲染不违反 hooks 规则。 ──
|
||||
if (dashboardMode) {
|
||||
return (
|
||||
<Step3DashboardMode
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={savedReport}
|
||||
dashboard={dashboard}
|
||||
onSwitchClassic={() => {
|
||||
// 切回经典:**不打断**大屏流(切走不断流);经典从未启动则补一次。
|
||||
setDashboardMode(false);
|
||||
if (phase === "idle") {
|
||||
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
|
||||
hydrateSaved(savedReport);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏 */}
|
||||
@@ -224,6 +264,15 @@ export function Step3ReportMode({
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDashboardMode(true)}
|
||||
title="大屏多页模式:模型只输出结构化数据、模板渲染总览页 + 每席位一页(更快,支持席位间跳转)。经典绘制若在进行中不会被打断,切回可续看。"
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shadow-sm shadow-primary/10"
|
||||
>
|
||||
<LayoutDashboard className="w-3.5 h-3.5" />
|
||||
大屏多页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -463,14 +512,14 @@ function AgentProgressPanel({
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
phase: ReturnType<typeof useStep3Report>["phase"];
|
||||
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
|
||||
phase: UseStep3ReportReturn["phase"];
|
||||
dialogues: UseStep3ReportReturn["dialogues"];
|
||||
error: string | null;
|
||||
themeMismatch: boolean;
|
||||
onRetry: () => void;
|
||||
onRevisionSubmit: (revisionInstruction: string) => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
|
||||
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
|
||||
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
|
||||
*
|
||||
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
|
||||
@@ -56,7 +56,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
@@ -81,8 +81,8 @@ interface Step3SummaryModeProps {
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
|
||||
summary: UseStep3SummaryReturn;
|
||||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||||
onViewStatic: () => void;
|
||||
}
|
||||
@@ -103,13 +103,10 @@ export function Step3SummaryMode({
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
summary,
|
||||
onViewStatic,
|
||||
}: Step3SummaryModeProps) {
|
||||
const summarySeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
[seatDeliveries],
|
||||
);
|
||||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||||
|
||||
const consensusDocTitle = useMemo(() => {
|
||||
const objective = intentReady?.objective?.trim();
|
||||
@@ -118,6 +115,7 @@ export function Step3SummaryMode({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
@@ -131,18 +129,9 @@ export function Step3SummaryMode({
|
||||
redraw,
|
||||
ask,
|
||||
pause,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
} = useStep3Summary({
|
||||
intentReady,
|
||||
seatDeliveries: summarySeatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
});
|
||||
} = summary;
|
||||
|
||||
const [modal, setModal] = useState<SourceModal>(null);
|
||||
|
||||
@@ -224,15 +213,24 @@ export function Step3SummaryMode({
|
||||
}
|
||||
}, [rightPanelVisible]);
|
||||
|
||||
// 进入即自动开跑;草稿里已存结果则恢复展示(流程图模式的快照可能只有 closing 没有 md)。
|
||||
// 卸载中断在途请求(理由同 Step3ReportMode)。
|
||||
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
|
||||
// 产物/进行中会立即接管沙箱续看。
|
||||
useEffect(() => {
|
||||
if (savedSummary && (savedSummary.md.trim() || savedSummary.closing?.trim())) {
|
||||
summary.setPreviewEnabled(true);
|
||||
return () => summary.setPreviewEnabled(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 续看语义(2026-06-13):hook 常驻页面层,页面在进入第三步时已统一编排启动。
|
||||
// 这里只做"从未启动过"的兜底(idle 才动手),**卸载不再 abort** —— 用户切去
|
||||
// 结果绘制 / 回第二步,总结流照跑,回头继续看进行中过程或结果。
|
||||
useEffect(() => {
|
||||
if (phase !== "idle") return;
|
||||
if (savedSummary && (savedSummary.md.trim() || (savedSummary.closing ?? "").trim())) {
|
||||
hydrateSaved(savedSummary);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return () => abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -557,8 +555,8 @@ function AgentProgressPanel({
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
phase: ReturnType<typeof useStep3Summary>["phase"];
|
||||
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
|
||||
phase: UseStep3SummaryReturn["phase"];
|
||||
dialogues: UseStep3SummaryReturn["dialogues"];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
|
||||
@@ -566,8 +564,8 @@ function AgentProgressPanel({
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Summary>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Summary>["openArtifactPreview"];
|
||||
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
|
||||
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface UseDraftPersistenceReturn {
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
handleManualSave: () => void;
|
||||
handleLoadDraft: (id: string) => void;
|
||||
handleLoadDraft: (id: string) => Promise<boolean>;
|
||||
/** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
|
||||
loadLatestByTask: (taskId: string) => Promise<boolean>;
|
||||
handleDeleteDraft: (id: string) => void;
|
||||
@@ -115,6 +115,11 @@ export interface UseDraftPersistenceReturn {
|
||||
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
|
||||
*/
|
||||
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/**
|
||||
* **强制**把当前会话内容存进草稿(不同于 ensureDraft 的"已有就跳过")——后台挂起
|
||||
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
|
||||
*/
|
||||
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/** 强制刷新草稿列表。 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -271,6 +276,17 @@ export function useDraftPersistence(
|
||||
[currentStep, enqueueSave, refreshList],
|
||||
);
|
||||
|
||||
// 强制存当前内容(与 ensureDraft 不同:已有草稿也照存,把最新对话回写进去)。
|
||||
const saveDraftNow = useCallback(
|
||||
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
|
||||
const hadId = currentDraftIdRef.current;
|
||||
const id = await enqueueSave(furthestStep);
|
||||
if (id && !hadId) await refreshList();
|
||||
return id;
|
||||
},
|
||||
[currentStep, enqueueSave, refreshList],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) || 1;
|
||||
void (async () => {
|
||||
@@ -381,23 +397,26 @@ export function useDraftPersistence(
|
||||
],
|
||||
);
|
||||
|
||||
// 返回 Promise<是否成功 hydrate>:调用方(如"查看运行中作业")需在 hydrate 完成
|
||||
// **之后**再订阅 SSE 实时流,否则草稿快照会覆盖实时帧(竞态)。
|
||||
const handleLoadDraft = useCallback(
|
||||
(id: string) => {
|
||||
void (async () => {
|
||||
(id: string): Promise<boolean> => {
|
||||
return (async () => {
|
||||
let draft;
|
||||
try {
|
||||
draft = await getDraft(id);
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 加载草稿失败:", err);
|
||||
triggerToast("⚠️ 加载草稿失败,请重试");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!draft) {
|
||||
triggerToast("⚠️ 该草稿已不存在");
|
||||
await refreshList();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
applyLoadedDraft(draft, `📂 已加载草稿:${draft.title}`);
|
||||
return true;
|
||||
})();
|
||||
},
|
||||
[applyLoadedDraft, refreshList, triggerToast],
|
||||
@@ -515,6 +534,7 @@ export function useDraftPersistence(
|
||||
handleCommitRename,
|
||||
handleCancelRename,
|
||||
ensureDraft,
|
||||
saveDraftNow,
|
||||
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
|
||||
refresh: refreshList,
|
||||
};
|
||||
|
||||
@@ -330,6 +330,17 @@ export interface UseStep2OrchestrationReturn {
|
||||
handleSaveAgentAndRecalculate: (id: number) => void;
|
||||
/** 从草稿加载;同时把 step2InitRef 置为「已 init」跳过自动 SSE。 */
|
||||
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
|
||||
/**
|
||||
* 「查看=恢复流程」(2026-06-13):后台作业 SSE 每帧调用,把作业实时对话/产物 stub/
|
||||
* 共识进度灌进第二步画面 —— 消息列表像本地编排一样实时长出,而不是静态聊天记录。
|
||||
*/
|
||||
applyLiveJobFrame: (frame: {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
consensusPercentage: number;
|
||||
hasConsensus: boolean;
|
||||
lastLeaderContent: string;
|
||||
}) => void;
|
||||
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
|
||||
resetStep2State: () => void;
|
||||
/** 当前沙箱预览绑定的席位 thread id。 */
|
||||
@@ -1129,20 +1140,26 @@ export function useStep2Orchestration(
|
||||
}
|
||||
}, [selectedParallelSeats, setDialogueContent]);
|
||||
|
||||
// 推荐模式并行批进行中标记(2026-06-13):总控一轮派给多个席位 → 判定可并行,
|
||||
// 批内并行执行 + 显示走「业务链 sandbox 单列可写」同款(含下方沙箱跟随)。
|
||||
const recommendParallelRef = useRef(false);
|
||||
|
||||
// sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时,
|
||||
// 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。
|
||||
// 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact
|
||||
// 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。
|
||||
// 推荐模式并行批(recommendParallelRef)同样按单列沙箱跟随处理。
|
||||
useEffect(() => {
|
||||
const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : "";
|
||||
const singleColumn = dagExecMode === "sandbox" || recommendParallelRef.current;
|
||||
const seat = singleColumn ? selectedParallelSeats[0] ?? "" : "";
|
||||
// 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact /
|
||||
// setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱
|
||||
// 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。
|
||||
const key = `${dagExecMode}:${seat}`;
|
||||
const key = `${dagExecMode}:${recommendParallelRef.current ? "rp" : ""}:${seat}`;
|
||||
if (lastReconciledSeatRef.current === key) return;
|
||||
lastReconciledSeatRef.current = key;
|
||||
|
||||
if (dagExecMode !== "sandbox" || !seat) {
|
||||
if (!singleColumn || !seat) {
|
||||
sandboxFollowThreadIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -1340,7 +1357,89 @@ export function useStep2Orchestration(
|
||||
const isStale = () =>
|
||||
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
|
||||
|
||||
let nextLeaderTask = seedMessage;
|
||||
// 串/并行交给**总控判断**(2026-06-13):各席位子任务相互独立 → 同一轮一次性
|
||||
// 派给多个席位(前端并行执行,单列可写显示);有先后依赖 → 每轮只派一个。
|
||||
const PARALLEL_JUDGE_NOTE =
|
||||
"\n\n【派活方式】派活前请先判断各席位子任务之间的依赖关系:相互独立的子任务请在" +
|
||||
"**同一轮**通过 agent_orchestration 一次性派给多个席位(系统会并行执行、显著提速);" +
|
||||
"有先后依赖的子任务则每轮只派一个席位,等其交付后再派下一个。";
|
||||
// 优化1+2(2026-06-13):每个席位任务都拼上「直接产出别提问 + 承接前序 + 结构化交付模板」。
|
||||
// 提升交付质量,并让 Step3 三产物(尤其大屏 seats.sections)的提炼有稳定结构可循。
|
||||
const SEAT_DELIVERY_GUIDE =
|
||||
"\n\n【交付要求】请直接基于已知信息做出合理假设、给出完整交付,**不要向用户提问或等待确认**;" +
|
||||
"承接此前各席位的交付(需要其它席位产物全文时用 read_peer_delivery 获取),不要重复或推翻已有结论。" +
|
||||
"交付请按以下结构组织:① 一句话核心结论(先行);② 关键要点(分条列出);" +
|
||||
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
|
||||
// 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run
|
||||
// (后端注入 context,席位用 read_peer_delivery 按需取全文,不进 prompt 不耗 token)。
|
||||
const priorDeliveries: Array<{ name: string; content: string }> = [];
|
||||
|
||||
// 共享的「单席位交付执行」——串行/并行分支共用。含**失败自动重试一次**(优化5,
|
||||
// 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。
|
||||
const runSeatDelivery = async (a: {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
task: string;
|
||||
bubbleId: number;
|
||||
seatThreadId: string | undefined;
|
||||
isParallel: boolean;
|
||||
}): Promise<{ content: string; aborted: boolean }> => {
|
||||
const meta = agentNameToRoleRef.current[a.agentId.toLowerCase()];
|
||||
const perSeatModel = meta ? agentConfigsRef.current[meta.speakerId]?.model : undefined;
|
||||
const fullTask = a.task + SEAT_DELIVERY_GUIDE;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: a.agentId,
|
||||
displayName: a.displayName,
|
||||
newMessage: fullTask,
|
||||
peerDeliveries: priorDeliveries,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) =>
|
||||
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(a.bubbleId, label);
|
||||
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
|
||||
});
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (isStale()) {
|
||||
finalizeDialogue(a.bubbleId, specRes.content || "(已停止)", "已停止");
|
||||
return { content: "", aborted: true };
|
||||
}
|
||||
finalizeDialogue(a.bubbleId, specRes.content, "子智能体交付");
|
||||
return { content: specRes.content ?? "", aborted: false };
|
||||
} catch (err) {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(a.bubbleId, "(已停止)", "已停止");
|
||||
return { content: "", aborted: true };
|
||||
}
|
||||
if (attempt === 0) {
|
||||
// 第一次失败 → 清掉半截内容,自动重试一次。
|
||||
console.warn(`[special:${a.agentId}] 第一次失败,自动重试:`, err);
|
||||
if (a.isParallel) parallelStreamRef.current.delete(a.bubbleId);
|
||||
else setDialogueContent(a.bubbleId, "");
|
||||
setDialogueConfidence(a.bubbleId, "失败重试中…");
|
||||
continue;
|
||||
}
|
||||
console.error(`[special:${a.agentId}] error:`, err);
|
||||
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
return { content: "", aborted: false };
|
||||
}
|
||||
}
|
||||
return { content: "", aborted: false };
|
||||
};
|
||||
|
||||
let nextLeaderTask = seedMessage + PARALLEL_JUDGE_NOTE;
|
||||
let safetyCounter = 0;
|
||||
|
||||
try {
|
||||
@@ -1445,86 +1544,97 @@ export function useStep2Orchestration(
|
||||
break;
|
||||
}
|
||||
|
||||
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
|
||||
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
|
||||
if (isStale()) break;
|
||||
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const perSeatModel = meta
|
||||
? agentConfigsRef.current[meta.speakerId]?.model
|
||||
: undefined;
|
||||
|
||||
const seatThreadId = resolveSeatThreadId(ids, subName);
|
||||
const subBubbleId = appendStreamingDialogue({
|
||||
sender: meta?.displayName ?? subName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
agentThreadId: seatThreadId,
|
||||
});
|
||||
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
let aborted = false;
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: subName,
|
||||
displayName: meta?.displayName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(subBubbleId, label);
|
||||
appendStepIfNew(
|
||||
subBubbleId,
|
||||
label,
|
||||
`${phase}:${toolName ?? ""}`,
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(
|
||||
phase,
|
||||
toolName,
|
||||
args,
|
||||
seatThreadId,
|
||||
);
|
||||
},
|
||||
onUpstreamFrame: (frame) =>
|
||||
handleUpstreamFrame(frame, seatThreadId),
|
||||
const dispatched = leaderRes.dispatched as LeaderDispatch[];
|
||||
if (dispatched.length > 1) {
|
||||
// ── 总控一轮派给多个席位 = 它判定子任务相互独立 → **批内并行**执行
|
||||
// (2026-06-13)。显示走「业务链 sandbox 单列可写」同款:头像条 + 单列
|
||||
// 消息流 + 沙箱跟随选中席位;席位可正常写文件(seat policy)。
|
||||
recommendParallelRef.current = true;
|
||||
const seatPrep = dispatched.map(([subName, task]) => {
|
||||
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
|
||||
const seatName = meta?.displayName ?? subName;
|
||||
const avatarType = meta?.avatarType ?? "blue";
|
||||
const seatThreadId = resolveSeatThreadId(ids, subName);
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: seatName,
|
||||
avatarType,
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
agentThreadId: seatThreadId,
|
||||
});
|
||||
return { agentId: subName, task, meta, seatName, avatarType, seatThreadId, bubbleId };
|
||||
});
|
||||
markParallelSpeakers(
|
||||
seatPrep
|
||||
.map((p) => p.meta?.speakerId)
|
||||
.filter((v): v is number => typeof v === "number"),
|
||||
);
|
||||
setParallelBatch(
|
||||
seatPrep.map((p) => ({
|
||||
agentId: p.agentId,
|
||||
displayName: p.seatName,
|
||||
avatarType: p.avatarType,
|
||||
status: "running" as const,
|
||||
bubbleId: p.bubbleId,
|
||||
})),
|
||||
);
|
||||
// 单列(对齐业务链 sandbox 模式):默认选中第一个,点头像切换。
|
||||
setSelectedParallelSeats(seatPrep.slice(0, 1).map((p) => p.agentId));
|
||||
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
|
||||
setParallelBatch((prev) =>
|
||||
prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)),
|
||||
);
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
seatPrep.map(async ({ agentId, task, meta, seatName, seatThreadId, bubbleId }) => {
|
||||
const r = await runSeatDelivery({
|
||||
agentId,
|
||||
displayName: seatName,
|
||||
task,
|
||||
bubbleId,
|
||||
seatThreadId,
|
||||
isParallel: true,
|
||||
});
|
||||
markBatchStatus(agentId, "done");
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
parallelStreamRef.current.delete(bubbleId);
|
||||
return r.content.trim() ? { name: seatName, content: r.content } : null;
|
||||
}),
|
||||
);
|
||||
|
||||
// 批结束:收起并行显示,回普通流式列表。
|
||||
setParallelBatch([]);
|
||||
setSelectedParallelSeats([]);
|
||||
resetParallelStreams();
|
||||
recommendParallelRef.current = false;
|
||||
// 累积本批交付(供后续轮席位 read_peer_delivery 取全文)。
|
||||
for (const r of batchResults) if (r) priorDeliveries.push(r);
|
||||
} else {
|
||||
// **顺序**派活(单席位 / 总控判定有依赖)—— 不要改回 Promise.all,详见 §5.4。
|
||||
for (const [subName, task] of dispatched) {
|
||||
if (isStale()) break;
|
||||
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const seatThreadId = resolveSeatThreadId(ids, subName);
|
||||
const subBubbleId = appendStreamingDialogue({
|
||||
sender: meta?.displayName ?? subName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
agentThreadId: seatThreadId,
|
||||
});
|
||||
const r = await runSeatDelivery({
|
||||
agentId: subName,
|
||||
displayName: meta?.displayName ?? subName,
|
||||
task,
|
||||
bubbleId: subBubbleId,
|
||||
seatThreadId,
|
||||
isParallel: false,
|
||||
});
|
||||
if (isStale()) {
|
||||
finalizeDialogue(
|
||||
subBubbleId,
|
||||
specRes.content || "(已停止)",
|
||||
"已停止",
|
||||
);
|
||||
aborted = true;
|
||||
} else {
|
||||
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
subAborter.signal.aborted ||
|
||||
(err as Error).name === "AbortError"
|
||||
) {
|
||||
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
|
||||
aborted = true;
|
||||
} else {
|
||||
console.error(`[special:${subName}] error:`, err);
|
||||
finalizeDialogue(
|
||||
subBubbleId,
|
||||
`(错误) ${(err as Error).message}`,
|
||||
"调用失败",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
if (r.content.trim()) priorDeliveries.push({ name: meta?.displayName ?? subName, content: r.content });
|
||||
if (r.aborted) break;
|
||||
}
|
||||
if (aborted) break;
|
||||
}
|
||||
|
||||
if (isStale()) break;
|
||||
@@ -1533,8 +1643,14 @@ export function useStep2Orchestration(
|
||||
Math.min(95, parseFloat((prev + 8).toFixed(1))),
|
||||
);
|
||||
|
||||
// 优化3:续轮强化收敛指引——信息足够就**明确收口**,结论开头标注【已达成共识】,
|
||||
// 减少弱模型该收口时还乱派 / 该继续时草草收尾的两类偏差。
|
||||
nextLeaderTask =
|
||||
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
|
||||
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
|
||||
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
|
||||
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
|
||||
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
|
||||
PARALLEL_JUDGE_NOTE;
|
||||
}
|
||||
|
||||
if (safetyCounter >= MAX_CYCLES) {
|
||||
@@ -1554,11 +1670,15 @@ export function useStep2Orchestration(
|
||||
appendToDialogue,
|
||||
appendStepIfNew,
|
||||
setDialogueConfidence,
|
||||
setDialogueContent,
|
||||
phaseLabel,
|
||||
finalizeDialogue,
|
||||
highlightSpeaker,
|
||||
markSpeakerStreaming,
|
||||
markSpeakerIdle,
|
||||
markParallelSpeakers,
|
||||
ingestParallelDelta,
|
||||
resetParallelStreams,
|
||||
clearAllStreamingSpeakers,
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
@@ -2531,6 +2651,26 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
|
||||
// 线程/init 闸门 —— 那些由先行的 hydrateFromDraft 建好)。
|
||||
const applyLiveJobFrame = useCallback(
|
||||
(frame: {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
consensusPercentage: number;
|
||||
hasConsensus: boolean;
|
||||
lastLeaderContent: string;
|
||||
}) => {
|
||||
setStep2RoundtableDialogues(frame.dialogues);
|
||||
seatStubMessagesRef.current = frame.seatStubMessages;
|
||||
setSeatStubMessages(frame.seatStubMessages);
|
||||
setConsensusPercentage(frame.consensusPercentage);
|
||||
setHasConsensus(frame.hasConsensus);
|
||||
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetStep2State = useCallback(() => {
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
@@ -2608,6 +2748,7 @@ export function useStep2Orchestration(
|
||||
handleBudgetChange,
|
||||
handleSaveAgentAndRecalculate,
|
||||
hydrateFromDraft,
|
||||
applyLiveJobFrame,
|
||||
resetStep2State,
|
||||
activeArtifactThreadId,
|
||||
seatStubMessages,
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Step 3「结果绘制 · 大屏多页(模式二)」编排 hook —— 与模式一同款交互(2026-06-13)。
|
||||
*
|
||||
* 链路与模式一/总结报告完全同源(`initReportThread` + `streamMultiAgent`,
|
||||
* 已验证可用):流式对话气泡 + 右侧沙箱预览 + 输入框继续修改。区别在产物:
|
||||
* - 模型只输出 ```report-json 结构化数据(buildDashboardPrompt,禁工具禁文件);
|
||||
* - 前端 `renderDashboardHtml` 模板渲染成大屏 HTML,**种成 write_file 虚拟 stub**
|
||||
* (path = DASHBOARD_OUTPUT_PATH)→ 走与模式一相同的沙箱 HTML 预览(含代码/预览
|
||||
* 切换、下载、新窗口打开);
|
||||
* - `ask(message)`:同线程续轮,要求模型输出**修订后的全量** report-json → 重渲染
|
||||
* → 更新 stub 与草稿快照。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import {
|
||||
initReportThread,
|
||||
streamMultiAgent,
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
buildDashboardPrompt,
|
||||
DASHBOARD_OUTPUT_PATH,
|
||||
extractReportData,
|
||||
isDashboardHtml,
|
||||
parseReportJson,
|
||||
renderDashboardHtml,
|
||||
} from "@/roundtable-planning/lib/report-template";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
const DASH_AGENT_FALLBACK = "roundtable-report";
|
||||
|
||||
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
|
||||
|
||||
interface UseStep3DashboardOptions {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: Array<{ sender: string; content: string }>;
|
||||
consensusText: string;
|
||||
selectedModel: string;
|
||||
/** 当前界面主题 —— 大屏按它取色(light=浅红色 UI 规范 / dark=深蓝色 UI 规范)。 */
|
||||
themeMode: "light" | "dark";
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
}
|
||||
|
||||
export interface UseStep3DashboardReturn {
|
||||
phase: DashboardPhase;
|
||||
error: string | null;
|
||||
dashThreadId: string | null;
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
activeArtifactThreadId: string | null;
|
||||
busy: boolean;
|
||||
/** 当前大屏 HTML(已渲染;无则 null)。 */
|
||||
html: string | null;
|
||||
start: () => void;
|
||||
redraw: () => void;
|
||||
/** 用户输入修改要求 → 同线程续轮,输出修订版全量数据并重渲染。 */
|
||||
ask: (message: string) => void;
|
||||
abort: () => void;
|
||||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||||
setPreviewEnabled: (enabled: boolean) => void;
|
||||
/** 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 完成气泡,不自动开沙箱。 */
|
||||
hydrateSaved: (html: string) => void;
|
||||
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
|
||||
hydrateFromJson: (jsonStr: string) => void;
|
||||
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
|
||||
openDashboardPreview: () => void;
|
||||
}
|
||||
|
||||
function formatNow(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function dashPhaseLabel(phase: StreamPhase): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "streaming":
|
||||
return "正在提炼大屏数据";
|
||||
default:
|
||||
return "正在输出…";
|
||||
}
|
||||
}
|
||||
|
||||
export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3DashboardReturn {
|
||||
const {
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
themeMode,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
} = options;
|
||||
|
||||
const onSavedRef = useRef(onReportSaved);
|
||||
useEffect(() => {
|
||||
onSavedRef.current = onReportSaved;
|
||||
}, [onReportSaved]);
|
||||
|
||||
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
artifactsOpenRef.current = artifactsOpen;
|
||||
}, [artifactsOpen]);
|
||||
|
||||
const [phase, setPhase] = useState<DashboardPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dashThreadId, setDashThreadId] = useState<string | null>(null);
|
||||
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
|
||||
const [seatStubMessages, setSeatStubMessages] = useState<Record<string, StubAiMessage[]>>({});
|
||||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(null);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
const runningRef = useRef(false);
|
||||
const aborterRef = useRef<AbortController | null>(null);
|
||||
const liveThreadRef = useRef<string | null>(null);
|
||||
const agentNameRef = useRef<string>(DASH_AGENT_FALLBACK);
|
||||
const htmlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 气泡三段式(append → delta → finalize),对齐 useStep3Summary ──
|
||||
const appendStreamingDialogue = useCallback((threadId?: string): number => {
|
||||
const id = Date.now() + Math.random();
|
||||
setDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
sender: "大屏绘制智能体",
|
||||
avatarType: "blue",
|
||||
content: "",
|
||||
time: nowTime(),
|
||||
confidence: "调度中",
|
||||
streaming: true,
|
||||
steps: [],
|
||||
agentThreadId: threadId,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const appendUserDialogue = useCallback((text: string) => {
|
||||
setDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + Math.random(),
|
||||
sender: "指挥官(修改要求)",
|
||||
avatarType: "human",
|
||||
content: text,
|
||||
time: nowTime(),
|
||||
confidence: "已发送",
|
||||
streaming: false,
|
||||
steps: [],
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const appendToDialogue = useCallback((id: number, deltaText: string) => {
|
||||
if (!deltaText) return;
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
|
||||
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, confidence } : d)));
|
||||
}, []);
|
||||
|
||||
const finalizeDialogue = useCallback((id: number, finalContent: string, confidence?: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== id) return d;
|
||||
const next = finalContent?.trim() ? finalContent : d.content || "(无文本输出)";
|
||||
return { ...d, content: next, confidence: confidence ?? d.confidence, streaming: false };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── 大屏 stub(write_file 虚拟产物 → 沙箱 HTML 预览) ──
|
||||
const upsertDashStub = useCallback((threadId: string, htmlContent: string) => {
|
||||
const messageId = `roundtable-dashboard-stub-${threadId}`;
|
||||
const toolCallId = `tc-${threadId}-0`;
|
||||
const msg: StubAiMessage = {
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: toolCallId, name: "write_file", args: { path: DASHBOARD_OUTPUT_PATH, content: htmlContent } },
|
||||
],
|
||||
};
|
||||
const nextMap = { ...stubRef.current, [threadId]: [msg] };
|
||||
stubRef.current = nextMap;
|
||||
setSeatStubMessages(nextMap);
|
||||
return { messageId, toolCallId };
|
||||
}, []);
|
||||
|
||||
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
|
||||
const previewEnabledRef = useRef(false);
|
||||
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openDashboardArtifact = useCallback(
|
||||
(threadId: string, messageId: string, toolCallId: string, opts?: { force?: boolean }) => {
|
||||
lastAutoOpenRef.current = { threadId, messageId, toolCallId };
|
||||
if (!opts?.force && !previewEnabledRef.current) return; // 不在前台只记录不抢
|
||||
const virtualUrl = new URL(
|
||||
`write-file:${DASHBOARD_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
).toString();
|
||||
setActiveArtifactThreadId(threadId);
|
||||
selectArtifact(virtualUrl, false);
|
||||
setArtifactsOpen(true);
|
||||
},
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
const setPreviewEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
previewEnabledRef.current = enabled;
|
||||
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台——
|
||||
// 面板停留在其它流的虚拟文件上时本视图查不到内容、显示空白);面板关着
|
||||
// 则保持关闭(尊重"进第三步默认关沙箱"),用户点按钮再开。
|
||||
const last = lastAutoOpenRef.current;
|
||||
if (enabled && last && artifactsOpenRef.current) {
|
||||
openDashboardArtifact(last.threadId, last.messageId, last.toolCallId, { force: true });
|
||||
}
|
||||
},
|
||||
[openDashboardArtifact],
|
||||
);
|
||||
|
||||
const openDashboardPreview = useCallback(() => {
|
||||
const tid = liveThreadRef.current ?? activeArtifactThreadId ?? "roundtable-dashboard-restored";
|
||||
const current = htmlRef.current;
|
||||
if (!current) return;
|
||||
const { messageId, toolCallId } = upsertDashStub(tid, current);
|
||||
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
|
||||
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
aborterRef.current = null;
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
/** 一轮流式:发 message 给大屏绘制线程,**生成过程实时进沙箱**(结构化数据边出
|
||||
* 边以代码态显示,对齐模式一"看着 HTML 被写出来"的体验),结束后解析
|
||||
* report-json → 按当前主题渲染 → 沙箱自动切预览。 */
|
||||
const streamOnce = useCallback(
|
||||
async (args: { bubbleId: number; message: string; aborter: AbortController }) => {
|
||||
const { bubbleId, message, aborter } = args;
|
||||
let threadId = liveThreadRef.current;
|
||||
if (!threadId) {
|
||||
const init = await initReportThread({ signal: aborter.signal });
|
||||
if (aborter.signal.aborted) return;
|
||||
threadId = init.threadId;
|
||||
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
|
||||
liveThreadRef.current = threadId;
|
||||
setDashThreadId(threadId);
|
||||
}
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
|
||||
);
|
||||
setPhase("streaming");
|
||||
|
||||
// 生成开始就打开沙箱:流式期间 stub 内容=模型已输出的结构化数据(代码态展示),
|
||||
// 完成后替换成渲染好的大屏 HTML(isLoading 翻 false → 预览自动切换)。
|
||||
let acc = "";
|
||||
let lastFlush = 0;
|
||||
const initial = upsertDashStub(threadId, "(正在连接模型,结构化数据稍后在此实时显示…)");
|
||||
openDashboardArtifact(threadId, initial.messageId, initial.toolCallId);
|
||||
const flushStub = (force = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastFlush < 200) return;
|
||||
lastFlush = now;
|
||||
upsertDashStub(threadId!, acc);
|
||||
};
|
||||
|
||||
const res = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
agentName: agentNameRef.current,
|
||||
dAgentThreadId: { [agentNameRef.current]: threadId },
|
||||
displayName: "大屏绘制",
|
||||
newMessage: message,
|
||||
model: selectedModel || undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (text) => {
|
||||
appendToDialogue(bubbleId, text);
|
||||
acc += text;
|
||||
flushStub();
|
||||
},
|
||||
onPhaseChange: (p) => setDialogueConfidence(bubbleId, dashPhaseLabel(p)),
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
acc = res.content || acc;
|
||||
flushStub(true);
|
||||
|
||||
const data = extractReportData(res.content);
|
||||
if (!data) {
|
||||
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
|
||||
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
|
||||
finalizeDialogue(
|
||||
bubbleId,
|
||||
(res.content || "").trim() ||
|
||||
"(模型没有输出有效的 report-json 数据,请点「重新绘制」或输入更明确的修改要求重试)",
|
||||
"数据解析失败",
|
||||
);
|
||||
setError("模型没有输出有效的 report-json 结构化数据");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
|
||||
htmlRef.current = rendered;
|
||||
setHtml(rendered);
|
||||
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
|
||||
openDashboardArtifact(threadId, messageId, toolCallId);
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
|
||||
setPhase("done");
|
||||
setError(null);
|
||||
// **只存结构化数据**(dashboardJson),不写 step3.html —— html 槽位归经典模式一,
|
||||
// 两者并行绘制互不覆盖;查看时按当前主题重新渲染,主题切换天然适配。
|
||||
onSavedRef.current?.({ dashboardJson: JSON.stringify(data) });
|
||||
},
|
||||
[
|
||||
selectedModel,
|
||||
themeMode,
|
||||
appendToDialogue,
|
||||
setDialogueConfidence,
|
||||
finalizeDialogue,
|
||||
upsertDashStub,
|
||||
openDashboardArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
setPhase("creating");
|
||||
setDialogues([]);
|
||||
stubRef.current = {};
|
||||
setSeatStubMessages({});
|
||||
liveThreadRef.current = null;
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
const bubbleId = appendStreamingDialogue();
|
||||
try {
|
||||
const intentText = [
|
||||
intentReady?.objective ?? "",
|
||||
...(intentReady?.constraints ?? []).map((c) => `约束:${c}`),
|
||||
...(intentReady?.assumptions ?? []).map((a) => `假设:${a}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
|
||||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
if (aborterRef.current === aborter) runningRef.current = false;
|
||||
}
|
||||
}, [intentReady, seatDeliveries, consensusText, appendStreamingDialogue, streamOnce, finalizeDialogue]);
|
||||
|
||||
const ask = useCallback(
|
||||
async (message: string) => {
|
||||
const text = message.trim();
|
||||
if (!text) return;
|
||||
const wasBusy = runningRef.current;
|
||||
abort();
|
||||
if (wasBusy) {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: "已打断" } : d)),
|
||||
);
|
||||
}
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
appendUserDialogue(text);
|
||||
const bubbleId = appendStreamingDialogue(liveThreadRef.current ?? undefined);
|
||||
setPhase("streaming");
|
||||
try {
|
||||
const prompt =
|
||||
`${text}\n\n(系统指令:请基于上一版数据按用户要求修订,在回复最末尾输出**修订后的完整全量** ` +
|
||||
"```report-json 代码块(不是增量),正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
|
||||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
if (aborterRef.current === aborter) runningRef.current = false;
|
||||
}
|
||||
},
|
||||
[abort, appendUserDialogue, appendStreamingDialogue, streamOnce, finalizeDialogue],
|
||||
);
|
||||
|
||||
const hydrateSaved = useCallback(
|
||||
(savedHtml: string) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
htmlRef.current = savedHtml;
|
||||
setHtml(savedHtml);
|
||||
const synthThreadId = "roundtable-dashboard-restored";
|
||||
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
|
||||
setDashThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
|
||||
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
const bubbleId = Date.now() + Math.random();
|
||||
setDialogues([
|
||||
{
|
||||
id: bubbleId,
|
||||
sender: "大屏绘制智能体",
|
||||
avatarType: "blue",
|
||||
content:
|
||||
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
|
||||
time: nowTime(),
|
||||
confidence: "历史记录",
|
||||
streaming: false,
|
||||
steps: [],
|
||||
agentThreadId: synthThreadId,
|
||||
},
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
|
||||
},
|
||||
[abort, upsertDashStub],
|
||||
);
|
||||
|
||||
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
|
||||
const hydrateFromJson = useCallback(
|
||||
(jsonStr: string) => {
|
||||
const data = parseReportJson(jsonStr);
|
||||
if (!data) return;
|
||||
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
|
||||
hydrateSaved(rendered);
|
||||
},
|
||||
[themeMode, hydrateSaved],
|
||||
);
|
||||
|
||||
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
|
||||
void savedReport;
|
||||
void isDashboardHtml;
|
||||
|
||||
const start = useCallback(() => {
|
||||
void run();
|
||||
}, [run]);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
abort();
|
||||
liveThreadRef.current = null; // 全新线程重画(避免旧上下文把修订当底稿)
|
||||
void run();
|
||||
}, [abort, run]);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
|
||||
return {
|
||||
phase,
|
||||
error,
|
||||
dashThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
html,
|
||||
start,
|
||||
redraw,
|
||||
ask: (m: string) => void ask(m),
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
hydrateSaved,
|
||||
hydrateFromJson,
|
||||
openDashboardPreview,
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export interface UseStep3ReportReturn {
|
||||
themeMismatch: boolean;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||||
setPreviewEnabled: (enabled: boolean) => void;
|
||||
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
@@ -271,7 +273,12 @@ export function useStep3Report(
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
open: artifactsOpen,
|
||||
} = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
artifactsOpenRef.current = artifactsOpen;
|
||||
}, [artifactsOpen]);
|
||||
|
||||
const [phase, setPhase] = useState<ReportPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -494,12 +501,38 @@ export function useStep3Report(
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 预览开关(2026-06-13,hook 提升页面层后总结/绘制并行共用全局沙箱):只有用户
|
||||
// 当前所在视图的流允许**自动**打开沙箱;显式用户点击不受限。
|
||||
const previewEnabledRef = useRef(false);
|
||||
const lastAutoThreadRef = useRef<string | null>(null);
|
||||
const setPreviewEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
previewEnabledRef.current = enabled;
|
||||
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
|
||||
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
|
||||
if (
|
||||
enabled &&
|
||||
lastAutoOpenedUrlRef.current &&
|
||||
lastAutoThreadRef.current &&
|
||||
artifactsOpenRef.current
|
||||
) {
|
||||
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
|
||||
}
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
/**
|
||||
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
|
||||
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
|
||||
*/
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (!previewEnabledRef.current) {
|
||||
lastAutoOpenedUrlRef.current = virtualUrl; // 只记录,回到视图时接管
|
||||
return;
|
||||
}
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -649,6 +682,7 @@ export function useStep3Report(
|
||||
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file =
|
||||
@@ -1001,7 +1035,13 @@ export function useStep3Report(
|
||||
],
|
||||
};
|
||||
stubRef.current = stub;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 HTML(防串台)。
|
||||
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
|
||||
REPORT_OUTPUT_PATH,
|
||||
messageId,
|
||||
toolCallId,
|
||||
);
|
||||
lastAutoThreadRef.current = synthThreadId;
|
||||
setSeatStubMessages(stub);
|
||||
setReportThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
@@ -1031,11 +1071,11 @@ export function useStep3Report(
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
const virtualUrl = buildWriteFileVirtualUrl(REPORT_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
|
||||
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
|
||||
// 「点击预览」步骤卡随时可开(stub 已种好)。
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
[abort, buildWriteFileVirtualUrl],
|
||||
);
|
||||
|
||||
const redraw = useCallback((revisionInstruction?: string) => {
|
||||
@@ -1061,6 +1101,7 @@ export function useStep3Report(
|
||||
interject,
|
||||
themeMismatch,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
|
||||
@@ -87,6 +87,8 @@ export interface UseStep3SummaryReturn {
|
||||
pause: () => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||||
setPreviewEnabled: (enabled: boolean) => void;
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
@@ -164,7 +166,8 @@ function buildSummaryPrompt(opts: {
|
||||
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
|
||||
"用户已明确要求结构化输出/流程图,因此本轮:" +
|
||||
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
|
||||
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
|
||||
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
|
||||
);
|
||||
} else {
|
||||
@@ -201,9 +204,35 @@ function buildSummaryPrompt(opts: {
|
||||
*/
|
||||
const FLOW_ASK_INSTRUCTION =
|
||||
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
|
||||
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
|
||||
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
|
||||
|
||||
/**
|
||||
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
|
||||
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
|
||||
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
|
||||
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
|
||||
*/
|
||||
const FLOW_CONTEXT_ASK_INSTRUCTION =
|
||||
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
|
||||
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
|
||||
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
|
||||
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
|
||||
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
|
||||
|
||||
/**
|
||||
* 普通报告语境下的意图引导指令(每轮续问都附上):
|
||||
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
|
||||
*/
|
||||
const SEMANTIC_ASK_INSTRUCTION =
|
||||
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
|
||||
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
|
||||
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
|
||||
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
|
||||
"只是提问就只用文字回答、不改文件;" +
|
||||
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
|
||||
|
||||
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
|
||||
function buildResumeAskPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
@@ -212,10 +241,10 @@ function buildResumeAskPrompt(opts: {
|
||||
coordinatorPrompt?: string | null;
|
||||
previousMd: string;
|
||||
userMessage: string;
|
||||
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
|
||||
flowIntent?: boolean;
|
||||
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
|
||||
askInstruction?: string;
|
||||
}): string {
|
||||
const { previousMd, userMessage, flowIntent, ...materials } = opts;
|
||||
const { previousMd, userMessage, askInstruction, ...materials } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
|
||||
@@ -234,9 +263,9 @@ function buildResumeAskPrompt(opts: {
|
||||
}
|
||||
parts.push("# 用户的新消息");
|
||||
parts.push(userMessage.trim());
|
||||
if (flowIntent) {
|
||||
if (askInstruction) {
|
||||
parts.push("");
|
||||
parts.push(FLOW_ASK_INSTRUCTION);
|
||||
parts.push(askInstruction);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -283,7 +312,12 @@ export function useStep3Summary(
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
open: artifactsOpen,
|
||||
} = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
artifactsOpenRef.current = artifactsOpen;
|
||||
}, [artifactsOpen]);
|
||||
|
||||
const [phase, setPhase] = useState<SummaryPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -303,6 +337,10 @@ export function useStep3Summary(
|
||||
const liveThreadRef = useRef<string | null>(null);
|
||||
// 当前报告 md 全文(续问写底 / 存草稿用)。
|
||||
const currentMdRef = useRef<string>("");
|
||||
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
|
||||
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
|
||||
// 不再要求用户说出「结构化输出/流程图」等关键词。
|
||||
const flowActiveRef = useRef(false);
|
||||
const lastAutoOpenedUrlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
|
||||
@@ -483,8 +521,36 @@ export function useStep3Summary(
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
|
||||
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
|
||||
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
|
||||
const previewEnabledRef = useRef(false);
|
||||
const lastAutoThreadRef = useRef<string | null>(null);
|
||||
const setPreviewEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
previewEnabledRef.current = enabled;
|
||||
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
|
||||
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
|
||||
if (
|
||||
enabled &&
|
||||
lastAutoOpenedUrlRef.current &&
|
||||
lastAutoThreadRef.current &&
|
||||
artifactsOpenRef.current
|
||||
) {
|
||||
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
|
||||
}
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (!previewEnabledRef.current) {
|
||||
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
return;
|
||||
}
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -618,6 +684,7 @@ export function useStep3Summary(
|
||||
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
|
||||
@@ -697,7 +764,7 @@ export function useStep3Summary(
|
||||
path: SUMMARY_OUTPUT_PATH,
|
||||
content: md,
|
||||
});
|
||||
if (opts?.open === false) return;
|
||||
if (opts?.open === false || !previewEnabledRef.current) return;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -720,6 +787,8 @@ export function useStep3Summary(
|
||||
flowRound?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
|
||||
// 本轮开始前的报告底稿——结束后对比判断「这一轮的主产物是报告还是流程图」。
|
||||
const mdBefore = currentMdRef.current;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
@@ -771,6 +840,14 @@ export function useStep3Summary(
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
// 跟踪本轮主产物:回复带 flow-json → 流程图轮;md 有实质更新 → 报告轮;
|
||||
// 都没有(纯问答轮)→ 维持上一轮状态。供下一次 ask() 选意图引导指令。
|
||||
if (hasFlowFence(res.content)) {
|
||||
flowActiveRef.current = true;
|
||||
} else if (md.trim() && md !== mdBefore) {
|
||||
flowActiveRef.current = false;
|
||||
}
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "总结完成");
|
||||
setPhase("done");
|
||||
|
||||
@@ -825,6 +902,7 @@ export function useStep3Summary(
|
||||
try {
|
||||
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
|
||||
const flowMode = detectFlowIntent(coordinatorPrompt);
|
||||
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
@@ -865,8 +943,19 @@ export function useStep3Summary(
|
||||
abort();
|
||||
if (wasBusy) freezeStreaming("已打断");
|
||||
|
||||
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
|
||||
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
|
||||
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
|
||||
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
|
||||
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
|
||||
const flowIntent = detectFlowIntent(text);
|
||||
const flowContext = !flowIntent && flowActiveRef.current;
|
||||
const askInstruction = flowIntent
|
||||
? FLOW_ASK_INSTRUCTION
|
||||
: flowContext
|
||||
? FLOW_CONTEXT_ASK_INSTRUCTION
|
||||
: SEMANTIC_ASK_INSTRUCTION;
|
||||
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
|
||||
const flowRound = flowIntent || flowContext;
|
||||
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
@@ -910,11 +999,11 @@ export function useStep3Summary(
|
||||
setPhase("streaming");
|
||||
|
||||
if (hadLiveThread) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
|
||||
// 真线程仍在 → 续问,发原始消息 + 按语境选好的意图引导指令,
|
||||
// 靠线程上下文 + SOUL 让智能体按语义自判改图 / 改报告 / 问答 / 反问澄清。
|
||||
const base =
|
||||
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
|
||||
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
|
||||
const prompt = `${base}\n\n${askInstruction}`;
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt,
|
||||
@@ -922,7 +1011,7 @@ export function useStep3Summary(
|
||||
seedThreadId: liveThreadRef.current,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
flowRound,
|
||||
});
|
||||
} else {
|
||||
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
|
||||
@@ -935,7 +1024,7 @@ export function useStep3Summary(
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage:
|
||||
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
|
||||
flowIntent,
|
||||
askInstruction,
|
||||
});
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
@@ -944,7 +1033,7 @@ export function useStep3Summary(
|
||||
seedThreadId: threadId,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
flowRound,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1021,7 +1110,20 @@ export function useStep3Summary(
|
||||
stubRef.current = stub;
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
|
||||
flowActiveRef.current = hasFlowFence(snap.closing);
|
||||
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 md(防串台)。
|
||||
if (hasMd) {
|
||||
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
|
||||
SUMMARY_OUTPUT_PATH,
|
||||
messageId,
|
||||
toolCallId,
|
||||
);
|
||||
lastAutoThreadRef.current = synthThreadId;
|
||||
} else {
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
lastAutoThreadRef.current = null;
|
||||
}
|
||||
setSeatStubMessages(stub);
|
||||
setSummaryThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
@@ -1052,13 +1154,11 @@ export function useStep3Summary(
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
if (hasMd) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
}
|
||||
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
|
||||
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
|
||||
// 「点击预览」步骤卡随时可开(stub 已种好)。
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
[abort, buildWriteFileVirtualUrl],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
@@ -1077,6 +1177,7 @@ export function useStep3Summary(
|
||||
ask,
|
||||
pause,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import test from "node:test";
|
||||
import {
|
||||
detectFlowIntent,
|
||||
extractFlowGraph,
|
||||
hasFlowFence,
|
||||
layoutFlowGraph,
|
||||
stripFlowFence,
|
||||
} from "./flow-graph";
|
||||
@@ -128,6 +129,16 @@ test("detectFlowIntent:普通措辞不误触发", () => {
|
||||
assert.equal(detectFlowIntent(undefined), false);
|
||||
});
|
||||
|
||||
test("hasFlowFence:判定回复主产物是否为流程图(流式半截也算)", () => {
|
||||
assert.ok(hasFlowFence(FULL)); // 完整闭合块
|
||||
assert.ok(hasFlowFence('说明文字\n```flow-json\n{"nodes":[')); // 流式半截、未闭合
|
||||
assert.equal(hasFlowFence("纯文字报告正文,没有流程图"), false);
|
||||
assert.equal(hasFlowFence("```json\n{}\n```"), false); // 普通 json 块不算
|
||||
assert.equal(hasFlowFence(""), false);
|
||||
assert.equal(hasFlowFence(null), false);
|
||||
assert.equal(hasFlowFence(undefined), false);
|
||||
});
|
||||
|
||||
test("layoutFlowGraph:环不死循环", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
|
||||
@@ -236,6 +236,11 @@ export function detectFlowIntent(text: string | null | undefined): boolean {
|
||||
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
/** 文本里是否带 flow-json 代码块(判定某轮回复的主产物是不是流程图)。 */
|
||||
export function hasFlowFence(text: string | null | undefined): boolean {
|
||||
return !!text && text.includes(FLOW_FENCE_OPEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
|
||||
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 后台作业 → Step2 实时画面映射(2026-06-13「查看=恢复流程」)。
|
||||
*
|
||||
* 用户点「查看」一个**运行中**的后台作业时:加载草稿后订阅 `streamJob` SSE,
|
||||
* 把每帧 `RoundtableJob.dialogues`(执行器落库的中性对话记录)映射成前端
|
||||
* `Step2Dialogue` + `seatStubMessages`,灌进 useStep2Orchestration ——
|
||||
* 第二步消息列表像本地编排一样**实时长出**新气泡/步骤卡,而不是静态聊天记录。
|
||||
*
|
||||
* 映射规则与后端 `roundtable_job_executor._to_step2_dialogue` / `_seat_stub_messages`
|
||||
* 逐字段对齐(id/steps/threadId/time/stub 约定一致),保证 SSE 实时帧与作业终态
|
||||
* 写进草稿的 run 形状无缝衔接(终态重载草稿不会跳变)。
|
||||
*/
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { JobDialogue, RoundtableJob } from "@/roundtable-planning/lib/job-types";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 工具步骤卡回退文案(对齐执行器 _STEP_LABEL;前端 getStepDisplay 主要按 key 推导)。 */
|
||||
const STEP_LABEL: Record<string, string> = {
|
||||
agent_orchestration: "派活给子智能体",
|
||||
write_file: "写入文件",
|
||||
str_replace: "写入文件",
|
||||
read_file: "读取文件",
|
||||
ls: "列出文件夹",
|
||||
bash: "执行命令",
|
||||
web_search: "搜索相关信息",
|
||||
ask_clarification: "需要你的协助",
|
||||
present_files: "展示文件",
|
||||
};
|
||||
|
||||
function stepsFromToolCalls(
|
||||
toolCalls: NonNullable<JobDialogue["toolCalls"]>,
|
||||
dialogueId: number,
|
||||
ts: string,
|
||||
): NonNullable<Step2Dialogue["steps"]> {
|
||||
const steps: NonNullable<Step2Dialogue["steps"]> = [];
|
||||
toolCalls.forEach((tc, i) => {
|
||||
const name = tc?.name ?? "";
|
||||
if (!name) return;
|
||||
steps.push({
|
||||
id: `${dialogueId}-${i}`,
|
||||
key: `tool_calling:${name}`,
|
||||
label: STEP_LABEL[name] ?? `使用 “${name}” 工具`,
|
||||
ts,
|
||||
args: (tc.args ?? {}) as Record<string, unknown>,
|
||||
});
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2Dialogue {
|
||||
const time = d.time ?? "";
|
||||
const base: Step2Dialogue = {
|
||||
id: d.id,
|
||||
sender: "",
|
||||
avatarType: "blue",
|
||||
content: d.content ?? "",
|
||||
time,
|
||||
confidence: "",
|
||||
streaming: false,
|
||||
steps: stepsFromToolCalls(d.toolCalls ?? [], d.id, time),
|
||||
};
|
||||
if (d.threadId) base.agentThreadId = d.threadId;
|
||||
if (d.role === "leader") {
|
||||
return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" };
|
||||
}
|
||||
const aid = d.agentId ?? "";
|
||||
return {
|
||||
...base,
|
||||
sender: d.name || aid,
|
||||
avatarType: getAvatarTypeFor(aid, idxByAgent.get(aid.toLowerCase()) ?? 0),
|
||||
confidence: "子智能体交付",
|
||||
};
|
||||
}
|
||||
|
||||
/** 从各对话的 write_file 重建 seatStubMessages(同 thread 跨轮按 path 去重、后写覆盖)。 */
|
||||
function seatStubsFromDialogues(dialogues: JobDialogue[]): Record<string, StubAiMessage[]> {
|
||||
const byTid = new Map<string, Map<string, NonNullable<JobDialogue["toolCalls"]>[number]>>();
|
||||
for (const d of dialogues) {
|
||||
const tid = d.threadId;
|
||||
if (!tid) continue;
|
||||
for (const tc of d.toolCalls ?? []) {
|
||||
if (tc?.name !== "write_file") continue;
|
||||
const path = String((tc.args as { path?: unknown } | undefined)?.path ?? "");
|
||||
let m = byTid.get(tid);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
byTid.set(tid, m);
|
||||
}
|
||||
m.set(path, tc); // 后写覆盖
|
||||
}
|
||||
}
|
||||
const out: Record<string, StubAiMessage[]> = {};
|
||||
for (const [tid, byPath] of byTid) {
|
||||
const toolCalls = Array.from(byPath.values()).map((tc, i) => ({
|
||||
id: `tc-${tid}-${i}`,
|
||||
name: "write_file",
|
||||
args: (tc.args ?? {}) as Record<string, unknown>,
|
||||
}));
|
||||
out[tid] = [{ id: `roundtable-stub-${tid}`, type: "ai", content: "", tool_calls: toolCalls }];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LiveJobFrame {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
consensusPercentage: number;
|
||||
hasConsensus: boolean;
|
||||
lastLeaderContent: string;
|
||||
}
|
||||
|
||||
/** 把一帧作业快照映射成 Step2 实时画面数据。 */
|
||||
export function mapJobToLiveStep2(job: RoundtableJob): LiveJobFrame {
|
||||
const idxByAgent = new Map<string, number>();
|
||||
job.dispatchChain.forEach((n, i) => idxByAgent.set(n.agentId.toLowerCase(), i));
|
||||
const dialogues = job.dialogues.map((d) => toStep2Dialogue(d, idxByAgent));
|
||||
const lastLeader =
|
||||
[...job.dialogues].reverse().find((d) => d.role === "leader")?.content ?? "";
|
||||
return {
|
||||
dialogues,
|
||||
seatStubMessages: seatStubsFromDialogues(job.dialogues),
|
||||
consensusPercentage: job.consensusPercentage,
|
||||
hasConsensus: job.status === "done",
|
||||
lastLeaderContent: lastLeader,
|
||||
};
|
||||
}
|
||||
@@ -25,8 +25,9 @@ export type JobPhase =
|
||||
| "leader_thinking" // 总控在决策派活
|
||||
| "dispatching" // 正在顺序派活给席位(配合 activeAgentId)
|
||||
| "consensus" // 共识达成(dispatched=[])
|
||||
| "report_drawing" // Step3 报告绘制中
|
||||
| "report_done"; // 绘制完成
|
||||
| "summary_writing" // Step3 方案总结报告撰写中(roundtable-summary 写 md)
|
||||
| "report_drawing" // Step3 结果绘制中(roundtable-report 画 HTML)
|
||||
| "report_done"; // Step3 完成
|
||||
|
||||
/** 派活链路单节点状态(驱动颜色 / 动画)。 */
|
||||
export type ChainNodeState = "pending" | "active" | "done";
|
||||
@@ -55,6 +56,12 @@ export interface JobDialogue {
|
||||
name: string;
|
||||
content: string;
|
||||
cycle: number;
|
||||
/** 本轮工具调用({name, args, id})—— 映射成前端步骤卡 / write_file stub。 */
|
||||
toolCalls?: Array<{ name: string; args?: Record<string, unknown>; id?: string }>;
|
||||
/** 该气泡对应的 leader/seat 线程 id(点开产物用)。 */
|
||||
threadId?: string | null;
|
||||
/** 气泡时间戳(HH:MM:SS,后端创建时盖章)。 */
|
||||
time?: string;
|
||||
}
|
||||
|
||||
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
|
||||
@@ -75,8 +82,23 @@ export interface RoundtableJob {
|
||||
error: string | null;
|
||||
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
|
||||
dialogues: JobDialogue[];
|
||||
/** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */
|
||||
step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null;
|
||||
/** Step3 快照(done 时有):结果绘制 { html, … } + 嵌套的方案总结报告 summaryReport。 */
|
||||
step3: {
|
||||
html?: string;
|
||||
generatedAt?: string;
|
||||
model?: string;
|
||||
summary?: string;
|
||||
/** 方案总结报告(roundtable-summary 的 md)——与草稿 DraftStep3SummarySnapshot 同形状。 */
|
||||
summaryReport?: {
|
||||
md: string;
|
||||
threadId?: string;
|
||||
generatedAt?: string;
|
||||
model?: string;
|
||||
closing?: string;
|
||||
} | null;
|
||||
/** 大屏多页(模式二)的 report-json 数据字符串(前端按当前主题渲染)。 */
|
||||
dashboardJson?: string | null;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* report-template.ts 纯函数单测(大屏多页模式的契约层)。
|
||||
* 运行方式(项目无 vitest,对齐 flow-graph.test.ts):
|
||||
* npx esbuild src/roundtable-planning/lib/report-template.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/report-template.test.mjs
|
||||
* node --test .tmp-tests/report-template.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildDashboardPrompt,
|
||||
extractReportData,
|
||||
isDashboardHtml,
|
||||
renderDashboardHtml,
|
||||
RT_DASHBOARD_MARKER,
|
||||
stripReportFence,
|
||||
} from "./report-template";
|
||||
|
||||
const SAMPLE = [
|
||||
"下面是大屏数据。",
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: "智能机房选址方案大屏",
|
||||
subtitle: "三地候选 · 综合评审",
|
||||
kpis: [
|
||||
{ label: "总投资", value: "1.2", unit: "亿元", trend: "up" },
|
||||
{ label: "工期", value: "14", unit: "个月" },
|
||||
],
|
||||
consensus: { headline: "推荐 B 地块方案", points: ["供电冗余最高", "扩容空间充足"] },
|
||||
charts: [
|
||||
{ type: "bar", title: "候选地块评分", items: [{ label: "A", value: 72 }, { label: "B", value: 89 }] },
|
||||
{ type: "donut", title: "投资构成", items: [{ label: "土建", value: 40 }, { label: "设备", value: 60 }] },
|
||||
],
|
||||
timeline: [{ label: "阶段一", desc: "勘察与立项" }],
|
||||
seats: [
|
||||
{ id: "seat-1", name: "情报收集", summary: "完成三地基础数据采集", sections: [{ heading: "数据来源", bullets: ["政务公开", "实地走访"] }], metrics: [{ label: "覆盖率", value: 92 }], risks: [], dependsOn: [] },
|
||||
{ id: "seat-2", name: "方案设计", summary: "B 地块双回路供电方案", sections: [], metrics: [], risks: [{ label: "供电瓶颈", level: "high" }], dependsOn: ["seat-1"] },
|
||||
],
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("extractReportData:完整块解析 + 字段归一化", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
assert.equal(d.title, "智能机房选址方案大屏");
|
||||
assert.equal(d.kpis.length, 2);
|
||||
assert.equal(d.consensus.headline, "推荐 B 地块方案");
|
||||
assert.equal(d.charts.length, 2);
|
||||
assert.equal(d.seats.length, 2);
|
||||
assert.deepEqual(d.seats[1].dependsOn, ["seat-1"]);
|
||||
});
|
||||
|
||||
test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除模型编造/自引用)", () => {
|
||||
const text = [
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: "t",
|
||||
seats: [
|
||||
{ id: "s1", name: "甲", dependsOn: ["s1", "s2", "ghost"] },
|
||||
{ id: "s2", name: "乙", dependsOn: [] },
|
||||
],
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
const d = extractReportData(text)!;
|
||||
assert.deepEqual(d.seats[0].dependsOn, ["s2"]);
|
||||
});
|
||||
|
||||
test("extractReportData:无 fence / 坏 JSON / 空数据返回 null", () => {
|
||||
assert.equal(extractReportData("没有数据块"), null);
|
||||
assert.equal(extractReportData("```report-json\n{broken\n```"), null);
|
||||
assert.equal(extractReportData("```report-json\n{}\n```"), null);
|
||||
assert.equal(extractReportData(null), null);
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:完整文档 + marker + 总览/席位多页 + :target 导航", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
const html = renderDashboardHtml(d, { generatedAt: "2026-06-13 10:00" });
|
||||
assert.ok(html.startsWith("<!doctype html>"));
|
||||
assert.ok(html.includes(RT_DASHBOARD_MARKER));
|
||||
assert.ok(html.trimEnd().endsWith("</html>"));
|
||||
assert.ok(isDashboardHtml(html));
|
||||
// 多页结构:总览 + 每席位一页
|
||||
assert.ok(html.includes('id="overview"'));
|
||||
assert.ok(html.includes('id="seat-1"'));
|
||||
assert.ok(html.includes('id="seat-2"'));
|
||||
// 纯 CSS :target 导航(零 JS:无 allow-scripts 沙箱也能切页)
|
||||
assert.ok(html.includes(".page:target"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
// 席位卡片跳转 + 依赖互相跳转(方案设计 依赖→ 情报收集)
|
||||
assert.ok(html.includes('href="#seat-2"'));
|
||||
assert.ok(html.includes('class="dep" href="#seat-1"'));
|
||||
// 反向「被依赖」chip(情报收集页里出现 方案设计 ← 承接)
|
||||
assert.ok(html.includes("承接本席位"));
|
||||
// 共识详情纯 CSS 弹窗
|
||||
assert.ok(html.includes('id="consensus-modal"'));
|
||||
// 图表 SVG 由模板生成
|
||||
assert.ok(html.includes("<svg"));
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:主题跟随 —— dark=深蓝规范 / light=浅红规范", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
const dark = renderDashboardHtml(d, { theme: "dark" });
|
||||
// 深蓝规范:页面 #0C1836 / 卡片 #122347 / 主色 #1677FF(tokens.css §1)
|
||||
assert.ok(dark.includes("#0C1836"));
|
||||
assert.ok(dark.includes("#122347"));
|
||||
assert.ok(dark.includes("#1677FF"));
|
||||
const light = renderDashboardHtml(d, { theme: "light" });
|
||||
// 浅红规范:页面 #FAFAFA / 容器 #FFFFFF / 主色 #F5222D(§1 色彩)
|
||||
assert.ok(light.includes("#FAFAFA"));
|
||||
assert.ok(light.includes("#F5222D"));
|
||||
assert.ok(!light.includes("#0C1836"));
|
||||
// 缺省 = dark(向后兼容)
|
||||
const def = renderDashboardHtml(d);
|
||||
assert.ok(def.includes("#0C1836"));
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:模型文本全部 HTML 转义(防注入/破版)", () => {
|
||||
const text = [
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: '<img src=x onerror=alert(1)>',
|
||||
seats: [{ id: "s1", name: "甲<b>", summary: 'a"b<c>', dependsOn: [] }],
|
||||
consensus: { headline: "<script>bad()</script>", points: [] },
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
const d = extractReportData(text)!;
|
||||
const html = renderDashboardHtml(d);
|
||||
assert.ok(!html.includes("<img src=x"));
|
||||
assert.ok(!html.includes("<script>bad"));
|
||||
assert.ok(html.includes("<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,722 @@
|
||||
/**
|
||||
* Step3「结果绘制 · 大屏多页模式(模式二)」—— report-json 契约 + 模板渲染器。
|
||||
*
|
||||
* 设计动机(2026-06-13):模式一让模型从零写整份 50KB+ 自包含 HTML,慢(1-3 分钟)
|
||||
* 且有超长截断风险。模式二把 HTML 骨架/样式/图表**全部预置在本模板里**,模型只输出
|
||||
* 1-2KB 的结构化 ```report-json 数据(与 Step3 总结报告的 flow-json 同一套路),
|
||||
* 前端代码确定性地渲染成完整大屏 HTML —— 快一个数量级、零截断、风格稳定。
|
||||
*
|
||||
* ## report-json 契约(模型输出)
|
||||
* ```report-json
|
||||
* {"title":"…","subtitle":"…",
|
||||
* "kpis":[{"label":"总投资","value":"1.2","unit":"亿元","trend":"up"}],
|
||||
* "consensus":{"headline":"一句话最终结论","points":["…"]},
|
||||
* "charts":[{"type":"bar|donut|radar","title":"…","items":[{"label":"…","value":30}]}],
|
||||
* "timeline":[{"label":"阶段一","desc":"…"}],
|
||||
* "seats":[{"id":"seat-1","name":"方案设计","summary":"一两句结论",
|
||||
* "sections":[{"heading":"…","bullets":["…"]}],
|
||||
* "metrics":[{"label":"可行性","value":86}],
|
||||
* "risks":[{"label":"…","level":"high|mid|low"}],
|
||||
* "dependsOn":["seat-0"]}]}
|
||||
* ```
|
||||
*
|
||||
* ## 生成的页面结构(单文件 · 零 JS · 离线自包含)
|
||||
* - 大屏展示风:深海军蓝渐变底 + 网格底纹 + 青色霓虹描边玻璃卡片 + 四角装饰括号;
|
||||
* - **总览页**(KPI 带 + 共识结论 + 总览图表 + 席位卡片矩阵 + 执行时间线)
|
||||
* + **每席位一页**(该席位交付的结构化展示 + 指标条 + 风险徽标 + 依赖面板);
|
||||
* - 导航纯 CSS `:target`:页签/席位卡片/依赖 chips 都是 `#seat-x` 锚点跳转,
|
||||
* `.page:target` 显示目标页、`body:has(.page:target)` 隐藏默认总览 ——
|
||||
* **无 allow-scripts 的沙箱 iframe / 公开分享页都能正常切页**;
|
||||
* - 「弹窗」同样用 :target 纯 CSS overlay(共识详情);
|
||||
* - 席位间依赖(dependsOn)渲染成双向 chips(依赖 → / ← 支撑),点击互相跳转。
|
||||
*
|
||||
* 文件头嵌入 `RT_DASHBOARD_MARKER` 注释,恢复草稿时据此识别「这是模板大屏」。
|
||||
*/
|
||||
|
||||
// ── 契约类型 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
trend?: "up" | "down" | "flat";
|
||||
}
|
||||
|
||||
export interface ReportChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportChart {
|
||||
type: "bar" | "donut" | "radar";
|
||||
title: string;
|
||||
items: ReportChartItem[];
|
||||
}
|
||||
|
||||
export interface ReportSeatSection {
|
||||
heading: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface ReportSeatMetric {
|
||||
label: string;
|
||||
/** 0-100。 */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportSeatRisk {
|
||||
label: string;
|
||||
level: "high" | "mid" | "low";
|
||||
}
|
||||
|
||||
export interface ReportSeat {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
sections: ReportSeatSection[];
|
||||
metrics: ReportSeatMetric[];
|
||||
risks: ReportSeatRisk[];
|
||||
/** 依赖的其它席位 id(产物承接关系),渲染成互相跳转 chips。 */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kpis: ReportKpi[];
|
||||
consensus: { headline: string; points: string[] };
|
||||
charts: ReportChart[];
|
||||
timeline: Array<{ label: string; desc: string }>;
|
||||
seats: ReportSeat[];
|
||||
}
|
||||
|
||||
/** 模板大屏 HTML 的识别标记(恢复草稿时区分模式一/二)。 */
|
||||
export const RT_DASHBOARD_MARKER = "<!-- rt-dashboard-v1 -->";
|
||||
|
||||
/** 大屏 HTML 在沙箱预览里的虚拟产物路径(write_file stub 用,前端渲染、非真实文件)。 */
|
||||
export const DASHBOARD_OUTPUT_PATH = "/mnt/user-data/outputs/方案研讨大屏.html";
|
||||
|
||||
export function isDashboardHtml(html: string | null | undefined): boolean {
|
||||
return !!html && html.includes(RT_DASHBOARD_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把回复里的 report-json 块替换成一句人话(气泡里不展示生 JSON,对齐 flow-json 的
|
||||
* stripFlowFence)。块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导看右侧沙箱。
|
||||
*/
|
||||
export function stripReportFence(text: string, streaming: boolean): string {
|
||||
const start = text.lastIndexOf(FENCE);
|
||||
if (start < 0) return text;
|
||||
const after = text.slice(start + FENCE.length);
|
||||
const close = after.indexOf("```");
|
||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
||||
const placeholder =
|
||||
close < 0 && streaming
|
||||
? "\n\n> 📊 正在输出大屏结构化数据,右侧沙箱稍后自动渲染…"
|
||||
: "\n\n> 📊 已生成大屏数据,右侧沙箱已渲染预览(可继续输入修改要求调整)。";
|
||||
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
|
||||
}
|
||||
|
||||
// ── 解析(容错:取最后一个 report-json 代码块;缺字段给安全默认) ─────────────────
|
||||
|
||||
const FENCE = "```report-json";
|
||||
|
||||
function str(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
|
||||
/** 从模型回复里提取并归一化 report-json(找不到 / 解析失败 → null)。 */
|
||||
export function extractReportData(text: string | null | undefined): ReportData | null {
|
||||
if (!text) return null;
|
||||
const start = text.lastIndexOf(FENCE);
|
||||
if (start < 0) return null;
|
||||
const after = text.slice(start + FENCE.length);
|
||||
const close = after.indexOf("```");
|
||||
const body = (close >= 0 ? after.slice(0, close) : after).trim();
|
||||
if (!body) return null;
|
||||
return parseReportJson(body);
|
||||
}
|
||||
|
||||
/** 解析裸 JSON 字符串(草稿/后台作业存的 step3.dashboardJson)并归一化。 */
|
||||
export function parseReportJson(jsonStr: string | null | undefined): ReportData | null {
|
||||
if (!jsonStr?.trim()) return null;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return coerceReportData(raw);
|
||||
}
|
||||
|
||||
/** 把已 JSON.parse 的对象归一化成 ReportData(缺字段给安全默认;非法 → null)。 */
|
||||
export function coerceReportData(raw: unknown): ReportData | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
const seats: ReportSeat[] = arr(o.seats)
|
||||
.map((s, i) => {
|
||||
const so = (s ?? {}) as Record<string, unknown>;
|
||||
const name = str(so.name).trim();
|
||||
if (!name) return null;
|
||||
const id = str(so.id).trim() || `seat-${i + 1}`;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
summary: str(so.summary),
|
||||
sections: arr(so.sections)
|
||||
.map((sec) => {
|
||||
const co = (sec ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
heading: str(co.heading),
|
||||
bullets: arr(co.bullets).map((b) => str(b)).filter(Boolean),
|
||||
};
|
||||
})
|
||||
.filter((sec) => sec.heading || sec.bullets.length > 0),
|
||||
metrics: arr(so.metrics)
|
||||
.map((m) => {
|
||||
const mo = (m ?? {}) as Record<string, unknown>;
|
||||
return { label: str(mo.label), value: Math.min(100, Math.max(0, num(mo.value))) };
|
||||
})
|
||||
.filter((m) => m.label),
|
||||
risks: arr(so.risks)
|
||||
.map((r) => {
|
||||
const ro = (r ?? {}) as Record<string, unknown>;
|
||||
const level = str(ro.level);
|
||||
return {
|
||||
label: str(ro.label),
|
||||
level: (level === "high" || level === "mid" || level === "low" ? level : "mid") as
|
||||
| "high"
|
||||
| "mid"
|
||||
| "low",
|
||||
};
|
||||
})
|
||||
.filter((r) => r.label),
|
||||
dependsOn: arr(so.dependsOn).map((d) => str(d).trim()).filter(Boolean),
|
||||
} as ReportSeat;
|
||||
})
|
||||
.filter((s): s is ReportSeat => s !== null);
|
||||
|
||||
// dependsOn 只保留真实存在的席位 id(模型可能编 id)。
|
||||
const idSet = new Set(seats.map((s) => s.id));
|
||||
for (const s of seats) s.dependsOn = s.dependsOn.filter((d) => idSet.has(d) && d !== s.id);
|
||||
|
||||
const consensusRaw = (o.consensus ?? {}) as Record<string, unknown>;
|
||||
const data: ReportData = {
|
||||
title: str(o.title, "方案研讨大屏"),
|
||||
subtitle: str(o.subtitle),
|
||||
kpis: arr(o.kpis)
|
||||
.map((k) => {
|
||||
const ko = (k ?? {}) as Record<string, unknown>;
|
||||
const trend = str(ko.trend);
|
||||
return {
|
||||
label: str(ko.label),
|
||||
value: str(ko.value, String(ko.value ?? "")),
|
||||
unit: str(ko.unit) || undefined,
|
||||
trend: (trend === "up" || trend === "down" || trend === "flat" ? trend : undefined) as
|
||||
| "up"
|
||||
| "down"
|
||||
| "flat"
|
||||
| undefined,
|
||||
};
|
||||
})
|
||||
.filter((k) => k.label && k.value)
|
||||
.slice(0, 6),
|
||||
consensus: {
|
||||
headline: str(consensusRaw.headline),
|
||||
points: arr(consensusRaw.points).map((p) => str(p)).filter(Boolean),
|
||||
},
|
||||
charts: arr(o.charts)
|
||||
.map((c) => {
|
||||
const co = (c ?? {}) as Record<string, unknown>;
|
||||
const type = str(co.type);
|
||||
return {
|
||||
type: (type === "bar" || type === "donut" || type === "radar" ? type : "bar") as
|
||||
| "bar"
|
||||
| "donut"
|
||||
| "radar",
|
||||
title: str(co.title),
|
||||
items: arr(co.items)
|
||||
.map((it) => {
|
||||
const io = (it ?? {}) as Record<string, unknown>;
|
||||
return { label: str(io.label), value: num(io.value) };
|
||||
})
|
||||
.filter((it) => it.label),
|
||||
};
|
||||
})
|
||||
.filter((c) => c.items.length > 0)
|
||||
.slice(0, 3),
|
||||
timeline: arr(o.timeline)
|
||||
.map((t) => {
|
||||
const to = (t ?? {}) as Record<string, unknown>;
|
||||
return { label: str(to.label), desc: str(to.desc) };
|
||||
})
|
||||
.filter((t) => t.label),
|
||||
seats,
|
||||
};
|
||||
if (data.seats.length === 0 && !data.consensus.headline) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── 模型 prompt(只要 report-json,不调工具、不写文件) ───────────────────────────
|
||||
|
||||
export function buildDashboardPrompt(opts: {
|
||||
intentText: string;
|
||||
seatDeliveries: Array<{ sender: string; content: string }>;
|
||||
consensusText: string;
|
||||
}): string {
|
||||
const parts: string[] = [
|
||||
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
|
||||
"请通读以下圆桌研讨成果,把它们提炼成一份大屏展示数据,在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
|
||||
"",
|
||||
"# 一、任务意图",
|
||||
(opts.intentText || "").trim() || "(未提供)",
|
||||
"",
|
||||
"# 二、各席位最终交付",
|
||||
];
|
||||
opts.seatDeliveries.forEach((d, i) => {
|
||||
parts.push(`## ${i + 1}. ${d.sender}`);
|
||||
parts.push((d.content || "").trim() || "(无内容)");
|
||||
parts.push("");
|
||||
});
|
||||
parts.push("# 三、总控最终共识结论");
|
||||
parts.push((opts.consensusText || "").trim() || "(未提供)");
|
||||
parts.push("");
|
||||
parts.push(
|
||||
[
|
||||
"```report-json 契约(严格 JSON,全程简体中文):",
|
||||
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
|
||||
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
|
||||
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
|
||||
' "charts":[{"type":"bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表(风险分布/资源占比/方案评分等),3-6 项/图',
|
||||
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
|
||||
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
|
||||
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
|
||||
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
|
||||
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
|
||||
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
|
||||
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
|
||||
].join("\n"),
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// ── 主题(2026-06-13):跟随应用当前主题绘制 ─────────────────────────────────────
|
||||
// light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、
|
||||
// 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE;
|
||||
// dark = 《深蓝色 UI 规范》(docs/ui规范-深蓝/tokens.css):页面#0C1836、卡片#122347、
|
||||
// 主色#1677FF、辅色#13C2C2、文字#C5CFE0、语义 成功#52C41A/警告#FAAD14/错误#F5222D。
|
||||
|
||||
export type DashboardTheme = "light" | "dark";
|
||||
|
||||
interface Palette {
|
||||
bgPage: string;
|
||||
bgGradient: string;
|
||||
gridLine: string;
|
||||
card: string;
|
||||
cardBorder: string;
|
||||
cardGlow: string;
|
||||
bracket: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textStrong: string;
|
||||
heading: string;
|
||||
primary: string;
|
||||
link: string;
|
||||
titleColor: string;
|
||||
navBg: string;
|
||||
trackBg: string;
|
||||
/** 图表 / KPI / 席位强调色序(规范图表色序)。 */
|
||||
accents: string[];
|
||||
risk: { high: string; mid: string; low: string };
|
||||
modalMask: string;
|
||||
}
|
||||
|
||||
const PALETTES: Record<DashboardTheme, Palette> = {
|
||||
dark: {
|
||||
bgPage: "#0C1836",
|
||||
bgGradient:
|
||||
"radial-gradient(1200px 600px at 70% -10%,rgba(22,119,255,.14),transparent),linear-gradient(160deg,#060E24,#0C1836)",
|
||||
gridLine: "rgba(64,150,255,.06)",
|
||||
card: "linear-gradient(160deg,#1A2F5A,#122347)",
|
||||
cardBorder: "rgba(64,150,255,.28)",
|
||||
cardGlow: "0 0 24px rgba(6,14,36,.5) inset,0 4px 18px rgba(6,14,36,.35)",
|
||||
bracket: "#4096FF",
|
||||
text: "#C5CFE0",
|
||||
textSecondary: "#6E88B0",
|
||||
textStrong: "#FFFFFF",
|
||||
heading: "#91CAFF",
|
||||
primary: "#1677FF",
|
||||
link: "#4096FF",
|
||||
titleColor: "linear-gradient(90deg,#91CAFF,#4096FF 45%,#13C2C2)",
|
||||
navBg: "rgba(6,14,36,.88)",
|
||||
trackBg: "rgba(110,136,176,.22)",
|
||||
accents: ["#4096FF", "#13C2C2", "#52C41A", "#FAAD14", "#FF7875", "#69B1FF"],
|
||||
risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" },
|
||||
modalMask: "rgba(6,14,36,.78)",
|
||||
},
|
||||
light: {
|
||||
bgPage: "#FAFAFA",
|
||||
bgGradient:
|
||||
"radial-gradient(1200px 600px at 70% -10%,rgba(245,34,45,.05),transparent),linear-gradient(160deg,#FFFFFF,#FAFAFA)",
|
||||
gridLine: "rgba(245,34,45,.035)",
|
||||
card: "#FFFFFF",
|
||||
cardBorder: "#E8E8E8",
|
||||
cardGlow: "0 2px 8px rgba(0,0,0,.04)",
|
||||
bracket: "#F5222D",
|
||||
text: "#222222",
|
||||
textSecondary: "#575757",
|
||||
textStrong: "#222222",
|
||||
heading: "#A8071A",
|
||||
primary: "#F5222D",
|
||||
link: "#0079FE",
|
||||
titleColor: "linear-gradient(90deg,#F5222D,#CF1322 60%,#A8071A)",
|
||||
navBg: "rgba(255,255,255,.92)",
|
||||
trackBg: "#F0F0F0",
|
||||
accents: ["#F5222D", "#0079FE", "#189D72", "#FF9900", "#45C3F3", "#7F7F7F"],
|
||||
risk: { high: "#F5222D", mid: "#FF9900", low: "#189D72" },
|
||||
modalMask: "rgba(34,34,34,.45)",
|
||||
},
|
||||
};
|
||||
|
||||
// ── 渲染(确定性模板,全部文本 HTML 转义) ────────────────────────────────────────
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.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 = 56;
|
||||
const C = 2 * Math.PI * R;
|
||||
let offset = 0;
|
||||
const segs = items
|
||||
.map((it, i) => {
|
||||
const frac = Math.max(0, it.value) / total;
|
||||
const seg = `<circle cx="80" cy="80" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="20" stroke-dasharray="${(frac * C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-offset * C).toFixed(2)}" transform="rotate(-90 80 80)" opacity="0.9"/>`;
|
||||
offset += frac;
|
||||
return seg;
|
||||
})
|
||||
.join("");
|
||||
const legend = items
|
||||
.map(
|
||||
(it, i) =>
|
||||
`<div class="lg"><i style="background:${P.accents[i % P.accents.length]}"></i>${esc(it.label)} <b>${esc(String(it.value))}</b></div>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="donut"><svg viewBox="0 0 160 160" role="img">${segs}</svg><div class="lgs">${legend}</div></div>`;
|
||||
}
|
||||
|
||||
function svgRadar(chart: ReportChart): string {
|
||||
const items = chart.items.slice(0, 6);
|
||||
const n = Math.max(items.length, 3);
|
||||
const cx = 110;
|
||||
const cy = 95;
|
||||
const R = 70;
|
||||
const max = Math.max(...items.map((i) => i.value), 1);
|
||||
const pt = (i: number, r: number) => {
|
||||
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
|
||||
};
|
||||
const grid = [0.33, 0.66, 1]
|
||||
.map(
|
||||
(k) =>
|
||||
`<polygon points="${Array.from({ length: n }, (_, i) => pt(i, R * k)).join(" ")}" class="gr"/>`,
|
||||
)
|
||||
.join("");
|
||||
const axes = Array.from(
|
||||
{ length: n },
|
||||
(_, i) => `<line x1="${cx}" y1="${cy}" x2="${pt(i, R).replace(",", '" y2="')}" class="gr"/>`,
|
||||
).join("");
|
||||
const poly = `<polygon points="${items.map((it, i) => pt(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}33" stroke="${P.primary}" stroke-width="2"/>`;
|
||||
const labels = items
|
||||
.map((it, i) => {
|
||||
const [x, y] = pt(i, R + 16).split(",");
|
||||
return `<text x="${x}" y="${y}" class="cl" text-anchor="middle">${esc(it.label)}</text>`;
|
||||
})
|
||||
.join("");
|
||||
return `<svg viewBox="0 0 220 200" role="img">${grid}${axes}${poly}${labels}</svg>`;
|
||||
}
|
||||
|
||||
function chartHtml(chart: ReportChart): string {
|
||||
const body =
|
||||
chart.type === "donut" ? svgDonut(chart) : chart.type === "radar" ? svgRadar(chart) : svgBar(chart);
|
||||
return `<div class="card chart"><h3>${esc(chart.title || "图表")}</h3>${body}</div>`;
|
||||
}
|
||||
|
||||
const RISK_LABEL = { high: "高", mid: "中", low: "低" } as const;
|
||||
|
||||
function riskChips(risks: ReportSeatRisk[]): string {
|
||||
if (risks.length === 0) return "";
|
||||
return `<div class="chips">${risks
|
||||
.map((r) => {
|
||||
const color = P.risk[r.level]; // 主题语义色:错误/警告/成功
|
||||
return `<span class="chip" style="border-color:${color};color:${color};background:${color}14">${RISK_LABEL[r.level]}风险 · ${esc(r.label)}</span>`;
|
||||
})
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function metricBars(metrics: ReportSeatMetric[]): string {
|
||||
if (metrics.length === 0) return "";
|
||||
return `<div class="metrics">${metrics
|
||||
.map(
|
||||
(m, i) =>
|
||||
`<div class="metric"><span>${esc(m.label)}</span><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div><b>${m.value}</b></div>`,
|
||||
)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
/** 依赖双向 chips:deps = 本席位依赖谁;supports = 谁依赖本席位(反向推导)。 */
|
||||
function depChips(seat: ReportSeat, all: ReportSeat[]): string {
|
||||
const byId = new Map(all.map((s) => [s.id, s]));
|
||||
const supports = all.filter((s) => s.dependsOn.includes(seat.id));
|
||||
const dep = seat.dependsOn
|
||||
.map((d) => byId.get(d))
|
||||
.filter((s): s is ReportSeat => !!s)
|
||||
.map((s) => `<a class="dep" href="#${esc(s.id)}">依赖 → ${esc(s.name)}</a>`)
|
||||
.join("");
|
||||
const sup = supports
|
||||
.map((s) => `<a class="dep sup" href="#${esc(s.id)}">${esc(s.name)} ← 承接本席位</a>`)
|
||||
.join("");
|
||||
if (!dep && !sup) return "";
|
||||
return `<div class="deps">${dep}${sup}</div>`;
|
||||
}
|
||||
|
||||
/** 渲染大屏多页 HTML(自包含 · 零 JS · :target 导航 · 跟随应用主题)。 */
|
||||
export function renderDashboardHtml(
|
||||
data: ReportData,
|
||||
opts?: { generatedAt?: string; theme?: DashboardTheme },
|
||||
): string {
|
||||
P = PALETTES[opts?.theme ?? "dark"];
|
||||
const seats = data.seats;
|
||||
const nav =
|
||||
`<a class="tab" href="#overview">总览</a>` +
|
||||
seats.map((s) => `<a class="tab" href="#${esc(s.id)}">${esc(s.name)}</a>`).join("");
|
||||
|
||||
const kpis = data.kpis
|
||||
.map(
|
||||
(k, i) =>
|
||||
`<div class="card kpi"><span class="klabel">${esc(k.label)}</span><div class="kval" style="color:${P.accents[i % P.accents.length]}">${esc(k.value)}<small>${esc(k.unit ?? "")}</small>${
|
||||
k.trend === "up" ? '<i class="tr up">▲</i>' : k.trend === "down" ? '<i class="tr dn">▼</i>' : ""
|
||||
}</div></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const seatCards = seats
|
||||
.map((s, i) => {
|
||||
const color = P.accents[i % P.accents.length];
|
||||
return `<a class="card seat" href="#${esc(s.id)}" style="--ac:${color}">
|
||||
<div class="shead"><i></i><h3>${esc(s.name)}</h3><span class="go">进入席位页 →</span></div>
|
||||
<p>${esc(s.summary || "(无摘要)")}</p>
|
||||
${metricBars(s.metrics.slice(0, 2))}
|
||||
${riskChips(s.risks.slice(0, 2))}
|
||||
</a>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const timeline = data.timeline.length
|
||||
? `<div class="card tl"><h3>执行路线</h3><ol>${data.timeline
|
||||
.map((t) => `<li><b>${esc(t.label)}</b><span>${esc(t.desc)}</span></li>`)
|
||||
.join("")}</ol></div>`
|
||||
: "";
|
||||
|
||||
const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join("");
|
||||
|
||||
const overview = `<section class="page" id="overview">
|
||||
<div class="kpis">${kpis}</div>
|
||||
<div class="grid2">
|
||||
<div class="card consensus"><h3>总控最终共识</h3>
|
||||
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
|
||||
<ul>${consensusPoints}</ul>
|
||||
<a class="more" href="#consensus-modal">查看详情</a>
|
||||
</div>
|
||||
<div class="charts">${data.charts.map(chartHtml).join("")}</div>
|
||||
</div>
|
||||
<h2 class="sec">席位交付总览 <small>点击卡片进入席位专页</small></h2>
|
||||
<div class="seats">${seatCards}</div>
|
||||
${timeline}
|
||||
</section>`;
|
||||
|
||||
const seatPages = seats
|
||||
.map((s, i) => {
|
||||
const color = P.accents[i % P.accents.length];
|
||||
const prev = seats[(i - 1 + seats.length) % seats.length];
|
||||
const next = seats[(i + 1) % seats.length];
|
||||
const sections = s.sections
|
||||
.map(
|
||||
(sec) =>
|
||||
`<div class="card sect"><h3>${esc(sec.heading)}</h3><ul>${sec.bullets
|
||||
.map((b) => `<li>${esc(b)}</li>`)
|
||||
.join("")}</ul></div>`,
|
||||
)
|
||||
.join("");
|
||||
return `<section class="page" id="${esc(s.id)}" style="--ac:${color}">
|
||||
<div class="pbar">
|
||||
<a class="back" href="#overview">← 返回总览</a>
|
||||
<div class="pnav"><a href="#${esc(prev.id)}">‹ ${esc(prev.name)}</a><a href="#${esc(next.id)}">${esc(next.name)} ›</a></div>
|
||||
</div>
|
||||
<header class="phead"><i></i><div><h2>${esc(s.name)}</h2><p>${esc(s.summary)}</p></div></header>
|
||||
${depChips(s, seats)}
|
||||
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
|
||||
<div class="grid2">
|
||||
${s.metrics.length ? `<div class="card"><h3>量化指标</h3>${metricBars(s.metrics)}</div>` : ""}
|
||||
${s.risks.length ? `<div class="card"><h3>风险提示</h3>${riskChips(s.risks)}</div>` : ""}
|
||||
</div>
|
||||
</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const modal = `<div class="modal" id="consensus-modal"><div class="mbody card">
|
||||
<a class="mclose" href="#overview">×</a>
|
||||
<h3>总控最终共识 · 全部要点</h3>
|
||||
<p class="headline">${esc(data.consensus.headline)}</p>
|
||||
<ul>${consensusPoints}</ul>
|
||||
</div></div>`;
|
||||
|
||||
const generated = opts?.generatedAt ? `生成于 ${esc(opts.generatedAt)}` : "";
|
||||
|
||||
return `<!doctype html>${RT_DASHBOARD_MARKER}
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(data.title)}</title>
|
||||
<style>
|
||||
/* 主题色板(light=浅红色 UI 规范 / dark=深蓝色 UI 规范,渲染时注入) */
|
||||
:root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong};
|
||||
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg}}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:"Noto Sans S Chinese","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
|
||||
background:${P.bgGradient};background-color:${P.bgPage};background-attachment:fixed}
|
||||
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.6;
|
||||
background:linear-gradient(${P.gridLine} 1px,transparent 1px),linear-gradient(90deg,${P.gridLine} 1px,transparent 1px);
|
||||
background-size:42px 42px}
|
||||
.top{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;align-items:center;gap:12px;
|
||||
padding:14px 26px;background:${P.navBg};backdrop-filter:blur(6px);border-bottom:1px solid var(--line)}
|
||||
.top h1{font-size:21px;letter-spacing:2px;background:${P.titleColor};
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
|
||||
.top .sub{font-size:12px;color:var(--dim)}
|
||||
.top .meta{margin-left:auto;font-size:11px;color:var(--dim)}
|
||||
nav{display:flex;flex-wrap:wrap;gap:8px;padding:10px 26px;border-bottom:1px solid var(--line)}
|
||||
.tab{font-size:12px;padding:5px 14px;border:1px solid var(--line);border-radius:2px;color:var(--dim);text-decoration:none;transition:.15s}
|
||||
.tab:hover{color:${P.primary};border-color:${P.primary};box-shadow:0 0 10px ${P.primary}40}
|
||||
main{padding:20px 26px 40px;max-width:1480px;margin:0 auto}
|
||||
.page{display:none;animation:fi .25s ease}
|
||||
#overview{display:block}
|
||||
.page:target{display:block}
|
||||
body:has(.page:target) #overview:not(:target){display:none}
|
||||
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1}}
|
||||
/* 卡片:规范圆角 4px;浅色实底白卡 + 轻阴影,深色深蓝渐变 + 内发光 */
|
||||
.card{position:relative;border:1px solid var(--line);border-radius:4px;padding:16px 18px;
|
||||
background:${P.card};box-shadow:${P.cardGlow}}
|
||||
.card::before,.card::after{content:"";position:absolute;width:14px;height:14px;border-color:${P.bracket};border-style:solid;opacity:.55}
|
||||
.card::before{top:-1px;left:-1px;border-width:2px 0 0 2px;border-radius:4px 0 0 0}
|
||||
.card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0;border-radius:0 0 4px 0}
|
||||
.card h3{font-size:13px;letter-spacing:1.5px;color:var(--heading);margin-bottom:10px}
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}
|
||||
.kpi .klabel{font-size:11px;color:var(--dim);letter-spacing:1px}
|
||||
.kpi .kval{font-size:30px;font-weight:800;margin-top:4px;font-family:"D-DIN",Consolas,monospace}
|
||||
.kpi small{font-size:12px;margin-left:4px;color:var(--dim)}
|
||||
.kpi .tr{font-style:normal;font-size:12px;margin-left:6px}.tr.up{color:${P.risk.low}}.tr.dn{color:${P.risk.high}}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}
|
||||
@media(max-width:980px){.grid2{grid-template-columns:1fr}}
|
||||
.consensus .headline{font-size:15px;font-weight:700;color:var(--strong);margin-bottom:8px}
|
||||
.consensus ul{padding-left:18px;display:grid;gap:5px;font-size:12.5px;color:var(--txt)}
|
||||
.consensus .more{display:inline-block;margin-top:10px;font-size:11px;color:${P.link};text-decoration:none;border-bottom:1px dashed ${P.link}}
|
||||
.charts{display:grid;gap:14px}
|
||||
.chart svg{width:100%;height:auto}
|
||||
.cl{fill:var(--dim);font-size:11px}.cv{fill:var(--txt);font-size:11px;font-weight:700}
|
||||
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.primary}2e;stroke-width:1}
|
||||
.donut{display:flex;align-items:center;gap:14px}.donut svg{width:130px;flex:none}
|
||||
.lgs{display:grid;gap:5px}.lg{font-size:11.5px;color:var(--dim)}.lg b{color:var(--txt);margin-left:4px}
|
||||
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px}
|
||||
.sec{font-size:14px;letter-spacing:2px;color:var(--heading);margin:6px 0 12px}
|
||||
.sec small{font-size:11px;color:var(--dim);font-weight:400;margin-left:8px}
|
||||
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px}
|
||||
.seat{display:block;text-decoration:none;color:inherit;transition:.15s}
|
||||
.seat:hover{border-color:var(--ac);box-shadow:0 0 16px ${P.primary}30;transform:translateY(-2px)}
|
||||
.shead{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||
.shead i{width:8px;height:8px;border-radius:50%;background:var(--ac);box-shadow:0 0 8px var(--ac)}
|
||||
.shead h3{margin:0;flex:1}
|
||||
.go{font-size:10.5px;color:var(--dim)}
|
||||
.seat:hover .go{color:var(--ac)}
|
||||
.seat p{font-size:12px;color:var(--dim);line-height:1.6;margin-bottom:8px}
|
||||
.metrics{display:grid;gap:7px}
|
||||
.metric{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
|
||||
.metric span{width:72px;flex:none}.metric b{width:26px;text-align:right;color:var(--txt)}
|
||||
.track{flex:1;height:7px;border-radius:4px;background:var(--track);overflow:hidden}
|
||||
.fill{height:100%;border-radius:4px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.chip{font-size:10.5px;padding:2px 9px;border:1px solid;border-radius:2px}
|
||||
.tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;counter-reset:tl}
|
||||
.tl li{position:relative;padding:10px 12px 10px 14px;border-left:2px solid ${P.primary};background:${P.primary}10;border-radius:0 4px 4px 0}
|
||||
.tl b{display:block;font-size:12.5px;color:var(--heading);margin-bottom:3px}
|
||||
.tl span{font-size:11px;color:var(--dim);line-height:1.5}
|
||||
.pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;font-size:12px}
|
||||
.pbar a{color:${P.link};text-decoration:none}.pnav{display:flex;gap:14px}.pnav a{color:var(--dim)}.pnav a:hover{color:${P.link}}
|
||||
.phead{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||
.phead i{width:10px;height:34px;border-radius:3px;background:var(--ac);box-shadow:0 0 10px var(--ac)}
|
||||
.phead h2{font-size:19px;letter-spacing:2px;color:var(--strong)}
|
||||
.phead p{font-size:12px;color:var(--dim);margin-top:3px}
|
||||
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
|
||||
.dep{font-size:11px;padding:3px 11px;border:1px dashed ${P.link};border-radius:2px;color:${P.link};text-decoration:none}
|
||||
.dep.sup{border-color:${P.risk.low};color:${P.risk.low}}
|
||||
.dep:hover{background:${P.link}1a}
|
||||
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-bottom:14px}
|
||||
.sect ul{padding-left:16px;display:grid;gap:6px;font-size:12.5px;line-height:1.6}
|
||||
.sect .empty{font-size:12px;color:var(--dim)}
|
||||
.modal{display:none;position:fixed;inset:0;z-index:20;background:${P.modalMask};padding:6vh 16px}
|
||||
.modal:target{display:flex;justify-content:center;align-items:flex-start}
|
||||
.mbody{max-width:680px;width:100%;max-height:84vh;overflow:auto}
|
||||
.mbody ul{padding-left:18px;display:grid;gap:6px;font-size:13px}
|
||||
.mclose{position:absolute;top:8px;right:14px;font-size:20px;color:var(--dim);text-decoration:none}
|
||||
.mclose:hover{color:${P.risk.high}}
|
||||
</style></head>
|
||||
<body>
|
||||
<header class="top"><h1>${esc(data.title)}</h1><span class="sub">${esc(data.subtitle)}</span><span class="meta">${generated}</span></header>
|
||||
<nav>${nav}</nav>
|
||||
<main>
|
||||
${overview}
|
||||
${seatPages}
|
||||
</main>
|
||||
${modal}
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -79,6 +79,11 @@ import {
|
||||
type Step2Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
|
||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
|
||||
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import {
|
||||
BUILTIN_AGENT_META,
|
||||
makeRolesFromTemplates,
|
||||
@@ -97,7 +102,13 @@ import {
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import {
|
||||
getJobByDraft,
|
||||
startJob,
|
||||
startJobKeepalive,
|
||||
streamJob,
|
||||
} from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { mapJobToLiveStep2 } from "@/roundtable-planning/lib/job-live";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
@@ -233,6 +244,21 @@ export default function RoundtablePlanningPage() {
|
||||
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
// ─── 沙箱默认关闭策略(2026-06-12)────────────────────────────
|
||||
// 步骤切换(1↔2↔3 任意方向)时**默认关闭**沙箱预览:
|
||||
// - 进第三步不再自动弹出旧产物预览(只有 live write_file 开始写文件时才自动打开);
|
||||
// - 第三步回第二步不再遗留一个「没有文件的空沙箱」(步骤切换前选中的产物在新步骤
|
||||
// 的 stub 映射里多半不存在 → 表现为空白沙箱)。
|
||||
// 用 deselect(raw 关闭)而**不是** wrapped setOpen(false) —— 后者会把 autoOpen 置
|
||||
// false,导致新步骤里 live 写文件时自动打开被守卫拦掉(见 useStep2Orchestration 注释)。
|
||||
const { deselect: deselectArtifactOnStepChange } = useArtifacts();
|
||||
const prevStepRef = useRef(currentStep);
|
||||
useEffect(() => {
|
||||
if (prevStepRef.current === currentStep) return;
|
||||
prevStepRef.current = currentStep;
|
||||
deselectArtifactOnStepChange();
|
||||
}, [currentStep, deselectArtifactOnStepChange]);
|
||||
|
||||
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
||||
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
||||
const [roles, setRoles] = useState<RoleEntry[]>([]);
|
||||
@@ -869,6 +895,153 @@ export default function RoundtablePlanningPage() {
|
||||
emitConsensusToParent();
|
||||
}, [step2.hasConsensus, inWujieEmbed, emitConsensusToParent]);
|
||||
|
||||
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
|
||||
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
|
||||
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
|
||||
// 回头进入对应视图即可续看进行中的过程或最终结果。
|
||||
const step3SeatDeliveries = useMemo(
|
||||
() =>
|
||||
step2.step2RoundtableDialogues
|
||||
.filter((d) => d.confidence === "子智能体交付")
|
||||
.map((d) => ({
|
||||
sender: String(d.sender ?? ""),
|
||||
content: stripReasoning(String(d.content ?? "")),
|
||||
})),
|
||||
[step2.step2RoundtableDialogues],
|
||||
);
|
||||
const handleReportSaved = useCallback((snap: DraftStep3Snapshot) => {
|
||||
// **合并**而非整体替换:snap 只带 HTML 报告字段,整体替换会把已存的
|
||||
// summaryReport(总结报告快照)抹掉 —— 重新绘制后总结就丢了。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), ...snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), ...snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}, []);
|
||||
const handleSummarySaved = useCallback((snap: NonNullable<DraftStep3Snapshot["summaryReport"]>) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
const summaryFlow = useStep3Summary({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
});
|
||||
const reportFlow = useStep3Report({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
savedReport: step3Report,
|
||||
onReportSaved: handleReportSaved,
|
||||
});
|
||||
const dashboardFlow = useStep3Dashboard({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
themeMode: isDark ? "dark" : "light",
|
||||
savedReport: step3Report,
|
||||
onReportSaved: handleReportSaved,
|
||||
});
|
||||
|
||||
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
|
||||
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
|
||||
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
|
||||
const step3EnteredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (currentStep !== 3 || step3EnteredRef.current) return;
|
||||
step3EnteredRef.current = true;
|
||||
// ① 方案总结报告
|
||||
const savedSum = step3Report?.summaryReport;
|
||||
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
|
||||
summaryFlow.hydrateSaved(savedSum);
|
||||
} else {
|
||||
summaryFlow.start();
|
||||
}
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。
|
||||
if (step3Report?.html && !isDashboardHtml(step3Report.html)) {
|
||||
reportFlow.hydrateSaved(step3Report);
|
||||
} else {
|
||||
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
if (step3Report?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(step3Report.dashboardJson);
|
||||
} else if (step3Report?.html && isDashboardHtml(step3Report.html)) {
|
||||
dashboardFlow.hydrateSaved(step3Report.html);
|
||||
} else {
|
||||
dashboardFlow.start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep]);
|
||||
// 切换草稿:停掉旧草稿的在途 Step3 流(防串台),并复位入口闸门让新草稿重新编排。
|
||||
useEffect(() => {
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
step3EnteredRef.current = false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drafts.currentDraftId]);
|
||||
|
||||
// ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ──
|
||||
// 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地
|
||||
// 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。
|
||||
const jobFollowUnsubRef = useRef<(() => void) | null>(null);
|
||||
const stopJobFollow = useCallback(() => {
|
||||
jobFollowUnsubRef.current?.();
|
||||
jobFollowUnsubRef.current = null;
|
||||
}, []);
|
||||
// 「查看运行中作业」:先 await 草稿 hydrate(建好席位/线程基础 + 切第二步),**再**
|
||||
// 订阅 SSE —— 保证实时帧总是最后权威,不被草稿快照覆盖(竞态根因)。强制停在第二步
|
||||
// 看研讨流程(作业可能已到 Step3,但用户点查看就是要看实时研讨过程)。
|
||||
const followRunningJob = useCallback(
|
||||
async (jobId: string, draftId: string) => {
|
||||
stopJobFollow();
|
||||
step2.setIsRoundTableRunning(false);
|
||||
step2.stopOrchestration();
|
||||
// 点查看前**立刻存一次**当前聊天(若用户在本地还跑着研讨,保住其记录再切去看作业)。
|
||||
await drafts.saveDraftNow(2).catch(() => null);
|
||||
await drafts.handleLoadDraft(draftId);
|
||||
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步看流程
|
||||
jobFollowUnsubRef.current = streamJob(jobId, {
|
||||
onProgress: (j) => {
|
||||
step2.applyLiveJobFrame(mapJobToLiveStep2(j));
|
||||
},
|
||||
onDone: (j) => {
|
||||
stopJobFollow();
|
||||
// 终态:作业已把最终 run + step3 写回草稿 → 重载草稿无缝衔接(含报告)。
|
||||
if (j.draftId) void drafts.handleLoadDraft(j.draftId);
|
||||
void drafts.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
/* 断流静默:画面保留最后一帧;用户可重新点「查看」再续 */
|
||||
},
|
||||
});
|
||||
},
|
||||
[stopJobFollow, step2, drafts, setCurrentStep],
|
||||
);
|
||||
// 卸载兜底:离开页面停止跟随(后台作业本身继续跑)。
|
||||
useEffect(() => stopJobFollow, [stopJobFollow]);
|
||||
|
||||
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
|
||||
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
|
||||
// 在历史记录里可见)。
|
||||
@@ -882,6 +1055,13 @@ export default function RoundtablePlanningPage() {
|
||||
setOrchestrationPlan(null);
|
||||
setDagExecMode("fast");
|
||||
setStep3Report(null);
|
||||
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
|
||||
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
stopJobFollow();
|
||||
step3EnteredRef.current = false;
|
||||
step1.stopIntentStream();
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
drafts.setCurrentDraftId(null);
|
||||
@@ -891,6 +1071,7 @@ export default function RoundtablePlanningPage() {
|
||||
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
|
||||
// currentStep 不变,useEffect 不会 re-fire。
|
||||
step1.runStep1Init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drafts, step1, step2]);
|
||||
|
||||
const handleStartNewTask = useCallback(() => {
|
||||
@@ -906,49 +1087,39 @@ export default function RoundtablePlanningPage() {
|
||||
});
|
||||
}, [resetToStep1]);
|
||||
|
||||
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
|
||||
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
|
||||
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
|
||||
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
|
||||
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
if (agents.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
if (agents.length === 0) return false;
|
||||
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
|
||||
const draftId = await drafts.saveDraftNow(2);
|
||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (!alreadyRunning) {
|
||||
await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
|
||||
const draftId = await drafts.ensureDraft(2);
|
||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (!alreadyRunning) {
|
||||
await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
return true;
|
||||
}, [
|
||||
step2,
|
||||
drafts,
|
||||
@@ -957,10 +1128,86 @@ export default function RoundtablePlanningPage() {
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
buildInitialIntent,
|
||||
resetToStep1,
|
||||
triggerToast,
|
||||
]);
|
||||
|
||||
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
if (step2.selectedAgents.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startBackgroundJob();
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
|
||||
|
||||
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
|
||||
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
|
||||
// 不能被旧渲染的闭包钉死。
|
||||
const autoSuspendRef = useRef<{ running: boolean; fire: () => void; beacon: () => void }>({
|
||||
running: false,
|
||||
fire: () => {},
|
||||
beacon: () => {},
|
||||
});
|
||||
useEffect(() => {
|
||||
autoSuspendRef.current = {
|
||||
// 只认 isOrchestrating(本地编排循环**真正在生成中**的锁)——
|
||||
// isRoundTableRunning 是「会商进行」的 UI 开关,查看聊天记录(加载草稿 hydrate)
|
||||
// 后它可能为 true,曾导致看历史时切页/刷新被误触发后台挂起、从头重跑一单。
|
||||
running: step2.isOrchestrating,
|
||||
// 路由卸载用:可走完整异步链(ensureDraft → 去重 → startJob),fetch 卸载后照常完成。
|
||||
fire: () => {
|
||||
void startBackgroundJob().catch(() => {});
|
||||
},
|
||||
// beforeunload 用:无法 await —— 用 keepalive fetch 同步发出(浏览器保证发完)。
|
||||
// draftId 取当前已存在的草稿(研讨启动时已 ensureDraft,见下方 effect)。
|
||||
beacon: () => {
|
||||
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
if (agents.length === 0) return;
|
||||
startJobKeepalive({
|
||||
draftId: drafts.currentDraftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined,
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
|
||||
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
|
||||
useEffect(() => {
|
||||
if (step2.isOrchestrating) void drafts.ensureDraft(2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step2.isOrchestrating]);
|
||||
// 路由卸载(切到其它页面):正在研讨 → 自动转后台挂起。
|
||||
useEffect(
|
||||
() => () => {
|
||||
const snap = autoSuspendRef.current;
|
||||
if (snap.running) snap.fire();
|
||||
},
|
||||
[],
|
||||
);
|
||||
// 刷新 / 关标签页:keepalive 兜底。后端幂等闸门保证与其它触发源并发也不重复建作业。
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const snap = autoSuspendRef.current;
|
||||
if (snap.running) snap.beacon();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
|
||||
const handleSkipQuestion = useCallback(() => {
|
||||
step1.appendLocalMessage({
|
||||
@@ -1033,15 +1280,16 @@ export default function RoundtablePlanningPage() {
|
||||
if (!o) setChainModalJobId(null);
|
||||
}}
|
||||
onView={(job) => {
|
||||
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
|
||||
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
|
||||
// 「查看」(2026-06-13 恢复流程语义):done → 加载草稿(含 step3)看报告;
|
||||
// **运行中/待澄清 → 草稿 hydrate 完成后 SSE 实时跟随**——第二步消息列表像
|
||||
// 本地编排一样实时长出新气泡/步骤卡,不再是静态聊天记录。
|
||||
setChainModalJobId(null);
|
||||
if (!job.draftId) return;
|
||||
if (job.status === "running" || job.status === "awaiting_input") {
|
||||
step2.setIsRoundTableRunning(false);
|
||||
step2.stopOrchestration();
|
||||
void followRunningJob(job.id, job.draftId);
|
||||
return;
|
||||
}
|
||||
drafts.handleLoadDraft(job.draftId);
|
||||
void drafts.handleLoadDraft(job.draftId);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1281,30 +1529,15 @@ export default function RoundtablePlanningPage() {
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={step3Report}
|
||||
onReportSaved={(snap) => {
|
||||
setStep3Report(snap);
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onReportSaved={handleReportSaved}
|
||||
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
|
||||
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
|
||||
savedSummary={step3Report?.summaryReport ?? null}
|
||||
onSummarySaved={(snap) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onSummarySaved={handleSummarySaved}
|
||||
// 页面层常驻的三个生成流(切走不断流,进入视图续看)。
|
||||
summaryFlow={summaryFlow}
|
||||
reportFlow={reportFlow}
|
||||
dashboardFlow={dashboardFlow}
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
|
||||
@@ -265,6 +265,8 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
|
||||
|
||||
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
|
||||
|
||||
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`.
|
||||
|
||||
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
|
||||
|
||||
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
@@ -26,6 +27,7 @@ from app.gateway.roundtable_run_policy import roundtable_run_policy
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.agents.roundtable_orchestrator.types import (
|
||||
@@ -33,6 +35,7 @@ from deerflow.agents.roundtable_orchestrator.types import (
|
||||
ReportResult,
|
||||
SeatRef,
|
||||
SeatTurn,
|
||||
SummaryResult,
|
||||
)
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.serialization import serialize_channel_values
|
||||
@@ -41,6 +44,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
|
||||
_REPORT_FILE = "方案总览.html"
|
||||
_SUMMARY_FILE = "方案总结报告.md"
|
||||
|
||||
# 各角色的工具裁剪 / thinking / reasoning 全部取自单一数据源 roundtable_run_policy
|
||||
# (与 multi_agent 的 _leader_run / _special_run 同源),不再在本文件硬编码——历史上
|
||||
@@ -227,6 +231,92 @@ def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _read_summary_md(app, thread_id: str, user_id: str) -> str | None:
|
||||
"""从 summary thread 的 outputs 目录读出总结报告 Markdown。
|
||||
|
||||
优先 ``方案总结报告.md``;找不到就扫目录里**任意 .md**(取体积最大的那个),
|
||||
容忍总结 agent 用了别的文件名。镜像 ``_read_report_html``。
|
||||
"""
|
||||
try:
|
||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
|
||||
preferred = outputs_dir / _SUMMARY_FILE
|
||||
if preferred.exists():
|
||||
return preferred.read_text(encoding="utf-8")
|
||||
if outputs_dir.exists():
|
||||
mds = sorted(
|
||||
outputs_dir.glob("*.md"),
|
||||
key=lambda p: p.stat().st_size,
|
||||
reverse=True,
|
||||
)
|
||||
if mds:
|
||||
logger.info("summary md: 方案总结报告.md not found, using %s", mds[0].name)
|
||||
return mds[0].read_text(encoding="utf-8")
|
||||
logger.warning("summary md: no .md found in %s", outputs_dir)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("read summary md failed for thread %s", thread_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Step3 产物自愈重试次数(首轮之外的纠偏轮数)。
|
||||
# 实测痛点(2026-06-12,h:\xiugai\后台日志.txt):deepseek 报告 agent 说「let me write
|
||||
# it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、
|
||||
# run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。
|
||||
# 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。
|
||||
_ARTIFACT_RETRY_LIMIT = 2
|
||||
|
||||
_REPORT_RETRY_MISSING = (
|
||||
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
|
||||
"(最常见原因:试图一次写入整份长 HTML,输出超长被截断,工具调用没有真正落盘)。\n"
|
||||
"请立即重新写入,并**严格分段**:\n"
|
||||
f"1. 先用 write_file 写入 /mnt/user-data/outputs/{_REPORT_FILE} 的开头部分(<!doctype html> 起,约 200-300 行);\n"
|
||||
"2. 再用 write_file 的 append=true 参数分多次续写后续片段,每段同样控制在 300 行以内;\n"
|
||||
"3. 直到文档以 </html> 收尾。\n"
|
||||
"不要在对话里粘贴 HTML 源码,写完用一两句话总结即可。"
|
||||
)
|
||||
|
||||
_REPORT_RETRY_TRUNCATED = (
|
||||
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 已写入但**不完整**(没有 </html> 收尾,疑似上一轮输出被截断)。\n"
|
||||
"请用 write_file 的 append=true 参数从中断处**继续补全剩余内容**(不要重写已有部分),"
|
||||
"每段控制在 300 行以内,分多次续写直到文档以 </html> 收尾。不要在对话里粘贴源码。"
|
||||
)
|
||||
|
||||
_SUMMARY_RETRY_MISSING = (
|
||||
f"检测到 /mnt/user-data/outputs/{_SUMMARY_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
|
||||
"(可能是单次输出过长被截断)。请立即用 write_file 重新写入完整报告;若内容很长,"
|
||||
"先写前半部分、再用 write_file 的 append=true 参数分段续写。写完后把报告正文也输出到对话回复里。"
|
||||
)
|
||||
|
||||
_DASHBOARD_FENCE = "```report-json"
|
||||
_DASHBOARD_RETRY = (
|
||||
"你刚才没有输出有效的 ```report-json 代码块(或其中的 JSON 无法解析)。"
|
||||
"请重新在回复**最末尾**输出一个完整且**严格合法**的 ```report-json 代码块"
|
||||
"(标准 JSON:无注释、无尾逗号、字符串用双引号),代码块之外只允许一两句说明;"
|
||||
"不要调用任何工具、不要写文件、不要输出 HTML。"
|
||||
)
|
||||
|
||||
|
||||
def _extract_report_json(text: str) -> str | None:
|
||||
"""从模型回复里提取最后一个 ```report-json 代码块的 JSON 字符串(须可解析)。"""
|
||||
start = text.rfind(_DASHBOARD_FENCE)
|
||||
if start < 0:
|
||||
return None
|
||||
after = text[start + len(_DASHBOARD_FENCE) :]
|
||||
close = after.find("```")
|
||||
body = (after[:close] if close >= 0 else after).strip()
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
json.loads(body)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return body
|
||||
|
||||
|
||||
def _html_complete(html: str | None) -> bool:
|
||||
"""报告 HTML 是否完整可用(非空 + 有 </html> 收尾,截断检测)。"""
|
||||
return bool(html and html.strip()) and "</html>" in html.lower()
|
||||
|
||||
|
||||
def _build_seat_intro(agents: list[SeatRef]) -> str:
|
||||
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
|
||||
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
|
||||
@@ -328,18 +418,111 @@ class InProcessRoundtableGateway:
|
||||
return await _create_thread(self._app)
|
||||
|
||||
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
|
||||
"""跑报告 agent,**带产物自愈**:首轮后 HTML 缺失/截断(无 </html>)→ 同线程追加
|
||||
纠偏消息(分段 + append 续写)重试,至多 ``_ARTIFACT_RETRY_LIMIT`` 轮。"""
|
||||
policy = roundtable_run_policy("report")
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
thread_id=report_thread_id,
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
message=prompt,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
)
|
||||
summary = _latest_ai_text(messages)
|
||||
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
|
||||
return ReportResult(html=html, summary=summary, model=model or self._model)
|
||||
summary = ""
|
||||
html: str | None = None
|
||||
message = prompt
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
thread_id=report_thread_id,
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
message=message,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
)
|
||||
summary = _latest_ai_text(messages) or summary
|
||||
html = _read_report_html(self._app, report_thread_id, self._user_id)
|
||||
if _html_complete(html):
|
||||
break
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
break
|
||||
message = _REPORT_RETRY_TRUNCATED if (html and html.strip()) else _REPORT_RETRY_MISSING
|
||||
logger.warning(
|
||||
"report html %s after attempt %d for thread %s; retrying with corrective prompt",
|
||||
"truncated" if (html and html.strip()) else "missing",
|
||||
attempt + 1,
|
||||
report_thread_id,
|
||||
)
|
||||
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
|
||||
final_html = html if (html and html.strip()) else summary
|
||||
return ReportResult(html=final_html, summary=summary, model=model or self._model)
|
||||
|
||||
async def create_summary_thread(self) -> str:
|
||||
ensure_roundtable_functional_agents()
|
||||
return await _create_thread(self._app)
|
||||
|
||||
async def run_summary(self, *, summary_thread_id, prompt, model) -> SummaryResult:
|
||||
"""进程内驱动 roundtable-summary 写「方案总结报告.md」+ 对话正文(与前端
|
||||
useStep3Summary 同一智能体 / 同一 run policy / 同款产物),返回与
|
||||
DraftStep3SummarySnapshot 对齐的结果。线程是真实持久线程——加载草稿后可续问。
|
||||
|
||||
带产物自愈:md 文件缺失时同线程追加纠偏消息重试(同 ``run_report``)。"""
|
||||
policy = roundtable_run_policy("summary")
|
||||
closing = ""
|
||||
md: str | None = None
|
||||
message = prompt
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
thread_id=summary_thread_id,
|
||||
agent_name=SUMMARY_AGENT_ID,
|
||||
message=message,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
)
|
||||
closing = _latest_ai_text(messages) or closing
|
||||
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
|
||||
if md and md.strip():
|
||||
break
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
break
|
||||
message = _SUMMARY_RETRY_MISSING
|
||||
logger.warning(
|
||||
"summary md missing after attempt %d for thread %s; retrying with corrective prompt",
|
||||
attempt + 1,
|
||||
summary_thread_id,
|
||||
)
|
||||
final_md = md if (md and md.strip()) else closing
|
||||
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
|
||||
|
||||
async def run_dashboard_data(self, *, prompt, model) -> str | None:
|
||||
"""大屏多页(模式二)的结构化数据:进程内驱动 roundtable-report 输出
|
||||
```report-json(prompt 已声明禁工具/禁文件),返回可解析的 JSON 字符串。
|
||||
缺失 / 坏 JSON → 同线程纠偏重试(同 run_report/run_summary 的自愈语义)。"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread(self._app)
|
||||
policy = roundtable_run_policy("report")
|
||||
message = prompt
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
thread_id=thread_id,
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
message=message,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
)
|
||||
body = _extract_report_json(_latest_ai_text(messages))
|
||||
if body:
|
||||
return body
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
break
|
||||
message = _DASHBOARD_RETRY
|
||||
logger.warning(
|
||||
"dashboard report-json missing/invalid after attempt %d for thread %s; retrying",
|
||||
attempt + 1,
|
||||
thread_id,
|
||||
)
|
||||
return None
|
||||
@@ -16,9 +16,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.roundtable_orchestrator import (
|
||||
JobProgressWriter,
|
||||
@@ -29,14 +30,13 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ⚠️ 临时禁用「后台挂起」的 Step3 结果绘制(报告 HTML 生成)。
|
||||
# 进程内驱动报告 agent 在真模型环境下仍无法稳定写出 HTML(前端手动调
|
||||
# /api/multi-agent/report/init + run/stream 可以,但后台 start_run 路径不行,
|
||||
# 根因尚未定位)。临时方案:后台只跑完 Step2 研讨即收尾,不自动画报告——用户需要
|
||||
# 报告时可在前端手动跑 Step3(那条路是通的)。
|
||||
# 待修复后把本开关改回 True 即恢复后台自动报告。
|
||||
# 详见 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md「后台报告 HTML」。
|
||||
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = False
|
||||
# 「后台挂起」的 Step3 自动产出(方案总结报告 md + 结果绘制 HTML)总开关。
|
||||
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
|
||||
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
|
||||
# 杀死编排,见 tests/test_roundtable_inprocess_request.py),修复后恢复开启。
|
||||
# 现 Step2 收口后自动跑:① roundtable-summary 写「方案总结报告.md」② roundtable-report
|
||||
# 画「方案总览.html」,两段故障隔离(engine._run_step3),产物按手动同款形状写回草稿。
|
||||
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True
|
||||
|
||||
|
||||
class _JobUser:
|
||||
@@ -176,8 +176,8 @@ class StartParams:
|
||||
# dag 模式分层编排计划(前端 OrchestrationPlan dict):驱动引擎 _run_dag + 写进草稿 run
|
||||
# 供前端流程图渲染。None = 非 dag(引擎按 mode 回退 chain/recommend)。
|
||||
orchestration_plan: dict[str, Any] | None = None
|
||||
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
|
||||
# 测试可显式传 True 覆盖以验证报告链路。
|
||||
# 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True,
|
||||
# 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。
|
||||
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||
|
||||
# 内置「知识库检索」技能:素材收集专家通用检索的默认技能。skills/ 目录被
|
||||
# .gitignore 排除,所以与 agent 目录一样需要 seeder 在缺失时从资源补建。
|
||||
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
|
||||
_SKILL_ASSETS_DIR = _ASSETS_DIR / "skills"
|
||||
|
||||
|
||||
def _ensure_one(agent_id: str) -> bool:
|
||||
"""Copy the verified SOUL.md + config.yaml for one functional agent into
|
||||
@@ -91,10 +96,69 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _skills_public_dir() -> Path:
|
||||
"""skills/public 物理目录(config.yaml skills.path 解析);独立成函数便于测试替身。"""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
|
||||
|
||||
|
||||
def _ensure_default_search_skill() -> bool:
|
||||
"""Seed the built-in knowledge-base-search skill into ``skills/public/``
|
||||
when missing. Never overwrites an existing skill directory.
|
||||
|
||||
Returns True if the skill was just created.
|
||||
"""
|
||||
src = _SKILL_ASSETS_DIR / KNOWLEDGE_SEARCH_SKILL_NAME / "SKILL.md"
|
||||
if not src.exists():
|
||||
logger.error("Missing seed asset for skill '%s' (expected at %s)", KNOWLEDGE_SEARCH_SKILL_NAME, src)
|
||||
return False
|
||||
|
||||
skill_dir = _skills_public_dir() / KNOWLEDGE_SEARCH_SKILL_NAME
|
||||
if skill_dir.exists():
|
||||
return False
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_bytes(src.read_bytes())
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_researcher_default_skills() -> bool:
|
||||
"""给**已存在**的 researcher agent config.yaml 补默认技能配置(就地升级)。
|
||||
|
||||
seeder 对已存在目录绝不整体覆盖,但旧版种子没有 ``skills`` 字段——通用
|
||||
检索改为技能驱动后,老部署的智能体管理页会显示"未配置技能"(运行时虽有
|
||||
knowledge-base-search 兜底,但用户看不见、也没法在它基础上增删)。只在
|
||||
``skills`` 键**完全缺失**时补默认值;用户显式改过(包括清空成 ``[]``)
|
||||
的配置不动。Returns True if the file was patched.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
config_file = get_paths().agent_dir(RESEARCHER_AGENT_ID) / "config.yaml"
|
||||
if not config_file.exists():
|
||||
return False
|
||||
try:
|
||||
data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError:
|
||||
logger.warning("researcher config.yaml unparsable, skip default-skills patch")
|
||||
return False
|
||||
if not isinstance(data, dict) or "skills" in data:
|
||||
return False
|
||||
data["skills"] = [KNOWLEDGE_SEARCH_SKILL_NAME]
|
||||
config_file.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def ensure_ai_writing_functional_agents() -> list[str]:
|
||||
"""Seed the four ai-writing functional agents if their on-disk directories
|
||||
are missing. Idempotent — fast no-op when all exist.
|
||||
|
||||
Also seeds the built-in knowledge-base-search skill (the researcher's
|
||||
default retrieval skill) and patches a pre-existing researcher config.yaml
|
||||
that predates the ``skills`` field.
|
||||
|
||||
Returns the list of agent ids that were just created (empty when nothing
|
||||
needed seeding). Errors are logged but never raised — a partial failure
|
||||
must not block the request; the graph node falls back to the legacy
|
||||
@@ -109,6 +173,16 @@ def ensure_ai_writing_functional_agents() -> list[str]:
|
||||
logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id)
|
||||
if created:
|
||||
logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created))
|
||||
try:
|
||||
if _ensure_default_search_skill():
|
||||
logger.info("Seeded built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
|
||||
except Exception:
|
||||
logger.exception("Failed to seed built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
|
||||
try:
|
||||
if _ensure_researcher_default_skills():
|
||||
logger.info("Patched researcher config.yaml with default skills")
|
||||
except Exception:
|
||||
logger.exception("Failed to patch researcher default skills")
|
||||
return created
|
||||
|
||||
|
||||
|
||||
+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」之类的标签、徽章或水印
|
||||
|
||||
@@ -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(
|
||||
|
||||
+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)────
|
||||
|
||||
|
||||
Reference in new issue
Block a user