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..660465b4 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} @@ -120,10 +122,14 @@ export function App() { } /> } /> + } + /> } /> } /> ); -} +} 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 d2e9f08a..7e22e50c 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -379,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; @@ -398,48 +405,31 @@ border: 0; padding: 0 18px; background: transparent; - color: rgba(224, 242, 255, 0.9); + 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: 16px; + font-size: 18px; line-height: 1; 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 { diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index cae1f24f..58e59a72 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -77,8 +77,8 @@ export function PageSidebar({ compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100" } ${small ? "text-xs" : "text-sm"} flex-1 font-medium`; const menuButtonClass = (isActive: boolean, depth = 0) => - `flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${ - collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3" + `group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${ + collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3" } ${ isActive ? isDark @@ -114,7 +114,14 @@ export function PageSidebar({ onClick={() => item.path && navigate(item.path)} className={menuButtonClass(isActive, depth)} > - + {isActive && ( + + )} + {(!collapsed || depth > 0) && ( 0)}> {item.label} @@ -144,7 +151,7 @@ export function PageSidebar({ className={menuButtonClass(false, depth)} > 0 ? "scale-105" : "" }`} /> diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index f1c11ddb..fa1a21dc 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; +import { getStoredAuth } from "@/core/auth"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; @@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 + const isAdmin = getStoredAuth()?.system_role === "admin"; const [viewMode, setViewMode] = useViewMode("agent"); const [activeTab, setActiveTab] = useState("mine"); const [search, setSearch] = useState(""); @@ -58,15 +61,21 @@ export function AgentGallery() { // If the active square tab disappears (admin removed that square), fall back // to "我的" so the gallery never points at a dead tab. useEffect(() => { + // 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。 + if (activeTab === "builtin" && !isAdmin) { + setActiveTab("mine"); + return; + } if ( activeTab !== "mine" && activeTab !== "private" && + activeTab !== "builtin" && squareTabs.length > 0 && !squareTabs.some((sq) => sq.id === activeTab) ) { setActiveTab("mine"); } - }, [activeTab, squareTabs]); + }, [activeTab, squareTabs, isAdmin]); const { data: privateStatus } = usePrivateSquareStatus(); const privateFeatureAvailable = !!privateStatus?.enabled; @@ -80,9 +89,16 @@ export function AgentGallery() { const [passwordInput, setPasswordInput] = useState(""); // Only the active tab fetches. "广场" maps to the server "square" scope - // (built-in/published, never private), "我的" to "mine" (owned + favorited). - const isSquareTab = activeTab !== "mine" && activeTab !== "private"; - const mainScope = activeTab === "mine" ? "mine" : "square"; + // (built-in/published, never private), "我的" to "mine" (owned + favorited), + // "内置" to "builtin" (admin-only, user_id IS NULL). + const isSquareTab = + activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin"; + const mainScope = + activeTab === "mine" + ? "mine" + : activeTab === "builtin" + ? "builtin" + : "square"; const mainPage = pages[activeTab] ?? 1; const { agents: mainAgents, @@ -393,6 +409,12 @@ export function AgentGallery() { 私密广场 )} + {isAdmin && ( + + + 内置智能体 + + )}
    @@ -490,6 +512,19 @@ export function AgentGallery() { })} + {isAdmin && ( + + {renderListBody({ + list: mainAgents, + total: mainTotal, + isLoading: mainLoading, + isFetching: mainFetching, + tab: "builtin", + emptyDescription: "暂时没有内置智能体。", + })} + + )} + {privateFeatureAvailable && ( void; disabled?: boolean; + /** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */ + compact?: boolean; } /** @@ -44,6 +46,7 @@ export function AgentSelector({ selectedAgentId, onAgentChange, disabled, + compact = false, }: AgentSelectorProps) { const [open, setOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false); @@ -76,12 +79,17 @@ export function AgentSelector({ disabled={disabled} className={cn( "gap-1.5! rounded-full border px-3! shadow-none transition-colors", - selectedAgent - ? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]" - : "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700", + compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium", + compact + ? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" + : selectedAgent + ? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]" + : "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700", )} > - + {triggerLabel} 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" && ( + + )} ,本组件不参与。 + */ +export function CompactWelcome({ className }: { className?: string }) { + return ( +
    + {/* CM logo:浅底圆环 + 主色渐变圆角方块 */} +
    +
    + + CM + +
    +
    + +

    + 👋 欢迎使用CM助手 +

    +

    + 基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 +

    +
    + ); +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 95fa306b..3e3b53b4 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -1,1185 +1,1264 @@ -"use client"; - -import type { ChatStatus } from "ai"; -import { - BrainCircuitIcon, - BookMarkedIcon, - CheckIcon, - FileTextIcon, - GraduationCapIcon, - LightbulbIcon, - PaperclipIcon, - PlusIcon, - SparklesIcon, - RocketIcon, - XIcon, - ZapIcon, -} from "lucide-react"; -import { useSearchParams } from "next/navigation"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; - -import { - PromptInput, - PromptInputActionMenu, - PromptInputActionMenuContent, - PromptInputActionMenuItem, - PromptInputActionMenuTrigger, - PromptInputAttachment, - PromptInputAttachments, - PromptInputBody, - PromptInputButton, - PromptInputFooter, - PromptInputSubmit, - PromptInputTextarea, - PromptInputTools, - usePromptInputAttachments, - usePromptInputController, - type PromptInputMessage, -} from "@/components/ai-elements/prompt-input"; -import { Button } from "@/components/ui/button"; -import { ConfettiButton } from "@/components/ui/confetti-button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; -import { apiFetch } from "@/core/api"; -import { getBackendBaseURL } from "@/core/config"; -import { useI18n } from "@/core/i18n/hooks"; -import { useModels } from "@/core/models/hooks"; -import type { AgentThreadContext } from "@/core/threads"; -import { textOfMessage } from "@/core/threads/utils"; -import { desensitize } from "@/lib/desensitize"; -import { cn } from "@/lib/utils"; - -import { AgentSelector } from "@/components/workspace/agents/agent-selector"; - -import { - ModelSelector, - ModelSelectorContent, - ModelSelectorInput, - ModelSelectorItem, - ModelSelectorList, - ModelSelectorName, - ModelSelectorTrigger, -} from "../ai-elements/model-selector"; -import { Suggestion, Suggestions } from "../ai-elements/suggestion"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; - -import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; -import { useUserPreferences } from "@/core/user-preferences"; -import { - isThreadRagModeLocked, - lockThreadRagMode, - useLocalSettings, -} from "@/core/settings"; - - -import { CustomPromptBar } from "./custom-prompt-menu"; -import { useThread } from "./messages/context"; -import { ModeHoverGuide } from "./mode-hover-guide"; -import { Tooltip } from "./tooltip"; - -type InputMode = "flash" | "thinking" | "pro" | "ultra"; - - -function getResolvedMode( - mode: InputMode | undefined, - supportsThinking: boolean, -): InputMode { - if (!supportsThinking && mode !== "flash") { - return "flash"; - } - if (mode) { - return mode; - } - return supportsThinking ? "pro" : "flash"; -} - -export function InputBox({ - className, - disabled, - autoFocus, - status = "ready", - context, - extraHeader, - isNewThread, - threadId, - initialValue, - onContextChange, - onFollowupsVisibilityChange, - onSubmit, - onStop, - promptPrefix, - agentSelector, - assistantId, - hideAdminRecommendedQuestions, - ...props -}: Omit, "onSubmit"> & { - assistantId?: string | null; - status?: ChatStatus; - disabled?: boolean; - context: Omit< - AgentThreadContext, - "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" - > & { - mode: "flash" | "thinking" | "pro" | "ultra" | undefined; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - writing_mode?: boolean; - memory_injection_enabled?: boolean; - }; - extraHeader?: React.ReactNode; - isNewThread?: boolean; - /** Suppress the global admin "recommended questions" row. The agent chat page - * sets this so only the agent's own common questions surface on its home. */ - hideAdminRecommendedQuestions?: boolean; - threadId: string; - initialValue?: string; - onContextChange?: ( - context: Omit< - AgentThreadContext, - "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" - > & { - mode: "flash" | "thinking" | "pro" | "ultra" | undefined; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - writing_mode?: boolean; - memory_injection_enabled?: boolean; - }, - ) => void; - onFollowupsVisibilityChange?: (visible: boolean) => void; - onSubmit?: (message: PromptInputMessage) => void; - onStop?: () => void; - /** - * Optional toggle rendered next to the model selector. Used for the - * admin-configurable prompt-prefix switch on the chat input. Omitting the - * prop hides the button entirely. - */ - promptPrefix?: { - label: string; - on: boolean; - onToggle: () => void; - }; - /** - * Optional agent picker shown alongside the model selector. Only the new-chat - * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the - * thread is already bound to one agent. - */ - agentSelector?: { - selectedAgentId: string | null; - onAgentChange: (agentId: string | null) => void; - }; -}) { - const { t } = useI18n(); - const [localSettings, setLocalSettings] = useLocalSettings(); - const searchParams = useSearchParams(); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const { models } = useModels(); - const { thread, isMock } = useThread(); - const { textInput } = usePromptInputController(); - const promptRootRef = useRef(null); - - const [followups, setFollowups] = useState([]); - const [followupsHidden, setFollowupsHidden] = useState(false); - const [followupsLoading, setFollowupsLoading] = useState(false); - const lastGeneratedForAiIdRef = useRef(null); - const wasStreamingRef = useRef(false); - - const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingSuggestion, setPendingSuggestion] = useState( - null, - ); - - const { questions: recommendedQuestions } = useRecommendedQuestions(); - const userPreferences = useUserPreferences(); - const showAdminRecommendedQuestions = - !hideAdminRecommendedQuestions && - !userPreferences.hide_admin_prompt_prefix && - recommendedQuestions.length > 0; - - useEffect(() => { - if (models.length === 0) { - return; - } - const currentModel = models.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? models[0]!; - const supportsThinking = fallbackModel.supports_thinking ?? false; - const nextModelName = fallbackModel.name; - const nextMode = getResolvedMode(context.mode, supportsThinking); - - if (context.model_name === nextModelName && context.mode === nextMode) { - return; - } - - onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - }); - }, [context, models, onContextChange]); - - const selectedModel = useMemo(() => { - if (models.length === 0) { - return undefined; - } - return models.find((m) => m.name === context.model_name) ?? models[0]; - }, [context.model_name, models]); - - const resolvedModelName = selectedModel?.name; - - const supportThinking = useMemo( - () => selectedModel?.supports_thinking ?? false, - [selectedModel], - ); - - const supportReasoningEffort = useMemo( - () => selectedModel?.supports_reasoning_effort ?? false, - [selectedModel], - ); - - const handleModelSelect = useCallback( - (model_name: string) => { - const model = models.find((m) => m.name === model_name); - if (!model) { - return; - } - onContextChange?.({ - ...context, - model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, - }); - setModelDialogOpen(false); - }, - [onContextChange, context, models], - ); - - const handleModeSelect = useCallback( - (mode: InputMode) => { - onContextChange?.({ - ...context, - mode: getResolvedMode(mode, supportThinking), - reasoning_effort: - mode === "ultra" - ? "high" - : mode === "pro" - ? "medium" - : mode === "thinking" - ? "low" - : "minimal", - }); - }, - [onContextChange, context, supportThinking], - ); - - const handleReasoningEffortSelect = useCallback( - (effort: "minimal" | "low" | "medium" | "high") => { - onContextChange?.({ - ...context, - reasoning_effort: effort, - }); - }, - [onContextChange, context], - ); - - const handleWritingModeToggle = useCallback(() => { - onContextChange?.({ - ...context, - writing_mode: !context.writing_mode, - }); - }, [context, onContextChange]); - - const memoryInjectionEnabled = context.memory_injection_enabled ?? true; - const handleMemoryInjectionToggle = useCallback(() => { - onContextChange?.({ - ...context, - memory_injection_enabled: !memoryInjectionEnabled, - }); - }, [context, memoryInjectionEnabled, onContextChange]); - - const ragModeLocked = isThreadRagModeLocked(threadId); - const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; - const handleRagModeToggle = useCallback(() => { - if (ragModeLocked) { - return; - } - setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); - }, [ragModeEnabled, ragModeLocked, setLocalSettings]); - - const handleSubmit = useCallback( - async (message: PromptInputMessage) => { - if (status === "streaming") { - onStop?.(); - return; - } - if (!message.text) { - return; - } - setFollowups([]); - setFollowupsHidden(false); - setFollowupsLoading(false); - if (ragModeEnabled) { - lockThreadRagMode(threadId); - } - - // Guard against submitting before the initial model auto-selection - // effect has flushed thread settings to storage/state. - if (resolvedModelName && context.model_name !== resolvedModelName) { - onContextChange?.({ - ...context, - model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), - }); - setTimeout(() => onSubmit?.(message), 0); - return; - } - - onSubmit?.(message); - }, - [ - context, - onContextChange, - onSubmit, - onStop, - resolvedModelName, - ragModeEnabled, - selectedModel?.supports_thinking, - status, - threadId, - ], - ); - - const requestFormSubmit = useCallback(() => { - const form = promptRootRef.current?.querySelector("form"); - form?.requestSubmit(); - }, []); - - const handleFollowupClick = useCallback( - (suggestion: string) => { - if (status === "streaming") { - return; - } - const current = (textInput.value ?? "").trim(); - if (current) { - setPendingSuggestion(suggestion); - setConfirmOpen(true); - return; - } - textInput.setInput(suggestion); - setFollowupsHidden(true); - setTimeout(() => requestFormSubmit(), 0); - }, - [requestFormSubmit, status, textInput], - ); - - const confirmReplaceAndSend = useCallback(() => { - if (!pendingSuggestion) { - setConfirmOpen(false); - return; - } - textInput.setInput(pendingSuggestion); - setFollowupsHidden(true); - setConfirmOpen(false); - setPendingSuggestion(null); - setTimeout(() => requestFormSubmit(), 0); - }, [pendingSuggestion, requestFormSubmit, textInput]); - - const confirmAppendAndSend = useCallback(() => { - if (!pendingSuggestion) { - setConfirmOpen(false); - return; - } - const current = (textInput.value ?? "").trim(); - const next = current - ? `${current}\n${pendingSuggestion}` - : pendingSuggestion; - textInput.setInput(next); - setFollowupsHidden(true); - setConfirmOpen(false); - setPendingSuggestion(null); - setTimeout(() => requestFormSubmit(), 0); - }, [pendingSuggestion, requestFormSubmit, textInput]); - - const showFollowups = - !disabled && - !isNewThread && - !followupsHidden && - (followupsLoading || followups.length > 0); - - const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); - - useEffect(() => { - followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; - }, [onFollowupsVisibilityChange]); - - useEffect(() => { - followupsVisibilityChangeRef.current?.(showFollowups); - }, [showFollowups]); - - useEffect(() => { - return () => followupsVisibilityChangeRef.current?.(false); - }, []); - - useEffect(() => { - const streaming = status === "streaming"; - const wasStreaming = wasStreamingRef.current; - wasStreamingRef.current = streaming; - if (!wasStreaming || streaming) { - return; - } - - if (disabled || isMock) { - return; - } - - const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); - const lastAiId = lastAi?.id ?? null; - if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { - return; - } - lastGeneratedForAiIdRef.current = lastAiId; - - const recent = thread.messages - .filter((m) => m.type === "human" || m.type === "ai") - .map((m) => { - const role = m.type === "human" ? "user" : "assistant"; - const content = textOfMessage(m) ?? ""; - return { role, content }; - }) - .filter((m) => m.content.trim().length > 0) - .slice(-6); - - if (recent.length === 0) { - return; - } - - const controller = new AbortController(); - setFollowupsHidden(false); - setFollowupsLoading(true); - setFollowups([]); - - apiFetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: recent, - n: 3, - model_name: context.model_name ?? undefined, - }), - signal: controller.signal, - }) - .then(async (res) => { - if (!res.ok) { - return { suggestions: [] as string[] }; - } - return (await res.json()) as { suggestions?: string[] }; - }) - .then((data) => { - const suggestions = (data.suggestions ?? []) - .map((s) => (typeof s === "string" ? s.trim() : "")) - .filter((s) => s.length > 0) - .slice(0, 5); - setFollowups(suggestions); - }) - .catch(() => { - setFollowups([]); - }) - .finally(() => { - setFollowupsLoading(false); - }); - - return () => controller.abort(); - }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); - - return ( -
    - {extraHeader && ( -
    - {extraHeader} -
    - )} - - {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( -
    - {recommendedQuestions.map((q) => ( - - ))} -
    - )} - - {showFollowups && ( -
    -
    - {followupsLoading ? ( -
    - {t.inputBox.followupLoading} -
    - ) : ( - - {followups.map((s) => ( - handleFollowupClick(s)} - /> - ))} - - - )} -
    -
    - )} -
    - {isNewThread && ( - { - const current = textInput.value?.trim() ?? ""; - textInput.setInput(current ? `${current}\n${content}` : content); - setTimeout(() => { - document - .querySelector("textarea[name='message']") - ?.focus(); - }, 0); - }} - /> - )} -
    - - - {(attachment) => } - - - - - - - {/* TODO: Add more connectors here - - - - - - */} - - - - - 写作 - - - - - - 记忆 - - - - - {ragModeEnabled && ( - - )} - - 参考文献 - - - - - -
    - {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
    -
    - {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
    -
    -
    - - - - {t.inputBox.mode} - - - handleModeSelect("flash")} - > -
    -
    - - {t.inputBox.flashMode} -
    -
    - {t.inputBox.flashModeDescription} -
    -
    - {context.mode === "flash" ? ( - - ) : ( -
    - )} - - {supportThinking && ( - handleModeSelect("thinking")} - > -
    -
    - - {t.inputBox.reasoningMode} -
    -
    - {t.inputBox.reasoningModeDescription} -
    -
    - {context.mode === "thinking" ? ( - - ) : ( -
    - )} - - )} - handleModeSelect("pro")} - > -
    -
    - - {t.inputBox.proMode} -
    -
    - {t.inputBox.proModeDescription} -
    -
    - {context.mode === "pro" ? ( - - ) : ( -
    - )} - - handleModeSelect("ultra")} - > -
    -
    - -
    - {t.inputBox.ultraMode} -
    -
    -
    - {t.inputBox.ultraModeDescription} -
    -
    - {context.mode === "ultra" ? ( - - ) : ( -
    - )} - - - - - - {supportReasoningEffort && context.mode !== "flash" && ( - - -
    - {t.inputBox.reasoningEffort}: - {context.reasoning_effort === "minimal" && - " " + t.inputBox.reasoningEffortMinimal} - {context.reasoning_effort === "low" && - " " + t.inputBox.reasoningEffortLow} - {context.reasoning_effort === "medium" && - " " + t.inputBox.reasoningEffortMedium} - {context.reasoning_effort === "high" && - " " + t.inputBox.reasoningEffortHigh} -
    -
    - - - - {t.inputBox.reasoningEffort} - - - handleReasoningEffortSelect("minimal")} - > -
    -
    - {t.inputBox.reasoningEffortMinimal} -
    -
    - {t.inputBox.reasoningEffortMinimalDescription} -
    -
    - {context.reasoning_effort === "minimal" ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("low")} - > -
    -
    - {t.inputBox.reasoningEffortLow} -
    -
    - {t.inputBox.reasoningEffortLowDescription} -
    -
    - {context.reasoning_effort === "low" ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("medium")} - > -
    -
    - {t.inputBox.reasoningEffortMedium} -
    -
    - {t.inputBox.reasoningEffortMediumDescription} -
    -
    - {context.reasoning_effort === "medium" || - !context.reasoning_effort ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("high")} - > -
    -
    - {t.inputBox.reasoningEffortHigh} -
    -
    - {t.inputBox.reasoningEffortHighDescription} -
    -
    - {context.reasoning_effort === "high" ? ( - - ) : ( -
    - )} - - - - - - )} - - - {agentSelector && ( - - )} - {promptPrefix && ( - - - - {promptPrefix.label} - - {promptPrefix.on && ( - - )} - - - )} - - - -
    - - {selectedModel?.display_name} - -
    -
    -
    - - - - {models.map((m) => ( - handleModelSelect(m.name)} - > -
    - {m.display_name} - - {m.model} - -
    - {m.name === context.model_name ? ( - - ) : ( -
    - )} - - ))} - - - - - - - {!isNewThread && ( -
    - )} - -
    -
    - - - - - {t.inputBox.followupConfirmTitle} - - {t.inputBox.followupConfirmDescription} - - - - - - - - - -
    - ); -} - -function SuggestionList() { - const { t } = useI18n(); - const { textInput } = usePromptInputController(); - const handleSuggestionClick = useCallback( - (prompt: string | undefined) => { - if (!prompt) return; - textInput.setInput(prompt); - setTimeout(() => { - const textarea = document.querySelector( - "textarea[name='message']", - ); - if (textarea) { - const selStart = prompt.indexOf("["); - const selEnd = prompt.indexOf("]"); - if (selStart !== -1 && selEnd !== -1) { - textarea.setSelectionRange(selStart, selEnd + 1); - textarea.focus(); - } - } - }, 500); - }, - [textInput], - ); - return ( - - handleSuggestionClick(t.inputBox.surpriseMePrompt)} - > - {t.inputBox.surpriseMe} - - {t.inputBox.suggestions.map((suggestion) => ( - handleSuggestionClick(suggestion.prompt)} - /> - ))} - - - - - - - {t.inputBox.suggestionsCreate.map((suggestion, index) => - "type" in suggestion && suggestion.type === "separator" ? ( - - ) : ( - !("type" in suggestion) && ( - handleSuggestionClick(suggestion.prompt)} - > - {suggestion.icon && } - {suggestion.suggestion} - - ) - ), - )} - - - - - ); -} - -function AddAttachmentsButton({ className }: { className?: string }) { - const { t } = useI18n(); - const attachments = usePromptInputAttachments(); - return ( - - attachments.openFileDialog()} - > - - - - ); -} +"use client"; + +import type { ChatStatus } from "ai"; +import { + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, + FileTextIcon, + GraduationCapIcon, + LightbulbIcon, + PaperclipIcon, + PlusIcon, + SparklesIcon, + RocketIcon, + XIcon, + ZapIcon, +} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; + +import { + PromptInput, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuItem, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + usePromptInputController, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { ConfettiButton } from "@/components/ui/confetti-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; +import { useI18n } from "@/core/i18n/hooks"; +import { useModels } from "@/core/models/hooks"; +import type { AgentThreadContext } from "@/core/threads"; +import { textOfMessage } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +import { AgentSelector } from "@/components/workspace/agents/agent-selector"; + +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorName, + ModelSelectorTrigger, +} from "../ai-elements/model-selector"; +import { Suggestion, Suggestions } from "../ai-elements/suggestion"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; + +import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { useUserPreferences } from "@/core/user-preferences"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; + + +import { CustomPromptBar } from "./custom-prompt-menu"; +import { useThread } from "./messages/context"; +import { ModeHoverGuide } from "./mode-hover-guide"; +import { Tooltip } from "./tooltip"; + +type InputMode = "flash" | "thinking" | "pro" | "ultra"; + + +function getResolvedMode( + mode: InputMode | undefined, + supportsThinking: boolean, +): InputMode { + if (!supportsThinking && mode !== "flash") { + return "flash"; + } + if (mode) { + return mode; + } + return supportsThinking ? "pro" : "flash"; +} + +export function InputBox({ + className, + disabled, + autoFocus, + status = "ready", + context, + extraHeader, + isNewThread, + threadId, + initialValue, + onContextChange, + onFollowupsVisibilityChange, + onSubmit, + onStop, + promptPrefix, + agentSelector, + assistantId, + hideAdminRecommendedQuestions, + compact, + ...props +}: Omit, "onSubmit"> & { + assistantId?: string | null; + status?: ChatStatus; + /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, + * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ + compact?: boolean; + disabled?: boolean; + context: Omit< + AgentThreadContext, + "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" + > & { + mode: "flash" | "thinking" | "pro" | "ultra" | undefined; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + writing_mode?: boolean; + memory_injection_enabled?: boolean; + }; + extraHeader?: React.ReactNode; + isNewThread?: boolean; + /** Suppress the global admin "recommended questions" row. The agent chat page + * sets this so only the agent's own common questions surface on its home. */ + hideAdminRecommendedQuestions?: boolean; + threadId: string; + initialValue?: string; + onContextChange?: ( + context: Omit< + AgentThreadContext, + "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" + > & { + mode: "flash" | "thinking" | "pro" | "ultra" | undefined; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + writing_mode?: boolean; + memory_injection_enabled?: boolean; + }, + ) => void; + onFollowupsVisibilityChange?: (visible: boolean) => void; + onSubmit?: (message: PromptInputMessage) => void; + onStop?: () => void; + /** + * Optional toggle rendered next to the model selector. Used for the + * admin-configurable prompt-prefix switch on the chat input. Omitting the + * prop hides the button entirely. + */ + promptPrefix?: { + label: string; + on: boolean; + onToggle: () => void; + }; + /** + * Optional agent picker shown alongside the model selector. Only the new-chat + * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the + * thread is already bound to one agent. + */ + agentSelector?: { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + }; +}) { + const { t } = useI18n(); + const [localSettings, setLocalSettings] = useLocalSettings(); + const searchParams = useSearchParams(); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const { models } = useModels(); + const { thread, isMock } = useThread(); + const { textInput } = usePromptInputController(); + const promptRootRef = useRef(null); + + const [followups, setFollowups] = useState([]); + const [followupsHidden, setFollowupsHidden] = useState(false); + const [followupsLoading, setFollowupsLoading] = useState(false); + const lastGeneratedForAiIdRef = useRef(null); + const wasStreamingRef = useRef(false); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingSuggestion, setPendingSuggestion] = useState( + null, + ); + + const { questions: recommendedQuestions } = useRecommendedQuestions(); + const userPreferences = useUserPreferences(); + const showAdminRecommendedQuestions = + !hideAdminRecommendedQuestions && + !userPreferences.hide_admin_prompt_prefix && + recommendedQuestions.length > 0; + + useEffect(() => { + if (models.length === 0) { + return; + } + const currentModel = models.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? models[0]!; + const supportsThinking = fallbackModel.supports_thinking ?? false; + const nextModelName = fallbackModel.name; + const nextMode = getResolvedMode(context.mode, supportsThinking); + + if (context.model_name === nextModelName && context.mode === nextMode) { + return; + } + + onContextChange?.({ + ...context, + model_name: nextModelName, + mode: nextMode, + }); + }, [context, models, onContextChange]); + + const selectedModel = useMemo(() => { + if (models.length === 0) { + return undefined; + } + return models.find((m) => m.name === context.model_name) ?? models[0]; + }, [context.model_name, models]); + + const resolvedModelName = selectedModel?.name; + + const supportThinking = useMemo( + () => selectedModel?.supports_thinking ?? false, + [selectedModel], + ); + + const supportReasoningEffort = useMemo( + () => selectedModel?.supports_reasoning_effort ?? false, + [selectedModel], + ); + + const handleModelSelect = useCallback( + (model_name: string) => { + const model = models.find((m) => m.name === model_name); + if (!model) { + return; + } + onContextChange?.({ + ...context, + model_name, + mode: getResolvedMode(context.mode, model.supports_thinking ?? false), + reasoning_effort: context.reasoning_effort, + }); + setModelDialogOpen(false); + }, + [onContextChange, context, models], + ); + + const handleModeSelect = useCallback( + (mode: InputMode) => { + onContextChange?.({ + ...context, + mode: getResolvedMode(mode, supportThinking), + reasoning_effort: + mode === "ultra" + ? "high" + : mode === "pro" + ? "medium" + : mode === "thinking" + ? "low" + : "minimal", + }); + }, + [onContextChange, context, supportThinking], + ); + + const handleReasoningEffortSelect = useCallback( + (effort: "minimal" | "low" | "medium" | "high") => { + onContextChange?.({ + ...context, + reasoning_effort: effort, + }); + }, + [onContextChange, context], + ); + + const handleWritingModeToggle = useCallback(() => { + onContextChange?.({ + ...context, + writing_mode: !context.writing_mode, + }); + }, [context, onContextChange]); + + const memoryInjectionEnabled = context.memory_injection_enabled ?? true; + const handleMemoryInjectionToggle = useCallback(() => { + onContextChange?.({ + ...context, + memory_injection_enabled: !memoryInjectionEnabled, + }); + }, [context, memoryInjectionEnabled, onContextChange]); + + const ragModeLocked = isThreadRagModeLocked(threadId); + const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const handleRagModeToggle = useCallback(() => { + if (ragModeLocked) { + return; + } + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, ragModeLocked, setLocalSettings]); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + if (status === "streaming") { + onStop?.(); + return; + } + if (!message.text) { + return; + } + setFollowups([]); + setFollowupsHidden(false); + setFollowupsLoading(false); + if (ragModeEnabled) { + lockThreadRagMode(threadId); + } + + // Guard against submitting before the initial model auto-selection + // effect has flushed thread settings to storage/state. + if (resolvedModelName && context.model_name !== resolvedModelName) { + onContextChange?.({ + ...context, + model_name: resolvedModelName, + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + }); + setTimeout(() => onSubmit?.(message), 0); + return; + } + + onSubmit?.(message); + }, + [ + context, + onContextChange, + onSubmit, + onStop, + resolvedModelName, + ragModeEnabled, + selectedModel?.supports_thinking, + status, + threadId, + ], + ); + + const requestFormSubmit = useCallback(() => { + const form = promptRootRef.current?.querySelector("form"); + form?.requestSubmit(); + }, []); + + const handleFollowupClick = useCallback( + (suggestion: string) => { + if (status === "streaming") { + return; + } + const current = (textInput.value ?? "").trim(); + if (current) { + setPendingSuggestion(suggestion); + setConfirmOpen(true); + return; + } + textInput.setInput(suggestion); + setFollowupsHidden(true); + setTimeout(() => requestFormSubmit(), 0); + }, + [requestFormSubmit, status, textInput], + ); + + const confirmReplaceAndSend = useCallback(() => { + if (!pendingSuggestion) { + setConfirmOpen(false); + return; + } + textInput.setInput(pendingSuggestion); + setFollowupsHidden(true); + setConfirmOpen(false); + setPendingSuggestion(null); + setTimeout(() => requestFormSubmit(), 0); + }, [pendingSuggestion, requestFormSubmit, textInput]); + + const confirmAppendAndSend = useCallback(() => { + if (!pendingSuggestion) { + setConfirmOpen(false); + return; + } + const current = (textInput.value ?? "").trim(); + const next = current + ? `${current}\n${pendingSuggestion}` + : pendingSuggestion; + textInput.setInput(next); + setFollowupsHidden(true); + setConfirmOpen(false); + setPendingSuggestion(null); + setTimeout(() => requestFormSubmit(), 0); + }, [pendingSuggestion, requestFormSubmit, textInput]); + + const showFollowups = + !disabled && + !isNewThread && + !followupsHidden && + (followupsLoading || followups.length > 0); + + const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); + + useEffect(() => { + followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; + }, [onFollowupsVisibilityChange]); + + useEffect(() => { + followupsVisibilityChangeRef.current?.(showFollowups); + }, [showFollowups]); + + useEffect(() => { + return () => followupsVisibilityChangeRef.current?.(false); + }, []); + + useEffect(() => { + const streaming = status === "streaming"; + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = streaming; + if (!wasStreaming || streaming) { + return; + } + + if (disabled || isMock) { + return; + } + + const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); + const lastAiId = lastAi?.id ?? null; + if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { + return; + } + lastGeneratedForAiIdRef.current = lastAiId; + + const recent = thread.messages + .filter((m) => m.type === "human" || m.type === "ai") + .map((m) => { + const role = m.type === "human" ? "user" : "assistant"; + const content = textOfMessage(m) ?? ""; + return { role, content }; + }) + .filter((m) => m.content.trim().length > 0) + .slice(-6); + + if (recent.length === 0) { + return; + } + + const controller = new AbortController(); + setFollowupsHidden(false); + setFollowupsLoading(true); + setFollowups([]); + + apiFetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: recent, + n: 3, + model_name: context.model_name ?? undefined, + }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + return { suggestions: [] as string[] }; + } + return (await res.json()) as { suggestions?: string[] }; + }) + .then((data) => { + const suggestions = (data.suggestions ?? []) + .map((s) => (typeof s === "string" ? s.trim() : "")) + .filter((s) => s.length > 0) + .slice(0, 5); + setFollowups(suggestions); + }) + .catch(() => { + setFollowups([]); + }) + .finally(() => { + setFollowupsLoading(false); + }); + + return () => controller.abort(); + }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); + + return ( +
    + {extraHeader && ( +
    + {extraHeader} +
    + )} + + {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( +
    + {compact && ( +
    + + 试试这些高频业务场景 +
    + )} +
    + {recommendedQuestions.map((q) => ( + + ))} +
    +
    + )} + + {showFollowups && ( +
    +
    + {followupsLoading ? ( +
    + {t.inputBox.followupLoading} +
    + ) : ( + + {followups.map((s) => ( + handleFollowupClick(s)} + /> + ))} + + + )} +
    +
    + )} +
    + {isNewThread && ( + { + const current = textInput.value?.trim() ?? ""; + textInput.setInput(current ? `${current}\n${content}` : content); + setTimeout(() => { + document + .querySelector("textarea[name='message']") + ?.focus(); + }, 0); + }} + /> + )} +
    + + + {(attachment) => } + + + + + + + {/* TODO: Add more connectors here + + + + + + */} + + + + + 写作 + + + + + + 记忆 + + + + + {ragModeEnabled && ( + + )} + + 参考文献 + + + + + +
    + {context.mode === "flash" && } + {context.mode === "thinking" && ( + + )} + {context.mode === "pro" && ( + + )} + {context.mode === "ultra" && ( + + )} +
    +
    + {(context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && + t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
    +
    +
    + + + + {t.inputBox.mode} + + + handleModeSelect("flash")} + > +
    +
    + + {t.inputBox.flashMode} +
    +
    + {t.inputBox.flashModeDescription} +
    +
    + {context.mode === "flash" ? ( + + ) : ( +
    + )} + + {supportThinking && ( + handleModeSelect("thinking")} + > +
    +
    + + {t.inputBox.reasoningMode} +
    +
    + {t.inputBox.reasoningModeDescription} +
    +
    + {context.mode === "thinking" ? ( + + ) : ( +
    + )} + + )} + handleModeSelect("pro")} + > +
    +
    + + {t.inputBox.proMode} +
    +
    + {t.inputBox.proModeDescription} +
    +
    + {context.mode === "pro" ? ( + + ) : ( +
    + )} + + handleModeSelect("ultra")} + > +
    +
    + +
    + {t.inputBox.ultraMode} +
    +
    +
    + {t.inputBox.ultraModeDescription} +
    +
    + {context.mode === "ultra" ? ( + + ) : ( +
    + )} + + + + + + {supportReasoningEffort && context.mode !== "flash" && ( + + +
    + {t.inputBox.reasoningEffort}: + {context.reasoning_effort === "minimal" && + " " + t.inputBox.reasoningEffortMinimal} + {context.reasoning_effort === "low" && + " " + t.inputBox.reasoningEffortLow} + {context.reasoning_effort === "medium" && + " " + t.inputBox.reasoningEffortMedium} + {context.reasoning_effort === "high" && + " " + t.inputBox.reasoningEffortHigh} +
    +
    + + + + {t.inputBox.reasoningEffort} + + + handleReasoningEffortSelect("minimal")} + > +
    +
    + {t.inputBox.reasoningEffortMinimal} +
    +
    + {t.inputBox.reasoningEffortMinimalDescription} +
    +
    + {context.reasoning_effort === "minimal" ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("low")} + > +
    +
    + {t.inputBox.reasoningEffortLow} +
    +
    + {t.inputBox.reasoningEffortLowDescription} +
    +
    + {context.reasoning_effort === "low" ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("medium")} + > +
    +
    + {t.inputBox.reasoningEffortMedium} +
    +
    + {t.inputBox.reasoningEffortMediumDescription} +
    +
    + {context.reasoning_effort === "medium" || + !context.reasoning_effort ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("high")} + > +
    +
    + {t.inputBox.reasoningEffortHigh} +
    +
    + {t.inputBox.reasoningEffortHighDescription} +
    +
    + {context.reasoning_effort === "high" ? ( + + ) : ( +
    + )} + + + + + + )} + + + {agentSelector && ( + + )} + {promptPrefix && ( + + + + {promptPrefix.label} + + {promptPrefix.on && ( + + )} + + + )} + + + +
    + + {selectedModel?.display_name} + +
    +
    +
    + + + + {models.map((m) => ( + handleModelSelect(m.name)} + > +
    + {m.display_name} + + {m.model} + +
    + {m.name === context.model_name ? ( + + ) : ( +
    + )} + + ))} + + + + + + + {!isNewThread && ( +
    + )} + +
    +
    + + + + + {t.inputBox.followupConfirmTitle} + + {t.inputBox.followupConfirmDescription} + + + + + + + + + +
    + ); +} + +function SuggestionList() { + const { t } = useI18n(); + const { textInput } = usePromptInputController(); + const handleSuggestionClick = useCallback( + (prompt: string | undefined) => { + if (!prompt) return; + textInput.setInput(prompt); + setTimeout(() => { + const textarea = document.querySelector( + "textarea[name='message']", + ); + if (textarea) { + const selStart = prompt.indexOf("["); + const selEnd = prompt.indexOf("]"); + if (selStart !== -1 && selEnd !== -1) { + textarea.setSelectionRange(selStart, selEnd + 1); + textarea.focus(); + } + } + }, 500); + }, + [textInput], + ); + return ( + + handleSuggestionClick(t.inputBox.surpriseMePrompt)} + > + {t.inputBox.surpriseMe} + + {t.inputBox.suggestions.map((suggestion) => ( + handleSuggestionClick(suggestion.prompt)} + /> + ))} + + + + + + + {t.inputBox.suggestionsCreate.map((suggestion, index) => + "type" in suggestion && suggestion.type === "separator" ? ( + + ) : ( + !("type" in suggestion) && ( + handleSuggestionClick(suggestion.prompt)} + > + {suggestion.icon && } + {suggestion.suggestion} + + ) + ), + )} + + + + + ); +} + +function AddAttachmentsButton({ className }: { className?: string }) { + const { t } = useI18n(); + const attachments = usePromptInputAttachments(); + return ( + + attachments.openFileDialog()} + > + + + + ); +} diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 6ff8ec19..9b7badbf 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -756,6 +756,9 @@ const WRITING_MODE_LABELS: Record = { loose: "可结合通用知识", }; const MATERIAL_SOURCE_LABELS: Record = { + general: "通用检索", + notebook: "我的空间", + // 旧值(历史会话回显兼容) knowledge_base: "知识库", skill: "技能检索", }; diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 46026eb8..457c8806 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -5,6 +5,7 @@ import { Download, FileJson, FileText, + MessageSquare, MoreHorizontal, Pencil, Pin, @@ -71,18 +72,22 @@ import { import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; +import { cn } from "@/lib/utils"; -export function RecentChatList() { +export function RecentChatList({ compact = false }: { compact?: boolean }) { const { t } = useI18n(); const router = useRouter(); const pathname = usePathname(); - const [collapsed, setCollapsed] = useState( + const [internalCollapsed, setInternalCollapsed] = useState( () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true", ); + // 紧凑模式下整列由 WorkspaceSidebar 统一收起,列表本身始终展开、不显示标题折叠钮。 + const collapsed = compact ? false : internalCollapsed; const toggleCollapsed = () => { - const next = !collapsed; - setCollapsed(next); + if (compact) return; + const next = !internalCollapsed; + setInternalCollapsed(next); localStorage.setItem("sidebar:recent-chats-collapsed", String(next)); }; const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = @@ -189,17 +194,29 @@ export function RecentChatList() { <> - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" - ? t.sidebar.recentChats - : t.sidebar.demoChats} + + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" + ? t.sidebar.recentChats + : t.sidebar.demoChats} + + {compact && ( + + {visibleThreads.length} + + )} - + {!compact && ( + + )} + {compact && ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 3d24c3a2..83c984c5 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -1,7 +1,8 @@ "use client"; -import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react"; +import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react"; import { useMemo, type ComponentType, type SVGProps } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; @@ -42,9 +43,10 @@ const languageOptions: { value: Locale; label: string }[] = [ { value: "zh-CN", label: zhCN.locale.localName }, ]; -export function AppearanceSettingsPage() { +export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const { t, locale, changeLocale } = useI18n(); const { theme, setTheme } = useTheme(); + const navigate = useNavigate(); const currentTheme = theme; const [settings, setSettings] = useLocalSettings(); const isAdmin = getStoredAuth()?.system_role === "admin"; @@ -58,15 +60,15 @@ export function AppearanceSettingsPage() { const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; - const handleSetGlobalDefault = async (value: "light" | "dark") => { + const handleSetGlobalDefault = async ( + value: "light" | "dark" | "lightblue", + ) => { if (value === globalDefault) return; try { await updateAppearance.mutateAsync({ default_theme: value }); - toast.success( - value === "light" - ? "已将系统默认主题设置为浅色" - : "已将系统默认主题设置为深色", - ); + const label = + value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝"; + toast.success(`已将系统默认主题设置为${label}`); } catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); } @@ -107,12 +109,20 @@ export function AppearanceSettingsPage() { description: t.settings.appearance.darkDescription, icon: MoonIcon, }, + { + id: "lightblue", + label: t.settings.appearance.lightblue, + description: t.settings.appearance.lightblueDescription, + icon: DropletIcon, + }, ], [ t.settings.appearance.dark, t.settings.appearance.darkDescription, t.settings.appearance.light, t.settings.appearance.lightDescription, + t.settings.appearance.lightblue, + t.settings.appearance.lightblueDescription, ], ); @@ -130,13 +140,49 @@ export function AppearanceSettingsPage() { label={option.label} description={option.description} active={currentTheme === option.id} - mode={option.id as "light" | "dark"} + mode={option.id as "light" | "dark" | "lightblue"} onSelect={(value) => setTheme(value)} /> ))}
    + + + +
    + + +
    +
    + {isAdmin && ( <> @@ -165,9 +211,23 @@ export function AppearanceSettingsPage() { 深色 +

    当前系统默认: - {globalDefault === "light" ? "浅色" : "深色"} + {globalDefault === "light" + ? "浅色" + : globalDefault === "dark" + ? "深色" + : "浅蓝"}

    @@ -370,8 +430,8 @@ function ThemePreviewCard({ label: string; description: string; active: boolean; - mode: "light" | "dark"; - onSelect: (mode: "light" | "dark") => void; + mode: "light" | "dark" | "lightblue"; + onSelect: (mode: "light" | "dark" | "lightblue") => void; }) { const previewMode = mode; return ( @@ -401,14 +461,20 @@ function ThemePreviewCard({ "relative overflow-hidden rounded-md border text-xs transition-colors", previewMode === "dark" ? "border-neutral-800 bg-neutral-900 text-neutral-200" - : "border-slate-200 bg-white text-slate-900", + : previewMode === "lightblue" + ? "border-blue-200 bg-white text-slate-900" + : "border-slate-200 bg-white text-slate-900", )} >
    diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index ea5cc6a6..00f587dd 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -121,7 +121,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
    {activeSection === "account" && } - {activeSection === "appearance" && } + {activeSection === "appearance" && ( + props.onOpenChange?.(false)} /> + )} {activeSection === "memory" && } {activeSection === "tools" && } {activeSection === "about" && } diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 28b50d63..08433495 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; -import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -33,10 +33,19 @@ const NEW_CHAT_CLASS = "data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " + "dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25"; +// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标 +const NEW_CHAT_COMPACT_CLASS = + "h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " + + "border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " + + "hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " + + "data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none"; + export function WorkspaceNavChatList({ showCollapseTrigger = false, + compact = false, }: { showCollapseTrigger?: boolean; + compact?: boolean; }) { const { t } = useI18n(); const pathname = usePathname(); @@ -60,21 +69,46 @@ export function WorkspaceNavChatList({ -
    - - - - {t.sidebar.newChat} - - - {showCollapseTrigger && ( - - )} -
    + {compact ? ( + // 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。 +
    + + + + {t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} +
    + ) : ( +
    + + + + {t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} +
    + )}
    {!showCollapseTrigger && ( <> diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index dc1e097b..a721a9c9 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -27,11 +27,47 @@ export function WorkspaceSidebar({ const { theme } = useTheme(); const useWhiteSidebar = compactChrome && theme === "light"; + // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 + // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 + // sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 + // --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。 + if (compactChrome) { + return ( +
    + + + + + + + +
    + ); + } + return ( <> )} - - {isSidebarOpen && } - {isSidebarOpen && } - {isSidebarOpen && } + + {isSidebarOpen && } + {/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: + 仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} + {isSidebarOpen && !compactChrome && } + {isSidebarOpen && !compactChrome && } {!compactChrome && ( diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index a085beb8..0c7fc748 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes'; import { useAppearanceSettings } from '@/core/system-settings/hooks'; // 主题类型 -export type ThemeType = 'dark' | 'light'; +export type ThemeType = 'dark' | 'light' | 'lightblue'; // 主题配置 export interface ThemeConfig { @@ -82,6 +82,28 @@ const themes: Record = { overlay: 'black/30', }, }, + lightblue: { + type: 'lightblue', + name: '浅蓝主题', + colors: { + background: 'white', + backgroundSecondary: 'blue-50', + text: 'gray-800', + textSecondary: 'gray-500', + primary: 'blue-600', + primaryHover: 'blue-500', + border: 'blue-200', + accent: 'blue-700', + success: 'emerald-600', + warning: 'amber-500', + error: 'red-600', + card: 'white', + cardHover: 'blue-50', + input: 'white', + inputBorder: 'blue-200', + overlay: 'black/30', + }, + }, }; const STORAGE_KEY = 'strategy-theme'; @@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType { return 'light'; } const saved = localStorage.getItem(STORAGE_KEY); - return saved === 'dark' ? 'dark' : 'light'; + if (saved === 'dark') return 'dark'; + if (saved === 'lightblue') return 'lightblue'; + return 'light'; } function hasExplicitChoice(): boolean { @@ -151,7 +175,12 @@ export const ThemeProvider: React.FC = ({ children }) => { const current = localStorage.getItem(STORAGE_KEY); const legacy = localStorage.getItem('theme'); - if (current && current !== 'light' && current !== 'dark') { + if ( + current && + current !== 'light' && + current !== 'dark' && + current !== 'lightblue' + ) { // Garbage value — wipe it so the boot-sync below can take over. localStorage.removeItem(STORAGE_KEY); } @@ -185,7 +214,9 @@ export const ThemeProvider: React.FC = ({ children }) => { const theme: ThemeType = mounted ? effectiveTheme === 'dark' ? 'dark' - : 'light' + : effectiveTheme === 'lightblue' + ? 'lightblue' + : 'light' : readPersistedTheme(); useEffect(() => { diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 27f29252..a580c48c 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error { export interface ListAgentsParams { search?: string; tagIds?: string[]; - /** Pagination scope. Omit for the legacy combined-visibility list. */ - scope?: "mine" | "square"; + /** Pagination scope. Omit for the legacy combined-visibility list. + * "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */ + scope?: "mine" | "square" | "builtin"; /** 1-based page index. Providing this (or scope) enables server-side pagination. */ page?: number; pageSize?: number; diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index c7ee5517..46e70ca0 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) { /** Server-side paginated agent listing for one gallery tab (scope). Returns * the page of agents plus the total count for the "共 N 个" header. */ export function useAgentsPaginated(params: { - scope: "mine" | "square"; + scope: "mine" | "square" | "builtin"; search?: string; tagIds?: string[]; sort?: AgentSort; diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 95923851..88cf4259 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -44,6 +44,37 @@ export function clearStoredAuth() { window.localStorage.removeItem(AUTH_STORAGE_KEY); } +/** + * 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。 + * 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。 + */ +export function setStoredUserName(name: string | null | undefined) { + if (!isBrowser()) { + return; + } + const trimmed = name?.trim(); + if (!trimmed) { + return; + } + let info: Record = {}; + try { + const raw = window.localStorage.getItem("userInfo"); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + info = parsed as Record; + } + } + } catch { + info = {}; + } + if (typeof info.userName === "string" && info.userName.trim()) { + return; + } + info.userName = trimmed; + window.localStorage.setItem("userInfo", JSON.stringify(info)); +} + export function getAccessToken(): string | undefined { const auth = getStoredAuth(); const token = auth?.access_token?.trim(); diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 0724dbaa..3844d46e 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -448,9 +448,11 @@ export const enUS: Translations = { system: "System", light: "Light", dark: "Dark", + lightblue: "Light Blue", systemDescription: "Match the operating system preference automatically.", lightDescription: "Bright palette with higher contrast for daytime.", darkDescription: "Dim palette that reduces glare for focus.", + lightblueDescription: "A crisp light palette built around blue.", languageTitle: "Language", languageDescription: "Switch between languages.", }, diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 4c695f81..6f21b24a 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -383,9 +383,11 @@ export interface Translations { system: string; light: string; dark: string; + lightblue: string; systemDescription: string; lightDescription: string; darkDescription: string; + lightblueDescription: string; languageTitle: string; languageDescription: string; }; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 64b22bca..df437800 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -429,9 +429,11 @@ export const zhCN: Translations = { system: "系统", light: "浅色", dark: "深色", + lightblue: "浅蓝", systemDescription: "自动跟随系统主题。", lightDescription: "更明亮的配色,适合日间使用。", darkDescription: "更暗的配色,减少眩光方便专注。", + lightblueDescription: "以蓝色为主色的清爽浅色配色。", languageTitle: "语言", languageDescription: "在不同语言之间切换。", }, diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index 1c7555ac..cb78eba0 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -62,6 +62,11 @@ export interface LocalSettings { */ skillViewMode?: "card" | "list"; agentViewMode?: "card" | "list"; + /** + * Layout mode: "full" shows the page sidebar + header; "compact" hides them + * for a cleaner work area. Defaults to "full" (undefined treated as "full"). + */ + layoutMode?: "full" | "compact"; }; context: Omit< AgentThreadContext, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 048a424d..864801f1 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -135,7 +135,7 @@ export async function updatePublishSquares( return (await response.json()) as PublishSquaresSettings; } -export type ThemeMode = "light" | "dark"; +export type ThemeMode = "light" | "dark" | "lightblue"; export type ViewMode = "card" | "list"; export interface AppearanceSettings { diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 0d0c313e..3778cc98 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -5,7 +5,6 @@ */ export const DESENSITIZE_MAP: Record = { 态势: "TS", - 你好: "nh", 滨海作战体系: "第一张网", 滨海作战部队: "第一张网", 'Taiwan Air Force Flight Units': "第二张网", 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..e6ca5d91 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -39,16 +39,15 @@ 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: '从空间中检索素材' }, ]; -const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]'; +const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]'; const FIELD_TRIGGER_CLASS = 'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm'; @@ -279,14 +278,14 @@ export function AIWritingForm({
    - +