sync update from LAN repo (source master-zg @ 1aec5bce)

This commit is contained in:
gozeros committed 2026-11-10 14:04:19 +08:00
1 parent 754b61572c
commit 7053c37a1e
107 files changed
+8083 -386

No files matched your search

+5
View File
@@ -88,9 +88,14 @@ 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
!offline-backend-20260512/backend/tests/test_agent_skill_migration.py
+3 -1
View File
@@ -96,9 +96,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **菜单管理 (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` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。
- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。配置在 `system_settings.json -> app_identity`,图片在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片地址 `GET /api/public/app-identity/logo`。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。
- **生成网页离线下载包 (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/<name>/vNNNNNN`,位于技能扫描/沙箱挂载目录之外;`skills/custom/<name>` 永远只保留一个活动包,所以大模型只能发现当前激活版本。版本元数据与调用统计解耦并继续以稳定技能名计数,上传迭代和版本切换都不清零外部调用次数。实现 `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;渲染器拒绝覆盖已有文件并对 `</script>` 注入做转义。最终单文件 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`。
+3
View File
@@ -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)---
+4
View File
@@ -145,6 +145,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",
@@ -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\);/,
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAIAAAB0j/2rAADncElEQVR42uz9aZctSXYdBp59zG/Em3POrEKhJoAkKE4tCmyBk4ZufQDYf1X9mb2WunuxRUlscqnJJZKCCBQKqEJVzpmV05si4l4/uz/Y7G7mw414WZCIt0hUpF+/fn0wNztnn332xi9/+cs3X3tNWv9IAiApIgDSFv/HZGP6qHmQ3n+GjSKQff/8V+b/N30qrWNOfogi4k8+bkHx3cnBJzsIKcWFhO31D0yOJrOzahy2/rv5b74D6qNtP+b8DFf3nGxBfWO5eNqTZ7d6sZNP58+Xi1+Z33YuXtHGf2d/8S/Uv7/IV7Fwbs2XevUgWNt//qb3fnr7UOfaYOZs5+3v/sK3Ns4P513almd39ov2v9M36/8YE8Jf/vvLf3/57z+2medbnrvu6udwR7Hc9vWRfwFuI9aCir9c9b61s/3LWOXst+z2r5t863kc9gf8f/lK/uW7+Rf/lfy1LCi3XE8X8uuN578dHMDmjb3Dso8EkpSIh/bwxjme2QM/d+GozZ23oLVNyLdEhicQce8gIvLF118PIvJ//2//2/kvTP5evTCaVb9Cto9ZHo0EYGb5P8vb7RFe0m+nCCgSAfFwKH9tpN+ab0Y8uAhBCinwhwwbxZhGQPMGi3A+xGC2dIF+hzx80rCx4lAMaHe6uuJQqI5JoQiAkZ27z+5p9G5+/8yn322fUjUE0/CKr0r+CuNQ6H19eg7l3Sg2pq/78b4yodg4jifnBhGQpsyDrTVVxbO1+NPozzakoHiEJqrNC4KEEQoRi+9hH46jgKQD/HCbvKW5wjGN2WAULS6iMZFAUMxUJuJEKEKIUvwoVpmM/XQXFutC9fTov+GvPT7zVNAJbx4EaM3XFP/SYvIzTQhz8kUgHjO844T2akxCCQ8sDSHEk58/6uo/EZ63P7hxUnhhus9QCMVMoKLo1sF6z6v3ckNAHG5eutO1QUFCzE9OIn54QEBRGCBKNQkLmr+0cI/Sq+q/ZUJM1ls0Hn8cJWkUAUKj4ObeExsuyVFElILxNJxuRDi6S4MTMYmzbX0/4yuc3/R4CcWDQPpo/uzD1+MywcY9BUVg1W1k+WwZtoAIU3N4gIxzux++KF9AxpmfDA+XIloNE44EBSoki/c1hxMQChGXsfR6hDNDXkH8mIr7LcyfVaQg+fEWxVUIKMUCCr/khRNEnMjhLyqticXkQTK+wgh/CyHpzWUzoUS+z2GEpjU9zrPlwwvLvOSSLiQvtcU9CrNNsTQDpKGu+oJxefJ3T0EzoSG+uWC8HeFp+btNkGBYbGJ4GMMS0igA/V3It86/S/T32uJt95+G+wkyPHv/mIyI9wRCf2R/M/31QcTS1My02IYb6wethvNL0ZRfiONt9qdDIoyBMJn44Q5heAUZZgjkmdQ/WX+qoL8hiA+Ms7pXMd2ymCziWfFEUcSNFEthjcgYT36kDIAJR8oBAGQkLxSkjKSJOEBERtIoFLnQ8CRPFoaCA3S2YlwZy8XEf2shGB+ZZ0K/8cbYiOuZ3itRkaGgL8ThLC4uTlyMekTk2lgeeFAMOabPu43kkTwAAnGCATiRfjsACtO3TqQK/N07kiZ0gjGu0APkGBctkbxSqcjC7ZnMplosjgyzRVzLBTFwz2txfLdzAE+gnEolLTDIo0jS/AwRwiQMJE0PA3m2S3/H40DytIv0TAFlNWv72R7xT//qgxAIADFBXg4AA5AmkfDGxnsJFPcEfkYlNazFPsRTpPHFFJCE44NChI15SQi/7gOeHIbG4CfF3+GMqnOIt6EaSOHFR1w9KExfXBurMgmN8jqOvJrHuKV+lyrGTxlclVF/kYsXr5yWkXcxao15+JahQrylVSwZH1s+LuYkorjy+fHnoyTEX2SMFVD/4ceYAhaXQh+mpUNPIQS/5ORpxS/KRqqq/yON2PQWicVAqbwoY76E+C6Vt5xW7j3lS/nLJOcfhZUF0HrtATgOp6OQJ3UcDmEMjKfDzQsXwg/GjDxHGMKc3iEMvBiVxvUKBBimB/HLofoFM3wZYbGMyzIJ+onBH58CVQUppm4cLgiNj6fImzJEEC86XrcZ2/NfmeCIQBgeBYrkghAtolj/Fatj3nLK9MPe0lodxxIhGl8QFbE0jzLPhsXIbpxwGuWSblgjwQPEwh8gLQFEYek3AQQKmt9LQ4LqkQwSmqd8pDc9PLUQV4jfLX7ElC8p5vklTcr8Ok25aSDR/KgqIqQivJQ0J1ZzF32c5E8bABRmhhAEh8dh/sjIaIof1MZiorCwpNACYijFTVi6LfF9Cue/QKeC0NITteIGqUAINZJChY52UndQUU5TVUgBvIjUA26VxjXJeeerwGp4gDwiubwdRYQIzGbhGglJk3nr0ppfSeBo3qc8h7RGJ9AHYFrOyt3Kr6efCFNnXjEmRxYJaAOrr1cZSgAr4ltH55q3y78wxXYpog4/WwZIk/FG+ZWDYW1NURhZPpQCvSrZt5TZe1Q/iOWhsjKEWjv/X//JPxlE5NiMNeqNXIlHhGnBy0gC6xGf1iUJcDE9FIzJiZuU6EnMPMMBUi4TIlMROQET7JvFjEewfM99LikqiIlfea3MMEb6ueKfCxULs1HqQkGONkqAMYIpZilcNCkTDJ+ejj6y9Ykzi9xXRAQamdoFSmoxd00hfjmfMn7EnANYcWb+o/RpXZthBS+mOykUyXc1rJU252VmuD+HnNKgbaKAaor/LR5vREDidGGUIpUtfyK8fuPJ5KjDwUdmbjTRAiFFHj25JmOUoXg1/QgOS59KChTzU64DmZCrCBNITcRHRoiyjDYzFptw2eLJiU8uXIhwIDHO1cYKkrAYWBlW5dAYrL6okq9LKOrTOvi0Tnzyns8wBlpCSWEc3EplkUUSmV5mWpxDQ8Aen51FYKS+wHSEHMMyRN9MZ6IxhYrjgyLqI1FUGAnqs0JVXUCJJswvZEbvj+sOpMDvp5GfiwAYBKILN6rOs1DkN8oMD4MkHO6P/O6LZw/t+ht3eS16AA5wHrgCjKJ6gjiMjDc84WITQDD/nX8hpMPF6CmzM+FJcshHg97nzTcvv/zFa+/e3Huofng7gZ7uH68Pxit3ODmdYujpJRUNS6MP5SAB2RcjPehH88/Kv/aoJhbzCYr5XSOyZ6xmF0SYSzRm1xbLOxhDXKEWsCw/cZgIqeJRrPSao0gZ4yJS1EYn2Z5LS5RFENKfZDmfwsQyZMsapg2lrYQqUFE+txDZ+rJRhIEoVCsg3dEYAOIAVurJjBnVmQ5uxEQ7JA8z2CHXF1MFt5i2y5WqWiv9a4V4jUhV8knQqCF+igtkCgKMozaCTgmJRUq3Gf728yZzAFBUtY0jNWTaAph5LOhkogHZEjFzoIr4nMsYIXGaxvJDeG38mDfzEQ/8e0gK/SREJY0UMyWFJhz9awKKGUnzv3IhPJFO/OlFEMwfSsTIwa8nNA81+GhpDHilqUAZF2ihgTAZSYiMwiFGQQDGWH4IqDZ9xixKMYRtoHj812dbTiiAExl9rh8HoRMZKfRImsiRIbj1mZeTACKHpSguimnd1TrWMhbAXQhW6CAKHP28klZLkUGQIgV/M4FcMU4Luoo4VAW09EPhJAHUU7OGegQdSmzKr4SYIhcSYgpFWYTKQFJ606qfQD6TAfCouoo4wA+RA0I92cUEHQF3ThMAFXARmLKYaap4TJRjXEscRCAR1uYFlBIGhqWpJXw35OYD8lqncX5QVE9HQok15mgUP0KAkOafKPH0JN5hX0uIKwsEMYxQFVp8gCpjxqegIfoC4hxiAkvziQAKmihgEAAmft4Wgfjyb6iXACoY/dGgEBDifKQDp+qJBzABFQYMgEBJ8f9XnZpAnTP1yxGgMIBQAUQB0VEFUKV6lEagAgKOQlHnXw6NyZ7RT80qhCjgB6xipKk6hEwTJCxUsv29cEU5KQScqioAeYJf6qB+QTKBms93/OMDVSUhOIRBQUsvPxGBo4QDECM8VyHXURmj8VidUhELZT7xQFVRzQoDDGWlGUSsQBgFowa4SikGn4lYCEYBesAqothKpFLnWCQQ/nCkouIFBBCc4mLcmILFMHj9tKp+4IiOpI9LfdAUMhuQDgmdFQOEooBCCD0ICYqJSgb0nJB+t1ha8EMcSDSFcDcdGNCxgGR5GlZiWOVLEYTg3PkLsVRcVAhdyn00FZxT1BLqzQzLFgvwGpLBW1EwhPoOvjDkZ3wtcVe/so73xhtHfXZxOLoLX6M53Lz8wbPnl3b6lWAg7iucMx9kSfhRJCQwzQMxWkFdqhcRMWCMCwWgQIhQ6AO6kUKqc2o5AguVpPF0nzcv3PDRxcOX7v40tW0F4enxxvmaOYZknWOiTYsJ76RWcWPr/2LG25mCEilHi4GKTHeQelFpXhXKDIjLiOc0MfDvsieVNOlHVVEhxbdzilTKIuO6rqm4XeFDBa6yRnbV2RVj8U6gvl0ubgxrWHFJaQuK+jNnFYjJ5ZeDgbPhgRoXzuefaj75jpeBiMwKBv62jarplTYenTtAXeYIQgPLBIGWgEhewWTgzUHCDjtv/kUPd2KJFYYQixQwkXqEVBUoq1CB9pBZJT44YIm05kqyv550SsT0V8N9RpXXh5w/1nFlwlIpCiEx0SguL8zi/vdRFbKBirKMFK8kzNdnaZJ/WkpUBDXliOo0BpCYILVh7CBjwfBVDhSwvGr5JUQmASp6mWZoHtWbrcQUemZV0ui+ZD7ya4yrBtmwzoOrV2MIV4XZhBMTGFZcMJb8xkQ0yGLine truncated
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAYAAAD77Wr8AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAP+lSURBVHhe7P1VdJxJuif89lqz9+5iM4Mkg2zLIGZmxmRmVIJYJpktMrOLqauaqpmqqbq4u6D3/jbOnHVuz8C5mIuZOff/s54n4s18M5WS5erqge+ci9+KiCfifVMm2c5/Rrzf+O//5T//1/8T/DdpubraSnOr819WkHyNR1+XXk//+v9LRpmvXV7qdZnn0qWvW4l6/eNem36f9NfPVHtcj3OPldZlmlvu3plqK0v/vZL2+/Q/C/9dsdLaDB41r16zmrXLyXR9ptpX81/+63/7f6ss+TkUP4+pr5WcW+7XKtOa9PpfKvN90398qV9v6vVLf33+j5P2+3eJ5ebU160k/TrpfzzOPb4W/ylDTakr/vN//R8sWaO+InVtZunr0q9V3+//7y/wn6T0+tdpuddQ6n+x/ygtN17q/yOl1v6jirhPau2rS35NS+f+9/D/UrVKXz33fyP/MUMtHa1ZSfq69OsfNfe4vs57/c+W/nP3KOrrlrtPhtf4H1LGNbKmrElZtwrq6zJJX/9VpN/z/xf891VIv2Yl6deudB917VHrVjP3l0i/X/rrfBXpr/G/2v/OX1d6TT230vz/DF/Hr2nK9f/pP6rQOL0m/cfUsfLvlyXr/o/znxL91B8T1ZdS/n2YXn8syv9d0uvLzWf4P9H/Ksq/19Pr//eX6f+36f/v/frQ/5nTa1+LR71HsNL7Eem15d5zeNQ90usZ3st4pPRrl7vPcvW/pvSv7a+Aft755/5/pcSvoerPQsoa1Xt/6fPyz5D6vV7lvd/U94CTfwb/25I/k+l/DtPry1F/7cnrkt/bls59HVK/b6Z/DX8d4r23pfWvw196b+X6v+Qej/L13TvTr9vSX+OVLHnPa9XUP470ucwyvWZy/PjvyS53f+XnIn0+fd1q65mkfy0rXbfadcpapf+N30yPI5PfTo+x9HrClJRez2hCWqauvg/fl2rKNcJvpyeF4xMJv6OW5+T6KTlWrXkk1bUpr7Oc4yrpc8utSyfX/Ob4FH57QvgNm5amEmvT66njdMr8FN+bayePJ6WvP6mguQzzmZycxq9Xcuq4ROMp0So1mj8hKeszjN89cRzvniTTwqnjePfUCdU9pvDrk3TPE/j16TS8Tkqfe5w1X4Xqvu+eJifx61Mn8bvpSbw/EcNHo2H2wdgwPhwNS0F8OBLEh3ESkq2sjYQEWjMWwofjIXwwEcb7ZDKEDydkbTyMDyekcVFjPFYk6x9NhFmiPhHEhxMBfDgeXGos1UcK1XyilhDAR2N+ifrquqo/TvcRaz8e9eODyWH87uQE3p05hV+dPY1fnTmFX505yX49cxK/Pk2/Ryfxu+kxvDcZx3tTI/jd8XH5e/KE+D3BfWqFR/++Ta4Rvw/TruM6/X4jqnsr66XEev5zp/wZpD/j0gmFaj7lz6tqLf/Zl9+fTkyylHnV90WBakLyexB9D1/BVKrfTY2x306NCpOj+N3EKH5LJkfwu8kR/F6ivlL73cQIfjcex2/HYimoxnWJx1RXzYlaHL8fjeH3I1EhHhFGo0lUp3Ysg/Eo3hun+Qh+PyKufW8kgvd4PIzfxyWaY6pamvfiYSE2rELjdCG8F1WhcSyEP6hFQ3ifRIL4Q1R4PxrA+9zKWiSwovcyjN8b9ifG76twbVh4P+JnfyC0ftiP99XCxMc+CPsZj0NefCBRn8dhFXVd5UNqg168H/SImmo91cmHkrKeUS1xvQfvBwTqfxgSqJ+cc+ODgBsfEr8bH5BEzYUP/RL1FUotzUd+p+Bz4iOvRH11Xc3rYB96nQlKTe1D1RoxtgseOz4iXoW8xm3LwKpiw0ceicYeyW3BR64M+BoLPnSZ8RHLtEb42GXBx04LPnKaEz52mASnCR/T9U4TPnIY8JHdgI8dEvV5rMcndjWDpMfHdh0+tumSrU2Hj2xafGTT4GObBp9YNfjEomWfWrX41EY0+NRKhvAJsZBBfErMwiemAfapeQCfmvrZJ8Y+fMp62SeGXnxq6MEfib47SdeNT7ntZH8i2g72GWtnf9K0JXymacMXQ6QVnw+14POhZnwx1IzPB4Uvhprw+WAjPh9oxBeDjfhyQPhC+nKgAX/ur8eXffX4gvQr6vBFX9KXsv28t5Z90VuLL0mPogZfkO501fhSov7n3K+RqvFlVxX7QrZCNb7orJIqVarweWclo/HnHcIXHRX4klE/6fPOCnzeUYHP26WOCnzRUY4vpS/ak76U1H3yZ/Vcm9RelpCcK8OXajwn17UJ6nmlpvZ5axk+z1RXXZ8+l7xvKb5oK8VnraX4vG2pL6jeKub/1FoiKeNSfNpSwv4ofSL9saWUUf+j5hJ80lyCP7WI6/5IWkr4vvTa9BqftZbgj63iXsr9ldekOt3nY9W96VoF3ZeuU16b8H1aSvCZRH2FUlPQ+g+aS/Bhs/haFcqYvtbEta2rIb9++vHKa5SfjxTKz4X8MdDP0YdN4nW5bUr9elLQnFyb6Mv2k6YSfNpcij82l3KbiuYk+WuW+vUK9PUsh+5L/iQpY3X9k6ZSfNBYkqD8eD5gxfiouTjxeyJd+v2UH8fH8lrysfw9RT8Omv+8pYwpX4/yY+U18veh+D2R/lrK7xf6cZfhy9YyfNlSxn26j/o65X5M/hryPej3kHwdNWVejX5uP2+h3/Nlq8ZfUyaqP9v0PYDXUi0xX54g/rwrY/HnXkiv0/ce9fey5PcqJr+X0feWz9sF8f1EfA/kOs+V4zNel/xeSTXFZyl9+f1Wou/LRN1X1/j7M3+PrsRnEn1v/yzxPV75O0CSf0983lW9FP/dUoPPJOoLtfi8py7hM0VvHf7E6vGZok/RgM/7GxM+U/cHmvB5vzTQjM8HWvDZQAs+H1TQ38MC/d38+VA7+0xDOvC5pgNfaDvwubYTn2m78Lmum30mW6bvwWeGXvzJ0IvPjH34zEit0u8XTP34k7kffzINCGbJMphkHUr4I9PgT3Ztwh9tevYnux5/sukEux5/tOvxKXEY8KnTyD5xiJa5zCom9onLLLipZuGW+x5rwiduFRozGz712tkn1HoE7ifqDnyc4MQnxOfExz4H+8RPYwfjmrKW+n5nqoBLxY2Pgx4hpBL04CMppS/neaysDXuTQl58RMLEt2ofhtJQTZFeS58P+4VhSV1LkGsTa5I+GFaJkMCqpPx/jaj+r/dBVFDXkv8PFP+/TEjMUV+F5tL/z6r6vyujfjwsxMJLaxkNCzEir+P/W6v+Ty3HYo3wHv2/PR5JXE/z4v/loq5c896IkJij/+vLWgrlOvlewHtjsqUxvb+gvB9A7xOQsQi/l8CU9xzU7ztkeh9CvY6MRfG7cfEex5J7jMfwe/l+x+9GYwL1FWnviSytJ99b+c1YHL/hVukLifdZlPdjqD8xgt/TezQTynsxI0kTAr+Po3pfh2vj8r0d1VzK+z7Ke0ET6tcUeEx19XX8NSxzT+X1lNdUKD8edS2d6msRP85HX/f7iVGBX1OspWsVia9FjesxvDc6jA/ofdpYEH+IBfn9JH6/i34uJ+m9szFu6df7/XgQH0U8+CTixsdRKeLCJxEXPh52c53nhiX1Ol4r6h8pcxHRV8afRD0S9YXEdYqoi9HrfDrsYZ8Q+XV9EnZJ9LWIuljjxifDLon6bnwapnsISo0p9wi58GnIhT9Kn4bFmHwSFD4O0X08+GPYw+3Hw158SO+F8Xt+8r1Apnr/j6n+rEr05/kPaiP0/UN+H6E+SVujfO9Y8v1iJfSe4IjE7xGqvofJ73vK9yuujSjCSfw9VH6/GxnG+0w1z2voe2sIfxiRrfL9WHkN1XuQie+tsTDejytCCR+MyH5MigeFmGyVPv99IN+rpHXK3xvKuhi9ZxkQYwW/j6nUZct/Z2Wg+vvqkWIS9fnvwSATX+PSvyNTpP2dmF4T46CQ+Ps1/e9c5WtOvk8q/t5WviYf3h8mNB9k4lr5d3tUtnytWJuoyznl7/PENYnrfPiA1itzUfEebvJrUd0v5Z7iutT3d+W91K+hQvdU+t8Y8fox4g1ixBeSgmLsDciW5hTKfAAjHj9GPMoaauk+/tR1Kdel11aop7+uN4hRX2iJMX8IY74gxvwBjPkCGPUHpRBG/WFJ1MZSBBLXJAUxRvdk1FdRrvMFMe4PCYFwsu+nPqH5IMYDRKkra5a5jtYx6g/LeWVNGGMBxbCqvwry2vHgMMaDEYn6S02o8deQoa6Scn1ISMxTX5E+XiIspV3zqOt4PiKEhzHJIpgMR5c1NRzFJFHXJHVfbenaCKbCw7JNXZN6bUzWY5gajuFkMITLLgfumDR4Rd+NVwzduG/sx3WTBlfMWly36HHHqsddq5Hdthpwm2s63LXqcMdqwB2LmLtrN+G23YRbNlqnT7hlEfg6iy5Nav2uWcvumJV5Le4SqpskuSaVBvfMGtxnWtyTdWqpLua0khgzkwb3jKK9b9LggXTfNIS71Jo1eNE4gJcMvbhtGsJ5px0TwRBGonGMxEYwGo1jNBLDaCQqhMKY8vlxyuPBWY8LM14vjvuDmMz0+0b9+1b5vaqqJ38vRzAWikryzwr/mQxjLBjGWGiY6wL1wxgNDjP6s8nrAmGMBmRNdb24h6jxvRLEn+nRQBgj9L2KrvUPY8QX5u+L/H1MuWdgmNfwnDeU/B7JaJwq7g0g7vVLQcLine truncated

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 193 KiB

