From d5f319d9cbe5a946595d5d8ebebe2ca556ee70c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 10:36:46 +0800 Subject: [PATCH 01/15] =?UTF-8?q?@=20feat(=E5=9C=86=E6=A1=8C):=20Step3=20?= =?UTF-8?q?=E4=B8=89=E4=BA=A7=E7=89=A9=E5=B9=B6=E8=A1=8C=20+=20=E5=A4=A7?= =?UTF-8?q?=E5=B1=8F=E5=A4=9A=E9=A1=B5=E6=A8=A1=E5=BC=8F=20+=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=8C=82=E8=B5=B7=E6=9F=A5=E7=9C=8B=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台挂起 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 @ --- frontend-web/.tmp-tests/flow-graph.test.mjs | 319 ------- .../docs/ai-writing-内置智能体改造.md | 28 + ...agent-background-run-遗留问题与下次继续.md | 31 +- .../artifacts/artifact-file-detail.tsx | 63 +- .../workspace/messages/message-list.tsx | 3 + .../components/ai-writing/AIWritingForm.tsx | 9 +- .../ai-writing/InterventionCard.tsx | 38 +- .../ai-writing/MaterialsToolbar.tsx | 4 +- .../open-canvas/contexts/AIWritingContext.tsx | 3 +- .../contexts/WritingFormContext.tsx | 13 +- .../open-canvas/hooks/useAIWritingStream.ts | 8 +- .../src/open-canvas/types/ai-writing.ts | 6 +- .../src/roundtable-planning/api/drafts.ts | 6 + .../api/roundtable-jobs.ts | 19 + .../components/DagFlowChart.tsx | 20 +- .../components/DagFlowMiniPanel.tsx | 469 ++++++++++ .../components/DispatchChainModal.tsx | 55 +- .../components/HighFidelityReport.tsx | 843 +++++++++++++----- .../components/Step2Panel.tsx | 127 ++- .../components/Step3DashboardMode.tsx | 312 +++++++ .../components/Step3Panel.tsx | 14 +- .../components/Step3ReportMode.tsx | 103 ++- .../components/Step3SummaryMode.tsx | 52 +- .../hooks/useDraftPersistence.ts | 30 +- .../hooks/useStep2Orchestration.ts | 305 +++++-- .../hooks/useStep3Dashboard.ts | 496 +++++++++++ .../hooks/useStep3Report.ts | 51 +- .../hooks/useStep3Summary.ts | 147 ++- .../lib/flow-graph.test.ts | 11 + .../src/roundtable-planning/lib/flow-graph.ts | 5 + .../src/roundtable-planning/lib/job-live.ts | 129 +++ .../src/roundtable-planning/lib/job-types.ts | 30 +- .../lib/report-template.test.ts | 159 ++++ .../lib/report-template.ts | 722 +++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 373 ++++++-- offline-backend-20260512/backend/CLAUDE.md | 2 + .../gateway/roundtable_inprocess_gateway.py | 211 ++++- .../app/gateway/roundtable_job_executor.py | 24 +- .../app/gateway/routers/_ai_writing_seed.py | 74 ++ .../ai-writing-researcher/config.yaml | 6 +- .../roundtable-summary/SOUL.md | 23 +- .../app/gateway/routers/roundtable_jobs.py | 12 +- .../deerflow/agents/ai_writing/graph.py | 10 +- .../agents/ai_writing/nodes/researcher.py | 515 ++++++----- .../agents/ai_writing/search/skill_search.py | 12 + .../deerflow/agents/ai_writing/state.py | 5 +- .../roundtable_orchestrator/__init__.py | 12 +- .../agents/roundtable_orchestrator/engine.py | 209 ++++- .../roundtable_orchestrator/progress.py | 7 +- .../simulated_gateway.py | 44 +- .../agents/roundtable_orchestrator/types.py | 33 + 51 files changed, 5016 insertions(+), 1186 deletions(-) delete mode 100644 frontend-web/.tmp-tests/flow-graph.test.mjs create mode 100644 frontend-web/src/roundtable-planning/components/DagFlowMiniPanel.tsx create mode 100644 frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts create mode 100644 frontend-web/src/roundtable-planning/lib/job-live.ts create mode 100644 frontend-web/src/roundtable-planning/lib/report-template.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/report-template.ts diff --git a/frontend-web/.tmp-tests/flow-graph.test.mjs b/frontend-web/.tmp-tests/flow-graph.test.mjs deleted file mode 100644 index 289bea27..00000000 --- a/frontend-web/.tmp-tests/flow-graph.test.mjs +++ /dev/null @@ -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))); -}); diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md index 2cb9dc0b..96016c25 100644 --- a/frontend-web/docs/ai-writing-内置智能体改造.md +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -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 键就地补丁、显式 `[]` 不动。 diff --git a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md index ba2eefde..4bdb11f6 100644 --- a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md +++ b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md @@ -54,21 +54,22 @@ roundtable-report 真调 deepseek、真搜索、跑到 report run success)。 ## 三、遗留问题(下次重点,已按优先级排) -### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物) -- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads//user-data/outputs`; - `run.step3.html` 回退成报告 agent 的**纯文字**(不含 ``),第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 注入「任务背景 + 前序交付 + 别提问」。 diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index d582cbb2..d8e4cfdd 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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 ( - +
@@ -574,7 +590,7 @@ export function ArtifactFileDetail({
-
+
{isSupportPreview && (
)}
-
- +
+ {canPublishToKnowledge && ( )} + {language === "html" && ( + + )} = { loose: "可结合通用知识", }; const MATERIAL_SOURCE_LABELS: Record = { + general: "通用检索", + notebook: "我的空间", + // 旧值(历史会话回显兼容) knowledge_base: "知识库", skill: "技能检索", }; diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 36ccdee0..fa3a806b 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -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({
- +