From acf538ac01fafa20396ee12135fe3c1de07ed88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 19 Aug 2026 16:59:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=80=E4=BB=8B=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=8C=89=E7=B1=BB=E5=9E=8B=20A/B=20=E5=88=86?= =?UTF-8?q?=E5=A5=97=EF=BC=8C=E5=85=BC=E5=AE=B9=E6=9C=AA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=97=A7=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。 Co-authored-by: Cursor --- .gitignore | 99 +++++------ AGENTS.md | 2 +- CLAUDE.md | 2 +- frontend-web/.env.example | 3 + frontend-web/public/runtime-config.js | 7 +- .../compact-menu-overrides.test.ts | 35 ++++ .../page-layout/compact-menu-overrides.ts | 34 ++++ frontend-web/src/env.ts | 5 + frontend-web/src/pages/MenuManagementPage.tsx | 58 ++++++- .../api/compact-menu-overrides.ts | 45 +++-- offline-backend-20260512/backend/CLAUDE.md | 1 + .../gateway/routers/compact_menu_overrides.py | 45 ++++- .../compact_menu_overrides/__init__.py | 8 + .../compact_menu_overrides/base.py | 14 +- .../compact_menu_overrides/keys.py | 47 ++++++ .../compact_menu_overrides/memory.py | 43 ++++- .../compact_menu_overrides/model.py | 4 +- .../persistence/compact_menu_overrides/sql.py | 46 +++++- .../versions/20260819_01_compact_menu_type.py | 52 ++++++ .../scripts/sql/column_additions_mysql.sql | 9 +- .../tests/test_compact_menu_overrides.py | 154 ++++++++++++++++++ 21 files changed, 610 insertions(+), 103 deletions(-) create mode 100644 frontend-web/src/core/page-layout/compact-menu-overrides.test.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/keys.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_01_compact_menu_type.py create mode 100644 offline-backend-20260512/backend/tests/test_compact_menu_overrides.py diff --git a/.gitignore b/.gitignore index e22a4ef0..34a6d1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ # Workspace/editor -.vscode/* -!.vscode/launch.json -!.vscode/tasks.json -!.vscode/extensions.json -!.vscode/settings.json -frontend-web/.vscode/ +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json +!.vscode/extensions.json +!.vscode/settings.json +frontend-web/.vscode/ node_modules/ # Claude Code local-only settings (per-user permissions, not shared) .claude/settings.local.json @@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh offline-backend-20260505/bundle/stop_backend.sh # offline-backend-20260512 runtime artifacts (large files, local config, data) -offline-backend-20260512/.env.example -offline-backend-20260512/.env -offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md +offline-backend-20260512/.env.example +offline-backend-20260512/.env +offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md offline-backend-20260512/SHA256SUMS offline-backend-20260512/config.yaml.example offline-backend-20260512/data/ @@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar offline-backend-20260512/extensions_config.json offline-backend-20260512/skills/ offline-backend-20260512/wheelhouse/ -offline-backend-20260512/backend/.deer-flow/data/*.db -offline-backend-20260512/backend/.deer-flow/data/*.db-* -offline-backend-20260512/backend/.deer-flow/* -offline-backend-20260512/backend/.env -offline-backend-20260512/backend/.env.local -offline-backend-20260512/start_backend.sh -offline-backend-20260512/stop_backend.sh -offline-backend-20260512/check_backend.sh +offline-backend-20260512/backend/.deer-flow/data/*.db +offline-backend-20260512/backend/.deer-flow/data/*.db-* +offline-backend-20260512/backend/.deer-flow/* +offline-backend-20260512/backend/.env +offline-backend-20260512/backend/.env.local +offline-backend-20260512/start_backend.sh +offline-backend-20260512/stop_backend.sh +offline-backend-20260512/check_backend.sh hermes-agent-main/ @@ -84,22 +84,23 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json -skills/ -hermes-agent-main/ +skills/ +hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* -!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py -!offline-backend-20260512/backend/tests/test_health_auth_bypass.py -# Keep the model configuration regression coverage versioned even though -# this deployment repository otherwise ignores newly added backend tests. -!offline-backend-20260512/backend/tests/test_model_management.py -# Deep-research reliability regression coverage. -!offline-backend-20260512/backend/tests/test_deep_research_persistence.py -!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py -!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py -!offline-backend-20260512/backend/tests/test_deep_research_start_job.py -!offline-backend-20260512/backend/tests/test_summarization_human_message.py -deploy +offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +!offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py +!offline-backend-20260512/backend/tests/test_deep_research_start_job.py +!offline-backend-20260512/backend/tests/test_summarization_human_message.py +!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py +deploy .history # Cloned third-party repos (local only) @@ -108,22 +109,22 @@ git-clone/ # Repo-root offline dependency pack (local only) /dist/ -# Frontend offline pack working directories -frontend-web/.offline-pack-build/ -frontend-web/.offline-extract-test/ -frontend-web-node_modules-offline-*.tar.gz - -# Local dependency caches and backend scratch archives -/.pnpm-store/ -/offline-backend-20260512/backend/backend*.zip - -# Local offline bundles and scratch tooling -/*.tar.gz -/offline-backend-20260512/*.zip -/tmp/ - -.codegraph -codegraph.mdc +# Frontend offline pack working directories +frontend-web/.offline-pack-build/ +frontend-web/.offline-extract-test/ +frontend-web-node_modules-offline-*.tar.gz + +# Local dependency caches and backend scratch archives +/.pnpm-store/ +/offline-backend-20260512/backend/backend*.zip + +# Local offline bundles and scratch tooling +/*.tar.gz +/offline-backend-20260512/*.zip +/tmp/ + +.codegraph +codegraph.mdc docs/aidev-docs docs/backups @@ -140,4 +141,4 @@ offline-backend-20260512/backend/uv.lock __pycache__/ # misc -.DS_Store +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 5cbfebb5..81ab06d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (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). -- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 diff --git a/CLAUDE.md b/CLAUDE.md index c0c669d4..a62332da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (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). -- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 68e79a97..415c1a07 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -56,4 +56,7 @@ # VITE_STATIC_WEBSITE_ONLY= # 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。 # VITE_FORCE_COLLAPSED_SIDEBAR=false +# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 +# 不填或留空时按类型 A,继续使用原来的简介菜单数据。 +# VITE_COMPACT_MENU_TYPE=A # VITE_GITHUB_OAUTH_TOKEN= diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index b9e96530..05e3fe6d 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -52,7 +52,7 @@ window.__CM_RUNTIME_CONFIG__ = { // 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项, // 不影响原多智能体会商使用的任务详情接口。 - VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.sit.4.cn/zl_consumer", // 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。 // 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。 @@ -138,6 +138,11 @@ window.__CM_RUNTIME_CONFIG__ = { // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 VITE_FORCE_COLLAPSED_SIDEBAR: "false", + // 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。 + // 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A, + // 继续读取原来的简介菜单数据,无需改旧页面。 + VITE_COMPACT_MENU_TYPE: "A", + // GitHub OAuth Token;不使用 GitHub 相关能力时留空。 VITE_GITHUB_OAUTH_TOKEN: "", diff --git a/frontend-web/src/core/page-layout/compact-menu-overrides.test.ts b/frontend-web/src/core/page-layout/compact-menu-overrides.test.ts new file mode 100644 index 00000000..c67df41c --- /dev/null +++ b/frontend-web/src/core/page-layout/compact-menu-overrides.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides"; +import type { MenuItem } from "./sidebar-menu"; + +const I = (() => null) as unknown as MenuItem["icon"]; + +test("normalizeCompactMenuType defaults to A", () => { + assert.equal(normalizeCompactMenuType(undefined), "A"); + assert.equal(normalizeCompactMenuType(""), "A"); + assert.equal(normalizeCompactMenuType("default"), "A"); + assert.equal(normalizeCompactMenuType("b"), "B"); + assert.equal(normalizeCompactMenuType("2"), "B"); + assert.equal(normalizeCompactMenuType("alt"), "B"); + assert.equal(isDefaultCompactMenuType(undefined), true); + assert.equal(isDefaultCompactMenuType(""), true); + assert.equal(isDefaultCompactMenuType("B"), false); +}); + +test("applyCompactMenuOverrides splits sidebar vs settings", () => { + const items: MenuItem[] = [ + { id: "a", label: "对话", icon: I, path: "/a" }, + { id: "b", label: "智能体", icon: I, path: "/b" }, + ]; + const applied = applyCompactMenuOverrides(items, [ + { nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" }, + ]); + assert.deepEqual( + applied.sidebarItems.map((item) => item.id), + ["a"], + ); + assert.equal(applied.settingsItems[0]?.id, "b"); + assert.equal(applied.settingsItems[0]?.label, "智能体设置"); +}); diff --git a/frontend-web/src/core/page-layout/compact-menu-overrides.ts b/frontend-web/src/core/page-layout/compact-menu-overrides.ts index 5aee6fc0..30cc4869 100644 --- a/frontend-web/src/core/page-layout/compact-menu-overrides.ts +++ b/frontend-web/src/core/page-layout/compact-menu-overrides.ts @@ -1,7 +1,41 @@ import type { MenuItem } from "./sidebar-menu"; +import { readRuntimeEnv } from "@/env"; export type CompactMenuPlacement = "sidebar" | "settings"; +export const COMPACT_MENU_TYPES = ["A", "B"] as const; +export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number]; +export const DEFAULT_COMPACT_MENU_TYPE: CompactMenuType = "A"; + +const TYPE_ALIASES: Record = { + A: "A", + "1": "A", + DEFAULT: "A", + B: "B", + "2": "B", + ALT: "B", +}; + +export function normalizeCompactMenuType(value?: string | null): CompactMenuType { + const raw = (value ?? "").trim().toUpperCase(); + return TYPE_ALIASES[raw] ?? DEFAULT_COMPACT_MENU_TYPE; +} + +export function isDefaultCompactMenuType(value?: string | null): boolean { + return normalizeCompactMenuType(value) === DEFAULT_COMPACT_MENU_TYPE; +} + +/** Compact-menu variant for this website, from runtime-config.js. + * Missing / empty config is type A, which loads the original compact menu. */ +export function getRuntimeCompactMenuType(): CompactMenuType { + return normalizeCompactMenuType(readRuntimeEnv("VITE_COMPACT_MENU_TYPE")); +} + +export const COMPACT_MENU_TYPE_LABELS: Record = { + A: "类型 A", + B: "类型 B", +}; + export interface CompactMenuOverride { nodeId: string; placement: CompactMenuPlacement; diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index dafd3f5f..166e0cec 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -41,5 +41,10 @@ export const env = { * flag so operations can switch the shell without rebuilding the frontend. */ FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"), + /** + * Compact-menu variant for this website. Pair with the admin 简介模式 + * type (A / B) so two deployments can show different left-nav items. + */ + COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"), GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"), }; diff --git a/frontend-web/src/pages/MenuManagementPage.tsx b/frontend-web/src/pages/MenuManagementPage.tsx index 4a5944e6..04741368 100644 --- a/frontend-web/src/pages/MenuManagementPage.tsx +++ b/frontend-web/src/pages/MenuManagementPage.tsx @@ -78,9 +78,13 @@ import { type MenuOverride, type MergedMenuNode, } from "@/core/page-layout/menu-overrides"; -import type { - CompactMenuOverride, - CompactMenuPlacement, +import { + COMPACT_MENU_TYPES, + COMPACT_MENU_TYPE_LABELS, + getRuntimeCompactMenuType, + type CompactMenuOverride, + type CompactMenuPlacement, + type CompactMenuType, } from "@/core/page-layout/compact-menu-overrides"; import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu"; import { injectLightApps } from "@/core/page-layout/use-sidebar-menu"; @@ -975,8 +979,10 @@ export default function MenuManagementPage() { } function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) { - const { data: overrides = [], isLoading } = useCompactMenuOverrides(); - const { save } = useCompactMenuOverrideActions(); + const runtimeType = getRuntimeCompactMenuType(); + const [menuType, setMenuType] = useState(runtimeType); + const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType); + const { save } = useCompactMenuOverrideActions(menuType); const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]); const [rows, setRows] = useState(initialRows); const [loadedKey, setLoadedKey] = useState(""); @@ -988,8 +994,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) { (top.children ?? []).map((child) => [top.id, child.id, child.label]), ), overrides, + menuType, }), - [baseTree, overrides], + [baseTree, overrides, menuType], ); useEffect(() => { if (sourceKey !== loadedKey) { @@ -1031,17 +1038,23 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) { const handleSave = async () => { try { await save.mutateAsync(currentOverrides); - toast.success("简介菜单配置已保存"); + toast.success(`简介菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`); } catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); } }; const handleReset = () => { - if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return; + if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简介模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return; setRows(compactRowsFromTree(baseTree, [])); }; + const switchMenuType = (next: CompactMenuType) => { + if (next === menuType) return; + if (dirty && !confirm("当前类型有未保存的修改,切换后将丢失。确定切换吗?")) return; + setMenuType(next); + }; + return ( <>
@@ -1082,9 +1095,36 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
+
+
+ {COMPACT_MENU_TYPES.map((type) => ( + + ))} +
+

