sync update from LAN repo (source dhk-1 @ c21a2a4a)

This commit is contained in:
gozeros committed 2026-11-10 14:04:00 +08:00
1 parent 264e2200d6
commit a1fa13a238
122 files changed
+8881 -623

No files matched your search

+5
View File
@@ -88,9 +88,14 @@ offline-backend-20260512/backend/skill_evolution.json
# Automatic config.yaml backups written by the admin models page on every save
offline-backend-20260512/backend/.model-config-backups/
skills/
# Harness skill-versioning logic is product source, not a runtime skill package.
!offline-backend-20260512/backend/packages/harness/deerflow/skills/
offline-backend-20260512/backend/packages/harness/deerflow/skills/*
!offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_skill_versioning.py
!offline-backend-20260512/backend/tests/test_large_file_safety.py
!offline-backend-20260512/backend/tests/test_account_authentication.py
# HTML 结构化模板(no.1)引擎/存储/路由回归
+20
View File
@@ -94,6 +94,26 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.py`。
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items.
- **生成网页标识 (Generated Page Branding, admin-only write)**: 管理员在设置弹窗「生成网页标识」中填写网页展示名称、上传 PNG/JPG/WebP Logo(≤10MB)、单独移除 Logo 或整体清除配置。配置保存在 runtime `system_settings.json -> generated_page_branding`,图片保存在 runtime `generated-page-branding/`,`GET /api/system-settings/generated-page-branding` 全员可读,`PUT/DELETE` 仅管理员;生成 HTML 通过公开只读 `/api/public/html-runtime/generated-page-logo` 获取图片。**只有网页展示名称非空且 Logo 文件真实存在时功能才启用**;缺少任意一项(含单独移除 Logo)都不注入任何内容,完全保持原有网页生成逻辑。该配置只约束后续生成的 HTML 网页内容,不修改 DeerFlow 自身的系统名称、主导航、欢迎区、工作区标题或 Logo。启用时,HTML 写入与 `page_report_template validate` 会由 `html_runtime/generated_page_chrome.py` 确定性注入平台套壳:页头**通栏全宽、贴顶、无圆角**,**背景直接用全量模式导航栏设计图**(`html_runtime/assets/topbar-light.svg` 浅色红调 / `topbar-dark.svg` 深色深蓝,已注册进网关 `html_runtime.py` 的 `_STATIC_ASSETS` 白名单以 `/api/public/html-runtime/topbar-*.svg` 提供;页头运行时按 body 明暗 tone 自动切图),**只注入页头、不注入页脚**;设置弹窗不再提供「品牌位置」选择,新配置默认使用 `center`:「Logo+系统名称」居中锁孔(26px 白色金属渐变字 + 深浅辉光,落在设计图中央发光基座上)+ 页面导航**分列两侧**白字页签(当前项白色加粗 + 白色墨条)。`generated_page_branding.header_layout` 字段继续兼容存量配置,既有 `left` 仍按品牌居左 + 导航居中单行渲染。页签均为通栏等高下划线式(当前项主色 + 底部渐变墨条),多页强制首个逻辑页初始可见。开关 `chrome_enabled` **默认开启**;明确关闭时完全回归无注入:不注入页头、也不对页面提任何品牌要求(校验不检查品牌,模型不会被迫自写)。页脚与「网页页脚文字」设置项均已按需求移除(`FOOTER_MARKER` 常量仅为旧页面兼容保留)。模板套件的兜底导航条同款(品牌左 + 导航中、通栏贴顶),平台注入时经 `body:has([data-df-generated-header])` + 兜底脚本双保险自动隐藏。后处理会隐藏旧的多页导航、移除模型写入的 `data-df-brand` 重复标识且保持幂等;lead prompt 只让模型生成正文和逻辑页。注入点共四处且全部**账号无关**(admin 配置对全部账号生效,非 admin 仅无配置入口):**`page_report_template prepare`(工作副本创建时即预注入页头,模型从第一秒起编辑的就是带品牌导航的页面,brief 的 `platform_branding.pre_injected` 标记此状态)**、`write_file`/`str_replace`(`sandbox/tools.py` 的 `maybe_fix_generated_html`)、`page_report_template validate`、以及 `present_files` 交付兜底(`present_file_tool._ensure_generated_html_chrome`,覆盖模型经 bash 等旁路写出的 HTML;幂等,>4MB 跳过);注入时还会**归一化 body/html 顶部 padding/margin(清零)**(防弱模型为自想页头留白)并**隐藏模型自写的顶部 nav/header**(仅正文首个内容容器之前的,正文内导航不受影响);品牌配置每次实时读取 `system_settings.json`,无缓存无账号隔离。
- **生成网页默认明暗模式 (Generated Page Theme)**: `ChatPage` 与 `AgentChatPage` 每次发送只把当前界面的明暗模式作为 `presentation_theme=light|dark` 上下文传入。用户未明确指定网页主题时,自由生成 HTML、显式模板的 `auto` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。
- **生成网页离线下载包 (Generated HTML Offline Bundle)**: 产物面板下载 `.htm/.html/.xhtml` 时不再只保存单个 HTML,而是请求 `GET /api/threads/{thread_id}/artifacts/{path}?download=true&offline_bundle=true`(公开只读分享的文件端点同样支持),下载 `{原名}-离线包.zip`。纯引擎 `deerflow/html_runtime/offline_bundle.py` 把入口统一为 `index.html`,只将平台信任的 `/api/public/html-runtime/*` JS/CSS/GeoJSON/品牌资源、认知态势图运行目录和签名 `/api/public/html-images/*` 图片复制到 `assets/` 并改写为相对地址;不得联网抓取任意第三方 URL。包内还带 `manifest.json`、中文 `README.txt` 和实际使用库的许可证。Leaflet 在线瓦片及用户主动引用的公网图片/API 不复制,保留原地址并列入 `external_urls`。非 HTML 下载、HTML 预览/新窗口打开及流式未落盘的临时下载行为不变。
- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。顶部导航名称字号可选 `adaptive`(默认,30px 上限、24px 下限)或 `fixed`(16–40px);配置字段为 `app_identity.header_title_font_mode/header_title_font_size`,固定模式不再按分辨率缩放,超长名称仍单行完整显示。红色主题还可分别上传导航栏背景图与菜单选中样式图(PNG/JPG/WebP/SVG,不设应用层文件大小限制),字段为 `light_red_header_background_*` / `light_red_nav_active_*`;选择文件或恢复默认时顶部导航即时预览,未配置时继续使用 `top-bg-light-red.svg` / `nav-active-light-red.svg`。其余配置在 `system_settings.json -> app_identity`,图片统一放在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片通过 `/api/public/app-identity/*` 读取。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。
- **平台模板套件 (Bootstrap page-template-suite 技能)**: 内置技能 `skills/public/page-template-suite`(public 类,缺省启用)让「画页面」走平台预制 Bootstrap 模板而不是从零手写。**套件只提供多页面门户(最低 2 个逻辑页),不做单页面 HTML**(单页请求自动回落共享响应式文档模板):5 套设计 × 明暗两版共 10 个 `deerflow/page_reports/assets/report-bootstrap-*-template.html`(图表门户 3 页 / 甘特双页「进度总览+里程碑风险」/ 瓦片地图门户 3 页 / 文字杂志双页「正文论述+脉络结论」/ 研判简报双页「核心结论+风险行动」),profile 注册于 `page_reports/templates.py`(id `bootstrap-charts-portal-{light,dark}`、`bootstrap-gantt-{light,dark}`、`bootstrap-map-portal-{light,dark}`、`bootstrap-text-magazine-{light,dark}`、`bootstrap-text-briefing-{light,dark}`)。显式 `template_id` 时**页数以模板为准**(prepare 会把 brief 的页数对齐到模板页数,技能无需依赖用户说出「多页」)。模板源文件用 `__DF_HTML_RUNTIME_BASE__` 占位符引用 Gateway 运行库(Bootstrap/Alpine/ECharts/Leaflet/deerflow-runtime.js),`page_report_template prepare` 在会话副本中替换为设置里保存的 HTML 运行库地址,部署无关;图表走 `data-df-chart` 严格 JSON(甘特 = 时间轴 + 阶段重叠柱 `barGap:-100%`),地图门户走 `data-df-map` Leaflet 瓦片契约(瓦片地址来自设置里保存的地图瓦片地址,需管理员在「外观设置 → HTML 地图」选择「地图网页」引擎,引擎不符时 prepare 的 brief 会带 `map_engine_warning`)。prepare 的 brief 还带 `platform_branding`(管理员「生成网页标识」的名称/Logo/页脚文字与注入规则,正文禁止自写品牌)和 `runtime_base_url`;`_choose_page_profile` 现在**显式 `template_id` 优先**(单页文档模式也能选中套件模板,此前单页一律回落共享文档模板),未显式指定时旧选择行为不变。多页门户模板自带锚点切页兜底脚本(检测 `data-df-generated-header` 让位平台套壳,勿用 `data-df-generated-chrome` 字面量——会被注入幂等检查误判)。测试 `tests/test_page_template_suite.py`(10 模板 prepare→validate 全绿 + 品牌注入 + 居左/居中布局 + 引擎门控)。
- **Docker 内置技能同步**: Linux Docker 启动时不能让旧的持久化 `/app/skills` 遮住当前版本的 `backend/skills/public`。共享实现 `app/builtin_skill_sync.py` 会把 bundled public 覆盖同步到 live public 根、保留 `custom/`,并强制校验 Facebook/X/TikTok/YouTube/Threads/CNN/BBC/新华社/白宫官网/马尼拉时报/GMA News/雅虎日本/读卖新闻共 13 个硬路由页面技能;它使用跨进程文件锁和必需包内容摘要,多 worker 首次只复制一次,后续相同版本只校验跳过。两个受支持启动脚本仍在 Gateway 前调用 `backend/scripts/sync_builtin_skills.py`,把当前源码 `backend/skills` 只读挂载为 `/app/bundled-skills`、live skills 读写挂载到 `/app/skills`;同时 Gateway lifespan 在加载配置后、技能 ownership 迁移/MySQL 目录投影/提示词缓存之前再次调用同一共享实现,所以镜像默认入口、Compose 或外部发版脚本绕过 Shell 时也会自动补齐。任一硬路由技能缺失会 fail-fast 并打印名单,不能静默启动成“技能不可用”。
- **自定义技能迭代与版本切换**: 管理员设置弹窗的「自定义技能迭代」总开关默认关闭(`GET/PUT /api/system-settings/skill-versioning`);关闭时保持原同名覆盖逻辑。开启后仅 `skills/custom` 参与:本人上传同名技能可选「迭代为新版本」(保留完整历史包并激活新版)或「直接覆盖」(清除旧版本链,以本次内容重新作为 v1);本人技能详情顶部也提供「迭代新版本」安装包入口,详情弹窗「版本」页可切换任一快照。历史包保存在 runtime `skill-versions/<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`。
- **YouTube 页面生成技能**: 内置公共技能 `skills/public/youtube-page-generator` 只在用户明确要求 YouTube 或 YouTube 风格视频门户、视频首页、频道页时触发。默认只写小型七模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_youtube_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、分类筛选、搜索、响应式视频网格、Shorts 横向列表、观看弹窗、点赞、收藏、订阅和评论;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_youtube_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **Threads 页面生成技能**: 内置公共技能 `skills/public/threads-page-generator` 只在用户明确要求 Threads 或 Threads 风格串文首页、文本社交信息流时触发。默认只写 `accountName`、`postText`、`topic`、`displayReplyCount` 等高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动映射六模块、补齐作者/统计/中文热评、保留示例视觉并一次完成校验与渲染,禁止模型猜测 `$set` 深层路径或读取默认数据。只有结构改造才回退 `scripts/render_threads_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板和移动端导航;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_threads_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **CNN 页面生成技能**: 内置公共技能 `skills/public/cnn-page-generator` 只在用户明确要求 CNN 或 CNN 风格全球新闻门户、新闻首页时触发。默认只写以 `topic` 为核心、可选 `headline`/`breaking`/栏目标题数组的高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动生成完整中文十模块与明确标注的非实时示意数据,禁止模型猜测 `$set` 深层路径、读取生成后的合并数据或探路后二次渲染。只有结构改造才回退 `scripts/render_cnn_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_cnn_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **BBC 页面生成技能**: 内置公共技能 `skills/public/bbc-page-generator` 只在用户明确要求 BBC 或 BBC 风格国际公共新闻门户、新闻首页时触发。默认只写小型十模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_bbc_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_bbc_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **新华社页面生成技能**: 内置公共技能 `skills/public/xinhua-page-generator` 只在用户明确要求新华社、新华网或新华社风格中文通讯社门户、新闻首页时触发。默认只写小型十模块中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_xinhua_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_xinhua_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **白宫官网页面生成技能**: 内置公共技能 `skills/public/white-house-page-generator` 只在用户明确要求白宫官网、White House 风格政府首页或美国行政机构门户时触发。技能把材料整理为 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_white_house_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈;默认内容明确为概念模板,不冒充政府官网,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_white_house_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **马尼拉时报页面生成技能**: 内置公共技能 `skills/public/manila-times-page-generator` 只在用户明确要求马尼拉时报、The Manila Times 或其风格的菲律宾大报门户时触发。技能把材料整理为 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_manila_times_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、菲律宾旗色带、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_manila_times_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **GMA News 页面生成技能**: 内置公共技能 `skills/public/gma-news-page-generator` 只在用户明确要求 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面时触发。技能把材料整理为 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可保留,其余读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_gma_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、即时新闻条、天气切换、吸顶导航、头条视频、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、播客、彩票开奖和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_gma_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **雅虎日本页面生成技能**: 内置公共技能 `skills/public/yahoo-japan-page-generator` 只在用户明确要求雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面时触发。技能把材料整理为 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yahoo_japan_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多类型搜索、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yahoo_japan_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **读卖新闻页面生成技能**: 内置公共技能 `skills/public/yomiuri-news-page-generator` 只在用户明确要求读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面时触发。技能把材料整理为 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yomiuri_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类切换、社论、视频字幕和邮件订阅;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yomiuri_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
+3
View File
@@ -62,6 +62,9 @@
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# 知识库视图切换:true=显示“列表 / 卡片”切换;false 或未设=固定为列表(默认)。
# 部署后优先改 dist/runtime-config.js 的 VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED,刷新即可。
# VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED=false
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+4
View File
@@ -147,6 +147,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false",
// 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表;
// 改为 true 并刷新页面后,才允许用户切换到卡片视图。
VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false",
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
@@ -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\);/,
@@ -0,0 +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,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

After

Width:  |  Height:  |  Size: 193 KiB

@@ -443,6 +443,25 @@
background-size: 152px 42px;
}
/* 浅红科技感顶栏保持深色版的尺寸与排布,只替换文字光效和选中底图。 */
.strategy-app-header--light-red-top-nav .top-submenu-item {
color: rgba(255, 255, 255, 0.86);
}
.strategy-app-header--light-red-top-nav .top-submenu-item:hover {
color: #ffffff;
text-shadow: 0 0 10px rgba(196, 51, 40, 0.4);
}
.strategy-app-header--light-red-top-nav .top-submenu-item--active {
color: #ffffff;
text-shadow: 0 0 12px rgba(177, 38, 28, 0.48);
background-image: var(
--light-red-nav-active-background-image,
url("../assets/img/nav-active-light-red.svg")
);
}
.third-submenu-bar {
position: relative;
z-index: 34;
@@ -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)}>
@@ -92,6 +92,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";
@@ -1557,6 +1558,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
<Separator />
<SkillVersioningSettingsPage embedded />
</>
)}
</div>
@@ -0,0 +1,68 @@
"use client";
import { BarChart3Icon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useCallMetricsSettings,
useUpdateCallMetricsSettings,
} from "@/core/system-settings/hooks";
export function CallMetricsSettingsPage() {
const { data, isError, isLoading } = useCallMetricsSettings();
const update = useUpdateCallMetricsSettings();
const enabled = data?.enabled ?? true;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "工具与技能调用统计已开启" : "工具与技能调用统计已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BarChart3Icon className="size-5" />
工具与技能调用统计
</h3>
<p className="text-muted-foreground text-sm">
为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、
技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">记录工具与技能调用</div>
<p className="text-muted-foreground text-sm">
关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="记录工具与技能调用统计"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取调用统计开关,系统将按当前部署配置执行。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。
</p>
</section>
);
}
@@ -0,0 +1,200 @@
"use client";
import { AlertCircleIcon, Loader2Icon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { buildWikiKnowledgeBaseCreateInput } from "@/core/llmwiki/knowledge-base-form";
import {
useLlmWikiActions,
useLlmWikiEmbeddingModels,
} from "@/strategy-components/api/llmwiki";
import { SettingsSection } from "./settings-section";
type BatchCreateResult = {
createdNames: string[];
failed: Array<{ name: string; message: string }>;
};
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function parseKnowledgeBaseNames(value: string): string[] {
const seen = new Set<string>();
return value
.split(/[,,\n\r]+/)
.map((name) => name.trim())
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
});
}
export function KnowledgeBatchCreateSettingsPage() {
const actions = useLlmWikiActions();
const [namesText, setNamesText] = useState("");
const [embeddingModelId, setEmbeddingModelId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<BatchCreateResult | null>(null);
const embeddingModels = useLlmWikiEmbeddingModels();
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
const names = useMemo(() => parseKnowledgeBaseNames(namesText), [namesText]);
useEffect(() => {
if (!embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data]);
const modelSelectionUnavailable =
embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const suffix = [model.provider?.trim(), model.model_name?.trim()]
.filter(Boolean)
.join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const handleCreate = async () => {
if (names.length === 0 || modelSelectionUnavailable) return;
setSubmitting(true);
setResult(null);
actions.create.reset();
const nextResult: BatchCreateResult = { createdNames: [], failed: [] };
for (const name of names) {
try {
await actions.create.mutateAsync(buildWikiKnowledgeBaseCreateInput({
name,
description: "",
embeddingModelId,
wikiConfig: {},
}));
nextResult.createdNames.push(name);
} catch (error) {
nextResult.failed.push({ name, message: messageOf(error) });
}
}
setResult(nextResult);
setSubmitting(false);
if (nextResult.createdNames.length > 0) {
toast.success(`已创建 ${nextResult.createdNames.length} 个知识库`);
}
if (nextResult.failed.length > 0) {
setNamesText(nextResult.failed.map((item) => item.name).join(","));
toast.error(`${nextResult.failed.length} 个知识库创建失败,可修改后重试`);
} else {
setNamesText("");
}
};
return (
<SettingsSection
title="批量新增知识库"
description="输入多个知识库名称后,系统会在浏览器端按顺序逐个创建到你的个人知识库。"
>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-names">
知识库名称
</label>
<Textarea
id="knowledge-batch-names"
value={namesText}
onChange={(event) => setNamesText(event.target.value)}
rows={7}
maxLength={10000}
disabled={submitting}
placeholder="例如:产品手册,项目资料库,常见问题"
/>
<p className="text-xs text-muted-foreground">
使用中文逗号、英文逗号或换行分隔;重复名称会自动忽略。已识别 {names.length} 个名称。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-embedding-model">
原文向量模型
</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground">
<Loader2Icon className="mr-2 size-4 animate-spin" />正在读取知识服务模型
</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive" role="alert">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>
重新读取
</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={submitting}>
<SelectTrigger id="knowledge-batch-embedding-model" className="w-full">
<SelectValue placeholder="请选择向量模型" />
</SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
知识服务中没有可用的 Embedding 模型,请先在知识服务中启用向量模型。
</div>
)}
</div>
{result && (
<div
className={result.failed.length
? "rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
: "rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-300"}
role={result.failed.length ? "alert" : "status"}
>
{result.createdNames.length > 0 && <p className="font-medium">已创建 {result.createdNames.length} 个知识库</p>}
{result.failed.length > 0 && (
<>
<p className="flex items-center gap-2 font-medium"><AlertCircleIcon className="size-4" />{result.failed.length} 个名称未创建</p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs leading-5">
{result.failed.slice(0, 5).map((item) => <li key={item.name}>{item.name}:{item.message}</li>)}
{result.failed.length > 5 && <li>其余 {result.failed.length - 5} 个失败项已保留在输入框中。</li>}
</ul>
</>
)}
</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleCreate()} disabled={submitting || names.length === 0 || modelSelectionUnavailable}>
{submitting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
{submitting ? "正在创建" : `创建${names.length ? `(${names.length})` : ""}`}
</Button>
</div>
</div>
</SettingsSection>
);
}
@@ -25,6 +25,9 @@ export function RoundtableRoomSettingsPage() {
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
);
const [leaseMinutes, setLeaseMinutes] = useState(
String(data?.lease_minutes ?? 5),
);
const [maxCycles, setMaxCycles] = useState(String(data?.max_cycles ?? 8));
const [activeCount, setActiveCount] = useState<number | null>(null);
@@ -38,6 +41,12 @@ export function RoundtableRoomSettingsPage() {
}
}, [data?.max_hold_minutes]);
useEffect(() => {
if (data?.lease_minutes != null) {
setLeaseMinutes(String(data.lease_minutes));
}
}, [data?.lease_minutes]);
useEffect(() => {
if (data?.max_cycles != null) setMaxCycles(String(data.max_cycles));
}, [data?.max_cycles]);
@@ -65,6 +74,7 @@ export function RoundtableRoomSettingsPage() {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
lease_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}) => {
@@ -94,6 +104,15 @@ export function RoundtableRoomSettingsPage() {
void save({ max_hold_minutes: parsed });
};
const saveLeaseMinutes = () => {
const parsed = Number(leaseMinutes);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 60) {
toast.error("房间租约请填写 1 到 60 的整数");
return;
}
void save({ lease_minutes: parsed });
};
const saveMaxCycles = () => {
const parsed = Number(maxCycles);
if (!Number.isInteger(parsed) || parsed < 2 || parsed > 100) {
@@ -111,7 +130,7 @@ export function RoundtableRoomSettingsPage() {
会商并发与收口
</h3>
<p className="text-muted-foreground text-sm">
配置第二步的全站并发名额、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
配置第二步的全站并发名额、房间租约、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
</p>
</div>
@@ -187,6 +206,39 @@ export function RoundtableRoomSettingsPage() {
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<Clock3Icon className="size-4" />
房间租约
</span>
<p className="text-muted-foreground text-sm">
默认 5 分钟,可改为 1 到 60 分钟。页面大约每 15 秒续一次;超过这个时间没有续上,房间会释放。保存后,下一次准入和心跳立即按新时长续租。它不会延长单个房间的最长占用时间。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
max={60}
value={leaseMinutes}
disabled={isError || update.isPending}
onChange={(event) => setLeaseMinutes(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间租约分钟数"
/>
<span className="text-muted-foreground text-sm">分钟</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveLeaseMinutes}
>
保存租约
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
@@ -1,8 +1,9 @@
"use client";
import {
BellIcon,
BarChart3Icon,
BrainIcon,
BookPlusIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
@@ -32,6 +33,7 @@ import { AccountSettingsPage } from "@/components/workspace/settings/account-set
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { CallMetricsSettingsPage } from "@/components/workspace/settings/call-metrics-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
@@ -39,6 +41,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";
@@ -60,9 +63,11 @@ type SettingsSection =
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "knowledge-batch-create"
| "appIdentity"
| "memory"
| "fileWriting"
| "callMetrics"
| "retryKeepPartial"
| "uploads"
| "tools"
@@ -115,10 +120,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
{ id: "appIdentity", label: "系统名称", icon: TypeIcon },
{ 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: "knowledge-batch-create", label: "批量新增知识库", icon: BookPlusIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -210,6 +217,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "knowledge-batch-create" && (
<KnowledgeBatchCreateSettingsPage />
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
@@ -219,6 +229,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "callMetrics" && isAdmin && (
<CallMetricsSettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
@@ -33,6 +33,11 @@ import {
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";
@@ -74,6 +79,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,
@@ -99,6 +105,7 @@ import {
import {
usePublishSquares,
useSkillCompressionSettings,
useSkillVersioningSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import {
@@ -155,6 +162,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: {
@@ -205,6 +225,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.
@@ -227,6 +248,19 @@ function SkillSettingsList({
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
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
@@ -238,8 +272,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 状态)。
@@ -248,7 +290,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 =
@@ -279,7 +321,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.
@@ -334,7 +376,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.
@@ -562,7 +604,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
@@ -749,8 +804,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) {
@@ -1076,8 +1131,8 @@ export function SkillRow({
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
@@ -1088,8 +1143,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
@@ -1322,9 +1377,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),
)}
@@ -1345,7 +1400,7 @@ export function SkillRow({
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
@@ -1418,6 +1473,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();
@@ -1506,10 +1562,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}",
@@ -1564,6 +1628,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 =
@@ -1789,6 +1857,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" />
@@ -1810,27 +1884,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>
</>
)}
@@ -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>
</>
);
}
@@ -4,18 +4,18 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
className,
threadId,
thread,
}: {
export function ThreadTitle({
className,
threadId,
thread,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
@@ -51,9 +51,9 @@ export function ThreadTitle({
if (!thread.values?.title) {
return null;
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
@@ -5,6 +5,8 @@ import { useMemo } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { AuroraText } from "../ui/aurora-text";
@@ -12,11 +14,15 @@ import { AuroraText } from "../ui/aurora-text";
export function Welcome({
className,
mode,
pageDescription,
}: {
className?: string;
mode?: "ultra" | "pro" | "thinking" | "flash";
/** The actual interface mode is resolved by ChatPage. */
pageDescription?: GeneralQaPageDescription;
}) {
const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
const desensitizeText = useDisplayDesensitizer();
const searchParams = useSearchParams();
const isUltra = useMemo(() => mode === "ultra", [mode]);
@@ -26,6 +32,9 @@ export function Welcome({
}
return ["var(--color-foreground)"];
}, [isUltra]);
const configured = pageDescription ?? pageDescriptions?.general_qa.full;
const normalTitle = configured?.welcome_title || t.welcome.greeting;
const normalDescription = configured?.welcome_description || "";
return (
<div
className={cn(
@@ -38,7 +47,7 @@ export function Welcome({
t.welcome.createYourOwnSkill
) : (
<div className="flex items-center">
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
<AuroraText colors={colors}>{desensitizeText(normalTitle)}</AuroraText>
</div>
)}
</div>
@@ -53,15 +62,11 @@ export function Welcome({
)} */}
</div>
) : (
<div className="text-muted-foreground text-sm">
{/* {t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.description}
</pre>
) : (
<p>{t.welcome.description}</p>
)} */}
</div>
normalDescription ? (
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{desensitizeText(normalDescription)}
</p>
) : null
)}
</div>
);
@@ -10,6 +10,7 @@ import {
Compass,
CpuIcon,
DatabaseZapIcon,
FileTextIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
@@ -318,6 +319,16 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/page-descriptions")
}
>
<FileTextIcon />
页面描述配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
+58
View File
@@ -94,6 +94,33 @@ export interface AdminLeaderboardDimension {
last_seen_at: string;
}
export type AdminLeaderboardDetailKind = "skill" | "tool" | "agent";
export interface AdminLeaderboardDetailItem {
name: string;
count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailSummary {
count: number;
item_count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailResponse {
kind: AdminLeaderboardDetailKind;
range_days: number;
since: string;
until: string;
summary: AdminLeaderboardDetailSummary;
items: AdminLeaderboardDetailItem[];
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
@@ -248,6 +275,37 @@ export async function getUserLeaderboard(
return result;
}
export async function getUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind,
range: {
since: string;
until: string;
days?: number;
},
limit = 500,
): Promise<AdminLeaderboardDetailResponse> {
const params = new URLSearchParams({
kind,
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/leaderboard/details?${params}`;
const result = await parse<AdminLeaderboardDetailResponse>(
await apiFetch(url),
"Failed to load leaderboard details",
);
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-管理后台-查询排行榜维度详情",
url,
reqParam: { kind, ...range, limit },
returnParam: { itemCount: result.items.length },
});
return result;
}
/**
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`.
+26
View File
@@ -8,6 +8,7 @@ import {
generateUserLeaderboardReport,
getUserMemory,
getUserLeaderboard,
getUserLeaderboardDetails,
refreshTodayLeaderboard,
listUserQa,
listUserThreadMessages,
@@ -16,6 +17,7 @@ import {
updateAdminV1Fact,
updateAdminV1Section,
} from "./api";
import type { AdminLeaderboardDetailKind } from "./api";
import {
cancelActiveRun,
cancelAllActiveRuns,
@@ -131,6 +133,30 @@ export function useUserLeaderboard(
});
}
/** Expanded, fresh detail data shown from a leaderboard card's “more” drawer. */
export function useUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind | null,
range: {
since: string;
until: string;
days?: number;
},
enabled: boolean,
) {
return useQuery({
queryKey: [
"admin-activity",
"leaderboard-details",
kind,
range.since,
range.until,
],
queryFn: () => getUserLeaderboardDetails(kind!, range),
enabled: enabled && !!kind,
refetchOnWindowFocus: false,
});
}
/**
* Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers.
+11 -1
View File
@@ -53,7 +53,17 @@ export async function downloadArtifactFile({
isMock?: boolean;
publicShareCode?: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
const isHtmlArtifact = /\.(?:html?|xhtml)$/i.test(getFileName(filepath));
const url = urlOfArtifact({
filepath,
threadId,
isMock,
publicShareCode,
download: true,
// Older deployments and mock endpoints safely ignore this query and return
// the original HTML, which preserves backwards compatibility.
offlineBundle: isHtmlArtifact && !isMock,
});
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
+10 -3
View File
@@ -5,28 +5,35 @@ export function urlOfArtifact({
filepath,
threadId,
download = false,
offlineBundle = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
download?: boolean;
offlineBundle?: boolean;
isMock?: boolean;
publicShareCode?: string;
}) {
const query = new URLSearchParams();
if (download) query.set("download", "true");
if (offlineBundle) query.set("offline_bundle", "true");
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
)}/files${normalized}${suffix}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearHeaderTitlePreview,
getHeaderTitlePreview,
setHeaderTitlePreview,
subscribeHeaderTitlePreview,
} from "./header-title-preview";
test("publishes live header title previews and clears unsaved values", () => {
clearHeaderTitlePreview();
let updates = 0;
const unsubscribe = subscribeHeaderTitlePreview(() => {
updates += 1;
});
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.deepEqual(getHeaderTitlePreview(), {
mode: "fixed",
fontSize: 34,
});
assert.equal(updates, 1);
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.equal(updates, 1);
clearHeaderTitlePreview();
assert.equal(getHeaderTitlePreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,35 @@
export type HeaderTitlePreview = {
mode: "adaptive" | "fixed";
fontSize: number;
};
let currentPreview: HeaderTitlePreview | null = null;
const listeners = new Set<() => void>();
export function getHeaderTitlePreview(): HeaderTitlePreview | null {
return currentPreview;
}
export function subscribeHeaderTitlePreview(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setHeaderTitlePreview(preview: HeaderTitlePreview): void {
if (
currentPreview?.mode === preview.mode &&
currentPreview.fontSize === preview.fontSize
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearHeaderTitlePreview(): void {
if (currentPreview === null) {
return;
}
currentPreview = null;
listeners.forEach((listener) => listener());
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearLightRedNavigationPreview,
getLightRedNavigationPreview,
setLightRedNavigationPreview,
subscribeLightRedNavigationPreview,
} from "./light-red-navigation-preview";
test("publishes temporary red navigation image URLs and clears them", () => {
clearLightRedNavigationPreview();
let updates = 0;
const unsubscribe = subscribeLightRedNavigationPreview(() => {
updates += 1;
});
setLightRedNavigationPreview({
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.deepEqual(getLightRedNavigationPreview(), {
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.equal(updates, 1);
clearLightRedNavigationPreview();
assert.equal(getLightRedNavigationPreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,37 @@
export interface LightRedNavigationPreview {
headerBackgroundUrl: string | null;
navActiveUrl: string | null;
}
type Listener = () => void;
let currentPreview: LightRedNavigationPreview | null = null;
const listeners = new Set<Listener>();
export function getLightRedNavigationPreview() {
return currentPreview;
}
export function subscribeLightRedNavigationPreview(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setLightRedNavigationPreview(
preview: LightRedNavigationPreview,
) {
if (
currentPreview?.headerBackgroundUrl === preview.headerBackgroundUrl &&
currentPreview.navActiveUrl === preview.navActiveUrl
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearLightRedNavigationPreview() {
if (currentPreview === null) return;
currentPreview = null;
listeners.forEach((listener) => listener());
}
+3 -1
View File
@@ -498,7 +498,7 @@ export const enUS: Translations = {
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeOffline: "Offline",
badgeOffline: "Offline",
badgeOfflineTitle: "Retained in the system but unavailable to models",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
@@ -541,6 +541,8 @@ export const enUS: Translations = {
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
introSectionTitle: "Overview",
introEmpty: "No skill overview yet.",
detailSectionTitle: "Details",
detailEmpty: "No details yet.",
detailEditAction: "Edit details",
@@ -471,6 +471,8 @@ export interface Translations {
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
introSectionTitle: string;
introEmpty: string;
detailSectionTitle: string;
detailEmpty: string;
detailEditAction: string;
+4 -2
View File
@@ -477,8 +477,8 @@ export const zhCN: Translations = {
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
@@ -519,6 +519,8 @@ export const zhCN: Translations = {
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
introSectionTitle: "简介",
introEmpty: "暂无技能简介。",
detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情",
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping runner requires the explicit suffix.
import { filterSkillsByCreatedAt } from "./skill-created-at-filter.ts";
const skills = [
{ name: "before", created_at: "2026-10-31T23:59:59" },
{ name: "start", created_at: "2026-11-01T00:00:00" },
{ name: "middle", created_at: "2026-11-05T12:00:00" },
{ name: "end", created_at: "2026-11-07T23:59:59" },
{ name: "after", created_at: "2026-11-08T00:00:00" },
{ name: "unknown", created_at: null },
];
test("keeps the original list when no complete range is selected", () => {
assert.equal(filterSkillsByCreatedAt(skills, []), skills);
assert.equal(filterSkillsByCreatedAt(skills, ["2026-11-01"]), skills);
});
test("filters creation time inclusively by local calendar day", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-01", "2026-11-07"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
test("accepts a reversed range without changing its meaning", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-07", "2026-11-01"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
@@ -0,0 +1,42 @@
import type { Skill } from "./skills/type";
export type SkillCreatedAtRange = readonly (string | number | Date)[];
function localDayStart(value: string | number | Date) {
let date: Date;
if (typeof value === "string") {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
date = dateOnly
? new Date(
Number(dateOnly[1]),
Number(dateOnly[2]) - 1,
Number(dateOnly[3]),
)
: new Date(value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return null;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/** Filter skills by an inclusive local-calendar creation date range. */
export function filterSkillsByCreatedAt<T extends Pick<Skill, "created_at">>(
skills: T[],
range: SkillCreatedAtRange,
): T[] {
if (range.length < 2) return skills;
const first = localDayStart(range[0]);
const second = localDayStart(range[1]);
if (first === null || second === null) return skills;
const start = Math.min(first, second);
const endDate = new Date(Math.max(first, second));
endDate.setHours(23, 59, 59, 999);
const end = endDate.getTime();
return skills.filter((skill) => {
if (!skill.created_at) return false;
const createdAt = new Date(skill.created_at).getTime();
return !Number.isNaN(createdAt) && createdAt >= start && createdAt <= end;
});
}
+50 -1
View File
@@ -159,6 +159,24 @@ export interface InstallSkillResponse {
message: string;
overwritten?: boolean;
}
export interface SkillVersionEntry {
version: number;
sha256: string;
created_at: string;
created_by?: string | null;
action: string;
summary?: string | null;
based_on_version?: number | null;
active: boolean;
}
export interface SkillVersionsResponse {
enabled: boolean;
skill_name: string;
active_version?: number | null;
versions: SkillVersionEntry[];
}
export interface PreviewSkillInstallRequest {
thread_id: string;
@@ -271,13 +289,18 @@ export async function validateSkillUpload(
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
options: {
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
} = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
if (options.installMode) params.set("install_mode", options.installMode);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
@@ -289,6 +312,32 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export async function getSkillVersions(
skillName: string,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill versions");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function activateSkillVersion(
skillName: string,
version: number,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions/${version}/activate`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to activate skill version");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
+52 -4
View File
@@ -10,6 +10,7 @@ import {
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
activateSkillVersion,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
@@ -17,6 +18,7 @@ import {
getSkillContent,
getSkillFileContent,
getSkillFiles,
getSkillVersions,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
@@ -41,17 +43,24 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams & { enabled?: boolean } = {}) {
export function useSkills(
params: ListSkillsParams & {
enabled?: boolean;
refetchOnMount?: boolean | "always";
} = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const enabled = params.enabled ?? true;
const refetchOnMount = params.refetchOnMount;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
enabled,
staleTime: 30_000,
refetchOnMount,
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -224,10 +233,49 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
mutationFn: ({ file, overwrite, targetCategory, installMode }: {
file: File;
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
}) => installSkillUpload(file, { overwrite, targetCategory, installMode }),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({
queryKey: ["skill-versions", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-content", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-files", result.skill_name],
});
},
});
}
export function useSkillVersions(skillName: string | null, enabled: boolean) {
return useQuery({
queryKey: ["skill-versions", skillName],
queryFn: () => getSkillVersions(skillName!),
enabled: enabled && !!skillName,
});
}
export function useActivateSkillVersion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, version }: { skillName: string; version: number }) =>
activateSkillVersion(skillName, version),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill-versions", skillName], data);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
},
});
}
+230 -3
View File
@@ -32,6 +32,86 @@ export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export interface GeneralQaPageDescription {
welcome_title: string;
welcome_description: string;
input_placeholder: string;
}
export interface GeneralQaPageDescriptions {
full: GeneralQaPageDescription;
compact: GeneralQaPageDescription;
}
export interface PageHeaderDescription {
title: string;
description: string;
}
export interface AIWritingGuideCard {
title: string;
description: string;
}
export interface AIWritingPageDescription extends PageHeaderDescription {
guide_cards: AIWritingGuideCard[];
}
export interface PageDescriptionsSettings {
general_qa: GeneralQaPageDescriptions;
knowledge_base: PageHeaderDescription;
ai_writing: AIWritingPageDescription;
strategy_projects: PageHeaderDescription;
deep_research: PageHeaderDescription;
gongwen: PageHeaderDescription;
light_app_management: PageHeaderDescription;
light_app_list: PageHeaderDescription;
agents: PageHeaderDescription;
business_chains: PageHeaderDescription;
skills: PageHeaderDescription;
knowledge_management: PageHeaderDescription;
}
export type PageDescriptionsUpdate = PageDescriptionsSettings;
export async function loadPageDescriptionsSettings(): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
);
if (!response.ok) {
throw new Error(`无法读取页面描述配置(HTTP ${response.status})`);
}
return (await response.json()) as PageDescriptionsSettings;
}
export async function updatePageDescriptionsSettings(
body: PageDescriptionsUpdate,
): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存页面描述配置失败" }));
throw new Error(detail.detail ?? "保存页面描述配置失败");
}
const data = (await response.json()) as PageDescriptionsSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-页面描述配置编辑",
url: "/api/system-settings/page-descriptions",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
@@ -311,17 +391,33 @@ export interface HtmlRuntimeSettings {
map_tile_url: string;
}
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 {
@@ -721,8 +817,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 },
@@ -740,8 +850,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,
});
@@ -772,10 +891,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 async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
@@ -867,6 +992,10 @@ export interface SkillCompressionUpdate {
keep_index_in_prompt?: boolean;
}
export interface SkillVersioningSettings {
enabled: boolean;
}
export interface ModelConcurrencySettings {
enabled: boolean;
default_limit: number;
@@ -906,6 +1035,7 @@ export interface RoundtableRoomLimitSettings {
enabled: boolean;
max_rooms: number;
max_hold_minutes: number;
lease_minutes: number;
max_cycles: number;
write_file_compatibility_enabled: boolean;
}
@@ -914,6 +1044,7 @@ export interface RoundtableRoomLimitUpdate {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
lease_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}
@@ -1012,6 +1143,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`,
@@ -1155,6 +1335,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`,
@@ -1230,6 +1429,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;
@@ -6,6 +6,7 @@ import {
loadAgentModuleConfig,
loadAssistantKnowledgeVisibility,
loadAppearanceSettings,
loadCallMetricsSettings,
loadCitationDisplaySettings,
loadConversationContextSettings,
loadRoundtableRoomLimitSettings,
@@ -15,6 +16,7 @@ import {
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadOrdinaryKnowledgeRetrievalSettings,
loadPageDescriptionsSettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
@@ -22,11 +24,13 @@ import {
loadPublishSquares,
loadRetryKeepPartialSettings,
loadSkillCompressionSettings,
loadSkillVersioningSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
loadWriteFileCompatibilitySettings,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCallMetricsSettings,
updateCitationDisplaySettings,
updateConversationContextSettings,
updateRoundtableRoomLimitSettings,
@@ -36,6 +40,7 @@ import {
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateOrdinaryKnowledgeRetrievalSettings,
updatePageDescriptionsSettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
@@ -43,6 +48,7 @@ import {
updatePublishSquares,
updateRetryKeepPartialSettings,
updateSkillCompressionSettings,
updateSkillVersioningSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
updateWriteFileCompatibilitySettings,
@@ -50,6 +56,7 @@ import {
deleteAppIdentitySettings,
type AppearanceUpdate,
type AppIdentityUpdate,
type CallMetricsSettings,
type CitationDisplayUpdate,
type ConversationContextUpdate,
type RoundtableRoomLimitUpdate,
@@ -58,6 +65,7 @@ import {
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type OrdinaryKnowledgeRetrievalUpdate,
type PageDescriptionsUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
@@ -65,6 +73,7 @@ import {
type PublishSquaresUpdate,
type RetryKeepPartialSettings,
type SkillCompressionUpdate,
type SkillVersioningSettings,
type StrategyProjectSettingsUpdate,
type LayoutMode,
type ViewMode,
@@ -81,12 +90,14 @@ 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;
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 = [
@@ -95,6 +106,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",
@@ -145,6 +157,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,
@@ -225,6 +258,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,
@@ -554,6 +609,29 @@ export function useUpdateSkillCompressionSettings() {
});
}
/** Deployment-wide custom-skill versioning feature gate. */
export function useSkillVersioningSettings() {
return useQuery({
queryKey: SKILL_VERSIONING_KEY,
queryFn: () => loadSkillVersioningSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillVersioningSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillVersioningSettings) =>
updateSkillVersioningSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_VERSIONING_KEY, data);
void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
},
});
}
export function useModelConcurrencySettings() {
return useQuery({
queryKey: MODEL_CONCURRENCY_KEY,
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation.ts";
test("finds a public skill executed directly by a weak model", () => {
assert.equal(
extractInvokedSkillName("bash", {
command: "python /mnt/skills/public/facebook-page-generator/scripts/fast_page.py --spec /tmp/spec.json",
}),
"facebook-page-generator",
);
});
test("finds custom skills in host paths and nested arguments", () => {
assert.equal(
extractInvokedSkillName("read_file", {
request: { path: String.raw`F:\runtime\skills\custom\analysis-helper\SKILL.md` },
}),
"analysis-helper",
);
});
test("uses the explicit name for skill_view", () => {
assert.equal(extractInvokedSkillName("skill_view", { name: "report-maker" }), "report-maker");
});
test("does not label ordinary tool paths as skill calls", () => {
assert.equal(
extractInvokedSkillName("bash", { command: "python /mnt/user-data/workspace/run.py" }),
undefined,
);
});
test("renders a deterministic skill label without model-authored description", () => {
assert.equal(skillInvocationLabel("report-maker"), "调用技能「report-maker」");
});
@@ -0,0 +1,54 @@
const SKILL_NAME_ARG_TOOLS = new Set([
"skill_view",
"skill_manage",
"skill_archive",
]);
const SKILL_PATH_PATTERN =
/(?:^|[\\/])skills[\\/](?:public|custom)[\\/]([^\\/\s"'`;&|<>()]+)(?=[\\/]|$)/i;
function findSkillNameInValue(
value: unknown,
seen: Set<object>,
): string | undefined {
if (typeof value === "string") {
return SKILL_PATH_PATTERN.exec(value)?.[1];
}
if (!value || typeof value !== "object" || seen.has(value)) {
return undefined;
}
seen.add(value);
const values = Array.isArray(value)
? value
: Object.values(value as Record<string, unknown>);
for (const nested of values) {
const name = findSkillNameInValue(nested, seen);
if (name) {
return name;
}
}
return undefined;
}
/** Resolve a skill from actual tool arguments rather than model-authored prose. */
export function extractInvokedSkillName(
toolName: string,
args: unknown,
): string | undefined {
if (
SKILL_NAME_ARG_TOOLS.has(toolName) &&
args &&
typeof args === "object" &&
typeof (args as Record<string, unknown>).name === "string"
) {
const name = ((args as Record<string, unknown>).name as string).trim();
if (name) {
return name;
}
}
return findSkillNameInValue(args, new Set());
}
export function skillInvocationLabel(skillName: string): string {
return `调用技能「${skillName}」`;
}
+7
View File
@@ -3,6 +3,10 @@ import type { AIMessage } from "@langchain/langgraph-sdk";
import type { Translations } from "../i18n";
import { hasToolCalls } from "../messages/utils";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation";
import { isInternalToolCallName } from "./visibility";
export function explainLastToolCall(message: AIMessage, t: Translations) {
@@ -18,6 +22,7 @@ export function explainLastToolCall(message: AIMessage, t: Translations) {
}
export function explainToolCall(toolCall: ToolCall, t: Translations) {
const invokedSkillName = extractInvokedSkillName(toolCall.name, toolCall.args);
if (toolCall.name === "web_search" || toolCall.name === "image_search") {
return t.toolCalls.searchFor(toolCall.args.query);
} else if (toolCall.name === "web_fetch") {
@@ -26,6 +31,8 @@ export function explainToolCall(toolCall: ToolCall, t: Translations) {
return t.toolCalls.presentFiles;
} else if (toolCall.name === "write_todos") {
return t.toolCalls.writeTodos;
} else if (invokedSkillName) {
return skillInvocationLabel(invokedSkillName);
} else if (toolCall.args.description) {
return toolCall.args.description;
} else {
+13
View File
@@ -49,6 +49,8 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/** Red-white theme opt-in for the same navigation shell used by dark mode. */
LIGHT_RED_TOP_NAV_ENABLED: readRuntimeFlag("VITE_LIGHT_RED_TOP_NAV_ENABLED"),
/**
* Development-only default entry switch for the isolated Vite-native
* official-style workspace. It never starts or embeds a Next.js app.
@@ -56,6 +58,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.
@@ -63,3 +72,7 @@ export const env = {
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
export function isLightRedTopNavEnabled(theme: string): boolean {
return theme === "light" && env.LIGHT_RED_TOP_NAV_ENABLED;
}
@@ -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, useUserLeaderboard } from "@/core/admin/hooks";
import { useRefreshTodayLeaderboard, useUserLeaderboard, useUserLeaderboardDetails } from "@/core/admin/hooks";
import {
ADMIN_QUERY_WINDOW_DAYS,
formatLocalDateInput,
@@ -70,6 +80,7 @@ type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 10;
const DETAIL_PAGE_SIZE = 15;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -776,11 +787,13 @@ function SkillRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
@@ -790,7 +803,14 @@ function SkillRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -834,11 +854,13 @@ function DimensionRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardDimension[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
@@ -848,7 +870,14 @@ function DimensionRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -886,6 +915,264 @@ function DimensionRanking({
);
}
function formatDuration(value: number): string {
if (value <= 0) return "-";
if (value < 1_000) return `${fmtNumber(value)} ms`;
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)} 秒`;
}
function LeaderboardDetailRankingBars({
kind,
items,
}: {
kind: AdminLeaderboardDetailKind;
items: AdminLeaderboardDetailItem[];
}) {
const topItems = items.slice(0, 10);
if (topItems.length === 0) {
return <div className="flex h-40 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">当前时间段暂无{kind === "agent" ? "智能体运行" : "调用"}数据</div>;
}
const max = Math.max(1, ...topItems.map((item) => item.count));
const verb = kind === "agent" ? "运行" : "调用";
return (
<div className="space-y-3" role="img" aria-label={`${kind === "agent" ? "智能体" : kind === "skill" ? "技能" : "工具"}${verb}次数前 ${topItems.length} 名柱状图`}>
{topItems.map((item, index) => {
const width = Math.max(4, (item.count / max) * 100);
return (
<div key={item.name} className="grid grid-cols-[24px_minmax(120px,1fr)_minmax(180px,2fr)_64px] items-center gap-3">
<span className="text-sm font-medium tabular-nums text-[#80868B]">{index + 1}</span>
<span className="truncate text-sm font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</span>
<div className="h-3 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-right text-sm font-semibold tabular-nums text-[#202124] dark:text-white">{fmtNumber(item.count)} 次</span>
</div>
);
})}
</div>
);
}
function LeaderboardDetailDrawer({
kind,
range,
onRangeChange,
onOpenChange,
}: {
kind: AdminLeaderboardDetailKind | null;
range: RangeState;
onRangeChange: (range: RangeState) => void;
onOpenChange: (open: boolean) => void;
}) {
const dateBounds = getAdminQueryDateBounds();
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"count" | "users">("count");
const [page, setPage] = useState(1);
const apiRange = useMemo(() => rangeForApi(range), [range]);
const { data, isLoading, error } = useUserLeaderboardDetails(kind, apiRange, !!kind);
const rangePickerValue = useMemo<DateRangeValue>(() => [range.since, range.until], [range.since, range.until]);
const rangePickerPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const lastSevenDays = defaultRange(7);
const lastThirtyDays = defaultRange(30);
return {
"近 7 天": [lastSevenDays.since, lastSevenDays.until] as [string, string],
"近 30 天": [lastThirtyDays.since, lastThirtyDays.until] as [string, string],
};
}, []);
const detailMeta = kind === "skill"
? { title: "技能调用统计", plural: "技能", verb: "调用", unit: "次调用", icon: Sparkles }
: kind === "tool"
? { title: "工具调用统计", plural: "工具", verb: "调用", unit: "次调用", icon: Activity }
: { title: "Agent 使用统计", plural: "智能体", verb: "运行", unit: "次运行", icon: Bot };
const DetailIcon = detailMeta.icon;
const filteredItems = useMemo(() => {
const needle = search.trim().toLowerCase();
const items = (data?.items ?? []).filter((item) => !needle || item.name.toLowerCase().includes(needle));
return [...items].sort((left, right) => {
if (sort === "users") return right.user_count - left.user_count || right.count - left.count;
return right.count - left.count || right.user_count - left.user_count;
});
}, [data?.items, search, sort]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / DETAIL_PAGE_SIZE));
const visibleItems = filteredItems.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE);
const summary = data?.summary;
useEffect(() => {
setPage(1);
}, [kind, apiRange.since, apiRange.until, search, sort]);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);
useEffect(() => {
if (!kind) {
setSearch("");
setSort("count");
setPage(1);
}
}, [kind]);
const setPickerRange = (value: DateRangeValue) => {
if (value.length < 2) return;
const normalizeDate = (date: DateRangeValue[number]) => {
if (date instanceof Date) return formatLocalDateInput(date);
if (typeof date === "number") return formatLocalDateInput(new Date(date));
return String(date).slice(0, 10);
};
const since = normalizeDate(value[0]);
const until = normalizeDate(value[1]);
if (!dateFromInput(since) || !dateFromInput(until)) return;
onRangeChange(clampRangeToAdminWindow({ since, until }));
};
const metric = (label: string, value: string, description: string) => (
<div className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]" title={description}>
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums text-[#231815] dark:text-white">{value}</div>
<div className="mt-1 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{description}</div>
</div>
);
return (
<Sheet open={!!kind} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:w-[min(94vw,1080px)] sm:max-w-none">
<SheetHeader className="shrink-0 border-b border-[#F1D6D6] bg-white px-5 pb-4 pt-4 pr-12 text-left dark:border-[#213D5E] dark:bg-[#02244C]">
<SheetTitle className="flex items-center gap-2 text-base">
<DetailIcon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{detailMeta.title}
</SheetTitle>
<SheetDescription className="mt-1 leading-5">
直接聚合原始统计记录;图表和明细会随当前时间范围重新汇总,不受主榜单快照刷新影响。
</SheetDescription>
</SheetHeader>
<div className="shrink-0 space-y-3 border-b border-[#F1D6D6] bg-[#FFF7F7] px-5 py-3 dark:border-[#213D5E] dark:bg-[#011226]">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-[#202124] dark:text-white">统计区间</span>
<DateRangePicker
value={rangePickerValue}
valueType="YYYY-MM-DD"
format="YYYY-MM-DD"
presets={rangePickerPresets}
clearable={false}
disableDate={{ before: dateBounds.min, after: dateBounds.max }}
onChange={setPickerRange}
className="w-full sm:w-80"
/>
<span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">仅支持近 30 天</span>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-5">
{isLoading && (
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" />
正在加载{detailMeta.plural}明细
</div>
)}
{error && (
<div role="alert" className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-4 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
明细加载失败:{error instanceof Error ? error.message : "请稍后重试"}
</div>
)}
{!isLoading && !error && summary && (
<>
<section>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">整体概览</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{range.since} 至 {range.until} · {fmtNumber(summary.item_count)} 个{detailMeta.plural}</p>
</div>
<Badge variant="outline">{fmtNumber(summary.count)} {detailMeta.unit}</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metric(`总${detailMeta.verb}量`, `${fmtNumber(summary.count)} 次`, `所选时间内的${detailMeta.verb}总数`) }
{metric("覆盖用户", `${fmtNumber(summary.user_count)} 人`, "至少使用过一次该维度的去重用户数")}
{metric(kind === "agent" ? "总 Token" : "平均耗时", kind === "agent" ? fmtNumber(summary.total_tokens) : formatDuration(summary.avg_duration_ms), kind === "agent" ? "此类 Agent 运行累计的模型 Token" : "按每次完成调用计算的平均耗时")}
{metric("活跃条目", `${fmtNumber(summary.item_count)} 项`, "当前条件下产生过统计记录的名称数量")}
</div>
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}{detailMeta.verb} Top 10</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">按当前筛选的完整时间范围累计排序,数值越大柱越长。</p>
</div>
<Badge variant="outline">{range.since} 至 {range.until}</Badge>
</div>
<LeaderboardDetailRankingBars kind={kind!} items={data?.items ?? []} />
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}明细</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">共 {fmtNumber(filteredItems.length)} 项;可按名称筛选、按调用量或用户数排序。</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
<Input aria-label={`搜索${detailMeta.plural}名称`} value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`搜索${detailMeta.plural}名称`} className="h-9 w-52 bg-white pl-8 dark:bg-[#011226]" />
</div>
<Select value={sort} onValueChange={(value) => setSort(value as "count" | "users")}>
<SelectTrigger className="h-9 w-32 bg-white text-xs dark:bg-[#011226]" aria-label="明细排序方式">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">按调用量</SelectItem>
<SelectItem value="users">按用户数</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{visibleItems.length === 0 ? (
<div className="py-16 text-center text-sm text-[#5F6368] dark:text-slate-400">当前筛选条件下暂无{detailMeta.plural}数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="border-y border-[#F1D6D6] bg-[#FFF7F7] text-xs text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#011226] dark:text-[#7FACF2]">
<tr>
<th className="px-3 py-2.5 font-medium">名称</th>
<th className="px-3 py-2.5 text-right font-medium">{detailMeta.verb}量</th>
<th className="px-3 py-2.5 text-right font-medium">用户</th>
<th className="px-3 py-2.5 text-right font-medium">{kind === "agent" ? "Token" : "平均耗时"}</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item: AdminLeaderboardDetailItem) => (
<tr key={item.name} className="border-b border-[#F6E6E6] last:border-0 dark:border-[#17375E]">
<td className="max-w-64 truncate px-3 py-3 font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.user_count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{kind === "agent" ? fmtNumber(item.total_tokens) : formatDuration(item.avg_duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>第 {page} / {pageCount} 页</span>
<Pagination page={page} pageSize={DETAIL_PAGE_SIZE} total={filteredItems.length} onPageChange={setPage} />
</div>
</section>
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -904,6 +1191,8 @@ export default function AdminUserLeaderboardPage() {
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [userPage, setUserPage] = useState(1);
const [detailKind, setDetailKind] = useState<AdminLeaderboardDetailKind | null>(null);
const [detailRange, setDetailRange] = useState<RangeState>(() => defaultRange(7));
const apiRange = useMemo(
() => rangeForApi(range),
[range],
@@ -968,6 +1257,11 @@ export default function AdminUserLeaderboardPage() {
setRange(clampRangeToAdminWindow(defaultRange(days)));
};
const openLeaderboardDetails = (kind: AdminLeaderboardDetailKind) => {
setDetailRange(clampRangeToAdminWindow(range));
setDetailKind(kind);
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
@@ -1438,19 +1732,27 @@ export default function AdminUserLeaderboardPage() {
</section>
<div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" onMore={() => openLeaderboardDetails("skill")} />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" onMore={() => openLeaderboardDetails("tool")} />
</div>
<div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" onMore={() => openLeaderboardDetails("agent")} />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
</>
)}
</div>
</ScrollArea>
<LeaderboardDetailDrawer
kind={detailKind}
range={detailRange}
onRangeChange={setDetailRange}
onOpenChange={(open) => {
if (!open) setDetailKind(null);
}}
/>
</main>
</div>
);
+14 -5
View File
@@ -51,7 +51,10 @@ import {
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import {
useAppSystemName,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime";
import {
@@ -328,6 +331,12 @@ function LoadingRows() {
export default function ArtifactLibraryPage() {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configuredDescription = pageDescriptions?.knowledge_base;
const pageTitle = configuredDescription?.title || "资料库";
const pageDescription =
configuredDescription?.description ||
"汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。";
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
@@ -337,8 +346,8 @@ export default function ArtifactLibraryPage() {
const forceRefreshRef = useRef(false);
useEffect(() => {
document.title = `资料库 - ${appName}`;
}, [appName]);
document.title = `${pageTitle} - ${appName}`;
}, [appName, pageTitle]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -478,10 +487,10 @@ export default function ArtifactLibraryPage() {
<div>
<div className="flex items-center gap-2">
<Library className="size-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
<h1 className="text-2xl font-semibold tracking-tight">{pageTitle}</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。
{pageDescription}
</p>
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end">
+23 -1
View File
@@ -11,6 +11,7 @@ import {
useConversationContextSettings,
useLayoutMode,
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
usePromptPrefixSettings,
} from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
@@ -168,6 +169,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();
@@ -1003,6 +1005,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),
@@ -1231,11 +1244,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 ? (
@@ -1263,6 +1280,11 @@ export default function ChatPage() {
<InputBox
className="w-full"
isNewThread={isLandingState}
placeholder={
isLandingState
? qaPageDescription?.input_placeholder
: undefined
}
compact={compactLanding}
composerHeightPercent={composerHeightPercent}
threadId={threadId}
@@ -40,6 +40,7 @@ import {
useNoteWikilinks,
useVaultPage,
} from "@/core/knowledge/hooks";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import MarkdownRenderer, {
@@ -537,6 +538,8 @@ export default function KnowledgeBasePage({
} = {}) {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
@@ -649,6 +652,8 @@ export default function KnowledgeBasePage({
return (
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
title={pageDescription?.title || undefined}
description={pageDescription?.description || undefined}
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
+5 -2
View File
@@ -36,6 +36,7 @@ import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -140,6 +141,8 @@ export default function LightAppListPage() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme();
const lightAccent = getLightAccent(theme);
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_list;
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
@@ -199,14 +202,14 @@ export default function LightAppListPage() {
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
{pageDescription?.title || "应用列表"}
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
{pageDescription?.description || "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。"}
</p>
</div>
@@ -36,6 +36,7 @@ import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useSetResourceTags } from "@/core/tags";
import {
useLightAppActions,
@@ -142,6 +143,8 @@ export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_management;
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
@@ -327,18 +330,20 @@ export default function LightAppManagementPage() {
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
{pageDescription?.title || "应用管理"}
</h1>
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
{pageDescription?.description || <>
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
</>}
</p>
</div>
@@ -0,0 +1,497 @@
"use client";
import {
BotIcon,
DatabaseIcon,
FileTextIcon,
FolderKanbanIcon,
GitBranchIcon,
LandmarkIcon,
Layout,
List,
Loader2Icon,
MonitorIcon,
PenLineIcon,
RotateCcwIcon,
SaveIcon,
SparklesIcon,
TelescopeIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type {
AIWritingGuideCard,
AIWritingPageDescription,
GeneralQaPageDescription,
PageDescriptionsSettings,
} from "@/core/system-settings/api";
import {
usePageDescriptionsSettings,
useUpdatePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
type PageTarget =
| "qa-compact"
| "qa-full"
| "knowledge-base"
| "ai-writing"
| "strategy-projects"
| "deep-research"
| "gongwen"
| "light-app-management"
| "light-app-list"
| "agents"
| "business-chains"
| "skills"
| "knowledge-management";
type HeaderTarget = Exclude<PageTarget, "qa-compact" | "qa-full">;
type HeaderKey =
| "knowledge_base"
| "ai_writing"
| "strategy_projects"
| "deep_research"
| "gongwen"
| "light_app_management"
| "light_app_list"
| "agents"
| "business_chains"
| "skills"
| "knowledge_management";
const EMPTY_QA: GeneralQaPageDescription = {
welcome_title: "",
welcome_description: "",
input_placeholder: "",
};
const EMPTY_HEADER = { title: "", description: "" };
const DEFAULT_AI_WRITING_GUIDE_CARDS: AIWritingGuideCard[] = [
{
title: "对白意图解析",
description: "在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。",
},
{
title: "参数多端对齐",
description: "右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。",
},
{
title: "深度排版落成",
description: "点击右侧红色创作按钮,精美的行业或创意文档极速铺开。",
},
];
const EMPTY_AI_WRITING: AIWritingPageDescription = {
...EMPTY_HEADER,
guide_cards: DEFAULT_AI_WRITING_GUIDE_CARDS.map(() => ({
title: "",
description: "",
})),
};
const EMPTY_SETTINGS: PageDescriptionsSettings = {
general_qa: { full: EMPTY_QA, compact: EMPTY_QA },
knowledge_base: EMPTY_HEADER,
ai_writing: EMPTY_AI_WRITING,
strategy_projects: EMPTY_HEADER,
deep_research: EMPTY_HEADER,
gongwen: EMPTY_HEADER,
light_app_management: EMPTY_HEADER,
light_app_list: EMPTY_HEADER,
agents: EMPTY_HEADER,
business_chains: EMPTY_HEADER,
skills: EMPTY_HEADER,
knowledge_management: EMPTY_HEADER,
};
const HEADER_KEY_BY_TARGET: Record<HeaderTarget, HeaderKey> = {
"knowledge-base": "knowledge_base",
"ai-writing": "ai_writing",
"strategy-projects": "strategy_projects",
"deep-research": "deep_research",
gongwen: "gongwen",
"light-app-management": "light_app_management",
"light-app-list": "light_app_list",
agents: "agents",
"business-chains": "business_chains",
skills: "skills",
"knowledge-management": "knowledge_management",
};
const PAGES: Array<{
id: PageTarget;
title: string;
subtitle: string;
path: string;
defaultTitle: string;
defaultDescription: string;
}> = [
{
id: "qa-compact",
title: "通用问答 · 简洁模式",
subtitle: "紧凑工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "qa-full",
title: "通用问答 · 全量模式",
subtitle: "完整工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "knowledge-base",
title: "资料库",
subtitle: "资料库首页的标题与说明",
path: "/page/workspace/library",
defaultTitle: "资料库",
defaultDescription: "汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。",
},
{
id: "ai-writing",
title: "AI 写作",
subtitle: "AI 写作工作台的欢迎区",
path: "/page/canvas/ai-writing",
defaultTitle: "欢迎使用「ai写作」",
defaultDescription: "这是一个专业的自动化写作系统。你只需在下方对话框中输入你的本意(例如:「帮我写一篇 1500 字的周报」),系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。",
},
{
id: "strategy-projects",
title: "我的项目",
subtitle: "策略项目列表的标题与说明",
path: "/page/strategy/qa/projects",
defaultTitle: "我的项目",
defaultDescription: "集中查看普通问答、策略优选和方案成果",
},
{
id: "deep-research",
title: "深度研究",
subtitle: "深度研究工作台的欢迎区",
path: "/page/strategy/qa/deep-research",
defaultTitle: "开始一项深度研究",
defaultDescription: "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。",
},
{
id: "gongwen",
title: "公文助手",
subtitle: "公文助手工作台的欢迎区",
path: "/page/strategy/qa/gongwen",
defaultTitle: "开始一份公文工作",
defaultDescription: "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。",
},
{
id: "light-app-management",
title: "应用管理",
subtitle: "轻应用登记页的标题与说明",
path: "/page/strategy/light-app/management",
defaultTitle: "应用管理",
defaultDescription: "注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。",
},
{
id: "light-app-list",
title: "应用列表",
subtitle: "轻应用启动器的标题与说明",
path: "/page/strategy/light-app/apps",
defaultTitle: "应用列表",
defaultDescription: "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。",
},
{
id: "agents",
title: "智能体管理",
subtitle: "智能体列表页的标题与说明",
path: "/page/strategy/agents",
defaultTitle: "智能体",
defaultDescription: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
},
{
id: "business-chains",
title: "业务链管理",
subtitle: "业务链条配置页的标题与说明",
path: "/page/strategy/business-chains",
defaultTitle: "业务链条配置",
defaultDescription: "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。",
},
{
id: "skills",
title: "技能管理",
subtitle: "技能工作台的标题与说明",
path: "/page/strategy/skills",
defaultTitle: "技能",
defaultDescription: "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。",
},
{
id: "knowledge-management",
title: "知识库管理",
subtitle: "知识库工作台的标题与说明",
path: "/page/workspace/knowledge",
defaultTitle: "知识库管理",
defaultDescription: "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。",
},
];
const DEFAULT_INPUT_PLACEHOLDER = "今天我能为你做些什么?";
export default function PageDescriptionSettingsPage() {
const { data, isLoading, error } = usePageDescriptionsSettings();
const update = useUpdatePageDescriptionsSettings();
const [draft, setDraft] = useState<PageDescriptionsSettings>(EMPTY_SETTINGS);
const [activeTarget, setActiveTarget] = useState<PageTarget>("qa-compact");
useEffect(() => {
if (data) setDraft(data);
}, [data]);
const currentPage = PAGES.find((item) => item.id === activeTarget) ?? PAGES[0];
const isQa = activeTarget === "qa-compact" || activeTarget === "qa-full";
const qaMode = activeTarget === "qa-compact" ? "compact" : "full";
const qaDraft = draft.general_qa[qaMode];
const headerKey: HeaderKey = isQa
? "knowledge_base"
: HEADER_KEY_BY_TARGET[activeTarget as HeaderTarget];
const headerDraft = draft[headerKey];
const updateQa = (patch: Partial<GeneralQaPageDescription>) => {
setDraft((current) => ({
...current,
general_qa: {
...current.general_qa,
[qaMode]: { ...current.general_qa[qaMode], ...patch },
},
}));
};
const updateHeader = (patch: typeof EMPTY_HEADER) => {
setDraft((current) => ({ ...current, [headerKey]: { ...current[headerKey], ...patch } }));
};
const updateAiWritingCard = (
index: number,
patch: Partial<AIWritingGuideCard>,
) => {
setDraft((current) => ({
...current,
ai_writing: {
...current.ai_writing,
guide_cards: current.ai_writing.guide_cards.map((card, cardIndex) =>
cardIndex === index ? { ...card, ...patch } : card,
),
},
}));
};
const resetActive = () => {
if (isQa) updateQa(EMPTY_QA);
else if (activeTarget === "ai-writing") {
setDraft((current) => ({ ...current, ai_writing: EMPTY_AI_WRITING }));
} else updateHeader(EMPTY_HEADER);
};
const handleSave = async () => {
const normalizeQa = (value: GeneralQaPageDescription): GeneralQaPageDescription => ({
welcome_title: value.welcome_title.trim(),
welcome_description: value.welcome_description.trim(),
input_placeholder: value.input_placeholder.trim(),
});
const normalizeHeader = (value: typeof EMPTY_HEADER) => ({
title: value.title.trim(),
description: value.description.trim(),
});
const normalizeAiWriting = (value: AIWritingPageDescription) => ({
...normalizeHeader(value),
guide_cards: value.guide_cards.map((card) => ({
title: card.title.trim(),
description: card.description.trim(),
})),
});
try {
await update.mutateAsync({
general_qa: { full: normalizeQa(draft.general_qa.full), compact: normalizeQa(draft.general_qa.compact) },
knowledge_base: normalizeHeader(draft.knowledge_base),
ai_writing: normalizeAiWriting(draft.ai_writing),
strategy_projects: normalizeHeader(draft.strategy_projects),
deep_research: normalizeHeader(draft.deep_research),
gongwen: normalizeHeader(draft.gongwen),
light_app_management: normalizeHeader(draft.light_app_management),
light_app_list: normalizeHeader(draft.light_app_list),
agents: normalizeHeader(draft.agents),
business_chains: normalizeHeader(draft.business_chains),
skills: normalizeHeader(draft.skills),
knowledge_management: normalizeHeader(draft.knowledge_management),
});
toast.success("页面描述已保存,重新进入对应页面后即可查看新文案。");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存页面描述配置失败");
}
};
const title = isQa ? qaDraft.welcome_title : headerDraft.title;
const description = isQa ? qaDraft.welcome_description : headerDraft.description;
const previewTitle = title || currentPage.defaultTitle;
const previewDescription = description || currentPage.defaultDescription;
const previewPlaceholder = qaDraft.input_placeholder || DEFAULT_INPUT_PLACEHOLDER;
const aiWritingPreviewCards = draft.ai_writing.guide_cards.map((card, index) => ({
title: card.title || DEFAULT_AI_WRITING_GUIDE_CARDS[index].title,
description: card.description || DEFAULT_AI_WRITING_GUIDE_CARDS[index].description,
}));
return (
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6 sm:py-8 lg:overflow-hidden">
<header className="shrink-0">
<h1 className="text-xl font-semibold tracking-tight">页面描述配置</h1>
<p className="text-muted-foreground mt-1 max-w-3xl text-sm leading-relaxed">
按页面维护用户可见的标题和说明文字。通用问答的简洁模式与全量模式可分别配置;留空会沿用页面默认文案。
</p>
</header>
{isLoading ? (
<div className="flex min-h-72 items-center justify-center" aria-label="正在加载页面描述配置">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed p-8 text-center text-sm">加载页面描述配置失败:{error.message}</div>
) : (
<div className="grid gap-5 lg:min-h-0 lg:flex-1 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="rounded-xl border bg-card p-3 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<p className="text-muted-foreground sticky top-0 z-10 bg-card px-2 pb-2 text-xs font-medium tracking-wide">可配置页面</p>
<div className="grid gap-1">
{PAGES.map((item) => {
const active = activeTarget === item.id;
const Icon = item.id.startsWith("qa-")
? MonitorIcon
: item.id === "knowledge-base"
? DatabaseIcon
: item.id === "ai-writing"
? PenLineIcon
: item.id === "strategy-projects"
? FolderKanbanIcon
: item.id === "deep-research"
? TelescopeIcon
: item.id === "gongwen"
? LandmarkIcon
: item.id === "light-app-management"
? Layout
: item.id === "light-app-list"
? List
: item.id === "agents"
? BotIcon
: item.id === "business-chains"
? GitBranchIcon
: item.id === "skills"
? SparklesIcon
: DatabaseIcon;
return (
<button
key={item.id}
type="button"
onClick={() => setActiveTarget(item.id)}
aria-pressed={active}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active ? "bg-primary/10 text-foreground ring-1 ring-primary/20" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md", active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0"><span className="block text-sm font-medium">{item.title}</span><span className="mt-0.5 block text-xs leading-relaxed">{item.subtitle}</span></span>
</button>
);
})}
</div>
</aside>
<main className="min-w-0 space-y-5 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<section className="rounded-xl border bg-card p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2"><FileTextIcon className="text-primary size-4" aria-hidden /><h2 className="text-base font-semibold">{currentPage.title}</h2></div>
<p className="text-muted-foreground mt-1 text-sm">配置 <code className="font-mono text-xs">{currentPage.path}</code> 的页面文案。</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={resetActive} disabled={update.isPending}><RotateCcwIcon className="mr-1.5 size-4" aria-hidden />恢复默认文案</Button>
</div>
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5">
<span className="text-sm font-medium">{isQa ? "欢迎标题" : "页面标题"}</span>
<span className="text-muted-foreground text-xs">留空时使用系统默认文案。</span>
<Input value={title} maxLength={80} onChange={(event) => isQa ? updateQa({ welcome_title: event.target.value }) : updateHeader({ title: event.target.value, description: headerDraft.description })} placeholder={currentPage.defaultTitle} />
</label>
<label className="grid gap-1.5">
<span className="text-sm font-medium">说明文字</span>
<span className="text-muted-foreground text-xs">建议用一句话说明该页面能为用户做什么。</span>
<Textarea rows={4} value={description} maxLength={500} onChange={(event) => isQa ? updateQa({ welcome_description: event.target.value }) : updateHeader({ title: headerDraft.title, description: event.target.value })} placeholder={currentPage.defaultDescription} />
</label>
{isQa && <label className="grid gap-1.5"><span className="text-sm font-medium">输入框提示语</span><span className="text-muted-foreground text-xs">在用户开始输入前显示于问答输入框中。</span><Input value={qaDraft.input_placeholder} maxLength={120} onChange={(event) => updateQa({ input_placeholder: event.target.value })} placeholder={DEFAULT_INPUT_PLACEHOLDER} /></label>}
{activeTarget === "ai-writing" && (
<fieldset className="grid gap-3 rounded-lg border bg-muted/20 p-4">
<legend className="px-1 text-sm font-medium">引导卡片</legend>
<p className="text-muted-foreground text-xs leading-relaxed">
三张卡片按页面既有顺序展示;可分别修改标题和说明,留空则保留默认文案。
</p>
<div className="grid gap-3 xl:grid-cols-3">
{draft.ai_writing.guide_cards.map((card, index) => (
<section key={index} className="grid gap-3 rounded-md border bg-card p-3">
<h3 className="text-sm font-medium">卡片 {index + 1}</h3>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">标题</span>
<Input
value={card.title}
maxLength={80}
onChange={(event) =>
updateAiWritingCard(index, { title: event.target.value })
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].title}
/>
</label>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">说明</span>
<Textarea
rows={4}
value={card.description}
maxLength={240}
onChange={(event) =>
updateAiWritingCard(index, {
description: event.target.value,
})
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].description}
/>
</label>
</section>
))}
</div>
</fieldset>
)}
</div>
</section>
<section className="rounded-xl border bg-muted/25 p-4 sm:p-5" aria-live="polite">
<div className="mb-4 flex items-center gap-2"><MonitorIcon className="text-muted-foreground size-4" aria-hidden /><h2 className="text-sm font-semibold">页面预览</h2><span className="text-muted-foreground text-xs">{currentPage.title}</span></div>
<div className="rounded-lg border bg-background px-5 py-8 text-center shadow-sm sm:px-10">
<h3 className="text-xl font-semibold tracking-tight">{previewTitle}</h3>
<p className="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-relaxed">{previewDescription}</p>
{isQa && <div className="text-muted-foreground mt-6 rounded-xl border bg-card px-4 py-3 text-left text-sm shadow-xs">{previewPlaceholder}</div>}
{activeTarget === "ai-writing" && (
<div className="mt-6 grid gap-3 text-left md:grid-cols-3">
{aiWritingPreviewCards.map((card, index) => (
<div key={index} className="rounded-lg border bg-card p-3">
<p className="text-sm font-semibold">{index + 1}. {card.title}</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">{card.description}</p>
</div>
))}
</div>
)}
</div>
</section>
<div className="flex justify-end"><Button type="button" onClick={handleSave} disabled={update.isPending} className={cn("min-w-28", update.isPending && "cursor-wait")}>{update.isPending ? <Loader2Icon className="mr-1.5 size-4 animate-spin" aria-hidden /> : <SaveIcon className="mr-1.5 size-4" aria-hidden />}保存配置</Button></div>
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useSkillVersioningSettings,
useUpdateSkillVersioningSettings,
} from "@/core/system-settings/hooks";
export default function SkillVersioningSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillVersioningSettings();
const update = useUpdateSkillVersioningSettings();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (data) setEnabled(data.enabled);
}, [data]);
async function handleSave() {
try {
await update.mutateAsync({ enabled });
toast.success(enabled ? "技能迭代已开启" : "技能迭代已关闭");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
}
}
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
自定义技能迭代
</h1>
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
开启后,同名自定义技能上传时可选择“迭代为新版本”或“直接覆盖”,并可在技能详情中切换版本。
旧版本不会提供给大模型,技能调用次数也不会因迭代或切换而清零。关闭后保持原有覆盖逻辑。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-12 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 gap-3">
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
<HistoryIcon className="size-4" />
</div>
<div>
<div className="text-sm font-medium">启用自定义技能迭代</div>
<div className="text-muted-foreground mt-1 text-xs leading-relaxed">
仅作用于 custom 技能。第一次开启时会为现有自定义技能建立初始版本,公共/内置技能不参与迭代。
</div>
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="启用自定义技能迭代"
/>
</div>
<div className="bg-muted/40 text-muted-foreground rounded-lg border px-4 py-3 text-xs leading-relaxed">
“迭代为新版本”会保留完整旧技能包;“直接覆盖”会按旧逻辑替换内容,并从当前上传内容重新作为第 1 版开始记录。
</div>
<div className="flex justify-end">
<Button onClick={() => void handleSave()} disabled={update.isPending}>
{update.isPending && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
export default function SkillsPage({
restrictWxTypes = false,
@@ -13,6 +14,8 @@ export default function SkillsPage({
restrictWxTypes?: boolean;
} = {}) {
const [searchParams, setSearchParams] = useSearchParams();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.skills;
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
@@ -33,9 +36,9 @@ export default function SkillsPage({
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<h1 className="text-xl font-semibold">{pageDescription?.title || "技能"}</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
{pageDescription?.description || "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。"}
</p>
</div>
<TabsList>
@@ -60,6 +60,9 @@ const AdminUserLeaderboardSettingsPage = lazy(
);
const MenuManagementPage = lazy(() => import("./MenuManagementPage"));
const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage"));
const PageDescriptionSettingsPage = lazy(
() => import("./PageDescriptionSettingsPage"),
);
const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage"));
const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage"));
const LlmWikiKnowledgePage = lazy(() => import("./LlmWikiKnowledgePage"));
@@ -1381,6 +1384,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/page-descriptions"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PageDescriptionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
@@ -1,6 +1,10 @@
"use client";
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
import {
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { normalizeKnowledgeRetrievalMode, resolveKnowledgeRetrievalRequestContext, resolveSmartKnowledgeRetrievalAvailability, type KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
@@ -672,6 +676,8 @@ function DeepResearchWorkbenchInner({
const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState<Record<string, string[]>>({});
const [classicMode, setClassicMode] = useState(false);
const retrievalSettings = useOrdinaryKnowledgeRetrievalSettings();
const pageDescriptions = usePageDescriptionsSettings();
const deepResearchPageDescription = pageDescriptions.data?.deep_research;
const [draftRetrievalMode, setDraftRetrievalMode] = useState<KnowledgeRetrievalMode>("classic");
const [draftKnowledgeBaseIds, setDraftKnowledgeBaseIds] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
@@ -3261,7 +3267,10 @@ function DeepResearchWorkbenchInner({
<main className="flex h-full min-w-0 flex-col">
{!session || (isChatSession && collectorEmpty) ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<ResearchWelcome onStart={() => document.getElementById("deep-research-composer")?.focus()} />
<ResearchWelcome
pageDescription={deepResearchPageDescription}
onStart={() => document.getElementById("deep-research-composer")?.focus()}
/>
</div>
) : isChatSession ? (
<>
@@ -3786,12 +3795,22 @@ function StatusBadge({ status }: { status: DeepResearchSession["status"] }) {
return <Badge variant="outline" className={`border-0 px-2 py-0.5 text-[11px] ${tone}`}>{STATUS_LABELS[status]}</Badge>;
}
function ResearchWelcome({ onStart }: { onStart: () => void }) {
function ResearchWelcome({
pageDescription,
onStart,
}: {
pageDescription?: PageHeaderDescription;
onStart: () => void;
}) {
return (
<div className="flex min-h-[440px] flex-col items-center justify-center text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Telescope className="h-7 w-7" /></div>
<h1 className="text-2xl font-semibold tracking-tight">开始一项深度研究</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。</p>
<h1 className="text-2xl font-semibold tracking-tight">
{pageDescription?.title || "开始一项深度研究"}
</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">
{pageDescription?.description || "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。"}
</p>
<Button className="mt-6" onClick={onStart}><Sparkles className="mr-2 h-4 w-4" />输入研究课题</Button>
</div>
);
@@ -68,6 +68,8 @@ import {
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { useModels } from "@/core/models/hooks";
import { useLocalSettings } from "@/core/settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useRenameThread,
@@ -248,6 +250,8 @@ function GongwenWorkbenchInner() {
const documentQuery = useGongwenDocument(selectedDocumentId);
const actions = useGongwenActions();
const renameGongwenThread = useRenameThread();
const pageDescriptions = usePageDescriptionsSettings();
const gongwenPageDescription = pageDescriptions.data?.gongwen;
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const templates = templatesQuery.data ?? [];
@@ -618,6 +622,7 @@ function GongwenWorkbenchInner() {
return <>
{view === "chat" ? (
<GongwenAgentConversationLayout
pageDescription={gongwenPageDescription}
templates={templates}
documents={documents}
documentsLoading={documentsQuery.isLoading}
@@ -755,6 +760,7 @@ function GongwenConversationLayout(props: {
}
function GongwenAgentConversationLayout(props: {
pageDescription?: PageHeaderDescription;
templates: GongwenTemplate[];
documents: GongwenDocument[];
documentsLoading: boolean;
@@ -846,7 +852,7 @@ function GongwenAgentConversationLayout(props: {
scrollButtonClassName="bottom-3"
onClarificationSubmit={props.onClarificationSubmit}
onEditResubmit={props.onEditResubmit}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">开始一份公文工作</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">{props.pageDescription?.title || "开始一份公文工作"}</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">{props.pageDescription?.description || "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。"}</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
<div className="shrink-0 border-t border-border/70 bg-background px-4 py-4 sm:px-6">
<div className="space-y-3 rounded-2xl border border-border bg-card p-3 shadow-[0_8px_30px_rgba(0,0,0,0.06)]">
@@ -3,8 +3,10 @@ import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
isRoundtableCapacityFailure,
RoundtableRoomFullError,
isRoundtableBusyFailure,
isRoundtableRoomFullFailure,
roundtableErrorForDisplay,
roundtableRoomFullError,
} from "@/roundtable-planning/lib/capacity-error";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
@@ -12,29 +14,33 @@ import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/s
export type ThreadIdMap = Record<string, string>;
function roomFullError(
function throwRoundtableRunError(
rawDetail: string,
status: number | null,
context: string,
): RoundtableRoomFullError {
let activeCount = 0;
let maxRooms = 0;
try {
const body = JSON.parse(rawDetail) as {
detail?: { active_count?: number; max_rooms?: number };
active_count?: number;
max_rooms?: number;
};
activeCount = body.detail?.active_count ?? body.active_count ?? 0;
maxRooms = body.detail?.max_rooms ?? body.max_rooms ?? 0;
} catch {
// 包装后的 502 / SSE 文本通常不是纯 JSON;计数仅供诊断,不影响统一提示。
): never {
if (isRoundtableRoomFullFailure(status, rawDetail)) {
throw roundtableRoomFullError(rawDetail, status, context);
}
console.error(`[roundtable-capacity] ${context}`, {
status,
detail: rawDetail,
});
return new RoundtableRoomFullError(activeCount, maxRooms);
throw new Error(
roundtableErrorForDisplay(rawDetail, status) || "会商暂时无法继续,请稍后重试",
);
}
function isClassifiedRoundtableFailure(
status: number | null,
detail: string,
): boolean {
if (status === 409 || status === 429) return true;
if (isRoundtableRoomFullFailure(status, detail) || isRoundtableBusyFailure(status, detail)) {
return true;
}
const displayed = roundtableErrorForDisplay(detail, status);
return displayed != null && displayed !== detail;
}
export interface SeatReadyEvent {
@@ -119,8 +125,8 @@ export async function initMultiAgent(
httpStatus: res.status,
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
});
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
throw roomFullError(text, res.status, "init HTTP request rejected");
if (isClassifiedRoundtableFailure(res.status, text)) {
throwRoundtableRunError(text, res.status, "init HTTP request rejected");
}
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
@@ -215,8 +221,8 @@ export async function initMultiAgent(
stage: "step2_init", event: "init_error_frame",
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
});
if (isRoundtableCapacityFailure(null, errorDetail)) {
throw roomFullError(errorDetail, null, "init SSE error frame");
if (isClassifiedRoundtableFailure(null, errorDetail)) {
throwRoundtableRunError(errorDetail, null, "init SSE error frame");
}
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
@@ -627,8 +633,8 @@ export async function streamMultiAgent(
detail: { agentType: req.agentType },
});
reported = true;
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
throw roomFullError(text, res.status, "run/stream HTTP request rejected");
if (isClassifiedRoundtableFailure(res.status, text)) {
throwRoundtableRunError(text, res.status, "run/stream HTTP request rejected");
}
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
@@ -998,9 +1004,14 @@ export async function streamMultiAgent(
agentName: req.displayName || req.agentName,
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
});
if (isRoundtableCapacityFailure(null, result.content)) {
const displayed = roundtableErrorForDisplay(result.content);
if (
isRoundtableRoomFullFailure(null, result.content) ||
isRoundtableBusyFailure(null, result.content) ||
(displayed != null && displayed !== result.content)
) {
reported = true;
throw roomFullError(
throwRoundtableRunError(
result.content,
null,
`run/stream SSE error frame (${req.displayName || req.agentName})`,
@@ -5,6 +5,7 @@ export {
isRoundtableCapacityFailure,
isRoundtableRoomFullError,
RoundtableRoomFullError,
roundtableCardFailure,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/lib/capacity-error";
@@ -20,9 +20,9 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isRoundtableCapacityFailure,
RoundtableRoomFullError,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
RoundtableRoomFullError,
sanitizeRoundtableDialogue,
} from "../lib/capacity-error";
import { reportRoundtableError } from "./diagnostics-report";
import { seatModeToRunParams } from "../lib/seat-mode";
@@ -57,6 +57,15 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
logCapacityError(ctx, text);
throw new RoundtableRoomFullError();
}
const displayed = roundtableErrorForDisplay(text, res.status);
if (
res.status === 409 ||
res.status === 429 ||
(displayed != null && displayed !== text)
) {
logCapacityError(ctx, text);
throw new Error(displayed || "会商暂时无法继续,请稍后重试");
}
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
@@ -84,14 +93,15 @@ function withAvatar(nodes: unknown): DispatchChainNode[] {
function mapJob(raw: Record<string, unknown>): RoundtableJob {
const id = String(raw.id ?? "");
const rawError = (raw.error as string | null) ?? null;
if (isRoundtableCapacityFailure(null, rawError)) {
logCapacityError(id, rawError ?? "");
if (rawError && (isRoundtableCapacityFailure(null, rawError) || roundtableErrorForDisplay(rawError) !== rawError)) {
logCapacityError(id, rawError);
}
const dialogues = Array.isArray(raw.dialogues)
? (raw.dialogues as JobDialogue[]).map((dialogue) => {
if (!isRoundtableCapacityFailure(null, dialogue.content)) return dialogue;
const sanitized = sanitizeRoundtableDialogue(dialogue.content);
if (sanitized === dialogue.content) return dialogue;
logCapacityError(id, dialogue.content);
return { ...dialogue, content: ROUNDTABLE_ROOM_FULL_MESSAGE };
return { ...dialogue, content: sanitized };
})
: [];
return {
@@ -25,7 +25,9 @@ import { Sparkles } from "lucide-react";
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
import {
isRoundtableCapacityFailure,
isRoundtableBusyFailure,
isRoundtableRoomFullFailure,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/lib/capacity-error";
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
@@ -88,10 +90,13 @@ export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
if (job.status === "done") {
setNotice("后台研讨已完成!可在「历史记录」查看结论与报告");
} else if (job.status === "error") {
const displayed = roundtableErrorForDisplay(job.error);
setNotice(
isRoundtableCapacityFailure(null, job.error)
isRoundtableRoomFullFailure(null, job.error)
? ROUNDTABLE_ROOM_FULL_MESSAGE
: "后台任务执行出错,请在「历史记录」中查看",
: isRoundtableBusyFailure(null, job.error) || (displayed && displayed !== job.error)
? displayed || ROUNDTABLE_ROOM_FULL_MESSAGE
: "后台任务执行出错,请在「历史记录」中查看",
);
}
// cancelled:用户主动取消,不提示。
@@ -82,6 +82,10 @@ import type {
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { chainToDagPlan } from "@/roundtable-planning/lib/dag-types";
import {
resolveDagMilestoneStates,
resolveLiveDagNodeStates,
} from "@/roundtable-planning/lib/dag-flow-state";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
@@ -368,61 +372,29 @@ export function Step2Panel(props: Step2PanelProps) {
},
[agentNameToRole],
);
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
const effectiveDagNodeStates = useMemo(() => {
const merged: Record<string, ChainNodeState> = {};
const deliveredNames = new Set(
step2RoundtableDialogues
.filter(
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
}
// 串行业务链(chain 模式):流式中的席位气泡点亮为 active(dagNodeStates 只在 dag
// 编排里维护,chain 靠对话推导;完成后由上面的「子智能体交付」规则置 done)。
if (orchestrationMode === "chain") {
const streamingSenders = new Set(
step2RoundtableDialogues
.filter((d) => d.streaming)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (streamingSenders.has(meta.displayName)) merged[aid.toLowerCase()] = "active";
}
}
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
for (const s of parallelBatch) {
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
}
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
return merged;
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch, orchestrationMode]);
// 流程图跟当前发言走:席位气泡已经在输出时,节点就是进行中。
// dagNodeStates 里残留的 pending 不能把这个实时状态盖掉,否则总控会一直转圈。
const effectiveDagNodeStates = useMemo(
() =>
resolveLiveDagNodeStates({
agentNameToRole,
dialogues: step2RoundtableDialogues,
dagNodeStates,
parallelBatch,
}),
[dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch],
);
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(effectiveDagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
const coordinator: ChainNodeState = anyActive
? "done"
: isOrchestrating
? "active"
: anyStarted
? "done"
: "pending";
const consensus: ChainNodeState = hasConsensus
? "done"
: allDone
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
const { dagCoordinatorState, dagConsensusState } = useMemo(
() =>
resolveDagMilestoneStates({
seatStates: effectiveDagNodeStates,
dialogues: step2RoundtableDialogues,
isOrchestrating,
hasConsensus,
}),
[effectiveDagNodeStates, step2RoundtableDialogues, isOrchestrating, hasConsensus],
);
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
@@ -22,6 +22,7 @@ import {
} from "@/components/workspace/artifacts";
import {
isRoundtableRoomFullError,
roundtableCardFailure,
type RoundtableRoomFullError,
} from "@/roundtable-planning/api/rooms";
import {
@@ -2356,7 +2357,8 @@ export function useStep2Orchestration(
continue;
}
console.error(`[special:${a.agentId}] error:`, err);
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(a.bubbleId, failure.content || "(已停止)", failure.badge);
return { content: "", aborted: false, invalid: true };
}
}
@@ -2438,10 +2440,11 @@ export function useStep2Orchestration(
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -2599,7 +2602,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`研讨异常: ${(err as Error).message}`);
triggerToast(`研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -2730,10 +2733,11 @@ export function useStep2Orchestration(
finishedAllSeats = false;
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -2838,7 +2842,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(subBubbleId, failure.content || "(已停止)", failure.badge);
seatInvalid = true;
}
} finally {
@@ -2949,7 +2954,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error("chain closeout error:", err);
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(closeoutBubbleId, failure.content || "(已停止)", failure.badge);
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
@@ -2961,7 +2967,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`链条研讨异常: ${(err as Error).message}`);
triggerToast(`链条研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -3138,10 +3144,11 @@ export function useStep2Orchestration(
finishedAllStages = false;
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -3297,7 +3304,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error(`[dag-special:${agentId}] error:`, err);
finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(bubbleId, failure.content || "(已停止)", failure.badge);
markBatchStatus(agentId, "error");
}
return { seatName, invalid: true, content: "" };
@@ -3386,7 +3394,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`依赖编排研讨异常: ${(err as Error).message}`);
triggerToast(`依赖编排研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -2,33 +2,108 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isRoundtableBusyFailure,
isRoundtableCapacityFailure,
roomDecisionMessage,
roundtableCardFailure,
roundtableErrorForDisplay,
RoundtableRoomFullError,
ROUNDTABLE_BUSY_MESSAGE,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "./capacity-error";
sanitizeRoundtableDialogue,
} from "./capacity-error.ts";
test("recognizes a direct HTTP 429", () => {
assert.equal(isRoundtableCapacityFailure(429, ""), true);
test("a direct HTTP 429 is model or worker busy, not a full room", () => {
assert.equal(isRoundtableCapacityFailure(429, ""), false);
assert.equal(isRoundtableBusyFailure(429, ""), true);
assert.equal(roundtableErrorForDisplay("", 429), ROUNDTABLE_BUSY_MESSAGE);
});
test("recognizes a 429 wrapped by the internal run stream gateway", () => {
test("a 429 wrapped by the internal run stream stays busy and hides the upstream URL", () => {
const detail =
"[502] upstream http://127.0.0.1:8001/api/threads/thread-secret/runs/stream returned HTTP 429: busy";
assert.equal(isRoundtableCapacityFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_ROOM_FULL_MESSAGE);
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
});
test("recognizes local concurrency and worker saturation messages", () => {
test("recognizes local concurrency and worker saturation messages as busy", () => {
const samples = [
"This account has reached the concurrency limit for the selected model",
"执行节点繁忙,请稍后重试",
"等待执行超时,请稍后重试",
"selected model is busy",
];
for (const detail of samples) {
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
}
});
test("only the room-full code opens the room-full message", () => {
const detail = JSON.stringify({
detail: { code: "ROUNDTABLE_ROOM_FULL", message: "当前会商房间暂时已满,请稍候片刻后再试。" },
});
assert.equal(isRoundtableCapacityFailure(409, detail), true);
assert.equal(isRoundtableBusyFailure(409, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 409), ROUNDTABLE_ROOM_FULL_MESSAGE);
assert.equal(roomDecisionMessage("ROUNDTABLE_ROOM_FULL"), ROUNDTABLE_ROOM_FULL_MESSAGE);
});
test("lease expiry and max hold keep their own messages", () => {
const lease = JSON.stringify({ code: "ROOM_LEASE_EXPIRED" });
const maxHold = JSON.stringify({ code: "ROOM_MAX_HOLD_EXCEEDED" });
assert.equal(isRoundtableCapacityFailure(409, lease), false);
assert.equal(isRoundtableCapacityFailure(409, maxHold), false);
assert.equal(roundtableErrorForDisplay(lease, 409), roomDecisionMessage("ROOM_LEASE_EXPIRED"));
assert.equal(
isRoundtableCapacityFailure(502, "This account has reached the concurrency limit for the selected model"),
true,
roundtableErrorForDisplay(maxHold, 409),
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
assert.equal(isRoundtableCapacityFailure(502, "执行节点繁忙,请稍后重试"), true);
assert.equal(isRoundtableCapacityFailure(502, "等待执行超时,请稍后重试"), true);
});
test("a plain HTTP 409 is not treated as a full room", () => {
const detail = "thread already has an active run";
assert.equal(isRoundtableCapacityFailure(409, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 409), detail);
});
test("does not hide an unrelated gateway error", () => {
const detail = "upstream returned HTTP 502: connection refused";
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 502), detail);
});
test("long seat deliveries that mention concurrency stay intact", () => {
const prose = `${"席位认为当前方案需要同时考虑资源并发和预算限制,建议先完成情报核对,再决定是否扩大行动范围。".repeat(20)}`;
assert.ok(prose.length > 800);
assert.equal(sanitizeRoundtableDialogue(prose), prose);
});
test("coordinator card does not relabel other failures as a full room", () => {
const lease = roundtableCardFailure(
new Error(JSON.stringify({ code: "ROOM_LEASE_EXPIRED" })),
);
assert.equal(lease.roomFull, false);
assert.equal(lease.badge, "调用失败");
assert.equal(lease.content.includes("当前房间已满"), false);
assert.equal(lease.content.includes("会商连接已断开"), true);
const busy = roundtableCardFailure(new Error("执行节点繁忙,请稍后重试"));
assert.equal(busy.badge, "调用失败");
assert.equal(busy.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
assert.equal(busy.content.includes(ROUNDTABLE_BUSY_MESSAGE), true);
const full = roundtableCardFailure(new RoundtableRoomFullError());
assert.equal(full.roomFull, true);
assert.equal(full.badge, "已停止");
assert.equal(full.content.includes("调用失败"), false);
assert.equal(full.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
});
test("error-shaped dialogue with an upstream address is replaced", () => {
const detail = "[502] upstream http://10.0.0.8/api/threads/abc/runs/stream returned HTTP 500";
assert.equal(sanitizeRoundtableDialogue(detail), "会商暂时无法继续,请稍后重试");
});
@@ -1,8 +1,63 @@
/** 会商容量不足时唯一允许展示给业务用户的文案。 */
/** 会商名额真正用尽时,唯一允许展示给业务用户的文案。 */
export const ROUNDTABLE_ROOM_FULL_MESSAGE = "当前房间已满,请稍后";
/** 模型并发或执行节点忙,不是会商房间名额。 */
export const ROUNDTABLE_BUSY_MESSAGE = "当前模型或执行节点繁忙,请稍后重试";
const GENERIC_ROUNDTABLE_FAILURE = "会商暂时无法继续,请稍后重试";
const ROOM_DECISION_MESSAGES: Record<string, string> = {
ROUNDTABLE_ROOM_FULL: ROUNDTABLE_ROOM_FULL_MESSAGE,
ROOM_LEASE_EXPIRED: "会商连接已断开,请重新恢复研讨。",
ROOM_MAX_HOLD_EXCEEDED:
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
ROOM_STORE_UNAVAILABLE: "会商并发服务暂不可用,请稍后再试。",
ROOM_SESSION_REQUIRED: "会商会话标识缺失,请刷新页面后重试。",
DRAFT_REQUIRED: "进入第二步需要先保存会商草稿",
};
export function roomDecisionMessage(code: string | null | undefined): string | null {
if (!code) return null;
return ROOM_DECISION_MESSAGES[code] ?? null;
}
function isBusyText(text: string): boolean {
return (
/returned\s+http\s+429\b/i.test(text) ||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
/\btoo\s+many\s+requests\b/i.test(text) ||
/\bconcurrency\s+limit\b/i.test(text) ||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
/执行节点繁忙|等待执行超时|模型繁忙/.test(text)
);
}
function readRoomCode(detail: string): string {
const match = detail.match(/["']code["']\s*:\s*["']([A-Z0-9_]+)["']/);
if (match?.[1]) return match[1];
try {
const body = JSON.parse(detail) as {
code?: unknown;
detail?: { code?: unknown };
};
const code = body.detail?.code ?? body.code;
return typeof code === "string" ? code : "";
} catch {
return "";
}
}
function isRoomFullText(text: string, code: string): boolean {
if (code === "ROUNDTABLE_ROOM_FULL") return true;
return (
text === ROUNDTABLE_ROOM_FULL_MESSAGE ||
text.includes("当前会商房间暂时已满")
);
}
/**
* 会商运行过程中遇到的容量错误。
* 会商运行过程中遇到的名额已满。
*
* 原始响应只能写入控制台 / 诊断日志,不能放进 message,避免内网地址、thread id
* 等实现细节经现有的 toast / 对话气泡 / 错误面板泄露到页面。
@@ -27,37 +82,133 @@ export function isRoundtableRoomFullError(error: unknown): error is RoundtableRo
}
/**
* Gateway 会把内部 `/runs/stream` 的 429 包装成 502 或 SSE error 帧,因此不能只看
* 最外层 HTTP 状态。这里同时识别包装后的状态文本和本地并发闸门的稳定错误文案。
* 总控/席位卡片上的失败展示。
* 名额用尽不写进卡片(由房间弹窗说明,标签也不再是「调用失败」)。
* 租约断开、模型繁忙仍标「调用失败」,但不得改写成房间已满文案。
*/
export function roundtableCardFailure(error: unknown): {
roomFull: boolean;
badge: string;
content: string;
} {
if (isRoundtableRoomFullError(error)) {
return {
roomFull: true,
badge: "已停止",
content: "",
};
}
const raw = error instanceof Error ? error.message : String(error ?? "");
const displayed = roundtableErrorForDisplay(raw);
const message =
displayed && displayed !== ROUNDTABLE_ROOM_FULL_MESSAGE
? displayed
: "会商暂时无法继续,请稍后重试";
return {
roomFull: false,
badge: "调用失败",
content: `(错误) ${message}`,
};
}
/** 只有会商房间名额用尽才返回 true。模型繁忙、租约丢失、最长占用都不是。 */
export function isRoundtableRoomFullFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
const text = (detail ?? "").trim();
if (!text && status !== 409) return false;
return isRoomFullText(text, readRoomCode(text));
}
/** 模型并发或执行节点忙。调用方不得据此释放会商房间。 */
export function isRoundtableBusyFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
if (isRoundtableRoomFullFailure(status, detail)) return false;
if (status === 429) return true;
const text = (detail ?? "").trim();
return text ? isBusyText(text) : false;
}
/**
* 旧名保留给调用方。语义收窄为「房间名额已满」,不再把 429 / 节点繁忙算进去。
*/
export function isRoundtableCapacityFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
if (status === 429) return true;
const text = (detail ?? "").trim();
if (!text) return false;
if (text === ROUNDTABLE_ROOM_FULL_MESSAGE) return true;
return (
/returned\s+http\s+429\b/i.test(text) ||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
/\btoo\s+many\s+requests\b/i.test(text) ||
/\bconcurrency\s+limit\b/i.test(text) ||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
/执行节点繁忙|等待执行超时|并发(?:数|量)?.*(?:上限|限制)|模型繁忙/.test(text)
);
return isRoundtableRoomFullFailure(status, detail);
}
/** 页面可展示的错误详情;容量错误永远折叠为统一文案。 */
function readCounts(detail: string): { activeCount: number; maxRooms: number } {
try {
const body = JSON.parse(detail) as {
detail?: { active_count?: number; max_rooms?: number };
active_count?: number;
max_rooms?: number;
};
return {
activeCount: body.detail?.active_count ?? body.active_count ?? 0,
maxRooms: body.detail?.max_rooms ?? body.max_rooms ?? 0,
};
} catch {
return { activeCount: 0, maxRooms: 0 };
}
}
export function roundtableRoomFullError(
rawDetail: string,
status: number | null,
context: string,
): RoundtableRoomFullError {
const counts = readCounts(rawDetail);
console.error(`[roundtable-capacity] ${context}`, {
status,
detail: rawDetail,
});
return new RoundtableRoomFullError(counts.activeCount, counts.maxRooms);
}
function containsTransportDetail(text: string): boolean {
return /https?:\/\//i.test(text) || /\/api\/threads\//i.test(text);
}
/** 页面可展示的错误详情。房间已满、节点繁忙和已知房间错误都折叠成固定文案。 */
export function roundtableErrorForDisplay(
detail: string | null | undefined,
status?: number | null,
): string | null {
if (detail == null) return null;
return isRoundtableCapacityFailure(status, detail)
? ROUNDTABLE_ROOM_FULL_MESSAGE
: detail;
if (detail == null && status !== 429) return null;
const text = (detail ?? "").trim();
if (isRoundtableRoomFullFailure(status, text)) return ROUNDTABLE_ROOM_FULL_MESSAGE;
if (isRoundtableBusyFailure(status, text)) return ROUNDTABLE_BUSY_MESSAGE;
if (!text) return detail ?? null;
const code = readRoomCode(text);
const known = ROOM_DECISION_MESSAGES[code];
if (known) return known;
if (containsTransportDetail(text)) return GENERIC_ROUNDTABLE_FAILURE;
return detail;
}
/**
* 只改写明显是错误载荷的短文本。正常席位交付即使提到「并发」「限制」也保持原文。
*/
export function sanitizeRoundtableDialogue(content: string): string {
const text = content.trim();
if (!text) return content;
const errorShaped =
text.length <= 800 ||
/^\[\d{3}\]/.test(text) ||
text.includes("returned HTTP") ||
text.includes("ROUNDTABLE_ROOM_") ||
text.includes("ROOM_LEASE_") ||
text.includes("ROOM_MAX_HOLD_") ||
text.includes("ROOM_STORE_") ||
text.includes("ROOM_SESSION_");
if (!errorShaped) return content;
const display = roundtableErrorForDisplay(text);
if (!display || display === text) return content;
return display;
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDagMilestoneStates, resolveLiveDagNodeStates } from "./dag-flow-state.ts";
const roles = {
"seat-01": { displayName: "测试01" },
"seat-02": { displayName: "测试02" },
};
test("a streaming first seat takes the in-progress marker off the coordinator", () => {
const seats = resolveLiveDagNodeStates({
agentNameToRole: roles,
dialogues: [
{ sender: "总控协调", streaming: false, confidence: "派活说明", content: "派给测试01" },
{ sender: "测试01", streaming: true, confidence: "调度中", content: "" },
],
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
parallelBatch: [],
});
assert.equal(seats["seat-01"], "active");
assert.equal(seats["seat-02"], "pending");
const milestones = resolveDagMilestoneStates({
seatStates: seats,
dialogues: [
{ sender: "总控协调", streaming: false },
{ sender: "测试01", streaming: true },
],
isOrchestrating: true,
hasConsensus: false,
});
assert.equal(milestones.coordinator, "done");
});
test("the coordinator stays in progress while it is still the one speaking", () => {
const seats = resolveLiveDagNodeStates({
agentNameToRole: roles,
dialogues: [{ sender: "总控协调", streaming: true, confidence: "派活中", content: "" }],
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
parallelBatch: [],
});
const milestones = resolveDagMilestoneStates({
seatStates: seats,
dialogues: [{ sender: "总控协调", streaming: true }],
isOrchestrating: true,
hasConsensus: false,
});
assert.equal(milestones.coordinator, "active");
assert.equal(seats["seat-01"], "pending");
});
@@ -0,0 +1,92 @@
import type { ChainNodeState } from "./job-types.ts";
/** 与 seat-filter 的 COORDINATOR_SENDER 相同。这里单独写出,避免测试去拉前端模块图。 */
const COORDINATOR_SENDER = "总控协调";
export interface DagFlowDialogue {
sender?: string;
streaming?: boolean;
confidence?: string;
content?: string;
}
export interface DagFlowSeatMeta {
displayName: string;
}
/**
* 流程图节点状态。
*
* 实时发言以对话气泡为准:席位气泡已经在流式输出时,节点必须是进行中,
* 不能被一份还停在 pending 的 dagNodeStates 盖回去。总控只在自己发言、
* 且还没有任何席位进行中时保持进行中。
*/
export function resolveLiveDagNodeStates(input: {
agentNameToRole: Record<string, DagFlowSeatMeta>;
dialogues: readonly DagFlowDialogue[];
dagNodeStates: Record<string, ChainNodeState>;
parallelBatch: readonly { agentId: string; status: string }[];
}): Record<string, ChainNodeState> {
const merged: Record<string, ChainNodeState> = {};
const keyOf = (value: string) => value.trim().toLowerCase();
const streamingSenders = new Set(
input.dialogues.filter((d) => d.streaming).map((d) => keyOf(String(d.sender ?? ""))),
);
const deliveredSenders = new Set(
input.dialogues
.filter(
(d) =>
d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => keyOf(String(d.sender ?? ""))),
);
const matches = (agentId: string, displayName: string, senders: Set<string>) =>
senders.has(keyOf(displayName)) || senders.has(keyOf(agentId));
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
if (matches(agentId, meta.displayName, deliveredSenders)) {
merged[keyOf(agentId)] = "done";
}
}
for (const seat of input.parallelBatch) {
merged[keyOf(seat.agentId)] = seat.status === "done" ? "done" : "active";
}
for (const [agentId, state] of Object.entries(input.dagNodeStates)) {
const key = keyOf(agentId);
if (state === "pending" && (merged[key] === "active" || merged[key] === "done")) {
continue;
}
merged[key] = state;
}
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
if (matches(agentId, meta.displayName, streamingSenders)) {
merged[keyOf(agentId)] = "active";
}
}
return merged;
}
export function resolveDagMilestoneStates(input: {
seatStates: Record<string, ChainNodeState>;
dialogues: readonly DagFlowDialogue[];
isOrchestrating: boolean;
hasConsensus: boolean;
}): { coordinator: ChainNodeState; consensus: ChainNodeState } {
const states = Object.values(input.seatStates);
const anyActive = states.includes("active");
const anyStarted = states.some((state) => state !== "pending");
const coordinatorStreaming = input.dialogues.some(
(d) => d.streaming && String(d.sender ?? "") === COORDINATOR_SENDER,
);
const coordinator: ChainNodeState = anyActive
? "done"
: coordinatorStreaming || (input.isOrchestrating && !anyStarted)
? "active"
: anyStarted
? "done"
: "pending";
const allDone = states.length > 0 && states.every((state) => state === "done");
const consensus: ChainNodeState = input.hasConsensus ? "done" : allDone ? "active" : "pending";
return { coordinator, consensus };
}
@@ -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>
@@ -164,6 +164,10 @@ import {
releaseRoundtableRoom,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/api/rooms";
import {
isRoundtableRoomFullFailure,
roomDecisionMessage,
} from "@/roundtable-planning/lib/capacity-error";
import { isRoundtableRoomExecutionActive } from "@/roundtable-planning/lib/room-lifecycle";
import {
Dialog,
@@ -492,10 +496,13 @@ export default function RoundtablePlanningPage({
roomSessionId: roomSessionIdRef.current,
});
const roomHoldRef = useRef(false);
const roomBlockRef = useRef<string | null>(null);
const roomReleasePromiseRef = useRef<Promise<unknown> | null>(null);
const roomAcquirePromiseRef = useRef<Promise<boolean> | null>(null);
const roomHandoffRef = useRef(false);
const roomEffectGenRef = useRef(0);
const [roomFull, setRoomFull] = useState(false);
const [roomLeaseEpoch, setRoomLeaseEpoch] = useState(0);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
@@ -1347,14 +1354,30 @@ export default function RoundtablePlanningPage({
const taskId = draftStorageTaskId ?? null;
roomSessionIdRef.current = createRoomSessionId();
const roomSessionId = roomSessionIdRef.current;
roomBlockRef.current = null;
roomScopeRef.current = { draftId, taskId, roomSessionId };
const acquirePromise = (async () => {
try {
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
if (!decision.ok) {
setRoomFull(true);
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
} else {
triggerToast(
decision.message ||
roomDecisionMessage(decision.code) ||
"暂时不能进入第二步",
);
}
return false;
}
roomBlockRef.current = null;
roomHoldRef.current = true;
return true;
} catch (error) {
@@ -1371,6 +1394,85 @@ export default function RoundtablePlanningPage({
},
[drafts, draftStorageTaskId, triggerToast],
);
const reacquireLiveRoom = useCallback(async (force = false) => {
if (!force && roomHoldRef.current) return true;
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
roomHoldRef.current = false;
triggerToast(
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
return false;
}
if (roomAcquirePromiseRef.current) return roomAcquirePromiseRef.current;
const pendingRelease = roomReleasePromiseRef.current;
if (pendingRelease) {
try {
await pendingRelease;
} catch {
// 释放是尽力而为,失败也不阻止用户重新占用同一个房间。
}
}
const scope = roomScopeRef.current;
if (!scope.draftId || !scope.roomSessionId) {
triggerToast("会商草稿尚未保存,暂时不能恢复");
return false;
}
const { draftId, taskId, roomSessionId } = scope;
const acquirePromise = (async () => {
try {
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
if (!decision.ok) {
roomHoldRef.current = false;
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
} else {
triggerToast(
decision.message ||
roomDecisionMessage(decision.code) ||
"暂时不能恢复会商",
);
}
return false;
}
roomBlockRef.current = null;
roomHoldRef.current = true;
drafts.loadedFromDraftRef.current = false;
setRoomLeaseEpoch((epoch) => epoch + 1);
return true;
} catch (error) {
roomHoldRef.current = false;
triggerToast(
`无法确认会商房间:${error instanceof Error ? error.message : "请稍后再试"}`,
);
return false;
} finally {
roomAcquirePromiseRef.current = null;
}
})();
roomAcquirePromiseRef.current = acquirePromise;
return acquirePromise;
}, [drafts, triggerToast]);
const resumeLiveRoundtable = useCallback(() => {
void (async () => {
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
triggerToast(
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
return;
}
const admitted = await reacquireLiveRoom(true);
if (!admitted) return;
step2.resumeRoundtable();
})();
}, [reacquireLiveRoom, step2, triggerToast]);
const handleConfirmAgents = useCallback(
(result: SeatSelectionResult) => {
void (async () => {
@@ -2320,16 +2422,31 @@ export default function RoundtablePlanningPage({
return;
}
roomHoldRef.current = false;
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
}
triggerToast(
decision.message || "本次会商房间已释放,研讨已停止。记录仍留在当前页。",
);
void releaseRoundtableRoom({
const releasePromise = releaseRoundtableRoom({
draftId,
taskId: scope.taskId,
roomSessionId: scope.roomSessionId,
}).catch(() => {});
roomReleasePromiseRef.current = releasePromise;
void releasePromise.finally(() => {
if (roomReleasePromiseRef.current === releasePromise) {
roomReleasePromiseRef.current = null;
}
});
})
.catch(() => {
// 心跳请求失败只能说明本次续租请求没有得到响应,不能据此终止仍在
@@ -2369,7 +2486,9 @@ export default function RoundtablePlanningPage({
currentStep,
step2.hasConsensus,
step2.stopOrchestration,
step2.setIsRoundTableRunning,
triggerToast,
roomLeaseEpoch,
]);
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
@@ -2891,7 +3010,7 @@ export default function RoundtablePlanningPage({
openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onResumeRoundtable={resumeLiveRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
+178 -19
View File
@@ -1,5 +1,11 @@
import { Bell, ChevronDown, ListTree, User } from "lucide-react";
import { useState } from "react";
import {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -19,8 +25,16 @@ 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 { readRuntimeEnv } from "@/env";
import { isLightRedTopNavEnabled, readRuntimeEnv } from "@/env";
import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import {
@@ -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(
@@ -69,10 +91,49 @@ const Header = ({ collapsed }: HeaderProps) => {
: isPaleLightTheme
? "text-[var(--foreground)]"
: "text-white";
const darkLogoPosition =
const isLightRedTopNav = isLightRedTopNavEnabled(theme);
const topNavLogoPosition =
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
const isDarkLogoOnLeft = darkLogoPosition === "left";
const systemName = useAppSystemName("CM业务中心");
const isTopNavLogoOnLeft = topNavLogoPosition === "left";
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();
@@ -82,16 +143,94 @@ 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() || "";
const displayName = liveAccount.displayName;
// 深色侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
const showTopNav = isDark && !isWujieEnv();
// 深色与可选浅红色皮肤共用同一套导航结构;两者只替换背景与选中态配色。
// 侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
const showTopNav = (isDark || isLightRedTopNav) && !isWujieEnv();
// 浅红主题继续使用原浅色侧栏承载三级菜单,避免与顶部横栏重复。
const showThirdSubNav = isDark && !isWujieEnv();
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isWujieEnv();
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);
@@ -272,17 +411,23 @@ const Header = ({ collapsed }: HeaderProps) => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
<header
className={cn(
"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 ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
<div
className={cn(
"flex h-full min-w-0 flex-1 items-center",
isDarkLogoOnLeft ? "justify-between" : "justify-end",
isTopNavLogoOnLeft ? "justify-between" : "justify-end",
)}
>
{isDarkLogoOnLeft && (
{isTopNavLogoOnLeft && (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-950/55"
title="系统 Logo"
@@ -304,15 +449,29 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
<div className="header-center-title">
{darkLogoPosition === "center" && (
<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>
@@ -343,7 +502,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="一级菜单">
@@ -377,7 +536,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", headerTextClass)}>
{desensitizeText(systemName)}
{displaySystemName}
</span>
)}
</div>
@@ -389,7 +548,7 @@ const Header = ({ collapsed }: HeaderProps) => {
)}
</header>
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
{showTopNav && thirdSubNavItems.length ? (
{showThirdSubNav && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
@@ -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">
+63 -7
View File
@@ -1528,19 +1528,38 @@ html.dark .strategy-app-header {
background-size: cover;
}
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
/* 红白主题可通过 runtime-config.js 选择复用深色顶栏结构,仅替换为浅红设计稿。 */
.strategy-app-header.strategy-app-header--light-red-top-nav {
background-image: var(
--light-red-top-nav-background-image,
url("../assets/img/top-bg-light-red.svg")
);
background-size: cover;
}
/* 深色顶栏:居中系统名称落在背景图中间的发光梯形基座上。
背景原稿为 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",
@@ -1565,7 +1584,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,
@@ -1582,6 +1606,38 @@ 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,
#ffffff 0%,
#fff8f7 42%,
#ffffff 100%
);
-webkit-background-clip: text;
background-clip: text;
filter: drop-shadow(0 0 12px rgba(161, 32, 24, 0.38));
}
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(CSS 变量管不到的地方)────────── */
html.lightblue .strategy-app-header {
background-image: url("../assets/img/blue-header.png");
+89 -12
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,11 +8,63 @@ 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,
// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。
// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload()
// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk。
// Chrome 在链接 `import { jsx } from ...` 时若没有本地 named export,会直接
// SyntaxError,模块体根本不会执行,页面就白屏卡死。缺失 chunk 一律补本地
// jsx 导出再刷新;真实 jsx-runtime 也补上 named export,兼容旧模块图。
const staleDepsChunkSelfReload = (): Plugin => ({
name: "stale-deps-chunk-self-reload",
configureServer(server) {
@@ -23,17 +75,28 @@ const staleDepsChunkSelfReload = (): Plugin => ({
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.end(
"if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" +
"sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" +
"location.reload();}",
);
res.setHeader("Cache-Control", "no-store");
res.end(staleDepsModuleSource());
});
},
});
@@ -91,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.
+27 -2
View File
@@ -1,5 +1,27 @@
# CLAUDE.md
## Model-independent skill invocation attribution (2026-11-07)
Never derive skill-use UI or counters from model narration or the optional tool
`description`. The canonical signals are a successful `skill_view`, a successful
`read_file` under `/mnt/skills/{public,custom}/<skill>/...`, and an executed
`bash` command containing such a skill path. `sandbox/tools.py` resolves names
through the active skill view so a directory/frontmatter-name mismatch remains
correct; both public and custom skills count.
`skills/usage.py::bump_use(..., event_key=run_id)` de-duplicates all file/script
signals to one use per skill per user run. It updates `.usage.json` and schedules
an absolute, monotonic update of the `skills` MySQL catalog projection on the
engine home loop. Keep this bridge inside `deerflow.*`; the harness must not
import `app.*`. The skill-card counter is intentionally independent of the
heavier full-tool audit switch. `skill_metrics_enabled` defaults on and mounts
`ToolMetricsMiddleware(skill_only=True)` while `tool_metrics_enabled` is off;
non-skill calls pass through without DB work, but attributed skill calls still
feed audit pages and leaderboards. Frontend labels are recovered by
`core/tools/skill-invocation.ts` from explicit skill-tool arguments or skill
paths. Regression coverage lives in `test_skill_usage_signals.py`,
`test_skill_view_fast_path.py`, and `skill-invocation.test.ts`.
## Cold agent construction (2026-10-30)
`runtime/runs/worker.py` dispatches opted-in synchronous factories through
@@ -549,7 +571,10 @@ handoff replaces the foreground lease with the durable job occupancy. There is
no per-user pool. Admission is serialized by a database gate row, and the cap
is enforced at acquire, init, every model-producing run, and background-job
start. Full pools return HTTP 409; an enabled gate with missing storage or a
missing session id fails closed. The switch applies on the next admission
missing session id fails closed. Foreground leases last ``lease_minutes``
(default 5, range 1–60) and are renewed by the browser heartbeat; changing the
setting applies on the next admission or heartbeat and never extends
``max_hold_minutes``. The switch applies on the next admission
without a restart. Position and eight-department roundtables are not counted.
Roundtable backend jobs use a database unique active-dedupe key plus a leased
@@ -837,7 +862,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. |
+27 -7
View File
@@ -1,5 +1,18 @@
# DeerFlow Backend
## 技能调用识别与统计(2026-11-07)
技能调用的展示和“调用次数”不再依赖模型主动说明。后端以真实工具信号为准:
成功的 `skill_view`、对 `/mnt/skills/{public,custom}/<name>/...` 的文件读取,
以及 `bash` 直接执行该目录下的技能脚本,都会识别为对应技能调用。一次问答中同一技能即使先读
`SKILL.md`、再读参考文件并执行多个脚本,也只累计一次;不同问答分别累计。
计数会同时更新 `.usage.json` 和技能列表使用的数据库投影,因此内置技能与自定义技能都能在
页面刷新后看到最新调用次数。对话步骤也直接从 `skill_view` 参数或真实技能路径显示
“调用技能「名称」”;弱模型未填写工具描述、未在正文里自述技能名时仍可识别。
完整工具审计仍由 `runtime_performance.tool_metrics_enabled` 独立控制;默认开启的
`skill_metrics_enabled` 只把可归因到具体技能的低频调用写入统计表,供技能排行和调用统计使用。
## 首次问答初始化响应性(2026-10-30)
普通问答、开放问答接口和工作流智能体节点的模型 SDK 加载、客户端初始化及
@@ -1715,7 +1728,9 @@ Multi-agent roundtable step 2 has a separate room cap. Administrators change
it under Settings → 会商并发 (`roundtable_room_limit`, default on, 3 rooms)
without restarting. The count is the number of live roundtable executions
across every account (foreground leases/live LangGraph runs plus executing
background jobs), not the current login. Merely opening a tab or viewing chat
background jobs), not the current login. A foreground lease stays occupied for
``lease_minutes`` without a renewal (default 5 minutes, range 1–60) and is
refreshed by the page heartbeat. Merely opening a tab or viewing chat
history does not count, but two tabs that both run the same saved draft count
as two rooms. All seats and Step 3 work in one execution share one room;
foreground-to-background handoff keeps one room. A full pool keeps the user on
@@ -1815,11 +1830,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
@@ -1827,13 +1843,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 沙箱初始化、命令转换和输出脱敏不再反复刷新。下一次工具调用重新验证,
不跨问答永久沿用启停状态。
@@ -186,6 +186,15 @@ def current_user_id(request: Request) -> str:
return get_effective_user_id()
def _lease_seconds(settings: Any) -> int:
minutes = getattr(settings, "lease_minutes", 5)
try:
minutes = int(minutes)
except (TypeError, ValueError):
minutes = 5
return max(1, min(60, minutes)) * 60
def _store(request: Request):
return getattr(request.app.state, "roundtable_room_store", None)
@@ -232,6 +241,7 @@ async def admit_roundtable_room(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
lease_seconds=_lease_seconds(settings),
extra_keys=extra_keys,
)
@@ -265,6 +275,7 @@ async def renew_roundtable_room(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
lease_seconds=_lease_seconds(settings),
extra_keys=extra_keys,
)
@@ -425,6 +436,7 @@ async def enforce_roundtable_run(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
lease_seconds=_lease_seconds(settings),
extra_keys=await backend_occupancy_keys(request),
)
if not decision.get("ok", False):
@@ -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
@@ -961,6 +996,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,
@@ -1876,6 +2030,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
@@ -369,6 +370,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,
@@ -1305,6 +1324,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),
@@ -1339,9 +1362,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"])
@@ -1360,8 +1405,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))
@@ -1603,6 +1689,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)
@@ -1616,8 +1703,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))
@@ -1730,6 +1825,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(
@@ -1784,6 +1887,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:
@@ -1869,8 +1979,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,
@@ -1889,7 +2007,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
@@ -1941,6 +2067,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:
@@ -2279,6 +2411,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:
@@ -2309,10 +2543,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
@@ -2775,6 +3025,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,
@@ -2826,7 +3098,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:
@@ -2852,6 +3135,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()
@@ -2911,10 +3203,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,
@@ -2928,6 +3229,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)
@@ -3010,6 +3318,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)
@@ -16,15 +16,20 @@ from app.gateway.deps import get_current_user_from_request, get_optional_user_fr
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AIWritingPageDescription,
AppearanceRouteParam,
AuthenticationSettings,
CallMetricsSettings,
CompactShortcutButton,
ConversationContextSettings,
CustomThemePreset,
GeneralQaPageDescriptions,
HtmlMapSettings,
LeaderboardSettings,
ModelConcurrencySettings,
ModelPromptSuffix,
PageDescriptionsSettings,
PageHeaderDescription,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
@@ -33,6 +38,7 @@ from deerflow.config.system_settings import (
RetryKeepPartialSettings,
RoundtableRoomLimitSettings,
SkillCompressionSettings,
SkillVersioningSettings,
StrategyProjectSettings,
WordHeadingNumberingStyle,
WriteFileCompatibilitySettings,
@@ -208,6 +214,91 @@ 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):
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_descriptions_response(load_system_settings().page_descriptions)
@router.put("/page-descriptions", response_model=PageDescriptionsResponse)
async def update_page_descriptions(
body: PageDescriptionsUpdateRequest,
request: Request,
) -> PageDescriptionsResponse:
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)
@@ -491,6 +582,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")
@@ -532,6 +631,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,
@@ -579,6 +689,7 @@ class RoundtableRoomLimitResponse(BaseModel):
enabled: bool = True
max_rooms: int = 3
max_hold_minutes: int = 30
lease_minutes: int = 5
max_cycles: int = 8
write_file_compatibility_enabled: bool = False
@@ -587,6 +698,7 @@ class RoundtableRoomLimitUpdateRequest(BaseModel):
enabled: bool | None = None
max_rooms: int | None = Field(default=None, ge=1, le=20)
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
lease_minutes: int | None = Field(default=None, ge=1, le=60)
max_cycles: int | None = Field(default=None, ge=2, le=100)
write_file_compatibility_enabled: bool | None = None
@@ -667,6 +779,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)."""
@@ -734,6 +872,7 @@ def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> Ro
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=settings.max_hold_minutes,
lease_minutes=settings.lease_minutes,
max_cycles=settings.max_cycles,
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
)
@@ -760,6 +899,8 @@ async def update_roundtable_room_limit_setting(
updates["max_rooms"] = body.max_rooms
if body.max_hold_minutes is not None:
updates["max_hold_minutes"] = body.max_hold_minutes
if body.lease_minutes is not None:
updates["lease_minutes"] = body.lease_minutes
if body.max_cycles is not None:
updates["max_cycles"] = body.max_cycles
if body.write_file_compatibility_enabled is not None:
@@ -767,10 +908,11 @@ async def update_roundtable_room_limit_setting(
updated = current.roundtable_room_limit.model_copy(update=updates)
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
logger.info(
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s lease_minutes=%s max_cycles=%s write_file_compatibility=%s",
saved.roundtable_room_limit.enabled,
saved.roundtable_room_limit.max_rooms,
saved.roundtable_room_limit.max_hold_minutes,
saved.roundtable_room_limit.lease_minutes,
saved.roundtable_room_limit.max_cycles,
saved.roundtable_room_limit.write_file_compatibility_enabled,
)
@@ -917,6 +1059,42 @@ async def update_skill_compression(body: SkillCompressionUpdateRequest, request:
return _skill_compression_to_response(saved.skill_compression)
@router.get("/skill-versioning", response_model=SkillVersioningResponse)
async def get_skill_versioning() -> SkillVersioningResponse:
"""Return the custom-skill versioning gate for skill detail clients."""
return SkillVersioningResponse(enabled=load_system_settings().skill_versioning.enabled)
@router.put("/skill-versioning", response_model=SkillVersioningResponse)
async def update_skill_versioning(
body: SkillVersioningUpdateRequest,
request: Request,
) -> SkillVersioningResponse:
"""Enable/disable custom-skill version capture and switching. Admin only."""
await _require_admin(request)
current = load_system_settings()
previous = current.skill_versioning.enabled
updated = SkillVersioningSettings(enabled=body.enabled)
saved = save_system_settings(current.model_copy(update={"skill_versioning": updated}))
if body.enabled and not previous:
try:
from deerflow.skills.versioning import reconcile_custom_skill_versions
actor = await get_optional_user_from_request(request)
actor_id = str(getattr(actor, "id", "") or "") or None
await asyncio.to_thread(reconcile_custom_skill_versions, author=actor_id)
except Exception:
logger.exception("Failed to capture custom-skill baselines after enabling versioning")
# The switch remains enabled. Individual skills are bootstrapped
# lazily when their version list is first opened or next changed.
logger.info("Custom skill versioning switch changed: enabled=%s", saved.skill_versioning.enabled)
return SkillVersioningResponse(enabled=saved.skill_versioning.enabled)
@router.get("/model-concurrency", response_model=ModelConcurrencyResponse)
async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse:
"""Return the per-model concurrency-limit configuration. Admin only."""
@@ -19,6 +19,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_checkpointer, get_thread_share_store, get_thread_store
from app.gateway.routers.artifacts import build_html_offline_bundle_response
from app.gateway.thread_copy import copy_thread_state
from deerflow.config.paths import get_paths
from deerflow.persistence.thread_meta.base import ThreadMetaStore
@@ -316,6 +317,7 @@ def _public_share_disposition(mime_type: str) -> str:
async def get_public_share_file(
share_code: str,
path: str,
offline_bundle: bool = False,
share_store: ThreadShareStore = Depends(get_thread_share_store),
) -> FileResponse:
"""Serve a file referenced by a view-shared conversation.
@@ -342,6 +344,11 @@ async def get_public_share_file(
if not actual_path.exists() or not actual_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
if offline_bundle:
if actual_path.suffix.lower() not in {".htm", ".html", ".xhtml"}:
raise HTTPException(status_code=400, detail="Offline bundles are available for HTML files only")
return await build_html_offline_bundle_response(actual_path)
mime_type = mimetypes.guess_type(actual_path.name)[0] or "application/octet-stream"
return FileResponse(
actual_path,
@@ -252,6 +252,29 @@ async def refresh_skill_catalog_projection(
return len(catalog)
async def set_skill_catalog_enabled(name: str, enabled: bool) -> bool:
"""Persist one skill's online flag in the catalog row the list API reads.
The extensions file is the runtime source of truth. The skills page reads
``skills.enabled`` instead, so a toggle that only rewrites the file looks
unchanged after the list refreshes. Updating this single row avoids a full
catalog rebuild, which rewrites every skill and can roll the flag back if
an unrelated row fails.
"""
session_factory = get_session_factory()
if session_factory is None or not name:
return False
async with session_factory() as session:
result = await session.execute(
update(SkillRow)
.where(SkillRow.name == name)
.values(enabled=bool(enabled), updated_at=datetime.now(UTC))
)
await session.commit()
return int(result.rowcount or 0) > 0
def _build_catalog_payload(skills: list[Any], usage_entries: dict[str, Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
defaults = {
+7 -3
View File
@@ -2,9 +2,10 @@
config_version: 8
log_level: info
# 高并发运行时优化。技能视图两项不写也默认开启,要关掉才写成 false。修改后需重启后台。
# 高并发运行时优化。工具/技能统计与技能视图默认开启;若确有写库压力才改成 false。修改后需重启后台。
runtime_performance:
tool_metrics_enabled: false # 暂停工具/技能统计写库;不影响问答与工具执行。恢复统计时设 true 并重启
tool_metrics_enabled: true # 记录普通工具调用,供综合管理的工具排行/调用统计使用
skill_metrics_enabled: true # 记录可归因到具体技能的调用,供综合管理的技能排行/调用统计使用
skill_view_reuse_enabled: true # 默认开启;false 才关闭。单次工具调用复用一次技能视图
skill_view_cache_enabled: true # 默认开启;false 才关闭。校验版本后复用未变化的技能视图
model_timing_nonblocking_enabled: true # 模型计时日志独立队列,不争抢工具线程池
@@ -661,7 +662,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
}
}
}
@@ -264,17 +264,17 @@ def _get_enabled_skills():
def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[Skill]:
"""Return enabled skills using the caller's config source.
Resolve storage from the caller's config and reuse the parsed catalogue only
when its root matches. A cold cache or a different root falls back to that
storage, keeping request-scoped skill directories isolated.
Resolve storage from the caller's config and reuse the parsed catalogue only
when its root matches. A cold cache or a different root falls back to that
storage, keeping request-scoped skill directories isolated.
"""
if app_config is None:
return _get_enabled_skills()
storage = get_or_new_skill_storage(app_config=app_config)
warm = get_warm_enabled_skills(storage)
if warm is not None:
return warm
return _apply_always_on(_drop_archived_skills(list(storage.load_skills(enabled_only=True))))
storage = get_or_new_skill_storage(app_config=app_config)
warm = get_warm_enabled_skills(storage)
if warm is not None:
return warm
return _apply_always_on(_drop_archived_skills(list(storage.load_skills(enabled_only=True))))
def _skill_mutability_label(category: SkillCategory | str) -> str:
@@ -617,44 +617,44 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
</subagent_system>"""
_ORDINARY_QA_HEADING_RULES = {
"hybrid_decimal": """- 一级章节使用 `## 一、标题`,二级章节使用 `### 1.1 标题`,三级章节使用 `#### 1.1.1 标题`。
- 二级章节按 `1.1`、`1.2`、`2.1` 的形式编号,三级及更深层级继续使用完整多级阿拉伯数字。
- 确需更深层级时,四级章节使用 `##### 1.1.1.1 标题`,五级章节使用 `###### 1.1.1.1.1 标题`。""",
"official_chinese": """- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。
- 确需更深层级时,四级章节使用 `##### (1) 标题`,五级章节使用 `###### 1) 标题`。""",
"decimal": """- 一级章节使用 `## 1 标题`,二级章节使用 `### 1.1 标题`,三级章节使用 `#### 1.1.1 标题`。
- 确需更深层级时,继续使用 `##### 1.1.1.1 标题`、`###### 1.1.1.1.1 标题`。""",
"chapter_section": """- 一级章节使用 `## 第一章 标题`,二级章节使用 `### 第一节 标题`,三级章节使用 `#### 一、标题`。
- 确需更深层级时,四级章节使用 `##### (一) 标题`,五级章节使用 `###### 1. 标题`。""",
"none": """- 各级章节只写标题文字,不手写编号:`## 一级标题`、`### 二级标题`、`#### 三级标题`。
- 确需更深层级时使用 `##### 四级标题`、`###### 五级标题`,仍不添加编号。""",
}
def _word_heading_numbering_style() -> str:
"""Return the configured Word heading-numbering preset with a safe default."""
try:
return load_system_settings().prompt_prefix.word_heading_numbering_style
except Exception:
logger.debug("Failed to read the Word heading-numbering style; using default", exc_info=True)
return "hybrid_decimal"
def _build_ordinary_qa_markdown_format_section() -> str:
rules = _ORDINARY_QA_HEADING_RULES.get(
_word_heading_numbering_style(),
_ORDINARY_QA_HEADING_RULES["hybrid_decimal"],
)
return f"""
<ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
{rules}
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format>
"""
_ORDINARY_QA_HEADING_RULES = {
"hybrid_decimal": """- 一级章节使用 `## 一、标题`,二级章节使用 `### 1.1 标题`,三级章节使用 `#### 1.1.1 标题`。
- 二级章节按 `1.1`、`1.2`、`2.1` 的形式编号,三级及更深层级继续使用完整多级阿拉伯数字。
- 确需更深层级时,四级章节使用 `##### 1.1.1.1 标题`,五级章节使用 `###### 1.1.1.1.1 标题`。""",
"official_chinese": """- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。
- 确需更深层级时,四级章节使用 `##### (1) 标题`,五级章节使用 `###### 1) 标题`。""",
"decimal": """- 一级章节使用 `## 1 标题`,二级章节使用 `### 1.1 标题`,三级章节使用 `#### 1.1.1 标题`。
- 确需更深层级时,继续使用 `##### 1.1.1.1 标题`、`###### 1.1.1.1.1 标题`。""",
"chapter_section": """- 一级章节使用 `## 第一章 标题`,二级章节使用 `### 第一节 标题`,三级章节使用 `#### 一、标题`。
- 确需更深层级时,四级章节使用 `##### (一) 标题`,五级章节使用 `###### 1. 标题`。""",
"none": """- 各级章节只写标题文字,不手写编号:`## 一级标题`、`### 二级标题`、`#### 三级标题`。
- 确需更深层级时使用 `##### 四级标题`、`###### 五级标题`,仍不添加编号。""",
}
def _word_heading_numbering_style() -> str:
"""Return the configured Word heading-numbering preset with a safe default."""
try:
return load_system_settings().prompt_prefix.word_heading_numbering_style
except Exception:
logger.debug("Failed to read the Word heading-numbering style; using default", exc_info=True)
return "hybrid_decimal"
def _build_ordinary_qa_markdown_format_section() -> str:
rules = _ORDINARY_QA_HEADING_RULES.get(
_word_heading_numbering_style(),
_ORDINARY_QA_HEADING_RULES["hybrid_decimal"],
)
return f"""
<ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
{rules}
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format>
"""
_ORDINARY_QA_VISUALIZATION_SECTION = """
@@ -1261,13 +1261,13 @@ RAG/reference display mode is enabled.
return prompt
def _get_memory_context(
agent_name: str | None = None,
*,
app_config: AppConfig | None = None,
injection_enabled: bool = True,
include_legacy_knowledge_routing: bool = True,
) -> str:
def _get_memory_context(
agent_name: str | None = None,
*,
app_config: AppConfig | None = None,
injection_enabled: bool = True,
include_legacy_knowledge_routing: bool = True,
) -> str:
"""组装注入系统提示词的记忆上下文 (Memory V2)。
通过 :class:`MemoryManager` 收集各 provider 的记忆块。P1 期只有 builtin
@@ -1278,10 +1278,10 @@ def _get_memory_context(
agent_name: agent 标识;``None`` 时按 "default" 处理。
app_config: 显式应用配置 (本函数实际按 per-user 覆盖解析生效配置)。
injection_enabled: 运行时开关;``False`` 时跳过注入直接返回空串。
include_legacy_knowledge_routing: 是否注入 ``/mnt/knowledge/routing.md``。
智能普通知识库检索已有数据库授权范围和专用检索工具时必须关闭,
避免旧文件路由把“这个库”错误引向沙箱目录;其它记忆 provider
仍按原配置保留。
include_legacy_knowledge_routing: 是否注入 ``/mnt/knowledge/routing.md``。
智能普通知识库检索已有数据库授权范围和专用检索工具时必须关闭,
避免旧文件路由把“这个库”错误引向沙箱目录;其它记忆 provider
仍按原配置保留。
Returns:
包在 ``<memory>`` 标签里的记忆上下文;未启用或为空时返回空串。
@@ -1306,11 +1306,11 @@ def _get_memory_context(
)
# 只取静态块 (builtin 的 frozen snapshot + Hindsight 的模式说明)。
# Hindsight 的动态召回需要当前 query,走 MemoryMiddleware.before_model。
context_content = manager.build_system_prompt(
excluded_provider_names=(
None if include_legacy_knowledge_routing else {"knowledge"}
)
)
context_content = manager.build_system_prompt(
excluded_provider_names=(
None if include_legacy_knowledge_routing else {"knowledge"}
)
)
if not context_content.strip():
return ""
@@ -1878,7 +1878,7 @@ def apply_prompt_template(
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
include_legacy_knowledge_routing: bool = True,
include_legacy_knowledge_routing: bool = True,
rag_mode_enabled: bool = False,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
@@ -1887,12 +1887,12 @@ def apply_prompt_template(
storage_agent_id = agent_id if agent_id is not None else agent_name
# Get memory context
memory_context = _get_memory_context(
storage_agent_id,
app_config=app_config,
injection_enabled=memory_injection_enabled,
include_legacy_knowledge_routing=include_legacy_knowledge_routing,
)
memory_context = _get_memory_context(
storage_agent_id,
app_config=app_config,
injection_enabled=memory_injection_enabled,
include_legacy_knowledge_routing=include_legacy_knowledge_routing,
)
# Include subagent section only if enabled (from runtime parameter)
n = max_concurrent_subagents
@@ -2004,8 +2004,8 @@ When a search, knowledge, skill, or MCP tool returns a result list:
if rag_mode_enabled
else ""
)
ordinary_qa_format_section = (
_build_ordinary_qa_markdown_format_section()
ordinary_qa_format_section = (
_build_ordinary_qa_markdown_format_section()
if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap
and not writing_mode
@@ -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:
from deerflow.agents.middlewares.tool_metrics_middleware import ToolMetricsMiddleware
middlewares.append(ToolMetricsMiddleware())
# 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(
skill_only=not performance.tool_metrics_enabled,
)
)
return middlewares
@@ -179,20 +184,20 @@ def build_subagent_runtime_middlewares(
model_name = app_config.models[0].name
model_config = app_config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
middlewares.append(ViewImageMiddleware())
# Must remain the innermost model-call wrapper: subagent-specific context,
# vision payloads, system prompts and tool schemas all need to be included
# in the final input-size decision.
from deerflow.agents.middlewares.context_window_guard_middleware import ContextWindowGuardMiddleware
middlewares.append(
ContextWindowGuardMiddleware(
max_input_tokens=model_config.max_input_tokens if model_config is not None else None,
)
)
return middlewares
if model_config is not None and model_config.supports_vision:
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
middlewares.append(ViewImageMiddleware())
# Must remain the innermost model-call wrapper: subagent-specific context,
# vision payloads, system prompts and tool schemas all need to be included
# in the final input-size decision.
from deerflow.agents.middlewares.context_window_guard_middleware import ContextWindowGuardMiddleware
middlewares.append(
ContextWindowGuardMiddleware(
max_input_tokens=model_config.max_input_tokens if model_config is not None else None,
)
)
return middlewares
@@ -1,6 +1,7 @@
"""Middleware that records per-tool-call metrics for the admin audit page.
Every tool / skill invocation ends with one row in ``tool_call_metrics``:
While the administrator-controlled call-metrics switch is enabled, every tool
/ skill invocation ends with one row in ``tool_call_metrics``:
- Successes record the wall-clock duration.
- Failures record a coarse ``error_category`` plus a one-line message.
@@ -40,6 +41,7 @@ from langgraph.errors import GraphBubbleUp
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.config.system_settings import get_call_metrics_settings
from deerflow.persistence.engine import fire_and_forget_db_write, get_session_factory
from deerflow.persistence.tool_metrics import ToolMetricRecord, make_tool_metrics_store
from deerflow.persistence.tool_metrics.base import ToolMetricsStore
@@ -188,11 +190,18 @@ def _evaluate_result(result: Any, tool_name: str | None) -> tuple[str, str | Non
class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
"""Wrap each tool call to time it and persist a metric row."""
"""Time tool calls, optionally persisting only attributable skill calls."""
def __init__(self, *, store: ToolMetricsStore | None = None, **kwargs: Any) -> None:
def __init__(
self,
*,
store: ToolMetricsStore | None = None,
skill_only: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._store_override = store
self._skill_only = skill_only
# -- store / context ----------------------------------------------------
@@ -205,6 +214,12 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
return None
return make_tool_metrics_store(sf)
@staticmethod
def _metrics_enabled() -> bool:
"""Read the administrator switch at call time so no restart is needed."""
return bool(get_call_metrics_settings().enabled)
@staticmethod
def _runtime_lookup(request: ToolCallRequest, keys: tuple[str, ...]) -> str | None:
runtime = getattr(request, "runtime", None)
@@ -280,7 +295,11 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
record = self._build_base_record(request)
if is_internal_tool_name(record.tool_name):
if (
not self._metrics_enabled()
or is_internal_tool_name(record.tool_name)
or (self._skill_only and not record.skill_name)
):
return handler(request)
started = time.monotonic()
try:
@@ -303,7 +322,11 @@ class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
record = self._build_base_record(request)
if is_internal_tool_name(record.tool_name):
if (
not self._metrics_enabled()
or is_internal_tool_name(record.tool_name)
or (self._skill_only and not record.skill_name)
):
return await handler(request)
started = time.monotonic()
try:
@@ -482,13 +482,14 @@ def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
)
performance = _app_config.runtime_performance
logger.info(
"RUNTIME_PERFORMANCE skill_view_reuse_enabled=%s skill_view_cache_enabled=%s model_timing_nonblocking_enabled=%s asyncio_thread_pool_enabled=%s asyncio_thread_pool_size=%d tool_metrics_enabled=%s",
"RUNTIME_PERFORMANCE skill_view_reuse_enabled=%s skill_view_cache_enabled=%s model_timing_nonblocking_enabled=%s asyncio_thread_pool_enabled=%s asyncio_thread_pool_size=%d tool_metrics_enabled=%s skill_metrics_enabled=%s",
performance.skill_view_reuse_enabled,
performance.skill_view_cache_enabled,
performance.model_timing_nonblocking_enabled,
performance.asyncio_thread_pool_enabled,
performance.asyncio_thread_pool_size,
performance.tool_metrics_enabled,
performance.skill_metrics_enabled,
)
return _app_config
@@ -2,9 +2,11 @@
Skill-view reuse and cache are on when those keys are absent. Set them to
false in config.yaml to restore a full refresh on every tool call. Model
timing and the Gateway thread pool stay off unless configured. If application
configuration cannot be loaded, every switch stays off. Tool-call metric writes
are paused by default; explicitly enable them to resume audit statistics.
timing and the Gateway thread pool stay off unless configured. Tool and skill
metrics are on by default so the management leaderboard has a complete source
of truth. Deployments that need to shed this best-effort audit write load can
explicitly pause either metric type in ``config.yaml``. If configuration
cannot be loaded, the fallback keeps every switch off.
"""
from pydantic import BaseModel, ConfigDict, Field
@@ -15,7 +17,14 @@ class RuntimePerformanceConfig(BaseModel):
skill_view_reuse_enabled: bool = Field(default=True, description="Reuse one verified skill view within each sandbox tool invocation. Set false to refresh on every call.")
skill_view_cache_enabled: bool = Field(default=True, description="Reuse unchanged skill views after validating source, target and availability versions. Set false to rebuild every call.")
tool_metrics_enabled: bool = Field(default=False, description="Persist tool/skill audit statistics to tool_call_metrics. Disabled by default to avoid competing with interactive database traffic; historical reads remain available.")
tool_metrics_enabled: bool = Field(
default=True,
description="Persist tool/skill audit statistics to tool_call_metrics. Enabled by default so management leaderboard tool rankings receive data; set false only to pause ordinary tool auditing.",
)
skill_metrics_enabled: bool = Field(
default=True,
description="Persist calls that can be attributed to a concrete skill, keeping skill statistics available even if ordinary tool auditing is paused.",
)
model_timing_nonblocking_enabled: bool = Field(default=False, description="Dispatch model timing logs through a dedicated bounded queue, outside the shared tool executor")
asyncio_thread_pool_enabled: bool = Field(default=False, description="Override the Gateway event loop's default executor at startup; disabled preserves Python defaults")
asyncio_thread_pool_size: int = Field(default=100, ge=1, le=1024, strict=True, description="Maximum Gateway executor threads when enabled, unless overridden by the documented environment variables")
@@ -24,7 +33,12 @@ class RuntimePerformanceConfig(BaseModel):
# Field defaults turn the skill-view fast path on. This fallback is only for
# callers that cannot read application configuration, and it keeps every
# switch off.
_DEFAULT = RuntimePerformanceConfig(skill_view_reuse_enabled=False, skill_view_cache_enabled=False)
_DEFAULT = RuntimePerformanceConfig(
skill_view_reuse_enabled=False,
skill_view_cache_enabled=False,
tool_metrics_enabled=False,
skill_metrics_enabled=False,
)
def get_runtime_performance_config() -> RuntimePerformanceConfig:
@@ -21,7 +21,7 @@ from pathlib import Path
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride
from deerflow.config.runtime_paths import runtime_home
@@ -474,6 +474,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.
@@ -668,6 +682,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.
@@ -717,6 +749,15 @@ class RoundtableRoomLimitSettings(BaseModel):
"but never extend this deadline."
),
)
lease_minutes: int = Field(
default=5,
ge=1,
le=60,
description=(
"How long a foreground step-2 lease stays occupied without a renewal. "
"The browser heartbeat renews it; this does not extend max_hold_minutes."
),
)
max_cycles: int = Field(
default=8,
ge=2,
@@ -769,7 +810,40 @@ class HtmlMapSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
_GENERATED_PAGE_LOGO_FILE_RE = re.compile(r"^logo-[a-f0-9]{32}\.(?:png|jpg|webp)$")
class GeneratedPageBrandingSettings(BaseModel):
"""Optional generated-page logo settings retained for offline bundles."""
chrome_enabled: bool = True
header_layout: Literal["left", "center"] = "center"
system_name: str | None = Field(default=None, max_length=80)
footer_text: str | None = Field(default=None, max_length=160)
logo_filename: str | None = None
logo_mode: Literal["custom", "default", "none"] = "custom"
logo_media_type: Literal["image/png", "image/jpeg", "image/webp"] | None = None
logo_version: str | None = Field(default=None, max_length=64)
model_config = ConfigDict(extra="ignore")
@field_validator("logo_filename")
@classmethod
def _validate_logo_filename(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip().lower()
if not _GENERATED_PAGE_LOGO_FILE_RE.fullmatch(normalized):
raise ValueError("Invalid generated-page logo filename")
return normalized
_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):
@@ -780,9 +854,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")
@@ -801,6 +887,26 @@ 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
def app_identity_logo_path(
settings: AppIdentitySettings | None = None,
@@ -814,6 +920,42 @@ 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:
"""Resolve the custom generated-page logo without accepting arbitrary paths."""
current = settings or load_system_settings().generated_page_branding
if current.logo_mode != "custom" or not current.logo_filename:
return None
path = runtime_home() / "generated-page-branding" / current.logo_filename
return path if path.is_file() else None
class HtmlRuntimeSettings(BaseModel):
"""Addresses written into newly generated HTML pages.
@@ -844,6 +986,108 @@ class StrategyProjectSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class GeneralQaPageDescription(BaseModel):
"""Administrator-provided copy for the ordinary Q&A landing page."""
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."""
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")
class ModelPromptSuffix(BaseModel):
"""Administrator-owned instructions appended to one configured model's system prompt."""
@@ -862,9 +1106,14 @@ class SystemSettings(BaseModel):
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
generated_page_branding: GeneratedPageBrandingSettings = Field(
default_factory=GeneratedPageBrandingSettings
)
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)
@@ -875,6 +1124,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
)
@@ -1007,6 +1257,19 @@ def get_write_file_compatibility_settings() -> WriteFileCompatibilitySettings:
return WriteFileCompatibilitySettings()
def get_call_metrics_settings() -> CallMetricsSettings:
"""Return the live call-metrics switch, defaulting to recording enabled."""
try:
return load_system_settings().call_metrics
except Exception:
logger.debug(
"Failed to read call metrics setting; keeping metrics enabled",
exc_info=True,
)
return CallMetricsSettings()
def get_roundtable_room_limit_settings() -> RoundtableRoomLimitSettings:
"""Return the admin-controlled roundtable room cap, defaulting to 3 enabled rooms."""
try:
@@ -0,0 +1,321 @@
"""Build portable ZIP bundles for generated HTML artifacts.
Generated pages intentionally reference the Gateway's vendored browser runtime
instead of public CDNs. A plain ``.html`` download therefore stops working
after it is moved away from the DeerFlow deployment. This module rewrites
those trusted Gateway URLs to relative paths and copies the corresponding
packaged assets into a ZIP. It never downloads arbitrary third-party URLs.
"""
from __future__ import annotations
import hashlib
import json
import re
import zipfile
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path, PurePosixPath
from deerflow.html_runtime.public_images import resolve_html_image_path, verify_html_image_token
_STATIC_ASSETS = {
"alpine.min.js",
"bootstrap.bundle.min.js",
"bootstrap.min.css",
"brand-logo-dark.png",
"echarts.min.js",
"g6.min.js",
"leaflet.css",
"leaflet.js",
"topbar-dark.svg",
"topbar-light.svg",
}
_GEO_MAP_ASSETS = {
"china-provinces.geojson",
"japan-prefectures.geojson",
"taiwan-counties.geojson",
"us-states.geojson",
}
_COGNITIVE_ASSET_SUFFIXES = {
".css",
".js",
".json",
".jpeg",
".jpg",
".mjs",
".png",
".svg",
".woff2",
}
_LICENSE_BY_ASSET = {
"alpine.min.js": "LICENSE.alpine.txt",
"bootstrap.bundle.min.js": "LICENSE.bootstrap.txt",
"bootstrap.min.css": "LICENSE.bootstrap.txt",
"echarts.min.js": "LICENSE.echarts.txt",
"g6.min.js": "LICENSE.g6.txt",
"leaflet.css": "LICENSE.leaflet.txt",
"leaflet.js": "LICENSE.leaflet.txt",
}
_RUNTIME_ARCHIVE_ROOT = PurePosixPath("assets/html-runtime")
_HTML_IMAGES_ARCHIVE_ROOT = PurePosixPath("assets/html-images")
_STANDARD_RUNTIME_BASE_RE = (
r"(?:https?://[^/\"'\s<>()]+)?"
r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-runtime"
)
_HTML_IMAGE_URL_RE = re.compile(
r"(?P<url>(?:https?://[^/\"'\s<>()]+)?"
r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-images/"
r"(?P<token>[^\"'\s<>()?#]+)(?:\?[^\"'\s<>()#]*)?)",
re.IGNORECASE,
)
_EXTERNAL_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
@dataclass(frozen=True)
class OfflineBundleResult:
"""Summary of one completed offline bundle."""
bundled_assets: tuple[str, ...]
external_urls: tuple[str, ...]
def _runtime_base_pattern(runtime_base_url: str) -> str:
bases = [_STANDARD_RUNTIME_BASE_RE]
configured = str(runtime_base_url or "").strip().rstrip("/")
if configured:
bases.insert(0, re.escape(configured))
return "(?:" + "|".join(bases) + ")"
def _safe_runtime_source(runtime_dir: Path, relative_path: str) -> Path | None:
"""Resolve only assets that the public HTML-runtime router exposes."""
normalized = relative_path.strip().lstrip("/").replace("\\", "/")
pure_path = PurePosixPath(normalized)
if not normalized or pure_path.is_absolute() or ".." in pure_path.parts:
return None
allowed = False
if len(pure_path.parts) == 1 and normalized in _STATIC_ASSETS:
allowed = True
elif len(pure_path.parts) == 2 and pure_path.parts[0] == "maps" and pure_path.name in _GEO_MAP_ASSETS:
allowed = True
elif pure_path.parts and pure_path.parts[0] == "cognitive-situation-map":
allowed = pure_path.suffix.lower() in _COGNITIVE_ASSET_SUFFIXES
if not allowed:
return None
root = runtime_dir.resolve()
source = (root / Path(*pure_path.parts)).resolve()
try:
source.relative_to(root)
except ValueError:
return None
return source if source.is_file() else None
def _add_file(
archive_files: dict[str, Path],
archive_path: PurePosixPath,
source: Path,
) -> None:
archive_files[archive_path.as_posix()] = source
def _add_cognitive_runtime(archive_files: dict[str, Path], runtime_dir: Path) -> None:
source_root = runtime_dir / "cognitive-situation-map"
if not source_root.is_dir():
return
for source in source_root.rglob("*"):
if not source.is_file():
continue
is_license = source.name.lower().startswith("license")
if source.suffix.lower() not in _COGNITIVE_ASSET_SUFFIXES and not is_license:
continue
relative = source.relative_to(runtime_dir)
_add_file(archive_files, _RUNTIME_ARCHIVE_ROOT / PurePosixPath(relative.as_posix()), source)
def _generated_logo_destination(generated_logo_path: Path | None) -> PurePosixPath:
suffix = generated_logo_path.suffix.lower() if generated_logo_path is not None else ".png"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".png"
return _RUNTIME_ARCHIVE_ROOT / f"generated-page-logo{suffix}"
def _runtime_script(
runtime_dir: Path,
*,
map_tile_url: str,
include_geo_maps: bool,
) -> str:
config: dict[str, object] = {"geoMaps": {}, "mapTileUrl": map_tile_url}
if include_geo_maps:
config["geoMaps"] = {
Path(filename).stem: (_RUNTIME_ARCHIVE_ROOT / "maps" / filename).as_posix()
for filename in sorted(_GEO_MAP_ASSETS)
}
prefix = "window.DEERFLOW_HTML_RUNTIME_CONFIG=" + json.dumps(
config,
ensure_ascii=False,
separators=(",", ":"),
) + ";\n"
return prefix + (runtime_dir / "deerflow-runtime.js").read_text(encoding="utf-8")
def _readme(original_name: str, external_urls: list[str]) -> str:
external_note = (
"未打包的外部地址已记录在 manifest.json 的 external_urls 中;例如在线地图瓦片、"
"用户主动引用的第三方图片或接口仍需要网络。"
if external_urls
else "本页没有检测到仍需联网的 http(s) 地址。"
)
return (
"DeerFlow 网页离线包\n"
"====================\n\n"
f"原始文件:{original_name}\n"
"入口文件:index.html\n"
"依赖目录:assets/\n\n"
"部署方式:请保持目录结构不变,将解压后的全部文件一起放到任意静态网站目录。\n"
"若页面含 GeoJSON 地图等 fetch 请求,不建议直接双击 file:// 打开;可使用 Nginx、"
"Apache、IIS 或其他静态 HTTP 服务访问 index.html。\n\n"
f"{external_note}\n"
)
def build_html_offline_bundle(
html_path: Path,
destination: Path,
*,
runtime_base_url: str,
map_tile_url: str,
generated_logo_path: Path | None = None,
runtime_dir: Path | None = None,
) -> OfflineBundleResult:
"""Write a portable HTML ZIP to *destination* and return its manifest summary."""
html_path = Path(html_path)
destination = Path(destination)
runtime_dir = runtime_dir or Path(str(files("deerflow.html_runtime.assets")))
runtime_dir = Path(runtime_dir)
html = html_path.read_text(encoding="utf-8", errors="replace")
archive_files: dict[str, Path] = {}
generated_files: dict[str, str] = {}
bundled_assets: set[str] = set()
licenses: set[str] = set()
include_geo_maps = bool(re.search(r"data-df-geo\s*=", html, re.IGNORECASE))
uses_leaflet_map = bool(re.search(r"data-df-map\s*=", html, re.IGNORECASE))
base_pattern = _runtime_base_pattern(runtime_base_url)
cognitive_base_re = re.compile(
rf"(?P<url>{base_pattern}/cognitive-situation-map)(?=$|[\"'\s<>()/])",
re.IGNORECASE,
)
def replace_cognitive_base(match: re.Match[str]) -> str:
_add_cognitive_runtime(archive_files, runtime_dir)
bundled_assets.add((_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix() + "/")
return (_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix()
html = cognitive_base_re.sub(replace_cognitive_base, html)
runtime_url_re = re.compile(
rf"(?P<url>{base_pattern}/(?P<asset>[A-Za-z0-9._/-]+)"
r"(?:\?[^\"'\s<>()#]*)?)",
re.IGNORECASE,
)
def replace_runtime_url(match: re.Match[str]) -> str:
asset = match.group("asset").strip("/")
if asset == "generated-page-logo":
source = generated_logo_path if generated_logo_path and generated_logo_path.is_file() else runtime_dir / "brand-logo-dark.png"
if not source.is_file():
return match.group("url")
archive_path = _generated_logo_destination(source)
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
return archive_path.as_posix()
if asset == "deerflow-runtime.js":
source = runtime_dir / asset
if not source.is_file():
return match.group("url")
archive_path = _RUNTIME_ARCHIVE_ROOT / asset
generated_files[archive_path.as_posix()] = _runtime_script(
runtime_dir,
map_tile_url=map_tile_url,
include_geo_maps=include_geo_maps,
)
bundled_assets.add(archive_path.as_posix())
if include_geo_maps:
for filename in sorted(_GEO_MAP_ASSETS):
map_source = _safe_runtime_source(runtime_dir, f"maps/{filename}")
if map_source is not None:
map_archive_path = _RUNTIME_ARCHIVE_ROOT / "maps" / filename
_add_file(archive_files, map_archive_path, map_source)
bundled_assets.add(map_archive_path.as_posix())
return archive_path.as_posix()
source = _safe_runtime_source(runtime_dir, asset)
if source is None:
return match.group("url")
archive_path = _RUNTIME_ARCHIVE_ROOT / PurePosixPath(asset)
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
license_name = _LICENSE_BY_ASSET.get(asset)
if license_name and (runtime_dir / license_name).is_file():
licenses.add(license_name)
return archive_path.as_posix()
html = runtime_url_re.sub(replace_runtime_url, html)
def replace_html_image(match: re.Match[str]) -> str:
token = match.group("token")
try:
_thread_id, _kind, filename = verify_html_image_token(token)
source = resolve_html_image_path(_thread_id, _kind, filename)
except (FileNotFoundError, ValueError):
return match.group("url")
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
safe_name = Path(filename).name
archive_path = _HTML_IMAGES_ARCHIVE_ROOT / f"{digest}-{safe_name}"
_add_file(archive_files, archive_path, source)
bundled_assets.add(archive_path.as_posix())
return archive_path.as_posix()
html = _HTML_IMAGE_URL_RE.sub(replace_html_image, html)
external_url_set = {
match.group(0).rstrip("),.;")
for match in _EXTERNAL_URL_RE.finditer(html)
}
if uses_leaflet_map and str(map_tile_url).lower().startswith(("http://", "https://")):
external_url_set.add(str(map_tile_url))
external_urls = sorted(external_url_set)
manifest = {
"format": "deerflow-html-offline-bundle-v1",
"entry": "index.html",
"source_name": html_path.name,
"bundled_assets": sorted(bundled_assets),
"external_urls": external_urls,
}
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive.writestr("index.html", html.encode("utf-8"))
archive.writestr(
"manifest.json",
json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"),
)
archive.writestr("README.txt", _readme(html_path.name, external_urls).encode("utf-8"))
for archive_path, source in sorted(archive_files.items()):
archive.write(source, archive_path)
for archive_path, content in sorted(generated_files.items()):
archive.writestr(archive_path, content.encode("utf-8"))
for license_name in sorted(licenses):
archive.write(runtime_dir / license_name, f"THIRD_PARTY_LICENSES/{license_name}")
return OfflineBundleResult(
bundled_assets=tuple(sorted(bundled_assets)),
external_urls=tuple(external_urls),
)
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more