diff --git a/CLAUDE.md b/CLAUDE.md index c054b1d8..7dba5283 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` -- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks) +- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) +- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` diff --git a/frontend-web/.tmp-tests/flow-graph.test.mjs b/frontend-web/.tmp-tests/flow-graph.test.mjs new file mode 100644 index 00000000..289bea27 --- /dev/null +++ b/frontend-web/.tmp-tests/flow-graph.test.mjs @@ -0,0 +1,319 @@ +// 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 new file mode 100644 index 00000000..2cb9dc0b --- /dev/null +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -0,0 +1,225 @@ +# AI 写作「四阶段内置智能体化」改造方案 + +> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能, +> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动—— +> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。 + +本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。 + +--- + +## 0. 一句话结论 + +不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。 + +--- + +## 1. 现状梳理(精确到文件) + +AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py` + +``` +intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认) + → writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done +``` + +四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时): + +| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) | +|---|---|---|---| +| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) | +| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) | +| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) | +| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) | + +关键事实: +- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。 +- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。 +- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。 +- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。 + +--- + +## 2. 可复用的现成机制(不用造轮子) + +代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`: + +1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets//` + - `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`) + - `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**) +2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents` + - 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents//` + DB(**已存在则永不覆盖**,尊重本地编辑)。 +3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。 +4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。 + +> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。 + +另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。 + +--- + +## 3. 目标设计 + +### 3.1 四个内置写作智能体(建议 id / 职责) + +| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema | +|---|---|---|---|---| +| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` | +| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` | +| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown | +| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` | + +每个 agent 自带: +- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。 +- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。 + +### 3.2 节点改造方式(核心) + +每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从: + +``` +# 旧:硬编码 prompt 直调 +model = get_model(config, state["model_name"]) +result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...)) +``` + +改为: + +``` +# 新:跑对应内置 agent,按 marker 解析 +result = await run_writing_agent( + agent_id="ai-writing-outliner", + state=state, config=config, + user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入 + marker="[OUTLINE_READY]", # 结构化输出契约 +) +``` + +新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。 + +### 3.3 为什么选这个方案(A)而非全多智能体重写(B) + +- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。 +- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。 + +--- + +## 4. 配置与可管理性 + +- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。 +- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。 +- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。 +- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。 + +--- + +## 5. 分步实现计划(每步可独立交付 / 验收) + +### Phase 0 · 契约冻结 +- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。 +- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。 + +### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程) +- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。 +- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。 +- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。 + +### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度) +- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。 +- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。 +- **接入顺序(由易到难)**: + 1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。 + 2. **outliner**(输出 Outline JSON)。 + 3. **researcher**(保留技能检索步骤事件、素材确认暂停)。 + 4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。 +- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。 +- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。 + +### Phase 3 · 每阶段 agent 配置化 +- per-agent 模型覆盖生效(`agent.config.model > model_name`)。 +- researcher 的检索能力改为挂载技能,admin 可增减。 +- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。 +- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。 + +### Phase 4 · 前端适配(小改) +- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。 +- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。 +- **验收**:前端展示各阶段对应的智能体名;配置入口可达。 + +### Phase 5 · 清理收口 +- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。 +- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。 +- **验收**:回归测试通过、冗余删除、文档同步。 + +--- + +## 6. 结构化输出契约(与现有 state 对齐) + +各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`: + +- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。 +- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。 +- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。 +- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。 + +解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。 + +--- + +## 7. 风险与注意 + +1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。 +2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。 +3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。 +4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents//` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。 +5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。 +6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。 + +--- + +## 8. 涉及文件清单(落地时对照) + +**后端(主要改动)** +- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` +- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`) +- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`) +- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一) +- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型) +- 保留 `prompts/*.py`(回退用,Phase 5 删) + +**前端(小改)** +- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name) +- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页) + +**文档** +- 本文件 +- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md` + +--- + +## 9. 实施记录(2026-06-11,Phase 1-4 已落地) + +### 已交付 + +- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。 +- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。 +- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。 +- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。 +- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。 + +### 与 §6 契约的修订(以实现为准) + +为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**: + +| Agent | 实际契约 | +|---|---| +| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 | +| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) | +| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) | +| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) | + +另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。 + +### 已知存量测试失败(与本次无关) + +`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 文案),改造前即失败,待另行清理。 diff --git a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md deleted file mode 100644 index ea9c4d50..00000000 --- a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md +++ /dev/null @@ -1,228 +0,0 @@ -# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单) - -> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。 - ---- - -## 一、功能概览 - -| 交互 | 用户感知 | 组件 | 触发方式 | -|------|----------|------|----------| -| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` | -| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` | -| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 | - -画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。 - ---- - -## 二、在 AI 写作中的挂载路径 - -```mermaid -flowchart TB - AIWritingPage --> CanvasResizableLayout - CanvasResizableLayout --> AIWritingDraftPanel - AIWritingDraftPanel --> DraftStreamingPreview - AIWritingDraftPanel --> TextArtifactRenderer - DraftStreamingPreview["DraftStreamingPreview
(流式只读 Markdown)"] - TextArtifactRenderer --> WritingBubbleMenu - TextArtifactRenderer --> WritingParagraphMenu - TextArtifactRenderer --> TiptapEditor - TextArtifactRenderer --> WritingAiMenu -``` - -| 阶段 | 右侧展示 | 滑词 / 段落菜单 | -|------|----------|-----------------| -| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** | -| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** | -| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) | - -相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。 - -入口文件: - -- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer` -- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单 - ---- - -## 三、技术栈:编辑器 vs 浮层 - -### 3.1 Tiptap 编辑器(有使用) - -| 包 | 用途 | -|----|------| -| `@tiptap/react` | `useEditor`、`EditorContent` | -| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 | -| `@tiptap/extension-placeholder` | 空文档占位符 | -| `tiptap-markdown` | Markdown ↔ 文档互转 | -| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) | - -扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。 - -### 3.2 浮层菜单(自研,非 Tiptap 官方插件) - -| 包 | 是否用于滑词/段落菜单 | -|----|----------------------| -| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) | -| `@tiptap/react` 的 `` | **未使用** | - -两套菜单均采用: - -1. 监听 Tiptap / DOM 事件更新位置与选区; -2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切; -3. `position: fixed` + `getBoundingClientRect()` 定位。 - -命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。 - ---- - -## 四、核心文件索引 - -| 文件 | 职责 | -|------|------| -| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 | -| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight | -| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 | -| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable | -| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 | -| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 | -| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 | -| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 | -| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 | -| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 | -| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 | -| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 | - ---- - -## 五、滑词:`WritingBubbleMenu` - -### 5.1 行为 - -- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。 -- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。 -- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。 - -### 5.2 实现要点 - -```text -editor.on("selectionUpdate") - → 读取 editor.state.selection (from, to, empty) - → window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标 - → setWritingSelection({ from, to, text }) 写入 CanvasUIContext - → createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方 -``` - -关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。 - -点击「AI 改写」时: - -1. `editor.commands.setAiHighlight(from, to)` 高亮选区; -2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。 - ---- - -## 六、段落菜单:`WritingParagraphMenu` - -### 6.1 行为 - -- 鼠标在编辑器内移动时,检测光标下的**块级节点**; -- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮; -- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。 - -### 6.2 实现要点 - -```text -editor.view.dom mousemove - → editor.view.posAtCoords({ left, top }) - → doc.resolve(pos).before(depth) 定位块起点 - → editor.view.nodeDOM(nodeStart) 取 DOM 矩形 - → createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6) -``` - -点击 ✨ 时: - -1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`); -2. `setAiHighlight` + `setWritingSelection`; -3. 触发与滑词相同的 `onAiRewrite()` 流程。 - -关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。 - ---- - -## 七、AI 改写数据流 - -```mermaid -sequenceDiagram - participant User - participant Menu as BubbleMenu / ParagraphMenu - participant Ctx as CanvasUIContext - participant Renderer as TextArtifactRenderer - participant API as rewrite API - - User->>Menu: 选中文本或点段落 ✨ - Menu->>Ctx: setWritingSelection - Menu->>Renderer: onAiRewrite() - Renderer->>Renderer: phase = menu, WritingAiMenu - User->>Renderer: 选择润色/扩写等 - Renderer->>API: useWritingRewrite.rewrite - API-->>Renderer: 流式结果 - Renderer->>Renderer: WritingRewritePreview - User->>Renderer: 接受改写 - Renderer->>Renderer: applyReplace / applyInsert -``` - -- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。 -- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。 - ---- - -## 八、与顶部工具栏、目录栏的关系 - -同一 `TextArtifactRenderer` 编辑模式下还包含: - -| 组件 | 作用 | -|------|------| -| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 | -| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading | - -滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。 - ---- - -## 九、开发与改造建议 - -### 9.1 若要改用官方 Bubble Menu - -1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`); -2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准); -3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。 - -### 9.2 新增浮层按钮 - -- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。 -- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。 - -### 9.3 流式阶段禁止交互 - -不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。 - ---- - -## 十、相关文档 - -- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表 -- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验) - ---- - -## 十一、依赖版本(摘录) - -以 [package.json](../package.json) 为准,编辑器相关主要为: - -- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4` -- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现 -- `tiptap-markdown`:Markdown 扩展 - -升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。 diff --git a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md deleted file mode 100644 index da73fbeb..00000000 --- a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md +++ /dev/null @@ -1,274 +0,0 @@ -# AI 写作 ·「用户自带大纲」手动测试用例 - -> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。 -> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx) - ---- - -## 一、测试目标 - -验证三件事: -1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。 -2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。 -3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。 - ---- - -## 二、前置条件 - -| 项 | 要求 | -|----|------| -| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 | -| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) | -| 账号 | 已登录(或无鉴权模式) | -| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 | - ---- - -## 三、通用操作步骤 - -除非用例另有说明,每条用例都按以下步骤执行: - -1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。 -2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。 -3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。 -4. 文章类型 / 目标字数 / 素材来源保持默认即可。 -5. 点击「开始 AI 写作」。 -6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。 -7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。 - -> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。 - ---- - -## 四、测试用例 - -### TC-01 Markdown 三级 + 文章标题 - -**输入大纲:** -``` -# 新能源汽车出口为何高速增长 -## 引言 -### 出口数据创新高 -#### 同比增幅 -#### 全球排名变化 -### 市场背景 -## 增长动因 -### 成本优势 -``` - -**预期大纲结构:** -- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节) -- 章节1 `引言` - - 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化` - - 小节 `市场背景`(无子要点) -- 章节2 `增长动因` - - 小节 `成本优势` - -**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。 - ---- - -### TC-02 中文编号三级(一、/(一)/ 1.) - -**输入大纲:** -``` -一、引言 -(一)背景 -(二)意义 -二、现状分析 -(一)市场数据 -1. 国内市场 -2. 海外市场 -``` - -**预期大纲结构:** -- 章节1 `引言` → 小节 `背景`、`意义` -- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场` -- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字) - -**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。 - ---- - -### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1) - -**输入大纲:** -``` -1. 引言 -1.1 研究背景 -1.2 研究意义 -2. 现状 -2.1 行业概况 -``` - -**预期大纲结构:** -- 章节1 `引言` → 小节 `研究背景`、`研究意义` -- 章节2 `现状` → 小节 `行业概况` - ---- - -### TC-04 缩进 bullet 表示层级 - -**输入大纲:** -``` -- 引言 - - 背景 - - 意义 -- 现状 - - 数据 - - 细节展开 -``` - -**预期大纲结构:** -- 章节1 `引言` → 小节 `背景`、`意义` -- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开` - -**校验重点:** 缩进深度正确映射成层级。 - ---- - -### TC-05 只有一级目录(平铺 Markdown) - -**输入大纲:** -``` -## 引言 -## 现状 -## 对策 -## 结论 -``` - -**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。 -**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。 - -**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。 - ---- - -### TC-06 只有一级目录(中文编号) - -**输入大纲:** -``` -一、引言 -二、现状 -三、结论 -``` - -**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。 - ---- - -### TC-07 纯文本平铺(无任何标记) - -**输入大纲:** -``` -引言 -现状 -对策 -总结 -``` - -**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。 - -**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。 - ---- - -### TC-08 格式混乱 / 散文化(走 LLM 兜底) - -**输入大纲:** -``` -我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。 -希望先讲背景,再讲现状,最后给出展望。 -``` - -**预期行为:** -- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。 -- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。 -- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。 - -**校验重点:** 流程不崩、能产出可用大纲。 - ---- - -### TC-09 保真校验(标题原样保留) - -**输入大纲:** -``` -一、A股市场2024年回顾与展望 -(一)宏观经济(GDP/CPI)影响 -(二)流动性与利率 -二、行业轮动主线 -``` - -**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离: -- 章节1 `A股市场2024年回顾与展望` - - 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率` -- 章节2 `行业轮动主线` - -**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。 - ---- - -### TC-10 大纲确认卡里再编辑 / 重新规划 - -**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。 - -**操作:** -1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。 -2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。 - -**预期行为:** -- 手动改后确认:正文按改后的大纲生成。 -- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。 - -**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。 - ---- - -### TC-11 回归:不填大纲(默认流程不受影响) - -**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。 - -**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。 - -**校验重点:** 增量功能不污染老流程。 - ---- - -### TC-12 素材关联(仍走检索) - -**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。 - -**预期行为:** -- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。 -- 关联**只填素材、不改大纲标题与结构**。 - -**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。 - ---- - -## 五、缺陷记录模板 - -| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 | -|------|------------------|------|------|----------|------| -| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | | -| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | | -| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | | -| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | | -| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | | -| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | | -| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | | -| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | | -| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | | -| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | | -| TC-11 | 留空 | 走自动规划 | | ☐ | | -| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | | - ---- - -## 六、备注 - -- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。 -- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。 -- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 7fa0e118..6ead2099 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from "@/components/query-client-provider"; import { AuthSyncWatcher } from "@/core/auth/sync"; import { I18nProvider } from "@/core/i18n/context"; import { detectLocale } from "@/core/i18n/locale"; +import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route"; import { ChatProvider } from "@/contexts/ChatContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { @@ -32,19 +33,15 @@ import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; -// 公开页路由前缀:这些页面会被第三方 iframe 嵌入,主题可由外部指定。 const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"]; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); const publicEmbedTheme = usePublicEmbedTheme(); - // 嵌入路由的主题用 forcedTheme 强制:只影响当前渲染,不写 localStorage。 - // 千万不要在嵌入页里调 ThemeContext.setTheme —— 它会持久化主题并打上 - // 「用户已显式选择」标记。无界子应用与主站同域共享 localStorage,宿主 - // 注入的 theme 会就此覆盖用户在主站自己选的主题(且每次打开宿主都覆盖)。 const forcedTheme = useMemo(() => { - if (location.pathname === "/landing") return "light"; + const routeTheme = resolveForcedThemeForPath(location.pathname); + if (routeTheme) return routeTheme; if (location.pathname === "/embed/roundtable") { const wp = readWujieProps(); const raw = @@ -72,8 +69,6 @@ function AppProviders({ children }: { children: React.ReactNode }) { disableTransitionOnChange forcedTheme={forcedTheme} > - {/* QueryClientProvider must wrap ThemeProvider because the custom - ThemeProvider reads the admin-configured default via useQuery. */} @@ -98,11 +93,8 @@ export function App() { } /> } /> } /> - {/* Public read-only conversation viewer — no authentication. */} } /> - {/* Public read-only user leaderboard publish page — no authentication. */} } /> - {/* Public read-only roundtable share — landing + full result report. */} } @@ -111,7 +103,6 @@ export function App() { path="/roundtable-share/:shareCode/report" element={} /> - {/* Public read-only scheduled-task runs viewer — no authentication. */} } @@ -120,7 +111,6 @@ export function App() { path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen" element={} /> - {/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */} } /> - {/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */} } /> } /> } /> @@ -137,4 +126,4 @@ export function App() { ); -} +} diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx index 363866e6..240b93f5 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -26,13 +26,24 @@ import { import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; +import { queryClient } from "@/components/query-client-provider"; import Empty from "./empty"; import { NetworkActor } from "./network-actor"; // ── 适配层 ───────────────────────────────────────────────── -function authenticatedFetch(url: string, init?: RequestInit) { - const token = getStoredAuth()?.access_token; +// 本组件只对接「我的笔记/空间」后端,用不到本系统(DeerFlow)token。 +// notebook token 由 fetchStudioToken(getTokenByEmail) 获取,缓存在 react-query +// 的 ["studio","token",email] 下(见 core/studio/hooks)。 +function getNotebookToken(): string | undefined { + const email = getStoredAuth()?.email ?? ""; + return queryClient.getQueryData(["studio", "token", email]); +} + +// 上传到笔记后端时传 useNotebookToken=true 注入我的笔记 token; +// 其余请求不注入本系统 token,调用方自行在 init.headers 里带 Authorization。 +export function authenticatedFetch(url: string, init?: RequestInit, useNotebookToken = false): Promise { + const token = useNotebookToken ? getNotebookToken() : undefined; return fetch(url, { ...init, headers: { @@ -334,7 +345,7 @@ const DocumentUploadPopupContent: FC<{ if (!files.length) return; setUploading(true); try { - const token = getStoredAuth()?.access_token ?? ""; + const token = getNotebookToken() ?? ""; const formData = new FormData(); formData.append("search_space_id", searchSpaceId); for (const file of files) { @@ -389,7 +400,7 @@ const DocumentUploadPopupContent: FC<{ search_space_id: Number(searchSpaceId), source_list: dataList, }), - }); + }, true); if (response.ok) { const data = await response.json() as { imported_count?: number }; toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`); diff --git a/frontend-web/src/components/assistant-ui/network-actor.tsx b/frontend-web/src/components/assistant-ui/network-actor.tsx index a4848248..9be9cfcd 100644 --- a/frontend-web/src/components/assistant-ui/network-actor.tsx +++ b/frontend-web/src/components/assistant-ui/network-actor.tsx @@ -10,17 +10,8 @@ import { Loader2, Plus, Search, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; +import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup"; -function authenticatedFetch(url: string, init?: RequestInit) { - const token = getStoredAuth()?.access_token; - return fetch(url, { - ...init, - headers: { - ...(init?.headers ?? {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); -} interface NetworkActorProps { inputValue: string; @@ -195,7 +186,7 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc const [actorCount, setActorCount] = useState(0); const [directionType, setDirectionType] = useState('TW'); const directionValue = useRef('TW'); - const consumerUrl = 'http://ch1.b.uat.4.cn/consumer'; + const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || 'https://ch1.b.uat.4.cn/consumer'; const backendUrl = typeof window !== 'undefined' ? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001') : 'http://localhost:8001'; @@ -395,7 +386,7 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc search_space_id: Number(searchSpaceId), source_list: dataList, }), - }); + }, true); if (response.ok) { const data = await response.json(); toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`); diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 205eb9a1..14497624 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -320,4 +320,155 @@ max-width: 64px; opacity: 1; transform: translateX(0); -} \ No newline at end of file +} + +.sidebar-contnet--top-submenu .item-nav:hover::before, +.sidebar-contnet--top-submenu .item-nav-active::before, +.sidebar-contnet--top-submenu .item-nav-hovered::before { + display: none; +} + +.top-submenu-bar { + position: relative; + z-index: 35; + height: 64px; + min-width: 0; + flex: 1 1 auto; + align-self: stretch; + margin-left: 34px; + background: transparent; + border: 0; + box-shadow: none; + overflow-x: auto; + overflow-y: hidden; +} + +.top-submenu-list { + min-width: max-content; + height: 64px; + display: flex; + align-items: stretch; + gap: 0; + padding: 0; +} + +.top-submenu-item { + position: relative; + min-width: 122px; + height: 64px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0 18px; + background: transparent; + color: rgba(224, 242, 255, 0.9); + font-family: Noto Sans S Chinese; + font-size: 16px; + line-height: 1; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; + cursor: pointer; + overflow: hidden; + transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease; +} + +.top-submenu-item:hover, +.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; +} + +.third-submenu-bar { + position: relative; + z-index: 34; + height: 42px; + margin-left: 118px; + flex-shrink: 0; + background: + linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%), + radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%); + border-top: 1px solid rgba(79, 154, 255, 0.18); + border-bottom: 1px solid rgba(43, 143, 254, 0.26); + box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22); + overflow-x: auto; + overflow-y: hidden; +} + +.third-submenu-list { + min-width: max-content; + height: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 0 24px; +} + +.third-submenu-item { + position: relative; + height: 28px; + min-width: 86px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(90, 169, 255, 0.2); + border-radius: 2px; + padding: 0 16px; + background: rgba(2, 28, 65, 0.36); + color: rgba(221, 242, 255, 0.78); + font-family: Noto Sans S Chinese; + font-size: 14px; + line-height: 1; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; + cursor: pointer; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.third-submenu-item:hover, +.third-submenu-item--active { + color: #ffffff; + border-color: rgba(82, 205, 255, 0.55); + background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%); + box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14); + text-shadow: 0 0 8px rgba(87, 215, 255, 0.34); +} + +.third-submenu-item--active::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 2px; + background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%); + box-shadow: 0 0 10px rgba(78, 230, 255, 0.72); +} diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 5d54ec6a..91379f23 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,14 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { PanelLeftOpen, PanelLeftClose } from "lucide-react"; import { getStoredAuth } from "@/core/auth"; +import { isWujieEnv } from "@/core/embed/wujie"; import { defaultExpandedIds, - filterMenuItemsByRole, - sidebarMenuItems, type MenuItem, } from "@/core/page-layout/sidebar-menu"; +import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import './page-sdebar.css' type PageSidebarDarkProps = { @@ -19,10 +19,8 @@ type PageSidebarDarkProps = { export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) { const isAdmin = getStoredAuth()?.system_role === "admin"; - const visibleMenuItems = useMemo( - () => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin), - [isAdmin], - ); + const visibleMenuItems = useSidebarMenuItems(!!isAdmin); + const inWujieEnv = isWujieEnv(); // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) const [expandedIds, setExpandedIds] = useState>(() => new Set(defaultExpandedIds)); const navigate = useNavigate(); @@ -163,6 +161,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar })} ); + // collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口 if (collapsed) { return ( @@ -180,16 +179,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar } return ( -