- 这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。 + + 这里配置的是简介模式工作区左侧菜单,按类型 A / B 分套保存。两套网页在{" "} + runtime-config.js 里填写{" "} + VITE_COMPACT_MENU_TYPE 为 A 或 B + 即可加载对应配置;全量模式菜单不受影响。当前网页正在使用 + {COMPACT_MENU_TYPE_LABELS[runtimeType]}。 +

diff --git a/frontend-web/src/strategy-components/api/compact-menu-overrides.ts b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts index dc3bd0bc..dfa8dd43 100644 --- a/frontend-web/src/strategy-components/api/compact-menu-overrides.ts +++ b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts @@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import { OptType, writeYLog } from "@/core/log/y-log"; -import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides"; +import { + getRuntimeCompactMenuType, + isDefaultCompactMenuType, + type CompactMenuOverride, + type CompactMenuType, +} from "@/core/page-layout/compact-menu-overrides"; const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`; @@ -12,6 +17,7 @@ interface ServerCompactOverride { placement: "sidebar" | "settings"; sort_order: number; label: string | null; + menu_type?: string | null; } function toOverride(s: ServerCompactOverride): CompactMenuOverride { @@ -44,27 +50,40 @@ async function readJson(res: Response, ctx: string): Promise { return (await res.json()) as T; } -export async function listCompactMenuOverrides(): Promise { - const res = await apiFetch(BASE(), { method: "GET" }); +export async function listCompactMenuOverrides( + menuType: CompactMenuType = getRuntimeCompactMenuType(), +): Promise { + // 旧页面 / 未配置 VITE_COMPACT_MENU_TYPE 时不带参数,后端按类型 A 返回原来的数据。 + const url = isDefaultCompactMenuType(menuType) + ? BASE() + : `${BASE()}?menu_type=${encodeURIComponent(menuType)}`; + const res = await apiFetch(url, { method: "GET" }); const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides"); return (data.overrides ?? []).map(toOverride); } export async function replaceCompactMenuOverrides( overrides: CompactMenuOverride[], + menuType: CompactMenuType = getRuntimeCompactMenuType(), ): Promise { + const body: { overrides: ServerCompactOverride[]; menu_type?: CompactMenuType } = { + overrides: overrides.map(toBody), + }; + if (!isDefaultCompactMenuType(menuType)) { + body.menu_type = menuType; + } const res = await apiFetch(BASE(), { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ overrides: overrides.map(toBody) }), + body: JSON.stringify(body), }); const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides"); const saved = (data.overrides ?? []).map(toOverride); writeYLog({ optType: OptType.edit, - optDetail: "CM助手-简介-菜单管理-保存", + optDetail: `CM助手-简介-菜单管理-保存-类型${menuType}`, url: "/api/compact-menu-overrides", - reqParam: { overrides }, + reqParam: { menuType, overrides }, returnParam: saved, }); return saved; @@ -72,21 +91,23 @@ export async function replaceCompactMenuOverrides( const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides"; -export function useCompactMenuOverrides() { +export function useCompactMenuOverrides(menuType?: CompactMenuType) { + const type = menuType ?? getRuntimeCompactMenuType(); return useQuery({ - queryKey: [COMPACT_MENU_OVERRIDES_KEY], - queryFn: listCompactMenuOverrides, + queryKey: [COMPACT_MENU_OVERRIDES_KEY, type], + queryFn: () => listCompactMenuOverrides(type), refetchOnWindowFocus: false, staleTime: 60_000, }); } -export function useCompactMenuOverrideActions() { +export function useCompactMenuOverrideActions(menuType?: CompactMenuType) { const queryClient = useQueryClient(); + const type = menuType ?? getRuntimeCompactMenuType(); return { save: useMutation({ - mutationFn: replaceCompactMenuOverrides, - onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }), + mutationFn: (overrides: CompactMenuOverride[]) => replaceCompactMenuOverrides(overrides, type), + onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY, type] }), }), }; } diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f3859ae9..58883967 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -342,6 +342,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | | **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. | | **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. | +| **Compact Menu Overrides** (`/api/compact-menu-overrides`) | Compact/sidebar-summary left-nav overlay (简介模式菜单), **global/shared**. Two independent sets keyed by `menu_type` (`A` default / `B`); saving one type does not wipe the other. Type B rows use a `B::` node_id prefix so they stay unique on databases that still use `node_id` as the sole PK. `GET ?menu_type=` lists one type (open); `PUT {menu_type, overrides}` admin-only replaces that type. Frontend: `MenuManagementPage` 简介模式 type switcher + `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. Tests: `tests/test_compact_menu_overrides.py`. | | **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. | | **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 | | **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 | diff --git a/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py b/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py index 9d6c7409..aa182d44 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py +++ b/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py @@ -1,8 +1,10 @@ """Compact/sidebar-summary menu override API. Routes (prefix ``/api/compact-menu-overrides``): - GET "" list all compact-menu overrides - PUT "" replace the entire override set (admin) + GET "" list compact-menu overrides. + ``menu_type`` omitted / empty → type A (original legacy data). + PUT "" replace that type's override set (admin). + ``menu_type`` omitted / empty → type A, does not wipe type B. """ from __future__ import annotations @@ -10,11 +12,15 @@ from __future__ import annotations from datetime import datetime from typing import Literal -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, Field from app.gateway.deps import get_current_user, get_optional_user_from_request -from deerflow.persistence.compact_menu_overrides import CompactMenuOverrideStore +from deerflow.persistence.compact_menu_overrides import ( + CompactMenuOverrideStore, + DEFAULT_COMPACT_MENU_TYPE, + normalize_compact_menu_type, +) router = APIRouter(prefix="/api/compact-menu-overrides", tags=["compact-menu-overrides"]) @@ -27,15 +33,22 @@ class CompactMenuOverride(BaseModel): class CompactMenuOverrideResponse(CompactMenuOverride): + menu_type: str = DEFAULT_COMPACT_MENU_TYPE updated_by: str | None = None updated_at: datetime | str | None = None class CompactMenuOverrideListResponse(BaseModel): + menu_type: str = DEFAULT_COMPACT_MENU_TYPE overrides: list[CompactMenuOverrideResponse] class CompactMenuOverrideReplaceRequest(BaseModel): + menu_type: str | None = Field( + default=None, + max_length=32, + description="A or B. Omitted/empty = A (original compact-menu set).", + ) overrides: list[CompactMenuOverride] = Field(default_factory=list) @@ -55,10 +68,21 @@ async def _require_admin(request: Request) -> None: @router.get("", response_model=CompactMenuOverrideListResponse) -async def list_compact_menu_overrides(request: Request) -> CompactMenuOverrideListResponse: +async def list_compact_menu_overrides( + request: Request, + menu_type: str | None = Query( + default=None, + max_length=32, + description="A or B. Omitted/empty = A, returning the original compact-menu rows.", + ), +) -> CompactMenuOverrideListResponse: + t = normalize_compact_menu_type(menu_type) store = _get_store(request) - rows = await store.list_overrides() - return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows]) + rows = await store.list_overrides(t) + return CompactMenuOverrideListResponse( + menu_type=t, + overrides=[CompactMenuOverrideResponse(**r) for r in rows], + ) @router.put("", response_model=CompactMenuOverrideListResponse) @@ -67,10 +91,15 @@ async def replace_compact_menu_overrides( body: CompactMenuOverrideReplaceRequest, ) -> CompactMenuOverrideListResponse: await _require_admin(request) + t = normalize_compact_menu_type(body.menu_type) store = _get_store(request) user_id = await get_current_user(request) rows = await store.replace_all( [o.model_dump() for o in body.overrides], + menu_type=t, updated_by=user_id, ) - return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows]) + return CompactMenuOverrideListResponse( + menu_type=t, + overrides=[CompactMenuOverrideResponse(**r) for r in rows], + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py index da11cc02..d768b584 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py @@ -3,16 +3,24 @@ from __future__ import annotations from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore +from deerflow.persistence.compact_menu_overrides.keys import ( + ALLOWED_COMPACT_MENU_TYPES, + DEFAULT_COMPACT_MENU_TYPE, + normalize_compact_menu_type, +) from deerflow.persistence.compact_menu_overrides.memory import MemoryCompactMenuOverrideStore from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow from deerflow.persistence.compact_menu_overrides.sql import CompactMenuOverrideRepository __all__ = [ + "ALLOWED_COMPACT_MENU_TYPES", "CompactMenuOverrideRepository", "CompactMenuOverrideRow", "CompactMenuOverrideStore", + "DEFAULT_COMPACT_MENU_TYPE", "MemoryCompactMenuOverrideStore", "make_compact_menu_override_store", + "normalize_compact_menu_type", ] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py index ef630c04..22f08e5f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py @@ -8,11 +8,17 @@ from typing import Any class CompactMenuOverrideStore(ABC): @abstractmethod - async def list_overrides(self) -> list[dict[str, Any]]: - """Return every stored compact-menu override row.""" + async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]: + """Return compact-menu override rows for one type (default A).""" raise NotImplementedError @abstractmethod - async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: - """Atomically replace the entire compact-menu override set.""" + async def replace_all( + self, + rows: list[dict[str, Any]], + *, + menu_type: str | None = None, + updated_by: str | None = None, + ) -> list[dict[str, Any]]: + """Atomically replace the compact-menu override set for one type.""" raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/keys.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/keys.py new file mode 100644 index 00000000..f6f72360 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/keys.py @@ -0,0 +1,47 @@ +"""Compact-menu type helpers. + +Two website deployments can keep independent compact-sidebar layouts. +Type ``A`` is the historical default (existing rows stay as-is). Type ``B`` +is stored under a prefixed primary key so it never collides with type A on +databases that still use ``node_id`` as the sole PK. +""" + +from __future__ import annotations + +DEFAULT_COMPACT_MENU_TYPE = "A" +ALLOWED_COMPACT_MENU_TYPES = ("A", "B") +_STORAGE_SEP = "::" +_TYPE_ALIASES = { + "A": "A", + "1": "A", + "DEFAULT": "A", + "B": "B", + "2": "B", + "ALT": "B", +} + + +def normalize_compact_menu_type(value: str | None) -> str: + raw = (value or "").strip().upper() + return _TYPE_ALIASES.get(raw, DEFAULT_COMPACT_MENU_TYPE) + + +def storage_node_id(menu_type: str, node_id: str) -> str: + """PK value that stays unique across types on a node_id-only primary key.""" + t = normalize_compact_menu_type(menu_type) + nid = str(node_id) + if t == DEFAULT_COMPACT_MENU_TYPE: + prefix = f"{t}{_STORAGE_SEP}" + return nid[len(prefix) :] if nid.startswith(prefix) else nid + prefix = f"{t}{_STORAGE_SEP}" + return nid if nid.startswith(prefix) else f"{prefix}{nid}" + + +def public_node_id(stored: str, menu_type: str | None = None) -> str: + """Strip the type prefix so API callers always see the real menu node id.""" + t = normalize_compact_menu_type(menu_type) + nid = str(stored) + prefix = f"{t}{_STORAGE_SEP}" + if nid.startswith(prefix): + return nid[len(prefix) :] + return nid diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py index 583a1a31..3b4898ca 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py @@ -6,12 +6,19 @@ from datetime import UTC, datetime from typing import Any from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore +from deerflow.persistence.compact_menu_overrides.keys import ( + normalize_compact_menu_type, + public_node_id, + storage_node_id, +) -def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]: +def _normalize(data: dict[str, Any], menu_type: str, updated_by: str | None) -> dict[str, Any]: placement = str(data.get("placement") or "sidebar") + node_id = public_node_id(str(data["node_id"]), menu_type) return { - "node_id": str(data["node_id"]), + "node_id": storage_node_id(menu_type, node_id), + "menu_type": menu_type, "placement": placement if placement in {"sidebar", "settings"} else "sidebar", "sort_order": int(data.get("sort_order", 0)), "label": (data.get("label") or None), @@ -20,18 +27,38 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]: } +def _public(row: dict[str, Any]) -> dict[str, Any]: + menu_type = normalize_compact_menu_type(row.get("menu_type")) + out = dict(row) + out["menu_type"] = menu_type + out["node_id"] = public_node_id(str(row.get("node_id") or ""), menu_type) + return out + + class MemoryCompactMenuOverrideStore(CompactMenuOverrideStore): def __init__(self) -> None: self._rows: dict[str, dict[str, Any]] = {} - async def list_overrides(self) -> list[dict[str, Any]]: - return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))] + async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]: + t = normalize_compact_menu_type(menu_type) + rows = [r for r in self._rows.values() if normalize_compact_menu_type(r.get("menu_type")) == t] + return [_public(r) for r in sorted(rows, key=lambda r: r.get("sort_order", 0))] - async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: - self._rows = {} + async def replace_all( + self, + rows: list[dict[str, Any]], + *, + menu_type: str | None = None, + updated_by: str | None = None, + ) -> list[dict[str, Any]]: + t = normalize_compact_menu_type(menu_type) + self._rows = { + k: v for k, v in self._rows.items() if normalize_compact_menu_type(v.get("menu_type")) != t + } for r in rows: node_id = r.get("node_id") if not node_id: continue - self._rows[str(node_id)] = _normalize(r, updated_by) - return [dict(r) for r in self._rows.values()] + stored = _normalize(r, t, updated_by) + self._rows[str(stored["node_id"])] = stored + return await self.list_overrides(t) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py index f34e5dee..d7a85944 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import Integer, String +from sqlalchemy import Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base @@ -17,8 +17,10 @@ from deerflow.persistence.types import BeijingDateTime class CompactMenuOverrideRow(Base): __tablename__ = "compact_menu_overrides" + __table_args__ = (Index("ix_compact_menu_overrides_menu_type", "menu_type"),) node_id: Mapped[str] = mapped_column(String(128), primary_key=True) + menu_type: Mapped[str] = mapped_column(String(32), default="A", server_default="A") placement: Mapped[str] = mapped_column(String(32), default="sidebar") sort_order: Mapped[int] = mapped_column(Integer, default=0) label: Mapped[str | None] = mapped_column(String(255)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py index 58bd5258..8acc289f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py @@ -5,25 +5,36 @@ from __future__ import annotations from datetime import datetime from typing import Any -from sqlalchemy import delete, select +from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore +from deerflow.persistence.compact_menu_overrides.keys import ( + DEFAULT_COMPACT_MENU_TYPE, + normalize_compact_menu_type, + public_node_id, + storage_node_id, +) from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow def _to_dict(row: CompactMenuOverrideRow) -> dict[str, Any]: data = row.to_dict() + menu_type = normalize_compact_menu_type(data.get("menu_type")) + data["menu_type"] = menu_type + data["node_id"] = public_node_id(str(data.get("node_id") or ""), menu_type) value = data.get("updated_at") if isinstance(value, datetime): data["updated_at"] = value.isoformat() return data -def _row_from(data: dict[str, Any], updated_by: str | None) -> CompactMenuOverrideRow: +def _row_from(data: dict[str, Any], menu_type: str, updated_by: str | None) -> CompactMenuOverrideRow: placement = str(data.get("placement") or "sidebar") + node_id = str(data["node_id"]) return CompactMenuOverrideRow( - node_id=str(data["node_id"]), + node_id=storage_node_id(menu_type, node_id), + menu_type=menu_type, placement=placement if placement in {"sidebar", "settings"} else "sidebar", sort_order=int(data.get("sort_order", 0)), label=(data.get("label") or None), @@ -35,23 +46,42 @@ class CompactMenuOverrideRepository(CompactMenuOverrideStore): def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory - async def list_overrides(self) -> list[dict[str, Any]]: + async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]: + t = normalize_compact_menu_type(menu_type) stmt = select(CompactMenuOverrideRow).order_by(CompactMenuOverrideRow.sort_order.asc()) + stmt = stmt.where(_menu_type_filter(t)) async with self._sf() as session: result = await session.execute(stmt) return [_to_dict(row) for row in result.scalars()] - async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + async def replace_all( + self, + rows: list[dict[str, Any]], + *, + menu_type: str | None = None, + updated_by: str | None = None, + ) -> list[dict[str, Any]]: + t = normalize_compact_menu_type(menu_type) by_id: dict[str, dict[str, Any]] = {} for r in rows: node_id = r.get("node_id") if node_id: - by_id[str(node_id)] = r + by_id[public_node_id(str(node_id), t)] = r async with self._sf() as session: - await session.execute(delete(CompactMenuOverrideRow)) - new_rows = [_row_from(r, updated_by) for r in by_id.values()] + await session.execute(delete(CompactMenuOverrideRow).where(_menu_type_filter(t))) + new_rows = [_row_from(r, t, updated_by) for r in by_id.values()] session.add_all(new_rows) await session.commit() for row in new_rows: await session.refresh(row) return [_to_dict(row) for row in new_rows] + + +def _menu_type_filter(menu_type: str): + if menu_type == DEFAULT_COMPACT_MENU_TYPE: + return or_( + CompactMenuOverrideRow.menu_type == menu_type, + CompactMenuOverrideRow.menu_type.is_(None), + CompactMenuOverrideRow.menu_type == "", + ) + return CompactMenuOverrideRow.menu_type == menu_type diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_01_compact_menu_type.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_01_compact_menu_type.py new file mode 100644 index 00000000..77330efa --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_01_compact_menu_type.py @@ -0,0 +1,52 @@ +"""Add compact_menu_overrides.menu_type (简介模式菜单分套). + +Two website deployments keep independent compact/sidebar-summary left menus. +Type ``A`` is the historical default; type ``B`` is stored under a prefixed +``node_id`` so it never collides on databases that still use ``node_id`` as +the sole primary key. + +ORM: ``CompactMenuOverrideRow.menu_type = String(32), NOT NULL, default A``. +``create_all`` gives new DBs the column; existing DBs are patched by this +revision and by startup ``_ensure_orm_columns_sync``. + +Revision ID: 20260819_01 +Revises: 20260816_01 +Create Date: 2026-08-19 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260819_01" +down_revision = "20260816_01" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_table("compact_menu_overrides") and not _has_column("compact_menu_overrides", "menu_type"): + op.add_column( + "compact_menu_overrides", + sa.Column("menu_type", sa.String(length=32), nullable=False, server_default="A"), + ) + + +def downgrade() -> None: + if _has_column("compact_menu_overrides", "menu_type"): + op.drop_column("compact_menu_overrides", "menu_type") diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 4b7d6cb3..34926d2e 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -142,6 +142,12 @@ CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1' -- ORM: UserRow.login_password = String(255), nullable。 CALL _add_column_if_missing('users', 'login_password', 'VARCHAR(255) NULL DEFAULT NULL'); +-- ---- compact_menu_overrides.menu_type: 简介模式菜单分套(A/B),对应 migration 20260819_01 ---- +-- 两套网页部署各自一份左侧简介菜单。存量行视为类型 A;类型 B 的 node_id 带 B:: 前缀, +-- 以免在仍以 node_id 为单列主键的旧库上撞主键。 +-- ORM: CompactMenuOverrideRow.menu_type = String(32), NOT NULL, default A, server_default A。 +CALL _add_column_if_missing('compact_menu_overrides', 'menu_type', "VARCHAR(32) NOT NULL DEFAULT 'A'"); + -- ---- 清理临时存储过程 -------------------------------------------------------- DROP PROCEDURE IF EXISTS _add_column_if_missing; @@ -205,5 +211,6 @@ SELECT table_name, column_name OR (table_name = 'roundtable_drafts' AND column_name = 'task_id') OR (table_name = 'roundtable_jobs' AND column_name = 'task_id') OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped') - OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))) + OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')) + OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')) ORDER BY table_name, column_name; diff --git a/offline-backend-20260512/backend/tests/test_compact_menu_overrides.py b/offline-backend-20260512/backend/tests/test_compact_menu_overrides.py new file mode 100644 index 00000000..a9153e32 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_compact_menu_overrides.py @@ -0,0 +1,154 @@ +"""Tests for compact/sidebar-summary menu overrides (简介模式菜单). + +Overrides are stored per ``menu_type`` (A / B) so two website deployments can +keep independent left-nav layouts. Saving type B must not wipe type A. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from deerflow.persistence.base import Base +from deerflow.persistence.compact_menu_overrides import ( + MemoryCompactMenuOverrideStore, + CompactMenuOverrideRepository, + normalize_compact_menu_type, +) +from deerflow.persistence.compact_menu_overrides.keys import public_node_id, storage_node_id + + +@pytest_asyncio.fixture +async def store(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'compact-menu.db'}") + async with engine.begin() as conn: + import deerflow.persistence.compact_menu_overrides.model # noqa: F401 + + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + return CompactMenuOverrideRepository(sf) + + +def test_normalize_compact_menu_type(): + assert normalize_compact_menu_type(None) == "A" + assert normalize_compact_menu_type("") == "A" + assert normalize_compact_menu_type("a") == "A" + assert normalize_compact_menu_type("default") == "A" + assert normalize_compact_menu_type("B") == "B" + assert normalize_compact_menu_type("alt") == "B" + assert normalize_compact_menu_type("2") == "B" + + +def test_storage_keys_keep_type_a_unprefixed(): + assert storage_node_id("A", "workspace-chats") == "workspace-chats" + assert storage_node_id("B", "workspace-chats") == "B::workspace-chats" + assert public_node_id("B::workspace-chats", "B") == "workspace-chats" + assert public_node_id("workspace-chats", "A") == "workspace-chats" + + +@pytest.mark.asyncio +async def test_replace_all_roundtrips(store: CompactMenuOverrideRepository): + out = await store.replace_all( + [ + {"node_id": "workspace-chats", "placement": "settings", "sort_order": 1, "label": "会话"}, + {"node_id": "workspace-agents", "placement": "sidebar", "sort_order": 0, "label": None}, + ], + updated_by="admin-1", + ) + assert len(out) == 2 + rows = {r["node_id"]: r for r in await store.list_overrides()} + assert rows["workspace-chats"]["label"] == "会话" + assert rows["workspace-chats"]["placement"] == "settings" + assert rows["workspace-chats"]["menu_type"] == "A" + assert rows["workspace-agents"]["updated_by"] == "admin-1" + + +@pytest.mark.asyncio +async def test_replace_all_is_full_replacement_within_type(store: CompactMenuOverrideRepository): + await store.replace_all([{"node_id": "a"}, {"node_id": "b"}], menu_type="A") + await store.replace_all([{"node_id": "c"}], menu_type="A") + ids = {r["node_id"] for r in await store.list_overrides("A")} + assert ids == {"c"} + + +@pytest.mark.asyncio +async def test_types_are_isolated(store: CompactMenuOverrideRepository): + await store.replace_all( + [{"node_id": "workspace-chats", "placement": "sidebar", "sort_order": 0, "label": "对话A"}], + menu_type="A", + ) + await store.replace_all( + [{"node_id": "workspace-chats", "placement": "settings", "sort_order": 2, "label": "对话B"}], + menu_type="B", + ) + a_rows = await store.list_overrides("A") + b_rows = await store.list_overrides("B") + assert len(a_rows) == 1 + assert a_rows[0]["node_id"] == "workspace-chats" + assert a_rows[0]["label"] == "对话A" + assert a_rows[0]["placement"] == "sidebar" + assert len(b_rows) == 1 + assert b_rows[0]["node_id"] == "workspace-chats" + assert b_rows[0]["label"] == "对话B" + assert b_rows[0]["placement"] == "settings" + + await store.replace_all([], menu_type="B") + assert await store.list_overrides("B") == [] + leftover = await store.list_overrides("A") + assert len(leftover) == 1 + assert leftover[0]["label"] == "对话A" + + +@pytest.mark.asyncio +async def test_omitted_menu_type_reads_legacy_rows(): + """Old pages that do not send menu_type still see the original compact menu.""" + mem = MemoryCompactMenuOverrideStore() + mem._rows["workspace-chats"] = { + "node_id": "workspace-chats", + "placement": "settings", + "sort_order": 1, + "label": "旧对话", + } + rows = await mem.list_overrides(None) + assert len(rows) == 1 + assert rows[0]["node_id"] == "workspace-chats" + assert rows[0]["label"] == "旧对话" + assert rows[0]["menu_type"] == "A" + assert await mem.list_overrides("B") == [] + + +@pytest.mark.asyncio +async def test_sql_blank_menu_type_is_treated_as_original(store: CompactMenuOverrideRepository): + from sqlalchemy import update + + from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow + + await store.replace_all( + [{"node_id": "legacy", "placement": "sidebar", "sort_order": 0, "label": "旧菜单"}], + ) + async with store._sf() as session: + await session.execute(update(CompactMenuOverrideRow).values(menu_type="")) + await session.commit() + + rows = await store.list_overrides(None) + assert {r["node_id"] for r in rows} == {"legacy"} + assert rows[0]["label"] == "旧菜单" + assert await store.list_overrides("B") == [] + + +@pytest.mark.asyncio +async def test_memory_store_parity(): + mem = MemoryCompactMenuOverrideStore() + await mem.replace_all( + [{"node_id": "x", "placement": "settings", "sort_order": 3}], + menu_type="B", + updated_by="u", + ) + await mem.replace_all([{"node_id": "y", "sort_order": 0}], menu_type="A") + b_rows = await mem.list_overrides("B") + assert b_rows[0]["node_id"] == "x" + assert b_rows[0]["menu_type"] == "B" + assert b_rows[0]["placement"] == "settings" + a_ids = {r["node_id"] for r in await mem.list_overrides("A")} + assert a_ids == {"y"}