From 66a443e915cd27956c502ca908caf98da4d1d8b3 Mon Sep 17 00:00:00 2001 From: gozeros Date: Tue, 10 Nov 2026 14:03:50 +0800 Subject: [PATCH] sync update from LAN repo (source master @ c21a2a4a) --- .gitignore | 7 + AGENTS.md | 20 + frontend-web/.env.example | 3 + frontend-web/public/runtime-config.js | 4 + frontend-web/scripts/check-chrome109-css.mjs | 15 + .../src/assets/img/top-bg-light-red.svg | 1 + frontend-web/src/components/page-sdebar.css | 19 + .../src/components/wiki/WikiHeader.tsx | 24 +- .../workspace/agents/agent-gallery.tsx | 15 +- .../artifacts/artifact-file-detail.tsx | 12 +- .../artifacts/artifact-file-list.tsx | 4 +- .../components/workspace/compact-welcome.tsx | 26 +- .../src/components/workspace/input-box.tsx | 5 +- .../workspace/messages/message-group.tsx | 23 +- .../workspace/question-navigator.tsx | 180 +++- .../settings/app-identity-settings-page.tsx | 510 +++++++++- .../settings/appearance-settings-page.tsx | 3 + .../settings/call-metrics-settings-page.tsx | 68 ++ .../knowledge-batch-create-settings-page.tsx | 200 ++++ .../roundtable-room-settings-page.tsx | 54 +- .../workspace/settings/settings-dialog.tsx | 15 +- .../settings/skill-settings-page.tsx | 148 ++- .../workspace/skills/skill-detail-dialog.tsx | 311 +++++- .../src/components/workspace/thread-title.tsx | 22 +- .../src/components/workspace/welcome.tsx | 25 +- .../workspace/workspace-nav-menu.tsx | 11 + frontend-web/src/core/admin/api.ts | 58 ++ frontend-web/src/core/admin/hooks.ts | 26 + frontend-web/src/core/artifacts/download.ts | 12 +- frontend-web/src/core/artifacts/utils.ts | 13 +- .../branding/header-title-preview.test.ts | 33 + .../src/core/branding/header-title-preview.ts | 35 + .../light-red-navigation-preview.test.ts | 33 + .../branding/light-red-navigation-preview.ts | 37 + frontend-web/src/core/i18n/locales/en-US.ts | 4 +- frontend-web/src/core/i18n/locales/types.ts | 2 + frontend-web/src/core/i18n/locales/zh-CN.ts | 6 +- .../src/core/scheduled-tasks/types.ts | 3 + .../src/core/skill-created-at-filter.test.ts | 37 + .../src/core/skill-created-at-filter.ts | 42 + frontend-web/src/core/skills/api.ts | 51 +- frontend-web/src/core/skills/hooks.ts | 56 +- frontend-web/src/core/system-settings/api.ts | 233 ++++- .../src/core/system-settings/hooks.ts | 78 ++ .../src/core/tools/skill-invocation.test.ts | 42 + .../src/core/tools/skill-invocation.ts | 54 + frontend-web/src/core/tools/utils.ts | 7 + frontend-web/src/env.ts | 13 + .../components/ai-writing/WritingWelcome.tsx | 40 +- .../src/pages/AdminUserLeaderboardPage.tsx | 314 +++++- .../src/pages/ArtifactLibraryPage.tsx | 19 +- frontend-web/src/pages/ChatPage.tsx | 24 +- frontend-web/src/pages/HtmlTemplatesPage.tsx | 700 +++++++------ frontend-web/src/pages/KnowledgeBasePage.tsx | 5 + frontend-web/src/pages/LightAppListPage.tsx | 7 +- .../src/pages/LightAppManagementPage.tsx | 25 +- .../src/pages/PageDescriptionSettingsPage.tsx | 497 +++++++++ frontend-web/src/pages/ScheduledTasksPage.tsx | 27 + .../src/pages/SkillVersioningSettingsPage.tsx | 97 ++ frontend-web/src/pages/SkillsPage.tsx | 7 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 15 + .../deep-research/DeepResearchWorkbench.tsx | 29 +- .../pages/deep-research/GongwenWorkbench.tsx | 8 +- .../src/pages/html-template-data-editor.tsx | 213 ++++ .../src/pages/html-template-page-config.tsx | 568 +++++++++++ frontend-web/src/pages/html-templates.css | 962 ++++++++++++++++++ .../roundtable-planning/api/multi-agent.ts | 61 +- .../src/roundtable-planning/api/rooms.ts | 1 + .../api/roundtable-jobs.ts | 22 +- .../components/RoundtableJobNotifier.tsx | 11 +- .../components/Step2Panel.tsx | 80 +- .../hooks/useStep2Orchestration.ts | 34 +- .../lib/capacity-error.test.ts | 97 +- .../roundtable-planning/lib/capacity-error.ts | 199 +++- .../lib/dag-flow-state.test.ts | 50 + .../roundtable-planning/lib/dag-flow-state.ts | 92 ++ .../pages/BusinessChainListPage.tsx | 10 +- .../pages/RoundtablePlanningPage.tsx | 125 ++- .../src/strategy-components/Header.tsx | 197 +++- .../strategy-components/api/html-templates.ts | 247 ++++- .../llmwiki/WeKnoraKnowledgeWorkspace.tsx | 250 ++++- .../header-title-fit.test.ts | 93 ++ .../strategy-components/header-title-fit.ts | 149 +++ .../StrategyProjectsPage.tsx | 9 +- frontend-web/src/styles/globals.css | 70 +- frontend-web/vite.config.ts | 101 +- offline-backend-20260512/backend/CLAUDE.md | 29 +- offline-backend-20260512/backend/README.md | 34 +- .../app/gateway/roundtable_room_gate.py | 12 + .../app/gateway/routers/admin_users.py | 209 +++- .../app/gateway/routers/app_identity.py | 314 +++++- .../backend/app/gateway/routers/artifacts.py | 65 +- .../app/gateway/routers/html_templates.py | 262 ++++- .../backend/app/gateway/routers/llmwiki.py | 97 +- .../backend/app/gateway/routers/skills.py | 317 +++++- .../app/gateway/routers/system_settings.py | 180 +++- .../app/gateway/routers/thread_shares.py | 7 + .../app/gateway/skill_catalog_cache.py | 23 + offline-backend-20260512/backend/config.yaml | 10 +- .../backend/docs/SKILL_EVOLUTION_ZH.md | 4 +- .../backend/extensions_config.json | 9 + .../deerflow/agents/lead_agent/prompt.py | 142 +-- .../tool_error_handling_middleware.py | 53 +- .../middlewares/tool_metrics_middleware.py | 33 +- .../harness/deerflow/config/app_config.py | 3 +- .../config/runtime_performance_config.py | 24 +- .../deerflow/config/system_settings.py | 265 ++++- .../deerflow/html_runtime/offline_bundle.py | 321 ++++++ .../persistence/roundtable_rooms/sql.py | 25 +- .../deerflow/runtime/scheduler/service.py | 19 +- .../runtime/scheduler/template_fill.py | 265 ++++- .../runtime/scheduler/template_store.py | 449 +++++++- .../runtime/scheduler/templates/no1/data.json | 1 + .../scheduler/templates/no1/fields.json | 557 ++++++++++ .../scheduler/templates/no1/manifest.json | 1 + .../scheduler/templates/no1/runtime.js | 336 ++++++ .../scheduler/templates/no1/template.html | 98 ++ .../harness/deerflow/sandbox/tools.py | 70 +- .../packages/harness/deerflow/skills/usage.py | 115 ++- .../harness/deerflow/skills/versioning.py | 397 ++++++++ .../deerflow/tools/builtins/skill_tools.py | 11 +- .../deerflow/tools/skill_manage_tool.py | 90 ++ .../tests/test_runtime_performance_config.py | 18 +- .../harness/tests/test_skill_usage_signals.py | 95 ++ .../tests/test_skill_view_fast_path.py | 26 +- .../test_tool_metrics_internal_visibility.py | 18 + .../harness/tests/test_tool_metrics_pause.py | 45 +- .../test_admin_leaderboard_query_shape.py | 89 +- .../backend/tests/test_api_read_cache.py | 17 + .../backend/tests/test_app_identity.py | 144 +++ .../backend/tests/test_artifact_preview.py | 166 +++ .../tests/test_catalog_cache_routes.py | 6 +- .../backend/tests/test_llmwiki_weknora.py | 66 +- .../backend/tests/test_prompt_prefix.py | 24 +- .../tests/test_roundtable_room_limit.py | 43 + .../backend/tests/test_skill_versioning.py | 190 ++++ .../backend/tests/test_skills_reconcile.py | 16 + .../tests/test_strategy_project_settings.py | 351 ++++++- .../tests/test_structured_templates.py | 597 +++++++++++ offline-backend-20260512/config.yaml | 4 +- 140 files changed, 13777 insertions(+), 1040 deletions(-) create mode 100644 frontend-web/src/assets/img/top-bg-light-red.svg create mode 100644 frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx create mode 100644 frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx create mode 100644 frontend-web/src/core/branding/header-title-preview.test.ts create mode 100644 frontend-web/src/core/branding/header-title-preview.ts create mode 100644 frontend-web/src/core/branding/light-red-navigation-preview.test.ts create mode 100644 frontend-web/src/core/branding/light-red-navigation-preview.ts create mode 100644 frontend-web/src/core/skill-created-at-filter.test.ts create mode 100644 frontend-web/src/core/skill-created-at-filter.ts create mode 100644 frontend-web/src/core/tools/skill-invocation.test.ts create mode 100644 frontend-web/src/core/tools/skill-invocation.ts create mode 100644 frontend-web/src/pages/PageDescriptionSettingsPage.tsx create mode 100644 frontend-web/src/pages/SkillVersioningSettingsPage.tsx create mode 100644 frontend-web/src/pages/html-template-data-editor.tsx create mode 100644 frontend-web/src/pages/html-template-page-config.tsx create mode 100644 frontend-web/src/pages/html-templates.css create mode 100644 frontend-web/src/roundtable-planning/lib/dag-flow-state.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/dag-flow-state.ts create mode 100644 frontend-web/src/strategy-components/header-title-fit.test.ts create mode 100644 frontend-web/src/strategy-components/header-title-fit.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/html_runtime/offline_bundle.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no1/data.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no1/fields.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no1/manifest.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no1/runtime.js create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no1/template.html create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py create mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_skill_usage_signals.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_versioning.py create mode 100644 offline-backend-20260512/backend/tests/test_structured_templates.py diff --git a/.gitignore b/.gitignore index b63d5dd7..1756cbf0 100644 --- a/.gitignore +++ b/.gitignore @@ -88,11 +88,18 @@ offline-backend-20260512/backend/skill_evolution.json # Automatic config.yaml backups written by the admin models page on every save offline-backend-20260512/backend/.model-config-backups/ skills/ +# Harness skill-versioning logic is product source, not a runtime skill package. +!offline-backend-20260512/backend/packages/harness/deerflow/skills/ +offline-backend-20260512/backend/packages/harness/deerflow/skills/* +!offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_skill_versioning.py !offline-backend-20260512/backend/tests/test_large_file_safety.py !offline-backend-20260512/backend/tests/test_account_authentication.py +# HTML 结构化模板(no.1)引擎/存储/路由回归 +!offline-backend-20260512/backend/tests/test_structured_templates.py !offline-backend-20260512/backend/tests/test_html_runtime_addresses.py !offline-backend-20260512/backend/tests/test_app_identity.py !offline-backend-20260512/backend/tests/test_roundtable_room_limit.py diff --git a/AGENTS.md b/AGENTS.md index 004d365e..66c86fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,26 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **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`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.py`。 - **轻应用中心 (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 defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items. +- **生成网页标识 (Generated Page Branding, admin-only write)**: 管理员在设置弹窗「生成网页标识」中填写网页展示名称、上传 PNG/JPG/WebP Logo(≤10MB)、单独移除 Logo 或整体清除配置。配置保存在 runtime `system_settings.json -> generated_page_branding`,图片保存在 runtime `generated-page-branding/`,`GET /api/system-settings/generated-page-branding` 全员可读,`PUT/DELETE` 仅管理员;生成 HTML 通过公开只读 `/api/public/html-runtime/generated-page-logo` 获取图片。**只有网页展示名称非空且 Logo 文件真实存在时功能才启用**;缺少任意一项(含单独移除 Logo)都不注入任何内容,完全保持原有网页生成逻辑。该配置只约束后续生成的 HTML 网页内容,不修改 DeerFlow 自身的系统名称、主导航、欢迎区、工作区标题或 Logo。启用时,HTML 写入与 `page_report_template validate` 会由 `html_runtime/generated_page_chrome.py` 确定性注入平台套壳:页头**通栏全宽、贴顶、无圆角**,**背景直接用全量模式导航栏设计图**(`html_runtime/assets/topbar-light.svg` 浅色红调 / `topbar-dark.svg` 深色深蓝,已注册进网关 `html_runtime.py` 的 `_STATIC_ASSETS` 白名单以 `/api/public/html-runtime/topbar-*.svg` 提供;页头运行时按 body 明暗 tone 自动切图),**只注入页头、不注入页脚**;设置弹窗不再提供「品牌位置」选择,新配置默认使用 `center`:「Logo+系统名称」居中锁孔(26px 白色金属渐变字 + 深浅辉光,落在设计图中央发光基座上)+ 页面导航**分列两侧**白字页签(当前项白色加粗 + 白色墨条)。`generated_page_branding.header_layout` 字段继续兼容存量配置,既有 `left` 仍按品牌居左 + 导航居中单行渲染。页签均为通栏等高下划线式(当前项主色 + 底部渐变墨条),多页强制首个逻辑页初始可见。开关 `chrome_enabled` **默认开启**;明确关闭时完全回归无注入:不注入页头、也不对页面提任何品牌要求(校验不检查品牌,模型不会被迫自写)。页脚与「网页页脚文字」设置项均已按需求移除(`FOOTER_MARKER` 常量仅为旧页面兼容保留)。模板套件的兜底导航条同款(品牌左 + 导航中、通栏贴顶),平台注入时经 `body:has([data-df-generated-header])` + 兜底脚本双保险自动隐藏。后处理会隐藏旧的多页导航、移除模型写入的 `data-df-brand` 重复标识且保持幂等;lead prompt 只让模型生成正文和逻辑页。注入点共四处且全部**账号无关**(admin 配置对全部账号生效,非 admin 仅无配置入口):**`page_report_template prepare`(工作副本创建时即预注入页头,模型从第一秒起编辑的就是带品牌导航的页面,brief 的 `platform_branding.pre_injected` 标记此状态)**、`write_file`/`str_replace`(`sandbox/tools.py` 的 `maybe_fix_generated_html`)、`page_report_template validate`、以及 `present_files` 交付兜底(`present_file_tool._ensure_generated_html_chrome`,覆盖模型经 bash 等旁路写出的 HTML;幂等,>4MB 跳过);注入时还会**归一化 body/html 顶部 padding/margin(清零)**(防弱模型为自想页头留白)并**隐藏模型自写的顶部 nav/header**(仅正文首个内容容器之前的,正文内导航不受影响);品牌配置每次实时读取 `system_settings.json`,无缓存无账号隔离。 +- **生成网页默认明暗模式 (Generated Page Theme)**: `ChatPage` 与 `AgentChatPage` 每次发送只把当前界面的明暗模式作为 `presentation_theme=light|dark` 上下文传入。用户未明确指定网页主题时,自由生成 HTML、显式模板的 `auto` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。 +- **生成网页离线下载包 (Generated HTML Offline Bundle)**: 产物面板下载 `.htm/.html/.xhtml` 时不再只保存单个 HTML,而是请求 `GET /api/threads/{thread_id}/artifacts/{path}?download=true&offline_bundle=true`(公开只读分享的文件端点同样支持),下载 `{原名}-离线包.zip`。纯引擎 `deerflow/html_runtime/offline_bundle.py` 把入口统一为 `index.html`,只将平台信任的 `/api/public/html-runtime/*` JS/CSS/GeoJSON/品牌资源、认知态势图运行目录和签名 `/api/public/html-images/*` 图片复制到 `assets/` 并改写为相对地址;不得联网抓取任意第三方 URL。包内还带 `manifest.json`、中文 `README.txt` 和实际使用库的许可证。Leaflet 在线瓦片及用户主动引用的公网图片/API 不复制,保留原地址并列入 `external_urls`。非 HTML 下载、HTML 预览/新窗口打开及流式未落盘的临时下载行为不变。 +- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。顶部导航名称字号可选 `adaptive`(默认,30px 上限、24px 下限)或 `fixed`(16–40px);配置字段为 `app_identity.header_title_font_mode/header_title_font_size`,固定模式不再按分辨率缩放,超长名称仍单行完整显示。红色主题还可分别上传导航栏背景图与菜单选中样式图(PNG/JPG/WebP/SVG,不设应用层文件大小限制),字段为 `light_red_header_background_*` / `light_red_nav_active_*`;选择文件或恢复默认时顶部导航即时预览,未配置时继续使用 `top-bg-light-red.svg` / `nav-active-light-red.svg`。其余配置在 `system_settings.json -> app_identity`,图片统一放在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片通过 `/api/public/app-identity/*` 读取。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。 +- **平台模板套件 (Bootstrap page-template-suite 技能)**: 内置技能 `skills/public/page-template-suite`(public 类,缺省启用)让「画页面」走平台预制 Bootstrap 模板而不是从零手写。**套件只提供多页面门户(最低 2 个逻辑页),不做单页面 HTML**(单页请求自动回落共享响应式文档模板):5 套设计 × 明暗两版共 10 个 `deerflow/page_reports/assets/report-bootstrap-*-template.html`(图表门户 3 页 / 甘特双页「进度总览+里程碑风险」/ 瓦片地图门户 3 页 / 文字杂志双页「正文论述+脉络结论」/ 研判简报双页「核心结论+风险行动」),profile 注册于 `page_reports/templates.py`(id `bootstrap-charts-portal-{light,dark}`、`bootstrap-gantt-{light,dark}`、`bootstrap-map-portal-{light,dark}`、`bootstrap-text-magazine-{light,dark}`、`bootstrap-text-briefing-{light,dark}`)。显式 `template_id` 时**页数以模板为准**(prepare 会把 brief 的页数对齐到模板页数,技能无需依赖用户说出「多页」)。模板源文件用 `__DF_HTML_RUNTIME_BASE__` 占位符引用 Gateway 运行库(Bootstrap/Alpine/ECharts/Leaflet/deerflow-runtime.js),`page_report_template prepare` 在会话副本中替换为设置里保存的 HTML 运行库地址,部署无关;图表走 `data-df-chart` 严格 JSON(甘特 = 时间轴 + 阶段重叠柱 `barGap:-100%`),地图门户走 `data-df-map` Leaflet 瓦片契约(瓦片地址来自设置里保存的地图瓦片地址,需管理员在「外观设置 → HTML 地图」选择「地图网页」引擎,引擎不符时 prepare 的 brief 会带 `map_engine_warning`)。prepare 的 brief 还带 `platform_branding`(管理员「生成网页标识」的名称/Logo/页脚文字与注入规则,正文禁止自写品牌)和 `runtime_base_url`;`_choose_page_profile` 现在**显式 `template_id` 优先**(单页文档模式也能选中套件模板,此前单页一律回落共享文档模板),未显式指定时旧选择行为不变。多页门户模板自带锚点切页兜底脚本(检测 `data-df-generated-header` 让位平台套壳,勿用 `data-df-generated-chrome` 字面量——会被注入幂等检查误判)。测试 `tests/test_page_template_suite.py`(10 模板 prepare→validate 全绿 + 品牌注入 + 居左/居中布局 + 引擎门控)。 +- **Docker 内置技能同步**: Linux Docker 启动时不能让旧的持久化 `/app/skills` 遮住当前版本的 `backend/skills/public`。共享实现 `app/builtin_skill_sync.py` 会把 bundled public 覆盖同步到 live public 根、保留 `custom/`,并强制校验 Facebook/X/TikTok/YouTube/Threads/CNN/BBC/新华社/白宫官网/马尼拉时报/GMA News/雅虎日本/读卖新闻共 13 个硬路由页面技能;它使用跨进程文件锁和必需包内容摘要,多 worker 首次只复制一次,后续相同版本只校验跳过。两个受支持启动脚本仍在 Gateway 前调用 `backend/scripts/sync_builtin_skills.py`,把当前源码 `backend/skills` 只读挂载为 `/app/bundled-skills`、live skills 读写挂载到 `/app/skills`;同时 Gateway lifespan 在加载配置后、技能 ownership 迁移/MySQL 目录投影/提示词缓存之前再次调用同一共享实现,所以镜像默认入口、Compose 或外部发版脚本绕过 Shell 时也会自动补齐。任一硬路由技能缺失会 fail-fast 并打印名单,不能静默启动成“技能不可用”。 +- **自定义技能迭代与版本切换**: 管理员设置弹窗的「自定义技能迭代」总开关默认关闭(`GET/PUT /api/system-settings/skill-versioning`);关闭时保持原同名覆盖逻辑。开启后仅 `skills/custom` 参与:本人上传同名技能可选「迭代为新版本」(保留完整历史包并激活新版)或「直接覆盖」(清除旧版本链,以本次内容重新作为 v1);本人技能详情顶部也提供「迭代新版本」安装包入口,详情弹窗「版本」页可切换任一快照。历史包保存在 runtime `skill-versions//vNNNNNN`,位于技能扫描/沙箱挂载目录之外;`skills/custom/` 永远只保留一个活动包,所以大模型只能发现当前激活版本。版本元数据与调用统计解耦并继续以稳定技能名计数,上传迭代和版本切换都不清零外部调用次数。实现 `deerflow/skills/versioning.py`、`app/gateway/routers/skills.py` 的 `/versions` 接口,测试 `tests/test_skill_versioning.py` 与 `tests/test_strategy_project_settings.py`。 +- **Facebook 页面生成技能**: 内置公共技能 `skills/public/facebook-page-generator` 只在用户明确要求 Facebook/Facebook 风格网页时触发;`lead_agent/prompt.py` 的普通问答 HTML 分流会强制读取该技能,并排除通用 `page-report-generation`、模板套件和从零手写 HTML。单账号单动态场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总评论数与实际展示数分离,按 `commentTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条评论,并在一次进程内完成七模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;动态与评论正文必须为简体中文。多帖子场景仍使用 `scripts/render_facebook_page.py` 的 `init → validate → render` 流程确定性注入 `assets/facebook-template.html`,模型不得重写模板 DOM/CSS;渲染器拒绝覆盖已有文件并对 `` 注入做转义。最终单文件 HTML 保留右上角设置按钮、分模块 JSON 编辑弹窗、实时校验与「应用并渲染」,以及响应式、深浅主题、发布/点赞/评论/搜索交互;浏览器缓存键包含初始 JSON 的 FNV-1a 指纹,同一制品刷新保留手动编辑,不同新制品不会再被沙箱同源旧 JSON 覆盖。测试 `tests/test_facebook_page_generator_skill.py`。 +- **X 页面生成技能**: 内置公共技能 `skills/public/x-page-generator` 只在用户明确要求 X 平台、Twitter/推特或 X 风格社交时间线网页时触发;专用路由会强制读取技能并绕开通用 HTML 生成器。单账号单推文场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总回复数与实际展示数分离,按 `replyTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条回复,并在一次进程内完成六模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;推文与回复正文必须为简体中文。多帖子场景仍通过 `scripts/render_x_page.py` 的 `init → validate → render` 流程注入固定三栏模板与内联运行时。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留深浅主题、发布、回复、点赞、转发、收藏、搜索、关注与响应式移动布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_x_page_generator_skill.py`。 +- **TikTok 页面生成技能**: 内置公共技能 `skills/public/tiktok-page-generator` 只在用户明确要求 TikTok、抖音国际版或 TikTok 风格短视频信息流网页时触发。默认只写小型六模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_tiktok_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索、方向键切换和移动端全屏布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_tiktok_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **YouTube 页面生成技能**: 内置公共技能 `skills/public/youtube-page-generator` 只在用户明确要求 YouTube 或 YouTube 风格视频门户、视频首页、频道页时触发。默认只写小型七模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_youtube_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、分类筛选、搜索、响应式视频网格、Shorts 横向列表、观看弹窗、点赞、收藏、订阅和评论;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_youtube_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **Threads 页面生成技能**: 内置公共技能 `skills/public/threads-page-generator` 只在用户明确要求 Threads 或 Threads 风格串文首页、文本社交信息流时触发。默认只写 `accountName`、`postText`、`topic`、`displayReplyCount` 等高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动映射六模块、补齐作者/统计/中文热评、保留示例视觉并一次完成校验与渲染,禁止模型猜测 `$set` 深层路径或读取默认数据。只有结构改造才回退 `scripts/render_threads_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板和移动端导航;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_threads_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **CNN 页面生成技能**: 内置公共技能 `skills/public/cnn-page-generator` 只在用户明确要求 CNN 或 CNN 风格全球新闻门户、新闻首页时触发。默认只写以 `topic` 为核心、可选 `headline`/`breaking`/栏目标题数组的高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动生成完整中文十模块与明确标注的非实时示意数据,禁止模型猜测 `$set` 深层路径、读取生成后的合并数据或探路后二次渲染。只有结构改造才回退 `scripts/render_cnn_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_cnn_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **BBC 页面生成技能**: 内置公共技能 `skills/public/bbc-page-generator` 只在用户明确要求 BBC 或 BBC 风格国际公共新闻门户、新闻首页时触发。默认只写小型十模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_bbc_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_bbc_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **新华社页面生成技能**: 内置公共技能 `skills/public/xinhua-page-generator` 只在用户明确要求新华社、新华网或新华社风格中文通讯社门户、新闻首页时触发。默认只写小型十模块中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_xinhua_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_xinhua_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **白宫官网页面生成技能**: 内置公共技能 `skills/public/white-house-page-generator` 只在用户明确要求白宫官网、White House 风格政府首页或美国行政机构门户时触发。技能把材料整理为 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_white_house_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈;默认内容明确为概念模板,不冒充政府官网,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_white_house_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **马尼拉时报页面生成技能**: 内置公共技能 `skills/public/manila-times-page-generator` 只在用户明确要求马尼拉时报、The Manila Times 或其风格的菲律宾大报门户时触发。技能把材料整理为 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_manila_times_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、菲律宾旗色带、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_manila_times_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **GMA News 页面生成技能**: 内置公共技能 `skills/public/gma-news-page-generator` 只在用户明确要求 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面时触发。技能把材料整理为 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可保留,其余读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_gma_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、即时新闻条、天气切换、吸顶导航、头条视频、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、播客、彩票开奖和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_gma_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **雅虎日本页面生成技能**: 内置公共技能 `skills/public/yahoo-japan-page-generator` 只在用户明确要求雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面时触发。技能把材料整理为 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yahoo_japan_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多类型搜索、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yahoo_japan_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 +- **读卖新闻页面生成技能**: 内置公共技能 `skills/public/yomiuri-news-page-generator` 只在用户明确要求读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面时触发。技能把材料整理为 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yomiuri_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类切换、社论、视频字幕和邮件订阅;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yomiuri_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。 - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 - **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 643809a0..ce1e9024 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -62,6 +62,9 @@ # 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。 # 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。 # VITE_TURBO_MODE_VISIBLE=false +# 知识库视图切换:true=显示“列表 / 卡片”切换;false 或未设=固定为列表(默认)。 +# 部署后优先改 dist/runtime-config.js 的 VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED,刷新即可。 +# VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED=false # VITE_GITHUB_OAUTH_TOKEN= # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index c02873c8..4c2eb1e6 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -147,6 +147,10 @@ window.__CM_RUNTIME_CONFIG__ = { // 静态站点模式开关;一般部署业务系统时保持 false。 VITE_STATIC_WEBSITE_ONLY: "false", + // 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表; + // 改为 true 并刷新页面后,才允许用户切换到卡片视图。 + VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false", + // 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标, // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 VITE_FORCE_COLLAPSED_SIDEBAR: "false", diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index 0e57723f..6cbc5cfe 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -93,6 +93,21 @@ const requiredLegacyFallbacks = [ pattern: /AbortSignal\.timeout = function/, description: "Chrome 102 needs AbortSignal.timeout before any module loads", }, + { + file: "vite.config.ts", + pattern: /export function jsx\(\)\{return null\}/, + description: "Stale Vite deps chunks must locally export jsx for Chrome 102", + }, + { + file: "vite.config.ts", + pattern: /export const \$\{name\} = __jsxRuntime\.\$\{name\}/, + description: "Vite jsx-runtime CJS interop must expose a named jsx export", + }, + { + file: "src/components/workspace/question-navigator.tsx", + pattern: /animateScrollTop\(parent, Math\.max\(0, Math\.min\(nextTop, maxScrollTop\(parent\)\)\)\)/, + description: "Question list anchors must set nested scrollTop for Chrome 102", + }, { file: "src/styles/globals.css", pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/, diff --git a/frontend-web/src/assets/img/top-bg-light-red.svg b/frontend-web/src/assets/img/top-bg-light-red.svg new file mode 100644 index 00000000..b460b268 --- /dev/null +++ b/frontend-web/src/assets/img/top-bg-light-red.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 157ba030..8f79d5da 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -443,6 +443,25 @@ background-size: 152px 42px; } +/* 浅红科技感顶栏保持深色版的尺寸与排布,只替换文字光效和选中底图。 */ +.strategy-app-header--light-red-top-nav .top-submenu-item { + color: rgba(255, 255, 255, 0.86); +} + +.strategy-app-header--light-red-top-nav .top-submenu-item:hover { + color: #ffffff; + text-shadow: 0 0 10px rgba(196, 51, 40, 0.4); +} + +.strategy-app-header--light-red-top-nav .top-submenu-item--active { + color: #ffffff; + text-shadow: 0 0 12px rgba(177, 38, 28, 0.48); + background-image: var( + --light-red-nav-active-background-image, + url("../assets/img/nav-active-light-red.svg") + ); +} + .third-submenu-bar { position: relative; z-index: 34; diff --git a/frontend-web/src/components/wiki/WikiHeader.tsx b/frontend-web/src/components/wiki/WikiHeader.tsx index 1381250b..7f2baaaa 100644 --- a/frontend-web/src/components/wiki/WikiHeader.tsx +++ b/frontend-web/src/components/wiki/WikiHeader.tsx @@ -45,6 +45,8 @@ function HeaderButton({ /** Light 56px wiki header: plain page title on the left, action entries on the right. */ export function WikiHeader({ + title = "知识库管理", + description, onToggleSidebar, onImportFile, onBatchImport, @@ -55,6 +57,8 @@ export function WikiHeader({ onManageTemplates, extraActions, }: { + title?: string; + description?: string; onToggleSidebar: () => void; onImportFile: () => void; onBatchImport: () => void; @@ -66,8 +70,13 @@ export function WikiHeader({ extraActions?: ReactNode; }) { return ( -
-
+
+
-
- 知识库管理 +
+
+ {title} +
+ {description ? ( +

+ {description} +

+ ) : null}
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index f0ae2639..6607ac04 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -18,7 +18,12 @@ import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useRouteBase } from "@/core/route-base"; import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; -import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; +import { + usePageDescriptionsSettings, + usePrivateSquareStatus, + usePublishSquares, + useViewMode, +} from "@/core/system-settings/hooks"; import { WX_RESTRICTED_TAB_DEFAULT_SQUARE, WX_RESTRICTED_TAB_NANJING_DB, @@ -51,6 +56,8 @@ export function AgentGallery({ const { t } = useI18n(); const router = useRouter(); const routeBase = useRouteBase(); + const pageDescriptions = usePageDescriptionsSettings(); + const pageDescription = pageDescriptions.data?.agents; const sentimentAgentVisible = isSentimentAgentVisible(); // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 const isAdmin = getStoredAuth()?.system_role === "admin"; @@ -455,9 +462,11 @@ export function AgentGallery({ {/* Page header */}
-

{t.agents.title}

+

+ {pageDescription?.title || t.agents.title} +

- {t.agents.description} + {pageDescription?.description || t.agents.description}

diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 092d2415..d03dfbe4 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({ )} { if (onDownloadClick) { onDownloadClick(); diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index 10fc066b..1876bd58 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -130,7 +130,9 @@ export function ArtifactFileList({ ) : ( )} - {t.common.download} + {!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file)) + ? "下载离线包" + : t.common.download} diff --git a/frontend-web/src/components/workspace/compact-welcome.tsx b/frontend-web/src/components/workspace/compact-welcome.tsx index 6aa6b7b5..44e31f75 100644 --- a/frontend-web/src/components/workspace/compact-welcome.tsx +++ b/frontend-web/src/components/workspace/compact-welcome.tsx @@ -3,7 +3,11 @@ import { useTheme } from "@/contexts/ThemeContext"; import { AppBrandImage } from "@/core/branding/app-brand-image"; import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer"; -import { useAppSystemName } from "@/core/system-settings/hooks"; +import { + useAppSystemName, + usePageDescriptionsSettings, +} from "@/core/system-settings/hooks"; +import type { GeneralQaPageDescription } from "@/core/system-settings/api"; import { readRuntimeFlag } from "@/env"; import { cn } from "@/lib/utils"; @@ -12,11 +16,25 @@ import { cn } from "@/lib/utils"; * 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。 * 非紧凑模式仍用 ,本组件不参与。 */ -export function CompactWelcome({ className }: { className?: string }) { +export function CompactWelcome({ + className, + pageDescription, +}: { + className?: string; + /** The actual interface mode is resolved by ChatPage. */ + pageDescription?: GeneralQaPageDescription; +}) { const desensitizeText = useDisplayDesensitizer(); const { theme } = useTheme(); const systemName = useAppSystemName(""); - const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手"; + const { data: pageDescriptions } = usePageDescriptionsSettings(); + const configured = pageDescription ?? pageDescriptions?.general_qa.compact; + const welcomeTitle = + configured?.welcome_title || + (systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手"); + const welcomeDescription = + configured?.welcome_description || + "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。"; const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE"); return ( @@ -46,7 +64,7 @@ export function CompactWelcome({ className }: { className?: string }) { {desensitizeText(welcomeTitle)}

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

); diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 81a11080..96389623 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -140,6 +140,7 @@ export function InputBox({ knowledgeRetrieval, knowledgeSuggestedQuestions = [], extraModels = [], + placeholder, assistantId, hideAdminRecommendedQuestions, compact, @@ -227,6 +228,8 @@ export function InputBox({ knowledgeSuggestedQuestions?: string[]; /** Page-scoped model choices implemented outside the DeerFlow model API. */ extraModels?: Model[]; + /** Page-specific prompt. Omit to keep the localized product default. */ + placeholder?: string; }) { const { t } = useI18n(); const [localSettings, setLocalSettings] = useLocalSettings(); @@ -861,7 +864,7 @@ export function InputBox({ data-composer-height={composerMinHeight ? "" : undefined} style={composerHeightStyle} disabled={disabled} - placeholder={t.inputBox.placeholder} + placeholder={placeholder || t.inputBox.placeholder} autoFocus={autoFocus} defaultValue={initialValue} /> diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 2bbfbe9b..9fcd7eb1 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -63,6 +63,10 @@ import { useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { isInternalToolCallName } from "@/core/tools/visibility"; +import { + extractInvokedSkillName, + skillInvocationLabel, +} from "@/core/tools/skill-invocation"; import { applyInlineCitations, buildSkillDisplayMap, @@ -371,6 +375,10 @@ function ToolCall({ ragModeEnabled?: boolean; }) { const { t } = useI18n(); + const invokedSkillName = extractInvokedSkillName(name, args); + const invokedSkillLabel = invokedSkillName + ? skillInvocationLabel(invokedSkillName) + : undefined; const displayMatch = result !== undefined && isDisplaySamplePayload(result) ? null @@ -566,7 +574,9 @@ function ToolCall({ } else if (name === "read_file") { let description: string | undefined = (args as { description: string }) ?.description; - if (!description) { + if (invokedSkillLabel) { + description = invokedSkillLabel; + } else if (!description) { description = t.toolCalls.readFile; } const { path } = args as { path: string; content: string }; @@ -598,11 +608,10 @@ function ToolCall({ /> ); } else if (name === "bash") { - const description: string | undefined = (args as { description: string }) - ?.description; - if (!description) { - return t.toolCalls.executeCommand; - } + const description = + invokedSkillLabel || + (args as { description?: string })?.description || + t.toolCalls.executeCommand; const command: string | undefined = (args as { command: string })?.command; return ( ); diff --git a/frontend-web/src/components/workspace/question-navigator.tsx b/frontend-web/src/components/workspace/question-navigator.tsx index aec0694c..c23e1fd5 100644 --- a/frontend-web/src/components/workspace/question-navigator.tsx +++ b/frontend-web/src/components/workspace/question-navigator.tsx @@ -27,21 +27,146 @@ const PANEL_GUTTER_MARGIN = 24; /** Questions needed before the navigator shows up at all. */ const MIN_QUESTIONS = 2; const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed"; +/** Matches `scroll-mt-20` so the target sits below the overlay chat header. */ +const QUESTION_SCROLL_TOP_OFFSET = 80; +/** Re-apply after the drawer overlay unmounts; StickToBottom may snap on resize. */ +const SHEET_SCROLL_RETRY_MS = 80; + +function verticalOverflow(node: HTMLElement): string { + const style = window.getComputedStyle(node); + const overflowY = style.overflowY; + if (overflowY && overflowY !== "visible") return overflowY; + const parts = style.overflow.split(" "); + return parts[1] || parts[0] || ""; +} + +function isVerticallyScrollable(node: HTMLElement): boolean { + const overflowY = verticalOverflow(node); + return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; +} /** Walk up from a message anchor to the element that actually scrolls. */ function findScrollParent(el: HTMLElement | null): HTMLElement | null { let node = el?.parentElement ?? null; - while (node) { - const overflowY = window.getComputedStyle(node).overflowY; - if ( - (overflowY === "auto" || overflowY === "scroll") && - node.scrollHeight > node.clientHeight - ) { + while (node && node !== document.body) { + if (isVerticallyScrollable(node) && node.scrollHeight > node.clientHeight + 1) { return node; } node = node.parentElement; } - return null; + if (document.scrollingElement instanceof HTMLElement) { + return document.scrollingElement; + } + return document.documentElement; +} + +let questionScrollFrame = 0; +let questionScrollTarget: { scroller: HTMLElement; top: number } | null = null; + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +/** Nested-container smooth scroll that Chrome 102 can actually run. */ +function animateScrollTop(scroller: HTMLElement, top: number): void { + if ( + questionScrollTarget?.scroller === scroller && + Math.abs(questionScrollTarget.top - top) < 8 + ) { + return; + } + if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame); + const from = scroller.scrollTop; + const delta = top - from; + questionScrollTarget = { scroller, top }; + if (Math.abs(delta) < 1 || prefersReducedMotion()) { + scroller.scrollTop = top; + questionScrollFrame = 0; + return; + } + const duration = Math.min(480, Math.max(240, Math.abs(delta) * 0.45)); + const start = performance.now(); + const stopListening = () => { + scroller.removeEventListener("wheel", cancelOnUserScroll); + scroller.removeEventListener("touchmove", cancelOnUserScroll); + }; + const cancelOnUserScroll = () => { + if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame); + questionScrollFrame = 0; + questionScrollTarget = null; + stopListening(); + }; + const step = (now: number) => { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - (1 - t) * (1 - t) * (1 - t); + scroller.scrollTop = from + delta * eased; + if (t < 1) { + questionScrollFrame = requestAnimationFrame(step); + return; + } + questionScrollFrame = 0; + stopListening(); + }; + scroller.addEventListener("wheel", cancelOnUserScroll, { passive: true }); + scroller.addEventListener("touchmove", cancelOnUserScroll, { passive: true }); + questionScrollFrame = requestAnimationFrame(step); +} + +function scrollChildIntoParent( + el: HTMLElement, + parent: HTMLElement, + topOffset = 0, +): void { + const parentRect = parent.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const nextTop = parent.scrollTop + (elRect.top - parentRect.top) - topOffset; + animateScrollTop(parent, Math.max(0, Math.min(nextTop, maxScrollTop(parent)))); +} + +function maxScrollTop(parent: HTMLElement): number { + return Math.max(0, parent.scrollHeight - parent.clientHeight); +} + +/** + * Chrome 102 (and other nested-scroller hosts) often no-ops + * `scrollIntoView({ behavior: "smooth" })` when the conversation is not the + * document. Animate `scrollTop` on the real overflow container instead. + */ +function scrollElementIntoContainer(el: HTMLElement, topOffset = 0): void { + const scroller = findScrollParent(el); + if (!scroller || scroller === el) { + try { + el.scrollIntoView(true); + } catch { + // ignore hosts that reject the boolean form + } + return; + } + try { + scroller.dispatchEvent( + new WheelEvent("wheel", { deltaY: -1, bubbles: true }), + ); + } catch { + // WheelEvent is available on Chrome 102; keep scrollTop even if this fails. + } + scrollChildIntoParent(el, scroller, topOffset); +} + +function scrollItemNearestInParent( + item: HTMLElement, + parent: HTMLElement, +): void { + const parentRect = parent.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + if (itemRect.top < parentRect.top) { + parent.scrollTop += itemRect.top - parentRect.top; + } else if (itemRect.bottom > parentRect.bottom) { + parent.scrollTop += itemRect.bottom - parentRect.bottom; + } } function readCollapsedPreference(): boolean { @@ -113,6 +238,7 @@ export function QuestionNavigator({ return; } const firstAnchor = document.getElementById(questions[0]!.anchorId); + if (!firstAnchor) return; const scroller = findScrollParent(firstAnchor); if (!scroller) return; @@ -145,17 +271,20 @@ export function QuestionNavigator({ const scrollToQuestion = useCallback((anchorId: string) => { const scroll = () => { - document - .getElementById(anchorId) - ?.scrollIntoView({ behavior: "smooth", block: "start" }); + const el = document.getElementById(anchorId); + if (!el) return false; + scrollElementIntoContainer(el, QUESTION_SCROLL_TOP_OFFSET); + return true; }; - if (!document.getElementById(anchorId)) { - window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT)); - requestAnimationFrame(() => requestAnimationFrame(scroll)); - } else { - scroll(); - } setActiveId(anchorId); + if (scroll()) return; + window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (scroll()) return; + window.setTimeout(scroll, 50); + }); + }); }, []); const toggleCollapsed = useCallback(() => { @@ -246,8 +375,12 @@ export function QuestionNavigator({ questions={questions} activeId={activeId} onSelect={(anchorId) => { - setSheetOpen(false); scrollToQuestion(anchorId); + setSheetOpen(false); + window.setTimeout( + () => scrollToQuestion(anchorId), + SHEET_SCROLL_RETRY_MS, + ); }} className="min-h-0 flex-1" /> @@ -272,17 +405,22 @@ function QuestionCardList({ className?: string; }) { const itemRefs = useRef(new Map()); + const navRef = useRef(null); - // Keep the active card visible as the chat scrolls. `block: "nearest"` only - // nudges this inner list — the card is already inside the on-screen panel, - // so no outer container moves. + // Keep the active card visible as the chat scrolls. Scroll only this inner + // list — `scrollIntoView({ block: "nearest" })` on Chrome 102 also moves + // ancestor conversation scrollers, which undoes the jump to the question. useEffect(() => { if (!activeId) return; - itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" }); + const item = itemRefs.current.get(activeId); + const nav = navRef.current; + if (!item || !nav) return; + scrollItemNearestInParent(item, nav); }, [activeId]); return (
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() { - 清除系统名称和 Logo? + 清除系统名称配置?

- 清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。 + 清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。

diff --git a/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx b/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx new file mode 100644 index 00000000..1c4c694b --- /dev/null +++ b/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { BarChart3Icon, Loader2Icon } from "lucide-react"; +import { toast } from "sonner"; + +import { Switch } from "@/components/ui/switch"; +import { + useCallMetricsSettings, + useUpdateCallMetricsSettings, +} from "@/core/system-settings/hooks"; + +export function CallMetricsSettingsPage() { + const { data, isError, isLoading } = useCallMetricsSettings(); + const update = useUpdateCallMetricsSettings(); + const enabled = data?.enabled ?? true; + + const handleChange = async (checked: boolean) => { + try { + await update.mutateAsync({ enabled: checked }); + toast.success(checked ? "工具与技能调用统计已开启" : "工具与技能调用统计已关闭"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }; + + return ( +
+
+

+ + 工具与技能调用统计 +

+

+ 为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、 + 技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。 +

+
+ +
+
+
记录工具与技能调用
+

+ 关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。 +

+
+ {isLoading ? ( + + ) : ( + + )} +
+ + {isError && ( +

+ 无法读取调用统计开关,系统将按当前部署配置执行。 +

+ )} +

+ 保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。 +

+
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx b/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx new file mode 100644 index 00000000..5591a34e --- /dev/null +++ b/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { AlertCircleIcon, Loader2Icon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { buildWikiKnowledgeBaseCreateInput } from "@/core/llmwiki/knowledge-base-form"; +import { + useLlmWikiActions, + useLlmWikiEmbeddingModels, +} from "@/strategy-components/api/llmwiki"; + +import { SettingsSection } from "./settings-section"; + +type BatchCreateResult = { + createdNames: string[]; + failed: Array<{ name: string; message: string }>; +}; + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : "操作失败,请稍后重试"; +} + +function parseKnowledgeBaseNames(value: string): string[] { + const seen = new Set(); + return value + .split(/[,,\n\r]+/) + .map((name) => name.trim()) + .filter((name) => { + if (!name || seen.has(name)) return false; + seen.add(name); + return true; + }); +} + +export function KnowledgeBatchCreateSettingsPage() { + const actions = useLlmWikiActions(); + const [namesText, setNamesText] = useState(""); + const [embeddingModelId, setEmbeddingModelId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const embeddingModels = useLlmWikiEmbeddingModels(); + const availableEmbeddingModels = useMemo( + () => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false), + [embeddingModels.data?.models], + ); + const names = useMemo(() => parseKnowledgeBaseNames(namesText), [namesText]); + + useEffect(() => { + if (!embeddingModels.data || availableEmbeddingModels.length === 0) return; + if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return; + const defaultId = embeddingModels.data.default_model_id; + setEmbeddingModelId( + availableEmbeddingModels.some((model) => model.id === defaultId) + ? defaultId! + : availableEmbeddingModels[0]!.id, + ); + }, [availableEmbeddingModels, embeddingModelId, embeddingModels.data]); + + const modelSelectionUnavailable = + embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId; + const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => { + const suffix = [model.provider?.trim(), model.model_name?.trim()] + .filter(Boolean) + .join(" · "); + return suffix ? `${model.name}(${suffix})` : model.name; + }; + + const handleCreate = async () => { + if (names.length === 0 || modelSelectionUnavailable) return; + setSubmitting(true); + setResult(null); + actions.create.reset(); + + const nextResult: BatchCreateResult = { createdNames: [], failed: [] }; + for (const name of names) { + try { + await actions.create.mutateAsync(buildWikiKnowledgeBaseCreateInput({ + name, + description: "", + embeddingModelId, + wikiConfig: {}, + })); + nextResult.createdNames.push(name); + } catch (error) { + nextResult.failed.push({ name, message: messageOf(error) }); + } + } + + setResult(nextResult); + setSubmitting(false); + if (nextResult.createdNames.length > 0) { + toast.success(`已创建 ${nextResult.createdNames.length} 个知识库`); + } + if (nextResult.failed.length > 0) { + setNamesText(nextResult.failed.map((item) => item.name).join(",")); + toast.error(`${nextResult.failed.length} 个知识库创建失败,可修改后重试`); + } else { + setNamesText(""); + } + }; + + return ( + +
+
+ + +
+

模块 = 页面数据(DATA)的顶层键,另附「系统设置」编辑 MANIFEST;沙箱预览(iframe)无法使用本地缓存时,修改仅当次会话有效。

+
+
+
+ + + + + + diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py index d7adffdd..502fe946 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py @@ -314,43 +314,41 @@ def _resolve_skills_path(path: str) -> str: return _join_path_preserving_style(skills_host, relative) -def _custom_skill_name_from_path(path: str) -> str | None: - """从虚拟技能路径里解析出 custom 技能名,用于"被使用"埋点。 - - 只识别 ``{skills_container}/custom/{name}/...`` 形式 —— public 技能不参与 - 策展,给它们埋点只会往 ``.usage.json`` 写无用条目。读技能目录下任意文件 - (SKILL.md 或 references/templates/scripts 支持文件)都算一次该技能的使用。 - 解析不出技能名时返回 None。 - """ - skills_container = _get_skills_container_path() - if not (path == skills_container or path.startswith(f"{skills_container}/")): - return None - relative = path[len(skills_container) :].lstrip("/") - parts = [segment for segment in relative.split("/") if segment] - if len(parts) >= 2 and parts[0] == "custom": - return parts[1] - return None - - -def _record_skill_read(virtual_path: str) -> None: - """技能文件被 ``read_file`` 读取时记一次 use —— 技能"被使用"的真实信号。 - - 技能正文不进 system prompt(prompt 里只有 name/description/location),Agent - 按 Progressive Loading 用 ``read_file`` 加载 SKILL.md 才算真正用到该技能。 - curator 的年龄流转据此判断闲置,没有这个埋点会把每天都在用的技能误判为陈旧。 - best-effort:埋点失败绝不影响 ``read_file`` 本身。 - """ +def _record_skill_paths( + virtual_paths: list[str], + runtime: ToolRuntime[ContextT, ThreadState] | None, +) -> None: + """Record public/custom skills from real tool paths, independent of prose.""" try: - name = _custom_skill_name_from_path(virtual_path) - if name is None: - return - from deerflow.skills.usage import bump_use + from deerflow.sandbox.skill_view import skill_names_for_paths + from deerflow.skills.usage import bump_use, runtime_use_event_key - bump_use(name) + event_key = runtime_use_event_key(runtime) + for name in skill_names_for_paths( + virtual_paths, + container_path=_get_skills_container_path(), + ): + bump_use(name, event_key=event_key) except Exception: pass +def _record_skill_read( + virtual_path: str, + runtime: ToolRuntime[ContextT, ThreadState] | None = None, +) -> None: + """Count a successful skill file read as a logical skill invocation.""" + _record_skill_paths([virtual_path], runtime) + + +def _record_skill_command( + command: str, + runtime: ToolRuntime[ContextT, ThreadState] | None = None, +) -> None: + """Count skills whose scripts are executed directly by ``bash``.""" + _record_skill_paths(_skill_paths_in_text(command), runtime) + + def _is_acp_workspace_path(path: str) -> bool: """Check if a path is under the ACP workspace virtual path.""" return path == _ACP_WORKSPACE_VIRTUAL_PATH or path.startswith(f"{_ACP_WORKSPACE_VIRTUAL_PATH}/") @@ -1445,6 +1443,7 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com command: The bash command to execute. Always use absolute paths for files and directories. """ try: + requested_command = command _prepare_command_skill_capability(command) _validate_enabled_skill_paths_in_command(command) sandbox = ensure_sandbox_initialized(runtime) @@ -1457,6 +1456,7 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com command = replace_virtual_paths_in_command(command, thread_data) command = _apply_cwd_prefix(command, thread_data) output = sandbox.execute_command(command) + _record_skill_command(requested_command, runtime) try: from deerflow.config.app_config import get_app_config @@ -1473,7 +1473,9 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000 except Exception: max_chars = 20000 - return _truncate_bash_output(sandbox.execute_command(command), max_chars) + output = sandbox.execute_command(command) + _record_skill_command(requested_command, runtime) + return _truncate_bash_output(output, max_chars) except SandboxError as e: return f"Error: {e}" except PermissionError as e: @@ -1751,7 +1753,7 @@ def read_file_tool( max_result_chars=max_chars, hard_max_result_chars=hard_max, ) - _record_skill_read(requested_path) + _record_skill_read(requested_path, runtime) return inspected["rendered"] except OSError: pass @@ -1767,7 +1769,7 @@ def read_file_tool( ) # 读成功即记一次技能使用 —— requested_path 仍是虚拟路径(本地/容器沙箱都一样), # 非技能路径会被 _record_skill_read 自行忽略。 - _record_skill_read(requested_path) + _record_skill_read(requested_path, runtime) if not content and not truncated: return "(empty)" if truncated: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/usage.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/usage.py index b37634a5..b2accfe2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/usage.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/usage.py @@ -7,6 +7,7 @@ import logging import os import tempfile import threading +from collections import OrderedDict from datetime import UTC, datetime from pathlib import Path @@ -19,6 +20,14 @@ _USAGE_FILENAME = ".usage.json" # 单进程跑,故进程内锁即可保证状态流转不丢。 _usage_lock = threading.Lock() +# One user run can first load SKILL.md and then execute several helper scripts. +# Those are implementation details of one logical skill invocation, so retain a +# small process-local LRU keyed by (run, skill) and increment the public counter +# only once. A run stays on one worker; persistence across restarts is neither +# needed nor desirable here. +_MAX_RECENT_USE_EVENTS = 8192 +_recent_use_events: OrderedDict[tuple[str, str], None] = OrderedDict() + STATE_ACTIVE = "active" STATE_STALE = "stale" STATE_ARCHIVED = "archived" @@ -121,8 +130,110 @@ def bump_view(name: str) -> None: _update_entry(name, lambda e: {"view_count": e.get("view_count", 0) + 1, "last_viewed_at": _now_iso()}) -def bump_use(name: str) -> None: - _update_entry(name, lambda e: {"use_count": e.get("use_count", 0) + 1, "last_used_at": _now_iso()}) +async def _increment_use_projection(name: str, use_count: int, used_at: datetime) -> None: + """Keep the MySQL skill-list projection current without importing ``app``.""" + try: + from sqlalchemy import case, update + + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.skills.model import SkillRow + + session_factory = get_session_factory() + if session_factory is None: + return + async with session_factory() as session: + await session.execute( + update(SkillRow) + .where(SkillRow.name == name) + .values( + # Fire-and-forget writes may finish out of order. Never let + # an older absolute sidecar count overwrite a newer value. + use_count=case((SkillRow.use_count < use_count, use_count), else_=SkillRow.use_count), + last_activity_at=case( + (SkillRow.last_activity_at.is_(None), used_at), + (SkillRow.last_activity_at < used_at, used_at), + else_=SkillRow.last_activity_at, + ), + updated_at=case( + (SkillRow.updated_at < used_at, used_at), + else_=SkillRow.updated_at, + ), + ) + ) + await session.commit() + except Exception: + logger.debug("Failed to update skill usage projection for %r", name, exc_info=True) + + +def _schedule_use_projection_increment(name: str, use_count: int, used_at: datetime) -> None: + """Best-effort bridge from synchronous sandbox tools to the DB home loop.""" + try: + from deerflow.persistence.engine import fire_and_forget_db_write + + fire_and_forget_db_write(_increment_use_projection(name, use_count, used_at)) + except Exception: + logger.debug("Failed to schedule skill usage projection for %r", name, exc_info=True) + + +def runtime_use_event_key(runtime: Any | None) -> str | None: + """Return the stable user-run identifier carried by ``ToolRuntime``.""" + if runtime is None: + return None + context = getattr(runtime, "context", None) + if isinstance(context, dict): + for key in ("run_id", "langgraph_run_id"): + value = context.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + config = getattr(runtime, "config", None) + if isinstance(config, dict): + for bucket_name in ("configurable", "metadata"): + bucket = config.get(bucket_name) + if not isinstance(bucket, dict): + continue + for key in ("run_id", "langgraph_run_id"): + value = bucket.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + return None + + +def bump_use(name: str, *, event_key: str | None = None) -> bool: + """Count one logical use, optionally de-duplicated by user run. + + Returns ``True`` only when the counter changed. This makes callers and tests + able to distinguish a new invocation from a second file/script access in + the same run. + """ + normalized_name = str(name or "").strip() + if not normalized_name: + return False + normalized_event = str(event_key or "").strip() or None + dedupe_key = (normalized_event, normalized_name) if normalized_event else None + used_at = datetime.now(UTC) + new_count = 0 + try: + with _usage_lock: + if dedupe_key is not None and dedupe_key in _recent_use_events: + _recent_use_events.move_to_end(dedupe_key) + return False + data = _read_all() + entry = {**_DEFAULT_ENTRY, **data.get(normalized_name, {})} + new_count = max(0, int(entry.get("use_count") or 0)) + 1 + entry.update({"use_count": new_count, "last_used_at": used_at.isoformat()}) + data[normalized_name] = entry + _write_all(data) + if dedupe_key is not None: + _recent_use_events[dedupe_key] = None + _recent_use_events.move_to_end(dedupe_key) + while len(_recent_use_events) > _MAX_RECENT_USE_EVENTS: + _recent_use_events.popitem(last=False) + except Exception: + logger.debug("Failed to bump usage entry for %r", normalized_name, exc_info=True) + return False + + _schedule_use_projection_increment(normalized_name, new_count, used_at) + return True def bump_patch(name: str) -> None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py new file mode 100644 index 00000000..4f06ba37 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py @@ -0,0 +1,397 @@ +"""Filesystem-backed version history for custom skills. + +Only the active package lives below ``skills/custom``. Historical snapshots +are kept under the writable DeerFlow runtime directory, outside the canonical +skills tree and therefore outside every model-facing discovery/mount path. + +The feature is deliberately gated by ``system_settings.skill_versioning``. +When the switch is off, callers keep the legacy write behaviour and this +module performs no snapshot writes. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from deerflow.config.paths import get_paths +from deerflow.config.system_settings import load_system_settings +from deerflow.skills.storage import SkillStorage, get_or_new_skill_storage +from deerflow.skills.types import SKILL_MD_FILE, SkillCategory + +_MANIFEST_FILE = "manifest.json" +_SCHEMA_VERSION = 1 +_LOCKS_GUARD = threading.Lock() +_THREAD_LOCKS: dict[str, threading.RLock] = {} + + +def skill_versioning_enabled() -> bool: + """Return the deployment-wide custom-skill versioning switch.""" + + try: + return bool(load_system_settings().skill_versioning.enabled) + except Exception: + return False + + +def _thread_lock(name: str) -> threading.RLock: + with _LOCKS_GUARD: + return _THREAD_LOCKS.setdefault(name, threading.RLock()) + + +def _versions_root() -> Path: + return get_paths().base_dir / "skill-versions" + + +def _skill_root(name: str) -> Path: + return _versions_root() / SkillStorage.validate_skill_name(name) + + +def _manifest_path(name: str) -> Path: + return _skill_root(name) / _MANIFEST_FILE + + +def _version_dir(name: str, version: int) -> Path: + return _skill_root(name) / f"v{version:06d}" + + +@contextmanager +def _cross_process_lock(name: str) -> Iterator[None]: + """Serialize one skill's snapshot/switch operations across workers.""" + + root = _skill_root(name) + root.mkdir(parents=True, exist_ok=True) + lock_path = root / ".lock" + with _thread_lock(name): + with lock_path.open("a+b") as handle: + if os.name == "nt": # pragma: no cover - exercised on Windows hosts + import msvcrt + + if lock_path.stat().st_size == 0: + handle.write(b"0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + else: # pragma: no cover - exercised in Linux CI/deployments + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": # pragma: no cover + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: # pragma: no cover + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _empty_manifest(name: str) -> dict[str, Any]: + return { + "schema_version": _SCHEMA_VERSION, + "skill_name": name, + "active_version": None, + "versions": [], + } + + +def _load_manifest(name: str) -> dict[str, Any]: + path = _manifest_path(name) + if not path.is_file(): + return _empty_manifest(name) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Skill version manifest for '{name}' is unreadable") from exc + if not isinstance(payload, dict) or payload.get("skill_name") != name: + raise ValueError(f"Skill version manifest for '{name}' is invalid") + versions = payload.get("versions") + if not isinstance(versions, list): + raise ValueError(f"Skill version manifest for '{name}' has invalid versions") + return payload + + +def _save_manifest(name: str, manifest: dict[str, Any]) -> None: + path = _manifest_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".manifest-", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + os.replace(tmp_name, path) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + + +def _resolve_custom_dir(storage: SkillStorage, name: str) -> Path: + for skill in storage.load_skills(enabled_only=False): + if skill.name == name and skill.category == SkillCategory.CUSTOM: + return skill.skill_dir.resolve() + raise FileNotFoundError(f"Custom skill '{name}' not found") + + +def _tree_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root).as_posix() + if any(part in {"__pycache__", ".git"} for part in path.relative_to(root).parts): + continue + if path.is_symlink(): + digest.update(b"L\0") + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(os.readlink(path).encode("utf-8", errors="surrogateescape")) + continue + if not path.is_file(): + continue + digest.update(b"F\0") + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _copy_package(source: Path, destination: Path) -> None: + shutil.copytree( + source, + destination, + symlinks=True, + ignore=shutil.ignore_patterns("__pycache__", ".git"), + ) + + +def record_custom_skill_version( + name: str, + *, + storage: SkillStorage | None = None, + action: str, + author: str | None = None, + summary: str | None = None, +) -> dict[str, Any] | None: + """Snapshot the current custom package when it differs from active. + + Returns the active/new version metadata. No-op (and no filesystem writes) + while the deployment switch is disabled. + """ + + if not skill_versioning_enabled(): + return None + name = SkillStorage.validate_skill_name(name) + storage = storage or get_or_new_skill_storage() + source = _resolve_custom_dir(storage, name) + current_hash = _tree_hash(source) + + with _cross_process_lock(name): + manifest = _load_manifest(name) + active = next( + (item for item in manifest["versions"] if int(item.get("version") or 0) == int(manifest.get("active_version") or 0)), + None, + ) + if active is not None and active.get("sha256") == current_hash: + return {**active, "active": True} + + next_version = max([int(item.get("version") or 0) for item in manifest["versions"]] or [0]) + 1 + destination = _version_dir(name, next_version) + staging = destination.parent / f".{destination.name}.staging-{uuid4().hex}" + _copy_package(source, staging) + try: + os.replace(staging, destination) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + + now = datetime.now(UTC).isoformat() + entry = { + "version": next_version, + "sha256": current_hash, + "created_at": now, + "created_by": author, + "action": str(action or "update"), + "summary": str(summary or "").strip() or None, + "based_on_version": manifest.get("active_version"), + } + manifest["versions"].append(entry) + manifest["active_version"] = next_version + manifest["updated_at"] = now + _save_manifest(name, manifest) + return {**entry, "active": True} + + +def list_custom_skill_versions( + name: str, + *, + storage: SkillStorage | None = None, + author: str | None = None, +) -> dict[str, Any]: + """Return newest-first metadata, lazily bootstrapping the current package.""" + + if not skill_versioning_enabled(): + return {"enabled": False, "skill_name": name, "active_version": None, "versions": []} + storage = storage or get_or_new_skill_storage() + record_custom_skill_version( + name, + storage=storage, + action="baseline", + author=author, + summary="启用技能迭代时保存的当前版本", + ) + with _cross_process_lock(name): + manifest = _load_manifest(name) + active_version = manifest.get("active_version") + versions = [{**item, "active": int(item.get("version") or 0) == int(active_version or 0)} for item in reversed(manifest["versions"])] + return { + "enabled": True, + "skill_name": name, + "active_version": active_version, + "versions": versions, + } + + +def activate_custom_skill_version( + name: str, + version: int, + *, + storage: SkillStorage | None = None, +) -> dict[str, Any]: + """Materialize one historical snapshot as the sole model-visible package.""" + + if not skill_versioning_enabled(): + raise RuntimeError("技能迭代功能未开启") + name = SkillStorage.validate_skill_name(name) + storage = storage or get_or_new_skill_storage() + target = _resolve_custom_dir(storage, name) + + with _cross_process_lock(name): + manifest = _load_manifest(name) + entry = next( + (item for item in manifest["versions"] if int(item.get("version") or 0) == int(version)), + None, + ) + if entry is None: + raise FileNotFoundError(f"Skill '{name}' version {version} not found") + source = _version_dir(name, int(version)) + if not source.is_dir() or _tree_hash(source) != entry.get("sha256"): + raise ValueError(f"Skill '{name}' version {version} snapshot is missing or corrupted") + storage.validate_skill_markdown_content( + name, + (source / SKILL_MD_FILE).read_text(encoding="utf-8"), + ) + if int(manifest.get("active_version") or 0) == int(version) and _tree_hash(target) == entry.get("sha256"): + return {**entry, "active": True} + + parent = target.parent + staging = parent / f".{name}.version-stage-{uuid4().hex}" + backup = parent / f".{name}.version-backup-{uuid4().hex}" + _copy_package(source, staging) + moved_old = False + try: + os.replace(target, backup) + moved_old = True + os.replace(staging, target) + manifest["active_version"] = int(version) + manifest["updated_at"] = datetime.now(UTC).isoformat() + _save_manifest(name, manifest) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + if moved_old: + shutil.rmtree(target, ignore_errors=True) + os.replace(backup, target) + raise + finally: + if backup.exists() and target.exists(): + shutil.rmtree(backup, ignore_errors=True) + return {**entry, "active": True} + + +def delete_custom_skill_versions(name: str) -> None: + """Remove stored snapshots after the custom skill itself is deleted.""" + + name = SkillStorage.validate_skill_name(name) + root = _skill_root(name) + if root.exists(): + shutil.rmtree(root) + + +def reset_custom_skill_versions( + name: str, + *, + storage: SkillStorage | None = None, + author: str | None = None, + summary: str | None = None, +) -> dict[str, Any] | None: + """Discard the previous chain and make the current package the new v1. + + This is the explicit "direct overwrite" semantic offered beside + "iterate as a new version" for same-name uploads. + """ + + if not skill_versioning_enabled(): + return None + name = SkillStorage.validate_skill_name(name) + root = _skill_root(name) + with _cross_process_lock(name): + for child in list(root.iterdir()): + if child.name == ".lock": + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink(missing_ok=True) + return record_custom_skill_version( + name, + storage=storage, + action="overwrite", + author=author, + summary=summary or "直接覆盖后重置版本链", + ) + + +def reconcile_custom_skill_versions(*, storage: SkillStorage | None = None, author: str | None = None) -> int: + """Capture each current custom skill after the master switch is enabled.""" + + if not skill_versioning_enabled(): + return 0 + storage = storage or get_or_new_skill_storage() + captured = 0 + for skill in storage.load_skills(enabled_only=False): + if skill.category != SkillCategory.CUSTOM: + continue + before = _load_manifest(skill.name).get("active_version") if _manifest_path(skill.name).exists() else None + result = record_custom_skill_version( + skill.name, + storage=storage, + action="feature_enabled", + author=author, + summary="技能迭代开关开启后捕获当前内容", + ) + if result is not None and result.get("version") != before: + captured += 1 + return captured + + +__all__ = [ + "activate_custom_skill_version", + "delete_custom_skill_versions", + "list_custom_skill_versions", + "record_custom_skill_version", + "reconcile_custom_skill_versions", + "reset_custom_skill_versions", + "skill_versioning_enabled", +] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py index cfe684e7..b5d13168 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py @@ -273,7 +273,16 @@ def skill_view_tool(name: str, runtime: ToolRuntime) -> str: Args: name: The skill name (hyphen-case, e.g. 'my-skill'). """ - return skill_view_impl(name, _resolve_active_agent_id(runtime), _resolve_extra_skills(runtime)) + result = skill_view_impl(name, _resolve_active_agent_id(runtime), _resolve_extra_skills(runtime)) + head = result.lstrip()[:200].lower() + if not ((head.startswith("skill '") and "not found" in head) or head.startswith("error reading skill")): + try: + from deerflow.skills.usage import bump_use, runtime_use_event_key + + bump_use(name, event_key=runtime_use_event_key(runtime)) + except Exception: + pass + return result def _resolve_skills_container_base_path() -> str: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py index 837715a7..972cad28 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py @@ -161,6 +161,26 @@ async def _publish_managed_skill(name: str, *, removed: bool = False) -> None: await _sync_managed_skill_view(name) +async def _record_managed_skill_version( + storage: SkillStorage, + name: str, + *, + action: str, + author: str, + summary: str, +) -> None: + from deerflow.skills.versioning import record_custom_skill_version + + await _to_thread( + record_custom_skill_version, + name, + storage=storage, + action=action, + author=author, + summary=summary, + ) + + async def _bump_patch_best_effort(name: str) -> None: """记一次技能维护活动(patch / edit)。 @@ -363,6 +383,14 @@ async def _skill_manage_impl( # async stack — contextvars don't propagate into ``to_thread``. if skill_store is not None: await _ensure_skill_db_registered(skill_store, name, user_id) + skill_storage.invalidate_skills_cache() + await _record_managed_skill_version( + skill_storage, + name, + action="agent_create", + author=user_id, + summary="大模型创建技能", + ) await _publish_managed_skill(name) return f"Created custom skill '{name}'." @@ -378,6 +406,13 @@ async def _skill_manage_impl( scan = await _scan_or_raise(content, executable=False, location=f"{name}/{SKILL_MD_FILE}") skill_file = skill_storage.get_custom_skill_file(name) prev_content = await _to_thread(skill_file.read_text, encoding="utf-8") + await _record_managed_skill_version( + skill_storage, + name, + action="baseline", + author=user_id, + summary="大模型编辑前的活动版本", + ) await _to_thread(skill_storage.write_custom_skill, name, SKILL_MD_FILE, content) await _to_thread( skill_storage.append_history, @@ -385,6 +420,13 @@ async def _skill_manage_impl( _history_record(action="edit", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan), ) await _bump_patch_best_effort(name) + await _record_managed_skill_version( + skill_storage, + name, + action="agent_edit", + author=user_id, + summary="大模型编辑 SKILL.md", + ) await _publish_managed_skill(name) return f"Updated custom skill '{name}'." @@ -406,6 +448,13 @@ async def _skill_manage_impl( new_content = prev_content.replace(find, replace, replacement_count) await _to_thread(skill_storage.validate_skill_markdown_content, name, new_content) scan = await _scan_or_raise(new_content, executable=False, location=f"{name}/{SKILL_MD_FILE}") + await _record_managed_skill_version( + skill_storage, + name, + action="baseline", + author=user_id, + summary="大模型修补前的活动版本", + ) await _to_thread(skill_storage.write_custom_skill, name, SKILL_MD_FILE, new_content) await _to_thread( skill_storage.append_history, @@ -413,6 +462,13 @@ async def _skill_manage_impl( _history_record(action="patch", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan), ) await _bump_patch_best_effort(name) + await _record_managed_skill_version( + skill_storage, + name, + action="agent_patch", + author=user_id, + summary="大模型修补 SKILL.md", + ) await _publish_managed_skill(name) return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)." @@ -453,6 +509,12 @@ async def _skill_manage_impl( await skill_store.delete(name, user_id) except Exception: logger.debug("Failed to delete DB row for %r", name, exc_info=True) + try: + from deerflow.skills.versioning import delete_custom_skill_versions + + await _to_thread(delete_custom_skill_versions, name) + except Exception: + logger.debug("Failed to delete version history for %r", name, exc_info=True) agent_store = _default_agent_store() if agent_store is not None: try: @@ -482,12 +544,26 @@ async def _skill_manage_impl( prev_content = await _to_thread(target.read_text, encoding="utf-8") if exists else None executable = "scripts/" in path or path.startswith("scripts/") scan = await _scan_or_raise(content, executable=executable, location=f"{name}/{path}") + await _record_managed_skill_version( + skill_storage, + name, + action="baseline", + author=user_id, + summary="大模型写文件前的活动版本", + ) await _to_thread(skill_storage.write_custom_skill, name, path, content) await _to_thread( skill_storage.append_history, name, _history_record(action="write_file", file_path=path, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan), ) + await _record_managed_skill_version( + skill_storage, + name, + action="agent_write_file", + author=user_id, + summary=f"大模型写入文件 {path}", + ) await _sync_managed_skill_view(name) return f"Wrote '{path}' for custom skill '{name}'." @@ -502,12 +578,26 @@ async def _skill_manage_impl( if not await _to_thread(target.exists): raise FileNotFoundError(f"Supporting file '{path}' not found for skill '{name}'.") prev_content = await _to_thread(target.read_text, encoding="utf-8") + await _record_managed_skill_version( + skill_storage, + name, + action="baseline", + author=user_id, + summary="大模型删文件前的活动版本", + ) await _to_thread(target.unlink) await _to_thread( skill_storage.append_history, name, _history_record(action="remove_file", file_path=path, prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}), ) + await _record_managed_skill_version( + skill_storage, + name, + action="agent_remove_file", + author=user_id, + summary=f"大模型删除文件 {path}", + ) await _sync_managed_skill_view(name) return f"Removed '{path}' from custom skill '{name}'." diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_runtime_performance_config.py b/offline-backend-20260512/backend/packages/harness/tests/test_runtime_performance_config.py index 2af2ca4d..5c0e773a 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_runtime_performance_config.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_runtime_performance_config.py @@ -5,26 +5,28 @@ import pytest from deerflow.config.app_config import AppConfig _SKILL_VIEW_KEYS = ("skill_view_reuse_enabled", "skill_view_cache_enabled") -_OPT_IN_KEYS = ("model_timing_nonblocking_enabled", "asyncio_thread_pool_enabled", "tool_metrics_enabled") +_DEFAULT_ON_KEYS = (*_SKILL_VIEW_KEYS, "tool_metrics_enabled", "skill_metrics_enabled") +_OPT_IN_KEYS = ("model_timing_nonblocking_enabled", "asyncio_thread_pool_enabled") -def test_missing_runtime_performance_enables_skill_view_only(): +def test_missing_runtime_performance_enables_skill_view_and_skill_metrics(): config = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}) assert config.runtime_performance.model_dump() == { "skill_view_reuse_enabled": True, "skill_view_cache_enabled": True, - "tool_metrics_enabled": False, + "tool_metrics_enabled": True, + "skill_metrics_enabled": True, "model_timing_nonblocking_enabled": False, "asyncio_thread_pool_enabled": False, "asyncio_thread_pool_size": 100, } -@pytest.mark.parametrize("key", [*_SKILL_VIEW_KEYS, *_OPT_IN_KEYS]) +@pytest.mark.parametrize("key", [*_DEFAULT_ON_KEYS, *_OPT_IN_KEYS]) def test_runtime_performance_switches_are_independent(tmp_path: Path, key: str): - # Skill-view switches default on, so independence is checked by turning - # one of them off. The opt-in switches default off and are turned on. - enabled = key not in _SKILL_VIEW_KEYS + # Skill-view and audit metrics default on, so independence is checked by + # turning one off. Opt-in switches default off and are turned on. + enabled = key not in _DEFAULT_ON_KEYS config_path = tmp_path / "config.yaml" config_path.write_text( f"sandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\nruntime_performance:\n {key}: {'true' if enabled else 'false'}\n", @@ -32,7 +34,7 @@ def test_runtime_performance_switches_are_independent(tmp_path: Path, key: str): ) flags = AppConfig.from_file(str(config_path)).runtime_performance.model_dump() assert flags[key] is enabled - for other in _SKILL_VIEW_KEYS: + for other in _DEFAULT_ON_KEYS: if other != key: assert flags[other] is True for other in _OPT_IN_KEYS: diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_skill_usage_signals.py b/offline-backend-20260512/backend/packages/harness/tests/test_skill_usage_signals.py new file mode 100644 index 00000000..3e965440 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/tests/test_skill_usage_signals.py @@ -0,0 +1,95 @@ +"""Regression coverage for model-independent skill usage attribution.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from deerflow.sandbox import tools +from deerflow.skills import usage +from deerflow.tools.builtins import skill_tools + + +@pytest.fixture(autouse=True) +def _clear_recent_use_events(): + usage._recent_use_events.clear() + yield + usage._recent_use_events.clear() + + +def test_bump_use_counts_a_skill_once_per_run_and_syncs_the_catalog(tmp_path: Path, monkeypatch): + usage_path = tmp_path / ".usage.json" + projected: list[tuple[str, int, object]] = [] + monkeypatch.setattr(usage, "_usage_path", lambda: usage_path) + monkeypatch.setattr( + usage, + "_schedule_use_projection_increment", + lambda name, use_count, used_at: projected.append((name, use_count, used_at)), + ) + + assert usage.bump_use("page-generator", event_key="run-1") is True + assert usage.bump_use("page-generator", event_key="run-1") is False + assert usage.bump_use("page-generator", event_key="run-2") is True + + entry = usage.get_entry("page-generator") + assert entry["use_count"] == 2 + assert entry["last_used_at"] + assert [(name, count) for name, count, _used_at in projected] == [ + ("page-generator", 1), + ("page-generator", 2), + ] + + +def test_skill_file_and_script_paths_record_public_and_custom_skills(monkeypatch): + calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + usage, + "bump_use", + lambda name, *, event_key=None: calls.append((name, event_key)), + ) + runtime = SimpleNamespace(context={"run_id": "run-weak-model"}, config={}) + + tools._record_skill_read("/mnt/skills/public/report-maker/SKILL.md", runtime) + tools._record_skill_command( + "python /mnt/skills/custom/data-cleaner/scripts/run.py --fast", + runtime, + ) + + assert calls == [ + ("report-maker", "run-weak-model"), + ("data-cleaner", "run-weak-model"), + ] + + +def test_skill_view_counts_as_use_without_model_narration(monkeypatch): + calls: list[tuple[str, str | None]] = [] + runtime = SimpleNamespace(context={"run_id": "run-skill-view"}, config={}) + monkeypatch.setattr(skill_tools, "skill_view_impl", lambda *_args, **_kwargs: "skill instructions") + monkeypatch.setattr( + usage, + "bump_use", + lambda name, *, event_key=None: calls.append((name, event_key)), + ) + + result = skill_tools.skill_view_tool.func(name="report-maker", runtime=runtime) + + assert result == "skill instructions" + assert calls == [("report-maker", "run-skill-view")] + + +def test_failed_skill_view_does_not_count_as_use(monkeypatch): + calls: list[str] = [] + runtime = SimpleNamespace(context={"run_id": "run-missing"}, config={}) + monkeypatch.setattr( + skill_tools, + "skill_view_impl", + lambda *_args, **_kwargs: "Skill 'missing' not found.", + ) + monkeypatch.setattr(usage, "bump_use", lambda name, **_kwargs: calls.append(name)) + + result = skill_tools.skill_view_tool.func(name="missing", runtime=runtime) + + assert result == "Skill 'missing' not found." + assert calls == [] diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_skill_view_fast_path.py b/offline-backend-20260512/backend/packages/harness/tests/test_skill_view_fast_path.py index 84003ff2..d7e6a213 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_skill_view_fast_path.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_skill_view_fast_path.py @@ -288,7 +288,7 @@ def _tool_sandbox(tmp_path: Path, view_root: Path, monkeypatch: pytest.MonkeyPat monkeypatch.setattr(tools, "is_local_sandbox", lambda _runtime: True) monkeypatch.setattr(tools, "is_host_bash_allowed", lambda: True) monkeypatch.setattr(tools, "get_thread_data", lambda _runtime: {"workspace_path": str(tmp_path / "workspace"), "uploads_path": str(tmp_path / "uploads"), "outputs_path": str(tmp_path / "outputs")}) - monkeypatch.setattr(tools, "_record_skill_read", lambda _path: None) + monkeypatch.setattr(tools, "_record_skill_read", lambda *_args: None) monkeypatch.setattr(tools, "_get_skills_container_path", lambda: "/mnt/skills") (tmp_path / "workspace").mkdir(exist_ok=True) assert view_root.is_dir() @@ -326,6 +326,30 @@ def test_bash_runs_the_updated_skill_script(cache_setup, monkeypatch, tmp_path): assert (view.root / "public" / "online" / "scripts" / "run.py").read_text(encoding="utf-8") == "print('two')\n" +def test_bash_records_a_direct_skill_script_invocation(cache_setup, monkeypatch, tmp_path): + root, _config, storage = cache_setup + script = root / "public" / "online" / "scripts" + script.mkdir() + (script / "run.py").write_text("print('used')\n", encoding="utf-8") + view = skill_view.refresh_skill_sandbox_view(storage=storage) + _tool_sandbox(tmp_path, view.root, monkeypatch) + recorded: list[tuple[str, object]] = [] + monkeypatch.setattr( + tools, + "_record_skill_command", + lambda command, runtime: recorded.append((command, runtime)), + ) + + result = tools.bash_tool.func( + runtime=None, + description="test", + command="python /mnt/skills/public/online/scripts/run.py", + ) + + assert result == "ok" + assert recorded == [("python /mnt/skills/public/online/scripts/run.py", None)] + + def test_bash_rejects_an_offline_skill(cache_setup, monkeypatch, tmp_path): root, config, storage = cache_setup _skill(root, "offline") diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_internal_visibility.py b/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_internal_visibility.py index 8f689ef3..0ec50b82 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_internal_visibility.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_internal_visibility.py @@ -50,3 +50,21 @@ def test_canonical_write_file_is_still_recorded(monkeypatch) -> None: middleware.wrap_tool_call(_request("write_file"), lambda _: _result("write_file")) flush.assert_called_once() + + +def test_call_metrics_switch_skips_new_metric_rows(monkeypatch) -> None: + middleware = ToolMetricsMiddleware() + flush = Mock() + monkeypatch.setattr(middleware, "_flush", flush) + monkeypatch.setattr( + "deerflow.agents.middlewares.tool_metrics_middleware.get_call_metrics_settings", + lambda: SimpleNamespace(enabled=False), + ) + + result = middleware.wrap_tool_call( + _request("write_file"), + lambda _: _result("write_file"), + ) + + assert result.name == "write_file" + flush.assert_not_called() diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_pause.py b/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_pause.py index 904e2793..6ab454fb 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_pause.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_tool_metrics_pause.py @@ -20,8 +20,10 @@ from deerflow.config.app_config import AppConfig BUILDERS = [build_lead_runtime_middlewares, build_subagent_runtime_middlewares] -def _config(enabled=None): +def _config(enabled=None, *, skill_enabled=None): options = {} if enabled is None else {"tool_metrics_enabled": enabled} + if skill_enabled is not None: + options["skill_metrics_enabled"] = skill_enabled return AppConfig( sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, runtime_performance=options, @@ -29,18 +31,29 @@ def _config(enabled=None): @pytest.mark.parametrize("builder", BUILDERS) -@pytest.mark.parametrize("enabled", [None, False]) -def test_paused_runtime_never_constructs_a_recorder(builder, enabled, monkeypatch): +def test_explicitly_paused_runtime_never_constructs_a_recorder(builder, monkeypatch): constructor = Mock(side_effect=AssertionError("paused runtime constructed a DB recorder")) monkeypatch.setattr(metrics, "ToolMetricsMiddleware", constructor) - middlewares = builder(app_config=_config(enabled)) + middlewares = builder(app_config=_config(False, skill_enabled=False)) constructor.assert_not_called() assert any(isinstance(m, ToolErrorHandlingMiddleware) for m in middlewares) assert any(isinstance(m, LlmMetricsMiddleware) for m in middlewares) +@pytest.mark.parametrize("builder", BUILDERS) +def test_default_runtime_keeps_a_full_tool_and_skill_recorder(builder): + recorders = [ + middleware + for middleware in builder(app_config=_config()) + if isinstance(middleware, metrics.ToolMetricsMiddleware) + ] + + assert len(recorders) == 1 + assert recorders[0]._skill_only is False + + def _tool_wrappers(builder, enabled): # Exercise the actual error/audit wrappers selected by both runtime builders. return [m for m in builder(app_config=_config(enabled)) if isinstance(m, (ToolErrorHandlingMiddleware, metrics.ToolMetricsMiddleware))] @@ -77,6 +90,30 @@ def test_sync_tool_execution_and_error_conversion_survive_pause(builder, enabled assert flushed.call_args.args[0].status == result.status +@pytest.mark.parametrize("builder", BUILDERS) +def test_skill_only_mode_records_a_path_based_skill_call(builder, monkeypatch): + flushed = Mock() + monkeypatch.setattr(metrics.ToolMetricsMiddleware, "_flush", lambda _, record: flushed(record)) + request = SimpleNamespace( + tool_call={ + "id": "call-skill", + "name": "bash", + "args": {"command": "python /mnt/skills/public/report-maker/scripts/run.py"}, + } + ) + expected = ToolMessage(content="ok", tool_call_id="call-skill", name="bash") + + def handler(_request): + return expected + + for middleware in reversed(_tool_wrappers(builder, False)): + handler = partial(middleware.wrap_tool_call, handler=handler) + + assert handler(request) is expected + assert flushed.call_count == 1 + assert flushed.call_args.args[0].skill_name == "report-maker" + + @pytest.mark.asyncio @pytest.mark.parametrize("builder", BUILDERS) @pytest.mark.parametrize("enabled", [False, True]) diff --git a/offline-backend-20260512/backend/tests/test_admin_leaderboard_query_shape.py b/offline-backend-20260512/backend/tests/test_admin_leaderboard_query_shape.py index c16f805d..bc0968ee 100644 --- a/offline-backend-20260512/backend/tests/test_admin_leaderboard_query_shape.py +++ b/offline-backend-20260512/backend/tests/test_admin_leaderboard_query_shape.py @@ -1,9 +1,16 @@ from __future__ import annotations +from datetime import UTC, datetime + +import pytest from sqlalchemy import select from sqlalchemy.dialects import mysql -from app.gateway.routers.admin_users import _apply_run_scope, _apply_tool_scope +from app.gateway.routers.admin_users import ( + _apply_run_scope, + _apply_tool_scope, + _query_leaderboard_dimension_details, +) from deerflow.persistence.run.model import RunRow from deerflow.persistence.tool_metrics.model import ToolCallMetricRow @@ -41,3 +48,83 @@ def test_tool_aggregation_does_not_join_the_full_runs_table() -> None: assert sql.count("NOT (EXISTS") == 2 assert "LEFT OUTER JOIN" not in sql assert " NOT IN " not in sql + + +class _DetailResult: + def __init__(self, *, rows=None, row=None): + self._rows = rows or [] + self._row = row + + def all(self): + return self._rows + + def one(self): + return self._row + + +class _DetailSession: + def __init__(self): + self.statements = [] + self.results = [ + _DetailResult(rows=[("web-search", 3, 2, 0, 125.5)]), + _DetailResult(row=(3, 1, 2, 0, 125.5)), + ] + + async def execute(self, statement): + self.statements.append(statement) + return self.results.pop(0) + + +@pytest.mark.asyncio +async def test_skill_detail_uses_metric_table_and_returns_drawer_aggregates() -> None: + session = _DetailSession() + + summary, items = await _query_leaderboard_dimension_details( + session, + datetime(2026, 11, 1, tzinfo=UTC), + datetime(2026, 11, 7, tzinfo=UTC), + kind="skill", + limit=500, + exclude_user_ids=set(), + include_scheduled=False, + include_admins=True, + include_failed=True, + ) + + assert summary.count == 3 + assert summary.item_count == 1 + assert items[0].name == "web-search" + assert items[0].avg_duration_ms == 125 + + sql = "\n".join(_mysql_sql(statement) for statement in session.statements) + assert "TOOL_CALL_METRICS" in sql + assert "LEFT OUTER JOIN" not in sql + assert " NOT IN " not in sql + + +@pytest.mark.asyncio +async def test_agent_detail_returns_token_aggregates_from_runs() -> None: + session = _DetailSession() + session.results = [ + _DetailResult(rows=[("lead_agent", 4, 2, 960, 0)]), + _DetailResult(row=(4, 1, 2, 960, 0)), + ] + + summary, items = await _query_leaderboard_dimension_details( + session, + datetime(2026, 11, 1, tzinfo=UTC), + datetime(2026, 11, 7, tzinfo=UTC), + kind="agent", + limit=500, + exclude_user_ids=set(), + include_scheduled=False, + include_admins=True, + include_failed=True, + ) + + assert summary.total_tokens == 960 + assert items[0].avg_duration_ms == 0 + + sql = "\n".join(_mysql_sql(statement) for statement in session.statements) + assert "FROM RUNS" in sql + assert "SCHEDULED_TASK_RUNS" in sql diff --git a/offline-backend-20260512/backend/tests/test_api_read_cache.py b/offline-backend-20260512/backend/tests/test_api_read_cache.py index 08d84592..f635bac2 100644 --- a/offline-backend-20260512/backend/tests/test_api_read_cache.py +++ b/offline-backend-20260512/backend/tests/test_api_read_cache.py @@ -405,6 +405,23 @@ async def test_skill_catalog_projection_uses_compact_rows_without_long_fields( assert rows[0]["description"] == "compact metadata" assert "detail" not in rows[0] assert "detail" not in {column.key for column in skill_catalog_cache._SKILL_LIST_COLUMNS} + + offline = SimpleNamespace( + name="catalog-offline", + description="stays untouched", + license=None, + category="custom", + enabled=True, + always_on=False, + skill_file=skill_file, + ) + assert await skill_catalog_cache.refresh_skill_catalog_projection(skills=[skill, offline]) == 2 + assert await skill_catalog_cache.set_skill_catalog_enabled("catalog-demo", False) is True + assert await skill_catalog_cache.set_skill_catalog_enabled("missing-skill", False) is False + rows = await skill_catalog_cache.load_skill_catalog_projection(user_id="user-1", admin=True) + by_name = {row["name"]: row for row in rows or []} + assert by_name["catalog-demo"]["enabled"] is False + assert by_name["catalog-offline"]["enabled"] is True await engine.dispose() diff --git a/offline-backend-20260512/backend/tests/test_app_identity.py b/offline-backend-20260512/backend/tests/test_app_identity.py index 9baed35b..498c0858 100644 --- a/offline-backend-20260512/backend/tests/test_app_identity.py +++ b/offline-backend-20260512/backend/tests/test_app_identity.py @@ -12,6 +12,7 @@ PNG = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" "890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082" ) +SVG = b'' def _store(monkeypatch: pytest.MonkeyPatch, tmp_path): @@ -41,6 +42,10 @@ def _store(monkeypatch: pytest.MonkeyPatch, tmp_path): def test_new_settings_leave_app_identity_empty() -> None: assert SystemSettings().app_identity == AppIdentitySettings() + assert SystemSettings().app_identity.header_title_font_mode == "adaptive" + assert SystemSettings().app_identity.header_title_font_size == 30 + assert SystemSettings().app_identity.light_red_header_background_filename is None + assert SystemSettings().app_identity.light_red_nav_active_filename is None @pytest.mark.asyncio @@ -84,6 +89,58 @@ async def test_admin_saves_name_without_replacing_the_static_logo( assert current["settings"].app_identity.logo_filename is None +@pytest.mark.asyncio +async def test_admin_saves_header_title_font_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + current = _store(monkeypatch, tmp_path) + + response = await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="研判中心", + remove_logo=False, + logo=None, + header_title_font_mode="fixed", + header_title_font_size=34, + ) + + assert response.header_title_font_mode == "fixed" + assert response.header_title_font_size == 34 + assert current["settings"].app_identity.header_title_font_mode == "fixed" + assert current["settings"].app_identity.header_title_font_size == 34 + + renamed = await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="新研判中心", + remove_logo=False, + logo=None, + ) + + assert renamed.header_title_font_mode == "fixed" + assert renamed.header_title_font_size == 34 + + +@pytest.mark.asyncio +async def test_header_title_fixed_font_size_is_bounded( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _store(monkeypatch, tmp_path) + + with pytest.raises(HTTPException) as error: + await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="研判中心", + remove_logo=False, + logo=None, + header_title_font_mode="fixed", + header_title_font_size=41, + ) + + assert error.value.status_code == 422 + + @pytest.mark.asyncio async def test_uploaded_logo_is_served_separately_from_generated_pages( monkeypatch: pytest.MonkeyPatch, @@ -126,6 +183,91 @@ async def test_invalid_logo_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_pat assert error.value.status_code == 422 +@pytest.mark.asyncio +async def test_red_navigation_images_can_be_uploaded_and_served( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + current = _store(monkeypatch, tmp_path) + + saved = await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="研判中心", + remove_logo=False, + logo=None, + light_red_header_background=UploadFile( + file=BytesIO(SVG), + filename="header.svg", + ), + light_red_nav_active=UploadFile( + file=BytesIO(PNG), + filename="active.png", + ), + ) + + assert saved.has_light_red_header_background is True + assert saved.has_light_red_nav_active is True + assert saved.light_red_header_background_url is not None + assert saved.light_red_header_background_url.startswith( + "/api/public/app-identity/light-red-header-background?v=" + ) + assert saved.light_red_nav_active_url is not None + assert saved.light_red_nav_active_url.startswith( + "/api/public/app-identity/light-red-nav-active?v=" + ) + identity = current["settings"].app_identity + header_file = tmp_path / "app-identity" / identity.light_red_header_background_filename + active_file = tmp_path / "app-identity" / identity.light_red_nav_active_filename + assert header_file.is_file() + assert header_file.suffix == ".svg" + assert active_file.is_file() + + served_header = await app_identity_router.get_light_red_header_background() + served_active = await app_identity_router.get_light_red_nav_active() + assert served_header.media_type == "image/svg+xml" + assert served_header.headers["content-security-policy"].startswith("sandbox;") + assert served_active.media_type == "image/png" + + +@pytest.mark.asyncio +async def test_restoring_red_navigation_images_uses_built_in_defaults( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + current = _store(monkeypatch, tmp_path) + await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="研判中心", + remove_logo=False, + logo=None, + light_red_header_background=UploadFile( + file=BytesIO(PNG), + filename="header.png", + ), + light_red_nav_active=UploadFile( + file=BytesIO(PNG), + filename="active.png", + ), + ) + identity = current["settings"].app_identity + header_file = tmp_path / "app-identity" / identity.light_red_header_background_filename + active_file = tmp_path / "app-identity" / identity.light_red_nav_active_filename + + restored = await app_identity_router.update_app_identity_settings( + SimpleNamespace(), + system_name="研判中心", + remove_logo=False, + logo=None, + remove_light_red_header_background=True, + remove_light_red_nav_active=True, + ) + + assert restored.has_light_red_header_background is False + assert restored.has_light_red_nav_active is False + assert header_file.exists() is False + assert active_file.exists() is False + + @pytest.mark.asyncio async def test_clearing_app_identity_removes_only_its_logo( monkeypatch: pytest.MonkeyPatch, @@ -144,6 +286,8 @@ async def test_clearing_app_identity_removes_only_its_logo( cleared = await app_identity_router.delete_app_identity_settings(SimpleNamespace()) assert cleared.system_name == "" + assert cleared.header_title_font_mode == "adaptive" + assert cleared.header_title_font_size == 30 assert cleared.has_logo is False assert logo_file.exists() is False with pytest.raises(HTTPException) as error: diff --git a/offline-backend-20260512/backend/tests/test_artifact_preview.py b/offline-backend-20260512/backend/tests/test_artifact_preview.py index d888cb1c..15f21095 100644 --- a/offline-backend-20260512/backend/tests/test_artifact_preview.py +++ b/offline-backend-20260512/backend/tests/test_artifact_preview.py @@ -1,11 +1,15 @@ from __future__ import annotations +import json +import zipfile from pathlib import Path from types import SimpleNamespace import pytest from app.gateway.routers import artifacts +from deerflow.html_runtime import offline_bundle +from deerflow.html_runtime.offline_bundle import build_html_offline_bundle from deerflow.uploads.policy import default_upload_policy, set_runtime_upload_policy @@ -91,3 +95,165 @@ async def test_artifact_preview_rejects_binary_content(tmp_path: Path, monkeypat max_chars=1024, ) assert raised.value.status_code == 415 + + +@pytest.mark.asyncio +async def test_html_artifact_offline_bundle_uses_zip_export( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "page.html" + source.write_text("portable", encoding="utf-8") + calls: list[Path] = [] + + async def resolve(_thread_id: str, _path: str) -> Path: + return source + + async def bundle_response(path: Path): + calls.append(path) + return artifacts.Response(content=b"zip-content", media_type="application/zip") + + monkeypatch.setattr(artifacts, "aresolve_thread_virtual_path", resolve) + monkeypatch.setattr(artifacts, "build_html_offline_bundle_response", bundle_response) + response = await artifacts.get_artifact.__wrapped__( + thread_id="thread-1", + path="mnt/user-data/outputs/page.html", + request=SimpleNamespace(), + download=True, + offline_bundle=True, + ) + + assert response.media_type == "application/zip" + assert calls == [source] + + +def _write_offline_fixture(path: Path, content: str | bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + + +def _offline_runtime_fixture(root: Path) -> Path: + runtime = root / "runtime" + _write_offline_fixture(runtime / "echarts.min.js", "window.echarts={};") + _write_offline_fixture(runtime / "deerflow-runtime.js", "window.__runtimeLoaded=true;") + _write_offline_fixture(runtime / "brand-logo-dark.png", b"fallback-logo") + _write_offline_fixture(runtime / "LICENSE.echarts.txt", "ECharts license") + for name in ( + "china-provinces.geojson", + "japan-prefectures.geojson", + "taiwan-counties.geojson", + "us-states.geojson", + ): + _write_offline_fixture(runtime / "maps" / name, '{"type":"FeatureCollection","features":[]}') + return runtime + + +def test_html_offline_bundle_rewrites_and_packages_gateway_runtime( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = _offline_runtime_fixture(tmp_path) + logo = tmp_path / "branding" / "logo.webp" + _write_offline_fixture(logo, b"configured-logo") + uploaded_image = tmp_path / "uploads" / "background.png" + _write_offline_fixture(uploaded_image, b"uploaded-image") + monkeypatch.setattr( + offline_bundle, + "verify_html_image_token", + lambda _token: ("thread-1", "uploads", "background.png"), + ) + monkeypatch.setattr( + offline_bundle, + "resolve_html_image_path", + lambda _thread_id, _kind, _filename: uploaded_image, + ) + source = tmp_path / "态势报告.html" + source.write_text( + """ + + + + + + + 来源""", + encoding="utf-8", + ) + destination = tmp_path / "bundle.zip" + + result = build_html_offline_bundle( + source, + destination, + runtime_base_url="http://gateway.local/prefix/api/public/html-runtime", + map_tile_url="https://tiles.example/{z}/{x}/{y}.png", + generated_logo_path=logo, + runtime_dir=runtime, + ) + + assert destination.is_file() + assert "assets/html-runtime/echarts.min.js" in result.bundled_assets + assert "https://example.com/source" in result.external_urls + assert "https://tiles.example/{z}/{x}/{y}.png" in result.external_urls + with zipfile.ZipFile(destination) as archive: + names = set(archive.namelist()) + assert "index.html" in names + assert "README.txt" in names + assert "manifest.json" in names + assert "assets/html-runtime/echarts.min.js" in names + assert "assets/html-runtime/deerflow-runtime.js" in names + assert "assets/html-runtime/generated-page-logo.webp" in names + assert "assets/html-runtime/maps/china-provinces.geojson" in names + assert any(name.startswith("assets/html-images/") for name in names) + assert "THIRD_PARTY_LICENSES/LICENSE.echarts.txt" in names + + html = archive.read("index.html").decode("utf-8") + assert "gateway.local" not in html + assert 'src="assets/html-runtime/echarts.min.js"' in html + assert "assets/html-runtime/generated-page-logo.webp" in html + assert "/api/public/html-images/" not in html + + runtime_script = archive.read("assets/html-runtime/deerflow-runtime.js").decode("utf-8") + assert "DEERFLOW_HTML_RUNTIME_CONFIG" in runtime_script + assert "assets/html-runtime/maps/china-provinces.geojson" in runtime_script + assert "window.__runtimeLoaded=true" in runtime_script + + manifest = json.loads(archive.read("manifest.json")) + assert manifest["entry"] == "index.html" + assert manifest["source_name"] == "态势报告.html" + assert manifest["external_urls"] == [ + "https://example.com/source", + "https://tiles.example/{z}/{x}/{y}.png", + ] + + +def test_html_offline_bundle_copies_cognitive_runtime_tree(tmp_path: Path) -> None: + runtime = _offline_runtime_fixture(tmp_path) + _write_offline_fixture(runtime / "cognitive-situation-map" / "app.js", "window.cognitive=true;") + _write_offline_fixture(runtime / "cognitive-situation-map" / "styles.css", "body{color:white}") + _write_offline_fixture(runtime / "cognitive-situation-map" / "assets" / "maps" / "taiwan.png", b"map") + source = tmp_path / "认知态势图.html" + source.write_text( + """ + + """, + encoding="utf-8", + ) + destination = tmp_path / "cognitive.zip" + + build_html_offline_bundle( + source, + destination, + runtime_base_url="http://gw/api/public/html-runtime", + map_tile_url="https://tiles.example/{z}/{x}/{y}.png", + runtime_dir=runtime, + ) + + with zipfile.ZipFile(destination) as archive: + html = archive.read("index.html").decode("utf-8") + assert "http://gw" not in html + assert "assets/html-runtime/cognitive-situation-map" in html + assert "assets/html-runtime/cognitive-situation-map/app.js" in archive.namelist() + assert "assets/html-runtime/cognitive-situation-map/assets/maps/taiwan.png" in archive.namelist() diff --git a/offline-backend-20260512/backend/tests/test_catalog_cache_routes.py b/offline-backend-20260512/backend/tests/test_catalog_cache_routes.py index ae01c9cc..a16becdd 100644 --- a/offline-backend-20260512/backend/tests/test_catalog_cache_routes.py +++ b/offline-backend-20260512/backend/tests/test_catalog_cache_routes.py @@ -139,8 +139,12 @@ async def test_html_edit_invalidates_metadata_content_and_list(cached_request, m request.state.user.system_role = "admin" state = {"html": "old"} monkeypatch.setattr(html_templates.template_store, "read_template_html", lambda _id: state["html"]) + # content 端点默认走引擎的「完整文档」读取(结构化模板会组合默认数据)。 + monkeypatch.setattr(html_templates.template_fill, "read_template_text", lambda _id: state["html"]) monkeypatch.setattr(html_templates.template_store, "list_templates", lambda: [{"name": state["html"]}]) - monkeypatch.setattr(html_templates.template_store, "get_template_meta", lambda _id: {"name": state["html"]}) + monkeypatch.setattr( + html_templates.template_store, "get_template_meta", lambda _id: {"id": _id, "name": state["html"]} + ) def update(_id, **kwargs): state["html"] = kwargs["html"] diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 2181206b..595bc575 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -355,10 +355,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo return {"id": "local-kb", "weknora_id": "remote-kb"} async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]: - return [ - {"id": "local-kb", "weknora_id": "remote-kb"}, - {"id": "public-kb", "weknora_id": "other-public-kb"}, - ] + raise AssertionError("creating an iframe session must not load every visible knowledge base") monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object()) monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping) @@ -387,7 +384,39 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert ctx.mapping_id == "local-kb" assert ctx.weknora_id == "remote-kb" assert ctx.can_write is True - assert ctx.allowed_weknora_ids == ("remote-kb", "other-public-kb") + assert ctx.allowed_weknora_ids == () + assert len(raw) < 1024 + + +@pytest.mark.asyncio +async def test_weknora_embed_picker_hydrates_visible_bases_server_side(monkeypatch: pytest.MonkeyPatch) -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + calls: list[dict[str, object]] = [] + + class Store: + async def list_visible(self, user_id: str, *, scope: str, is_admin: bool) -> list[dict[str, str]]: + calls.append({"user_id": user_id, "scope": scope, "is_admin": is_admin}) + return [ + {"id": "local-kb", "weknora_id": "remote-kb"}, + {"id": "public-kb", "weknora_id": "other-public-kb"}, + ] + + monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) + ctx = llmwiki_router.WeKnoraEmbedContext( + mapping_id="local-kb", + weknora_id="remote-kb", + user_id="user-a", + is_admin=False, + can_write=True, + exp=4_102_444_800, + ) + + hydrated = await llmwiki_router._with_visible_embed_knowledge_bases(_request("/platform/knowledge-bases/remote-kb"), ctx) + + assert calls == [{"user_id": "user-a", "scope": "all", "is_admin": False}] + assert hydrated.allowed_weknora_ids == ("remote-kb", "other-public-kb") + assert "allowed_weknora_ids" not in llmwiki_router._embed_context_payload(hydrated) @pytest.mark.asyncio @@ -2079,6 +2108,9 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_ async def get_authorized(self, *_: object, **__: object) -> dict[str, str]: return {"id": "local-kb", "weknora_id": "remote-kb"} + async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]: + return [{"id": "local-kb", "weknora_id": "remote-kb"}] + runtime = SimpleNamespace( web_base_url="http://weknora-web.test", api_base_url="http://weknora-api.test", @@ -2207,9 +2239,28 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_ api = await client.get("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me") assert api.status_code == 200 + directory = await client.get("/deerflow/api/llmwiki/weknora-embed/api/v1/knowledge-bases") + assert directory.status_code == 200 + assert directory.json() == { + "success": True, + "data": [ + { + "id": "remote-kb", + "name": "", + "description": "", + "type": "document", + "status": "active", + "created_at": None, + "updated_at": None, + } + ], + "total": 1, + } + api_request = next(request for request in upstream_requests if request[1].startswith(runtime.api_base_url)) assert api_request[2]["Authorization"] == "Bearer admin-token" assert api_request[2]["X-Tenant-ID"] == "tenant-1" + assert not any(request[1].endswith("/api/v1/knowledge-bases") for request in upstream_requests) def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None: @@ -2334,7 +2385,6 @@ async def test_weknora_iframe_switch_rechecks_selected_mapping_permissions( is_admin=False, can_write=True, exp=4_102_444_800, - allowed_weknora_ids=("remote-kb", "other-visible-kb"), ) selected = await llmwiki_router._switch_embed_context( @@ -2382,12 +2432,16 @@ async def test_weknora_platform_proxy_reissues_cookie_for_native_kb_switch( seen["target"] = weknora_id return selected + async def hydrate_visible_bases(_: Request, ctx: object) -> object: + return ctx + async def proxy_request(*_: object, **kwargs: object) -> Response: seen.update(kwargs) return Response("ok") monkeypatch.setattr(llmwiki_router, "_read_embed_context", read_context) monkeypatch.setattr(llmwiki_router, "_switch_embed_context", switch_context) + monkeypatch.setattr(llmwiki_router, "_with_visible_embed_knowledge_bases", hydrate_visible_bases) monkeypatch.setattr( llmwiki_router, "_runtime_for_iframe_proxy", diff --git a/offline-backend-20260512/backend/tests/test_prompt_prefix.py b/offline-backend-20260512/backend/tests/test_prompt_prefix.py index 0c2c8e55..42811b1d 100644 --- a/offline-backend-20260512/backend/tests/test_prompt_prefix.py +++ b/offline-backend-20260512/backend/tests/test_prompt_prefix.py @@ -40,7 +40,7 @@ def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None: assert settings.conversation_context.recent_turn_window_enabled is False assert settings.conversation_context.max_conversation_rounds == 20 assert settings.write_file_compatibility.enabled is False - assert settings.conversation_context.recent_turn_window_enabled is False + assert settings.call_metrics.enabled is True def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None: @@ -325,6 +325,28 @@ async def test_admin_can_toggle_write_file_compatibility_without_restart( assert ss.get_write_file_compatibility_settings().enabled is False +@pytest.mark.asyncio +async def test_admin_can_toggle_call_metrics_without_restart( + tmp_runtime_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_admin(monkeypatch) + + disabled = await ss_router.update_call_metrics_setting( + ss_router.CallMetricsUpdateRequest(enabled=False), + SimpleNamespace(), + ) + assert disabled.enabled is False + assert ss.get_call_metrics_settings().enabled is False + + enabled = await ss_router.update_call_metrics_setting( + ss_router.CallMetricsUpdateRequest(enabled=True), + SimpleNamespace(), + ) + assert enabled.enabled is True + assert ss.get_call_metrics_settings().enabled is True + + def test_prepend_prefix_tags_first_human_without_touching_content() -> None: graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]} out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度") diff --git a/offline-backend-20260512/backend/tests/test_roundtable_room_limit.py b/offline-backend-20260512/backend/tests/test_roundtable_room_limit.py index 580ceeee..b3ee56bc 100644 --- a/offline-backend-20260512/backend/tests/test_roundtable_room_limit.py +++ b/offline-backend-20260512/backend/tests/test_roundtable_room_limit.py @@ -69,6 +69,7 @@ async def _admit(repo: RoundtableRoomRepository, draft_id: str, user_id: str, ** room_session_id=kwargs.get("room_session_id"), enabled=kwargs.get("enabled", True), max_rooms=kwargs.get("max_rooms", 3), + lease_seconds=kwargs.get("lease_seconds", 5 * 60), extra_keys=kwargs.get("extra_keys"), ) @@ -257,6 +258,44 @@ async def test_delayed_heartbeat_cannot_resurrect_a_released_room(rooms) -> None assert next_room["ok"] is True +@pytest.mark.asyncio +async def test_foreground_lease_defaults_to_five_minutes_and_honors_override(rooms) -> None: + repo, factory = rooms + before = datetime.now(UTC) + admitted = await _admit( + repo, + "draft-lease", + "user-a", + room_session_id="lease-session", + ) + assert admitted["ok"] is True + key = compute_room_session_key("lease-session") + assert key is not None + async with factory() as session: + row = await session.get(RoundtableRoomLeaseRow, key) + assert row is not None and row.lease_until is not None + until = row.lease_until if row.lease_until.tzinfo else row.lease_until.replace(tzinfo=UTC) + remaining = until - before + assert timedelta(minutes=4, seconds=50) <= remaining <= timedelta(minutes=5, seconds=5) + + renewed = await repo.renew( + draft_id="draft-lease", + user_id="user-a", + task_id=None, + room_session_id="lease-session", + enabled=True, + max_rooms=3, + lease_seconds=120, + ) + assert renewed["ok"] is True + async with factory() as session: + row = await session.get(RoundtableRoomLeaseRow, key) + assert row is not None and row.lease_until is not None + until = row.lease_until if row.lease_until.tzinfo else row.lease_until.replace(tzinfo=UTC) + renewed_for = until - datetime.now(UTC) + assert timedelta(seconds=90) <= renewed_for <= timedelta(seconds=125) + + @pytest.mark.asyncio async def test_stale_run_for_leased_draft_does_not_consume_a_second_room(rooms) -> None: """Run-manager fallback must not double-count the authoritative lease.""" @@ -744,6 +783,7 @@ async def test_admin_can_change_roundtable_room_limit_without_restart( assert ss.get_roundtable_room_limit_settings().enabled is True assert ss.get_roundtable_room_limit_settings().max_rooms == 3 assert ss.get_roundtable_room_limit_settings().max_hold_minutes == 30 + assert ss.get_roundtable_room_limit_settings().lease_minutes == 5 assert ss.get_roundtable_room_limit_settings().max_cycles == 8 assert ss.get_roundtable_room_limit_settings().write_file_compatibility_enabled is False @@ -752,6 +792,7 @@ async def test_admin_can_change_roundtable_room_limit_without_restart( enabled=False, max_rooms=5, max_hold_minutes=45, + lease_minutes=8, max_cycles=12, ), SimpleNamespace(), @@ -759,9 +800,11 @@ async def test_admin_can_change_roundtable_room_limit_without_restart( assert saved.enabled is False assert saved.max_rooms == 5 assert saved.max_hold_minutes == 45 + assert saved.lease_minutes == 8 assert saved.max_cycles == 12 assert ss.load_system_settings().roundtable_room_limit.max_rooms == 5 assert ss.load_system_settings().roundtable_room_limit.max_hold_minutes == 45 + assert ss.load_system_settings().roundtable_room_limit.lease_minutes == 8 assert ss.load_system_settings().roundtable_room_limit.max_cycles == 12 assert saved.write_file_compatibility_enabled is False diff --git a/offline-backend-20260512/backend/tests/test_skill_versioning.py b/offline-backend-20260512/backend/tests/test_skill_versioning.py new file mode 100644 index 00000000..a4350425 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_skill_versioning.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import io +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import UploadFile + +from deerflow.skills.storage import LocalSkillStorage +from deerflow.skills.types import SkillCategory + + +def _skill_markdown(version: str) -> str: + return f"""--- +name: demo-skill +description: Demo custom skill +--- + +# Demo {version} +""" + + +@pytest.fixture +def versioned_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from deerflow.skills import versioning + + skills_root = tmp_path / "skills" + skill_dir = skills_root / "custom" / "demo-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(_skill_markdown("v1"), encoding="utf-8") + (skill_dir / "assets").mkdir() + (skill_dir / "assets" / "payload.txt").write_text("asset-v1", encoding="utf-8") + storage = LocalSkillStorage(host_path=str(skills_root)) + enabled = SimpleNamespace(enabled=True) + monkeypatch.setattr( + versioning, + "load_system_settings", + lambda: SimpleNamespace(skill_versioning=enabled), + ) + monkeypatch.setattr( + versioning, + "get_paths", + lambda: SimpleNamespace(base_dir=tmp_path / "runtime"), + ) + return versioning, storage, skill_dir, enabled, tmp_path + + +def test_versioning_is_noop_while_switch_is_disabled(versioned_skill) -> None: + versioning, storage, _skill_dir, enabled, tmp_path = versioned_skill + enabled.enabled = False + + assert versioning.record_custom_skill_version("demo-skill", storage=storage, action="upload") is None + assert not (tmp_path / "runtime" / "skill-versions").exists() + + +def test_complete_package_versions_switch_without_resetting_usage(versioned_skill, monkeypatch: pytest.MonkeyPatch) -> None: + versioning, storage, skill_dir, _enabled, tmp_path = versioned_skill + import deerflow.skills.storage as storage_package + from deerflow.skills import usage + + monkeypatch.setattr(storage_package, "get_or_new_skill_storage", lambda: storage) + monkeypatch.setattr(usage, "_schedule_use_projection_increment", lambda *args: None) + + first = versioning.record_custom_skill_version("demo-skill", storage=storage, action="upload") + assert first and first["version"] == 1 + assert usage.bump_use("demo-skill") is True + assert usage.bump_use("demo-skill") is True + + (skill_dir / "SKILL.md").write_text(_skill_markdown("v2"), encoding="utf-8") + (skill_dir / "assets" / "payload.txt").write_text("asset-v2", encoding="utf-8") + second = versioning.record_custom_skill_version("demo-skill", storage=storage, action="upload_iteration") + assert second and second["version"] == 2 + + listed = versioning.list_custom_skill_versions("demo-skill", storage=storage) + assert listed["active_version"] == 2 + assert [item["version"] for item in listed["versions"]] == [2, 1] + + versioning.activate_custom_skill_version("demo-skill", 1, storage=storage) + assert "Demo v1" in (skill_dir / "SKILL.md").read_text(encoding="utf-8") + assert (skill_dir / "assets" / "payload.txt").read_text(encoding="utf-8") == "asset-v1" + assert usage.get_entry("demo-skill")["use_count"] == 2 + + snapshot_root = tmp_path / "runtime" / "skill-versions" / "demo-skill" + assert snapshot_root.is_dir() + assert not snapshot_root.is_relative_to(skills_root := storage.get_skills_root_path()) + assert [skill.name for skill in storage.load_skills(enabled_only=False)] == ["demo-skill"] + assert skills_root == tmp_path / "skills" + + +def test_direct_overwrite_resets_version_chain(versioned_skill) -> None: + versioning, storage, skill_dir, _enabled, _tmp_path = versioned_skill + versioning.record_custom_skill_version("demo-skill", storage=storage, action="upload") + (skill_dir / "SKILL.md").write_text(_skill_markdown("v2"), encoding="utf-8") + versioning.record_custom_skill_version("demo-skill", storage=storage, action="upload_iteration") + + (skill_dir / "SKILL.md").write_text(_skill_markdown("replacement"), encoding="utf-8") + reset = versioning.reset_custom_skill_versions("demo-skill", storage=storage, summary="direct overwrite") + + assert reset and reset["version"] == 1 + listed = versioning.list_custom_skill_versions("demo-skill", storage=storage) + assert listed["active_version"] == 1 + assert [item["version"] for item in listed["versions"]] == [1] + assert "replacement" in (skill_dir / "SKILL.md").read_text(encoding="utf-8") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("install_mode", "expected_action"), + [("iterate", "upload_iteration"), ("overwrite", "reset")], +) +async def test_same_name_upload_supports_iteration_or_direct_overwrite( + monkeypatch: pytest.MonkeyPatch, + install_mode: str, + expected_action: str, +) -> None: + from app.gateway.routers import skills as skills_router + from deerflow.skills import usage, versioning + + class FakeStorage: + def invalidate_skills_cache(self): + return None + + def validate_skill_archive(self, *_args, **_kwargs): + return { + "skill_name": "demo-skill", + "conflict_kind": "custom", + } + + async def ainstall_skill_from_archive(self, *_args, **kwargs): + assert kwargs["overwrite"] is True + assert kwargs["target_category"] == SkillCategory.CUSTOM + return { + "success": True, + "skill_name": "demo-skill", + "message": "installed", + "overwritten": True, + "category": "custom", + } + + class FakeSkillStore: + async def ensure_legacy(self, payload): + assert payload["name"] == "demo-skill" + return payload + + storage = FakeStorage() + calls: list[str] = [] + + async def preview_target(**_kwargs): + return False, True, True, False, False, "user-1", "owner", "conflict" + + async def no_op(*_args, **_kwargs): + return None + + monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **_kwargs: storage) + monkeypatch.setattr(skills_router, "_preview_install_target", preview_target) + monkeypatch.setattr(skills_router, "_current_user_id", lambda _request: "user-1") + monkeypatch.setattr(skills_router, "_is_admin_user", lambda _request: False) + monkeypatch.setattr(skills_router, "_refresh_catalog_snapshot", no_op) + monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", no_op) + monkeypatch.setattr(skills_router, "_sync_skill_view", no_op) + monkeypatch.setattr(usage, "mark_uploaded", lambda _name: None) + monkeypatch.setattr(versioning, "skill_versioning_enabled", lambda: True) + monkeypatch.setattr( + versioning, + "record_custom_skill_version", + lambda _name, **kwargs: calls.append(kwargs["action"]), + ) + monkeypatch.setattr( + versioning, + "reset_custom_skill_versions", + lambda _name, **_kwargs: calls.append("reset"), + ) + + result = await skills_router.install_skill_upload( + request=SimpleNamespace(), + file=UploadFile(filename="demo.skill", file=io.BytesIO(b"PK\x03\x04archive")), + overwrite=False, + install_mode=install_mode, + target_category=SkillCategory.CUSTOM, + config=SimpleNamespace(), + skill_store=FakeSkillStore(), + ) + + assert result.overwritten is True + assert expected_action in calls + if install_mode == "iterate": + assert calls == ["baseline", "upload_iteration"] + else: + assert calls == ["reset"] diff --git a/offline-backend-20260512/backend/tests/test_skills_reconcile.py b/offline-backend-20260512/backend/tests/test_skills_reconcile.py index 144fc59b..82b45f22 100644 --- a/offline-backend-20260512/backend/tests/test_skills_reconcile.py +++ b/offline-backend-20260512/backend/tests/test_skills_reconcile.py @@ -221,11 +221,22 @@ async def test_update_public_skill_is_admin_only_and_preserves_raw_config( monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **_kwargs: storage) refresh_calls: list[bool] = [] + catalog_refresh_calls: list[object] = [] + enabled_row_updates: list[tuple[str, bool]] = [] async def _refresh() -> None: refresh_calls.append(True) + async def _refresh_catalog(config) -> None: + catalog_refresh_calls.append(config) + + async def _set_enabled(name: str, enabled: bool) -> bool: + enabled_row_updates.append((name, enabled)) + return True + monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) + monkeypatch.setattr(skills_router, "refresh_skill_catalog_projection", _refresh_catalog) + monkeypatch.setattr(skills_router, "set_skill_catalog_enabled", _set_enabled) replace_calls: list[tuple[object, object]] = [] real_replace = os.replace @@ -246,6 +257,7 @@ async def test_update_public_skill_is_admin_only_and_preserves_raw_config( assert forbidden.value.status_code == 403 assert config_path.read_bytes() == before assert refresh_calls == [] + assert enabled_row_updates == [] response = await skills_router.update_skill( "demo-skill", @@ -264,6 +276,8 @@ async def test_update_public_skill_is_admin_only_and_preserves_raw_config( assert persisted["mcpServers"]["preserved"]["command"] == "$MCP_COMMAND" assert get_extensions_config().skills["demo-skill"].enabled is False assert refresh_calls == [True] + assert enabled_row_updates == [("demo-skill", False)] + assert catalog_refresh_calls == [] config_replaces = [item for item in replace_calls if os.fspath(item[1]) == os.fspath(config_path)] assert len(config_replaces) == 1 @@ -282,6 +296,8 @@ async def test_update_public_skill_is_admin_only_and_preserves_raw_config( assert persisted["skills"]["demo-skill"]["enabled"] is True assert persisted["concurrentUnknownField"] == {"survives": True} assert refresh_calls == [True, True] + assert enabled_row_updates == [("demo-skill", False), ("demo-skill", True)] + assert catalog_refresh_calls == [] config_replaces = [item for item in replace_calls if os.fspath(item[1]) == os.fspath(config_path)] assert len(config_replaces) == 2 diff --git a/offline-backend-20260512/backend/tests/test_strategy_project_settings.py b/offline-backend-20260512/backend/tests/test_strategy_project_settings.py index d845af2d..6a06f2b7 100644 --- a/offline-backend-20260512/backend/tests/test_strategy_project_settings.py +++ b/offline-backend-20260512/backend/tests/test_strategy_project_settings.py @@ -4,7 +4,15 @@ import pytest from fastapi import HTTPException from app.gateway.routers import system_settings as system_settings_router -from deerflow.config.system_settings import SystemSettings +from deerflow.config.system_settings import ( + AIWritingGuideCard, + AIWritingPageDescription, + GeneralQaPageDescription, + GeneralQaPageDescriptions, + PageDescriptionsSettings, + PageHeaderDescription, + SystemSettings, +) def test_strategy_project_demo_is_hidden_by_default() -> None: @@ -15,6 +23,10 @@ def test_assistant_knowledge_is_hidden_by_default() -> None: assert SystemSettings().knowledge_base_product_version == "v1" +def test_custom_skill_versioning_is_disabled_by_default() -> None: + assert SystemSettings().skill_versioning.enabled is False + + @pytest.mark.asyncio async def test_non_admin_cannot_update_strategy_project_settings( monkeypatch: pytest.MonkeyPatch, @@ -194,6 +206,263 @@ def test_retry_keep_partial_defaults_to_enabled() -> None: assert SystemSettings().retry_keep_partial.enabled is True +def test_page_descriptions_default_to_product_copy() -> None: + descriptions = SystemSettings().page_descriptions + assert descriptions.general_qa.full == GeneralQaPageDescription() + assert descriptions.general_qa.compact == GeneralQaPageDescription() + assert descriptions.knowledge_base == PageHeaderDescription() + assert descriptions.ai_writing == AIWritingPageDescription() + assert len(descriptions.ai_writing.guide_cards) == 3 + assert descriptions.strategy_projects == PageHeaderDescription() + assert descriptions.deep_research == PageHeaderDescription() + assert descriptions.gongwen == PageHeaderDescription() + assert descriptions.light_app_management == PageHeaderDescription() + assert descriptions.light_app_list == PageHeaderDescription() + assert descriptions.agents == PageHeaderDescription() + assert descriptions.business_chains == PageHeaderDescription() + assert descriptions.skills == PageHeaderDescription() + assert descriptions.knowledge_management == PageHeaderDescription() + + +def test_legacy_general_qa_description_is_shared_by_both_layouts() -> None: + descriptions = PageDescriptionsSettings.model_validate( + { + "general_qa": { + "welcome_title": " 原有问答欢迎语 ", + "welcome_description": " 原有说明 ", + "input_placeholder": " 原有提示 ", + } + } + ) + + expected = GeneralQaPageDescription( + welcome_title="原有问答欢迎语", + welcome_description="原有说明", + input_placeholder="原有提示", + ) + assert descriptions.general_qa.full == expected + assert descriptions.general_qa.compact == expected + + legacy_request = system_settings_router.PageDescriptionsUpdateRequest( + general_qa=GeneralQaPageDescription(welcome_title="旧版请求"), + ) + assert legacy_request.general_qa == GeneralQaPageDescriptions( + full=GeneralQaPageDescription(welcome_title="旧版请求"), + compact=GeneralQaPageDescription(welcome_title="旧版请求"), + ) + + +def test_ai_writing_guide_cards_are_padded_to_three_items() -> None: + descriptions = PageDescriptionsSettings.model_validate( + { + "ai_writing": { + "guide_cards": [ + {"title": " 自定义卡片 ", "description": " 自定义说明 "}, + ] + } + } + ) + + assert descriptions.ai_writing.guide_cards == [ + AIWritingGuideCard(title="自定义卡片", description="自定义说明"), + AIWritingGuideCard(), + AIWritingGuideCard(), + ] + + +@pytest.mark.asyncio +async def test_non_admin_cannot_update_page_descriptions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def ordinary_user(_request): + return SimpleNamespace(system_role="user") + + monkeypatch.setattr( + system_settings_router, + "get_optional_user_from_request", + ordinary_user, + ) + + with pytest.raises(HTTPException) as error: + await system_settings_router.update_page_descriptions( + system_settings_router.PageDescriptionsUpdateRequest( + general_qa=GeneralQaPageDescriptions( + full=GeneralQaPageDescription(welcome_title="不可保存"), + ), + knowledge_base=PageHeaderDescription(), + ai_writing=AIWritingPageDescription(), + ), + SimpleNamespace(), + ) + + assert error.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_can_update_page_descriptions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + current = SystemSettings() + saved: list[SystemSettings] = [] + + async def admin_user(_request): + return SimpleNamespace(system_role="admin") + + monkeypatch.setattr( + system_settings_router, + "get_optional_user_from_request", + admin_user, + ) + monkeypatch.setattr(system_settings_router, "load_system_settings", lambda: current) + monkeypatch.setattr( + system_settings_router, + "save_system_settings", + lambda settings: saved.append(settings) or settings, + ) + + response = await system_settings_router.update_page_descriptions( + system_settings_router.PageDescriptionsUpdateRequest( + general_qa=GeneralQaPageDescriptions( + full=GeneralQaPageDescription( + welcome_title=" 欢迎使用工程开发智能问答助手 ", + welcome_description=" 请描述您希望完成的工程开发任务。 ", + input_placeholder=" 输入问题或任务描述 ", + ), + compact=GeneralQaPageDescription( + welcome_title=" 简洁问答欢迎语 ", + ), + ), + knowledge_base=PageHeaderDescription( + title=" 资料库 ", + description=" 在这里整理和检索团队资料。 ", + ), + ai_writing=AIWritingPageDescription( + title=" 写作工作台 ", + description=" 将想法逐步发展为文章。 ", + guide_cards=[ + AIWritingGuideCard( + title=" 明确写作意图 ", + description=" 用一句话说明想写什么。 ", + ), + AIWritingGuideCard( + title=" 对齐写作参数 ", + description=" 选择体裁与篇幅。 ", + ), + AIWritingGuideCard( + title=" 生成并优化文章 ", + description=" 查看并完善草稿。 ", + ), + ], + ), + strategy_projects=PageHeaderDescription( + title=" 项目工作台 ", + description=" 集中管理各类项目成果。 ", + ), + deep_research=PageHeaderDescription( + title=" 开始研究 ", + description=" 收集资料并生成研究报告。 ", + ), + gongwen=PageHeaderDescription( + title=" 公文工作台 ", + description=" 对话完成公文起草和校审。 ", + ), + light_app_management=PageHeaderDescription( + title=" 轻应用管理 ", + description=" 登记和维护轻应用。 ", + ), + light_app_list=PageHeaderDescription( + title=" 轻应用列表 ", + description=" 快速打开已登记的轻应用。 ", + ), + agents=PageHeaderDescription( + title=" 智能体工作台 ", + description=" 管理可用智能体。 ", + ), + business_chains=PageHeaderDescription( + title=" 业务链目录 ", + description=" 管理可复用的智能体编排。 ", + ), + skills=PageHeaderDescription( + title=" 技能工作台 ", + description=" 浏览和维护技能。 ", + ), + knowledge_management=PageHeaderDescription( + title=" 知识服务 ", + description=" 管理知识库与访问范围。 ", + ), + ), + SimpleNamespace(), + ) + + assert response.general_qa == GeneralQaPageDescriptions( + full=GeneralQaPageDescription( + welcome_title="欢迎使用工程开发智能问答助手", + welcome_description="请描述您希望完成的工程开发任务。", + input_placeholder="输入问题或任务描述", + ), + compact=GeneralQaPageDescription(welcome_title="简洁问答欢迎语"), + ) + assert response.knowledge_base == PageHeaderDescription( + title="资料库", + description="在这里整理和检索团队资料。", + ) + assert response.ai_writing == AIWritingPageDescription( + title="写作工作台", + description="将想法逐步发展为文章。", + guide_cards=[ + AIWritingGuideCard( + title="明确写作意图", + description="用一句话说明想写什么。", + ), + AIWritingGuideCard( + title="对齐写作参数", + description="选择体裁与篇幅。", + ), + AIWritingGuideCard( + title="生成并优化文章", + description="查看并完善草稿。", + ), + ], + ) + assert response.strategy_projects == PageHeaderDescription( + title="项目工作台", + description="集中管理各类项目成果。", + ) + assert response.deep_research == PageHeaderDescription( + title="开始研究", + description="收集资料并生成研究报告。", + ) + assert response.gongwen == PageHeaderDescription( + title="公文工作台", + description="对话完成公文起草和校审。", + ) + assert response.light_app_management == PageHeaderDescription( + title="轻应用管理", + description="登记和维护轻应用。", + ) + assert response.light_app_list == PageHeaderDescription( + title="轻应用列表", + description="快速打开已登记的轻应用。", + ) + assert response.agents == PageHeaderDescription( + title="智能体工作台", + description="管理可用智能体。", + ) + assert response.business_chains == PageHeaderDescription( + title="业务链目录", + description="管理可复用的智能体编排。", + ) + assert response.skills == PageHeaderDescription( + title="技能工作台", + description="浏览和维护技能。", + ) + assert response.knowledge_management == PageHeaderDescription( + title="知识服务", + description="管理知识库与访问范围。", + ) + assert saved[0].page_descriptions.general_qa == response.general_qa + + @pytest.mark.asyncio async def test_retry_keep_partial_get_reflects_saved_settings( monkeypatch: pytest.MonkeyPatch, @@ -266,3 +535,83 @@ async def test_admin_can_update_retry_keep_partial( assert response.enabled is False assert saved[0].retry_keep_partial.enabled is False + + +@pytest.mark.asyncio +async def test_skill_versioning_get_reflects_saved_setting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + current = SystemSettings().model_copy( + update={ + "skill_versioning": system_settings_router.SkillVersioningSettings( + enabled=True + ) + } + ) + monkeypatch.setattr(system_settings_router, "load_system_settings", lambda: current) + + response = await system_settings_router.get_skill_versioning() + + assert response.enabled is True + + +@pytest.mark.asyncio +async def test_non_admin_cannot_update_skill_versioning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def ordinary_user(_request): + return SimpleNamespace(system_role="user") + + monkeypatch.setattr( + system_settings_router, + "get_optional_user_from_request", + ordinary_user, + ) + + with pytest.raises(HTTPException) as error: + await system_settings_router.update_skill_versioning( + system_settings_router.SkillVersioningUpdateRequest(enabled=True), + SimpleNamespace(), + ) + + assert error.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_can_enable_skill_versioning_and_capture_baselines( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from deerflow.skills import versioning + + current = SystemSettings() + saved: list[SystemSettings] = [] + reconciled: list[str | None] = [] + + async def admin_user(_request): + return SimpleNamespace(system_role="admin", id="admin-1") + + monkeypatch.setattr( + system_settings_router, + "get_optional_user_from_request", + admin_user, + ) + monkeypatch.setattr(system_settings_router, "load_system_settings", lambda: current) + monkeypatch.setattr( + system_settings_router, + "save_system_settings", + lambda settings: saved.append(settings) or settings, + ) + monkeypatch.setattr( + versioning, + "reconcile_custom_skill_versions", + lambda *, author=None: reconciled.append(author) or 0, + ) + + response = await system_settings_router.update_skill_versioning( + system_settings_router.SkillVersioningUpdateRequest(enabled=True), + SimpleNamespace(), + ) + + assert response.enabled is True + assert saved[0].skill_versioning.enabled is True + assert reconciled == ["admin-1"] diff --git a/offline-backend-20260512/backend/tests/test_structured_templates.py b/offline-backend-20260512/backend/tests/test_structured_templates.py new file mode 100644 index 00000000..3588d557 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_structured_templates.py @@ -0,0 +1,597 @@ +"""结构化 HTML 模板(kind=structured,先拿 no.1 落地)的引擎/存储/路由测试。 + +覆盖: +* seed/meta:结构化 no1 四件套正确入库,单文件模板(如 no2)不受影响; +* 契约语义与单文件时代等价:``reference_data`` 读 data.json 与旧 ``const DATA=`` 行 + 解析结果一致,``validate_data`` / ``merge_with_reference`` 行为不变; +* 组合:``read_template_text`` / ``fill_template`` 产出无残留标记的自包含文档, + 数据注入可被页面 ``JSON.parse`` 还原,标题/副标题/更新时间生效; +* data.json 持久化(管理页「编辑数据」):整体替换 + 契约校验(顶层键不可增删); +* 骨架编辑标记校验、结构化重置; +* 旧单文件存储副本的自动升级:未手改 → 升级;手改过 → 保留旧版; +* 路由:content 的 doc/raw 双形态、data 的 GET/PUT 与权限。 +""" + +from __future__ import annotations + +import json +import re +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import HTTPException +from test_api_read_cache import FakeRedis, _config + +from app.gateway import api_read_cache +from app.gateway.routers import html_templates +from deerflow.runtime.scheduler import template_fill, template_store + +# no1 是当前唯一的结构化模板。 +TPL = "no1" +LEGACY_FILE = template_fill._TEMPLATES_ROOT / TPL / "taiwan_littoral_ui_redwhite_v2.html" + + +@pytest.fixture +def tmp_store(monkeypatch, tmp_path): + """隔离的运行时存储:全部用例不触碰真实 .deer-flow 目录。""" + root = tmp_path / "html-templates" + monkeypatch.setattr(template_store, "store_root", lambda: root) + template_fill.invalidate_cache() + template_store.ensure_seeded() + yield root + template_fill.invalidate_cache() + + +@pytest.fixture +def api_request(monkeypatch, tmp_store): + """可直接调路由函数的最小 request(admin 身份,目录缓存走 FakeRedis)。""" + redis = FakeRedis() + monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) + monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_client", lambda *a, **k: (redis, "test:catalog")) + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(config=_config())), + state=SimpleNamespace(user=SimpleNamespace(id="admin-1", system_role="admin")), + ) + + async def current_user(req): + return req.state.user + + monkeypatch.setattr(html_templates, "get_optional_user_from_request", current_user) + return request + + +def _page_data_element(html: str) -> dict: + match = re.search( + r'', html, re.DOTALL + ) + assert match is not None, "组合产物缺少 page-data JSON 元素" + return json.loads(match.group(1)) + + +# ── seed / meta ────────────────────────────────────────────────────────────── + + +def test_structured_seed_and_single_untouched(tmp_store): + meta = template_store.get_template_meta(TPL) + assert meta is not None + assert meta["kind"] == "structured" + assert meta["convertible"] is True + assert meta["manifest_var"] == "MANIFEST" + assert sorted(p.name for p in (tmp_store / TPL).iterdir()) == [ + "data.json", + "fields.json", + "manifest.json", + "meta.json", + "runtime.js", + "template.html", + ] + + # 单文件模板照旧(kind=single,正文 = 完整文档,无结构化部件)。 + no2 = template_store.get_template_meta("no2") + assert no2 is not None and no2["kind"] == "single" + assert not (tmp_store / "no2" / "runtime.js").exists() + + +def test_reference_data_matches_legacy_const_line(tmp_store): + """data.json 的契约语义必须与单文件时代的 const DATA= 行完全一致。""" + legacy = template_fill._extract_const_object(LEGACY_FILE.read_text(encoding="utf-8"), "DATA") + ref = template_fill.reference_data(TPL) + assert ref == legacy + assert template_fill.validate_data(TPL, legacy) == [] + assert set(template_fill.top_level_keys(TPL)) == set(legacy.keys()) + + +# ── 组合 / 填充 ───────────────────────────────────────────────────────────── + + +def test_read_template_text_composes_default_document(tmp_store): + html = template_fill.read_template_text(TPL) + for marker in template_fill.STRUCTURED_MARKERS: + assert marker not in html + assert 'id="page-data"' in html and 'id="page-manifest"' in html + assert "const DATA=JSON.parse" in html + # 页面内 JSON 编辑器随 runtime 内联。 + assert 'id="editorOverlay"' in html and 'id="applyJson"' in html + assert _page_data_element(html)["overview"] == template_fill.reference_data(TPL)["overview"] + + +def test_fill_template_structured_injects_data_titles_and_date(tmp_store): + ref = template_fill.reference_data(TPL) + modified = json.loads(json.dumps(ref)) + modified["overview"] = { + "核心指标": [{"名称": "测试指标", "数值": 7, "单位": "个", "说明": "单元测试"}] + } + import datetime as dt + + html = template_fill.fill_template( + TPL, + modified, + update_date=dt.date(2026, 11, 4), + title="新标题", + subtitle="新副标题", + ) + for marker in template_fill.STRUCTURED_MARKERS: + assert marker not in html + assert "新标题" in html + assert "

新标题

" in html + assert "新副标题" in html + # MANIFEST 系统.更新时间/名称 同步。 + assert "2026-11-04" in html + assert '"名称": "新标题"' in html + # 注入的数据可被页面 JSON.parse 原样还原。 + assert _page_data_element(html)["overview"]["核心指标"][0]["名称"] == "测试指标" + + +def test_merge_and_validate_semantics_unchanged(tmp_store): + # 顶层键缺失 → 硬校验报错(与单文件时代一致)。 + errors = template_fill.validate_data(TPL, {"overview": {}}) + assert any("缺少顶层字段" in e for e in errors) + # 空内容分支回退模板默认数据。 + merged = template_fill.merge_with_reference(TPL, {"overview": {}, "regions": []}) + assert merged == template_fill.reference_data(TPL) + # 有内容的分支保留转换结果。 + merged2 = template_fill.merge_with_reference( + TPL, {"overview": {"核心指标": [{"名称": "x", "数值": 1, "单位": "", "说明": ""}]}} + ) + assert merged2["overview"]["核心指标"][0]["名称"] == "x" + assert merged2["regions"] == template_fill.reference_data(TPL)["regions"] + + +# ── data.json 持久化 / 骨架编辑 / 重置 ────────────────────────────────────── + + +def test_write_read_template_data_roundtrip(tmp_store): + ref = template_fill.reference_data(TPL) + modified = json.loads(json.dumps(ref)) + modified["overview"]["核心指标"][0]["名称"] = "持久化指标" + meta = template_store.write_template_data(TPL, modified) + assert meta["kind"] == "structured" + assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] == "持久化指标" + # 引擎缓存已失效:reference_data / 组合产物立即反映新数据。 + assert template_fill.reference_data(TPL)["overview"]["核心指标"][0]["名称"] == "持久化指标" + assert "持久化指标" in template_fill.read_template_text(TPL) + + +def test_write_template_manifest_persists_and_composes(tmp_store): + """MANIFEST 持久化(系统名称/导航页面/交互规则):写回 manifest.json 并进入组合产物。""" + ref = template_fill.reference_data(TPL) + manifest = template_store.read_template_manifest(TPL) + assert manifest.get("系统", {}).get("名称") == "台湾滨海防卫知识数据展示系统" + manifest["系统"]["名称"] = "改名后的系统" + manifest["页面"][0]["名称"] = "首页" + template_store.write_template_data(TPL, ref, manifest=manifest) + assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "改名后的系统" + # 组合产物(MANIFEST 内嵌 JSON)反映新值;导航按钮由页面 JS 依据 MANIFEST 渲染。 + composed = template_fill.read_template_text(TPL) + assert "改名后的系统" in composed + assert '"名称": "首页"' in composed + # 非对象 MANIFEST 拒绝。 + with pytest.raises(template_store.TemplateStoreError, match="MANIFEST"): + template_store.write_template_data(TPL, ref, manifest=["nope"]) + # 重置回随包原版。 + template_store.reset_template(TPL) + assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "台湾滨海防卫知识数据展示系统" + + +def test_write_template_data_rejects_contract_changes(tmp_store): + with pytest.raises(template_store.TemplateStoreError, match="契约"): + template_store.write_template_data(TPL, {"overview": {}}) + with pytest.raises(template_store.TemplateStoreError): + template_store.write_template_data(TPL, ["not", "an", "object"]) + # 单文件模板不支持数据编辑。 + with pytest.raises(template_store.TemplateStoreError, match="结构化"): + template_store.write_template_data("no2", {"x": 1}) + + +def test_update_template_skeleton_marker_guard(tmp_store): + with pytest.raises(template_store.TemplateStoreError, match="注入标记"): + template_store.update_template(TPL, html="

标记丢了

") + # 保留标记的骨架编辑可以通过,且不影响 kind/convertible。 + skeleton = template_store.read_template_html(TPL) + edited = skeleton.replace("JSON 编辑器", "JSON 编辑器·编辑", 1) + meta = template_store.update_template(TPL, html=edited) + assert meta["kind"] == "structured" and meta["convertible"] is True + assert template_fill.read_template_text(TPL).count("JSON 编辑器·编辑") == 1 + + +def test_reset_structured_restores_all_parts(tmp_store): + template_store.write_template_data( + TPL, _with_first_metric_name("持久化指标") + ) + template_store.update_template(TPL, html=template_store.read_template_html(TPL) + "\n") + template_store.reset_template(TPL) + assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] != "持久化指标" + assert "" not in template_store.read_template_html(TPL) + assert template_fill.read_template_text(TPL) # 组合仍可用 + + +def _with_first_metric_name(name: str) -> dict: + ref = template_fill.reference_data(TPL) + modified = json.loads(json.dumps(ref)) + modified["overview"]["核心指标"][0]["名称"] = name + return modified + + +# ── 旧单文件存储副本的自动升级 ───────────────────────────────────────────── + + +def _make_legacy_copy(store_root, *, modified: bool) -> None: + import shutil + + tpl_dir = store_root / TPL + if tpl_dir.exists(): + shutil.rmtree(tpl_dir) + tpl_dir.mkdir(parents=True) + text = LEGACY_FILE.read_text(encoding="utf-8") + if modified: + text += "\n" + (tpl_dir / "template.html").write_text(text, encoding="utf-8") + (tpl_dir / "meta.json").write_text( + json.dumps({"id": TPL, "name": "no.1", "builtin": True, "convertible": True}), + encoding="utf-8", + ) + + +def test_auto_upgrade_untouched_legacy_copy(tmp_store): + _make_legacy_copy(tmp_store, modified=False) + assert template_store.ensure_seeded() is True + meta = template_store.get_template_meta(TPL) + assert meta["kind"] == "structured" + assert (tmp_store / TPL / "runtime.js").is_file() + # 升级后的模板可正常组合。 + assert "__PAGE_" not in template_fill.read_template_text(TPL) + + +def test_auto_upgrade_skips_hand_modified_copy(tmp_store): + _make_legacy_copy(tmp_store, modified=True) + template_store.ensure_seeded() + meta = template_store.get_template_meta(TPL) + assert meta["kind"] == "single" + assert "" in (tmp_store / TPL / "template.html").read_text(encoding="utf-8") + # 管理页「重置」可手动升级到结构化版。 + template_store.reset_template(TPL) + assert template_store.get_template_meta(TPL)["kind"] == "structured" + assert (tmp_store / TPL / "runtime.js").is_file() + + +# ── 路由 ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_router_content_doc_and_raw(tmp_store, api_request): + doc = await html_templates.get_html_template_content(TPL, api_request) + assert "__PAGE_DATA__" not in doc["html"] + assert 'id="page-data"' in doc["html"] + raw = await html_templates.get_html_template_content(TPL, api_request, raw=True) + assert "__PAGE_DATA__" in raw["html"] and "__PAGE_RUNTIME__" in raw["html"] + + +@pytest.mark.asyncio +async def test_router_data_get_put_and_guards(tmp_store, api_request): + got = await html_templates.get_html_template_data(TPL, api_request) + assert "overview" in got["data"] + # MANIFEST 随 GET 返回(管理页「系统设置」模块)。 + assert got["manifest"]["系统"]["名称"] == "台湾滨海防卫知识数据展示系统" + # 模块中文名(按 MANIFEST.页面 解析,管理页「编辑数据」的模块导航用)。 + labels = {m["key"]: m["label"] for m in got["modules"]} + assert labels["overview"] == "总览" + assert labels["regions"] == "沿岸区域" + assert labels["organization_charts"] == "组织与单位" + assert labels["events"] == "演习与项目" + assert set(labels) == set(got["data"].keys()) + + payload = got["data"] + payload["overview"]["核心指标"][0]["名称"] = "路由持久化" + manifest = json.loads(json.dumps(got["manifest"])) + manifest["系统"]["名称"] = "路由改名系统" + meta = await html_templates.update_html_template_data( + TPL, + html_templates.TemplateDataUpdateRequest(data=payload, manifest=manifest), + api_request, + ) + assert meta["kind"] == "structured" + assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] == "路由持久化" + assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统" + # 仅传 data(未动 MANIFEST)时不回写 manifest.json。 + await html_templates.update_html_template_data( + TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request + ) + assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统" + + # 契约被破坏(删顶层键) → 400。 + broken = {k: v for k, v in payload.items() if k != "regions"} + with pytest.raises(HTTPException) as exc: + await html_templates.update_html_template_data( + TPL, html_templates.TemplateDataUpdateRequest(data=broken), api_request + ) + assert exc.value.status_code == 400 + + # 单文件模板 → 400。 + with pytest.raises(HTTPException) as exc2: + await html_templates.get_html_template_data("no2", api_request) + assert exc2.value.status_code == 400 + + # 非管理员不可写。 + api_request.state.user = SimpleNamespace(id="u2", system_role="user") + with pytest.raises(HTTPException) as exc3: + await html_templates.update_html_template_data( + TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request + ) + assert exc3.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_scheduler_fill_path_yields_selfcontained_html(tmp_store): + """调度器模板填充路径的最终产物自包含:单文件、无标记、数据可解析。""" + data_obj = template_fill.merge_with_reference(TPL, {"overview": _with_first_metric_name("调度指标")["overview"]}) + html = template_fill.fill_template(TPL, data_obj, update_date=__import__("datetime").date(2026, 1, 1)) + assert html.startswith("") and "__PAGE_" not in html + assert "调度指标" in html + assert _page_data_element(html)["overview"]["核心指标"][0]["名称"] == "调度指标" + + +# ── 字段显示 / 自定义模块 ─────────────────────────────────────────────────── + + +def test_field_catalog_and_visibility_roundtrip(tmp_store): + catalog = template_store.read_template_fields(TPL) + labels = {f["label"] for f in catalog} + assert "节点类别" in labels and "来源" in labels + assert all(f.get("description") for f in catalog) + + # 隐藏两个字段 → manifest 记 {label: false},其余全部显示。 + template_store.write_template_field_visibility(TPL, ["节点类别", "来源"]) + manifest = template_store.read_template_manifest(TPL) + assert manifest["字段显示"] == {"节点类别": False, "来源": False} + # 组合产物带隐藏配置,runtime 的 F() 据此不渲染。 + composed = template_fill.read_template_text(TPL) + assert '"字段显示"' in composed + + # 目录外字段拒绝。 + with pytest.raises(template_store.TemplateStoreError, match="未知字段"): + template_store.write_template_field_visibility(TPL, ["不存在的字段"]) + + # 置空 = 全部显示。 + template_store.write_template_field_visibility(TPL, []) + assert template_store.read_template_manifest(TPL)["字段显示"] == {} + + +def test_custom_modules_write_and_sanitize(tmp_store): + raw = ( + '

x

' + '链接

正常内容

' + ) + template_store.write_template_custom_modules( + TPL, [{"编号": "c1", "页面": "overview", "标题": "测试模块", "html": raw}] + ) + saved = template_store.read_template_manifest(TPL)["自定义模块"] + assert len(saved) == 1 + html = saved[0]["html"] + assert "正常内容" in html + assert "x"}]) + with pytest.raises(template_store.TemplateStoreError, match="html"): + template_store.write_template_custom_modules(TPL, [{"页面": "overview"}]) + + +def test_field_extract_locks_and_restore(tmp_store): + """字段抽取锁定:锁定字段的键在抽取结果合并后恢复模板现有值(按条目身份匹配)。""" + ref = template_fill.reference_data(TPL) + + # 锁定「记录类别」(org_units 条目的 记录类别 键)与「日期」(events 条目的 日期显示 键)。 + template_store.write_template_field_locks(TPL, ["记录类别", "日期"]) + keys = template_store.locked_field_keys(TPL) + assert "记录类别" in keys and "日期显示" in keys + + # 模拟抽取结果:改了第一个单位的记录类别、事件日期,并保留身份键(id/名称)。 + extracted = json.loads(json.dumps(ref)) + unit = extracted["org_units"][0] + old_cat = ref["org_units"][0]["记录类别"] + unit["记录类别"] = "被抽取改过的类别" + event = extracted["events"][0] + event["日期显示"] = "2026-01-01" + event["名称"] = ref["events"][0].get("名称") or event.get("名称") or event.get("name") or "同身份" + + restored = template_store.restore_locked_fields(TPL, extracted) + # 锁定键恢复旧值。 + assert restored["org_units"][0]["记录类别"] == old_cat + assert restored["events"][0]["日期显示"] == ref["events"][0]["日期显示"] + # 未锁定分支不动。 + assert restored["overview"] == extracted["overview"] + # 目录外字段拒绝。 + with pytest.raises(template_store.TemplateStoreError, match="未知字段"): + template_store.write_template_field_locks(TPL, ["不存在字段"]) + # 清空 = 全部接受抽取。 + template_store.write_template_field_locks(TPL, []) + assert template_store.restore_locked_fields(TPL, extracted) == extracted + + +@pytest.mark.asyncio +async def test_router_fields_extract_flags(tmp_store, api_request): + got = await html_templates.get_html_template_fields(TPL, api_request) + by_label = {f["label"]: f for f in got["fields"]} + assert by_label["记录类别"]["extract"] is True + assert by_label["记录类别"]["has_key"] is True + assert by_label["组织与单位概况"]["has_key"] is False # 布局分区 + + meta = await html_templates.update_html_template_fields( + TPL, + html_templates.FieldVisibilityUpdateRequest(hidden=[], extract_locked=["记录类别"]), + api_request, + ) + assert meta["kind"] == "structured" + got2 = await html_templates.get_html_template_fields(TPL, api_request) + by2 = {f["label"]: f for f in got2["fields"]} + assert by2["记录类别"]["extract"] is False and by2["日期"]["extract"] is True + + +@pytest.mark.asyncio +async def test_router_fields_and_custom_modules(tmp_store, api_request): + got = await html_templates.get_html_template_fields(TPL, api_request) + assert any(f["label"] == "节点类别" and f["visible"] for f in got["fields"]) + assert [p["key"] for p in got["pages"]][0] == "overview" + assert got["custom_modules"] == [] + + meta = await html_templates.update_html_template_fields( + TPL, html_templates.FieldVisibilityUpdateRequest(hidden=["节点类别"]), api_request + ) + assert meta["kind"] == "structured" + got2 = await html_templates.get_html_template_fields(TPL, api_request) + by_label = {f["label"]: f["visible"] for f in got2["fields"]} + assert by_label["节点类别"] is False and by_label["来源"] is True + + await html_templates.update_html_template_custom_modules( + TPL, + html_templates.CustomModuleListUpdateRequest( + modules=[{"页面": "overview", "标题": "t", "html": "
hi
"}] + ), + api_request, + ) + got3 = await html_templates.get_html_template_fields(TPL, api_request) + assert got3["custom_modules"][0]["html"] == "
hi
" + + # 非管理员不可写。 + api_request.state.user = SimpleNamespace(id="u2", system_role="user") + with pytest.raises(HTTPException) as exc: + await html_templates.update_html_template_fields( + TPL, html_templates.FieldVisibilityUpdateRequest(hidden=[]), api_request + ) + assert exc.value.status_code == 403 + + +# ── 定时任务「抽取信息写回模板数据」开关(html_config.template_data_writeback) ── + + +def _writeback_task(writeback: bool | None) -> dict[str, Any]: + ctx: dict[str, Any] = {"require_html": True, "html_config": {}} + if writeback is not None: + ctx["html_config"]["template_data_writeback"] = writeback + return { + "task_id": "t1", + "user_id": "u1", + "name": "回写联调", + "prompt": "更新总览的核心指标名称为「回写指标」", + "description": "", + "timezone": "UTC", + "enabled": True, + "execution_context": ctx, + } + + +def _build_writeback_service(monkeypatch, *, converted: dict | None): + """构造 mock 掉检索/转换/归档的 service,返回 (service, write_mock)。""" + from datetime import UTC + from datetime import datetime as dt + from unittest.mock import AsyncMock, MagicMock + + from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore + from deerflow.runtime.scheduler import service as scheduler_service + from deerflow.runtime.scheduler.service import ScheduledTaskService + + service = ScheduledTaskService(app=None, store=MemoryScheduledTaskStore(), poll_interval_seconds=60.0) + ref = template_fill.reference_data(TPL) + source_obj = json.loads(json.dumps(ref)) + source_obj["overview"] = {"核心指标": [{"名称": "回写指标", "数值": 3, "单位": "个", "说明": ""}]} + monkeypatch.setattr( + service, "_gather_html_data", AsyncMock(return_value=("一些源数据", [])) + ) + if converted is None: + async def _fail(*a, **k): + raise scheduler_service.TemplateFillError("boom") + monkeypatch.setattr(service, "_convert_to_template_data", AsyncMock(side_effect=_fail)) + else: + # 成功分支返回「抽取了新指标」的转换结果(converted 仅作成功/失败哨兵)。 + monkeypatch.setattr(service, "_convert_to_template_data", AsyncMock(return_value=source_obj)) + monkeypatch.setattr(service, "_finalize_template_html", AsyncMock(return_value={"ok": True})) + monkeypatch.setattr(service, "_archive_html_index", lambda *_a, **_k: None) + # write_template_data 是同步函数,用 MagicMock 而非 AsyncMock。 + write_mock = MagicMock() + monkeypatch.setattr(template_store, "write_template_data", write_mock) + scheduled_for = dt(2026, 11, 4, 12, 0, tzinfo=UTC) + return service, write_mock, scheduled_for, source_obj + + + + +@pytest.mark.asyncio +@pytest.mark.parametrize("writeback,converted,expect_write", [ + (True, "converted", True), + (False, "converted", False), + (None, "converted", False), # 老任务缺省关闭 + (True, None, False), # 转换失败(回退默认数据)不写回 +]) +async def test_html_template_reference_writeback_switch(tmp_store, monkeypatch, writeback, converted, expect_write): + service, write_mock, scheduled_for, _source = _build_writeback_service( + monkeypatch, converted=converted + ) + await service._run_html_template_reference( + _writeback_task(writeback), + tpl_id=TPL, + runtime_thread_id="rt", + scheduled_for=scheduled_for, + run_id="r1", + ) + if expect_write: + assert write_mock.call_count == 1 + # 只写 data.json:调用参数不含 manifest kwarg(系统名称等 MANIFEST 不动)。 + args, kwargs = write_mock.call_args + assert args[0] == TPL and "manifest" not in kwargs + assert args[1]["overview"]["核心指标"][0]["名称"] == "回写指标" + else: + assert write_mock.call_count == 0 + + +@pytest.mark.asyncio +async def test_writeback_respects_field_locks(tmp_store, monkeypatch): + """锁定字段不接受抽取更新:写回 data.json 的数据里,锁定键保留模板旧值。""" + template_store.write_template_field_locks(TPL, ["日期"]) + service, write_mock, scheduled_for, source_obj = _build_writeback_service( + monkeypatch, converted=source_marker() + ) + await service._run_html_template_reference( + _writeback_task(True), + tpl_id=TPL, + runtime_thread_id="rt", + scheduled_for=scheduled_for, + run_id="r-lock", + ) + assert write_mock.call_count == 1 + args, _kwargs = write_mock.call_args + old_dates = [e.get("日期显示") for e in template_fill.reference_data(TPL)["events"]] + new_dates = [e.get("日期显示") for e in args[1]["events"]] + assert new_dates == old_dates # 锁定键恢复旧值 + + +def source_marker(): + ref = template_fill.reference_data(TPL) + obj = json.loads(json.dumps(ref)) + for e in obj["events"]: + e["日期显示"] = "2099-12-31" + return obj diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 8ad093d4..1f4c066d 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -1,8 +1,10 @@ config_version: 8 log_level: info -# 高并发运行时优化。技能视图两项不写也默认开启,要关掉才写成 false。修改后需重启后台。 +# 高并发运行时优化。工具/技能统计与技能视图默认开启;若确有写库压力才改成 false。修改后需重启后台。 runtime_performance: + tool_metrics_enabled: true # 记录普通工具调用,供综合管理的工具排行/调用统计使用 + skill_metrics_enabled: true # 记录可归因到具体技能的调用,供综合管理的技能排行/调用统计使用 skill_view_reuse_enabled: true # 默认开启;false 才关闭。单次工具调用复用一次技能视图 skill_view_cache_enabled: true # 默认开启;false 才关闭。校验版本后复用未变化的技能视图 model_timing_nonblocking_enabled: true # 模型计时日志独立队列,不争抢工具线程池