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/docs/multi-agent-后台挂起续跑实现方案.md b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md new file mode 100644 index 00000000..74b3811d --- /dev/null +++ b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md @@ -0,0 +1,198 @@ +# 后台挂起「在当前进度继续」实现方案 + +> 状态:**待实现**(仅方案,未改代码) +> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background` +> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。 + +--- + +## 1. 现象与根因 + +### 现象 +在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。 + +### 根因 +后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**: + +```python +# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行) +init = getattr(gateway, "init_threads", None) +if init is not None and not params.is_resume: + thread_ids, coordinator_name = await init() # ← 建全新空线程 +``` + +加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。 + +> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。 + +### 后端其实已支持续跑 +澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」: + +```python +executor.start_job(StartParams( + ..., + seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent + thread_ids=row.get("thread_ids"), # 复用作业自己的线程 + coordinator_name=row.get("coordinator_name") or "roundtable-coordinator", + is_resume=True, # ← 关键:跳过 init_threads,直接复用 + ... +)) +``` + +执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。** + +--- + +## 2. 设计目标与关键事实 + +- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。 +- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。 +- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。 + +--- + +## 3. 改造点(最小集) + +### 3.1 后端:让 `start_job` 支持复用线程 + +文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py` + +1. `StartJobRequest` 增加字段: + ```python + resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建 + ``` +2. `start_job` 里把它透传给 `StartParams`: + ```python + is_resume = bool(body.resume and body.threadIds) + executor.start_job(StartParams( + ..., + thread_ids=body.threadIds, + coordinator_name=body.coordinatorName, + is_resume=is_resume, # ← 新增 + ... + )) + ``` + 其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。 + +> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。 + +### 3.2 前端 API:补 `resume` 入参 + +文件:`frontend-web/src/roundtable-planning/lib/job-types.ts` +- `StartJobPayload` 增加 `resume?: boolean`。 + +文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts` +- `toStartBody` 增加 `resume: p.resume ?? false`。 + +### 3.3 前端:`startBackgroundJob` 改为复用线程续跑 + +文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行) + +当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传: + +```ts +const canContinue = !!(step2.threadIds && step2.coordinatorName); +await startJob({ + draftId: draftId ?? undefined, + // 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务) + intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt + seedMessage: canContinue + ? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。" + : undefined, + agents, + model: selectedModel || undefined, + orchestrationMode, + chain: chainMeta, + orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, + // 关键三件套:复用前端真实线程 + 协调名 + resume 标志 + threadIds: canContinue ? step2.threadIds ?? undefined : undefined, + coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined, + resume: canContinue, +}); +``` + +> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。 + +### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞) + +文件:同上,`handleBackgroundSuspend`(约 1161 行) + +当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**: + +```ts +const handleBackgroundSuspend = useCallback(async () => { + if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ } + // 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲 + step2.stopOrchestration(); + step2.setIsRoundTableRunning(false); + try { + // 2. 再起后台作业(复用线程续跑) + await startBackgroundJob(); + } catch (err) { /* toast 失败后 return */ } + // 3. 最后重置回第一步(后台作业继续在历史记录里跑) + resetToStep1(); + triggerToast("🚀 已转入后台,将基于当前进度继续研讨…"); + void drafts.refresh(); +}, [...]); +``` + +--- + +## 4. 关键风险:线程占用(409)与三种处理 + +复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。 + +| 方案 | 做法 | 取舍 | +|---|---|---| +| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 | +| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 | +| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 | + +> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。 + +--- + +## 5. 适用范围(哪些挂起入口改续跑) + +三个触发源(见 `autoSuspendRef`): + +1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。 +2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。 +3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。 + +> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。 + +--- + +## 6. 与已实现「查看=接管回前端」的关系 + +二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实: + +- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。 +- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。 + +实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。 + +--- + +## 7. 测试 + +后端(`make test`): +- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。 +- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。 + +前端(`pnpm typecheck`)+ 真机: +- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。 +- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。 + +--- + +## 8. 落地清单(TL;DR) + +- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume` +- [ ] 前端 `StartJobPayload.resume` + `toStartBody` +- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume` +- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset** +- [ ] 后端测试(resume 复用 vs 新建分支) +- [ ] 真机验证「接着进度跑」+ 409 命中率 +- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run diff --git a/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md new file mode 100644 index 00000000..e652e565 --- /dev/null +++ b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md @@ -0,0 +1,375 @@ +# 浅蓝色 UI 规范 + +> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。 +> 全部色值已与"色彩附录"编号表逐一核对,保证准确。 + +--- + +## 1.1 色彩体系总览 + +调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯: + +| 阶梯 | 数量 | 用途 | +| --- | --- | --- | +| 浅色 | 3 | 用于大面积底色 | +| 中亮色 | 6 | 用于标签、图表、提示等 | +| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 | +| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 | +| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 | + +> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。 + +--- + +## 主色 + +### 主色 - 红(基准 `#CF1322`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` | +| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` | +| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` | +| 线性渐变色 | `#F5222D` → `#A8071A` | +| 径向渐变色 | `#F86C73` → `#F5222D` | + +### 主色 - 蓝(基准 `#0062D9`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` | +| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` | +| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` | +| 线性渐变色 | `#0079FE` → `#004AB3` | +| 径向渐变色 | `#55A6FE` → `#0079FE` | + +--- + +## 辅色 + +### 辅色 - 橙(基准 `#D97B00`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` | +| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` | +| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` | +| 线性渐变色 | `#FF9900` → `#B35F00` | +| 径向渐变色 | `#FFBB55` → `#FF9900` | + +### 辅色 - 绿(基准 `#1EB07F`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` | +| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` | +| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` | +| 线性渐变色 | `#2FD69A` → `#118A65` | +| 径向渐变色 | `#74E4BC` → `#2FD69A` | + +### 辅色 - 青蓝(基准 `#2F9ACC`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` | +| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` | +| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` | +| 线性渐变色 | `#45C3F3` → `#1E76A6` | +| 径向渐变色 | `#83D7F7` → `#45C3F3` | + +### 中性色 - 灰(基准 `#222222`) + +| 用途 | 色值 | +| --- | --- | +| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` | +| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` | +| 深色(正文) | `#3D3D3D` · `#222222` | + +--- + +## 1.2 色彩附录(编号对照表) + +编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。 + +### 主色 ZS + +| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) | +| --- | --- | --- | --- | +| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` | +| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` | +| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` | +| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` | +| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` | +| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` | +| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` | +| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` | +| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` | +| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` | +| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` | +| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` | +| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` | +| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` | +| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` | +| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` | + +### 辅色 / 中性色 FS + +| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) | +| --- | --- | --- | --- | --- | +| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` | +| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` | +| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` | +| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` | +| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` | +| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` | +| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` | +| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` | +| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` | +| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` | +| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` | +| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — | +| -12 | `#8C4600` | `#07634B` | `#115580` | — | +| -13 | `#663000` | `#043D30` | `#0B3959` | — | +| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — | +| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — | + +> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。 + +--- + +# 组件规范 + +> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。 + +## 语义化 Token 建议 + +把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护: + +```css +:root { + /* 品牌 / 主色(蓝) */ + --color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */ + --color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */ + --color-primary-active: #0056C6; /* 按下态:ZS-002-10 */ + --color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */ + --color-primary-border: #0079FE; /* 描边选中边框 */ + + /* 中性 */ + --color-text: #3D3D3D; /* 正文:FS-004-10 */ + --color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */ + --color-border: #DBDBDB; /* 默认边框:FS-004-05 */ + --color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */ + --color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */ + --color-surface: #FFFFFF; /* 卡片 / 控件底 */ +} +``` + +> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。 + +--- + +## 2.1 基础 - 按钮(Button) + +### 按钮尺寸 + +| 规格 | 高度 | 圆角 | 用途 | +| --- | --- | --- | --- | +| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 | +| 正常 | **32px** | 4px | 常用按钮(默认尺寸) | + +水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。 + +### 常用按钮类型 + +| 类型 | 视觉 | 用途 | +| --- | --- | --- | +| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 | +| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 | +| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 | +| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) | +| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 | +| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 | +| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 | + +实现(Tailwind 示意): + +```tsx +// 主按钮 + + +// 次要按钮(次按钮) + + +// 默认按钮 + + +// 文本按钮 + + +// 图标按钮(实底圆形) + +``` + +图标按钮三种底 × 两种形状: +- **描边**:白底 + `--color-border` 边框,图标用 `--color-text` +- **实底**:`--color-primary` 底 + 白图标 +- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标 +- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full` + +### 按钮组合 + +| 组合 | 说明 | +| --- | --- | +| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 | +| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 | +| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` | +| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px | + +### 其他状态 + +- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。 +- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。 + +```tsx + +``` + +--- + +## 5.9 输入 - 单选框(选择按钮 / Segmented Radio) + +一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。 + +### 排布方式 + +| 排布 | 说明 | +| --- | --- | +| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 | +| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 | + +### 选中(已选)样式 × 三种 + +| 样式 | 已选 | 未选 | +| --- | --- | --- | +| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` | +| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 | +| 实底式 | `--color-primary` 实底 + 白字 | 同上 | + +- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。 + +实现(拼接式 + 描边选中): + +```tsx +
+ {options.map((o) => ( + + ))} +
+``` + +> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。 +> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。 + +### 按钮式页签(Tab as Button) + +图标 + 文字的页签型按钮: +- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字) +- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标 +- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。 + +--- + +## 5.11 输入 - 选择器(Select / Dropdown) + +单项输入选择,下拉单选。 + +### 状态 + +| 状态 | 触发框 | +| --- | --- | +| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` | +| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` | +| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 | + +### 下拉面板 + +- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条 +- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字 +- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border` + +实现示意: + +```tsx +// 触发框 + + +// 下拉项 +
  • {label}
  • + +// 分组标题 +
    分组标题1
    +``` + +> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。 + +--- + +## 5.14 输入 - 开关(Switch) + +两种形态: + +### 1. 滑块开关(多用于表单、表格中) + +- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」 +- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」 +- 切换时滑块带左右过渡动画 + +```tsx + +``` + +### 2. 单选框作为开关(分段式) + +- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框 +- 即 5.9 拼接式「实底选择按钮」的两选项特例 + +```tsx +
    + + +
    +``` diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 6ead2099..0c191a09 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -30,6 +30,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; +import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -65,6 +66,7 @@ function AppProviders({ children }: { children: React.ReactNode }) { attribute="class" storageKey="strategy-theme" defaultTheme="light" + themes={["light", "dark", "lightblue"]} enableSystem={false} disableTransitionOnChange forcedTheme={forcedTheme} @@ -97,11 +99,15 @@ export function App() { } /> } + element={} /> } + element={} + /> + } /> } /> + } + /> } /> } /> ); -} +} diff --git a/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg b/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg new file mode 100644 index 00000000..dabbcbe3 --- /dev/null +++ b/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/nav-active.svg b/frontend-web/src/assets/img/nav-active.svg new file mode 100644 index 00000000..fe2b1952 --- /dev/null +++ b/frontend-web/src/assets/img/nav-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/top-bg.svg b/frontend-web/src/assets/img/top-bg.svg new file mode 100644 index 00000000..54563425 --- /dev/null +++ b/frontend-web/src/assets/img/top-bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/菜单按钮.svg b/frontend-web/src/assets/img/菜单按钮.svg new file mode 100644 index 00000000..a7062876 --- /dev/null +++ b/frontend-web/src/assets/img/菜单按钮.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 14497624..e6eb7677 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -43,8 +43,8 @@ width: 100%; height: 75px; margin-top: 13px; - font-family: Noto Sans S Chinese; - font-weight: 500; + font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif; + font-weight: 700; color: #fff; font-size: 20px; display: flex; @@ -111,6 +111,42 @@ word-break: break-all; } +.sidebar-contnet--top-submenu .sidebar-contents { + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-contnet--top-submenu .item-nav { + height: auto; + min-height: 75px; + padding: 10px 2px; + font-size: 19px; + line-height: 1.35; +} + +.sidebar-contnet--top-submenu .item-nav__label { + width: 3.1em; + max-width: 100%; + padding-left: 0; + letter-spacing: 0; + line-height: 1.35; + text-align: center; + word-break: normal; + overflow-wrap: normal; + white-space: normal; +} + +.sidebar-contnet--top-submenu .item-nav__label--four-char { + width: 2.2em; +} + +.sidebar-contnet--top-submenu .item-nav:hover::after, +.sidebar-contnet--top-submenu .item-nav-active::after, +.sidebar-contnet--top-submenu .item-nav-hovered::after { + top: auto; + bottom: 0; +} + .item-nav-content { width: 155px; height: calc(100vh - 4rem - 20px); @@ -194,10 +230,10 @@ border: 0; background: transparent; padding: 3px 0; - font-family: Noto Sans S Chinese; + font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif; font-size: 18px; line-height: 16px; - font-weight: 500; + font-weight: 700; letter-spacing: 0; color: rgba(255, 255, 255, 0.8); text-align: left; @@ -343,6 +379,13 @@ overflow-y: hidden; } +/* 左右分组:不撑满、不裁切选中高光 */ +.top-submenu-bar--group { + flex: 0 0 auto; + margin-left: 0; + overflow: visible; +} + .top-submenu-list { min-width: max-content; height: 64px; @@ -362,48 +405,31 @@ border: 0; padding: 0 18px; background: transparent; - color: rgba(224, 242, 255, 0.9); - font-family: Noto Sans S Chinese; - font-size: 16px; + color: rgba(255, 255, 255, 0.8); + font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif; + font-size: 18px; line-height: 1; - font-weight: 500; + font-weight: 700; letter-spacing: 0; white-space: nowrap; cursor: pointer; - overflow: hidden; - transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease; + overflow: visible; + transition: color 0.18s ease, text-shadow 0.18s ease; } -.top-submenu-item:hover, +.top-submenu-item:hover { + color: #9fe3ff; + text-shadow: 0 0 10px rgba(91, 210, 255, 0.4); +} + +/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */ .top-submenu-item--active { - color: #ffffff; - text-shadow: 0 0 10px rgba(91, 210, 255, 0.45); - background: - linear-gradient(135deg, rgba(15, 84, 169, 0.52) 0%, rgba(5, 43, 103, 0.34) 76%), - linear-gradient(180deg, rgba(39, 126, 230, 0.2) 0%, rgba(0, 50, 122, 0.26) 100%); - box-shadow: inset 0 1px 0 rgba(98, 180, 255, 0.16), inset 0 -18px 22px rgba(0, 31, 79, 0.24); -} - -.top-submenu-item--active::before { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background: linear-gradient(90deg, rgba(59, 162, 255, 0) 0%, #35d7ff 50%, rgba(59, 162, 255, 0) 100%); - box-shadow: 0 0 12px rgba(53, 215, 255, 0.9), 0 -6px 18px rgba(33, 143, 255, 0.36); -} - -.top-submenu-item--active::after { - content: ''; - position: absolute; - left: 16px; - right: 16px; - bottom: 0; - height: 16px; - background: radial-gradient(ellipse at bottom, rgba(37, 189, 255, 0.34) 0%, rgba(37, 189, 255, 0) 68%); - pointer-events: none; + color: #64cdf5; + text-shadow: 0 0 12px rgba(100, 205, 245, 0.5); + background-image: url("../assets/img/nav-active.svg"); + background-repeat: no-repeat; + background-position: center calc(50% + 7px); + background-size: 152px 42px; } .third-submenu-bar { @@ -443,10 +469,10 @@ padding: 0 16px; background: rgba(2, 28, 65, 0.36); color: rgba(221, 242, 255, 0.78); - font-family: Noto Sans S Chinese; + font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif; font-size: 14px; line-height: 1; - font-weight: 500; + font-weight: 700; letter-spacing: 0; white-space: nowrap; cursor: pointer; @@ -472,3 +498,248 @@ background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%); box-shadow: 0 0 10px rgba(78, 230, 255, 0.72); } + +/* ══════════════════════════════════════════════════════════════════════ + 浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换) + 逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。 + 但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。 + ══════════════════════════════════════════════════════════════════════ */ + +/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */ +.light-topnav { + display: flex; + align-items: center; + height: 64px; + min-width: 0; + gap: 6px; + /* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */ + margin-left: 10%; + overflow-x: auto; + overflow-y: hidden; +} + +.light-topnav-item { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + height: 60px; + padding: 0 18px; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.85); + /* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上 + 常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC + 领先)才能正常加粗。 */ + font-family: var(--font-sans); + font-size: 16px; + /* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */ + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: color 0.18s ease; +} + +.light-topnav-item:hover { + color: #ffffff; +} + +/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */ +.light-topnav-item--active, +.light-topnav-item--active:hover { + color: #ffffff; + font-weight: 700; +} + +.light-topnav-item--active::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: 0; + height: 4px; + border-radius: 3px 3px 0 0; + background: #ffffff; +} + +/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */ +.light-sidebar { + position: fixed; + left: 0; + top: 4rem; + z-index: 40; + width: 96px; + height: calc(100vh - 4rem); + display: flex; + flex-direction: row; + background: #ffffff; + border-right: 1px solid var(--border); + box-sizing: border-box; + overflow: hidden; + /* 展开三级列时整体变宽的过渡动画 */ + transition: width 0.3s ease; +} + +/* 展开三级列:96(二级) + 140(三级) = 236px */ +.light-sidebar--expanded { + width: 236px; +} + +/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */ +.light-sidebar__primary { + flex: 0 0 96px; + width: 96px; + height: 100%; + display: flex; + flex-direction: column; + /* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */ + padding: 10px 0; + box-sizing: border-box; +} + +/* 三级列:固定 140px,随侧边栏展开淡入 */ +.light-sidebar__secondary { + flex: 0 0 140px; + width: 140px; + height: 100%; + border-left: 1px solid var(--border); + /* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */ + padding: 12px 0; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + background: #ffffff; + opacity: 0; + transition: opacity 0.2s ease; +} + +.light-sidebar--expanded .light-sidebar__secondary { + opacity: 1; +} + +.light-sidebar__list { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + overflow-x: hidden; +} + +.light-sidebar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 14px 4px; + border: 0; + border-radius: 0; + background: transparent; + color: #222222; + /* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */ + font-family: var(--font-sans); + font-size: 16px; + font-weight: 500; + line-height: 1.3; + text-align: center; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +.light-sidebar-item__icon { + flex-shrink: 0; + /* 图标统一用主题色(红主题红、蓝主题蓝) */ + color: var(--brand-900); + transition: color 0.18s ease, transform 0.18s ease; +} + +.light-sidebar-item__label { + display: block; + width: 100%; + word-break: break-word; +} + +.light-sidebar-item:hover { + background: var(--brand-200); +} + +.light-sidebar-item:hover .light-sidebar-item__icon { + color: var(--brand-900); + transform: scale(1.05); +} + +.light-sidebar-item--active { + background: var(--brand-200); + color: var(--brand-900); + font-weight: 600; + /* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */ + box-shadow: inset -4px 0 0 0 var(--brand-900); +} + +.light-sidebar-item--active .light-sidebar-item__icon { + color: var(--brand-900); +} + +/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */ +.light-sidebar__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */ +.light-sidebar-subnav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.light-sidebar-subitem { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px; + border: 0; + background: transparent; + color: #222222; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + line-height: 1.3; + text-align: left; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +.light-sidebar-subitem__icon { + flex-shrink: 0; + /* 图标用主题色(红主题红、蓝主题蓝) */ + color: var(--brand-900); +} + +.light-sidebar-subitem__label { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.light-sidebar-subitem:hover { + background: var(--brand-200); +} + +.light-sidebar-subitem--active { + background: var(--brand-200); + color: var(--brand-900); + font-weight: 700; + box-shadow: inset -4px 0 0 0 var(--brand-900); +} diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 91379f23..15b6f8c8 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -124,6 +124,13 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar } }; + const handleSecondItemClick = (item: MenuItem) => { + const path = item.path ?? findFirstRoutePath(item.children); + if (path) { + navigate(path); + } + }; + const handleMenuItemClick = (item: MenuItem) => { if (item.children?.length) { toggleExpanded(item.id); @@ -135,6 +142,35 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar } }; + const activeTopItem = + visibleMenuItems.find((item) => item.id === activeTopId) ?? + visibleMenuItems.find(hasActiveChild) ?? + visibleMenuItems[0]; + const sidebarMenuItemsToRender = inWujieEnv + ? visibleMenuItems + : activeTopItem?.children ?? []; + const isSidebarItemActive = (item: MenuItem) => + inWujieEnv + ? item.id === activeTopId + : item.path + ? pathname === item.path + : hasActiveChild(item); + const handleSidebarItemClick = (item: MenuItem) => { + if (inWujieEnv) { + handleTopItemClick(item); + return; + } + + handleSecondItemClick(item); + }; + const getSidebarLabelClassName = (label: string) => { + if (!inWujieEnv && Array.from(label.trim()).length === 4) { + return "item-nav__label item-nav__label--four-char"; + } + + return "item-nav__label"; + }; + const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
    {items.map((item) => { @@ -181,7 +217,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar return (