feat: 简介模式菜单按类型 A/B 分套,兼容未配置的旧部署
两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
5e1765d889
commit
acf538ac01
21 files changed
+610
-103
No files matched your search
+50
-49
@@ -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
|
||||
@@ -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/`。
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
@@ -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=
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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],
|
||||
)
|
||||
+8
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+10
-4
@@ -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
|
||||
+47
@@ -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
|
||||
+35
-8
@@ -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)
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+38
-8
@@ -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
|
||||
+52
@@ -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"}
|
||||
Reference in new issue
Block a user