feat: 简介模式菜单按类型 A/B 分套,兼容未配置的旧部署

两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 16:59:34 +08:00
1 parent 5e1765d889
commit acf538ac01
21 files changed
+610 -103

No files matched your search

+50 -49
View File
@@ -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
+1 -1
View File
@@ -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-<appId>`). 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-<appId>`). 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/`。
+1 -1
View File
@@ -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-<appId>`). 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-<appId>`). 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/`。
+3
View File
@@ -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=
+6 -1
View File
@@ -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: "",
@@ -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, "智能体设置");
});
@@ -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<string, CompactMenuType> = {
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<CompactMenuType, string> = {
A: "类型 A",
B: "类型 B",
};
export interface CompactMenuOverride {
nodeId: string;
placement: CompactMenuPlacement;
+5
View File
@@ -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"),
};
+49 -9
View File
@@ -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<CompactMenuType>(runtimeType);
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(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 (
<>
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
@@ -1082,9 +1095,36 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-2xl border border-slate-200 bg-slate-50 p-1 text-sm font-semibold">
{COMPACT_MENU_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => switchMenuType(type)}
className={`rounded-xl px-4 py-1.5 transition-all ${
menuType === type ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
{COMPACT_MENU_TYPE_LABELS[type]}
{runtimeType === type ? (
<span className={`ml-1.5 text-[10px] font-medium ${menuType === type ? "text-white/80" : "text-slate-400"}`}>
当前网页
</span>
) : null}
</button>
))}
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
<span>
这里配置的是简介模式工作区左侧菜单,按类型 A / B 分套保存。两套网页在{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">runtime-config.js</code> 里填写{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">VITE_COMPACT_MENU_TYPE</code> 为 A 或 B
即可加载对应配置;全量模式菜单不受影响。当前网页正在使用
{COMPACT_MENU_TYPE_LABELS[runtimeType]}。
</span>
</p>
</div>
</div>
@@ -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<T>(res: Response, ctx: string): Promise<T> {
return (await res.json()) as T;
}
export async function listCompactMenuOverrides(): Promise<CompactMenuOverride[]> {
const res = await apiFetch(BASE(), { method: "GET" });
export async function listCompactMenuOverrides(
menuType: CompactMenuType = getRuntimeCompactMenuType(),
): Promise<CompactMenuOverride[]> {
// 旧页面 / 未配置 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<CompactMenuOverride[]> {
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] }),
}),
};
}
@@ -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`。 |
@@ -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],
)
@@ -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",
]
@@ -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
@@ -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
@@ -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)
@@ -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))
@@ -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
@@ -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")
@@ -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;
@@ -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"}