+4 -1
View File
@@ -456,7 +456,10 @@
.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: url("../assets/img/nav-active-light-red.svg");
background-image: var(
--light-red-nav-active-background-image,
url("../assets/img/nav-active-light-red.svg")
);
}
.third-submenu-bar {
@@ -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 (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<header
className={cn(
"flex shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900",
description ? "min-h-14 py-2" : "h-14",
)}
>
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
@@ -76,8 +85,15 @@ export function WikiHeader({
>
<Menu className="size-5" />
</button>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
知识库管理
<div className="min-w-0">
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{title}
</div>
{description ? (
<p className="mt-0.5 max-w-xl truncate text-xs text-slate-500 dark:text-slate-400">
{description}
</p>
) : null}
</div>
</div>
@@ -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 */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<h1 className="text-xl font-semibold">
{pageDescription?.title || t.agents.title}
</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
{pageDescription?.description || t.agents.description}
</p>
</div>
<div className="flex items-center gap-2">
@@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
label={
language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载离线包"
: t.common.download
}
tooltip={
language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载包含网页运行库的 ZIP 离线包"
: t.common.download
}
onClick={async () => {
if (onDownloadClick) {
onDownloadClick();
@@ -130,7 +130,9 @@ export function ArtifactFileList({
) : (
<DownloadIcon className="size-4" />
)}
{t.common.download}
{!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file))
? "下载离线包"
: t.common.download}
</Button>
</CardAction>
</CardHeader>
@@ -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),红/蓝/深蓝主题自适应。
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
*/
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)}
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
{desensitizeText(welcomeDescription)}
</p>
</div>
);
@@ -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}
/>
@@ -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 (
<ChainOfThoughtStep
@@ -653,7 +662,7 @@ function ToolCall({
return (
<ChainOfThoughtStep
key={id}
label={description ?? t.toolCalls.useTool(name)}
label={invokedSkillLabel ?? description ?? t.toolCalls.useTool(name)}
icon={WrenchIcon}
></ChainOfThoughtStep>
);
@@ -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<string, HTMLButtonElement>());
const navRef = useRef<HTMLElement | null>(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 (
<nav
ref={navRef}
aria-label="问题导航"
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
>
@@ -1,9 +1,18 @@
"use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type RefObject,
} from "react";
import { toast } from "sonner";
import defaultLightRedNavActive from "@/assets/img/nav-active-light-red.svg";
import defaultLightRedHeaderBackground from "@/assets/img/top-bg-light-red.svg";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -13,17 +22,35 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import {
clearHeaderTitlePreview,
setHeaderTitlePreview,
} from "@/core/branding/header-title-preview";
import {
clearLightRedNavigationPreview,
setLightRedNavigationPreview,
} from "@/core/branding/light-red-navigation-preview";
import {
resolveAppIdentityAssetUrl,
resolveAppIdentityLogoUrl,
type AppIdentityHeaderTitleFontMode,
} from "@/core/system-settings/api";
import {
useAppIdentitySettings,
useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks";
import {
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
HEADER_TITLE_FIXED_MIN_FONT_SIZE,
HEADER_TITLE_MAX_FONT_SIZE,
} from "@/strategy-components/header-title-fit";
import { SettingsSection } from "./settings-section";
@@ -33,6 +60,121 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/jpeg",
"image/webp",
]);
const ACCEPTED_NAVIGATION_IMAGE_TYPES = new Set([
...ACCEPTED_LOGO_TYPES,
"image/svg+xml",
]);
const isAcceptedNavigationImage = (file: File) =>
ACCEPTED_NAVIGATION_IMAGE_TYPES.has(file.type) ||
(file.type === "" && file.name.toLowerCase().endsWith(".svg"));
const clampHeaderTitleFontSize = (value: number) =>
Math.min(
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, Math.round(value)),
);
interface NavigationImageUploadCardProps {
inputId: string;
inputRef: RefObject<HTMLInputElement | null>;
title: string;
description: string;
previewUrl: string;
previewAlt: string;
previewImageClassName: string;
pendingFile: File | null;
hasCustomImage: boolean;
restoringDefault: boolean;
disabled: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onRestoreDefault: () => void;
}
function NavigationImageUploadCard({
inputId,
inputRef,
title,
description,
previewUrl,
previewAlt,
previewImageClassName,
pendingFile,
hasCustomImage,
restoringDefault,
disabled,
onChange,
onRestoreDefault,
}: NavigationImageUploadCardProps) {
return (
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(220px,2fr)_minmax(180px,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-slate-950/90 p-2">
<img
src={previewUrl}
alt={previewAlt}
className={previewImageClassName}
loading="lazy"
/>
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
<div>
<div className="text-sm font-medium">{title}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{description}
</p>
</div>
<input
ref={inputRef}
id={inputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg"
aria-label={`${title}文件`}
className="sr-only"
onChange={onChange}
disabled={disabled}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => inputRef.current?.click()}
disabled={disabled}
>
<ImageUp aria-hidden />
{hasCustomImage || pendingFile ? "更换图片" : "上传图片"}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={onRestoreDefault}
disabled={
disabled ||
(!hasCustomImage && !pendingFile) ||
(restoringDefault && !pendingFile)
}
>
恢复默认
</Button>
</div>
{pendingFile && (
<p
className="truncate text-xs text-muted-foreground"
title={pendingFile.name}
>
待上传:{pendingFile.name}
</p>
)}
{restoringDefault && !pendingFile && (
<p className="text-xs text-muted-foreground">
保存后使用当前内置图片。
</p>
)}
</div>
</div>
);
}
export function AppIdentitySettingsPage() {
const { theme } = useTheme();
@@ -43,22 +185,72 @@ export function AppIdentitySettingsPage() {
const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const headerBackgroundInputRef = useRef<HTMLInputElement>(null);
const navActiveInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [headerTitleFontMode, setHeaderTitleFontMode] =
useState<AppIdentityHeaderTitleFontMode>("adaptive");
const [headerTitleFontSize, setHeaderTitleFontSize] = useState(
HEADER_TITLE_MAX_FONT_SIZE,
);
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false);
const [pendingHeaderBackground, setPendingHeaderBackground] =
useState<File | null>(null);
const [removeHeaderBackground, setRemoveHeaderBackground] = useState(false);
const [pendingNavActive, setPendingNavActive] = useState<File | null>(null);
const [removeNavActive, setRemoveNavActive] = useState(false);
const [navigationImagesTouched, setNavigationImagesTouched] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setSystemName(identity?.system_name ?? "");
setHeaderTitleFontMode(identity?.header_title_font_mode ?? "adaptive");
setHeaderTitleFontSize(
clampHeaderTitleFontSize(
identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE,
),
);
setPendingLogo(null);
setRemoveLogo(false);
setPendingHeaderBackground(null);
setRemoveHeaderBackground(false);
setPendingNavActive(null);
setRemoveNavActive(false);
setNavigationImagesTouched(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
if (headerBackgroundInputRef.current) {
headerBackgroundInputRef.current.value = "";
}
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
}, [
identity?.system_name,
identity?.header_title_font_mode,
identity?.header_title_font_size,
identity?.logo_version,
identity?.has_logo,
identity?.light_red_header_background_version,
identity?.has_light_red_header_background,
identity?.light_red_nav_active_version,
identity?.has_light_red_nav_active,
]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
const headerBackgroundPreviewUrl = useMemo(
() =>
pendingHeaderBackground
? URL.createObjectURL(pendingHeaderBackground)
: undefined,
[pendingHeaderBackground],
);
const navActivePreviewUrl = useMemo(
() =>
pendingNavActive ? URL.createObjectURL(pendingNavActive) : undefined,
[pendingNavActive],
);
useEffect(
() => () => {
@@ -67,16 +259,86 @@ export function AppIdentitySettingsPage() {
[previewUrl],
);
useEffect(
() => () => {
if (headerBackgroundPreviewUrl) {
URL.revokeObjectURL(headerBackgroundPreviewUrl);
}
},
[headerBackgroundPreviewUrl],
);
useEffect(
() => () => {
if (navActivePreviewUrl) URL.revokeObjectURL(navActivePreviewUrl);
},
[navActivePreviewUrl],
);
useEffect(
() => () => {
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
},
[],
);
const savedLogoUrl = removeLogo
? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
const savedHeaderBackgroundUrl = removeHeaderBackground
? undefined
: resolveAppIdentityAssetUrl(
identity?.light_red_header_background_url,
);
const savedNavActiveUrl = removeNavActive
? undefined
: resolveAppIdentityAssetUrl(identity?.light_red_nav_active_url);
const displayedHeaderBackgroundUrl =
headerBackgroundPreviewUrl ??
savedHeaderBackgroundUrl ??
defaultLightRedHeaderBackground;
const displayedNavActiveUrl =
navActivePreviewUrl ?? savedNavActiveUrl ?? defaultLightRedNavActive;
useEffect(() => {
if (!navigationImagesTouched) return;
setLightRedNavigationPreview({
headerBackgroundUrl:
headerBackgroundPreviewUrl ?? savedHeaderBackgroundUrl ?? null,
navActiveUrl: navActivePreviewUrl ?? savedNavActiveUrl ?? null,
});
}, [
headerBackgroundPreviewUrl,
navigationImagesTouched,
navActivePreviewUrl,
savedHeaderBackgroundUrl,
savedNavActiveUrl,
]);
const normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges =
normalizedName !== (identity?.system_name ?? "") ||
headerTitleFontMode !==
(identity?.header_title_font_mode ?? "adaptive") ||
clampHeaderTitleFontSize(headerTitleFontSize) !==
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) ||
Boolean(pendingLogo) ||
removeLogo;
const customized = Boolean(identity?.system_name || identity?.has_logo);
removeLogo ||
Boolean(pendingHeaderBackground) ||
removeHeaderBackground ||
Boolean(pendingNavActive) ||
removeNavActive;
const customized = Boolean(
identity?.system_name ||
identity?.has_logo ||
identity?.has_light_red_header_background ||
identity?.has_light_red_nav_active ||
(identity?.header_title_font_mode ?? "adaptive") !== "adaptive" ||
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) !==
HEADER_TITLE_MAX_FONT_SIZE,
);
const busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -102,26 +364,82 @@ export function AppIdentitySettingsPage() {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleHeaderBackgroundChange = (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!isAcceptedNavigationImage(file)) {
toast.error("导航栏背景图仅支持 PNG、JPG、WebP 或 SVG 图片");
event.target.value = "";
return;
}
setPendingHeaderBackground(file);
setRemoveHeaderBackground(false);
setNavigationImagesTouched(true);
};
const handleNavActiveChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!isAcceptedNavigationImage(file)) {
toast.error("菜单选中样式图仅支持 PNG、JPG、WebP 或 SVG 图片");
event.target.value = "";
return;
}
setPendingNavActive(file);
setRemoveNavActive(false);
setNavigationImagesTouched(true);
};
const handleUseDefaultHeaderBackground = () => {
setPendingHeaderBackground(null);
setRemoveHeaderBackground(true);
setNavigationImagesTouched(true);
if (headerBackgroundInputRef.current) {
headerBackgroundInputRef.current.value = "";
}
};
const handleUseDefaultNavActive = () => {
setPendingNavActive(null);
setRemoveNavActive(true);
setNavigationImagesTouched(true);
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
};
const handleSave = async () => {
try {
await updateIdentity.mutateAsync({
systemName: normalizedName,
headerTitleFontMode,
headerTitleFontSize: clampHeaderTitleFontSize(headerTitleFontSize),
logo: pendingLogo,
removeLogo,
lightRedHeaderBackground: pendingHeaderBackground,
removeLightRedHeaderBackground: removeHeaderBackground,
lightRedNavActive: pendingNavActive,
removeLightRedNavActive: removeNavActive,
});
toast.success("系统名称已保存", {
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
setNavigationImagesTouched(false);
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
toast.success("系统名称配置已保存", {
description: "导航名称、Logo 与红色主题图片已更新。",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
toast.error(error instanceof Error ? error.message : "系统名称配置保存失败");
}
};
const handleReset = async () => {
try {
await deleteIdentity.mutateAsync();
setNavigationImagesTouched(false);
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo");
toast.success("已恢复默认系统名称、Logo、字号模式和导航图片");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
}
@@ -131,7 +449,7 @@ export function AppIdentitySettingsPage() {
<>
<SettingsSection
title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo,并设置顶部导航中的名称字号及红色主题图片。未上传自定义图片时继续使用当前内置图片。这里不会改「生成网页标识」。"
>
<div className="space-y-6">
<div className="space-y-2">
@@ -152,6 +470,131 @@ export function AppIdentitySettingsPage() {
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">导航栏名称字号</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
自适应会根据导航栏凹槽的可用宽度自动调整;固定大小始终使用指定字号。
</p>
</div>
<ToggleGroup
type="single"
value={headerTitleFontMode}
onValueChange={(value) => {
if (value) {
const mode = value as AppIdentityHeaderTitleFontMode;
setHeaderTitleFontMode(mode);
setHeaderTitlePreview({
mode,
fontSize: clampHeaderTitleFontSize(headerTitleFontSize),
});
}
}}
variant="outline"
aria-label="导航栏名称字号模式"
disabled={isPending || busy}
className="w-full sm:w-fit"
>
<ToggleGroupItem
value="adaptive"
className="h-11 flex-1 px-5 sm:flex-none"
>
自适应
</ToggleGroupItem>
<ToggleGroupItem
value="fixed"
className="h-11 flex-1 px-5 sm:flex-none"
>
固定大小
</ToggleGroupItem>
</ToggleGroup>
{headerTitleFontMode === "fixed" ? (
<div className="grid gap-3 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(0,1fr)_112px] sm:items-center">
<div className="space-y-2">
<label
htmlFor="app-identity-header-font-size-range"
className="text-sm font-medium"
>
固定字号
</label>
<input
id="app-identity-header-font-size-range"
type="range"
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
step={1}
value={clampHeaderTitleFontSize(headerTitleFontSize)}
onChange={(event) => {
const fontSize = Number(event.target.value);
setHeaderTitleFontSize(fontSize);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize,
});
}}
disabled={isPending || busy}
className="h-11 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-valuetext={`${clampHeaderTitleFontSize(headerTitleFontSize)} 像素`}
/>
<div className="flex justify-between text-xs tabular-nums text-muted-foreground">
<span>{HEADER_TITLE_FIXED_MIN_FONT_SIZE}px</span>
<span>{HEADER_TITLE_FIXED_MAX_FONT_SIZE}px</span>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="app-identity-header-font-size"
className="text-sm font-medium"
>
字号数值
</label>
<div className="relative">
<Input
id="app-identity-header-font-size"
type="number"
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
step={1}
value={headerTitleFontSize}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) {
setHeaderTitleFontSize(value);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize: clampHeaderTitleFontSize(value),
});
}
}}
onBlur={() => {
const fontSize =
clampHeaderTitleFontSize(headerTitleFontSize);
setHeaderTitleFontSize(fontSize);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize,
});
}}
disabled={isPending || busy}
className="h-11 pr-9 tabular-nums"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
px
</span>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground sm:col-span-2">
拖动滑杆时顶部导航会同步预览。固定模式不会随分辨率缩放;名称过长时仍保持单行并完整显示,可能超出装饰区域。
</p>
</div>
) : (
<p className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-xs leading-5 text-muted-foreground">
当前使用自适应模式:最大 30px,空间不足时逐步缩小,最小 24px。
</p>
)}
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">系统 Logo</div>
@@ -226,6 +669,47 @@ export function AppIdentitySettingsPage() {
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">红色主题导航图片</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
分别配置顶部导航栏背景和菜单选中样式。选择图片或恢复默认时,页面顶部会立即同步预览;保存后对所有用户生效。
</p>
</div>
<NavigationImageUploadCard
inputId="app-identity-light-red-header-background"
inputRef={headerBackgroundInputRef}
title="导航栏背景图"
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用 1920×64 或相同比例的横向通栏图片。"
previewUrl={displayedHeaderBackgroundUrl}
previewAlt="红色导航栏背景图预览"
previewImageClassName="h-full w-full object-cover"
pendingFile={pendingHeaderBackground}
hasCustomImage={Boolean(
identity?.has_light_red_header_background,
)}
restoringDefault={removeHeaderBackground}
disabled={isPending || busy}
onChange={handleHeaderBackgroundChange}
onRestoreDefault={handleUseDefaultHeaderBackground}
/>
<NavigationImageUploadCard
inputId="app-identity-light-red-nav-active"
inputRef={navActiveInputRef}
title="菜单选中样式图"
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用与当前 152×42 比例接近的透明图片。"
previewUrl={displayedNavActiveUrl}
previewAlt="红色导航菜单选中样式图预览"
previewImageClassName="max-h-full max-w-full object-contain"
pendingFile={pendingNavActive}
hasCustomImage={Boolean(identity?.has_light_red_nav_active)}
restoringDefault={removeNavActive}
disabled={isPending || busy}
onChange={handleNavActiveChange}
onRestoreDefault={handleUseDefaultNavActive}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
@@ -244,7 +728,7 @@ export function AppIdentitySettingsPage() {
disabled={!hasChanges || isPending || busy}
>
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
{updateIdentity.isPending ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() {
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
<DialogTitle>清除系统名称配置?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。
</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
@@ -97,6 +97,7 @@ import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import SkillVersioningSettingsPage from "@/pages/SkillVersioningSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section";
@@ -1662,6 +1663,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
<Separator />
<SkillVersioningSettingsPage embedded />
</>
)}
</div>
@@ -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 (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BarChart3Icon className="size-5" />
工具与技能调用统计
</h3>
<p className="text-muted-foreground text-sm">
为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、
技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">记录工具与技能调用</div>
<p className="text-muted-foreground text-sm">
关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="记录工具与技能调用统计"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取调用统计开关,系统将按当前部署配置执行。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。
</p>
</section>
);
}
@@ -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<string>();
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<BatchCreateResult | null>(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 (
<SettingsSection
title="批量新增知识库"
description="输入多个知识库名称后,系统会在浏览器端按顺序逐个创建到你的个人知识库。"
>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-names">
知识库名称
</label>
<Textarea
id="knowledge-batch-names"
value={namesText}
onChange={(event) => setNamesText(event.target.value)}
rows={7}
maxLength={10000}
disabled={submitting}
placeholder="例如:产品手册,项目资料库,常见问题"
/>
<p className="text-xs text-muted-foreground">
使用中文逗号、英文逗号或换行分隔;重复名称会自动忽略。已识别 {names.length} 个名称。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-embedding-model">
原文向量模型
</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground">
<Loader2Icon className="mr-2 size-4 animate-spin" />正在读取知识服务模型
</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive" role="alert">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>
重新读取
</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={submitting}>
<SelectTrigger id="knowledge-batch-embedding-model" className="w-full">
<SelectValue placeholder="请选择向量模型" />
</SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
知识服务中没有可用的 Embedding 模型,请先在知识服务中启用向量模型。
</div>
)}
</div>
{result && (
<div
className={result.failed.length
? "rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
: "rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-300"}
role={result.failed.length ? "alert" : "status"}
>
{result.createdNames.length > 0 && <p className="font-medium">已创建 {result.createdNames.length} 个知识库</p>}
{result.failed.length > 0 && (
<>
<p className="flex items-center gap-2 font-medium"><AlertCircleIcon className="size-4" />{result.failed.length} 个名称未创建</p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs leading-5">
{result.failed.slice(0, 5).map((item) => <li key={item.name}>{item.name}:{item.message}</li>)}
{result.failed.length > 5 && <li>其余 {result.failed.length - 5} 个失败项已保留在输入框中。</li>}
</ul>
</>
)}
</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleCreate()} disabled={submitting || names.length === 0 || modelSelectionUnavailable}>
{submitting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
{submitting ? "正在创建" : `创建${names.length ? `(${names.length})` : ""}`}
</Button>
</div>
</div>
</SettingsSection>
);
}
@@ -3,7 +3,9 @@
import {
ArchiveIcon,
ArrowRightLeftIcon,
BarChart3Icon,
BrainIcon,
BookPlusIcon,
CpuIcon,
DatabaseIcon,
FileOutputIcon,
@@ -36,6 +38,7 @@ import { AuthenticationSettingsPage } from "@/components/workspace/settings/auth
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { CallMetricsSettingsPage } from "@/components/workspace/settings/call-metrics-settings-page";
import { GeneratedPageBrandingSettingsPage } from "@/components/workspace/settings/generated-page-branding-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
@@ -44,6 +47,7 @@ import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-ar
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { KnowledgeBatchCreateSettingsPage } from "@/components/workspace/settings/knowledge-batch-create-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
@@ -67,11 +71,13 @@ type SettingsSection =
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "knowledge-batch-create"
| "appIdentity"
| "generatedPageBranding"
| "agentSkillMigration"
| "memory"
| "fileWriting"
| "callMetrics"
| "retryKeepPartial"
| "uploads"
| "tools"
@@ -127,11 +133,13 @@ export function SettingsDialog(props: SettingsDialogProps) {
{ id: "agentSkillMigration", label: "智能体与技能迁移", icon: ArrowRightLeftIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "callMetrics", label: "调用统计", icon: BarChart3Icon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
]
: []),
{ id: "knowledge-batch-create", label: "批量新增知识库", icon: BookPlusIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -223,6 +231,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "knowledge-batch-create" && (
<KnowledgeBatchCreateSettingsPage />
)}
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
@@ -244,6 +255,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "callMetrics" && isAdmin && (
<CallMetricsSettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
@@ -36,6 +36,11 @@ import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
DateRangePicker,
type DateRangePickerProps,
type DateRangeValue,
} from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -91,6 +96,7 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { filterSkillsByCreatedAt } from "@/core/skill-created-at-filter";
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
@@ -117,6 +123,7 @@ import {
useAgentSkillMigrationSettings,
usePublishSquares,
useSkillCompressionSettings,
useSkillVersioningSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import {
@@ -173,6 +180,19 @@ type SkillSort =
| "name_asc"
| "name_desc";
function formatLocalCalendarDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function recentCalendarRange(days: number, now = new Date()): [string, string] {
const start = new Date(now);
start.setDate(start.getDate() - Math.max(1, days) + 1);
return [formatLocalCalendarDate(start), formatLocalCalendarDate(now)];
}
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
classNames: {
@@ -224,6 +244,7 @@ function SkillSettingsList({
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [createdAtRange, setCreatedAtRange] = useState<DateRangeValue>([]);
const [page, setPage] = useState(1);
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
// {id} is owner_user_id; {label} the desensitized display name.
@@ -268,6 +289,19 @@ function SkillSettingsList({
useState(false);
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
const migrationInputRef = useRef<HTMLInputElement>(null);
const createdAtPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
return {
今天: recentCalendarRange(1, now),
最近7天: recentCalendarRange(7, now),
最近30天: recentCalendarRange(30, now),
本月: [
formatLocalCalendarDate(monthStart),
formatLocalCalendarDate(now),
],
};
}, []);
// Name + tag filtering is resolved server-side via the /api/skills query.
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
@@ -279,8 +313,16 @@ function SkillSettingsList({
tagIds,
sort,
scope: isAdmin ? undefined : "mine",
// The same catalogue query is also used while chatting. Always refetch
// when Skill Management mounts so a just-invoked skill's counter is fresh.
refetchOnMount: "always",
});
const skillsInCreatedAtRange = useMemo(
() => filterSkillsByCreatedAt(skills, createdAtRange),
[skills, createdAtRange],
);
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
@@ -289,7 +331,7 @@ function SkillSettingsList({
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
for (const skill of skillsInCreatedAtRange) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
@@ -320,7 +362,7 @@ function SkillSettingsList({
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
}, [skillsInCreatedAtRange, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool.
@@ -375,7 +417,7 @@ function SkillSettingsList({
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort, publisher?.id, filter]);
}, [search, tagIds, sort, publisher?.id, filter, createdAtRange]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
@@ -782,7 +824,20 @@ function SkillSettingsList({
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<DateRangePicker
className="w-full sm:w-[300px]"
value={createdAtRange}
valueType="YYYY-MM-DD"
format="YYYY-MM-DD"
placeholder={["开始日期", "结束日期"]}
presets={createdAtPresets}
presetsPlacement="bottom"
clearable
rangeInputProps={{ label: "创建时间" }}
popupProps={{ overlayClassName: "skill-created-at-range-popup" }}
onChange={setCreatedAtRange}
/>
<ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
@@ -1287,8 +1342,8 @@ export function SkillRow({
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
toast.success(
enabled
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
);
setConfirmOfflineOpen(false);
} catch (err) {
@@ -1625,8 +1680,8 @@ export function SkillRow({
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
@@ -1637,8 +1692,8 @@ export function SkillRow({
<PowerOff className="size-3.5" />
)}
{isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
</Button>
)}
<Button
@@ -1895,9 +1950,9 @@ export function SkillRow({
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.skills.offlineConfirmDescription.replace(
{t.settings.skills.offlineConfirmDescription.replace(
"{name}",
desensitize(skill.name),
)}
@@ -1918,7 +1973,7 @@ export function SkillRow({
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
@@ -1991,6 +2046,7 @@ function InstallSkillDialog({
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const { data: skillVersioning } = useSkillVersioningSettings();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
@@ -2079,10 +2135,18 @@ function InstallSkillDialog({
);
const handleInstall = useCallback(
async (overwrite: boolean) => {
async (
overwrite: boolean,
installMode?: "iterate" | "overwrite",
) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
const result = await installMutation.mutateAsync({
file,
overwrite,
targetCategory,
installMode,
});
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
@@ -2137,6 +2201,10 @@ function InstallSkillDialog({
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const showIterationChoices =
skillVersioning?.enabled === true &&
targetCategory === "custom" &&
ownCustomCanOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict =
@@ -2362,6 +2430,12 @@ function InstallSkillDialog({
</span>
</div>
)}
{showIterationChoices && (
<div className="bg-muted/40 mt-1 rounded-md border px-3 py-2 text-xs leading-relaxed">
<span className="font-medium">请选择更新方式:</span>
迭代会保留旧版本并把本次上传设为最新版;直接覆盖会清除旧版本链,从本次上传重新作为第 1 版记录。两种方式都不会清零技能调用次数。
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
@@ -2383,27 +2457,45 @@ function InstallSkillDialog({
>
{dialog.cancel}
</Button>
{canOverwrite && (
{showIterationChoices && (
<Button
variant="destructive"
onClick={() => handleInstall(true)}
onClick={() => void handleInstall(true, "iterate")}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{dialog.replace}
迭代为新版本
</Button>
)}
{canOverwrite && (
<Button
variant="destructive"
onClick={() =>
void handleInstall(
true,
showIterationChoices ? "overwrite" : undefined,
)
}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{showIterationChoices ? "直接覆盖" : dialog.replace}
</Button>
)}
{!showIterationChoices && (
<Button
onClick={() => void handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
)}
<Button
onClick={() => handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</>
)}
@@ -1,5 +1,13 @@
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -16,6 +24,7 @@ import {
useSkillKnowledgeBindings,
useSkillKnowledgeJobs,
} from "@/strategy-components/api/skill-knowledge";
import { cn } from "@/lib/utils";
const PHASE_LABELS: Record<string, string> = {
scanning: "扫描知识文件",
@@ -42,6 +51,11 @@ const STATUS_LABELS: Record<string, string> = {
canceled: "已取消",
};
const MIN_LEFT_PANEL_WIDTH = 320;
const MIN_RIGHT_PANEL_WIDTH = 420;
const PANEL_RESIZER_WIDTH = 12;
const PANEL_GRID_GAP = 16;
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
const visibleSkills = useSkills();
const allSkills = useAllSkillsAdmin(adminMode);
@@ -56,6 +70,115 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
const jobs = useSkillKnowledgeJobs(!adminMode);
const actions = useSkillKnowledgeActions();
const panelLayoutRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<{
pointerId: number;
startClientX: number;
startLeftPanelWidth: number;
} | null>(null);
const [leftPanelWidth, setLeftPanelWidth] = useState<number | null>(null);
const [isResizingPanels, setIsResizingPanels] = useState(false);
const getPanelLayoutMetrics = () => {
const container = panelLayoutRef.current;
if (!container || typeof window === "undefined") {
return { contentWidth: 0, paddingLeft: 0 };
}
const style = window.getComputedStyle(container);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
return {
contentWidth: Math.max(0, container.clientWidth - paddingLeft - paddingRight),
paddingLeft,
};
};
const getPanelResizeBounds = () => {
const { contentWidth } = getPanelLayoutMetrics();
const maximum = Math.max(
MIN_LEFT_PANEL_WIDTH,
contentWidth - MIN_RIGHT_PANEL_WIDTH - PANEL_RESIZER_WIDTH - PANEL_GRID_GAP * 2,
);
return { minimum: MIN_LEFT_PANEL_WIDTH, maximum };
};
const clampLeftPanelWidth = (width: number) => {
const { minimum, maximum } = getPanelResizeBounds();
return Math.min(Math.max(width, minimum), maximum);
};
const adjustLeftPanelWidth = (delta: number) => {
const { contentWidth } = getPanelLayoutMetrics();
const availableWidth = Math.max(
0,
contentWidth - PANEL_RESIZER_WIDTH - PANEL_GRID_GAP * 2,
);
const currentWidth = leftPanelWidth ?? Math.round(availableWidth * 0.45);
setLeftPanelWidth(clampLeftPanelWidth(currentWidth + delta));
};
const startPanelResize = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const container = panelLayoutRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const dividerRect = event.currentTarget.getBoundingClientRect();
const { paddingLeft } = getPanelLayoutMetrics();
resizeSessionRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startLeftPanelWidth: clampLeftPanelWidth(
dividerRect.left - containerRect.left - paddingLeft - PANEL_GRID_GAP,
),
};
event.currentTarget.setPointerCapture(event.pointerId);
setIsResizingPanels(true);
event.preventDefault();
};
const movePanelResize = (event: PointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
setLeftPanelWidth(
clampLeftPanelWidth(
session.startLeftPanelWidth + event.clientX - session.startClientX,
),
);
};
const finishPanelResize = (event: PointerEvent<HTMLDivElement>) => {
if (resizeSessionRef.current?.pointerId !== event.pointerId) return;
resizeSessionRef.current = null;
setIsResizingPanels(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handlePanelResizerKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowLeft":
adjustLeftPanelWidth(-32);
break;
case "ArrowRight":
adjustLeftPanelWidth(32);
break;
case "Home":
setLeftPanelWidth(getPanelResizeBounds().minimum);
break;
case "End":
setLeftPanelWidth(getPanelResizeBounds().maximum);
break;
default:
return;
}
event.preventDefault();
};
const panelLayoutStyle = leftPanelWidth === null
? undefined
: ({ "--skill-knowledge-left-panel-width": `${leftPanelWidth}px` } as CSSProperties);
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
return (knowledgeBases.data ?? [])
@@ -143,7 +266,17 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
};
return (
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
<div
ref={panelLayoutRef}
className={cn(
"grid size-full min-h-0 gap-4 overflow-auto p-4 lg:overflow-hidden lg:p-6",
leftPanelWidth === null
? "lg:grid-cols-[minmax(320px,0.9fr)_12px_minmax(420px,1.1fr)]"
: "lg:grid-cols-[minmax(320px,var(--skill-knowledge-left-panel-width))_12px_minmax(420px,1fr)]",
isResizingPanels && "select-none",
)}
style={panelLayoutStyle}
>
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4">
<h2 className="font-semibold">归纳技能知识</h2>
@@ -236,6 +369,27 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
</ScrollArea>
</section>
<div
role="separator"
aria-label="调整归纳配置与任务面板宽度"
aria-orientation="vertical"
aria-valuemin={MIN_LEFT_PANEL_WIDTH}
aria-valuemax={getPanelResizeBounds().maximum}
aria-valuenow={leftPanelWidth ?? undefined}
aria-valuetext={leftPanelWidth === null ? "默认宽度" : `${leftPanelWidth} 像素`}
tabIndex={0}
title="拖动调整面板宽度,双击恢复默认"
className="group hidden touch-none cursor-col-resize items-stretch justify-center outline-none lg:flex"
onPointerDown={startPanelResize}
onPointerMove={movePanelResize}
onPointerUp={finishPanelResize}
onPointerCancel={finishPanelResize}
onKeyDown={handlePanelResizerKeyDown}
onDoubleClick={() => setLeftPanelWidth(null)}
>
<span className="w-px rounded-full bg-border transition-colors group-hover:w-0.5 group-hover:bg-primary group-focus-visible:w-0.5 group-focus-visible:bg-primary" />
</div>
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4">
@@ -257,10 +411,10 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
<Progress className="my-2" value={progress} />
{job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
<span className="min-w-0 flex-1 truncate font-medium">{item.skill_name}</span>
<span className="shrink-0 text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
<span className="shrink-0">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" className="shrink-0" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div>
))}
</div>
@@ -272,7 +426,7 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
</div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><div className="flex min-w-48 flex-1 items-center gap-2"><span className="min-w-0 flex-1 truncate font-medium">{binding.skill_name}</span><span className="min-w-0 flex-1 truncate text-muted-foreground">已归纳到 {binding.target_name}</span></div><Badge variant="outline" className="shrink-0">{STATUS_LABELS[binding.status] || binding.status}</Badge><div className="ml-auto flex shrink-0 items-center gap-1"><Button size="sm" variant="outline" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div></div>)}</div>
</div>
</section>
</div>
@@ -7,14 +7,17 @@ import {
FilesIcon,
FileTextIcon,
GlobeIcon,
HistoryIcon,
Loader2,
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Dialog as TDesignDialog } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -38,15 +41,23 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useGenerateSkillNameZh,
useActivateSkillVersion,
useInstallSkillUpload,
useSetSkillAlwaysOn,
useSetSkillDetail,
useSetSkillNameZh,
useSkill,
useSkillContent,
useSkillFiles,
useSkillVersions,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import {
useSkillCompressionSettings,
useSkillVersioningSettings,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { formatDateTime } from "@/core/utils/datetime";
import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
@@ -109,7 +120,11 @@ export function SkillDetailDialog({
const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const activateVersion = useActivateSkillVersion();
const validateIteration = useValidateSkillUpload();
const installIteration = useInstallSkillUpload();
const { data: compression } = useSkillCompressionSettings();
const { data: versioning } = useSkillVersioningSettings();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@@ -118,10 +133,15 @@ export function SkillDetailDialog({
// Optional model override for the "AI 生成" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null
"overview" | "detail" | "display" | "source" | "files" | "versions" | null
>(null);
const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const [versionToActivate, setVersionToActivate] = useState<number | null>(null);
const [iterationFile, setIterationFile] = useState<File | null>(null);
const iterationFileRef = useRef<HTMLInputElement | null>(null);
const confirmationOpen =
versionToActivate !== null || iterationFile !== null;
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
@@ -132,6 +152,12 @@ export function SkillDetailDialog({
if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]);
const canUseVersions =
versioning?.enabled === true &&
skill?.category === "custom" &&
!skill.builtin &&
canManage;
const canIterate = canUseVersions && isOwner;
const {
data: contentData,
isLoading: isContentLoading,
@@ -144,6 +170,12 @@ export function SkillDetailDialog({
isLoading: isFilesLoading,
error: filesError,
} = useSkillFiles(filesEnabled ? skillName : null);
const versionsEnabled = open && canUseVersions && activeTab === "versions";
const {
data: versionsData,
isLoading: isVersionsLoading,
error: versionsError,
} = useSkillVersions(versionsEnabled ? skillName : null, versionsEnabled);
useEffect(() => {
setEditing(false);
@@ -151,17 +183,23 @@ export function SkillDetailDialog({
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
setVersionToActivate(null);
setIterationFile(null);
}, [open, skillName]);
useEffect(() => {
setNameZhDraft(skill?.name_zh ?? "");
}, [skill?.name, skill?.name_zh]);
useEffect(() => {
if (!canUseVersions && activeTab === "versions") setActiveTab("overview");
}, [activeTab, canUseVersions]);
const skillBody = useMemo(() => {
return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]);
const defaultTab: "detail" = "detail";
const defaultTab: "overview" = "overview";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
@@ -218,11 +256,80 @@ export function SkillDetailDialog({
toast.success("Copied SKILL.md content");
}
function handleActivateVersion(version: number) {
if (!skill || version === versionsData?.active_version) return;
setVersionToActivate(version);
}
async function confirmActivateVersion() {
if (!skill || versionToActivate === null) return;
try {
await activateVersion.mutateAsync({
skillName: skill.name,
version: versionToActivate,
});
toast.success(`已切换到 v${versionToActivate}`);
setVersionToActivate(null);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "版本切换失败");
}
}
async function handleIterationFile(file: File | undefined) {
if (!file || !skill || !canIterate) return;
try {
const validation = await validateIteration.mutateAsync({
file,
targetCategory: "custom",
});
if (
validation.skill_name !== skill.name ||
validation.conflict_kind !== "custom" ||
!validation.owned_by_me ||
!validation.can_overwrite
) {
throw new Error(`请选择与当前技能同名的安装包:${skill.name}`);
}
setIterationFile(file);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
} finally {
if (iterationFileRef.current) iterationFileRef.current.value = "";
}
}
async function confirmIteration() {
if (!iterationFile) return;
try {
await installIteration.mutateAsync({
file: iterationFile,
overwrite: true,
targetCategory: "custom",
installMode: "iterate",
});
toast.success("已迭代为新版本");
setIterationFile(null);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
}
}
function handleDetailOpenChange(nextOpen: boolean) {
// TDesign renders its modal through a portal. While it is open, Radix sees
// clicks inside that portal as interactions outside this detail dialog.
// Keep the parent mounted until the nested confirmation has finished.
if (!nextOpen && confirmationOpen) return;
onOpenChange(nextOpen);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleDetailOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
onInteractOutside={(event) => {
if (confirmationOpen) event.preventDefault();
}}
style={{
width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none",
@@ -345,8 +452,10 @@ export function SkillDetailDialog({
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description ? desensitize(skill.description) : "-"}
<DialogDescription className="sr-only">
{skill?.description
? desensitize(skill.description)
: tx.introEmpty}
</DialogDescription>
</div>
</div>
@@ -368,7 +477,11 @@ export function SkillDetailDialog({
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
<TabsList className="h-8">
<TabsList className="h-8 min-w-0 max-w-full overflow-x-auto">
<TabsTrigger value="overview" className="text-xs">
<SparklesIcon className="mr-1 size-3.5" />
{tx.introSectionTitle}
</TabsTrigger>
<TabsTrigger value="detail" className="text-xs">
<FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle}
@@ -385,9 +498,45 @@ export function SkillDetailDialog({
{tx.filesSectionTitle}
</TabsTrigger>
)}
{canUseVersions && (
<TabsTrigger value="versions" className="text-xs">
<HistoryIcon className="mr-1 size-3.5" />
版本
</TabsTrigger>
)}
</TabsList>
<div className="flex shrink-0 items-center gap-1">
{canIterate && (
<>
<input
ref={iterationFileRef}
type="file"
accept=".zip,.skill,application/zip"
className="hidden"
onChange={(event) =>
void handleIterationFile(event.currentTarget.files?.[0])
}
/>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={
validateIteration.isPending ||
installIteration.isPending
}
onClick={() => iterationFileRef.current?.click()}
>
{validateIteration.isPending || installIteration.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<UploadCloudIcon className="size-3.5" />
)}
迭代新版本
</Button>
</>
)}
{canManage && compression?.enabled && skill && (
<div
className="mr-1 flex items-center gap-1.5"
@@ -460,6 +609,25 @@ export function SkillDetailDialog({
</div>
</div>
<TabsContent
value="overview"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{skill.description ? (
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
<p className="text-sm leading-7 whitespace-pre-wrap">
{desensitize(skill.description)}
</p>
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<SparklesIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.introEmpty}</p>
</div>
)}
</TabsContent>
<TabsContent
value="detail"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
@@ -554,6 +722,80 @@ export function SkillDetailDialog({
</TabsContent>
)}
{canUseVersions && (
<TabsContent
value="versions"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
<div className="mb-4 rounded-lg border bg-muted/30 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
版本切换只替换当前生效的完整技能包,历史版本不会进入大模型上下文;技能名称和累计调用次数保持不变。
</div>
{isVersionsLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : versionsError ? (
<div className="text-destructive py-6 text-sm">
{versionsError instanceof Error
? versionsError.message
: String(versionsError)}
</div>
) : versionsData?.versions.length ? (
<div className="space-y-3">
{versionsData.versions.map((version) => (
<div
key={version.version}
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-semibold">
v{version.version}
</span>
{version.active && (
<Badge className="text-[11px]">当前生效</Badge>
)}
<span className="text-muted-foreground text-xs">
{formatDateTime(version.created_at)}
</span>
</div>
<p className="mt-1.5 text-sm">
{version.summary || "技能内容更新"}
</p>
<p className="text-muted-foreground mt-1 font-mono text-[11px]">
{version.sha256.slice(0, 12)}
{version.based_on_version
? ` · 基于 v${version.based_on_version}`
: ""}
</p>
</div>
{!version.active && (
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={activateVersion.isPending}
onClick={() => void handleActivateVersion(version.version)}
>
{activateVersion.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
切换到此版本
</Button>
)}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-10 text-center text-sm">
暂无版本记录
</div>
)}
</TabsContent>
)}
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
@@ -597,6 +839,59 @@ export function SkillDetailDialog({
if (!next) setOpenedFilePath(null);
}}
/>
<TDesignDialog
visible={versionToActivate !== null}
header="切换技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定切换", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: activateVersion.isPending }}
confirmLoading={activateVersion.isPending}
closeBtn={!activateVersion.isPending}
closeOnEscKeydown={!activateVersion.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onCancel={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onConfirm={() => void confirmActivateVersion()}
>
<p className="text-sm leading-6 text-muted-foreground">
确认切换到 v{versionToActivate ?? ""}?切换后大模型只会看到该版本,技能调用次数保持不变。
</p>
</TDesignDialog>
<TDesignDialog
visible={iterationFile !== null}
header="迭代技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定迭代", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: installIteration.isPending }}
confirmLoading={installIteration.isPending}
closeBtn={!installIteration.isPending}
closeOnEscKeydown={!installIteration.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onCancel={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onConfirm={() => void confirmIteration()}
>
<div className="space-y-2 text-sm leading-6 text-muted-foreground">
<p>确认将此安装包迭代为新版本?当前版本会完整保留,技能调用次数保持不变。</p>
<p className="truncate text-xs" title={iterationFile?.name}>
安装包:{iterationFile?.name}
</p>
</div>
</TDesignDialog>
</>
);
}
@@ -5,6 +5,8 @@ import { useMemo } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { AuroraText } from "../ui/aurora-text";
@@ -12,11 +14,15 @@ import { AuroraText } from "../ui/aurora-text";
export function Welcome({
className,
mode,
pageDescription,
}: {
className?: string;
mode?: "ultra" | "pro" | "thinking" | "flash";
/** The actual interface mode is resolved by ChatPage. */
pageDescription?: GeneralQaPageDescription;
}) {
const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
const desensitizeText = useDisplayDesensitizer();
const searchParams = useSearchParams();
const isUltra = useMemo(() => mode === "ultra", [mode]);
@@ -26,6 +32,9 @@ export function Welcome({
}
return ["var(--color-foreground)"];
}, [isUltra]);
const configured = pageDescription ?? pageDescriptions?.general_qa.full;
const normalTitle = configured?.welcome_title || t.welcome.greeting;
const normalDescription = configured?.welcome_description || "";
return (
<div
className={cn(
@@ -38,7 +47,7 @@ export function Welcome({
t.welcome.createYourOwnSkill
) : (
<div className="flex items-center">
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
<AuroraText colors={colors}>{desensitizeText(normalTitle)}</AuroraText>
</div>
)}
</div>
@@ -53,15 +62,11 @@ export function Welcome({
)} */}
</div>
) : (
<div className="text-muted-foreground text-sm">
{/* {t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.description}
</pre>
) : (
<p>{t.welcome.description}</p>
)} */}
</div>
normalDescription ? (
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{desensitizeText(normalDescription)}
</p>
) : null
)}
</div>
);
@@ -10,6 +10,7 @@ import {
Compass,
CpuIcon,
DatabaseZapIcon,
FileTextIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
@@ -318,6 +319,16 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/page-descriptions")
}
>
<FileTextIcon />
页面描述配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
+58
View File
@@ -94,6 +94,33 @@ export interface AdminLeaderboardDimension {
last_seen_at: string;
}
export type AdminLeaderboardDetailKind = "skill" | "tool" | "agent";
export interface AdminLeaderboardDetailItem {
name: string;
count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailSummary {
count: number;
item_count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailResponse {
kind: AdminLeaderboardDetailKind;
range_days: number;
since: string;
until: string;
summary: AdminLeaderboardDetailSummary;
items: AdminLeaderboardDetailItem[];
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
@@ -248,6 +275,37 @@ export async function getUserLeaderboard(
return result;
}
export async function getUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind,
range: {
since: string;
until: string;
days?: number;
},
limit = 500,
): Promise<AdminLeaderboardDetailResponse> {
const params = new URLSearchParams({
kind,
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/leaderboard/details?${params}`;
const result = await parse<AdminLeaderboardDetailResponse>(
await apiFetch(url),
"Failed to load leaderboard details",
);
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-管理后台-查询排行榜维度详情",
url,
reqParam: { kind, ...range, limit },
returnParam: { itemCount: result.items.length },
});
return result;
}
/**
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`.
+26
View File
@@ -8,6 +8,7 @@ import {
generateUserLeaderboardReport,
getUserMemory,
getUserLeaderboard,
getUserLeaderboardDetails,
refreshTodayLeaderboard,
listUserQa,
listUserThreadMessages,
@@ -16,6 +17,7 @@ import {
updateAdminV1Fact,
updateAdminV1Section,
} from "./api";
import type { AdminLeaderboardDetailKind } from "./api";
import {
cancelActiveRun,
cancelAllActiveRuns,
@@ -131,6 +133,30 @@ export function useUserLeaderboard(
});
}
/** Expanded, fresh detail data shown from a leaderboard card's “more” drawer. */
export function useUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind | null,
range: {
since: string;
until: string;
days?: number;
},
enabled: boolean,
) {
return useQuery({
queryKey: [
"admin-activity",
"leaderboard-details",
kind,
range.since,
range.until,
],
queryFn: () => getUserLeaderboardDetails(kind!, range),
enabled: enabled && !!kind,
refetchOnWindowFocus: false,
});
}
/**
* Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers.
+11 -1
View File
@@ -53,7 +53,17 @@ export async function downloadArtifactFile({
isMock?: boolean;
publicShareCode?: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
const isHtmlArtifact = /\.(?:html?|xhtml)$/i.test(getFileName(filepath));
const url = urlOfArtifact({
filepath,
threadId,
isMock,
publicShareCode,
download: true,
// Older deployments and mock endpoints safely ignore this query and return
// the original HTML, which preserves backwards compatibility.
offlineBundle: isHtmlArtifact && !isMock,
});
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
+10 -3
View File
@@ -5,28 +5,35 @@ export function urlOfArtifact({
filepath,
threadId,
download = false,
offlineBundle = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
download?: boolean;
offlineBundle?: boolean;
isMock?: boolean;
publicShareCode?: string;
}) {
const query = new URLSearchParams();
if (download) query.set("download", "true");
if (offlineBundle) query.set("offline_bundle", "true");
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
)}/files${normalized}${suffix}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearHeaderTitlePreview,
getHeaderTitlePreview,
setHeaderTitlePreview,
subscribeHeaderTitlePreview,
} from "./header-title-preview";
test("publishes live header title previews and clears unsaved values", () => {
clearHeaderTitlePreview();
let updates = 0;
const unsubscribe = subscribeHeaderTitlePreview(() => {
updates += 1;
});
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.deepEqual(getHeaderTitlePreview(), {
mode: "fixed",
fontSize: 34,
});
assert.equal(updates, 1);
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.equal(updates, 1);
clearHeaderTitlePreview();
assert.equal(getHeaderTitlePreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,35 @@
export type HeaderTitlePreview = {
mode: "adaptive" | "fixed";
fontSize: number;
};
let currentPreview: HeaderTitlePreview | null = null;
const listeners = new Set<() => void>();
export function getHeaderTitlePreview(): HeaderTitlePreview | null {
return currentPreview;
}
export function subscribeHeaderTitlePreview(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setHeaderTitlePreview(preview: HeaderTitlePreview): void {
if (
currentPreview?.mode === preview.mode &&
currentPreview.fontSize === preview.fontSize
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearHeaderTitlePreview(): void {
if (currentPreview === null) {
return;
}
currentPreview = null;
listeners.forEach((listener) => listener());
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearLightRedNavigationPreview,
getLightRedNavigationPreview,
setLightRedNavigationPreview,
subscribeLightRedNavigationPreview,
} from "./light-red-navigation-preview";
test("publishes temporary red navigation image URLs and clears them", () => {
clearLightRedNavigationPreview();
let updates = 0;
const unsubscribe = subscribeLightRedNavigationPreview(() => {
updates += 1;
});
setLightRedNavigationPreview({
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.deepEqual(getLightRedNavigationPreview(), {
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.equal(updates, 1);
clearLightRedNavigationPreview();
assert.equal(getLightRedNavigationPreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,37 @@
export interface LightRedNavigationPreview {
headerBackgroundUrl: string | null;
navActiveUrl: string | null;
}
type Listener = () => void;
let currentPreview: LightRedNavigationPreview | null = null;
const listeners = new Set<Listener>();
export function getLightRedNavigationPreview() {
return currentPreview;
}
export function subscribeLightRedNavigationPreview(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setLightRedNavigationPreview(
preview: LightRedNavigationPreview,
) {
if (
currentPreview?.headerBackgroundUrl === preview.headerBackgroundUrl &&
currentPreview.navActiveUrl === preview.navActiveUrl
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearLightRedNavigationPreview() {
if (currentPreview === null) return;
currentPreview = null;
listeners.forEach((listener) => listener());
}
+3 -1
View File
@@ -498,7 +498,7 @@ export const enUS: Translations = {
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeOffline: "Offline",
badgeOffline: "Offline",
badgeOfflineTitle: "Retained in the system but unavailable to models",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
@@ -541,6 +541,8 @@ export const enUS: Translations = {
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
introSectionTitle: "Overview",
introEmpty: "No skill overview yet.",
detailSectionTitle: "Details",
detailEmpty: "No details yet.",
detailEditAction: "Edit details",
@@ -471,6 +471,8 @@ export interface Translations {
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
introSectionTitle: string;
introEmpty: string;
detailSectionTitle: string;
detailEmpty: string;
detailEditAction: string;
+4 -2
View File
@@ -477,8 +477,8 @@ export const zhCN: Translations = {
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
@@ -519,6 +519,8 @@ export const zhCN: Translations = {
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
introSectionTitle: "简介",
introEmpty: "暂无技能简介。",
detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情",
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping runner requires the explicit suffix.
import { filterSkillsByCreatedAt } from "./skill-created-at-filter.ts";
const skills = [
{ name: "before", created_at: "2026-10-31T23:59:59" },
{ name: "start", created_at: "2026-11-01T00:00:00" },
{ name: "middle", created_at: "2026-11-05T12:00:00" },
{ name: "end", created_at: "2026-11-07T23:59:59" },
{ name: "after", created_at: "2026-11-08T00:00:00" },
{ name: "unknown", created_at: null },
];
test("keeps the original list when no complete range is selected", () => {
assert.equal(filterSkillsByCreatedAt(skills, []), skills);
assert.equal(filterSkillsByCreatedAt(skills, ["2026-11-01"]), skills);
});
test("filters creation time inclusively by local calendar day", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-01", "2026-11-07"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
test("accepts a reversed range without changing its meaning", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-07", "2026-11-01"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
@@ -0,0 +1,42 @@
import type { Skill } from "./skills/type";
export type SkillCreatedAtRange = readonly (string | number | Date)[];
function localDayStart(value: string | number | Date) {
let date: Date;
if (typeof value === "string") {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
date = dateOnly
? new Date(
Number(dateOnly[1]),
Number(dateOnly[2]) - 1,
Number(dateOnly[3]),
)
: new Date(value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return null;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/** Filter skills by an inclusive local-calendar creation date range. */
export function filterSkillsByCreatedAt<T extends Pick<Skill, "created_at">>(
skills: T[],
range: SkillCreatedAtRange,
): T[] {
if (range.length < 2) return skills;
const first = localDayStart(range[0]);
const second = localDayStart(range[1]);
if (first === null || second === null) return skills;
const start = Math.min(first, second);
const endDate = new Date(Math.max(first, second));
endDate.setHours(23, 59, 59, 999);
const end = endDate.getTime();
return skills.filter((skill) => {
if (!skill.created_at) return false;
const createdAt = new Date(skill.created_at).getTime();
return !Number.isNaN(createdAt) && createdAt >= start && createdAt <= end;
});
}
+50 -1
View File
@@ -159,6 +159,24 @@ export interface InstallSkillResponse {
message: string;
overwritten?: boolean;
}
export interface SkillVersionEntry {
version: number;
sha256: string;
created_at: string;
created_by?: string | null;
action: string;
summary?: string | null;
based_on_version?: number | null;
active: boolean;
}
export interface SkillVersionsResponse {
enabled: boolean;
skill_name: string;
active_version?: number | null;
versions: SkillVersionEntry[];
}
export interface PreviewSkillInstallRequest {
thread_id: string;
@@ -271,13 +289,18 @@ export async function validateSkillUpload(
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
options: {
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
} = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
if (options.installMode) params.set("install_mode", options.installMode);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
@@ -289,6 +312,32 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export async function getSkillVersions(
skillName: string,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill versions");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function activateSkillVersion(
skillName: string,
version: number,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions/${version}/activate`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to activate skill version");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
+52 -4
View File
@@ -10,6 +10,7 @@ import {
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
activateSkillVersion,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
@@ -17,6 +18,7 @@ import {
getSkillContent,
getSkillFileContent,
getSkillFiles,
getSkillVersions,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
@@ -41,17 +43,24 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams & { enabled?: boolean } = {}) {
export function useSkills(
params: ListSkillsParams & {
enabled?: boolean;
refetchOnMount?: boolean | "always";
} = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const enabled = params.enabled ?? true;
const refetchOnMount = params.refetchOnMount;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
enabled,
staleTime: 30_000,
refetchOnMount,
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -224,10 +233,49 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
mutationFn: ({ file, overwrite, targetCategory, installMode }: {
file: File;
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
}) => installSkillUpload(file, { overwrite, targetCategory, installMode }),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({
queryKey: ["skill-versions", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-content", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-files", result.skill_name],
});
},
});
}
export function useSkillVersions(skillName: string | null, enabled: boolean) {
return useQuery({
queryKey: ["skill-versions", skillName],
queryFn: () => getSkillVersions(skillName!),
enabled: enabled && !!skillName,
});
}
export function useActivateSkillVersion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, version }: { skillName: string; version: number }) =>
activateSkillVersion(skillName, version),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill-versions", skillName], data);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
},
});
}
+242 -3
View File
@@ -32,6 +32,86 @@ export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export interface GeneralQaPageDescription {
welcome_title: string;
welcome_description: string;
input_placeholder: string;
}
export interface GeneralQaPageDescriptions {
full: GeneralQaPageDescription;
compact: GeneralQaPageDescription;
}
export interface PageHeaderDescription {
title: string;
description: string;
}
export interface AIWritingGuideCard {
title: string;
description: string;
}
export interface AIWritingPageDescription extends PageHeaderDescription {
guide_cards: AIWritingGuideCard[];
}
export interface PageDescriptionsSettings {
general_qa: GeneralQaPageDescriptions;
knowledge_base: PageHeaderDescription;
ai_writing: AIWritingPageDescription;
strategy_projects: PageHeaderDescription;
deep_research: PageHeaderDescription;
gongwen: PageHeaderDescription;
light_app_management: PageHeaderDescription;
light_app_list: PageHeaderDescription;
agents: PageHeaderDescription;
business_chains: PageHeaderDescription;
skills: PageHeaderDescription;
knowledge_management: PageHeaderDescription;
}
export type PageDescriptionsUpdate = PageDescriptionsSettings;
export async function loadPageDescriptionsSettings(): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
);
if (!response.ok) {
throw new Error(`无法读取页面描述配置(HTTP ${response.status})`);
}
return (await response.json()) as PageDescriptionsSettings;
}
export async function updatePageDescriptionsSettings(
body: PageDescriptionsUpdate,
): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存页面描述配置失败" }));
throw new Error(detail.detail ?? "保存页面描述配置失败");
}
const data = (await response.json()) as PageDescriptionsSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-页面描述配置编辑",
url: "/api/system-settings/page-descriptions",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
@@ -313,17 +393,33 @@ export interface HtmlRuntimeSettings {
message_map_tile_max_zoom: number;
}
export type AppIdentityHeaderTitleFontMode = "adaptive" | "fixed";
export interface AppIdentitySettings {
system_name: string;
header_title_font_mode: AppIdentityHeaderTitleFontMode;
header_title_font_size: number;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
light_red_header_background_url: string | null;
light_red_header_background_version: string | null;
has_light_red_header_background: boolean;
light_red_nav_active_url: string | null;
light_red_nav_active_version: string | null;
has_light_red_nav_active: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
headerTitleFontSize: number;
logo?: File | null;
removeLogo?: boolean;
lightRedHeaderBackground?: File | null;
removeLightRedHeaderBackground?: boolean;
lightRedNavActive?: File | null;
removeLightRedNavActive?: boolean;
}
export interface LeaderboardSettings {
@@ -571,15 +667,29 @@ export interface GeneratedPageBrandingUpdate {
export interface AppIdentitySettings {
system_name: string;
header_title_font_mode: AppIdentityHeaderTitleFontMode;
header_title_font_size: number;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
light_red_header_background_url: string | null;
light_red_header_background_version: string | null;
has_light_red_header_background: boolean;
light_red_nav_active_url: string | null;
light_red_nav_active_version: string | null;
has_light_red_nav_active: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
headerTitleFontSize: number;
logo?: File | null;
removeLogo?: boolean;
lightRedHeaderBackground?: File | null;
removeLightRedHeaderBackground?: boolean;
lightRedNavActive?: File | null;
removeLightRedNavActive?: boolean;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -729,8 +839,22 @@ export async function updateAppIdentitySettings(
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
body.set("header_title_font_mode", update.headerTitleFontMode);
body.set("header_title_font_size", String(update.headerTitleFontSize));
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
if (update.lightRedHeaderBackground) {
body.set("light_red_header_background", update.lightRedHeaderBackground);
}
if (update.removeLightRedHeaderBackground) {
body.set("remove_light_red_header_background", "true");
}
if (update.lightRedNavActive) {
body.set("light_red_nav_active", update.lightRedNavActive);
}
if (update.removeLightRedNavActive) {
body.set("remove_light_red_nav_active", "true");
}
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
@@ -748,8 +872,17 @@ export async function updateAppIdentitySettings(
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
header_title_font_mode: update.headerTitleFontMode,
header_title_font_size: update.headerTitleFontSize,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
light_red_header_background_name:
update.lightRedHeaderBackground?.name,
remove_light_red_header_background: Boolean(
update.removeLightRedHeaderBackground,
),
light_red_nav_active_name: update.lightRedNavActive?.name,
remove_light_red_nav_active: Boolean(update.removeLightRedNavActive),
},
returnParam: data,
});
@@ -780,10 +913,16 @@ export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings>
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
return resolveAppIdentityAssetUrl(logoUrl);
}
export function resolveAppIdentityAssetUrl(
assetUrl: string | null | undefined,
): string | undefined {
if (!assetUrl) return undefined;
if (/^https?:\/\//i.test(assetUrl)) return assetUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
return `${base}${assetUrl.startsWith("/") ? "" : "/"}${assetUrl}`;
}
export function resolveGeneratedPageLogoUrl(
@@ -993,6 +1132,10 @@ export interface SkillCompressionUpdate {
keep_index_in_prompt?: boolean;
}
export interface SkillVersioningSettings {
enabled: boolean;
}
export interface ModelConcurrencySettings {
enabled: boolean;
default_limit: number;
@@ -1180,6 +1323,55 @@ export async function updateWriteFileCompatibilitySettings(
return data;
}
export interface CallMetricsSettings {
enabled: boolean;
}
export async function loadCallMetricsSettings(): Promise<CallMetricsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
);
if (!response.ok) {
throw new Error(`无法读取调用统计开关(HTTP ${response.status})`);
}
const data = (await response.json()) as CallMetricsSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-调用统计查看",
url: "/api/system-settings/call-metrics",
returnParam: data,
});
return data;
}
export async function updateCallMetricsSettings(
body: CallMetricsSettings,
): Promise<CallMetricsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({
detail: "保存调用统计开关失败",
}));
throw new Error(detail.detail ?? "保存调用统计开关失败");
}
const data = (await response.json()) as CallMetricsSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-调用统计开关",
url: "/api/system-settings/call-metrics",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadConversationContextSettings(): Promise<ConversationContextSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/conversation-context`,
@@ -1323,6 +1515,25 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
return data;
}
export async function loadSkillVersioningSettings(): Promise<SkillVersioningSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
);
if (!response.ok) {
throw new Error(
`Failed to load skill versioning settings: ${response.status}`,
);
}
const data = (await response.json()) as SkillVersioningSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-技能迭代查看",
url: "/api/system-settings/skill-versioning",
returnParam: data,
});
return data;
}
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
@@ -1398,6 +1609,34 @@ export async function updateSkillCompressionSettings(
return data;
}
export async function updateSkillVersioningSettings(
body: SkillVersioningSettings,
): Promise<SkillVersioningSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update skill versioning" }));
throw new Error(detail.detail ?? "Failed to update skill versioning");
}
const data = (await response.json()) as SkillVersioningSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-技能迭代编辑",
url: "/api/system-settings/skill-versioning",
reqParam: body,
returnParam: data,
});
return data;
}
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
export interface AgentModuleConfig {
key: string;
@@ -8,6 +8,7 @@ import {
loadAssistantKnowledgeVisibility,
loadAppearanceSettings,
loadAppIdentitySettings,
loadCallMetricsSettings,
loadGeneratedPageBrandingSettings,
loadCitationDisplaySettings,
loadConversationContextSettings,
@@ -17,6 +18,7 @@ import {
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadOrdinaryKnowledgeRetrievalSettings,
loadPageDescriptionsSettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
@@ -24,6 +26,7 @@ import {
loadPublishSquares,
loadRetryKeepPartialSettings,
loadSkillCompressionSettings,
loadSkillVersioningSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
loadWriteFileCompatibilitySettings,
@@ -31,6 +34,7 @@ import {
updateAgentSkillMigrationSettings,
updateAppearanceSettings,
updateAppIdentitySettings,
updateCallMetricsSettings,
updateGeneratedPageBrandingSettings,
updateCitationDisplaySettings,
updateConversationContextSettings,
@@ -40,6 +44,7 @@ import {
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateOrdinaryKnowledgeRetrievalSettings,
updatePageDescriptionsSettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
@@ -47,6 +52,7 @@ import {
updatePublishSquares,
updateRetryKeepPartialSettings,
updateSkillCompressionSettings,
updateSkillVersioningSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
updateWriteFileCompatibilitySettings,
@@ -56,6 +62,7 @@ import {
type AppearanceUpdate,
type AgentSkillMigrationSettings,
type AppIdentityUpdate,
type CallMetricsSettings,
type GeneratedPageBrandingUpdate,
type CitationDisplayUpdate,
type ConversationContextUpdate,
@@ -65,6 +72,7 @@ import {
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type OrdinaryKnowledgeRetrievalUpdate,
type PageDescriptionsUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
@@ -72,6 +80,7 @@ import {
type PublishSquaresUpdate,
type RetryKeepPartialSettings,
type SkillCompressionUpdate,
type SkillVersioningSettings,
type StrategyProjectSettingsUpdate,
type LayoutMode,
type ViewMode,
@@ -92,6 +101,7 @@ const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const SKILL_VERSIONING_KEY = ["systemSettings", "skillVersioning"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
export const AGENT_SKILL_MIGRATION_KEY = [
@@ -102,6 +112,7 @@ const WRITE_FILE_COMPATIBILITY_KEY = [
"systemSettings",
"writeFileCompatibility",
] as const;
const CALL_METRICS_KEY = ["systemSettings", "callMetrics"] as const;
const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const;
const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const;
const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
@@ -110,6 +121,7 @@ const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
const PAGE_DESCRIPTIONS_KEY = ["systemSettings", "pageDescriptions"] as const;
export const ASSISTANT_KNOWLEDGE_VISIBILITY_KEY = [
"system-settings",
"knowledge-base-product-version",
@@ -160,6 +172,27 @@ export function useUpdateStrategyProjectSettings() {
});
}
export function usePageDescriptionsSettings() {
return useQuery({
queryKey: PAGE_DESCRIPTIONS_KEY,
queryFn: loadPageDescriptionsSettings,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePageDescriptionsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PageDescriptionsUpdate) =>
updatePageDescriptionsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PAGE_DESCRIPTIONS_KEY, data);
},
});
}
export function usePromptPrefixSettings() {
return useQuery({
queryKey: PROMPT_PREFIX_KEY,
@@ -264,6 +297,28 @@ export function useUpdateWriteFileCompatibilitySettings() {
});
}
export function useCallMetricsSettings() {
return useQuery({
queryKey: CALL_METRICS_KEY,
queryFn: loadCallMetricsSettings,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateCallMetricsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CallMetricsSettings) => updateCallMetricsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(CALL_METRICS_KEY, data);
void queryClient.invalidateQueries({
queryKey: ["admin-activity", "leaderboard"],
});
},
});
}
export function useConversationContextSettings() {
return useQuery({
queryKey: CONVERSATION_CONTEXT_KEY,
@@ -627,6 +682,29 @@ export function useUpdateSkillCompressionSettings() {
});
}
/** Deployment-wide custom-skill versioning feature gate. */
export function useSkillVersioningSettings() {
return useQuery({
queryKey: SKILL_VERSIONING_KEY,
queryFn: () => loadSkillVersioningSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillVersioningSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillVersioningSettings) =>
updateSkillVersioningSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_VERSIONING_KEY, data);
void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
},
});
}
export function useModelConcurrencySettings() {
return useQuery({
queryKey: MODEL_CONCURRENCY_KEY,
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation.ts";
test("finds a public skill executed directly by a weak model", () => {
assert.equal(
extractInvokedSkillName("bash", {
command: "python /mnt/skills/public/facebook-page-generator/scripts/fast_page.py --spec /tmp/spec.json",
}),
"facebook-page-generator",
);
});
test("finds custom skills in host paths and nested arguments", () => {
assert.equal(
extractInvokedSkillName("read_file", {
request: { path: String.raw`F:\runtime\skills\custom\analysis-helper\SKILL.md` },
}),
"analysis-helper",
);
});
test("uses the explicit name for skill_view", () => {
assert.equal(extractInvokedSkillName("skill_view", { name: "report-maker" }), "report-maker");
});
test("does not label ordinary tool paths as skill calls", () => {
assert.equal(
extractInvokedSkillName("bash", { command: "python /mnt/user-data/workspace/run.py" }),
undefined,
);
});
test("renders a deterministic skill label without model-authored description", () => {
assert.equal(skillInvocationLabel("report-maker"), "调用技能「report-maker」");
});
@@ -0,0 +1,54 @@
const SKILL_NAME_ARG_TOOLS = new Set([
"skill_view",
"skill_manage",
"skill_archive",
]);
const SKILL_PATH_PATTERN =
/(?:^|[\\/])skills[\\/](?:public|custom)[\\/]([^\\/\s"'`;&|<>()]+)(?=[\\/]|$)/i;
function findSkillNameInValue(
value: unknown,
seen: Set<object>,
): string | undefined {
if (typeof value === "string") {
return SKILL_PATH_PATTERN.exec(value)?.[1];
}
if (!value || typeof value !== "object" || seen.has(value)) {
return undefined;
}
seen.add(value);
const values = Array.isArray(value)
? value
: Object.values(value as Record<string, unknown>);
for (const nested of values) {
const name = findSkillNameInValue(nested, seen);
if (name) {
return name;
}
}
return undefined;
}
/** Resolve a skill from actual tool arguments rather than model-authored prose. */
export function extractInvokedSkillName(
toolName: string,
args: unknown,
): string | undefined {
if (
SKILL_NAME_ARG_TOOLS.has(toolName) &&
args &&
typeof args === "object" &&
typeof (args as Record<string, unknown>).name === "string"
) {
const name = ((args as Record<string, unknown>).name as string).trim();
if (name) {
return name;
}
}
return findSkillNameInValue(args, new Set());
}
export function skillInvocationLabel(skillName: string): string {
return `调用技能「${skillName}」`;
}
+7
View File
@@ -3,6 +3,10 @@ import type { AIMessage } from "@langchain/langgraph-sdk";
import type { Translations } from "../i18n";
import { hasToolCalls } from "../messages/utils";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation";
import { isInternalToolCallName } from "./visibility";
export function explainLastToolCall(message: AIMessage, t: Translations) {
@@ -18,6 +22,7 @@ export function explainLastToolCall(message: AIMessage, t: Translations) {
}
export function explainToolCall(toolCall: ToolCall, t: Translations) {
const invokedSkillName = extractInvokedSkillName(toolCall.name, toolCall.args);
if (toolCall.name === "web_search" || toolCall.name === "image_search") {
return t.toolCalls.searchFor(toolCall.args.query);
} else if (toolCall.name === "web_fetch") {
@@ -26,6 +31,8 @@ export function explainToolCall(toolCall: ToolCall, t: Translations) {
return t.toolCalls.presentFiles;
} else if (toolCall.name === "write_todos") {
return t.toolCalls.writeTodos;
} else if (invokedSkillName) {
return skillInvocationLabel(invokedSkillName);
} else if (toolCall.args.description) {
return toolCall.args.description;
} else {
+7
View File
@@ -61,6 +61,13 @@ export const env = {
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY",
),
/**
* Knowledge-base card/list switch. Off by default so the compact list is
* always used unless a deployment explicitly opts into the alternate view.
*/
KNOWLEDGE_VIEW_SWITCH_ENABLED: readRuntimeFlag(
"VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED",
),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
+4
View File
@@ -1,3 +1,7 @@
// TDesign imperative APIs (for example DialogPlugin) create their own React
// roots. React 19 requires the library's adapter to register createRoot first.
import "tdesign-react/es/_util/react-19-adapter";
import "./core/compat/abort-signal";
import "@fontsource/noto-sans-sc/chinese-simplified-400.css";
import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
@@ -1,4 +1,5 @@
import { PenTool, Sliders, Sparkles } from 'lucide-react';
import { usePageDescriptionsSettings } from '@/core/system-settings/hooks';
/**
* AI 写作入口页左栏「欢迎/空状态」屏。
@@ -7,7 +8,7 @@ import { PenTool, Sliders, Sparkles } from 'lucide-react';
* 第一条消息,就回到正常的对话时间线。
*/
const STEPS = [
const DEFAULT_STEPS = [
{
n: '1',
title: '对白意图解析',
@@ -32,6 +33,19 @@ const STEPS = [
];
export function WritingWelcome() {
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configured = pageDescriptions?.ai_writing;
const title = configured?.title || '欢迎使用「ai写作」';
const description = configured?.description || '';
const steps = DEFAULT_STEPS.map((step, index) => {
const configuredCard = configured?.guide_cards[index];
return {
...step,
title: configuredCard?.title || step.title,
desc: configuredCard?.description || step.desc,
};
});
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
@@ -39,19 +53,25 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「ai写作」
{title}
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
{description ? (
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
{description}
</p>
) : (
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
{steps.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
@@ -27,6 +27,7 @@ import {
} from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { DateRangePicker, type DateRangePickerProps, type DateRangeValue } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,11 +43,20 @@ import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor";
import type {
AdminLeaderboardDimension,
AdminLeaderboardDetailItem,
AdminLeaderboardDetailKind,
AdminLeaderboardOverview,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
@@ -54,7 +64,7 @@ import type {
AdminLeaderboardUser,
} from "@/core/admin/api";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useRefreshTodayLeaderboard, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useRefreshTodayLeaderboard, useUserLeaderboard, useUserLeaderboardDetails } from "@/core/admin/hooks";
import {
ADMIN_QUERY_WINDOW_DAYS,
formatLocalDateInput,
@@ -70,6 +80,7 @@ type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 10;
const DETAIL_PAGE_SIZE = 15;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -776,11 +787,13 @@ function SkillRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
@@ -790,7 +803,14 @@ function SkillRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -834,11 +854,13 @@ function DimensionRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardDimension[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
@@ -848,7 +870,14 @@ function DimensionRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -886,6 +915,264 @@ function DimensionRanking({
);
}
function formatDuration(value: number): string {
if (value <= 0) return "-";
if (value < 1_000) return `${fmtNumber(value)} ms`;
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)} 秒`;
}
function LeaderboardDetailRankingBars({
kind,
items,
}: {
kind: AdminLeaderboardDetailKind;
items: AdminLeaderboardDetailItem[];
}) {
const topItems = items.slice(0, 10);
if (topItems.length === 0) {
return <div className="flex h-40 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">当前时间段暂无{kind === "agent" ? "智能体运行" : "调用"}数据</div>;
}
const max = Math.max(1, ...topItems.map((item) => item.count));
const verb = kind === "agent" ? "运行" : "调用";
return (
<div className="space-y-3" role="img" aria-label={`${kind === "agent" ? "智能体" : kind === "skill" ? "技能" : "工具"}${verb}次数前 ${topItems.length} 名柱状图`}>
{topItems.map((item, index) => {
const width = Math.max(4, (item.count / max) * 100);
return (
<div key={item.name} className="grid grid-cols-[24px_minmax(120px,1fr)_minmax(180px,2fr)_64px] items-center gap-3">
<span className="text-sm font-medium tabular-nums text-[#80868B]">{index + 1}</span>
<span className="truncate text-sm font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</span>
<div className="h-3 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-right text-sm font-semibold tabular-nums text-[#202124] dark:text-white">{fmtNumber(item.count)} 次</span>
</div>
);
})}
</div>
);
}
function LeaderboardDetailDrawer({
kind,
range,
onRangeChange,
onOpenChange,
}: {
kind: AdminLeaderboardDetailKind | null;
range: RangeState;
onRangeChange: (range: RangeState) => void;
onOpenChange: (open: boolean) => void;
}) {
const dateBounds = getAdminQueryDateBounds();
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"count" | "users">("count");
const [page, setPage] = useState(1);
const apiRange = useMemo(() => rangeForApi(range), [range]);
const { data, isLoading, error } = useUserLeaderboardDetails(kind, apiRange, !!kind);
const rangePickerValue = useMemo<DateRangeValue>(() => [range.since, range.until], [range.since, range.until]);
const rangePickerPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const lastSevenDays = defaultRange(7);
const lastThirtyDays = defaultRange(30);
return {
"近 7 天": [lastSevenDays.since, lastSevenDays.until] as [string, string],
"近 30 天": [lastThirtyDays.since, lastThirtyDays.until] as [string, string],
};
}, []);
const detailMeta = kind === "skill"
? { title: "技能调用统计", plural: "技能", verb: "调用", unit: "次调用", icon: Sparkles }
: kind === "tool"
? { title: "工具调用统计", plural: "工具", verb: "调用", unit: "次调用", icon: Activity }
: { title: "Agent 使用统计", plural: "智能体", verb: "运行", unit: "次运行", icon: Bot };
const DetailIcon = detailMeta.icon;
const filteredItems = useMemo(() => {
const needle = search.trim().toLowerCase();
const items = (data?.items ?? []).filter((item) => !needle || item.name.toLowerCase().includes(needle));
return [...items].sort((left, right) => {
if (sort === "users") return right.user_count - left.user_count || right.count - left.count;
return right.count - left.count || right.user_count - left.user_count;
});
}, [data?.items, search, sort]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / DETAIL_PAGE_SIZE));
const visibleItems = filteredItems.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE);
const summary = data?.summary;
useEffect(() => {
setPage(1);
}, [kind, apiRange.since, apiRange.until, search, sort]);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);
useEffect(() => {
if (!kind) {
setSearch("");
setSort("count");
setPage(1);
}
}, [kind]);
const setPickerRange = (value: DateRangeValue) => {
if (value.length < 2) return;
const normalizeDate = (date: DateRangeValue[number]) => {
if (date instanceof Date) return formatLocalDateInput(date);
if (typeof date === "number") return formatLocalDateInput(new Date(date));
return String(date).slice(0, 10);
};
const since = normalizeDate(value[0]);
const until = normalizeDate(value[1]);
if (!dateFromInput(since) || !dateFromInput(until)) return;
onRangeChange(clampRangeToAdminWindow({ since, until }));
};
const metric = (label: string, value: string, description: string) => (
<div className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]" title={description}>
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums text-[#231815] dark:text-white">{value}</div>
<div className="mt-1 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{description}</div>
</div>
);
return (
<Sheet open={!!kind} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:w-[min(94vw,1080px)] sm:max-w-none">
<SheetHeader className="shrink-0 border-b border-[#F1D6D6] bg-white px-5 pb-4 pt-4 pr-12 text-left dark:border-[#213D5E] dark:bg-[#02244C]">
<SheetTitle className="flex items-center gap-2 text-base">
<DetailIcon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{detailMeta.title}
</SheetTitle>
<SheetDescription className="mt-1 leading-5">
直接聚合原始统计记录;图表和明细会随当前时间范围重新汇总,不受主榜单快照刷新影响。
</SheetDescription>
</SheetHeader>
<div className="shrink-0 space-y-3 border-b border-[#F1D6D6] bg-[#FFF7F7] px-5 py-3 dark:border-[#213D5E] dark:bg-[#011226]">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-[#202124] dark:text-white">统计区间</span>
<DateRangePicker
value={rangePickerValue}
valueType="YYYY-MM-DD"
format="YYYY-MM-DD"
presets={rangePickerPresets}
clearable={false}
disableDate={{ before: dateBounds.min, after: dateBounds.max }}
onChange={setPickerRange}
className="w-full sm:w-80"
/>
<span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">仅支持近 30 天</span>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-5">
{isLoading && (
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" />
正在加载{detailMeta.plural}明细
</div>
)}
{error && (
<div role="alert" className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-4 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
明细加载失败:{error instanceof Error ? error.message : "请稍后重试"}
</div>
)}
{!isLoading && !error && summary && (
<>
<section>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">整体概览</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{range.since} 至 {range.until} · {fmtNumber(summary.item_count)} 个{detailMeta.plural}</p>
</div>
<Badge variant="outline">{fmtNumber(summary.count)} {detailMeta.unit}</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metric(`总${detailMeta.verb}量`, `${fmtNumber(summary.count)} 次`, `所选时间内的${detailMeta.verb}总数`) }
{metric("覆盖用户", `${fmtNumber(summary.user_count)} 人`, "至少使用过一次该维度的去重用户数")}
{metric(kind === "agent" ? "总 Token" : "平均耗时", kind === "agent" ? fmtNumber(summary.total_tokens) : formatDuration(summary.avg_duration_ms), kind === "agent" ? "此类 Agent 运行累计的模型 Token" : "按每次完成调用计算的平均耗时")}
{metric("活跃条目", `${fmtNumber(summary.item_count)} 项`, "当前条件下产生过统计记录的名称数量")}
</div>
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}{detailMeta.verb} Top 10</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">按当前筛选的完整时间范围累计排序,数值越大柱越长。</p>
</div>
<Badge variant="outline">{range.since} 至 {range.until}</Badge>
</div>
<LeaderboardDetailRankingBars kind={kind!} items={data?.items ?? []} />
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}明细</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">共 {fmtNumber(filteredItems.length)} 项;可按名称筛选、按调用量或用户数排序。</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
<Input aria-label={`搜索${detailMeta.plural}名称`} value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`搜索${detailMeta.plural}名称`} className="h-9 w-52 bg-white pl-8 dark:bg-[#011226]" />
</div>
<Select value={sort} onValueChange={(value) => setSort(value as "count" | "users")}>
<SelectTrigger className="h-9 w-32 bg-white text-xs dark:bg-[#011226]" aria-label="明细排序方式">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">按调用量</SelectItem>
<SelectItem value="users">按用户数</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{visibleItems.length === 0 ? (
<div className="py-16 text-center text-sm text-[#5F6368] dark:text-slate-400">当前筛选条件下暂无{detailMeta.plural}数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="border-y border-[#F1D6D6] bg-[#FFF7F7] text-xs text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#011226] dark:text-[#7FACF2]">
<tr>
<th className="px-3 py-2.5 font-medium">名称</th>
<th className="px-3 py-2.5 text-right font-medium">{detailMeta.verb}量</th>
<th className="px-3 py-2.5 text-right font-medium">用户</th>
<th className="px-3 py-2.5 text-right font-medium">{kind === "agent" ? "Token" : "平均耗时"}</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item: AdminLeaderboardDetailItem) => (
<tr key={item.name} className="border-b border-[#F6E6E6] last:border-0 dark:border-[#17375E]">
<td className="max-w-64 truncate px-3 py-3 font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.user_count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{kind === "agent" ? fmtNumber(item.total_tokens) : formatDuration(item.avg_duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>第 {page} / {pageCount} 页</span>
<Pagination page={page} pageSize={DETAIL_PAGE_SIZE} total={filteredItems.length} onPageChange={setPage} />
</div>
</section>
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -904,6 +1191,8 @@ export default function AdminUserLeaderboardPage() {
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [userPage, setUserPage] = useState(1);
const [detailKind, setDetailKind] = useState<AdminLeaderboardDetailKind | null>(null);
const [detailRange, setDetailRange] = useState<RangeState>(() => defaultRange(7));
const apiRange = useMemo(
() => rangeForApi(range),
[range],
@@ -968,6 +1257,11 @@ export default function AdminUserLeaderboardPage() {
setRange(clampRangeToAdminWindow(defaultRange(days)));
};
const openLeaderboardDetails = (kind: AdminLeaderboardDetailKind) => {
setDetailRange(clampRangeToAdminWindow(range));
setDetailKind(kind);
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
@@ -1438,19 +1732,27 @@ export default function AdminUserLeaderboardPage() {
</section>
<div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" onMore={() => openLeaderboardDetails("skill")} />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" onMore={() => openLeaderboardDetails("tool")} />
</div>
<div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" onMore={() => openLeaderboardDetails("agent")} />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
</>
)}
</div>
</ScrollArea>
<LeaderboardDetailDrawer
kind={detailKind}
range={detailRange}
onRangeChange={setDetailRange}
onOpenChange={(open) => {
if (!open) setDetailKind(null);
}}
/>
</main>
</div>
);
+14 -5
View File
@@ -51,7 +51,10 @@ import {
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import {
useAppSystemName,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime";
import {
@@ -328,6 +331,12 @@ function LoadingRows() {
export default function ArtifactLibraryPage() {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configuredDescription = pageDescriptions?.knowledge_base;
const pageTitle = configuredDescription?.title || "资料库";
const pageDescription =
configuredDescription?.description ||
"汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。";
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
@@ -337,8 +346,8 @@ export default function ArtifactLibraryPage() {
const forceRefreshRef = useRef(false);
useEffect(() => {
document.title = `资料库 - ${appName}`;
}, [appName]);
document.title = `${pageTitle} - ${appName}`;
}, [appName, pageTitle]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -478,10 +487,10 @@ export default function ArtifactLibraryPage() {
<div>
<div className="flex items-center gap-2">
<Library className="size-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
<h1 className="text-2xl font-semibold tracking-tight">{pageTitle}</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。
{pageDescription}
</p>
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end">
+23 -1
View File
@@ -11,6 +11,7 @@ import {
useConversationContextSettings,
useLayoutMode,
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
usePromptPrefixSettings,
} from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
@@ -171,6 +172,7 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar();
const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
const [effectiveLayoutMode] = useLayoutMode();
const { data: appearance } = useAppearanceSettings();
@@ -1014,6 +1016,17 @@ export default function ChatPage() {
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
// Configuration follows the user-facing interface mode, rather than the
// internal welcome component name. The compact-looking welcome component
// is also used by the full-chrome surface, so tying settings to that name
// would invert the administrator's "全量 / 简洁" configuration.
const qaDescriptionMode =
fullChatMode ||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
effectiveLayoutMode === "full"
? "full"
: "compact";
const qaPageDescription = pageDescriptions?.general_qa[qaDescriptionMode];
const composerWidthMode = resolveComposerWidthMode({
fullChatMode,
wxFullChat: pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX),
@@ -1240,11 +1253,15 @@ export default function ChatPage() {
</div>
{isLandingState && mounted &&
(compactLanding ? (
<CompactWelcome className="chat-landing-content-lift mb-6" />
<CompactWelcome
className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/>
) : (
<Welcome
mode={settings.context.mode}
className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/>
))}
{mounted ? (
@@ -1272,6 +1289,11 @@ export default function ChatPage() {
<InputBox
className="w-full"
isNewThread={isLandingState}
placeholder={
isLandingState
? qaPageDescription?.input_placeholder
: undefined
}
compact={compactLanding}
composerHeightPercent={composerHeightPercent}
threadId={threadId}
@@ -40,6 +40,7 @@ import {
useNoteWikilinks,
useVaultPage,
} from "@/core/knowledge/hooks";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import MarkdownRenderer, {
@@ -537,6 +538,8 @@ export default function KnowledgeBasePage({
} = {}) {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
@@ -649,6 +652,8 @@ export default function KnowledgeBasePage({
return (
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
title={pageDescription?.title || undefined}
description={pageDescription?.description || undefined}
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
+5 -2
View File
@@ -36,6 +36,7 @@ import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -140,6 +141,8 @@ export default function LightAppListPage() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme();
const lightAccent = getLightAccent(theme);
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_list;
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
@@ -199,14 +202,14 @@ export default function LightAppListPage() {
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
{pageDescription?.title || "应用列表"}
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
{pageDescription?.description || "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。"}
</p>
</div>
@@ -36,6 +36,7 @@ import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useSetResourceTags } from "@/core/tags";
import {
useLightAppActions,
@@ -142,6 +143,8 @@ export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_management;
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
@@ -327,18 +330,20 @@ export default function LightAppManagementPage() {
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
{pageDescription?.title || "应用管理"}
</h1>
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
{pageDescription?.description || <>
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
</>}
</p>
</div>
@@ -0,0 +1,497 @@
"use client";
import {
BotIcon,
DatabaseIcon,
FileTextIcon,
FolderKanbanIcon,
GitBranchIcon,
LandmarkIcon,
Layout,
List,
Loader2Icon,
MonitorIcon,
PenLineIcon,
RotateCcwIcon,
SaveIcon,
SparklesIcon,
TelescopeIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type {
AIWritingGuideCard,
AIWritingPageDescription,
GeneralQaPageDescription,
PageDescriptionsSettings,
} from "@/core/system-settings/api";
import {
usePageDescriptionsSettings,
useUpdatePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
type PageTarget =
| "qa-compact"
| "qa-full"
| "knowledge-base"
| "ai-writing"
| "strategy-projects"
| "deep-research"
| "gongwen"
| "light-app-management"
| "light-app-list"
| "agents"
| "business-chains"
| "skills"
| "knowledge-management";
type HeaderTarget = Exclude<PageTarget, "qa-compact" | "qa-full">;
type HeaderKey =
| "knowledge_base"
| "ai_writing"
| "strategy_projects"
| "deep_research"
| "gongwen"
| "light_app_management"
| "light_app_list"
| "agents"
| "business_chains"
| "skills"
| "knowledge_management";
const EMPTY_QA: GeneralQaPageDescription = {
welcome_title: "",
welcome_description: "",
input_placeholder: "",
};
const EMPTY_HEADER = { title: "", description: "" };
const DEFAULT_AI_WRITING_GUIDE_CARDS: AIWritingGuideCard[] = [
{
title: "对白意图解析",
description: "在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。",
},
{
title: "参数多端对齐",
description: "右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。",
},
{
title: "深度排版落成",
description: "点击右侧红色创作按钮,精美的行业或创意文档极速铺开。",
},
];
const EMPTY_AI_WRITING: AIWritingPageDescription = {
...EMPTY_HEADER,
guide_cards: DEFAULT_AI_WRITING_GUIDE_CARDS.map(() => ({
title: "",
description: "",
})),
};
const EMPTY_SETTINGS: PageDescriptionsSettings = {
general_qa: { full: EMPTY_QA, compact: EMPTY_QA },
knowledge_base: EMPTY_HEADER,
ai_writing: EMPTY_AI_WRITING,
strategy_projects: EMPTY_HEADER,
deep_research: EMPTY_HEADER,
gongwen: EMPTY_HEADER,
light_app_management: EMPTY_HEADER,
light_app_list: EMPTY_HEADER,
agents: EMPTY_HEADER,
business_chains: EMPTY_HEADER,
skills: EMPTY_HEADER,
knowledge_management: EMPTY_HEADER,
};
const HEADER_KEY_BY_TARGET: Record<HeaderTarget, HeaderKey> = {
"knowledge-base": "knowledge_base",
"ai-writing": "ai_writing",
"strategy-projects": "strategy_projects",
"deep-research": "deep_research",
gongwen: "gongwen",
"light-app-management": "light_app_management",
"light-app-list": "light_app_list",
agents: "agents",
"business-chains": "business_chains",
skills: "skills",
"knowledge-management": "knowledge_management",
};
const PAGES: Array<{
id: PageTarget;
title: string;
subtitle: string;
path: string;
defaultTitle: string;
defaultDescription: string;
}> = [
{
id: "qa-compact",
title: "通用问答 · 简洁模式",
subtitle: "紧凑工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "qa-full",
title: "通用问答 · 全量模式",
subtitle: "完整工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "knowledge-base",
title: "资料库",
subtitle: "资料库首页的标题与说明",
path: "/page/workspace/library",
defaultTitle: "资料库",
defaultDescription: "汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。",
},
{
id: "ai-writing",
title: "AI 写作",
subtitle: "AI 写作工作台的欢迎区",
path: "/page/canvas/ai-writing",
defaultTitle: "欢迎使用「ai写作」",
defaultDescription: "这是一个专业的自动化写作系统。你只需在下方对话框中输入你的本意(例如:「帮我写一篇 1500 字的周报」),系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。",
},
{
id: "strategy-projects",
title: "我的项目",
subtitle: "策略项目列表的标题与说明",
path: "/page/strategy/qa/projects",
defaultTitle: "我的项目",
defaultDescription: "集中查看普通问答、策略优选和方案成果",
},
{
id: "deep-research",
title: "深度研究",
subtitle: "深度研究工作台的欢迎区",
path: "/page/strategy/qa/deep-research",
defaultTitle: "开始一项深度研究",
defaultDescription: "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。",
},
{
id: "gongwen",
title: "公文助手",
subtitle: "公文助手工作台的欢迎区",
path: "/page/strategy/qa/gongwen",
defaultTitle: "开始一份公文工作",
defaultDescription: "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。",
},
{
id: "light-app-management",
title: "应用管理",
subtitle: "轻应用登记页的标题与说明",
path: "/page/strategy/light-app/management",
defaultTitle: "应用管理",
defaultDescription: "注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。",
},
{
id: "light-app-list",
title: "应用列表",
subtitle: "轻应用启动器的标题与说明",
path: "/page/strategy/light-app/apps",
defaultTitle: "应用列表",
defaultDescription: "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。",
},
{
id: "agents",
title: "智能体管理",
subtitle: "智能体列表页的标题与说明",
path: "/page/strategy/agents",
defaultTitle: "智能体",
defaultDescription: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
},
{
id: "business-chains",
title: "业务链管理",
subtitle: "业务链条配置页的标题与说明",
path: "/page/strategy/business-chains",
defaultTitle: "业务链条配置",
defaultDescription: "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。",
},
{
id: "skills",
title: "技能管理",
subtitle: "技能工作台的标题与说明",
path: "/page/strategy/skills",
defaultTitle: "技能",
defaultDescription: "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。",
},
{
id: "knowledge-management",
title: "知识库管理",
subtitle: "知识库工作台的标题与说明",
path: "/page/workspace/knowledge",
defaultTitle: "知识库管理",
defaultDescription: "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。",
},
];
const DEFAULT_INPUT_PLACEHOLDER = "今天我能为你做些什么?";
export default function PageDescriptionSettingsPage() {
const { data, isLoading, error } = usePageDescriptionsSettings();
const update = useUpdatePageDescriptionsSettings();
const [draft, setDraft] = useState<PageDescriptionsSettings>(EMPTY_SETTINGS);
const [activeTarget, setActiveTarget] = useState<PageTarget>("qa-compact");
useEffect(() => {
if (data) setDraft(data);
}, [data]);
const currentPage = PAGES.find((item) => item.id === activeTarget) ?? PAGES[0];
const isQa = activeTarget === "qa-compact" || activeTarget === "qa-full";
const qaMode = activeTarget === "qa-compact" ? "compact" : "full";
const qaDraft = draft.general_qa[qaMode];
const headerKey: HeaderKey = isQa
? "knowledge_base"
: HEADER_KEY_BY_TARGET[activeTarget as HeaderTarget];
const headerDraft = draft[headerKey];
const updateQa = (patch: Partial<GeneralQaPageDescription>) => {
setDraft((current) => ({
...current,
general_qa: {
...current.general_qa,
[qaMode]: { ...current.general_qa[qaMode], ...patch },
},
}));
};
const updateHeader = (patch: typeof EMPTY_HEADER) => {
setDraft((current) => ({ ...current, [headerKey]: { ...current[headerKey], ...patch } }));
};
const updateAiWritingCard = (
index: number,
patch: Partial<AIWritingGuideCard>,
) => {
setDraft((current) => ({
...current,
ai_writing: {
...current.ai_writing,
guide_cards: current.ai_writing.guide_cards.map((card, cardIndex) =>
cardIndex === index ? { ...card, ...patch } : card,
),
},
}));
};
const resetActive = () => {
if (isQa) updateQa(EMPTY_QA);
else if (activeTarget === "ai-writing") {
setDraft((current) => ({ ...current, ai_writing: EMPTY_AI_WRITING }));
} else updateHeader(EMPTY_HEADER);
};
const handleSave = async () => {
const normalizeQa = (value: GeneralQaPageDescription): GeneralQaPageDescription => ({
welcome_title: value.welcome_title.trim(),
welcome_description: value.welcome_description.trim(),
input_placeholder: value.input_placeholder.trim(),
});
const normalizeHeader = (value: typeof EMPTY_HEADER) => ({
title: value.title.trim(),
description: value.description.trim(),
});
const normalizeAiWriting = (value: AIWritingPageDescription) => ({
...normalizeHeader(value),
guide_cards: value.guide_cards.map((card) => ({
title: card.title.trim(),
description: card.description.trim(),
})),
});
try {
await update.mutateAsync({
general_qa: { full: normalizeQa(draft.general_qa.full), compact: normalizeQa(draft.general_qa.compact) },
knowledge_base: normalizeHeader(draft.knowledge_base),
ai_writing: normalizeAiWriting(draft.ai_writing),
strategy_projects: normalizeHeader(draft.strategy_projects),
deep_research: normalizeHeader(draft.deep_research),
gongwen: normalizeHeader(draft.gongwen),
light_app_management: normalizeHeader(draft.light_app_management),
light_app_list: normalizeHeader(draft.light_app_list),
agents: normalizeHeader(draft.agents),
business_chains: normalizeHeader(draft.business_chains),
skills: normalizeHeader(draft.skills),
knowledge_management: normalizeHeader(draft.knowledge_management),
});
toast.success("页面描述已保存,重新进入对应页面后即可查看新文案。");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存页面描述配置失败");
}
};
const title = isQa ? qaDraft.welcome_title : headerDraft.title;
const description = isQa ? qaDraft.welcome_description : headerDraft.description;
const previewTitle = title || currentPage.defaultTitle;
const previewDescription = description || currentPage.defaultDescription;
const previewPlaceholder = qaDraft.input_placeholder || DEFAULT_INPUT_PLACEHOLDER;
const aiWritingPreviewCards = draft.ai_writing.guide_cards.map((card, index) => ({
title: card.title || DEFAULT_AI_WRITING_GUIDE_CARDS[index].title,
description: card.description || DEFAULT_AI_WRITING_GUIDE_CARDS[index].description,
}));
return (
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6 sm:py-8 lg:overflow-hidden">
<header className="shrink-0">
<h1 className="text-xl font-semibold tracking-tight">页面描述配置</h1>
<p className="text-muted-foreground mt-1 max-w-3xl text-sm leading-relaxed">
按页面维护用户可见的标题和说明文字。通用问答的简洁模式与全量模式可分别配置;留空会沿用页面默认文案。
</p>
</header>
{isLoading ? (
<div className="flex min-h-72 items-center justify-center" aria-label="正在加载页面描述配置">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed p-8 text-center text-sm">加载页面描述配置失败:{error.message}</div>
) : (
<div className="grid gap-5 lg:min-h-0 lg:flex-1 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="rounded-xl border bg-card p-3 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<p className="text-muted-foreground sticky top-0 z-10 bg-card px-2 pb-2 text-xs font-medium tracking-wide">可配置页面</p>
<div className="grid gap-1">
{PAGES.map((item) => {
const active = activeTarget === item.id;
const Icon = item.id.startsWith("qa-")
? MonitorIcon
: item.id === "knowledge-base"
? DatabaseIcon
: item.id === "ai-writing"
? PenLineIcon
: item.id === "strategy-projects"
? FolderKanbanIcon
: item.id === "deep-research"
? TelescopeIcon
: item.id === "gongwen"
? LandmarkIcon
: item.id === "light-app-management"
? Layout
: item.id === "light-app-list"
? List
: item.id === "agents"
? BotIcon
: item.id === "business-chains"
? GitBranchIcon
: item.id === "skills"
? SparklesIcon
: DatabaseIcon;
return (
<button
key={item.id}
type="button"
onClick={() => setActiveTarget(item.id)}
aria-pressed={active}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active ? "bg-primary/10 text-foreground ring-1 ring-primary/20" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md", active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0"><span className="block text-sm font-medium">{item.title}</span><span className="mt-0.5 block text-xs leading-relaxed">{item.subtitle}</span></span>
</button>
);
})}
</div>
</aside>
<main className="min-w-0 space-y-5 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<section className="rounded-xl border bg-card p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2"><FileTextIcon className="text-primary size-4" aria-hidden /><h2 className="text-base font-semibold">{currentPage.title}</h2></div>
<p className="text-muted-foreground mt-1 text-sm">配置 <code className="font-mono text-xs">{currentPage.path}</code> 的页面文案。</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={resetActive} disabled={update.isPending}><RotateCcwIcon className="mr-1.5 size-4" aria-hidden />恢复默认文案</Button>
</div>
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5">
<span className="text-sm font-medium">{isQa ? "欢迎标题" : "页面标题"}</span>
<span className="text-muted-foreground text-xs">留空时使用系统默认文案。</span>
<Input value={title} maxLength={80} onChange={(event) => isQa ? updateQa({ welcome_title: event.target.value }) : updateHeader({ title: event.target.value, description: headerDraft.description })} placeholder={currentPage.defaultTitle} />
</label>
<label className="grid gap-1.5">
<span className="text-sm font-medium">说明文字</span>
<span className="text-muted-foreground text-xs">建议用一句话说明该页面能为用户做什么。</span>
<Textarea rows={4} value={description} maxLength={500} onChange={(event) => isQa ? updateQa({ welcome_description: event.target.value }) : updateHeader({ title: headerDraft.title, description: event.target.value })} placeholder={currentPage.defaultDescription} />
</label>
{isQa && <label className="grid gap-1.5"><span className="text-sm font-medium">输入框提示语</span><span className="text-muted-foreground text-xs">在用户开始输入前显示于问答输入框中。</span><Input value={qaDraft.input_placeholder} maxLength={120} onChange={(event) => updateQa({ input_placeholder: event.target.value })} placeholder={DEFAULT_INPUT_PLACEHOLDER} /></label>}
{activeTarget === "ai-writing" && (
<fieldset className="grid gap-3 rounded-lg border bg-muted/20 p-4">
<legend className="px-1 text-sm font-medium">引导卡片</legend>
<p className="text-muted-foreground text-xs leading-relaxed">
三张卡片按页面既有顺序展示;可分别修改标题和说明,留空则保留默认文案。
</p>
<div className="grid gap-3 xl:grid-cols-3">
{draft.ai_writing.guide_cards.map((card, index) => (
<section key={index} className="grid gap-3 rounded-md border bg-card p-3">
<h3 className="text-sm font-medium">卡片 {index + 1}</h3>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">标题</span>
<Input
value={card.title}
maxLength={80}
onChange={(event) =>
updateAiWritingCard(index, { title: event.target.value })
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].title}
/>
</label>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">说明</span>
<Textarea
rows={4}
value={card.description}
maxLength={240}
onChange={(event) =>
updateAiWritingCard(index, {
description: event.target.value,
})
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].description}
/>
</label>
</section>
))}
</div>
</fieldset>
)}
</div>
</section>
<section className="rounded-xl border bg-muted/25 p-4 sm:p-5" aria-live="polite">
<div className="mb-4 flex items-center gap-2"><MonitorIcon className="text-muted-foreground size-4" aria-hidden /><h2 className="text-sm font-semibold">页面预览</h2><span className="text-muted-foreground text-xs">{currentPage.title}</span></div>
<div className="rounded-lg border bg-background px-5 py-8 text-center shadow-sm sm:px-10">
<h3 className="text-xl font-semibold tracking-tight">{previewTitle}</h3>
<p className="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-relaxed">{previewDescription}</p>
{isQa && <div className="text-muted-foreground mt-6 rounded-xl border bg-card px-4 py-3 text-left text-sm shadow-xs">{previewPlaceholder}</div>}
{activeTarget === "ai-writing" && (
<div className="mt-6 grid gap-3 text-left md:grid-cols-3">
{aiWritingPreviewCards.map((card, index) => (
<div key={index} className="rounded-lg border bg-card p-3">
<p className="text-sm font-semibold">{index + 1}. {card.title}</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">{card.description}</p>
</div>
))}
</div>
)}
</div>
</section>
<div className="flex justify-end"><Button type="button" onClick={handleSave} disabled={update.isPending} className={cn("min-w-28", update.isPending && "cursor-wait")}>{update.isPending ? <Loader2Icon className="mr-1.5 size-4 animate-spin" aria-hidden /> : <SaveIcon className="mr-1.5 size-4" aria-hidden />}保存配置</Button></div>
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useSkillVersioningSettings,
useUpdateSkillVersioningSettings,
} from "@/core/system-settings/hooks";
export default function SkillVersioningSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillVersioningSettings();
const update = useUpdateSkillVersioningSettings();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (data) setEnabled(data.enabled);
}, [data]);
async function handleSave() {
try {
await update.mutateAsync({ enabled });
toast.success(enabled ? "技能迭代已开启" : "技能迭代已关闭");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
}
}
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
自定义技能迭代
</h1>
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
开启后,同名自定义技能上传时可选择“迭代为新版本”或“直接覆盖”,并可在技能详情中切换版本。
旧版本不会提供给大模型,技能调用次数也不会因迭代或切换而清零。关闭后保持原有覆盖逻辑。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-12 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 gap-3">
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
<HistoryIcon className="size-4" />
</div>
<div>
<div className="text-sm font-medium">启用自定义技能迭代</div>
<div className="text-muted-foreground mt-1 text-xs leading-relaxed">
仅作用于 custom 技能。第一次开启时会为现有自定义技能建立初始版本,公共/内置技能不参与迭代。
</div>
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="启用自定义技能迭代"
/>
</div>
<div className="bg-muted/40 text-muted-foreground rounded-lg border px-4 py-3 text-xs leading-relaxed">
“迭代为新版本”会保留完整旧技能包;“直接覆盖”会按旧逻辑替换内容,并从当前上传内容重新作为第 1 版开始记录。
</div>
<div className="flex justify-end">
<Button onClick={() => void handleSave()} disabled={update.isPending}>
{update.isPending && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
export default function SkillsPage({
restrictWxTypes = false,
@@ -13,6 +14,8 @@ export default function SkillsPage({
restrictWxTypes?: boolean;
} = {}) {
const [searchParams, setSearchParams] = useSearchParams();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.skills;
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
@@ -33,9 +36,9 @@ export default function SkillsPage({
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<h1 className="text-xl font-semibold">{pageDescription?.title || "技能"}</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
{pageDescription?.description || "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。"}
</p>
</div>
<TabsList>
@@ -60,6 +60,9 @@ const AdminUserLeaderboardSettingsPage = lazy(
);
const MenuManagementPage = lazy(() => import("./MenuManagementPage"));
const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage"));
const PageDescriptionSettingsPage = lazy(
() => import("./PageDescriptionSettingsPage"),
);
const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage"));
const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage"));
const LlmWikiKnowledgePage = lazy(() => import("./LlmWikiKnowledgePage"));
@@ -1381,6 +1384,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/page-descriptions"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PageDescriptionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
@@ -1,6 +1,10 @@
"use client";
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
import {
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { normalizeKnowledgeRetrievalMode, resolveKnowledgeRetrievalRequestContext, resolveSmartKnowledgeRetrievalAvailability, type KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
@@ -672,6 +676,8 @@ function DeepResearchWorkbenchInner({
const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState<Record<string, string[]>>({});
const [classicMode, setClassicMode] = useState(false);
const retrievalSettings = useOrdinaryKnowledgeRetrievalSettings();
const pageDescriptions = usePageDescriptionsSettings();
const deepResearchPageDescription = pageDescriptions.data?.deep_research;
const [draftRetrievalMode, setDraftRetrievalMode] = useState<KnowledgeRetrievalMode>("classic");
const [draftKnowledgeBaseIds, setDraftKnowledgeBaseIds] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
@@ -3261,7 +3267,10 @@ function DeepResearchWorkbenchInner({
<main className="flex h-full min-w-0 flex-col">
{!session || (isChatSession && collectorEmpty) ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<ResearchWelcome onStart={() => document.getElementById("deep-research-composer")?.focus()} />
<ResearchWelcome
pageDescription={deepResearchPageDescription}
onStart={() => document.getElementById("deep-research-composer")?.focus()}
/>
</div>
) : isChatSession ? (
<>
@@ -3786,12 +3795,22 @@ function StatusBadge({ status }: { status: DeepResearchSession["status"] }) {
return <Badge variant="outline" className={`border-0 px-2 py-0.5 text-[11px] ${tone}`}>{STATUS_LABELS[status]}</Badge>;
}
function ResearchWelcome({ onStart }: { onStart: () => void }) {
function ResearchWelcome({
pageDescription,
onStart,
}: {
pageDescription?: PageHeaderDescription;
onStart: () => void;
}) {
return (
<div className="flex min-h-[440px] flex-col items-center justify-center text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Telescope className="h-7 w-7" /></div>
<h1 className="text-2xl font-semibold tracking-tight">开始一项深度研究</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。</p>
<h1 className="text-2xl font-semibold tracking-tight">
{pageDescription?.title || "开始一项深度研究"}
</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">
{pageDescription?.description || "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。"}
</p>
<Button className="mt-6" onClick={onStart}><Sparkles className="mr-2 h-4 w-4" />输入研究课题</Button>
</div>
);
@@ -68,6 +68,8 @@ import {
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { useModels } from "@/core/models/hooks";
import { useLocalSettings } from "@/core/settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useRenameThread,
@@ -248,6 +250,8 @@ function GongwenWorkbenchInner() {
const documentQuery = useGongwenDocument(selectedDocumentId);
const actions = useGongwenActions();
const renameGongwenThread = useRenameThread();
const pageDescriptions = usePageDescriptionsSettings();
const gongwenPageDescription = pageDescriptions.data?.gongwen;
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const templates = templatesQuery.data ?? [];
@@ -618,6 +622,7 @@ function GongwenWorkbenchInner() {
return <>
{view === "chat" ? (
<GongwenAgentConversationLayout
pageDescription={gongwenPageDescription}
templates={templates}
documents={documents}
documentsLoading={documentsQuery.isLoading}
@@ -755,6 +760,7 @@ function GongwenConversationLayout(props: {
}
function GongwenAgentConversationLayout(props: {
pageDescription?: PageHeaderDescription;
templates: GongwenTemplate[];
documents: GongwenDocument[];
documentsLoading: boolean;
@@ -846,7 +852,7 @@ function GongwenAgentConversationLayout(props: {
scrollButtonClassName="bottom-3"
onClarificationSubmit={props.onClarificationSubmit}
onEditResubmit={props.onEditResubmit}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">开始一份公文工作</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">{props.pageDescription?.title || "开始一份公文工作"}</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">{props.pageDescription?.description || "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。"}</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
<div className="shrink-0 border-t border-border/70 bg-background px-4 py-4 sm:px-6">
<div className="space-y-3 rounded-2xl border border-border bg-card p-3 shadow-[0_8px_30px_rgba(0,0,0,0.06)]">
@@ -68,6 +68,7 @@ import {
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useRouteNavigate } from "@/core/route-base";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
@@ -101,6 +102,8 @@ export default function BusinessChainListPage() {
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// 管理员:额外多一个「全部链条」筛选,且可编辑 / 发布 / 删除任意账号的链条。
const isAdmin = getStoredAuth()?.system_role === "admin";
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.business_chains;
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
@@ -333,13 +336,10 @@ export default function BusinessChainListPage() {
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h1 className="flex items-center text-lg font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
业务链条配置
<span className="ml-2 rounded-md border border-[#CF1322]/30 bg-[#FFF1F0] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#CF1322] dark:border-[#A8071A]/40 dark:bg-[#A8071A]/20 dark:text-[#FF7875]">
Orchestrator v2
</span>
{pageDescription?.title || "业务链条配置"}
</h1>
<p className="mt-1 max-w-xl text-xs text-zinc-500 dark:text-[#6E88B0]">
可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。
{pageDescription?.description || "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。"}
</p>
</div>
+158 -8
View File
@@ -1,5 +1,11 @@
import { Bell, ChevronDown, ListTree, User } from "lucide-react";
import { useState } from "react";
import {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -19,6 +25,14 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import {
getHeaderTitlePreview,
subscribeHeaderTitlePreview,
} from "@/core/branding/header-title-preview";
import {
getLightRedNavigationPreview,
subscribeLightRedNavigationPreview,
} from "@/core/branding/light-red-navigation-preview";
import { isWujieEnv } from "@/core/embed/wujie";
import { isLightRedTopNavEnabled, readRuntimeEnv } from "@/env";
import { useUnreadCount } from "@/core/notifications";
@@ -31,10 +45,18 @@ import {
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { resolveAppIdentityAssetUrl } from "@/core/system-settings/api";
import { useAppIdentitySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
import {
calculateHeaderTitleFit,
HEADER_TITLE_MAX_FONT_SIZE,
HEADER_TITLE_MAX_LETTER_SPACING,
resolveHeaderTitleFit,
type HeaderTitleFit,
} from "./header-title-fit";
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
cn(
@@ -73,7 +95,45 @@ const Header = ({ collapsed }: HeaderProps) => {
const topNavLogoPosition =
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
const isTopNavLogoOnLeft = topNavLogoPosition === "left";
const systemName = useAppSystemName("CM业务中心");
const { data: appIdentity } = useAppIdentitySettings();
const headerTitlePreview = useSyncExternalStore(
subscribeHeaderTitlePreview,
getHeaderTitlePreview,
getHeaderTitlePreview,
);
const lightRedNavigationPreview = useSyncExternalStore(
subscribeLightRedNavigationPreview,
getLightRedNavigationPreview,
getLightRedNavigationPreview,
);
const systemName = appIdentity?.system_name.trim() || "CM业务中心";
const headerTitleFontMode =
headerTitlePreview?.mode ??
(appIdentity?.header_title_font_mode === "fixed" ? "fixed" : "adaptive");
const headerTitleFontSize =
headerTitlePreview?.fontSize ??
appIdentity?.header_title_font_size ??
HEADER_TITLE_MAX_FONT_SIZE;
const savedLightRedHeaderBackgroundUrl = resolveAppIdentityAssetUrl(
appIdentity?.light_red_header_background_url,
);
const savedLightRedNavActiveUrl = resolveAppIdentityAssetUrl(
appIdentity?.light_red_nav_active_url,
);
const lightRedHeaderBackgroundUrl = lightRedNavigationPreview
? lightRedNavigationPreview.headerBackgroundUrl
: savedLightRedHeaderBackgroundUrl;
const lightRedNavActiveUrl = lightRedNavigationPreview
? lightRedNavigationPreview.navActiveUrl
: savedLightRedNavActiveUrl;
const lightRedArtworkStyle = {
"--light-red-top-nav-background-image": lightRedHeaderBackgroundUrl
? `url("${lightRedHeaderBackgroundUrl}")`
: undefined,
"--light-red-nav-active-background-image": lightRedNavActiveUrl
? `url("${lightRedNavActiveUrl}")`
: undefined,
} as CSSProperties;
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
const navigate = useNavigate();
@@ -83,6 +143,19 @@ const Header = ({ collapsed }: HeaderProps) => {
const isAdmin = !auth || auth.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const desensitizeText = useDisplayDesensitizer();
const displaySystemName = desensitizeText(systemName);
const centerTitleRef = useRef<HTMLDivElement>(null);
const centerTitleMeasureRef = useRef<HTMLSpanElement>(null);
const [centerTitleFit, setCenterTitleFit] = useState<HeaderTitleFit>({
fontSize: HEADER_TITLE_MAX_FONT_SIZE,
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
lines: 1,
});
const renderedCenterTitleFit = resolveHeaderTitleFit({
mode: headerTitleFontMode,
fixedFontSize: headerTitleFontSize,
adaptiveFit: centerTitleFit,
});
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
@@ -97,6 +170,68 @@ const Header = ({ collapsed }: HeaderProps) => {
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isLightRedTopNav && !isWujieEnv();
useLayoutEffect(() => {
if (!showTopNav || headerTitleFontMode === "fixed") {
return;
}
const title = centerTitleRef.current;
const measure = centerTitleMeasureRef.current;
if (!title || !measure) {
return;
}
let disposed = false;
const updateTitleFit = () => {
if (disposed) {
return;
}
const titleStyle = window.getComputedStyle(title);
const horizontalPadding =
Number.parseFloat(titleStyle.paddingLeft) +
Number.parseFloat(titleStyle.paddingRight);
const logo = title.querySelector<HTMLElement>(".header-center-title__logo");
const logoWidth = logo?.getBoundingClientRect().width ?? 0;
const gap = logoWidth > 0 ? Number.parseFloat(titleStyle.columnGap) || 0 : 0;
const availableWidth = Math.max(
1,
title.clientWidth - horizontalPadding - logoWidth - gap,
);
const nextFit = calculateHeaderTitleFit({
availableWidth,
naturalWidth: measure.getBoundingClientRect().width,
characterCount: Array.from(displaySystemName).length,
});
setCenterTitleFit((currentFit) =>
currentFit.fontSize === nextFit.fontSize &&
currentFit.letterSpacing === nextFit.letterSpacing &&
currentFit.lines === nextFit.lines
? currentFit
: nextFit,
);
};
updateTitleFit();
const resizeObserver = new ResizeObserver(updateTitleFit);
resizeObserver.observe(title);
void document.fonts?.ready.then(updateTitleFit);
return () => {
disposed = true;
resizeObserver.disconnect();
};
}, [
displaySystemName,
headerTitleFontMode,
showTopNav,
topNavLogoPosition,
]);
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
@@ -281,6 +416,7 @@ const Header = ({ collapsed }: HeaderProps) => {
"strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4",
isLightRedTopNav && "strategy-app-header--light-red-top-nav",
)}
style={isLightRedTopNav ? lightRedArtworkStyle : undefined}
>
{showTopNav ? (
<>
@@ -316,15 +452,29 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
<div className="header-center-title">
<div ref={centerTitleRef} className="header-center-title">
{topNavLogoPosition === "center" && (
<AppBrandImage
theme={theme}
className="header-center-title__logo"
/>
)}
<span className="header-center-title__label">
{desensitizeText(systemName)}
<span
className="header-center-title__label"
data-lines={renderedCenterTitleFit.lines}
style={{
fontSize: `${renderedCenterTitleFit.fontSize}px`,
letterSpacing: `${renderedCenterTitleFit.letterSpacing}px`,
}}
>
{displaySystemName}
</span>
<span
ref={centerTitleMeasureRef}
className="header-center-title__measure"
aria-hidden="true"
>
{displaySystemName}
</span>
</div>
@@ -355,7 +505,7 @@ const Header = ({ collapsed }: HeaderProps) => {
/>
</div>
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
{desensitizeText(systemName)}
{displaySystemName}
</span>
</div>
<nav className="light-topnav" aria-label="一级菜单">
@@ -389,7 +539,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", headerTextClass)}>
{desensitizeText(systemName)}
{displaySystemName}
</span>
)}
</div>
@@ -28,6 +28,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import {
Dialog,
DialogContent,
@@ -37,6 +38,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import { Pagination } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import {
Select,
@@ -48,7 +57,11 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
import { env } from "@/env";
import {
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import {
formatWikiDirectoryPaths,
type WikiDirectoryTreeNode,
@@ -87,9 +100,17 @@ import {
} from "@/strategy-components/api/llmwiki";
type WorkspaceTab = "personal" | "public" | "settings";
type KnowledgeSort = "name-asc" | "name-desc" | "created-desc" | "created-asc";
const LIST_PATH = "/page/workspace/knowledge";
const WIKI_INSTRUCTION_MAX_LENGTH = 4000;
const WIKI_FOCUS_INSTRUCTION_MAX_LENGTH = 2000;
const knowledgePageSizeOptions = [10, 20, 50] as const;
type KnowledgePageSize = (typeof knowledgePageSizeOptions)[number];
const knowledgeNameCollator = new Intl.Collator("zh-Hans-CN", {
numeric: true,
sensitivity: "base",
});
const publicationLabels: Record<string, string> = {
private: "仅自己",
@@ -106,6 +127,34 @@ function codePointLength(value: string): number {
return Array.from(value).length;
}
function knowledgeNameInitial(value: string): string | null {
const match = value.trim().match(/^([a-z])\d+-/i);
return match ? match[1].toUpperCase() : null;
}
function knowledgeCreatedAtTimestamp(item: LlmWikiKnowledgeBase): number {
const value = item.created_at || item.remote_created_at;
const timestamp = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(timestamp) ? timestamp : 0;
}
function compareKnowledgeBases(
left: LlmWikiKnowledgeBase,
right: LlmWikiKnowledgeBase,
sort: KnowledgeSort,
): number {
const nameOrder = knowledgeNameCollator.compare(left.name, right.name);
const createdOrder = knowledgeCreatedAtTimestamp(left) - knowledgeCreatedAtTimestamp(right);
const order = sort === "name-asc"
? nameOrder
: sort === "name-desc"
? -nameOrder
: sort === "created-asc"
? createdOrder
: -createdOrder;
return order || nameOrder || left.id.localeCompare(right.id);
}
function isWikiKnowledgeBase(knowledgeBase: LlmWikiKnowledgeBase): boolean {
if (typeof knowledgeBase.wiki_generation_managed === "boolean") {
return knowledgeBase.wiki_generation_managed;
@@ -163,11 +212,20 @@ function resultPreview(result: LlmWikiSearchResult) {
export function WeKnoraKnowledgeWorkspace() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
const knowledgeManagementSettings =
useOrdinaryKnowledgeRetrievalSettings(isAdmin);
const advancedManagementEnabled =
knowledgeManagementSettings.data?.advanced_management_enabled === true;
const [tab, setTab] = useState<WorkspaceTab>("personal");
const [viewMode, setViewMode] = useState<"card" | "list">("list");
const [sort, setSort] = useState<KnowledgeSort>("name-asc");
const [nameInitialFilter, setNameInitialFilter] = useState<string | null>(null);
const [knowledgePage, setKnowledgePage] = useState(1);
const [knowledgePageSize, setKnowledgePageSize] = useState<KnowledgePageSize>(20);
const viewSwitchEnabled = env.KNOWLEDGE_VIEW_SWITCH_ENABLED;
const effectiveViewMode = viewSwitchEnabled ? viewMode : "list";
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
const actions = useLlmWikiActions();
@@ -177,12 +235,28 @@ export function WeKnoraKnowledgeWorkspace() {
const [searching, setSearching] = useState(false);
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
const availableNameInitials = useMemo(() => {
if (tab !== "personal") return [];
return [...new Set(
(knowledge.data ?? [])
.filter((item) => item.is_owner && item.remote_status !== "missing")
.map((item) => knowledgeNameInitial(item.name))
.filter((initial): initial is string => initial !== null),
)].sort(knowledgeNameCollator.compare);
}, [knowledge.data, tab]);
useEffect(() => {
setSearchResponse(null);
setSelectedSearchResult(null);
setKnowledgePage(1);
}, [tab]);
useEffect(() => {
if (nameInitialFilter && !availableNameInitials.includes(nameInitialFilter)) {
setNameInitialFilter(null);
}
}, [availableNameInitials, nameInitialFilter]);
const busy = actions.create.isPending || actions.update.isPending || actions.remove.isPending || actions.publication.isPending;
const setCreateDialogOpen = (open: boolean) => {
if (open) actions.create.reset();
@@ -195,6 +269,10 @@ export function WeKnoraKnowledgeWorkspace() {
const openDetails = (item: LlmWikiKnowledgeBase) => {
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
};
const changeKnowledgePageSize = (pageSize: KnowledgePageSize) => {
setKnowledgePageSize(pageSize);
setKnowledgePage(1);
};
const runSearch = async () => {
const query = searchText.trim();
@@ -236,10 +314,14 @@ export function WeKnoraKnowledgeWorkspace() {
<div>
<div className="flex items-center gap-2">
<Database className="size-5 text-primary" />
<h1 className="text-xl font-semibold">知识库管理</h1>
<h1 className="text-xl font-semibold">
{pageDescription?.title || "知识库管理"}
</h1>
<Badge variant="secondary">知识服务</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
<p className="mt-1 text-sm text-muted-foreground">
{pageDescription?.description || "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。"}
</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
@@ -252,7 +334,9 @@ export function WeKnoraKnowledgeWorkspace() {
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
</Button>
)}
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
{tab === "personal" && <>
<Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>
</>}
</div>
</div>
</div>
@@ -267,18 +351,57 @@ export function WeKnoraKnowledgeWorkspace() {
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
</TabsList>
{tab !== "settings" && (
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
<div className="flex w-full items-center gap-2 sm:w-auto">
<Select
value={sort}
onValueChange={(value) => {
setSort(value as KnowledgeSort);
setKnowledgePage(1);
}}
>
<SelectTrigger className="w-36 shrink-0" aria-label="知识库排序">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="name-asc">名称:A–Z</SelectItem>
<SelectItem value="name-desc">名称:Z–A</SelectItem>
<SelectItem value="created-desc">创建时间:最新</SelectItem>
<SelectItem value="created-asc">创建时间:最早</SelectItem>
</SelectContent>
</Select>
{tab === "personal" && availableNameInitials.length > 0 && (
<Select
value={nameInitialFilter ?? "all"}
onValueChange={(value) => {
setNameInitialFilter(value === "all" ? null : value);
setKnowledgePage(1);
}}
>
<SelectTrigger className="w-28 shrink-0" aria-label="按名称首字母筛选">
<SelectValue placeholder="首字母" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">全部字母</SelectItem>
{availableNameInitials.map((initial) => (
<SelectItem key={initial} value={initial}>{initial}</SelectItem>
))}
</SelectContent>
</Select>
)}
{viewSwitchEnabled && <ViewModeToggle value={viewMode} onChange={setViewMode} className="shrink-0" />}
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
</div>
<Button variant="secondary" className="shrink-0 rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
</div>
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
</div>
)}
</div>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={nameInitialFilter} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={null} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
@@ -472,6 +595,13 @@ function WikiConfigurationStatus({
}
function KnowledgeGrid({
viewMode,
sort,
nameInitialFilter,
page,
pageSize,
onPageChange,
onPageSizeChange,
rows,
loading,
error,
@@ -484,6 +614,13 @@ function KnowledgeGrid({
onPublish,
onDelete,
}: {
viewMode: "card" | "list";
sort: KnowledgeSort;
nameInitialFilter: string | null;
page: number;
pageSize: KnowledgePageSize;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: KnowledgePageSize) => void;
rows: LlmWikiKnowledgeBase[];
loading: boolean;
error: unknown;
@@ -498,11 +635,92 @@ function KnowledgeGrid({
}) {
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
const visibleRows = rows.filter((item) => item.remote_status !== "missing");
if (!visibleRows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
const visibleRows = rows.filter(
(item) => item.remote_status !== "missing" &&
(!nameInitialFilter || knowledgeNameInitial(item.name) === nameInitialFilter),
);
if (!visibleRows.length) {
return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{nameInitialFilter ? `没有名称以 ${nameInitialFilter} 开头的知识库` : emptyText}</div>;
}
const sortedRows = [...visibleRows].sort((left, right) => compareKnowledgeBases(left, right, sort));
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
const currentPage = Math.min(Math.max(page, 1), totalPages);
const pageRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const pagination = (
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span>共 {sortedRows.length} 个知识库 · 第 {currentPage}/{totalPages} 页</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value) as KnowledgePageSize)}
>
<SelectTrigger className="h-8 w-28" aria-label="每页知识库数量">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{knowledgePageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>{option} 条/页</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination
page={currentPage}
pageSize={pageSize}
total={sortedRows.length}
onPageChange={onPageChange}
/>
</div>
);
if (viewMode === "list") {
return (
<div>
<div className="flex flex-col gap-3">
{pageRows.map((item) => (
<Item key={item.id} variant="outline" size="sm" className="w-full">
<ItemContent
className="min-w-0 cursor-pointer"
role="button"
tabIndex={0}
aria-label={`查看知识库 ${item.name}`}
onClick={() => onOpen(item)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(item);
}
}}
>
<ItemTitle className="max-w-full flex-wrap">
<span className="truncate">{item.name}</span>
<Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>
{isWikiKnowledgeBase(item) && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}
<WikiConfigurationStatus knowledgeBase={item} />
</ItemTitle>
<ItemDescription className="line-clamp-1">{item.description || "暂无描述"}</ItemDescription>
</ItemContent>
<ItemActions className="flex-wrap justify-end">
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onResearch(item)}><Microscope className="mr-2 size-4" />深度研究</Button>
{onTest && <Button size="sm" variant="outline" className="rounded-full" onClick={() => onTest(item)}><Search className="mr-2 size-4" />检索测试</Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="sm" variant="outline" className="rounded-full" disabled={busy} onClick={() => onEdit(item)} aria-label={`编辑知识库 ${item.name}`}><Edit3 className="mr-2 size-4" />编辑</Button>}
{item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`删除知识库 ${item.name}`} title="删除知识库" disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
</ItemActions>
</Item>
))}
</div>
{pagination}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleRows.map((item) => (
<div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{pageRows.map((item) => (
<Card
key={item.id}
className="group overflow-hidden rounded-2xl border-border/70 bg-card/95 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg"
@@ -531,6 +749,8 @@ function KnowledgeGrid({
</CardContent>
</Card>
))}
</div>
{pagination}
</div>
);
}
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
calculateHeaderTitleFit,
resolveHeaderTitleFit,
} from "./header-title-fit";
test("keeps the existing 30px title treatment when the name fits", () => {
assert.deepEqual(
calculateHeaderTitleFit({ availableWidth: 500, naturalWidth: 420 }),
{ fontSize: 30, letterSpacing: 4, lines: 1 },
);
});
test("shrinks a long name proportionally while keeping it on one line", () => {
const fit = calculateHeaderTitleFit({
availableWidth: 360,
naturalWidth: 420,
characterCount: 12,
});
assert.equal(fit.lines, 1);
assert.ok(fit.fontSize < 30);
assert.ok(fit.fontSize > 24);
assert.equal(fit.letterSpacing, 0);
});
test("tightens character spacing before reducing the current maximum font size", () => {
assert.deepEqual(
calculateHeaderTitleFit({
availableWidth: 390,
naturalWidth: 420,
characterCount: 12,
}),
{ fontSize: 30, letterSpacing: 1.41, lines: 1 },
);
});
test("keeps one line at the readable floor and allows intentional overflow", () => {
assert.deepEqual(
calculateHeaderTitleFit({
availableWidth: 300,
naturalWidth: 900,
characterCount: 30,
}),
{ fontSize: 24, letterSpacing: 0, lines: 1 },
);
});
test("does not wrap or shrink below 24px for extreme names", () => {
const fit = calculateHeaderTitleFit({
availableWidth: 300,
naturalWidth: 2400,
characterCount: 80,
});
assert.deepEqual(fit, { fontSize: 24, letterSpacing: 0, lines: 1 });
});
test("uses the configured font size without adaptive shrinking in fixed mode", () => {
assert.deepEqual(
resolveHeaderTitleFit({
mode: "fixed",
fixedFontSize: 34,
adaptiveFit: { fontSize: 24, letterSpacing: 0, lines: 1 },
}),
{ fontSize: 34, letterSpacing: 4, lines: 1 },
);
});
test("bounds fixed font sizes to the supported navigation range", () => {
const adaptiveFit = { fontSize: 28, letterSpacing: 1, lines: 1 };
assert.equal(
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 8, adaptiveFit })
.fontSize,
16,
);
assert.equal(
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 60, adaptiveFit })
.fontSize,
40,
);
assert.strictEqual(
resolveHeaderTitleFit({
mode: "adaptive",
fixedFontSize: 40,
adaptiveFit,
}),
adaptiveFit,
);
});
@@ -0,0 +1,149 @@
export const HEADER_TITLE_MAX_FONT_SIZE = 30;
export const HEADER_TITLE_MIN_FONT_SIZE = 24;
export const HEADER_TITLE_MAX_LETTER_SPACING = 4;
export const HEADER_TITLE_MAX_LINES = 1;
export const HEADER_TITLE_FIXED_MIN_FONT_SIZE = 16;
export const HEADER_TITLE_FIXED_MAX_FONT_SIZE = 40;
export type HeaderTitleFit = {
fontSize: number;
letterSpacing: number;
lines: number;
};
type ResolveHeaderTitleFitOptions = {
mode: "adaptive" | "fixed";
fixedFontSize: number;
adaptiveFit: HeaderTitleFit;
};
type CalculateHeaderTitleFitOptions = {
availableWidth: number;
naturalWidth: number;
characterCount?: number;
maxFontSize?: number;
minFontSize?: number;
maxLetterSpacing?: number;
maxLines?: number;
};
const roundDown = (value: number) => Math.floor(value * 100) / 100;
export function resolveHeaderTitleFit({
mode,
fixedFontSize,
adaptiveFit,
}: ResolveHeaderTitleFitOptions): HeaderTitleFit {
if (mode !== "fixed") {
return adaptiveFit;
}
const normalizedFontSize = Number.isFinite(fixedFontSize)
? Math.min(
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, fixedFontSize),
)
: HEADER_TITLE_MAX_FONT_SIZE;
return {
fontSize: roundDown(normalizedFontSize),
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
lines: 1,
};
}
/**
* Fits the full system name into the decorative title slot without ellipsis.
* The current 30px treatment remains the upper bound. The title stays on one
* line and stops shrinking at 24px; overflow is intentional beyond that point.
*/
export function calculateHeaderTitleFit({
availableWidth,
naturalWidth,
characterCount = 1,
maxFontSize = HEADER_TITLE_MAX_FONT_SIZE,
minFontSize = HEADER_TITLE_MIN_FONT_SIZE,
maxLetterSpacing = HEADER_TITLE_MAX_LETTER_SPACING,
maxLines = HEADER_TITLE_MAX_LINES,
}: CalculateHeaderTitleFitOptions): HeaderTitleFit {
if (
!Number.isFinite(availableWidth) ||
!Number.isFinite(naturalWidth) ||
availableWidth <= 0 ||
naturalWidth <= 0 ||
maxFontSize <= 0
) {
return {
fontSize: maxFontSize,
letterSpacing: maxLetterSpacing,
lines: 1,
};
}
// Keep a one-pixel safety margin for browser sub-pixel text measurement.
const safeAvailableWidth = Math.max(1, availableWidth - 1);
const safeCharacterCount = Math.max(1, characterCount);
const maximumSpacingWidth = maxLetterSpacing * safeCharacterCount;
const naturalGlyphWidth = Math.max(1, naturalWidth - maximumSpacingWidth);
const minimumScale = Math.min(1, Math.max(0.01, minFontSize / maxFontSize));
if (naturalWidth <= safeAvailableWidth) {
return {
fontSize: maxFontSize,
letterSpacing: maxLetterSpacing,
lines: 1,
};
}
// Preserve the existing maximum font size as long as possible by tightening
// only the decorative character spacing first.
if (naturalGlyphWidth <= safeAvailableWidth) {
return {
fontSize: maxFontSize,
letterSpacing: roundDown(
Math.max(
0,
Math.min(
maxLetterSpacing,
(safeAvailableWidth - naturalGlyphWidth) / safeCharacterCount,
),
),
),
lines: 1,
};
}
const singleLineScale = safeAvailableWidth / naturalGlyphWidth;
if (singleLineScale >= minimumScale) {
return {
fontSize: roundDown(maxFontSize * singleLineScale),
letterSpacing: 0,
lines: 1,
};
}
if (maxLines <= 1) {
return {
fontSize: minFontSize,
letterSpacing: 0,
lines: 1,
};
}
const requiredLines = Math.ceil(
(naturalGlyphWidth * minimumScale) / safeAvailableWidth,
);
const lines = Math.min(Math.max(2, requiredLines), Math.max(1, maxLines));
// Hold the readable minimum where possible. If even maxLines cannot contain
// the name, shrink just enough to preserve every character.
const scale = Math.min(
minimumScale,
(safeAvailableWidth * lines) / naturalGlyphWidth,
);
return {
fontSize: roundDown(maxFontSize * scale),
letterSpacing: 0,
lines,
};
}
@@ -40,6 +40,7 @@ import {
} from "@/core/artifacts/download";
import { getStoredAuth } from "@/core/auth";
import {
usePageDescriptionsSettings,
useStrategyProjectSettings,
useUpdateStrategyProjectSettings,
} from "@/core/system-settings/hooks";
@@ -321,6 +322,8 @@ export default function StrategyProjectsPage({
isError: demoSettingError,
} = useStrategyProjectSettings();
const updateStrategyProjectSettings = useUpdateStrategyProjectSettings();
const pageDescriptions = usePageDescriptionsSettings();
const projectsPageDescription = pageDescriptions.data?.strategy_projects;
const strategyAngleCatalog = useStrategyAngleCatalog();
const createAngles = strategyAngleCatalog.angles;
const demoEnabled =
@@ -472,10 +475,12 @@ export default function StrategyProjectsPage({
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FolderKanban className="size-4" />
</span>
<h1 className="text-xl font-semibold tracking-tight">我的项目</h1>
<h1 className="text-xl font-semibold tracking-tight">
{projectsPageDescription?.title || "我的项目"}
</h1>
</div>
<p className="mt-1.5 truncate text-xs text-muted-foreground sm:text-sm">
集中查看普通问答、策略优选和方案成果
{projectsPageDescription?.description || "集中查看普通问答、策略优选和方案成果"}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
+46 -8
View File
@@ -1596,23 +1596,36 @@ html.dark .strategy-app-header {
/* 红白主题可通过 runtime-config.js 选择复用深色顶栏结构,仅替换为浅红设计稿。 */
.strategy-app-header.strategy-app-header--light-red-top-nav {
background-image: url("../assets/img/top-bg-light-red.svg");
background-image: var(
--light-red-top-nav-background-image,
url("../assets/img/top-bg-light-red.svg")
);
background-size: cover;
}
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
/* 深色顶栏:居中系统名称落在背景图中间的发光梯形基座上。
背景原稿为 1920px 宽,基座槽位约 445px;视口超过原稿宽度后随背景等比放大。
固定这个视觉槽位后,用户名称不再反向撑宽容器、挤动左右菜单。 */
.header-center-title {
flex: 0 0 auto;
--header-center-slot-width: max(445px, 23.18vw);
/* 凹槽内侧亮边在 1920px 原稿中约 352px 宽;两边各留约 9px,
字号测量按 334px 安全区随背景等比放大,但外层槽宽保持不变,
因此不会挪动左右导航,也不会改变 Logo 与文字的 10px 间距。 */
--header-center-title-safe-width: max(334px, 17.4vw);
width: var(--header-center-slot-width);
flex: 0 0 var(--header-center-slot-width);
box-sizing: border-box;
height: 64px;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
padding: 0 8.07vw;
padding: 0
calc(
(var(--header-center-slot-width) - var(--header-center-title-safe-width)) /
2
);
/* 文字略微上移,贴合基座视觉中心 */
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
@@ -1637,7 +1650,12 @@ html.dark .strategy-app-header {
.header-center-title__label {
display: block;
max-width: none;
flex: 0 0 auto;
overflow: visible;
line-height: 1;
text-align: center;
white-space: nowrap;
/* 白→浅蓝→白 金属质感渐变文字 */
background: linear-gradient(
180deg,
@@ -1654,6 +1672,26 @@ html.dark .strategy-app-header {
filter: drop-shadow(0 0 14px rgba(120, 200, 255, 0.35));
}
.header-center-title__label[data-lines="2"],
.header-center-title__label[data-lines="3"] {
line-height: 1.05;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
/* 始终以现有最大字号测量名称的自然宽度,不参与布局和无障碍树。 */
.header-center-title__measure {
position: absolute;
visibility: hidden;
white-space: nowrap;
font-size: 30px;
font-weight: 700;
letter-spacing: 4px;
line-height: 1;
pointer-events: none;
}
.strategy-app-header--light-red-top-nav .header-center-title__label {
background: linear-gradient(
180deg,
+87 -24
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,46 +8,95 @@ import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
const JSX_RUNTIME_NAMED_EXPORTS = [
"jsx",
"jsxs",
"jsxDEV",
"createElement",
"Fragment",
] as const;
const JSX_RUNTIME_DEP_FILES = new Set([
"react_jsx-runtime.js",
"react_jsx-dev-runtime.js",
]);
function hasNamedJsxExport(code: string): boolean {
return (
/\bexport\s+const\s+jsx\b/.test(code) ||
/\bexport\s+function\s+jsx\b/.test(code) ||
/\bexport\s*\{[^}]*\bjsx\b/.test(code)
);
}
// Vite prebundles CJS `react/jsx-runtime` as `export default require_jsx_runtime()`.
// App source is rewritten to a default import, but a stale Chrome 102 tab may still
// execute `import { jsx } from '.../react_jsx-runtime.js'`. Named re-exports from a
// default-only module throw SyntaxError at link time, so the page never reloads.
function withNamedJsxExports(code: string): string {
if (hasNamedJsxExport(code)) return code;
return code.replace(
/export\s+default\s+([^;]+);/,
(_match, expr: string) =>
[
`const __jsxRuntime = ${String(expr).trim()};`,
"export default __jsxRuntime;",
...JSX_RUNTIME_NAMED_EXPORTS.map(
(name) => `export const ${name} = __jsxRuntime.${name};`,
),
].join("\n"),
);
}
function staleDepsModuleSource(): string {
return [
"export function jsx(){return null}",
"export function jsxs(){return null}",
"export function jsxDEV(){return null}",
"export const Fragment = Symbol.for('react.fragment');",
"export default {jsx, jsxs, jsxDEV, Fragment};",
"try{const k='__vite_stale_chunk_reloaded__';const n=Number(sessionStorage.getItem(k)||'0');" +
"if(n<3){sessionStorage.setItem(k,String(n+1));location.reload();}}catch(e){}",
].join("\n");
}
// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force),
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk。
// 旧实现只返回一段 location.reload(),没有 named export;Chrome 在链接
// `import { jsx } from ...` 时会直接 SyntaxError,模块体根本不会执行,
// 页面就白屏卡死。这里补上 jsx-runtime 的常见导出,再触发有限次数刷新。
// Chrome 在链接 `import { jsx } from ...` 时若没有本地 named export,会直接
// SyntaxError,模块体根本不会执行,页面就白屏卡死。缺失 chunk 一律补本地
// jsx 导出再刷新;真实 jsx-runtime 也补上 named export,兼容旧模块图。
const staleDepsChunkSelfReload = (): Plugin => ({
name: "stale-deps-chunk-self-reload",
configureServer(server) {
const depsDir = path.join(rootDir, "node_modules", ".vite", "deps");
const jsxRuntimeUrl = "/node_modules/.vite/deps/react_jsx-runtime.js";
server.middlewares.use((req, res, next) => {
const match = /^\/node_modules\/\.vite\/deps\/([^?]+)/.exec(req.url ?? "");
if (!match) {
next();
return;
}
const filePath = path.join(depsDir, decodeURIComponent(match[1]));
const fileName = path.basename(decodeURIComponent(match[1]));
if (fileName !== decodeURIComponent(match[1])) {
next();
return;
}
const filePath = path.join(depsDir, fileName);
if (existsSync(filePath)) {
if (!JSX_RUNTIME_DEP_FILES.has(fileName)) {
next();
return;
}
res.setHeader("Content-Type", "application/javascript");
res.end(withNamedJsxExports(readFileSync(filePath, "utf8")));
return;
}
if (!/^chunk-[A-Za-z0-9_-]+\.js$/.test(fileName)) {
next();
return;
}
res.setHeader("Content-Type", "application/javascript");
res.setHeader("Cache-Control", "no-store");
const jsxRuntimeExists = existsSync(
path.join(depsDir, "react_jsx-runtime.js"),
);
const jsxExports = jsxRuntimeExists
? `export { jsx, jsxs, jsxDEV, Fragment } from ${JSON.stringify(jsxRuntimeUrl)};\n`
: [
"export function jsx(){return null}",
"export function jsxs(){return null}",
"export function jsxDEV(){return null}",
"export const Fragment = Symbol.for('react.fragment');",
"",
].join("\n");
res.end(
`${jsxExports}` +
"try{const k='__vite_stale_chunk_reloaded__';const n=Number(sessionStorage.getItem(k)||'0');" +
"if(n<3){sessionStorage.setItem(k,String(n+1));location.reload();}}catch(e){}",
);
res.end(staleDepsModuleSource());
});
},
});
@@ -105,7 +154,21 @@ export default defineConfig({
// 下混用新旧缓存 chunk,报 "does not provide an export named 'refractor'"
// 并整树白屏(清 node_modules/.vite + 重启 + 硬刷新才恢复)。启动即预打包可避免。
optimizeDeps: {
include: ["react-syntax-highlighter", "refractor"],
include: [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
"react-syntax-highlighter",
"refractor",
],
esbuildOptions: {
target: "chrome100",
},
},
esbuild: {
target: "chrome100",
},
// Relative URLs make the same build work at the domain root and at any
// Nginx path prefix. A fixed prefix can still be supplied at release time.
+23 -1
View File
@@ -1,5 +1,27 @@
# CLAUDE.md
## Model-independent skill invocation attribution (2026-11-07)
Never derive skill-use UI or counters from model narration or the optional tool
`description`. The canonical signals are a successful `skill_view`, a successful
`read_file` under `/mnt/skills/{public,custom}/<skill>/...`, and an executed
`bash` command containing such a skill path. `sandbox/tools.py` resolves names
through the active skill view so a directory/frontmatter-name mismatch remains
correct; both public and custom skills count.
`skills/usage.py::bump_use(..., event_key=run_id)` de-duplicates all file/script
signals to one use per skill per user run. It updates `.usage.json` and schedules
an absolute, monotonic update of the `skills` MySQL catalog projection on the
engine home loop. Keep this bridge inside `deerflow.*`; the harness must not
import `app.*`. The skill-card counter is intentionally independent of the
heavier full-tool audit switch. `skill_metrics_enabled` defaults on and mounts
`ToolMetricsMiddleware(skill_only=True)` while `tool_metrics_enabled` is off;
non-skill calls pass through without DB work, but attributed skill calls still
feed audit pages and leaderboards. Frontend labels are recovered by
`core/tools/skill-invocation.ts` from explicit skill-tool arguments or skill
paths. Regression coverage lives in `test_skill_usage_signals.py`,
`test_skill_view_fast_path.py`, and `skill-invocation.test.ts`.
## Cold agent construction (2026-10-30)
`runtime/runs/worker.py` dispatches opted-in synchronous factories through
@@ -873,7 +895,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Task Deep-link Threads** (`/api/task-deeplink-threads`) | Dedicated shared history index for the `3qfx` / `rwfx` / `xdfx` single-agent task workspaces. `GET /?task_id=&go_path=&limit=` performs an indexed `(task_id, go_path)` lookup in `task_deeplink_threads` and joins `threads_meta` by `thread_id` for the current title/status/timestamps; LangGraph checkpoints remain the source of truth for messages. Thread metadata carrying `taskRecordId + taskGoPath` is dual-written atomically by both SQL and memory thread stores and deleted with the thread. `3qfx/rwfx` use the task id as `taskRecordId`; `xdfx` uses the original action id while retaining the resolved real task id in `metadata.taskId`. `thread_type=task_deeplink` keeps these rows out of general recent chats; explicit `taskId` searches still opt out of that exclusion for compatibility. Alembic `20261019_01` is schema-only and intentionally does **not** backfill legacy task threads. Frontend hook: `core/threads/use-task-deeplink-threads.ts`. Tests: `packages/harness/tests/test_task_deeplink_threads.py`, `packages/harness/tests/test_thread_meta_task_lookup.py`. |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) or whose `thread_type` is a known system type (`task-analysis` seat runs included; older seat rows are also matched by `task_analysis_seat`) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`, `/api/strategy-project-artifacts/{id}/download`) | `GET /{path}` streams thread artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `GET /api/threads/{id}/artifact-preview?path=` returns bounded text, using streaming statistics plus deterministic head/middle/tail samples above the database threshold. `GET /api/artifact-library` combines every current-user normal thread's authoritative `outputs/` files with completed strategy-selection syntheses projected from the owner-scoped `parallel_agent_tasks.messages` snapshot. Strategy items use `source_type=strategy`, carry `strategy_project_id` / `strategy_name` / `strategy_stale`, and explicitly distinguish `strategy_source_kind=strategy_selection` from an `analysis_project_mode=strategy` thread (`analysis_project`); virtual items also expose `download_url`, so clients never infer routing from sentinel thread/path values. Search/source filters cover both. `workflowStale=true` keeps the last completed historical synthesis visible and downloadable. The dedicated strategy download route revalidates current-user ownership and snapshot completion and serves Markdown without pretending the virtual item is a thread artifact. Uploads and image/audio/video media remain excluded. Library metadata is persisted in `artifact_library_items`; list/search/filter/count/pagination read only this user-scoped SQL index. A generation-fenced `artifact_library_sync_states` lease schedules low-concurrency filesystem/task reconciliation in the background on first/stale reads or `refresh=true`, keeps the last successful snapshot visible during refresh/failure, and reports `sync_status` / `syncing` / `indexed_at` / `sync_error` to the client. Files themselves remain authoritative on disk and are never copied into the database. |
| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`, `/api/strategy-project-artifacts/{id}/download`) | `GET /{path}` streams thread artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. HTML/XHTML additionally accepts `offline_bundle=true`: it returns `{stem}-离线包.zip` with `index.html`, the referenced trusted Gateway runtime/image dependencies rewritten under `assets/`, a manifest/README and relevant third-party licenses; public view-share files support the same query. The builder never fetches arbitrary remote URLs, and records remaining Leaflet tile/user-supplied external URLs in the manifest. `GET /api/threads/{id}/artifact-preview?path=` returns bounded text, using streaming statistics plus deterministic head/middle/tail samples above the database threshold. `GET /api/artifact-library` combines every current-user normal thread's authoritative `outputs/` files with completed strategy-selection syntheses projected from the owner-scoped `parallel_agent_tasks.messages` snapshot. Strategy items use `source_type=strategy`, carry `strategy_project_id` / `strategy_name` / `strategy_stale`, and explicitly distinguish `strategy_source_kind=strategy_selection` from an `analysis_project_mode=strategy` thread (`analysis_project`); virtual items also expose `download_url`, so clients never infer routing from sentinel thread/path values. Search/source filters cover both. `workflowStale=true` keeps the last completed historical synthesis visible and downloadable. The dedicated strategy download route revalidates current-user ownership and snapshot completion and serves Markdown without pretending the virtual item is a thread artifact. Uploads and image/audio/video media remain excluded. Library metadata is persisted in `artifact_library_items`; list/search/filter/count/pagination read only this user-scoped SQL index. A generation-fenced `artifact_library_sync_states` lease schedules low-concurrency filesystem/task reconciliation in the background on first/stale reads or `refresh=true`, keeps the last successful snapshot visible during refresh/failure, and reports `sync_status` / `syncing` / `indexed_at` / `sync_error` to the client. Files themselves remain authoritative on disk and are never copied into the database. |
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables `prompt_prefix.ordinary_qa_markdown_format_enabled` in `system_settings.json`; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. The compact writer validates generated Markdown against that frozen selection: `fixed` requires every non-placeholder heading with the same level and order, while `adaptive` retries only when no core template heading survives. That correction shares the single existing retry budget (never more than two writer calls), and its deterministic fallback is outline-shaped as well. When `custom_outline` is empty (the workbench default — the user did not pick a report structure), chat collection first calls `POST /sessions/{id}/intent-outline` after the collector turn ends: the chosen model plans a Markdown skeleton from the topic plus collected materials, the outline is frozen into the session snapshot and shown in the「开始撰写」form for editing. If that step is skipped or fails, `basic`/`quick` still call `generate_intent_outline` at write time, emit `plan_created` (`source: intent`), then write through the same shared writer; generation failure is a recoverable `intent_outline_fallback` warning and does not abort the report. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
+24 -6
View File
@@ -1,5 +1,18 @@
# DeerFlow Backend
## 技能调用识别与统计(2026-11-07)
技能调用的展示和“调用次数”不再依赖模型主动说明。后端以真实工具信号为准:
成功的 `skill_view`、对 `/mnt/skills/{public,custom}/<name>/...` 的文件读取,
以及 `bash` 直接执行该目录下的技能脚本,都会识别为对应技能调用。一次问答中同一技能即使先读
`SKILL.md`、再读参考文件并执行多个脚本,也只累计一次;不同问答分别累计。
计数会同时更新 `.usage.json` 和技能列表使用的数据库投影,因此内置技能与自定义技能都能在
页面刷新后看到最新调用次数。对话步骤也直接从 `skill_view` 参数或真实技能路径显示
“调用技能「名称」”;弱模型未填写工具描述、未在正文里自述技能名时仍可识别。
完整工具审计仍由 `runtime_performance.tool_metrics_enabled` 独立控制;默认开启的
`skill_metrics_enabled` 只把可归因到具体技能的低频调用写入统计表,供技能排行和调用统计使用。
## 首次问答初始化响应性(2026-10-30)
普通问答、开放问答接口和工作流智能体节点的模型 SDK 加载、客户端初始化及
@@ -1911,11 +1924,12 @@ Beta(8,2) 先验抑制小样本极值;零样本返回 `score: null` 和
实际加载的 `config.yaml`(`DEER_FLOW_CONFIG_PATH` 指定文件优先)支持以下独立开关。
`skill_view_reuse_enabled` 和 `skill_view_cache_enabled` 在配置里不写时默认开启;
要恢复每次工具调用都全量刷新技能视图,把对应项写成 `false` 后重启。
模型计时与线程池开关默认关闭,工具统计写入也默认暂停:
模型计时与线程池开关默认关闭,普通工具审计默认暂停,低频技能统计默认保留:
```yaml
runtime_performance:
tool_metrics_enabled: false
skill_metrics_enabled: true
skill_view_reuse_enabled: true
skill_view_cache_enabled: true
model_timing_nonblocking_enabled: true
@@ -1923,13 +1937,17 @@ runtime_performance:
asyncio_thread_pool_size: 100
```
- `tool_metrics_enabled`:工具/技能调用统计写入开关,**缺省为 false(暂停)**,旧部署配置
没有此项时也暂停。主智能体和子智能体不再装载工具统计中间件,不创建统计写库任务、
不执行技能统计合并查询,从源头减少与登录共用业务数据库连接池的竞争。
- `tool_metrics_enabled`:普通工具审计写入开关,**缺省为 false(暂停)**,旧部署配置
没有此项时也暂停。主智能体和子智能体不会为普通工具创建统计写库任务,
从源头减少与登录共用业务数据库连接池的竞争。
正常工具调用、错误处理、问答消息/checkpoint 和模型调用统计继续运行;已有
`tool_call_metrics` 记录与查询/导出接口保留。暂停期间工具/技能审计明细及排行榜的
工具/技能调用计数不再新增,且不缓存或事后补录。恢复时设为 `true` 并重启后台。
`tool_call_metrics` 记录与查询/导出接口保留。暂停期间普通工具调用计数不再新增,
且不缓存或事后补录。恢复时设为 `true` 并重启后台。
部署新代码并重启后才对所有运行实例生效;这不消除历史统计查询或其他数据库操作的开销。
- `skill_metrics_enabled`:只记录从 `skill_view` 参数或
`/mnt/skills/{public,custom}/<name>/...` 工具路径确定出的技能调用,缺省为 `true`。
非技能工具直接透传,不执行技能合并查询或写库;因此普通工具审计暂停时,技能排行与
技能调用统计仍会更新。若连这部分低频写入也要暂停,显式设为 `false` 并重启。
- `skill_view_reuse_enabled`:一次沙箱工具调用共用一份已验证技能视图,路径检查、
本地/AIO 沙箱初始化、命令转换和输出脱敏不再反复刷新。下一次工具调用重新验证,
不跨问答永久沿用启停状态。
@@ -30,7 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, not_, or_, select, text
from sqlalchemy import and_, case, desc, func, literal, not_, or_, select, text
from app.gateway.admin_query_window import (
ADMIN_QUERY_WINDOW_DAYS,
@@ -279,6 +279,41 @@ class AdminLeaderboardDimension(BaseModel):
last_seen_at: str = ""
class AdminLeaderboardDetailItem(BaseModel):
"""One row in the expanded skill, tool, or agent analytics drawer."""
name: str
count: int = 0
user_count: int = 0
total_tokens: int = 0
avg_duration_ms: int = 0
class AdminLeaderboardDetailSummary(BaseModel):
count: int = 0
item_count: int = 0
user_count: int = 0
total_tokens: int = 0
avg_duration_ms: int = 0
class AdminLeaderboardDetailResponse(BaseModel):
"""Fresh, drill-down analytics for a leaderboard dimension.
Unlike the dashboard's compact daily snapshots, this response is queried
directly. It lets an administrator inspect the entire matching dimension
(up to the explicit limit) and does not lose long-tail rows because they
were outside a daily top-N snapshot.
"""
kind: str
range_days: int
since: str
until: str
summary: AdminLeaderboardDetailSummary
items: list[AdminLeaderboardDetailItem] = Field(default_factory=list)
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
@@ -975,6 +1010,125 @@ async def _query_run_dimensions(
]
async def _query_leaderboard_dimension_details(
session,
since: datetime,
until: datetime,
*,
kind: str,
limit: int,
exclude_user_ids: set[str],
include_scheduled: bool,
include_admins: bool,
include_failed: bool,
) -> tuple[
AdminLeaderboardDetailSummary,
list[AdminLeaderboardDetailItem],
]:
"""Load complete, fresh detail statistics for one leaderboard dimension.
The main leaderboard deliberately reads compact daily snapshots so it can
remain quick under load. The drawer is an intentional admin drill-down,
therefore it runs a bounded aggregate against the source metric tables and
returns exact unique-user counts for the selected interval.
"""
if kind in {"skill", "tool"}:
name_col = ToolCallMetricRow.skill_name if kind == "skill" else ToolCallMetricRow.tool_name
base_filters = [
_tool_time_filter(since, until),
name_col.is_not(None),
name_col != "",
]
def apply_scope(stmt):
return _apply_tool_scope(
stmt,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_scheduled=include_scheduled,
)
count_col = func.count(ToolCallMetricRow.id)
user_count_col = func.count(func.distinct(ToolCallMetricRow.user_id))
total_tokens_col = literal(0)
avg_duration_col = func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0)
elif kind == "agent":
name_col = RunRow.assistant_id
base_filters = [
_run_time_filter(since, until),
name_col.is_not(None),
name_col != "",
]
def apply_scope(stmt):
return _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
count_col = func.count(RunRow.run_id)
user_count_col = func.count(func.distinct(RunRow.user_id))
total_tokens_col = func.coalesce(func.sum(RunRow.total_tokens), 0)
# Run rows have no reliable wall-clock duration. Keep this explicit at
# zero instead of inferring elapsed time from an asynchronously updated
# journal timestamp.
avg_duration_col = literal(0)
else:
raise ValueError(f"Unknown leaderboard detail kind: {kind}")
item_stmt = (
select(
name_col.label("name"),
count_col.label("count"),
user_count_col.label("user_count"),
total_tokens_col.label("total_tokens"),
avg_duration_col.label("avg_duration_ms"),
)
.where(*base_filters)
.group_by(name_col)
.order_by(count_col.desc(), name_col.asc())
.limit(limit)
)
item_stmt = apply_scope(item_stmt)
summary_stmt = select(
count_col.label("count"),
func.count(func.distinct(name_col)).label("item_count"),
user_count_col.label("user_count"),
total_tokens_col.label("total_tokens"),
avg_duration_col.label("avg_duration_ms"),
).where(*base_filters)
summary_stmt = apply_scope(summary_stmt)
item_rows = (await _execute_leaderboard_query(session, f"{kind}_detail.items", item_stmt)).all()
summary_row = (await _execute_leaderboard_query(session, f"{kind}_detail.summary", summary_stmt)).one()
summary = AdminLeaderboardDetailSummary(
count=int(summary_row[0] or 0),
item_count=int(summary_row[1] or 0),
user_count=int(summary_row[2] or 0),
total_tokens=int(summary_row[3] or 0),
avg_duration_ms=int(summary_row[4] or 0),
)
items = [
AdminLeaderboardDetailItem(
name=row[0] or "",
count=int(row[1] or 0),
user_count=int(row[2] or 0),
total_tokens=int(row[3] or 0),
avg_duration_ms=int(row[4] or 0),
)
for row in item_rows
]
return summary, items
async def _query_model_rankings(
session,
since: datetime,
@@ -1890,6 +2044,59 @@ async def get_user_leaderboard(
)
@router.get("/leaderboard/details", response_model=AdminLeaderboardDetailResponse)
async def get_user_leaderboard_details(
request: Request,
kind: str = Query(description="One of: skill, tool, agent", pattern="^(skill|tool|agent)$"),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=500, ge=1, le=1000),
) -> AdminLeaderboardDetailResponse:
"""Return fresh drill-down statistics for one leaderboard card.
This endpoint intentionally reads the source metric tables instead of the
asynchronous daily leaderboard snapshots. A drawer represents an explicit
admin investigation, where exact unique-user counts, all matching entries,
and complete detail rows are more useful than a compact cache.
"""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
excluded, include_scheduled, include_admins, include_failed, _settings_hash = _resolve_leaderboard_snapshot_scope(settings)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
summary, items = await _query_leaderboard_dimension_details(
session,
since_dt,
until_dt,
kind=kind,
limit=limit,
exclude_user_ids=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
return AdminLeaderboardDetailResponse(
kind=kind,
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
summary=summary,
items=items,
)
@router.get("/leaderboard/snapshots", response_model=AdminLeaderboardSnapshotStatusResponse)
async def get_leaderboard_snapshot_status(
request: Request,
@@ -1,14 +1,16 @@
"""Application chrome name and logo.
"""Application chrome name, logo, and optional red-theme artwork.
Stored separately from ``generated_page_branding`` and the
``generated-page-branding/`` directory. An empty name or a missing logo leaves
the built-in title and static theme logos in place.
``generated-page-branding/`` directory. Missing custom images leave the
built-in theme artwork in place.
"""
from __future__ import annotations
import asyncio
from typing import Annotated
from uuid import uuid4
from xml.etree import ElementTree
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
@@ -18,6 +20,8 @@ from app.gateway.routers.system_settings import _require_admin
from deerflow.config.runtime_paths import runtime_home
from deerflow.config.system_settings import (
AppIdentitySettings,
app_identity_light_red_header_background_path,
app_identity_light_red_nav_active_path,
app_identity_logo_path,
load_system_settings,
save_system_settings,
@@ -26,25 +30,48 @@ from deerflow.config.system_settings import (
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
public_router = APIRouter(prefix="/api/public/app-identity", tags=["app-identity"])
_MAX_LOGO_BYTES = 10 * 1024 * 1024
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
_PUBLIC_LOGO_PATH = "/api/public/app-identity/logo"
_PUBLIC_LIGHT_RED_HEADER_PATH = "/api/public/app-identity/light-red-header-background"
_PUBLIC_LIGHT_RED_ACTIVE_PATH = "/api/public/app-identity/light-red-nav-active"
class AppIdentityResponse(BaseModel):
system_name: str = ""
header_title_font_mode: str = "adaptive"
header_title_font_size: int = 30
logo_url: str | None = None
logo_version: str | None = None
has_logo: bool = False
light_red_header_background_url: str | None = None
light_red_header_background_version: str | None = None
has_light_red_header_background: bool = False
light_red_nav_active_url: str | None = None
light_red_nav_active_version: str | None = None
has_light_red_nav_active: bool = False
def _detect_logo(data: bytes) -> tuple[str, str]:
def _detect_image(
data: bytes,
label: str,
*,
allow_svg: bool = False,
) -> tuple[str, str]:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png", "image/png"
if data.startswith(b"\xff\xd8\xff"):
return ".jpg", "image/jpeg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp", "image/webp"
raise HTTPException(status_code=422, detail="Logo 仅支持 PNG、JPG 或 WebP 图片")
if allow_svg:
try:
root = ElementTree.fromstring(data)
except ElementTree.ParseError:
root = None
if root is not None and root.tag.rsplit("}", 1)[-1].lower() == "svg":
return ".svg", "image/svg+xml"
supported = "PNG、JPG、WebP 或 SVG" if allow_svg else "PNG、JPG 或 WebP"
raise HTTPException(status_code=422, detail=f"{label}仅支持 {supported} 图片")
def _normalize_system_name(value: str | None) -> str:
@@ -54,21 +81,61 @@ def _normalize_system_name(value: str | None) -> str:
return normalized
def _normalize_header_title_font_mode(value: str) -> str:
normalized = str(value).strip().lower()
if normalized not in {"adaptive", "fixed"}:
raise HTTPException(status_code=422, detail="系统名称字号模式仅支持自适应或固定大小")
return normalized
def _normalize_header_title_font_size(value: int) -> int:
normalized = int(value)
if normalized < 16 or normalized > 40:
raise HTTPException(status_code=422, detail="系统名称固定字号必须在 16px 到 40px 之间")
return normalized
def _response(settings: AppIdentitySettings) -> AppIdentityResponse:
path = app_identity_logo_path(settings)
version = settings.logo_version if path is not None else None
header_background_path = app_identity_light_red_header_background_path(settings)
header_background_version = (
settings.light_red_header_background_version
if header_background_path is not None
else None
)
nav_active_path = app_identity_light_red_nav_active_path(settings)
nav_active_version = (
settings.light_red_nav_active_version if nav_active_path is not None else None
)
return AppIdentityResponse(
system_name=settings.system_name,
header_title_font_mode=settings.header_title_font_mode,
header_title_font_size=settings.header_title_font_size,
logo_url=f"{_PUBLIC_LOGO_PATH}?v={version}" if version else None,
logo_version=version,
has_logo=path is not None,
light_red_header_background_url=(
f"{_PUBLIC_LIGHT_RED_HEADER_PATH}?v={header_background_version}"
if header_background_version
else None
),
light_red_header_background_version=header_background_version,
has_light_red_header_background=header_background_path is not None,
light_red_nav_active_url=(
f"{_PUBLIC_LIGHT_RED_ACTIVE_PATH}?v={nav_active_version}"
if nav_active_version
else None
),
light_red_nav_active_version=nav_active_version,
has_light_red_nav_active=nav_active_path is not None,
)
def _write_logo(data: bytes, suffix: str) -> str:
def _write_asset(data: bytes, suffix: str, prefix: str) -> str:
directory = runtime_home() / "app-identity"
directory.mkdir(parents=True, exist_ok=True)
filename = f"logo-{uuid4().hex}{suffix}"
filename = f"{prefix}-{uuid4().hex}{suffix}"
destination = directory / filename
temporary = directory / f".{filename}.tmp"
try:
@@ -79,9 +146,40 @@ def _write_logo(data: bytes, suffix: str) -> str:
return filename
async def _read_uploaded_image(
upload: UploadFile,
label: str,
*,
allow_svg: bool = False,
max_bytes: int | None = _MAX_IMAGE_BYTES,
) -> tuple[bytes, str, str]:
data = await upload.read(max_bytes + 1) if max_bytes is not None else await upload.read()
await upload.close()
if not data:
raise HTTPException(status_code=422, detail=f"{label}文件不能为空")
if max_bytes is not None and len(data) > max_bytes:
raise HTTPException(status_code=413, detail=f"{label}不能超过 10MB")
suffix, media_type = _detect_image(data, label, allow_svg=allow_svg)
return data, suffix, media_type
def _public_asset_headers(media_type: str | None) -> dict[str, str]:
headers = {"Cache-Control": "public, max-age=31536000, immutable"}
if media_type == "image/svg+xml":
headers.update(
{
"Content-Security-Policy": (
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
),
"X-Content-Type-Options": "nosniff",
}
)
return headers
@router.get("/app-identity", response_model=AppIdentityResponse)
async def get_app_identity_settings() -> AppIdentityResponse:
"""Return the application name and uploaded logo. Any logged-in user may read it."""
"""Return the application identity settings readable by all logged-in users."""
return _response(load_system_settings().app_identity)
@@ -89,68 +187,160 @@ async def get_app_identity_settings() -> AppIdentityResponse:
@router.put("/app-identity", response_model=AppIdentityResponse)
async def update_app_identity_settings(
request: Request,
system_name: str = Form(default=""),
remove_logo: bool = Form(default=False),
logo: UploadFile | None = File(default=None),
system_name: Annotated[str, Form()] = "",
remove_logo: Annotated[bool, Form()] = False,
logo: Annotated[UploadFile | None, File()] = None,
header_title_font_mode: Annotated[str | None, Form()] = None,
header_title_font_size: Annotated[int | None, Form()] = None,
light_red_header_background: Annotated[UploadFile | None, File()] = None,
remove_light_red_header_background: Annotated[bool, Form()] = False,
light_red_nav_active: Annotated[UploadFile | None, File()] = None,
remove_light_red_nav_active: Annotated[bool, Form()] = False,
) -> AppIdentityResponse:
"""Save the application chrome name and optional logo. Admin only."""
"""Save the application chrome identity and optional red-theme artwork."""
await _require_admin(request)
if logo is not None and remove_logo:
raise HTTPException(status_code=422, detail="上传 Logo 时不能同时恢复静态 Logo")
if light_red_header_background is not None and remove_light_red_header_background:
raise HTTPException(status_code=422, detail="上传导航栏背景图时不能同时恢复默认图片")
if light_red_nav_active is not None and remove_light_red_nav_active:
raise HTTPException(status_code=422, detail="上传菜单选中样式图时不能同时恢复默认图片")
current = load_system_settings()
previous_logo = app_identity_logo_path(current.app_identity)
previous_header_background = app_identity_light_red_header_background_path(
current.app_identity
)
previous_nav_active = app_identity_light_red_nav_active_path(current.app_identity)
update: dict[str, object] = {"system_name": _normalize_system_name(system_name)}
new_logo_path = None
if logo is not None:
data = await logo.read(_MAX_LOGO_BYTES + 1)
await logo.close()
if not data:
raise HTTPException(status_code=422, detail="Logo 文件不能为空")
if len(data) > _MAX_LOGO_BYTES:
raise HTTPException(status_code=413, detail="Logo 文件不能超过 10MB")
suffix, media_type = _detect_logo(data)
filename = await asyncio.to_thread(_write_logo, data, suffix)
new_logo_path = runtime_home() / "app-identity" / filename
update.update(
{
"logo_filename": filename,
"logo_media_type": media_type,
"logo_version": uuid4().hex,
}
if header_title_font_mode is not None:
update["header_title_font_mode"] = _normalize_header_title_font_mode(header_title_font_mode)
if header_title_font_size is not None:
update["header_title_font_size"] = _normalize_header_title_font_size(header_title_font_size)
logo_asset = (
await _read_uploaded_image(logo, "Logo") if logo is not None else None
)
header_background_asset = (
await _read_uploaded_image(
light_red_header_background,
"导航栏背景图",
allow_svg=True,
max_bytes=None,
)
elif remove_logo:
update.update(
{
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
if light_red_header_background is not None
else None
)
nav_active_asset = (
await _read_uploaded_image(
light_red_nav_active,
"菜单选中样式图",
allow_svg=True,
max_bytes=None,
)
if light_red_nav_active is not None
else None
)
updated = current.app_identity.model_copy(update=update)
staged_paths = []
try:
if logo_asset is not None:
data, suffix, media_type = logo_asset
filename = await asyncio.to_thread(_write_asset, data, suffix, "logo")
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"logo_filename": filename,
"logo_media_type": media_type,
"logo_version": uuid4().hex,
}
)
elif remove_logo:
update.update(
{
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
if header_background_asset is not None:
data, suffix, media_type = header_background_asset
filename = await asyncio.to_thread(
_write_asset, data, suffix, "light-red-header"
)
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"light_red_header_background_filename": filename,
"light_red_header_background_media_type": media_type,
"light_red_header_background_version": uuid4().hex,
}
)
elif remove_light_red_header_background:
update.update(
{
"light_red_header_background_filename": None,
"light_red_header_background_media_type": None,
"light_red_header_background_version": None,
}
)
if nav_active_asset is not None:
data, suffix, media_type = nav_active_asset
filename = await asyncio.to_thread(
_write_asset, data, suffix, "light-red-active"
)
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"light_red_nav_active_filename": filename,
"light_red_nav_active_media_type": media_type,
"light_red_nav_active_version": uuid4().hex,
}
)
elif remove_light_red_nav_active:
update.update(
{
"light_red_nav_active_filename": None,
"light_red_nav_active_media_type": None,
"light_red_nav_active_version": None,
}
)
updated = current.app_identity.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"app_identity": updated}))
except Exception:
if new_logo_path is not None:
await asyncio.to_thread(new_logo_path.unlink, missing_ok=True)
for staged_path in staged_paths:
await asyncio.to_thread(staged_path.unlink, missing_ok=True)
raise
if previous_logo is not None and previous_logo != app_identity_logo_path(saved.app_identity):
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
previous_paths = (previous_logo, previous_header_background, previous_nav_active)
saved_paths = {
app_identity_logo_path(saved.app_identity),
app_identity_light_red_header_background_path(saved.app_identity),
app_identity_light_red_nav_active_path(saved.app_identity),
}
for previous_path in previous_paths:
if previous_path is not None and previous_path not in saved_paths:
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
return _response(saved.app_identity)
@router.delete("/app-identity", response_model=AppIdentityResponse)
async def delete_app_identity_settings(request: Request) -> AppIdentityResponse:
"""Clear the application name and uploaded logo. Admin only."""
"""Clear the application identity and uploaded theme artwork. Admin only."""
await _require_admin(request)
current = load_system_settings()
previous_logo = app_identity_logo_path(current.app_identity)
previous_paths = (
app_identity_logo_path(current.app_identity),
app_identity_light_red_header_background_path(current.app_identity),
app_identity_light_red_nav_active_path(current.app_identity),
)
saved = save_system_settings(current.model_copy(update={"app_identity": AppIdentitySettings()}))
if previous_logo is not None:
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
for previous_path in previous_paths:
if previous_path is not None:
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
return _response(saved.app_identity)
@@ -166,5 +356,35 @@ async def get_app_identity_logo() -> FileResponse:
path,
media_type=settings.logo_media_type or "image/png",
content_disposition_type="inline",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
headers=_public_asset_headers(settings.logo_media_type),
)
@public_router.get("/light-red-header-background")
async def get_light_red_header_background() -> FileResponse:
settings = load_system_settings().app_identity
path = app_identity_light_red_header_background_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传红色导航栏背景图")
return FileResponse(
path,
media_type=settings.light_red_header_background_media_type or "image/png",
content_disposition_type="inline",
headers=_public_asset_headers(
settings.light_red_header_background_media_type
),
)
@public_router.get("/light-red-nav-active")
async def get_light_red_nav_active() -> FileResponse:
settings = load_system_settings().app_identity
path = app_identity_light_red_nav_active_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传红色菜单选中样式图")
return FileResponse(
path,
media_type=settings.light_red_nav_active_media_type or "image/png",
content_disposition_type="inline",
headers=_public_asset_headers(settings.light_red_nav_active_media_type),
)
@@ -1,7 +1,9 @@
import asyncio
import logging
import mimetypes
import os
import re
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path
@@ -11,12 +13,16 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from pydantic import BaseModel, Field
from starlette.background import BackgroundTask
from app.gateway.authz import require_permission
from app.gateway.operation_timing import OperationTiming
from app.gateway.path_utils import aresolve_thread_virtual_path
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import generated_page_logo_path, load_system_settings
from deerflow.html_runtime.addresses import html_map_tile_url, html_runtime_base_url
from deerflow.html_runtime.offline_bundle import build_html_offline_bundle
from deerflow.runtime.thread_paths import aresolve_path_user_id
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_io import file_io
@@ -213,6 +219,44 @@ def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | Non
return headers
def _remove_temporary_bundle(path: str) -> None:
try:
Path(path).unlink(missing_ok=True)
except OSError:
logger.warning("Failed to remove temporary HTML offline bundle: %s", path, exc_info=True)
async def build_html_offline_bundle_response(actual_path: Path) -> FileResponse:
"""Build and stream a portable ZIP for one generated HTML artifact."""
descriptor, temporary_name = tempfile.mkstemp(prefix="deerflow-html-offline-", suffix=".zip")
os.close(descriptor)
temporary_path = Path(temporary_name)
try:
branding = load_system_settings().generated_page_branding
logo_path = generated_page_logo_path(branding)
await file_io.run_until_complete(
build_html_offline_bundle,
actual_path,
temporary_path,
runtime_base_url=html_runtime_base_url(),
map_tile_url=html_map_tile_url(),
generated_logo_path=logo_path,
)
except BaseException:
temporary_path.unlink(missing_ok=True)
raise
download_name = f"{actual_path.stem}-离线包.zip"
return FileResponse(
temporary_path,
filename=download_name,
media_type="application/zip",
headers={"Cache-Control": "private, no-store"},
background=BackgroundTask(_remove_temporary_bundle, str(temporary_path)),
)
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes."""
try:
@@ -988,10 +1032,20 @@ async def preview_artifact(
@router.get(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File",
description="Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, while active web content is always downloaded.",
description=(
"Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, "
"while active web content is always downloaded. HTML files can be exported with their DeerFlow "
"runtime dependencies as a portable ZIP."
),
)
@require_permission("threads", "read", owner_check=True)
async def get_artifact(thread_id: str, path: str, request: Request, download: bool = False) -> Response:
async def get_artifact(
thread_id: str,
path: str,
request: Request,
download: bool = False,
offline_bundle: bool = False,
) -> Response:
"""Get an artifact file by its path.
The endpoint automatically detects file types and returns appropriate content types.
@@ -1018,6 +1072,8 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
download (bool): If true, forces attachment download for file types that are
otherwise returned inline or as plain text. Active HTML/XHTML/SVG content
is always downloaded regardless of this flag.
offline_bundle (bool): If true for an HTML/XHTML file, returns a ZIP containing
``index.html`` plus the packaged DeerFlow JS/CSS/image runtime dependencies.
Example:
- Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt`
@@ -1075,6 +1131,11 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
mime_type, _ = mimetypes.guess_type(actual_path)
if offline_bundle:
if actual_path.suffix.lower() not in {".htm", ".html", ".xhtml"}:
raise HTTPException(status_code=400, detail="Offline bundles are available for HTML files only")
return await build_html_offline_bundle_response(actual_path)
if download:
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers=_build_attachment_headers(actual_path.name))
@@ -681,6 +681,9 @@ def _embed_allowed_weknora_ids(ctx: WeKnoraEmbedContext) -> tuple[str, ...]:
def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
# Keep this payload bounded: it is signed directly into the browser cookie.
# The visible knowledge-base list is hydrated only for the embedded page or
# its list API call, after the cookie has been verified.
return {
"mapping_id": ctx.mapping_id,
"weknora_id": ctx.weknora_id,
@@ -688,11 +691,79 @@ def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
"is_admin": ctx.is_admin,
"can_write": ctx.can_write,
"is_conversation_deposit": ctx.is_conversation_deposit,
"allowed_weknora_ids": list(_embed_allowed_weknora_ids(ctx)),
"exp": ctx.exp,
}
async def _with_visible_embed_knowledge_bases(
request: Request,
ctx: WeKnoraEmbedContext,
) -> WeKnoraEmbedContext:
"""Hydrate the iframe-only picker allowlist without putting it in a cookie."""
visible_rows = await _store(request).list_visible(
ctx.user_id,
scope="all",
# Mirror the regular picker. An administrator must not receive another
# user's private bases merely because the iframe uses an admin token.
is_admin=False,
)
return replace(
ctx,
allowed_weknora_ids=tuple(
dict.fromkeys(
value
for value in (
ctx.weknora_id,
*(str(item.get("weknora_id") or "") for item in visible_rows),
)
if value
)
),
)
def _embed_directory_time(value: Any) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, str) and value.strip():
return value
return None
async def _local_embed_knowledge_base_directory(
request: Request,
ctx: WeKnoraEmbedContext,
) -> Response:
"""Serve the iframe picker from DeerFlow mappings, never WeKnora's full directory."""
rows = await _store(request).list_visible(
ctx.user_id,
scope="all",
# Match the normal picker: the WeKnora service-admin token must not
# make an administrator's private libraries discoverable to others.
is_admin=False,
)
items = [
{
"id": str(row.get("weknora_id") or ""),
"name": str(row.get("name") or ""),
"description": str(row.get("description") or ""),
"type": str(row.get("kb_type") or "document"),
"status": "active",
"created_at": _embed_directory_time(row.get("created_at")),
"updated_at": _embed_directory_time(row.get("updated_at")),
}
for row in rows
if str(row.get("weknora_id") or "")
]
response = _json_response(
{"success": True, "data": items, "total": len(items)},
)
response.headers["Cache-Control"] = "no-store"
return response
def _weknora_id_from_platform_path(path: str) -> str | None:
prefix = "/platform/knowledge-bases/"
if not path.startswith(prefix):
@@ -708,15 +779,13 @@ async def _switch_embed_context(
ctx: WeKnoraEmbedContext,
weknora_id: str,
) -> WeKnoraEmbedContext:
"""Resolve a permitted native-UI switch to the exact cmzs mapping.
"""Resolve a native-UI switch to the exact cmzs mapping.
The embedded WeKnora UI uses a service-admin token, so every target remains
constrained to an ID signed into this short-lived session and is checked
again against the cmzs visibility rules before its page can be proxied.
The embedded WeKnora UI uses a service-admin token, so the target must be
checked against the current cmzs visibility rules on every switch. This is
deliberately dynamic rather than a signed ID list in the browser cookie.
"""
if weknora_id not in _embed_allowed_weknora_ids(ctx):
raise HTTPException(status_code=403, detail="Knowledge base is outside the embedded session scope")
store = _store(request)
mapped = await store.get_by_weknora_id(weknora_id)
if mapped is None:
@@ -2807,13 +2876,6 @@ async def _prepare_weknora_embed_session(
) -> tuple[str, dict[str, Any]]:
_runtime_for_iframe_proxy(request)
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
visible_rows = await _store(request).list_visible(
user_id,
scope="all",
# The iframe must mirror the knowledge-base picker, not grant an
# administrator an unlisted user's private knowledge bases.
is_admin=False,
)
writable = None
if not _is_conversation_deposit_mapping(row):
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
@@ -2825,7 +2887,6 @@ async def _prepare_weknora_embed_session(
is_admin=is_admin,
can_write=writable is not None,
is_conversation_deposit=_is_conversation_deposit_mapping(row),
allowed_weknora_ids=tuple(dict.fromkeys(value for value in (weknora_id, *(str(item.get("weknora_id") or "") for item in visible_rows)) if value)),
exp=int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
)
return weknora_id, _embed_context_payload(ctx)
@@ -2885,6 +2946,7 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
requested_weknora_id = _weknora_id_from_platform_path(requested)
if requested_weknora_id is not None:
selected_ctx = await _switch_embed_context(request, ctx, requested_weknora_id)
selected_ctx = await _with_visible_embed_knowledge_bases(request, selected_ctx)
response = await _proxy_weknora_request(
request,
target_base_url=_runtime_for_iframe_proxy(request).web_base_url,
@@ -2909,6 +2971,7 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
runtime = _runtime_for_iframe_proxy(request)
ctx = await _with_visible_embed_knowledge_bases(request, ctx)
return await _proxy_weknora_request(
request,
target_base_url=runtime.web_base_url,
@@ -2958,6 +3021,8 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
if request.method.upper() == "OPTIONS":
return Response(status_code=204)
ctx = await _read_embed_context(request)
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
return await _local_embed_knowledge_base_directory(request, ctx)
runtime = _runtime_for_iframe_proxy(request)
return await _proxy_weknora_request(
request,
@@ -2982,6 +3047,8 @@ async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Respon
if request.method.upper() == "OPTIONS":
return Response(status_code=204)
ctx = await _read_embed_context(request)
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
return await _local_embed_knowledge_base_directory(request, ctx)
runtime = _runtime_for_iframe_proxy(request)
return await _proxy_weknora_request(
request,
@@ -12,7 +12,7 @@ import time
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
@@ -35,6 +35,7 @@ from app.gateway.skill_catalog_cache import (
load_or_warm_skill_catalog_snapshot,
load_skill_catalog_projection,
refresh_skill_catalog_projection,
set_skill_catalog_enabled,
)
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
@@ -379,6 +380,24 @@ class SkillRollbackRequest(BaseModel):
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
class SkillVersionEntryResponse(BaseModel):
version: int
sha256: str
created_at: str
created_by: str | None = None
action: str
summary: str | None = None
based_on_version: int | None = None
active: bool = False
class SkillVersionsResponse(BaseModel):
enabled: bool
skill_name: str
active_version: int | None = None
versions: list[SkillVersionEntryResponse] = Field(default_factory=list)
def _skill_to_response(
skill: Skill,
ownership: dict[str, Any] | None = None,
@@ -1378,6 +1397,10 @@ async def install_skill_upload(
request: Request,
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
overwrite: bool = False,
install_mode: Literal["iterate", "overwrite"] | None = Query(
default=None,
description="Same-name custom upload mode when versioning is enabled: iterate or overwrite.",
),
target_category: SkillCategory = SkillCategory.CUSTOM,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
@@ -1412,9 +1435,31 @@ async def install_skill_upload(
target_category=target_category,
conflict_kind=validation.get("conflict_kind"),
)
if (overwrite and not can_install_as_is and not can_overwrite) or (not overwrite and not can_install_as_is):
requested_overwrite = overwrite or install_mode in {"iterate", "overwrite"}
if (requested_overwrite and not can_install_as_is and not can_overwrite) or (
not requested_overwrite and not can_install_as_is
):
raise HTTPException(status_code=409, detail=conflict_message)
if install_mode == "iterate":
from deerflow.skills.versioning import record_custom_skill_version, skill_versioning_enabled
if not skill_versioning_enabled():
raise HTTPException(status_code=409, detail="技能迭代功能未开启")
if target_category != SkillCategory.CUSTOM or not _owned_by_me or not can_overwrite:
raise HTTPException(status_code=409, detail="只有本人已有的同名自定义技能可以迭代")
overwrite = True
await asyncio.to_thread(
record_custom_skill_version,
preview_name,
storage=storage,
action="baseline",
author=user_id,
summary="同名上传迭代前的活动版本",
)
elif install_mode == "overwrite":
overwrite = True
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
storage.invalidate_skills_cache()
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
@@ -1433,8 +1478,49 @@ async def install_skill_upload(
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
try:
from deerflow.skills.versioning import (
record_custom_skill_version,
reset_custom_skill_versions,
skill_versioning_enabled,
)
if skill_versioning_enabled():
if result.get("overwritten") and install_mode == "iterate":
await asyncio.to_thread(
record_custom_skill_version,
result["skill_name"],
storage=storage,
action="upload_iteration",
author=user_id,
summary="上传同名技能并迭代为新版本",
)
elif result.get("overwritten"):
await asyncio.to_thread(
reset_custom_skill_versions,
result["skill_name"],
storage=storage,
author=user_id,
summary="上传同名技能并直接覆盖",
)
else:
await asyncio.to_thread(
record_custom_skill_version,
result["skill_name"],
storage=storage,
action="upload",
author=user_id,
summary="上传技能初始版本",
)
except Exception:
logger.exception("Failed to persist custom skill version after upload: %s", result["skill_name"])
# The package is already installed. Do not report the whole
# upload as failed (which would invite an unsafe duplicate
# retry); opening the version list lazily snapshots the current
# package and repairs the manifest.
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(result["skill_name"])
return SkillInstallResponse(**result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -1676,6 +1762,7 @@ async def install_skill(
target_category=target_category,
require_skill_suffix=False,
)
storage.invalidate_skills_cache()
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result.skill_name)
@@ -1689,8 +1776,16 @@ async def install_skill(
await skill_store.ensure_legacy(
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
)
await _record_custom_version(
result.skill_name,
storage=storage,
action="artifact_install",
author=user_id,
summary="从会话产物安装技能初始版本",
)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(result.skill_name)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -1803,6 +1898,14 @@ async def duplicate_skill(
mark_uploaded(new_name)
except Exception:
pass
storage.invalidate_skills_cache()
await _record_custom_version(
new_name,
storage=storage,
action="duplicate",
author=user_id,
summary=f"从技能 {source_name} 复制创建",
)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(
@@ -1857,6 +1960,13 @@ async def reconcile_custom_skills(
mark_uploaded(skill.name)
except Exception:
pass
await _record_custom_version(
skill.name,
storage=storage,
action="reconcile",
author=user_id,
summary="接管已有自定义技能并建立初始版本",
)
adopted.append(skill.name)
if adopted:
@@ -1942,8 +2052,16 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
if scan.decision == "block":
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
await _record_custom_version(
skill_name,
storage=storage,
action="baseline",
author=None,
summary="编辑前的活动版本",
)
prev_content = storage.read_custom_skill(skill_name)
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
storage.invalidate_skills_cache()
await _refresh_catalog_snapshot(config)
storage.append_history(
skill_name,
@@ -1962,7 +2080,15 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
bump_patch(skill_name)
except Exception:
pass
await _record_custom_version(
skill_name,
storage=storage,
action="human_edit",
author=None,
summary="编辑 SKILL.md",
)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(skill_name)
return await get_custom_skill(skill_name, config)
except HTTPException:
raise
@@ -2014,6 +2140,12 @@ async def delete_custom_skill(
await skill_store.delete_admin(skill_name)
else:
await skill_store.delete(skill_name, user_id)
try:
from deerflow.skills.versioning import delete_custom_skill_versions
await asyncio.to_thread(delete_custom_skill_versions, skill_name)
except Exception:
logger.warning("Failed to delete version history for %s", skill_name, exc_info=True)
await _refresh_catalog_snapshot(config)
try:
@@ -2352,6 +2484,108 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(
raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
@router.get(
"/skills/custom/{skill_name}/versions",
response_model=SkillVersionsResponse,
summary="List Custom Skill Versions",
)
async def get_custom_skill_versions(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillVersionsResponse:
"""List complete-package snapshots for one owned/admin-managed custom skill."""
try:
skill_name = SkillStorage.validate_skill_name(skill_name.replace("\r\n", "").replace("\n", ""))
await _get_editable_skill_or_raise(
skill_store,
skill_name,
_current_user_id(request),
is_admin=_is_admin_user(request),
)
storage = get_or_new_skill_storage(app_config=config)
if not storage.custom_skill_exists(skill_name):
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
from deerflow.skills.versioning import list_custom_skill_versions
payload = await asyncio.to_thread(
list_custom_skill_versions,
skill_name,
storage=storage,
author=_current_user_id(request),
)
return SkillVersionsResponse.model_validate(payload)
except HTTPException:
raise
except (FileNotFoundError, ValueError) as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to list versions for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list skill versions: {e}")
@router.post(
"/skills/custom/{skill_name}/versions/{version}/activate",
response_model=SkillVersionsResponse,
summary="Activate Custom Skill Version",
)
async def activate_custom_skill_version_endpoint(
skill_name: str,
version: int,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillVersionsResponse:
"""Switch the sole model-visible package without touching usage counters."""
try:
skill_name = SkillStorage.validate_skill_name(skill_name.replace("\r\n", "").replace("\n", ""))
await _get_editable_skill_or_raise(
skill_store,
skill_name,
_current_user_id(request),
is_admin=_is_admin_user(request),
)
storage = get_or_new_skill_storage(app_config=config)
if not storage.custom_skill_exists(skill_name):
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
from deerflow.skills.versioning import (
activate_custom_skill_version,
list_custom_skill_versions,
)
await asyncio.to_thread(
activate_custom_skill_version,
skill_name,
version,
storage=storage,
)
storage.invalidate_skills_cache()
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(skill_name)
payload = await asyncio.to_thread(
list_custom_skill_versions,
skill_name,
storage=storage,
author=_current_user_id(request),
)
return SkillVersionsResponse.model_validate(payload)
except HTTPException:
raise
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to activate %s version %s: %s", skill_name, version, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to activate skill version: {e}")
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
try:
@@ -2382,10 +2616,26 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
if scan.decision == "block":
storage.append_history(skill_name, history_entry)
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
await _record_custom_version(
skill_name,
storage=storage,
action="baseline",
author=None,
summary="旧历史回滚前的活动版本",
)
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
storage.invalidate_skills_cache()
await _refresh_catalog_snapshot(config)
storage.append_history(skill_name, history_entry)
await _record_custom_version(
skill_name,
storage=storage,
action="legacy_rollback",
author=None,
summary="从旧编辑历史恢复 SKILL.md",
)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(skill_name)
return await get_custom_skill(skill_name, config)
except HTTPException:
raise
@@ -2848,6 +3098,28 @@ async def _sync_skill_view(name: str) -> None:
logger.warning("Failed to sync skill %s into the sandbox view", name, exc_info=True)
async def _record_custom_version(
name: str,
*,
storage: SkillStorage,
action: str,
author: str | None,
summary: str,
) -> None:
"""Capture one complete custom-skill package when versioning is enabled."""
from deerflow.skills.versioning import record_custom_skill_version
await asyncio.to_thread(
record_custom_skill_version,
name,
storage=storage,
action=action,
author=author,
summary=summary,
)
@router.put(
"/skills/{skill_name}/files/content",
response_model=SkillFileSaveResponse,
@@ -2899,7 +3171,18 @@ async def save_skill_file(
# SKILL.md edits must keep their frontmatter valid (name must match).
storage.validate_skill_markdown_content(skill.name, body.content)
if skill.category == SkillCategory.CUSTOM:
await _record_custom_version(
skill.name,
storage=storage,
action="baseline",
author=_current_user_id(request),
summary="文件编辑前的活动版本",
)
_write_skill_text_file_atomically(target, body.content)
if skill.category == SkillCategory.CUSTOM:
storage.invalidate_skills_cache()
try:
if skill.category == SkillCategory.CUSTOM:
@@ -2925,6 +3208,15 @@ async def save_skill_file(
pass
await refresh_skills_system_prompt_cache_async()
if skill.category == SkillCategory.CUSTOM:
await _record_custom_version(
skill.name,
storage=storage,
action="human_edit_file",
author=_current_user_id(request),
summary=f"编辑文件 {relative_posix}",
)
await _sync_skill_view(skill.name)
stat = target.stat()
@@ -2984,10 +3276,19 @@ async def delete_skill_file(
prev_content = target.read_text(encoding="utf-8")
except UnicodeDecodeError:
pass
if skill.category == SkillCategory.CUSTOM:
await _record_custom_version(
skill.name,
storage=get_or_new_skill_storage(app_config=config),
action="baseline",
author=_current_user_id(request),
summary="文件删除前的活动版本",
)
target.unlink()
if skill.category == SkillCategory.CUSTOM:
storage = get_or_new_skill_storage(app_config=config)
storage.invalidate_skills_cache()
try:
storage.append_history(
skill.name,
@@ -3001,6 +3302,13 @@ async def delete_skill_file(
)
except Exception as history_err: # noqa: BLE001
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
await _record_custom_version(
skill.name,
storage=storage,
action="human_delete_file",
author=_current_user_id(request),
summary=f"删除文件 {relative_posix}",
)
else:
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
await _sync_skill_view(skill.name)
@@ -3083,6 +3391,11 @@ async def update_skill(
logger.info(f"Skills configuration updated and saved to: {config_path}")
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(skill_name)
# The list endpoint reads skills.enabled from the catalog projection.
# Write that one row before the client refetches. A missing row still
# needs the full snapshot so a newly discovered skill can appear.
if not await set_skill_catalog_enabled(skill_name, request.enabled):
await _refresh_catalog_snapshot(config)
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
updated_skill = next((s for s in skills if s.name == skill_name), None)
@@ -18,16 +18,21 @@ from deerflow.config.runtime_paths import runtime_home
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AgentSkillMigrationSettings,
AIWritingPageDescription,
AppearanceRouteParam,
AuthenticationSettings,
CallMetricsSettings,
CompactShortcutButton,
ConversationContextSettings,
CustomThemePreset,
GeneralQaPageDescriptions,
GeneratedPageBrandingSettings,
HtmlMapSettings,
LeaderboardSettings,
ModelConcurrencySettings,
ModelPromptSuffix,
PageDescriptionsSettings,
PageHeaderDescription,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
@@ -36,6 +41,7 @@ from deerflow.config.system_settings import (
RetryKeepPartialSettings,
RoundtableRoomLimitSettings,
SkillCompressionSettings,
SkillVersioningSettings,
StrategyProjectSettings,
WordHeadingNumberingStyle,
WriteFileCompatibilitySettings,
@@ -213,6 +219,109 @@ class StrategyProjectSettingsUpdateRequest(BaseModel):
taiwan_strait_demo_enabled: bool | None = None
class PageDescriptionsResponse(BaseModel):
general_qa: GeneralQaPageDescriptions = Field(
default_factory=GeneralQaPageDescriptions,
)
knowledge_base: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
ai_writing: AIWritingPageDescription = Field(default_factory=AIWritingPageDescription)
strategy_projects: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
deep_research: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
gongwen: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
light_app_management: PageHeaderDescription = Field(
default_factory=PageHeaderDescription,
)
light_app_list: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
agents: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
business_chains: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
skills: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
knowledge_management: PageHeaderDescription = Field(
default_factory=PageHeaderDescription,
)
class PageDescriptionsUpdateRequest(BaseModel):
# Keep this request backward compatible with the first release, whose
# editor only submitted ``general_qa`` as a single description object.
general_qa: GeneralQaPageDescriptions | None = None
knowledge_base: PageHeaderDescription | None = None
ai_writing: AIWritingPageDescription | None = None
strategy_projects: PageHeaderDescription | None = None
deep_research: PageHeaderDescription | None = None
gongwen: PageHeaderDescription | None = None
light_app_management: PageHeaderDescription | None = None
light_app_list: PageHeaderDescription | None = None
agents: PageHeaderDescription | None = None
business_chains: PageHeaderDescription | None = None
skills: PageHeaderDescription | None = None
knowledge_management: PageHeaderDescription | None = None
def _page_descriptions_response(
settings: PageDescriptionsSettings,
) -> PageDescriptionsResponse:
return PageDescriptionsResponse(
general_qa=settings.general_qa,
knowledge_base=settings.knowledge_base,
ai_writing=settings.ai_writing,
strategy_projects=settings.strategy_projects,
deep_research=settings.deep_research,
gongwen=settings.gongwen,
light_app_management=settings.light_app_management,
light_app_list=settings.light_app_list,
agents=settings.agents,
business_chains=settings.business_chains,
skills=settings.skills,
knowledge_management=settings.knowledge_management,
)
@router.get("/page-descriptions", response_model=PageDescriptionsResponse)
async def get_page_descriptions() -> PageDescriptionsResponse:
"""Return page-scoped display copy for normal product surfaces."""
return _page_descriptions_response(load_system_settings().page_descriptions)
@router.put("/page-descriptions", response_model=PageDescriptionsResponse)
async def update_page_descriptions(
body: PageDescriptionsUpdateRequest,
request: Request,
) -> PageDescriptionsResponse:
"""Replace page-scoped display copy. Administrators only."""
await _require_admin(request)
current = load_system_settings()
updated = PageDescriptionsSettings(
general_qa=body.general_qa or current.page_descriptions.general_qa,
knowledge_base=body.knowledge_base or current.page_descriptions.knowledge_base,
ai_writing=body.ai_writing or current.page_descriptions.ai_writing,
strategy_projects=(
body.strategy_projects or current.page_descriptions.strategy_projects
),
deep_research=body.deep_research or current.page_descriptions.deep_research,
gongwen=body.gongwen or current.page_descriptions.gongwen,
light_app_management=(
body.light_app_management
or current.page_descriptions.light_app_management
),
light_app_list=body.light_app_list or current.page_descriptions.light_app_list,
agents=body.agents or current.page_descriptions.agents,
business_chains=(
body.business_chains or current.page_descriptions.business_chains
),
skills=body.skills or current.page_descriptions.skills,
knowledge_management=(
body.knowledge_management
or current.page_descriptions.knowledge_management
),
)
saved = save_system_settings(
current.model_copy(update={"page_descriptions": updated}),
)
return _page_descriptions_response(saved.page_descriptions)
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
@@ -500,6 +609,14 @@ class SkillCompressionUpdateRequest(BaseModel):
keep_index_in_prompt: bool | None = None
class SkillVersioningResponse(BaseModel):
enabled: bool = Field(..., description="Whether custom-skill version history is active")
class SkillVersioningUpdateRequest(BaseModel):
enabled: bool
class ModelConcurrencyResponse(BaseModel):
enabled: bool = Field(..., description="Whether per-model concurrency limiting is active")
default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override")
@@ -552,6 +669,17 @@ class WriteFileCompatibilityUpdateRequest(BaseModel):
enabled: bool
class CallMetricsResponse(BaseModel):
enabled: bool = Field(
default=True,
description="Whether new tool and skill calls are recorded for management statistics",
)
class CallMetricsUpdateRequest(BaseModel):
enabled: bool
class RetryKeepPartialResponse(BaseModel):
enabled: bool = Field(
default=True,
@@ -710,6 +838,32 @@ async def update_write_file_compatibility_setting(
)
@router.get("/call-metrics", response_model=CallMetricsResponse)
async def get_call_metrics_setting(request: Request) -> CallMetricsResponse:
"""Return the administrator-controlled tool/skill metrics switch."""
await _require_admin(request)
return CallMetricsResponse(enabled=load_system_settings().call_metrics.enabled)
@router.put("/call-metrics", response_model=CallMetricsResponse)
async def update_call_metrics_setting(
body: CallMetricsUpdateRequest,
request: Request,
) -> CallMetricsResponse:
"""Enable or pause new tool/skill metric writes without a restart."""
await _require_admin(request)
current = load_system_settings()
saved = save_system_settings(
current.model_copy(
update={"call_metrics": CallMetricsSettings(enabled=body.enabled)}
)
)
logger.info("Tool and skill call metrics switch changed: enabled=%s", saved.call_metrics.enabled)
return CallMetricsResponse(enabled=saved.call_metrics.enabled)
@router.get("/retry-keep-partial", response_model=RetryKeepPartialResponse)
async def get_retry_keep_partial_setting() -> RetryKeepPartialResponse:
"""Return the retry-keeps-partial display switch (readable by chat clients)."""
@@ -961,6 +1115,42 @@ async def update_skill_compression(body: SkillCompressionUpdateRequest, request:
return _skill_compression_to_response(saved.skill_compression)
@router.get("/skill-versioning", response_model=SkillVersioningResponse)
async def get_skill_versioning() -> SkillVersioningResponse:
"""Return the custom-skill versioning gate for skill detail clients."""
return SkillVersioningResponse(enabled=load_system_settings().skill_versioning.enabled)
@router.put("/skill-versioning", response_model=SkillVersioningResponse)
async def update_skill_versioning(
body: SkillVersioningUpdateRequest,
request: Request,
) -> SkillVersioningResponse:
"""Enable/disable custom-skill version capture and switching. Admin only."""
await _require_admin(request)
current = load_system_settings()
previous = current.skill_versioning.enabled
updated = SkillVersioningSettings(enabled=body.enabled)
saved = save_system_settings(current.model_copy(update={"skill_versioning": updated}))
if body.enabled and not previous:
try:
from deerflow.skills.versioning import reconcile_custom_skill_versions
actor = await get_optional_user_from_request(request)
actor_id = str(getattr(actor, "id", "") or "") or None
await asyncio.to_thread(reconcile_custom_skill_versions, author=actor_id)
except Exception:
logger.exception("Failed to capture custom-skill baselines after enabling versioning")
# The switch remains enabled. Individual skills are bootstrapped
# lazily when their version list is first opened or next changed.
logger.info("Custom skill versioning switch changed: enabled=%s", saved.skill_versioning.enabled)
return SkillVersioningResponse(enabled=saved.skill_versioning.enabled)
@router.get("/model-concurrency", response_model=ModelConcurrencyResponse)
async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse:
"""Return the per-model concurrency-limit configuration. Admin only."""
@@ -19,6 +19,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_checkpointer, get_thread_share_store, get_thread_store
from app.gateway.routers.artifacts import build_html_offline_bundle_response
from app.gateway.thread_copy import copy_thread_state
from deerflow.config.paths import get_paths
from deerflow.persistence.thread_meta.base import ThreadMetaStore
@@ -316,6 +317,7 @@ def _public_share_disposition(mime_type: str) -> str:
async def get_public_share_file(
share_code: str,
path: str,
offline_bundle: bool = False,
share_store: ThreadShareStore = Depends(get_thread_share_store),
) -> FileResponse:
"""Serve a file referenced by a view-shared conversation.
@@ -342,6 +344,11 @@ async def get_public_share_file(
if not actual_path.exists() or not actual_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
if offline_bundle:
if actual_path.suffix.lower() not in {".htm", ".html", ".xhtml"}:
raise HTTPException(status_code=400, detail="Offline bundles are available for HTML files only")
return await build_html_offline_bundle_response(actual_path)
mime_type = mimetypes.guess_type(actual_path.name)[0] or "application/octet-stream"
return FileResponse(
actual_path,
@@ -252,6 +252,29 @@ async def refresh_skill_catalog_projection(
return len(catalog)
async def set_skill_catalog_enabled(name: str, enabled: bool) -> bool:
"""Persist one skill's online flag in the catalog row the list API reads.
The extensions file is the runtime source of truth. The skills page reads
``skills.enabled`` instead, so a toggle that only rewrites the file looks
unchanged after the list refreshes. Updating this single row avoids a full
catalog rebuild, which rewrites every skill and can roll the flag back if
an unrelated row fails.
"""
session_factory = get_session_factory()
if session_factory is None or not name:
return False
async with session_factory() as session:
result = await session.execute(
update(SkillRow)
.where(SkillRow.name == name)
.values(enabled=bool(enabled), updated_at=datetime.now(UTC))
)
await session.commit()
return int(result.rowcount or 0) > 0
def _build_catalog_payload(skills: list[Any], usage_entries: dict[str, Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
defaults = {
+7 -3
View File
@@ -2,9 +2,10 @@
config_version: 8
log_level: info
# 高并发运行时优化。技能视图两项不写也默认开启,要关掉才写成 false。修改后需重启后台。
# 高并发运行时优化。工具/技能统计与技能视图默认开启;若确有写库压力才改成 false。修改后需重启后台。
runtime_performance:
tool_metrics_enabled: false # 暂停工具/技能统计写库;不影响问答与工具执行。恢复统计时设 true 并重启
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 # 模型计时日志独立队列,不争抢工具线程池
@@ -662,7 +663,10 @@ ai_writing:
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
llmwiki:
weknora:
api_base_url: "http://119.254.86.251:8088/deerflow-api"
# The restored WeKnora deployment exposes its API at the site root.
# The historical /deerflow-api prefix is a frontend-only route there and
# turns API mutations (POST/OPTIONS) into nginx 405 responses.
api_base_url: "http://119.254.86.251:8088"
web_base_url: "http://119.254.86.251:8088"
admin_email: "lqq@qq.com"
admin_password: "1qaz@WSX"
@@ -217,9 +217,9 @@ or_(
---
---
## 十、已知限制
## 十、实现状态与已知限制
1. **`use_count` 永不增长**:`bump_use()` 已定义但全后端无调用点。"使用一个技能"的语义模糊(技能是 prompt 知识,不是工具),尚未接线。`view_count`(`skill_view` 触发)与 `patch_count`(回滚触发)是正常工作的。
1. **`use_count` 按问答轮次统计**:成功 `skill_view`、读取 `/mnt/skills/{public,custom}/<name>/...` 文件或通过 `bash` 执行该目录脚本,都视为真实调用;同一 run 内同一技能只加一次,不依赖模型自述。计数同步写入 `.usage.json` 与技能列表数据库投影。
2. **`.usage.json` 全局单文件**:不按用户分文件;因技能名全局唯一,一个技能只有一份 state/计数,这是设计取舍。
3. **enabled 开关**:`extensions_config.json` 的技能 `enabled` 是全局的(非 per-user),前端开关已移除,归档功能覆盖了"对 Agent 隐藏技能"的需求。
4. **历史物理归档**:早期 curator 曾把技能目录物理移到 `custom/.archive/`,这些遗留目录不会出现在新的归档抽屉里(列表 API 扫不到);新流程已统一为软归档。
@@ -42,6 +42,15 @@
},
"intent-clarification-policy-copy": {
"enabled": true
},
"multi-seat-delivery-gating": {
"enabled": true
},
"stage-handoff-chat-delivery": {
"enabled": true
},
"leaflet-tile-zoom-guard": {
"enabled": true
}
}
}
@@ -136,13 +136,18 @@ def _build_runtime_middlewares(
# wrap_tool_call chain: its handler is the raw tool, so it observes the
# unconverted exception (before ToolErrorHandlingMiddleware turns it into
# an error ToolMessage) and the raw return value.
# Omit the recorder entirely while paused: no record allocation, skill-merge
# lookup, or fire-and-forget DB task is created by any tool invocation.
# Keep tool execution/error handling and LLM metrics independent of this flag.
if app_config.runtime_performance.tool_metrics_enabled:
# Full auditing can stay paused under high concurrency while the low-volume
# skill-only recorder remains active. In skill-only mode non-skill calls are
# passed through without a merge query or fire-and-forget DB write.
performance = app_config.runtime_performance
if performance.tool_metrics_enabled or performance.skill_metrics_enabled:
from deerflow.agents.middlewares.tool_metrics_middleware import ToolMetricsMiddleware
middlewares.append(ToolMetricsMiddleware())
middlewares.append(
ToolMetricsMiddleware(
skill_only=not performance.tool_metrics_enabled,
)
)
return middlewares
@@ -1,6 +1,7 @@
"""Middleware that records per-tool-call metrics for the admin audit page.
Every tool / skill invocation ends with one row in ``tool_call_metrics``:
While the administrator-controlled call-metrics switch is enabled, every tool
/ skill invocation ends with one row in ``tool_call_metrics``:
- Successes record the wall-clock duration.
- Failures record a coarse ``error_category`` plus a one-line message.
@@ -40,6 +41,7 @@ from langgraph.errors import GraphBubbleUp
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.config.system_settings import get_call_metrics_settings
from deerflow.persistence.engine import fire_and_forget_db_write, get_session_factory
from deerflow.persistence.tool_metrics import ToolMetricRecord, make_tool_metrics_store
from deerflow.persistence.tool_metrics.base import ToolMetricsStore
@@ -188,11 +190,18 @@ def _evaluate_result(result: Any, tool_name: str | None) -> tuple[str, str | Non
class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
"""Wrap each tool call to time it and persist a metric row."""
"""Time tool calls, optionally persisting only attributable skill calls."""
def __init__(self, *, store: ToolMetricsStore | None = None, **kwargs: Any) -> None:
def __init__(
self,
*,
store: ToolMetricsStore | None = None,
skill_only: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._store_override = store
self._skill_only = skill_only
# -- store / context ----------------------------------------------------
@@ -205,6 +214,12 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
return None
return make_tool_metrics_store(sf)
@staticmethod
def _metrics_enabled() -> bool:
"""Read the administrator switch at call time so no restart is needed."""
return bool(get_call_metrics_settings().enabled)
@staticmethod
def _runtime_lookup(request: ToolCallRequest, keys: tuple[str, ...]) -> str | None:
runtime = getattr(request, "runtime", None)
@@ -280,7 +295,11 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
record = self._build_base_record(request)
if is_internal_tool_name(record.tool_name):
if (
not self._metrics_enabled()
or is_internal_tool_name(record.tool_name)
or (self._skill_only and not record.skill_name)
):
return handler(request)
started = time.monotonic()
try:
@@ -303,7 +322,11 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
record = self._build_base_record(request)
if is_internal_tool_name(record.tool_name):
if (
not self._metrics_enabled()
or is_internal_tool_name(record.tool_name)
or (self._skill_only and not record.skill_name)
):
return await handler(request)
started = time.monotonic()
try:
@@ -482,13 +482,14 @@ def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
)
performance = _app_config.runtime_performance
logger.info(
"RUNTIME_PERFORMANCE skill_view_reuse_enabled=%s skill_view_cache_enabled=%s model_timing_nonblocking_enabled=%s asyncio_thread_pool_enabled=%s asyncio_thread_pool_size=%d tool_metrics_enabled=%s",
"RUNTIME_PERFORMANCE skill_view_reuse_enabled=%s skill_view_cache_enabled=%s model_timing_nonblocking_enabled=%s asyncio_thread_pool_enabled=%s asyncio_thread_pool_size=%d tool_metrics_enabled=%s skill_metrics_enabled=%s",
performance.skill_view_reuse_enabled,
performance.skill_view_cache_enabled,
performance.model_timing_nonblocking_enabled,
performance.asyncio_thread_pool_enabled,
performance.asyncio_thread_pool_size,
performance.tool_metrics_enabled,
performance.skill_metrics_enabled,
)
return _app_config
@@ -2,9 +2,11 @@
Skill-view reuse and cache are on when those keys are absent. Set them to
false in config.yaml to restore a full refresh on every tool call. Model
timing and the Gateway thread pool stay off unless configured. If application
configuration cannot be loaded, every switch stays off. Tool-call metric writes
are paused by default; explicitly enable them to resume audit statistics.
timing and the Gateway thread pool stay off unless configured. Tool and skill
metrics are on by default so the management leaderboard has a complete source
of truth. Deployments that need to shed this best-effort audit write load can
explicitly pause either metric type in ``config.yaml``. If configuration
cannot be loaded, the fallback keeps every switch off.
"""
from pydantic import BaseModel, ConfigDict, Field
@@ -15,7 +17,14 @@ class RuntimePerformanceConfig(BaseModel):
skill_view_reuse_enabled: bool = Field(default=True, description="Reuse one verified skill view within each sandbox tool invocation. Set false to refresh on every call.")
skill_view_cache_enabled: bool = Field(default=True, description="Reuse unchanged skill views after validating source, target and availability versions. Set false to rebuild every call.")
tool_metrics_enabled: bool = Field(default=False, description="Persist tool/skill audit statistics to tool_call_metrics. Disabled by default to avoid competing with interactive database traffic; historical reads remain available.")
tool_metrics_enabled: bool = Field(
default=True,
description="Persist tool/skill audit statistics to tool_call_metrics. Enabled by default so management leaderboard tool rankings receive data; set false only to pause ordinary tool auditing.",
)
skill_metrics_enabled: bool = Field(
default=True,
description="Persist calls that can be attributed to a concrete skill, keeping skill statistics available even if ordinary tool auditing is paused.",
)
model_timing_nonblocking_enabled: bool = Field(default=False, description="Dispatch model timing logs through a dedicated bounded queue, outside the shared tool executor")
asyncio_thread_pool_enabled: bool = Field(default=False, description="Override the Gateway event loop's default executor at startup; disabled preserves Python defaults")
asyncio_thread_pool_size: int = Field(default=100, ge=1, le=1024, strict=True, description="Maximum Gateway executor threads when enabled, unless overridden by the documented environment variables")
@@ -24,7 +33,12 @@ class RuntimePerformanceConfig(BaseModel):
# Field defaults turn the skill-view fast path on. This fallback is only for
# callers that cannot read application configuration, and it keeps every
# switch off.
_DEFAULT = RuntimePerformanceConfig(skill_view_reuse_enabled=False, skill_view_cache_enabled=False)
_DEFAULT = RuntimePerformanceConfig(
skill_view_reuse_enabled=False,
skill_view_cache_enabled=False,
tool_metrics_enabled=False,
skill_metrics_enabled=False,
)
def get_runtime_performance_config() -> RuntimePerformanceConfig:
@@ -21,7 +21,7 @@ from pathlib import Path
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride
from deerflow.config.runtime_paths import runtime_home
@@ -431,6 +431,12 @@ class GeneratedPageBrandingSettings(BaseModel):
_APP_IDENTITY_LOGO_FILE_RE = re.compile(r"^logo-[a-f0-9]{32}\.(?:png|jpg|webp)$")
_APP_IDENTITY_LIGHT_RED_HEADER_FILE_RE = re.compile(
r"^light-red-header-[a-f0-9]{32}\.(?:png|jpg|webp|svg)$"
)
_APP_IDENTITY_LIGHT_RED_ACTIVE_FILE_RE = re.compile(
r"^light-red-active-[a-f0-9]{32}\.(?:png|jpg|webp|svg)$"
)
class AppIdentitySettings(BaseModel):
@@ -441,9 +447,21 @@ class AppIdentitySettings(BaseModel):
"""
system_name: str = Field(default="", max_length=80)
header_title_font_mode: Literal["adaptive", "fixed"] = "adaptive"
header_title_font_size: int = Field(default=30, ge=16, le=40)
logo_filename: str | None = None
logo_media_type: Literal["image/png", "image/jpeg", "image/webp"] | None = None
logo_version: str | None = None
light_red_header_background_filename: str | None = None
light_red_header_background_media_type: Literal[
"image/png", "image/jpeg", "image/webp", "image/svg+xml"
] | None = None
light_red_header_background_version: str | None = None
light_red_nav_active_filename: str | None = None
light_red_nav_active_media_type: Literal[
"image/png", "image/jpeg", "image/webp", "image/svg+xml"
] | None = None
light_red_nav_active_version: str | None = None
model_config = ConfigDict(extra="ignore")
@field_validator("system_name")
@@ -462,6 +480,141 @@ class AppIdentitySettings(BaseModel):
raise ValueError("Invalid application logo filename")
return normalized
@field_validator("light_red_header_background_filename")
@classmethod
def _validate_light_red_header_filename(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip().lower()
if not _APP_IDENTITY_LIGHT_RED_HEADER_FILE_RE.fullmatch(normalized):
raise ValueError("Invalid light-red header background filename")
return normalized
@field_validator("light_red_nav_active_filename")
@classmethod
def _validate_light_red_active_filename(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip().lower()
if not _APP_IDENTITY_LIGHT_RED_ACTIVE_FILE_RE.fullmatch(normalized):
raise ValueError("Invalid light-red active navigation filename")
return normalized
class GeneralQaPageDescription(BaseModel):
"""Administrator-provided copy for the ordinary Q&A landing page.
Empty values mean "use the product default", allowing an administrator to
reset one field without coupling runtime data to built-in copy.
"""
welcome_title: str = Field(default="", max_length=80)
welcome_description: str = Field(default="", max_length=500)
input_placeholder: str = Field(default="", max_length=120)
model_config = ConfigDict(extra="ignore")
@field_validator("welcome_title", "welcome_description", "input_placeholder")
@classmethod
def _normalize_copy(cls, value: str | None) -> str:
return str(value or "").strip()
class GeneralQaPageDescriptions(BaseModel):
"""Separate ordinary-Q&A copy for the full and compact layouts.
The first release stored a single description directly under
``general_qa``. Expand that legacy value into both layouts on read so an
existing administrator configuration remains visible in either mode.
"""
full: GeneralQaPageDescription = Field(default_factory=GeneralQaPageDescription)
compact: GeneralQaPageDescription = Field(default_factory=GeneralQaPageDescription)
model_config = ConfigDict(extra="ignore")
@model_validator(mode="before")
@classmethod
def _migrate_legacy_single_description(cls, value: object) -> object:
if isinstance(value, GeneralQaPageDescription):
return {"full": value, "compact": value}
if not isinstance(value, dict):
return value
if "full" in value or "compact" in value:
return value
legacy = {
key: value.get(key, "")
for key in ("welcome_title", "welcome_description", "input_placeholder")
}
return {"full": legacy, "compact": legacy}
class PageHeaderDescription(BaseModel):
"""Configurable title and explanatory copy for a product landing page."""
title: str = Field(default="", max_length=80)
description: str = Field(default="", max_length=500)
model_config = ConfigDict(extra="ignore")
@field_validator("title", "description")
@classmethod
def _normalize_copy(cls, value: str | None) -> str:
return str(value or "").strip()
class AIWritingGuideCard(BaseModel):
"""Editable text for one of the fixed AI-writing welcome cards."""
title: str = Field(default="", max_length=80)
description: str = Field(default="", max_length=240)
model_config = ConfigDict(extra="ignore")
@field_validator("title", "description")
@classmethod
def _normalize_copy(cls, value: str | None) -> str:
return str(value or "").strip()
def _empty_ai_writing_guide_cards() -> list[AIWritingGuideCard]:
return [AIWritingGuideCard() for _ in range(3)]
class AIWritingPageDescription(PageHeaderDescription):
"""Configurable copy for the classic AI-writing welcome page."""
guide_cards: list[AIWritingGuideCard] = Field(
default_factory=_empty_ai_writing_guide_cards,
max_length=3,
)
@model_validator(mode="before")
@classmethod
def _keep_three_guide_cards(cls, value: object) -> object:
if not isinstance(value, dict) or not isinstance(value.get("guide_cards"), list):
return value
cards = value["guide_cards"][:3]
return {**value, "guide_cards": [*cards, *({} for _ in range(3 - len(cards)))]}
class PageDescriptionsSettings(BaseModel):
"""Page-scoped, administrator-managed display copy."""
general_qa: GeneralQaPageDescriptions = Field(default_factory=GeneralQaPageDescriptions)
knowledge_base: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
ai_writing: AIWritingPageDescription = Field(default_factory=AIWritingPageDescription)
strategy_projects: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
deep_research: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
gongwen: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
light_app_management: PageHeaderDescription = Field(
default_factory=PageHeaderDescription,
)
light_app_list: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
agents: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
business_chains: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
skills: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
knowledge_management: PageHeaderDescription = Field(
default_factory=PageHeaderDescription,
)
model_config = ConfigDict(extra="ignore")
def app_identity_logo_path(
settings: AppIdentitySettings | None = None,
@@ -475,6 +628,30 @@ def app_identity_logo_path(
return path if path.is_file() else None
def app_identity_light_red_header_background_path(
settings: AppIdentitySettings | None = None,
) -> Path | None:
"""Resolve the uploaded light-red header background inside runtime storage."""
current = settings or load_system_settings().app_identity
if not current.light_red_header_background_filename:
return None
path = runtime_home() / "app-identity" / current.light_red_header_background_filename
return path if path.is_file() else None
def app_identity_light_red_nav_active_path(
settings: AppIdentitySettings | None = None,
) -> Path | None:
"""Resolve the uploaded light-red active-navigation image inside runtime storage."""
current = settings or load_system_settings().app_identity
if not current.light_red_nav_active_filename:
return None
path = runtime_home() / "app-identity" / current.light_red_nav_active_filename
return path if path.is_file() else None
def generated_page_logo_path(
settings: GeneratedPageBrandingSettings | None = None,
) -> Path | None:
@@ -635,6 +812,20 @@ class SkillCompressionSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class SkillVersioningSettings(BaseModel):
"""Deployment-wide gate for custom-skill package version history.
The default is intentionally disabled so existing deployments retain the
legacy in-place editing behaviour until an administrator opts in.
"""
enabled: bool = Field(
default=False,
description="Whether custom-skill package versioning and switching are enabled",
)
model_config = ConfigDict(extra="ignore")
class AgentModuleSettings(BaseModel):
"""Admin-configured「按类型聚合」module → selected agent-tag ids.
@@ -844,6 +1035,24 @@ class WriteFileCompatibilitySettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class CallMetricsSettings(BaseModel):
"""Administrator-controlled recording for tool and skill call statistics.
Both rankings use the same ``tool_call_metrics`` audit stream. Keeping a
single switch prevents a partially enabled configuration where the
management page can show one ranking but not the other.
"""
enabled: bool = Field(
default=True,
description=(
"Whether new tool and skill calls are persisted for management "
"statistics. Existing records are retained when disabled."
),
)
model_config = ConfigDict(extra="ignore")
class RetryKeepPartialSettings(BaseModel):
"""Keep already-streamed partial answers visible across silent LLM retries.
@@ -1004,9 +1213,11 @@ class SystemSettings(BaseModel):
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
generated_page_branding: GeneratedPageBrandingSettings = Field(default_factory=GeneratedPageBrandingSettings)
app_identity: AppIdentitySettings = Field(default_factory=AppIdentitySettings)
page_descriptions: PageDescriptionsSettings = Field(default_factory=PageDescriptionsSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
skill_versioning: SkillVersioningSettings = Field(default_factory=SkillVersioningSettings)
plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
wx_common_agents: WxCommonAgentsSettings = Field(default_factory=WxCommonAgentsSettings)
@@ -1017,6 +1228,7 @@ class SystemSettings(BaseModel):
write_file_compatibility: WriteFileCompatibilitySettings = Field(
default_factory=WriteFileCompatibilitySettings
)
call_metrics: CallMetricsSettings = Field(default_factory=CallMetricsSettings)
retry_keep_partial: RetryKeepPartialSettings = Field(
default_factory=RetryKeepPartialSettings
)
@@ -1146,6 +1358,19 @@ def get_write_file_compatibility_settings() -> WriteFileCompatibilitySettings:
return WriteFileCompatibilitySettings()
def get_call_metrics_settings() -> CallMetricsSettings:
"""Return the live call-metrics switch, defaulting to recording enabled."""
try:
return load_system_settings().call_metrics
except Exception:
logger.debug(
"Failed to read call metrics setting; keeping metrics enabled",
exc_info=True,
)
return CallMetricsSettings()
def get_roundtable_room_limit_settings() -> RoundtableRoomLimitSettings:
"""Return the admin-controlled roundtable room cap, defaulting to 3 enabled rooms."""
try:
@@ -0,0 +1,321 @@
"""Build portable ZIP bundles for generated HTML artifacts.
Generated pages intentionally reference the Gateway's vendored browser runtime
instead of public CDNs. A plain ``.html`` download therefore stops working
after it is moved away from the DeerFlow deployment. This module rewrites
those trusted Gateway URLs to relative paths and copies the corresponding
packaged assets into a ZIP. It never downloads arbitrary third-party URLs.
"""
from __future__ import annotations
import hashlib
import json
import re
import zipfile
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path, PurePosixPath
from deerflow.html_runtime.public_images import resolve_html_image_path, verify_html_image_token
_STATIC_ASSETS = {
"alpine.min.js",
"bootstrap.bundle.min.js",
"bootstrap.min.css",
"brand-logo-dark.png",
"echarts.min.js",
"g6.min.js",
"leaflet.css",
"leaflet.js",
"topbar-dark.svg",
"topbar-light.svg",
}
_GEO_MAP_ASSETS = {
"china-provinces.geojson",
"japan-prefectures.geojson",
"taiwan-counties.geojson",
"us-states.geojson",
}
_COGNITIVE_ASSET_SUFFIXES = {
".css",
".js",
".json",
".jpeg",
".jpg",
".mjs",
".png",
".svg",
".woff2",
}
_LICENSE_BY_ASSET = {
"alpine.min.js": "LICENSE.alpine.txt",
"bootstrap.bundle.min.js": "LICENSE.bootstrap.txt",
"bootstrap.min.css": "LICENSE.bootstrap.txt",
"echarts.min.js": "LICENSE.echarts.txt",
"g6.min.js": "LICENSE.g6.txt",
"leaflet.css": "LICENSE.leaflet.txt",
"leaflet.js": "LICENSE.leaflet.txt",
}
_RUNTIME_ARCHIVE_ROOT = PurePosixPath("assets/html-runtime")
_HTML_IMAGES_ARCHIVE_ROOT = PurePosixPath("assets/html-images")
_STANDARD_RUNTIME_BASE_RE = (
r"(?:https?://[^/\"'\s<>()]+)?"
r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-runtime"
)
_HTML_IMAGE_URL_RE = re.compile(
r"(?P<url>(?:https?://[^/\"'\s<>()]+)?"
r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-images/"
r"(?P<token>[^\"'\s<>()?#]+)(?:\?[^\"'\s<>()#]*)?)",
re.IGNORECASE,
)
_EXTERNAL_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
@dataclass(frozen=True)
class OfflineBundleResult:
"""Summary of one completed offline bundle."""
bundled_assets: tuple[str, ...]
external_urls: tuple[str, ...]
def _runtime_base_pattern(runtime_base_url: str) -> str:
bases = [_STANDARD_RUNTIME_BASE_RE]
configured = str(runtime_base_url or "").strip().rstrip("/")
if configured:
bases.insert(0, re.escape(configured))
return "(?:" + "|".join(bases) + ")"
def _safe_runtime_source(runtime_dir: Path, relative_path: str) -> Path | None:
"""Resolve only assets that the public HTML-runtime router exposes."""
normalized = relative_path.strip().lstrip("/").replace("\\", "/")
pure_path = PurePosixPath(normalized)
if not normalized or pure_path.is_absolute() or ".." in pure_path.parts:
return None
allowed = False
if len(pure_path.parts) == 1 and normalized in _STATIC_ASSETS:
allowed = True
elif len(pure_path.parts) == 2 and pure_path.parts[0] == "maps" and pure_path.name in _GEO_MAP_ASSETS:
allowed = True
elif pure_path.parts and pure_path.parts[0] == "cognitive-situation-map":
allowed = pure_path.suffix.lower() in _COGNITIVE_ASSET_SUFFIXES
if not allowed:
return None
root = runtime_dir.resolve()
source = (root / Path(*pure_path.parts)).resolve()
try:
source.relative_to(root)
except ValueError:
return None
return source if source.is_file() else None
def _add_file(
archive_files: dict[str, Path],
archive_path: PurePosixPath,
source: Path,
) -> None:
archive_files[archive_path.as_posix()] = source
def _add_cognitive_runtime(archive_files: dict[str, Path], runtime_dir: Path) -> None:
source_root = runtime_dir / "cognitive-situation-map"
if not source_root.is_dir():
return
for source in source_root.rglob("*"):
if not source.is_file():
continue
is_license = source.name.lower().startswith("license")
if source.suffix.lower() not in _COGNITIVE_ASSET_SUFFIXES and not is_license:
continue
relative = source.relative_to(runtime_dir)
_add_file(archive_files, _RUNTIME_ARCHIVE_ROOT / PurePosixPath(relative.as_posix()), source)
def _generated_logo_destination(generated_logo_path: Path | None) -> PurePosixPath:
suffix = generated_logo_path.suffix.lower() if generated_logo_path is not None else ".png"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".png"
return _RUNTIME_ARCHIVE_ROOT / f"generated-page-logo{suffix}"
def _runtime_script(
runtime_dir: Path,
*,
map_tile_url: str,
include_geo_maps: bool,
) -> str:
config: dict[str, object] = {"geoMaps": {}, "mapTileUrl": map_tile_url}
if include_geo_maps:
config["geoMaps"] = {
Path(filename).stem: (_RUNTIME_ARCHIVE_ROOT / "maps" / filename).as_posix()
for filename in sorted(_GEO_MAP_ASSETS)
}
prefix = "window.DEERFLOW_HTML_RUNTIME_CONFIG=" + json.dumps(
config,
ensure_ascii=False,
separators=(",", ":"),
) + ";\n"
return prefix + (runtime_dir / "deerflow-runtime.js").read_text(encoding="utf-8")
def _readme(original_name: str, external_urls: list[str]) -> str:
external_note = (
"未打包的外部地址已记录在 manifest.json 的 external_urls 中;例如在线地图瓦片、"
"用户主动引用的第三方图片或接口仍需要网络。"
if external_urls
else "本页没有检测到仍需联网的 http(s) 地址。"
)
return (
"DeerFlow 网页离线包\n"
"====================\n\n"
f"原始文件:{original_name}\n"
"入口文件:index.html\n"
"依赖目录:assets/\n\n"
"部署方式:请保持目录结构不变,将解压后的全部文件一起放到任意静态网站目录。\n"
"若页面含 GeoJSON 地图等 fetch 请求,不建议直接双击 file:// 打开;可使用 Nginx、"
"Apache、IIS 或其他静态 HTTP 服务访问 index.html。\n\n"
f"{external_note}\n"
)
def build_html_offline_bundle(
html_path: Path,
destination: Path,
*,
runtime_base_url: str,
map_tile_url: str,
generated_logo_path: Path | None = None,
runtime_dir: Path | None = None,
) -> OfflineBundleResult:
"""Write a portable HTML ZIP to *destination* and return its manifest summary."""
html_path = Path(html_path)
destination = Path(destination)
runtime_dir = runtime_dir or Path(str(files("deerflow.html_runtime.assets")))
runtime_dir = Path(runtime_dir)
html = html_path.read_text(encoding="utf-8", errors="replace")
archive_files: dict[str, Path] = {}
generated_files: dict[str, str] = {}
bundled_assets: set[str] = set()
licenses: set[str] = set()
include_geo_maps = bool(re.search(r"data-df-geo\s*=", html, re.IGNORECASE))
uses_leaflet_map = bool(re.search(r"data-df-map\s*=", html, re.IGNORECASE))
base_pattern = _runtime_base_pattern(runtime_base_url)
cognitive_base_re = re.compile(
rf"(?P<url>{base_pattern}/cognitive-situation-map)(?=$|[\"'\s<>()/])",
re.IGNORECASE,
)
def replace_cognitive_base(match: re.Match[str]) -> str:
_add_cognitive_runtime(archive_files, runtime_dir)
bundled_assets.add((_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix() + "/")
return (_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix()
html = cognitive_base_re.sub(replace_cognitive_base, html)
runtime_url_re = re.compile(
rf"(?P<url>{base_pattern}/(?P<asset>[A-Za-z0-9._/-]+)"
r"(?:\?[^\"'\s<>()#]*)?)",
re.IGNORECASE,
)
def replace_runtime_url(match: re.Match[str]) -> str:
asset = match.group("asset").strip("/")
if asset == "generated-page-logo":
source = generated_logo_path if generated_logo_path and generated_logo_path.is_file() else runtime_dir / "brand-logo-dark.png"
if not source.is_file():
return match.group("url")
archive_path = _generated_logo_destination(source)
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
return archive_path.as_posix()
if asset == "deerflow-runtime.js":
source = runtime_dir / asset
if not source.is_file():
return match.group("url")
archive_path = _RUNTIME_ARCHIVE_ROOT / asset
generated_files[archive_path.as_posix()] = _runtime_script(
runtime_dir,
map_tile_url=map_tile_url,
include_geo_maps=include_geo_maps,
)
bundled_assets.add(archive_path.as_posix())
if include_geo_maps:
for filename in sorted(_GEO_MAP_ASSETS):
map_source = _safe_runtime_source(runtime_dir, f"maps/{filename}")
if map_source is not None:
map_archive_path = _RUNTIME_ARCHIVE_ROOT / "maps" / filename
_add_file(archive_files, map_archive_path, map_source)
bundled_assets.add(map_archive_path.as_posix())
return archive_path.as_posix()
source = _safe_runtime_source(runtime_dir, asset)
if source is None:
return match.group("url")
archive_path = _RUNTIME_ARCHIVE_ROOT / PurePosixPath(asset)
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
license_name = _LICENSE_BY_ASSET.get(asset)
if license_name and (runtime_dir / license_name).is_file():
licenses.add(license_name)
return archive_path.as_posix()
html = runtime_url_re.sub(replace_runtime_url, html)
def replace_html_image(match: re.Match[str]) -> str:
token = match.group("token")
try:
_thread_id, _kind, filename = verify_html_image_token(token)
source = resolve_html_image_path(_thread_id, _kind, filename)
except (FileNotFoundError, ValueError):
return match.group("url")
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
safe_name = Path(filename).name
archive_path = _HTML_IMAGES_ARCHIVE_ROOT / f"{digest}-{safe_name}"
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
return archive_path.as_posix()
html = _HTML_IMAGE_URL_RE.sub(replace_html_image, html)
external_url_set = {
match.group(0).rstrip("),.;")
for match in _EXTERNAL_URL_RE.finditer(html)
}
if uses_leaflet_map and str(map_tile_url).lower().startswith(("http://", "https://")):
external_url_set.add(str(map_tile_url))
external_urls = sorted(external_url_set)
manifest = {
"format": "deerflow-html-offline-bundle-v1",
"entry": "index.html",
"source_name": html_path.name,
"bundled_assets": sorted(bundled_assets),
"external_urls": external_urls,
}
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive.writestr("index.html", html.encode("utf-8"))
archive.writestr(
"manifest.json",
json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"),
)
archive.writestr("README.txt", _readme(html_path.name, external_urls).encode("utf-8"))
for archive_path, source in sorted(archive_files.items()):
archive.write(source, archive_path)
for archive_path, content in sorted(generated_files.items()):
archive.writestr(archive_path, content.encode("utf-8"))
for license_name in sorted(licenses):
archive.write(runtime_dir / license_name, f"THIRD_PARTY_LICENSES/{license_name}")
return OfflineBundleResult(
bundled_assets=tuple(sorted(bundled_assets)),
external_urls=tuple(external_urls),
)
@@ -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:
@@ -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
from typing import Any
@@ -20,6 +21,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"
@@ -122,8 +131,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:
@@ -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",
]
@@ -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:
@@ -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}'."
@@ -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:
@@ -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 == []
@@ -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")
@@ -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()
@@ -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])
@@ -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
@@ -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()
@@ -12,6 +12,7 @@ PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082"
)
SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="64"><rect width="1920" height="64" fill="#8f1d14"/></svg>'
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:
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more