sync update from LAN repo (source master-zg @ 1aec5bce)
This commit is contained in:
1 parent
754b61572c
commit
7053c37a1e
107 files changed
+8083
-386
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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)---
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
+200
@@ -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")}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}」`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+235
-15
@@ -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">
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -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
|
||||
|
||||
|
||||
|
||||
+28
-5
@@ -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
|
||||
|
||||
|
||||
+19
-5
@@ -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:
|
||||
|
||||
+226
-1
@@ -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:
|
||||
|
||||
+321
@@ -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",
|
||||
]
|
||||
+10
-1
@@ -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}'."
|
||||
|
||||
|
||||
+10
-8
@@ -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 == []
|
||||
+25
-1
@@ -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")
|
||||
|
||||
+18
@@ -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
Reference in new issue
Block a user