Compare commits

...
2 Commits
253 changed files with 26089 additions and 1095 deletions

No files matched your search

+52
View File
@@ -88,11 +88,17 @@ offline-backend-20260512/backend/skill_evolution.json
# Automatic config.yaml backups written by the admin models page on every save # Automatic config.yaml backups written by the admin models page on every save
offline-backend-20260512/backend/.model-config-backups/ offline-backend-20260512/backend/.model-config-backups/
skills/ 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/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* 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_large_file_safety.py
!offline-backend-20260512/backend/tests/test_account_authentication.py !offline-backend-20260512/backend/tests/test_account_authentication.py
!offline-backend-20260512/backend/tests/test_agent_skill_migration.py
!offline-backend-20260512/backend/tests/test_html_runtime_addresses.py !offline-backend-20260512/backend/tests/test_html_runtime_addresses.py
!offline-backend-20260512/backend/tests/test_app_identity.py !offline-backend-20260512/backend/tests/test_app_identity.py
!offline-backend-20260512/backend/tests/test_roundtable_room_limit.py !offline-backend-20260512/backend/tests/test_roundtable_room_limit.py
@@ -131,6 +137,9 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_gongwen_documents.py !offline-backend-20260512/backend/tests/test_gongwen_documents.py
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. # Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py !offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_gauss_mysql_compat.py
!offline-backend-20260512/backend/tests/test_chat_history_migration_seed.py
!offline-backend-20260512/backend/tests/test_gauss_data_adaptation_seed.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py !offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py !offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py
@@ -260,8 +269,51 @@ docs/backups
offline-backend-20260512/backend/skills/* offline-backend-20260512/backend/skills/*
!offline-backend-20260512/backend/skills/public/ !offline-backend-20260512/backend/skills/public/
offline-backend-20260512/backend/skills/public/* offline-backend-20260512/backend/skills/public/*
!offline-backend-20260512/backend/skills/public/_shared/
!offline-backend-20260512/backend/skills/public/_shared/**
!offline-backend-20260512/backend/skills/public/analysis-mindmap/ !offline-backend-20260512/backend/skills/public/analysis-mindmap/
!offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md !offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md
!offline-backend-20260512/backend/skills/public/facebook-page-generator/
!offline-backend-20260512/backend/skills/public/facebook-page-generator/**
!offline-backend-20260512/backend/tests/test_facebook_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/x-page-generator/
!offline-backend-20260512/backend/skills/public/x-page-generator/**
!offline-backend-20260512/backend/tests/test_x_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/tiktok-page-generator/
!offline-backend-20260512/backend/skills/public/tiktok-page-generator/**
!offline-backend-20260512/backend/tests/test_tiktok_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/youtube-page-generator/
!offline-backend-20260512/backend/skills/public/youtube-page-generator/**
!offline-backend-20260512/backend/tests/test_youtube_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/threads-page-generator/
!offline-backend-20260512/backend/skills/public/threads-page-generator/**
!offline-backend-20260512/backend/tests/test_threads_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/cnn-page-generator/
!offline-backend-20260512/backend/skills/public/cnn-page-generator/**
!offline-backend-20260512/backend/tests/test_cnn_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/bbc-page-generator/
!offline-backend-20260512/backend/skills/public/bbc-page-generator/**
!offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/xinhua-page-generator/
!offline-backend-20260512/backend/skills/public/xinhua-page-generator/**
!offline-backend-20260512/backend/tests/test_xinhua_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/white-house-page-generator/
!offline-backend-20260512/backend/skills/public/white-house-page-generator/**
!offline-backend-20260512/backend/tests/test_white_house_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/manila-times-page-generator/
!offline-backend-20260512/backend/skills/public/manila-times-page-generator/**
!offline-backend-20260512/backend/tests/test_manila_times_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/gma-news-page-generator/
!offline-backend-20260512/backend/skills/public/gma-news-page-generator/**
!offline-backend-20260512/backend/tests/test_gma_news_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/
!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/**
!offline-backend-20260512/backend/tests/test_yahoo_japan_page_generator_skill.py
!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/
!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/**
!offline-backend-20260512/backend/tests/test_yomiuri_news_page_generator_skill.py
!offline-backend-20260512/backend/tests/test_fast_page_generator_specs.py
!offline-backend-20260512/backend/tests/test_sync_builtin_skills.py
# Built-in skill-creator ported from bytedance/deer-flow (product source too). # Built-in skill-creator ported from bytedance/deer-flow (product source too).
!offline-backend-20260512/backend/skills/public/skill-creator/ !offline-backend-20260512/backend/skills/public/skill-creator/
!offline-backend-20260512/backend/skills/public/skill-creator/** !offline-backend-20260512/backend/skills/public/skill-creator/**
+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`。 - **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). - **轻应用中心 (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. - **菜单管理 (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/`。 - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 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 等侧栏宽度过渡稳定后再测量高亮框。 - **简洁模式用户引导 (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>` 窗口事件强制重放(忽略已看过记忆)。 - **智能体使用引导 (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 或未设=隐藏。 # 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。 # 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false # 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= # VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
-1
View File
@@ -20,7 +20,6 @@
"test:users-paged": "node scripts/test-users-paged.mjs", "test:users-paged": "node scripts/test-users-paged.mjs",
"test:auth-entry": "node scripts/test-auth-entry.mjs", "test:auth-entry": "node scripts/test-auth-entry.mjs",
"test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs", "test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs",
"test:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.test.mjs",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs", "test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts", "test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts",
"test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts", "test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts",
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.6 MiB

+11 -9
View File
@@ -13,7 +13,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。 // 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。
// 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。 // 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。
VITE_WELCOME_LOGO_VISIBLE: "false", VITE_WELCOME_LOGO_VISIBLE: "false",
// 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。 // 红白主题是否使用浅红色科技感顶栏,并完整复用深色主题的导航布局。
// "true" 启用;"false" 保持原来的红白主题顶栏。
VITE_LIGHT_RED_TOP_NAV_ENABLED: "true",
// 科技感顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。
// 同时作用于深色主题和启用后的浅红色顶栏。
VITE_DARK_LOGO_POSITION: "center", VITE_DARK_LOGO_POSITION: "center",
// 红白主题的顶栏系统 Logo 图片地址。 // 红白主题的顶栏系统 Logo 图片地址。
VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png", VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png",
@@ -23,13 +27,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png", VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png",
// 深色主题的顶栏系统 Logo 图片地址。 // 深色主题的顶栏系统 Logo 图片地址。
VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png", VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png",
// 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 // 简洁模式欢迎区复用上面的 VITE_BRAND_LOGO_* 系统 Logo,不再单独配置欢迎页图片。
VITE_BRAND_WELCOME_LIGHT_URL: "",
// 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
VITE_BRAND_WELCOME_LIGHTBLUE_URL: "",
VITE_BRAND_WELCOME_LIGHTGREEN_URL: "",
// 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow", // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
@@ -42,7 +40,7 @@ window.__CM_RUNTIME_CONFIG__ = {
// 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。 // 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。 // 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png", VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。 // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
@@ -147,6 +145,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// 静态站点模式开关;一般部署业务系统时保持 false。 // 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false", VITE_STATIC_WEBSITE_ONLY: "false",
// 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表;
// 改为 true 并刷新页面后,才允许用户切换到卡片视图。
VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false",
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标, // 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false", VITE_FORCE_COLLAPSED_SIDEBAR: "false",
@@ -93,6 +93,21 @@ const requiredLegacyFallbacks = [
pattern: /AbortSignal\.timeout = function/, pattern: /AbortSignal\.timeout = function/,
description: "Chrome 102 needs AbortSignal.timeout before any module loads", 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", file: "src/styles/globals.css",
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/, pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
+5 -1
View File
@@ -28,7 +28,6 @@ import {
} from "@/core/embed/embed-session"; } from "@/core/embed/embed-session";
import { HashRouter, useLocation } from "react-router-dom"; import { HashRouter, useLocation } from "react-router-dom";
import { import {
isPositionRoundtableStandaloneMode, isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE, POSITION_ROUNDTABLE_ROUTE,
@@ -36,6 +35,7 @@ import {
const LandingPage = lazy(() => import("./pages/LandingPage")); const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage")); const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes")); const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage")); const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage")); const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
@@ -202,6 +202,10 @@ export function App() {
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage /> positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
} }
/> />
<Route
path="/register"
element={<RegisterPage />}
/>
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
{/* Anonymous standalone tool: no workspace navigation or login gate. */} {/* Anonymous standalone tool: no workspace navigation or login gate. */}
<Route <Route
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAABDtklEQVR42u2dW5bkOJJkVUC6R2RlzRLmu9fSK56t9Hz3EqarMjLcCJ0PgCQA4kkCfJjD8sTJCHcz2hu4FBUVBfVLvzS6/J//+N94g6eBb3Kf73Dhb3KfVS//+X//m/tHp1/6QtYvHSie+z3o37XvByPcgaNfOmD0S7+8P1DgDb5D/ft6/sbN3x0+OnD0S1+w+qVDRZvPNfp3r0PJScflDhv90gGjXzpQvNfnF/071S83BgbuwNEvHTD6pYPFcz636N+ffjlxg7/LMTpo9EsHjH7pUFHpM4pv/v1A91Pc+jnwu71+HTb6pQNGv9wNKNCViP7dfDNQuVK14A4c/dIXsX75rmCBb6ZGoH+3b7Uh8jdTPbiDRr90wOiXDhTXKxpX32f/Xt4DGp4U3MUdOPqlA0a/dLC4l7LxLt0r/fJ+3RxnKBUdNPqlA0a/PAYsWgLF3UOt+vfp+wLJXcO/HgMcHTQ6YPRLB4p3gAm86/eFH/6dxjO6Su5ovHw76OjA0QGjX74fWLSAhDvBBDogdEA5ET7uAB18Z9jooNEBo1/eFyqeAhR3LbVUhwZ80+8tV97ccG9PBXfg6LDRAaNf3hUungAWt4OKWjCB/t28BD5OhI4rN/FHg0aHjA4Y/dKh4qoZH7gjPHRgeB8gwfPKGjWO1WGjXzpgdKi4dNM+s30TdwGIp8LDWX4PPDQOfC+E4LryxtntuFfAUIeNDhj9cmOwqLl544kwUbKxops+u6mzEoTgmlLHmXHlZ0JQB40OGP3ypnDxOLC4O1TcASRqPe/aZsyngMeFsHEn0OiQ0S8dML4JVNRSIs4ACpy5UeNhps/u42gDMGeBSO5jxnllDr7RfXCHjQ4Y/fJeYNF6w28OFDmbN25o+mwNCk8tq7Te7G/mqch+TLgXDLQ8fgeNDhj98lCoaF2SQKvP2RUgsWeTxgnPs1/aA8EeEKkNISeBB1847fUMhaXDRgeMDhYXQsXtgOJMmCjd0Pfcb4eG94ORUgCpBR8nQMdVwHEZbHTQ6IDR4eJecHEJWFwBFaX3+Y4wkXoN7mDovBo6rjB3pu7zhqDRIaMDRr90qLgeKM6EiZL7eqdOD+oGz1MA5ESPRfS+bgYcHTY6YPRLh4o6x2upTtSEiLMB4omdLE9sJT0bSnKfV2v4aKRydNjolw4YDweLI9BweemjlTpRCyZabrg4qTumX9oAS0sYyXl8V0IH6k6CbVFKuQQ2Omh0wPjuYFEbKqqpFHs33BqqR4uNutXz6ZdnwEgLAKkBHrWfT2V1ozZsdNDogNHh4iQFoWYJ5HKl4ugx7zAavcNE7yi5CjZagFNFZePMkkyHjA4Y3x4szlQsmkJF7Y04o3vhsoTMGwIE3tfT+T26S47ed23oqKhsnAEbpysaHTQ6YDwVLGqqFYeh4g5AUWNDf0i3SP8+PhhManeXHLm/mwJHLdi4jarRQaMvaHcBi7PUiiZQgRN9GUc2+Bt2jDS5H/zjz2/13eV//4ufDCM1O0tqqxy1gONmsHGqqtFBowPGHcGillqBFqUPnFA+aQ0TDUECbwMILNs8Fgh+I0DhO3eW1FQ5apSCKpVS+GmqRgeNDhgdLi4Gi6ObfutySu3vymkw0QoUTl+VzgGTg9BxOnAcLauEjn0j0OiQ0QGjg0UjsHgMVJylVJxp9DzyfTgMEO8CBm8GKgcA5NTOktrD2WoAx41ho4NGB4zHwkUpWOAsb0ULqKgJFCfDBE6Bhw4Obw8ihRDCd4WOUuA4ETZqeDVKYYU7ZHTAeIJicalacQQqSlSK2upEJZhoM9TsH392gPjOAPLvf7VqU+W7lVd8xzuqbjSAjZaqRlc0OmBcBhctyyC3gYpaPorGQIHqIPHHH+jfXeqZGLmXv/5q0aLKdwGOEv/GA2DjsvLJd4MMdLh4X7hoUQK5EVjUhYp7AkVXSJ4KH5nAcQZstAaN2iWUDhkdML4rXJwJFk0Vi1ZqRaPOEbyJOnGr7xszn/J4AHAHkiYqB5/VUXKGqlFZ0bg1aHwXyEAHiyY+i6ZqRWuouBgo8FBVAu8CBLdftM4BFn6o2sF3Ao4LYKO2qsFdzeiAcRfF4lSwuAIqzgaK5HNsDxHooNDB5DIQScBHBgBcAhwtYeNmoNEVje8EGDdWLN4KKrjuwLU8VaItTKBDQ79UhBG+UvHgyhtiSUfJG8FGVzQ6YHS4KL3NBWrFXcECHSj65QTg4HcAjTNUjQ4Z7wUZ6HBxus+iCBJqKRZ3USsuKnmgQ0Tww4PGKwx3+DgRPPaXVC5VNWooGjtB45b+jHeBDHS4uIdicVStuLgEght4KN4fIri3rT4NWi6Hj2MeDr5DCaWRqnF7ReMdIANdtciGgepg0aIEwge6MGpAxUlAgbcAiA4M3x5ICgGEn6BwHCmlpJSNBrDRGjS+tZqBrlpUybJAa7XCvF0tqGiuUtQvd+ARUHEDcAA9q7zDdIOsjAtB5HTQyICOq9WNEGzsTQrdoWrs7dDhrmY8GDBOhIs9qkVVuGilWPCxltr44z4OFs9RKC6AiafBw6Mh5GL141TwiKgbR5WN2pCxd+hbA8g4Tc14ImTgm8FFrkJxa7B4Q6i4pzLREB6+KyS8JZycDCEF0MHvChsPAQ2u/b48DTLQVYv7lEOOqhWnQcUJCsWpMFERJK4CB36zlnNcFOddFUROBI9M6KiucFwBG0dUjYvKJt9WzcA3VS2agsXVagXvN6S28FPcR52oABKtAYL7gLNbg0kVADkBPJqrHOW+Da7RunqhqnE70HgCZKDDxXVwcaFigW8DFgehohVQdJB4D/A4DByNYeNmoMEPVzQ6ZLwTYNwALpqUQ46AxZOhohlM3EyZ6PDQIeTOSkcGdDwSNvaCRuOyybeGDHSwqKta7FUsStWKm0DFuUBxACQ6QPTL2wBIZfBoAhyNYaNE1diraHQ14w0B4wBctAAL1Ij0jl2vgVrREipuDxQ1QKIDRL9cBSC7wOPuwFERNmqqGpVAw33Ml4LG3SAD3wguqigWez0WR8CiGlRUVCmalDsKgOIoSLwRRODtO0w7fJSBR4PSSgQ6uGE3CtdSNWqARuK6RxWNt4QMdOWifjmklseiahmkolpRFS5OUikeDBRdTXlDANkDHFerGwllg58MGbkejYplk2+hZOAb+C2qlESOlkNKPBYXKhZtoeIEmHgASHRg6EBSDTyugo67w8Zej0YDf8ZlSsYdQANdtbgOLI6WQipAxa2A4g1gAtTh5R0ggL8VdNwJOPJgo4mqcSFovK2agW+oWlT1WpwAFmm14i5Q0dBDwd9j8+7qxnMAgd8WOg4CRyvYKFU1WoJGI2/G26kZ+MZGTpROLa0JFierFW2gIhMoHuSZQAeHDiJPhI+mno4DwFEFNiqrGq0UjSeUTc6GDDxYubisJHI5XFRQLDpYNPsOdJjo0HEZbDwQNGoqGpdAxkUlk0coGfgGykVxSeQNwOIRUPFQmOgA0QHkbaHjEbDxbNAoeayPVzLwjeDiNLA44rF4ElS8OUx0kOiXM6HhudDxUNjI9WicABpvCxl4EFzs9Vvs9lqUBmVdABZ1oeKZQNFVCSIC81sBEQPcoeM88LgDcByCjcagURrUtaNsctSXcUvIwBvCRTPVolIpBC3Uim8CFW+vSrwbKHQweZ7acWPYqKFqcK3SyQ3VjD2ZKPSf/9UOMvBmZZGqcFFZtWhWCtkFFxmeily4uDlYoMNEh443hY1bgEaWb2MHaDSEjOBxjqgZJ0BGznPgPatjK8jAd1QuWngtdoHFmVDxHKDA00HibHB41+FsONmXcDMQYboZdFwJHLVgYw9o1PZm3ErJMFaOFpAx3jgFcQ9c7FItGoFFjXJIU7C4AVA8EiZaAkSf5HrstTgCJan39WQAwc4NFa2gwxM+yDnrihc45nUpEzTmdc8BDWQ9vz/+wAwZRrmbjWNw5GSUPbeJXsf8ubtvxH7vPB9Enpf7u9S/3yedcId6cQvV4hTFIg8smqsVKbC4GVDgHUDiMnC4exnmItWgpjpysfLBdBOVI/Wa1vZr7FI1KpROShSN26sZzupQW8XAxWWRZnBRU7U4rFjsUCveQKl4jDpxFCSawkP3aVwGKIdHrF8DHpeXVqqUUo7DBh8onew2gpaUTTLKKu0hI7C61AKN8aHqCXKPcxJcHPFZNFMscsCC75FkiacpE02gosNEndes0sburBN89HN2EnBgJzSgFmjkvm7z+hQsoRRABjOjuHzilE6csgmFSidY1Qf4yia+35s/d0smifeE71b2OH1hb6BenOq3eBxY3K8EgrsCxRGYqPY6dXB4a/XjiNJxssrBdGEp5awSytHSSa2ySW47a2GXyd4oci5diWuoGHiQ5+I0v0VO7gWF7qsQLIrUimcqFbeHiSqvzftBBOjBI87vAB97weOm0MFnl5+CwFGgbBSXTsIejaJcjFJfxomtrFzyLT8KGXgHuLhKtTiiWNRQK05WKnAnheISmLgfRKDHcD8LPm4OHXxHz0YNr8YR0MjxZ5yoZtSFjIwV5Ahk4EFwUaxavCNYxKDiXYHidJi4DiTuAAx4AxjgJ4HHjaHjdsBxVNV4GmgcKJnkQUbmt30vZIxPPhO7Yt7FXrg4Wgo5Ua3At4OLine truncated

After

Width:  |  Height:  |  Size: 23 KiB

@@ -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

+2 -1
View File
@@ -14,7 +14,7 @@ import {
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。 * 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变; * 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。 * 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。 * 默认倍率为 `1.375`,即 22px。
*/ */
export const BASE_FONT_PX = 16; export const BASE_FONT_PX = 16;
@@ -24,6 +24,7 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1, label: "标准", px: 16 }, { value: 1, label: "标准", px: 16 },
{ value: 1.125, label: "大", px: 18 }, { value: 1.125, label: "大", px: 18 },
{ value: 1.25, label: "特大", px: 20 }, { value: 1.25, label: "特大", px: 20 },
{ value: 1.375, label: "超大", px: 22 },
]; ];
export const FONT_SCALE_MODE_OPTIONS: { export const FONT_SCALE_MODE_OPTIONS: {
+72 -1
View File
@@ -443,6 +443,25 @@
background-size: 152px 42px; 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 { .third-submenu-bar {
position: relative; position: relative;
z-index: 34; z-index: 34;
@@ -753,7 +772,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
color: var(--brand-900); color: var(--brand-900);
} }
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */ /* 侧边栏底部:设置入口 + 收起面板按钮 */
.light-sidebar__footer { .light-sidebar__footer {
margin-top: auto; margin-top: auto;
display: flex; display: flex;
@@ -763,6 +782,58 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
/* 侧边栏收起后的左下角浮动展开入口(交互同深色 sidebar-collapse-btn--floating,配色用浅色主题变量) */
.light-sidebar-expand-btn {
position: fixed;
left: 10px;
bottom: 10px;
z-index: 60;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 36px;
height: 36px;
padding: 0 10px;
border: 1px solid var(--brand-200);
border-radius: 8px;
background: #ffffff;
color: var(--brand-900);
font-family: var(--font-sans);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
cursor: pointer;
overflow: hidden;
transition: width 0.22s ease, background-color 0.2s ease, border-color 0.2s ease;
}
.light-sidebar-expand-btn:hover {
width: 104px;
background: var(--brand-200);
}
.light-sidebar-expand-btn__icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
color: var(--brand-900);
}
.light-sidebar-expand-btn__label {
font-size: 14px;
line-height: 1;
white-space: nowrap;
max-width: 0;
opacity: 0;
transform: translateX(-4px);
transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease;
}
.light-sidebar-expand-btn:hover .light-sidebar-expand-btn__label {
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */ /* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
.light-sidebar-subnav { .light-sidebar-subnav {
display: flex; display: flex;
+41 -3
View File
@@ -13,6 +13,8 @@ import {
MessageSquareText, MessageSquareText,
MousePointerClick, MousePointerClick,
BriefcaseBusiness, BriefcaseBusiness,
PanelLeftClose,
PanelLeftOpen,
Settings, Settings,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
@@ -100,14 +102,24 @@ export function PageSidebar({
/> />
); );
} }
return <SecondaryNavSidebar headerHidden={headerHidden} />; return (
<SecondaryNavSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
headerHidden={headerHidden}
/>
);
} }
/** /**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。 * 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。 * 收起交互与深色侧边栏一致:收起后整栏隐藏,仅左下角保留浮动展开入口。
*/ */
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { function SecondaryNavSidebar({
collapsed,
onToggleCollapsed,
headerHidden,
}: PageSidebarProps) {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n(); const { t } = useI18n();
@@ -151,6 +163,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname); const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const expanded = thirdItems.length > 0; const expanded = thirdItems.length > 0;
// 收起时整栏不占宽度,仅保留左下角浮动入口以便随时恢复(交互同深色侧边栏)。
if (collapsed) {
return (
<button
type="button"
title="展开侧边栏"
aria-label="展开侧边栏"
className="light-sidebar-expand-btn"
onClick={onToggleCollapsed}
>
<PanelLeftOpen className="light-sidebar-expand-btn__icon" aria-hidden="true" />
<span className="light-sidebar-expand-btn__label">展开面板</span>
</button>
);
}
return ( return (
<> <>
<SettingsDialog <SettingsDialog
@@ -311,6 +339,16 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
<button
type="button"
title="收起侧边栏"
aria-label="收起侧边栏"
onClick={onToggleCollapsed}
className="light-sidebar-item"
>
<PanelLeftClose className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">收起面板</span>
</button>
</div> </div>
</div> </div>
@@ -45,6 +45,8 @@ function HeaderButton({
/** Light 56px wiki header: plain page title on the left, action entries on the right. */ /** Light 56px wiki header: plain page title on the left, action entries on the right. */
export function WikiHeader({ export function WikiHeader({
title = "知识库管理",
description,
onToggleSidebar, onToggleSidebar,
onImportFile, onImportFile,
onBatchImport, onBatchImport,
@@ -55,6 +57,8 @@ export function WikiHeader({
onManageTemplates, onManageTemplates,
extraActions, extraActions,
}: { }: {
title?: string;
description?: string;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onImportFile: () => void; onImportFile: () => void;
onBatchImport: () => void; onBatchImport: () => void;
@@ -66,8 +70,13 @@ export function WikiHeader({
extraActions?: ReactNode; extraActions?: ReactNode;
}) { }) {
return ( 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"> <header
<div className="flex items-center gap-3"> 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 <button
type="button" type="button"
onClick={onToggleSidebar} onClick={onToggleSidebar}
@@ -76,8 +85,15 @@ export function WikiHeader({
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
<div className="min-w-0">
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100"> <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>
</div> </div>
@@ -96,6 +96,8 @@ import { cn } from "@/lib/utils";
interface AgentCardProps { interface AgentCardProps {
agent: Agent; agent: Agent;
/** Optional visual state supplied by management surfaces (for example selection). */
className?: string;
/** "card" (default) renders the full gallery card; "list" renders a compact /** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */ * single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list"; layout?: "card" | "list";
@@ -220,6 +222,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
export function AgentCard({ export function AgentCard({
agent, agent,
className,
layout = "card", layout = "card",
onPublisherClick, onPublisherClick,
onToggleHidden, onToggleHidden,
@@ -928,8 +931,9 @@ export function AgentCard({
const cardLayout = ( const cardLayout = (
<Card <Card
className={cn( className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md", "group flex min-w-0 flex-col transition-[border-color,box-shadow,background-color] hover:shadow-md",
agent.hidden && "opacity-60", agent.hidden && "opacity-60",
className,
)} )}
data-agent-guide="agent-card" data-agent-guide="agent-card"
> >
@@ -992,8 +996,9 @@ export function AgentCard({
const listLayout = ( const listLayout = (
<div <div
className={cn( className={cn(
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]", "group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-[border-color,box-shadow,background-color] hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
agent.hidden && "opacity-60", agent.hidden && "opacity-60",
className,
)} )}
data-agent-guide="agent-card" data-agent-guide="agent-card"
> >
@@ -18,7 +18,12 @@ import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; 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 { import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE, WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB, WX_RESTRICTED_TAB_NANJING_DB,
@@ -51,6 +56,8 @@ export function AgentGallery({
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.agents;
const sentimentAgentVisible = isSentimentAgentVisible(); const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -455,9 +462,11 @@ export function AgentGallery({
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b px-6 py-4">
<div> <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"> <p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description} {pageDescription?.description || t.agents.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({
)} )}
<ArtifactAction <ArtifactAction
icon={DownloadIcon} icon={DownloadIcon}
label={t.common.download} label={
tooltip={t.common.download} language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载离线包"
: t.common.download
}
tooltip={
language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载包含网页运行库的 ZIP 离线包"
: t.common.download
}
onClick={async () => { onClick={async () => {
if (onDownloadClick) { if (onDownloadClick) {
onDownloadClick(); onDownloadClick();
@@ -130,7 +130,9 @@ export function ArtifactFileList({
) : ( ) : (
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
)} )}
{t.common.download} {!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file))
? "下载离线包"
: t.common.download}
</Button> </Button>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
@@ -0,0 +1,258 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import {
exportAllThreadsInBackground,
importTransferThreads,
parseTransferBundle,
skipReasonLabel,
type SkippedThread,
type TransferProgress,
} from "@/core/threads/transfer";
function ProgressPanel({
progress,
label,
}: {
progress: TransferProgress | null;
label: string;
}) {
if (!progress) {
return (
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
<Loader2 className="size-4 animate-spin" />
{label}
</div>
);
}
const percent =
progress.total > 0
? Math.round((progress.processed / progress.total) * 100)
: 0;
return (
<div className="space-y-3 py-2">
<Progress value={percent} />
<p className="text-sm text-neutral-600">
已处理 {progress.processed} / {progress.total} 条,成功{" "}
{progress.succeeded},跳过 {progress.skipped}
</p>
</div>
);
}
function SkippedList({ items }: { items: SkippedThread[] }) {
if (items.length === 0) return null;
return (
<div className="max-h-40 space-y-1 overflow-auto rounded-lg border p-2 text-xs text-neutral-600">
{items.slice(0, 20).map((item) => (
<p key={`${item.thread_id}-${item.reason}`}>
{item.title || item.thread_id}:{skipReasonLabel(item.reason)}
</p>
))}
{items.length > 20 && <p>其余 {items.length - 20} 条已省略</p>}
</div>
);
}
export function ChatHistoryExportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<TransferProgress | null>(null);
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
async function handleExport() {
if (running) return;
setRunning(true);
setProgress(null);
setSkipped([]);
try {
setProgress({
total: 0,
processed: 0,
succeeded: 0,
skipped: 0,
});
const result = await exportAllThreadsInBackground(setProgress);
setSkipped(result.skipped);
if (result.succeeded === 0) {
toast.message(
result.total === 0 ? "没有可导出的对话" : "没有成功导出的对话",
);
return;
}
const extras = [
result.skipped.length > 0 ? `跳过 ${result.skipped.length} 条` : "",
result.skipped_files > 0 ? `跳过 ${result.skipped_files} 个过大附件` : "",
].filter(Boolean);
toast.success(
extras.length > 0
? `已导出 ${result.succeeded} 条,${extras.join(",")}`
: `已导出 ${result.succeeded} 条对话`,
);
if (result.skipped.length === 0) {
onOpenChange(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出失败");
} finally {
setRunning(false);
}
}
return (
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导出对话记录</DialogTitle>
<DialogDescription>
后台逐条打包当前账号的对话消息和附件,进度按条前进。单条上限 50MB,超出部分会跳过大附件,消息仍导出;一条失败不影响其余记录。
</DialogDescription>
</DialogHeader>
{running || progress ? (
<ProgressPanel progress={progress} label="正在准备导出…" />
) : (
<p className="text-muted-foreground text-sm">
导出文件可拷到另一台机器,在本页「导入记录」还原。
</p>
)}
<SkippedList items={skipped} />
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={running}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button disabled={running} onClick={() => void handleExport()}>
{running && <Loader2 className="size-4 animate-spin" />}
{running ? "导出中" : "开始导出"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function ChatHistoryImportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<TransferProgress | null>(null);
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
async function handleFile(file: File) {
if (running) return;
setRunning(true);
setProgress(null);
setSkipped([]);
try {
const threads = parseTransferBundle(await file.text());
setProgress({
total: threads.length,
processed: 0,
succeeded: 0,
skipped: 0,
});
const result = await importTransferThreads(threads, setProgress);
setSkipped(result.skipped);
void queryClient.invalidateQueries({ queryKey: ["threads"] });
if (result.imported === 0) {
toast.message(
result.skipped.length > 0
? `没有导入新对话,跳过 ${result.skipped.length} 条`
: "没有导入新对话",
);
return;
}
toast.success(
result.skipped.length > 0
? `已导入 ${result.imported} 条,跳过 ${result.skipped.length} 条`
: `已导入 ${result.imported} 条对话`,
);
if (result.skipped.length === 0) {
onOpenChange(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "导入失败");
} finally {
setRunning(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导入对话记录</DialogTitle>
<DialogDescription>
选择从另一台机器导出的 JSON 文件。逐条写入,进度按条前进。已存在或失败的记录会跳过,不影响其余记录。
</DialogDescription>
</DialogHeader>
{running || progress ? (
<ProgressPanel progress={progress} label="正在读取文件…" />
) : (
<p className="text-muted-foreground text-sm">
导入后归属当前登录账号,可继续查看和提问。附件会一并还原。
</p>
)}
<SkippedList items={skipped} />
<input
ref={inputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleFile(file);
}}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={running}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button
disabled={running}
onClick={() => inputRef.current?.click()}
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{running ? "导入中" : "选择文件"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,7 +3,11 @@
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { AppBrandImage } from "@/core/branding/app-brand-image"; import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer"; 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 { readRuntimeFlag } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,11 +16,25 @@ import { cn } from "@/lib/utils";
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。 * 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
* 非紧凑模式仍用 <Welcome/>,本组件不参与。 * 非紧凑模式仍用 <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 desensitizeText = useDisplayDesensitizer();
const { theme } = useTheme(); const { theme } = useTheme();
const systemName = useAppSystemName(""); 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"); const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
return ( return (
@@ -46,7 +64,7 @@ export function CompactWelcome({ className }: { className?: string }) {
{desensitizeText(welcomeTitle)} {desensitizeText(welcomeTitle)}
</h1> </h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed"> <p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 {desensitizeText(welcomeDescription)}
</p> </p>
</div> </div>
); );
@@ -140,6 +140,7 @@ export function InputBox({
knowledgeRetrieval, knowledgeRetrieval,
knowledgeSuggestedQuestions = [], knowledgeSuggestedQuestions = [],
extraModels = [], extraModels = [],
placeholder,
assistantId, assistantId,
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
@@ -227,6 +228,8 @@ export function InputBox({
knowledgeSuggestedQuestions?: string[]; knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */ /** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[]; extraModels?: Model[];
/** Page-specific prompt. Omit to keep the localized product default. */
placeholder?: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings(); const [localSettings, setLocalSettings] = useLocalSettings();
@@ -861,7 +864,7 @@ export function InputBox({
data-composer-height={composerMinHeight ? "" : undefined} data-composer-height={composerMinHeight ? "" : undefined}
style={composerHeightStyle} style={composerHeightStyle}
disabled={disabled} disabled={disabled}
placeholder={t.inputBox.placeholder} placeholder={placeholder || t.inputBox.placeholder}
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={initialValue} defaultValue={initialValue}
/> />
@@ -63,6 +63,10 @@ import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility"; import { isInternalToolCallName } from "@/core/tools/visibility";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "@/core/tools/skill-invocation";
import { import {
applyInlineCitations, applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
@@ -371,6 +375,10 @@ function ToolCall({
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const invokedSkillName = extractInvokedSkillName(name, args);
const invokedSkillLabel = invokedSkillName
? skillInvocationLabel(invokedSkillName)
: undefined;
const displayMatch = const displayMatch =
result !== undefined && isDisplaySamplePayload(result) result !== undefined && isDisplaySamplePayload(result)
? null ? null
@@ -566,7 +574,9 @@ function ToolCall({
} else if (name === "read_file") { } else if (name === "read_file") {
let description: string | undefined = (args as { description: string }) let description: string | undefined = (args as { description: string })
?.description; ?.description;
if (!description) { if (invokedSkillLabel) {
description = invokedSkillLabel;
} else if (!description) {
description = t.toolCalls.readFile; description = t.toolCalls.readFile;
} }
const { path } = args as { path: string; content: string }; const { path } = args as { path: string; content: string };
@@ -598,11 +608,10 @@ function ToolCall({
/> />
); );
} else if (name === "bash") { } else if (name === "bash") {
const description: string | undefined = (args as { description: string }) const description =
?.description; invokedSkillLabel ||
if (!description) { (args as { description?: string })?.description ||
return t.toolCalls.executeCommand; t.toolCalls.executeCommand;
}
const command: string | undefined = (args as { command: string })?.command; const command: string | undefined = (args as { command: string })?.command;
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -653,7 +662,7 @@ function ToolCall({
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={description ?? t.toolCalls.useTool(name)} label={invokedSkillLabel ?? description ?? t.toolCalls.useTool(name)}
icon={WrenchIcon} icon={WrenchIcon}
></ChainOfThoughtStep> ></ChainOfThoughtStep>
); );
@@ -449,7 +449,7 @@ function HumanMessageBlock({
setEditing(false); setEditing(false);
} }
}} }}
className="min-h-20 resize-none text-[15px] leading-7" className="min-h-20 resize-none text-[calc(15px*var(--ui-font-scale,1))] leading-7"
/> />
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button <Button
@@ -473,7 +473,7 @@ function HumanMessageBlock({
} }
const messageResponse = text ? ( const messageResponse = text ? (
<div className="whitespace-pre-wrap break-words text-[15px] leading-7"> <div className="whitespace-pre-wrap break-words text-[calc(15px*var(--ui-font-scale,1))] leading-7">
{text} {text}
</div> </div>
) : null; ) : null;
@@ -1353,7 +1353,7 @@ function AssistantDisplayContent({
content={part.content} content={part.content}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
className="my-3" className="chat-assistant-markdown my-3"
components={components} components={components}
/> />
) : ( ) : (
@@ -729,7 +729,9 @@ export function MessageList({
<ConversationContent <ConversationContent
className={cn( className={cn(
"mx-auto w-full max-w-(--container-width-lg)", "mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12", hasSingleRoundRag
? "gap-4 pt-4"
: "chat-message-list gap-8 pt-12",
contentClassName, contentClassName,
)} )}
> >
@@ -752,6 +754,13 @@ export function MessageList({
</div> </div>
) : null} ) : null}
{(() => { {(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
let didRenderLiveKnowledgeRetrieval = false; let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages( const grouped = groupMessages(
displayMessages, displayMessages,
@@ -27,21 +27,146 @@ const PANEL_GUTTER_MARGIN = 24;
/** Questions needed before the navigator shows up at all. */ /** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2; const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed"; 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. */ /** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null { function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null; let node = el?.parentElement ?? null;
while (node) { while (node && node !== document.body) {
const overflowY = window.getComputedStyle(node).overflowY; if (isVerticallyScrollable(node) && node.scrollHeight > node.clientHeight + 1) {
if (
(overflowY === "auto" || overflowY === "scroll") &&
node.scrollHeight > node.clientHeight
) {
return node; return node;
} }
node = node.parentElement; 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 { function readCollapsedPreference(): boolean {
@@ -113,6 +238,7 @@ export function QuestionNavigator({
return; return;
} }
const firstAnchor = document.getElementById(questions[0]!.anchorId); const firstAnchor = document.getElementById(questions[0]!.anchorId);
if (!firstAnchor) return;
const scroller = findScrollParent(firstAnchor); const scroller = findScrollParent(firstAnchor);
if (!scroller) return; if (!scroller) return;
@@ -145,17 +271,20 @@ export function QuestionNavigator({
const scrollToQuestion = useCallback((anchorId: string) => { const scrollToQuestion = useCallback((anchorId: string) => {
const scroll = () => { const scroll = () => {
document const el = document.getElementById(anchorId);
.getElementById(anchorId) if (!el) return false;
?.scrollIntoView({ behavior: "smooth", block: "start" }); 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); 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(() => { const toggleCollapsed = useCallback(() => {
@@ -246,8 +375,12 @@ export function QuestionNavigator({
questions={questions} questions={questions}
activeId={activeId} activeId={activeId}
onSelect={(anchorId) => { onSelect={(anchorId) => {
setSheetOpen(false);
scrollToQuestion(anchorId); scrollToQuestion(anchorId);
setSheetOpen(false);
window.setTimeout(
() => scrollToQuestion(anchorId),
SHEET_SCROLL_RETRY_MS,
);
}} }}
className="min-h-0 flex-1" className="min-h-0 flex-1"
/> />
@@ -272,17 +405,22 @@ function QuestionCardList({
className?: string; className?: string;
}) { }) {
const itemRefs = useRef(new Map<string, HTMLButtonElement>()); 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 // Keep the active card visible as the chat scrolls. Scroll only this inner
// nudges this inner list — the card is already inside the on-screen panel, // list — `scrollIntoView({ block: "nearest" })` on Chrome 102 also moves
// so no outer container moves. // ancestor conversation scrollers, which undoes the jump to the question.
useEffect(() => { useEffect(() => {
if (!activeId) return; 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]); }, [activeId]);
return ( return (
<nav <nav
ref={navRef}
aria-label="问题导航" aria-label="问题导航"
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)} className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
> >
@@ -0,0 +1,71 @@
"use client";
import { ArrowRightLeftIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useAgentSkillMigrationSettings,
useUpdateAgentSkillMigrationSettings,
} from "@/core/system-settings/hooks";
export function AgentSkillMigrationSettingsPage() {
const { data, isLoading, isError } = useAgentSkillMigrationSettings();
const update = useUpdateAgentSkillMigrationSettings();
const enabled = data?.enabled ?? false;
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">
<ArrowRightLeftIcon 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>
);
}
@@ -1,9 +1,18 @@
"use client"; "use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react"; 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 { 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 { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -13,17 +22,35 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { import {
getHeaderBrandAsset, getHeaderBrandAsset,
getHeaderBrandFallback, getHeaderBrandFallback,
} from "@/core/branding/brand-assets"; } 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 { import {
useAppIdentitySettings, useAppIdentitySettings,
useDeleteAppIdentitySettings, useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings, useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks"; } 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"; import { SettingsSection } from "./settings-section";
@@ -33,6 +60,121 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/jpeg", "image/jpeg",
"image/webp", "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() { export function AppIdentitySettingsPage() {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -43,22 +185,72 @@ export function AppIdentitySettingsPage() {
const updateIdentity = useUpdateAppIdentitySettings(); const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings(); const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const headerBackgroundInputRef = useRef<HTMLInputElement>(null);
const navActiveInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState(""); 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 [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false); 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); const [resetOpen, setResetOpen] = useState(false);
useEffect(() => { useEffect(() => {
setSystemName(identity?.system_name ?? ""); 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); setPendingLogo(null);
setRemoveLogo(false); setRemoveLogo(false);
setPendingHeaderBackground(null);
setRemoveHeaderBackground(false);
setPendingNavActive(null);
setRemoveNavActive(false);
setNavigationImagesTouched(false);
if (fileInputRef.current) fileInputRef.current.value = ""; 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( const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined), () => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo], [pendingLogo],
); );
const headerBackgroundPreviewUrl = useMemo(
() =>
pendingHeaderBackground
? URL.createObjectURL(pendingHeaderBackground)
: undefined,
[pendingHeaderBackground],
);
const navActivePreviewUrl = useMemo(
() =>
pendingNavActive ? URL.createObjectURL(pendingNavActive) : undefined,
[pendingNavActive],
);
useEffect( useEffect(
() => () => { () => () => {
@@ -67,16 +259,86 @@ export function AppIdentitySettingsPage() {
[previewUrl], [previewUrl],
); );
useEffect(
() => () => {
if (headerBackgroundPreviewUrl) {
URL.revokeObjectURL(headerBackgroundPreviewUrl);
}
},
[headerBackgroundPreviewUrl],
);
useEffect(
() => () => {
if (navActivePreviewUrl) URL.revokeObjectURL(navActivePreviewUrl);
},
[navActivePreviewUrl],
);
useEffect(
() => () => {
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
},
[],
);
const savedLogoUrl = removeLogo const savedLogoUrl = removeLogo
? undefined ? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url); : resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl; 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 normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges = const hasChanges =
normalizedName !== (identity?.system_name ?? "") || 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) || Boolean(pendingLogo) ||
removeLogo; removeLogo ||
const customized = Boolean(identity?.system_name || identity?.has_logo); 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 busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => { const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -102,26 +364,82 @@ export function AppIdentitySettingsPage() {
if (fileInputRef.current) fileInputRef.current.value = ""; 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 () => { const handleSave = async () => {
try { try {
await updateIdentity.mutateAsync({ await updateIdentity.mutateAsync({
systemName: normalizedName, systemName: normalizedName,
headerTitleFontMode,
headerTitleFontSize: clampHeaderTitleFontSize(headerTitleFontSize),
logo: pendingLogo, logo: pendingLogo,
removeLogo, removeLogo,
lightRedHeaderBackground: pendingHeaderBackground,
removeLightRedHeaderBackground: removeHeaderBackground,
lightRedNavActive: pendingNavActive,
removeLightRedNavActive: removeNavActive,
}); });
toast.success("系统名称已保存", { setNavigationImagesTouched(false);
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。", clearHeaderTitlePreview();
clearLightRedNavigationPreview();
toast.success("系统名称配置已保存", {
description: "导航名称、Logo 与红色主题图片已更新。",
}); });
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败"); toast.error(error instanceof Error ? error.message : "系统名称配置保存失败");
} }
}; };
const handleReset = async () => { const handleReset = async () => {
try { try {
await deleteIdentity.mutateAsync(); await deleteIdentity.mutateAsync();
setNavigationImagesTouched(false);
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
setResetOpen(false); setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo"); toast.success("已恢复默认系统名称、Logo、字号模式和导航图片");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败"); toast.error(error instanceof Error ? error.message : "清除系统名称失败");
} }
@@ -131,7 +449,7 @@ export function AppIdentitySettingsPage() {
<> <>
<SettingsSection <SettingsSection
title="系统名称" title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。" description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo,并设置顶部导航中的名称字号及红色主题图片。未上传自定义图片时继续使用当前内置图片。这里不会改「生成网页标识」。"
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -152,6 +470,131 @@ export function AppIdentitySettingsPage() {
</div> </div>
</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 className="space-y-3">
<div> <div>
<div className="text-sm font-medium">系统 Logo</div> <div className="text-sm font-medium">系统 Logo</div>
@@ -226,6 +669,47 @@ export function AppIdentitySettingsPage() {
</div> </div>
</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"> <div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button <Button
type="button" type="button"
@@ -244,7 +728,7 @@ export function AppIdentitySettingsPage() {
disabled={!hasChanges || isPending || busy} disabled={!hasChanges || isPending || busy}
> >
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />} {updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"} {updateIdentity.isPending ? "保存中..." : "保存配置"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() {
<Dialog open={resetOpen} onOpenChange={setResetOpen}> <Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle> <DialogTitle>清除系统名称配置?</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。 清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。
</p> </p>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}> <Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
@@ -41,7 +41,7 @@ import { migrateAuthToSession } from "@/core/auth/scoped-storage";
import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session"; import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings"; import { DEFAULT_FONT_SCALE, useLocalSettings } from "@/core/settings";
import { import {
AGENT_COMPOSER_WIDTH_MAX, AGENT_COMPOSER_WIDTH_MAX,
AGENT_COMPOSER_WIDTH_MIN, AGENT_COMPOSER_WIDTH_MIN,
@@ -79,6 +79,11 @@ import type {
CompactShortcutButton, CompactShortcutButton,
CompactShortcutRouteParam, CompactShortcutRouteParam,
} from "@/core/system-settings/api"; } from "@/core/system-settings/api";
import {
DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
MESSAGE_MAP_DISPLAY_MAX_ZOOM,
} from "@/core/utils/map-tile-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id"; import { createClientId } from "@/lib/client-id";
import { import {
@@ -92,6 +97,7 @@ import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage"; import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import SkillVersioningSettingsPage from "@/pages/SkillVersioningSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings"; import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -492,10 +498,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const [htmlRuntimeDraft, setHtmlRuntimeDraft] = useState<{ const [htmlRuntimeDraft, setHtmlRuntimeDraft] = useState<{
base_url: string; base_url: string;
map_tile_url: string; map_tile_url: string;
message_map_tile_min_zoom: string;
message_map_tile_max_zoom: string;
} | null>(null); } | null>(null);
const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? ""; const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? "";
const runtimeTileUrl = const runtimeTileUrl =
htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? ""; htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? "";
const runtimeTileMinZoom =
htmlRuntimeDraft?.message_map_tile_min_zoom ??
String(
htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
);
const runtimeTileMaxZoom =
htmlRuntimeDraft?.message_map_tile_max_zoom ??
String(
htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
);
const parsedRuntimeTileMinZoom = Number(runtimeTileMinZoom);
const parsedRuntimeTileMaxZoom = Number(runtimeTileMaxZoom);
const runtimeTileZoomValid =
runtimeTileMinZoom.trim() !== "" &&
runtimeTileMaxZoom.trim() !== "" &&
Number.isInteger(parsedRuntimeTileMinZoom) &&
Number.isInteger(parsedRuntimeTileMaxZoom) &&
parsedRuntimeTileMinZoom >= 0 &&
parsedRuntimeTileMaxZoom <= MESSAGE_MAP_DISPLAY_MAX_ZOOM &&
parsedRuntimeTileMinZoom <= parsedRuntimeTileMaxZoom;
const htmlRuntimeDirty =
!!htmlRuntime &&
(runtimeBaseUrl !== htmlRuntime.base_url ||
runtimeTileUrl !== htmlRuntime.map_tile_url ||
parsedRuntimeTileMinZoom !==
(htmlRuntime.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM) ||
parsedRuntimeTileMaxZoom !==
(htmlRuntime.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM));
const globalDefault = appearance?.default_theme ?? "light"; const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list"; const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card"; const globalAgentView = appearance?.default_agent_view ?? "card";
@@ -1220,11 +1256,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<SettingsSection <SettingsSection
title="字号" title="字号"
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。" description="调整界面字号。默认 22px(超大)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
> >
<div className="space-y-3"> <div className="space-y-3">
<Select <Select
value={String(settings.display.fontScale ?? 1)} value={String(settings.display.fontScale ?? DEFAULT_FONT_SCALE)}
onValueChange={(value) => { onValueChange={(value) => {
const scale = Number(value); const scale = Number(value);
if (Number.isFinite(scale) && scale > 0) { if (Number.isFinite(scale) && scale > 0) {
@@ -1432,6 +1468,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
setHtmlRuntimeDraft({ setHtmlRuntimeDraft({
base_url: event.target.value, base_url: event.target.value,
map_tile_url: runtimeTileUrl, map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: runtimeTileMaxZoom,
}) })
} }
placeholder="https://你的域名/api/public/html-runtime" placeholder="https://你的域名/api/public/html-runtime"
@@ -1452,12 +1490,75 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
setHtmlRuntimeDraft({ setHtmlRuntimeDraft({
base_url: runtimeBaseUrl, base_url: runtimeBaseUrl,
map_tile_url: event.target.value, map_tile_url: event.target.value,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: runtimeTileMaxZoom,
}) })
} }
placeholder="https://tiles.example/{z}/{x}/{y}.png" placeholder="https://tiles.example/{z}/{x}/{y}.png"
className="mt-1 font-mono text-xs" className="mt-1 font-mono text-xs"
spellCheck={false} spellCheck={false}
/> />
<div className="mt-4 rounded-lg border bg-muted/20 p-3">
<div className="text-xs font-medium">消息列表地图瓦片层级</div>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
只作用于普通问答消息列表里的地图,生成 HTML 网页暂不受影响。请填写瓦片服务真实存在的层级;地图仍可放大到 {MESSAGE_MAP_DISPLAY_MAX_ZOOM} 级,超过最大真实层级后会放大已有瓦片。
</p>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium" htmlFor="message-map-tile-min-zoom">
最小真实层级
</label>
<Input
id="message-map-tile-min-zoom"
type="number"
min={0}
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
step={1}
inputMode="numeric"
value={runtimeTileMinZoom}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: event.target.value,
message_map_tile_max_zoom: runtimeTileMaxZoom,
})
}
className="mt-1"
/>
</div>
<div>
<label className="block text-xs font-medium" htmlFor="message-map-tile-max-zoom">
最大真实层级
</label>
<Input
id="message-map-tile-max-zoom"
type="number"
min={0}
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
step={1}
inputMode="numeric"
value={runtimeTileMaxZoom}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: event.target.value,
})
}
className="mt-1"
/>
</div>
</div>
{!runtimeTileZoomValid ? (
<p className="mt-2 text-xs text-destructive" role="alert">
请输入 0~{MESSAGE_MAP_DISPLAY_MAX_ZOOM} 的整数,且最小真实层级不能大于最大真实层级。
</p>
) : null}
</div>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<Button <Button
type="button" type="button"
@@ -1465,23 +1566,28 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
disabled={ disabled={
!htmlRuntime || !htmlRuntime ||
updateHtmlRuntime.isPending || updateHtmlRuntime.isPending ||
(runtimeBaseUrl === htmlRuntime.base_url && !runtimeTileZoomValid ||
runtimeTileUrl === htmlRuntime.map_tile_url) !htmlRuntimeDirty
} }
onClick={() => onClick={() =>
updateHtmlRuntime.mutate( updateHtmlRuntime.mutate(
{ base_url: runtimeBaseUrl.trim(), map_tile_url: runtimeTileUrl.trim() }, {
base_url: runtimeBaseUrl.trim(),
map_tile_url: runtimeTileUrl.trim(),
message_map_tile_min_zoom: parsedRuntimeTileMinZoom,
message_map_tile_max_zoom: parsedRuntimeTileMaxZoom,
},
{ {
onSuccess: () => { onSuccess: () => {
setHtmlRuntimeDraft(null); setHtmlRuntimeDraft(null);
toast.success("HTML 运行地址已保存,之后新画的网页会使用这个地址"); toast.success("地址与消息列表地图瓦片层级已保存");
}, },
onError: (error) => toast.error(error.message || "保存失败"), onError: (error) => toast.error(error.message || "保存失败"),
}, },
) )
} }
> >
{updateHtmlRuntime.isPending ? "保存中..." : "保存地址"} {updateHtmlRuntime.isPending ? "保存中..." : "保存设置"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1557,6 +1663,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<PublishSquaresSettingsPage embedded /> <PublishSquaresSettingsPage embedded />
<Separator /> <Separator />
<SkillCompressionSettingsPage embedded /> <SkillCompressionSettingsPage embedded />
<Separator />
<SkillVersioningSettingsPage embedded />
</> </>
)} )}
</div> </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,410 @@
"use client";
import {
Image,
ImageOff,
ImageUp,
Loader2,
RotateCcw,
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useDeleteGeneratedPageBrandingSettings,
useGeneratedPageBrandingSettings,
useUpdateGeneratedPageBrandingSettings,
} from "@/core/system-settings/hooks";
import {
resolveGeneratedPageLogoUrl,
type GeneratedPageBrandLogoMode,
} from "@/core/system-settings/api";
import { SettingsSection } from "./settings-section";
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
const DEFAULT_LOGO_URL = "/api/public/html-runtime/brand-logo-dark.png";
export function GeneratedPageBrandingSettingsPage() {
const { data: branding, isPending } = useGeneratedPageBrandingSettings();
const updateBranding = useUpdateGeneratedPageBrandingSettings();
const deleteBranding = useDeleteGeneratedPageBrandingSettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [chromeEnabled, setChromeEnabled] = useState(true);
const [headerLayout, setHeaderLayout] = useState<"left" | "center">("center");
const [logoMode, setLogoMode] =
useState<GeneratedPageBrandLogoMode>("custom");
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setChromeEnabled(branding?.chrome_enabled ?? true);
setHeaderLayout(branding?.header_layout === "left" ? "left" : "center");
setSystemName(branding?.system_name ?? "");
setLogoMode(branding?.logo_mode ?? "custom");
setPendingLogo(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [
branding?.logo_version,
branding?.system_name,
branding?.footer_text,
branding?.logo_mode,
branding?.chrome_enabled,
branding?.header_layout,
]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
const currentLogoUrl = resolveGeneratedPageLogoUrl(branding?.logo_url);
const defaultLogoUrl = resolveGeneratedPageLogoUrl(DEFAULT_LOGO_URL);
const displayedLogoUrl =
previewUrl ??
(logoMode === "default"
? defaultLogoUrl
: logoMode === "custom" && branding?.logo_mode === "custom"
? currentLogoUrl
: undefined);
const normalizedName = systemName.trim();
const configurationWillBeActive = Boolean(
normalizedName &&
chromeEnabled &&
(logoMode !== "custom" || displayedLogoUrl),
);
const hasChanges =
chromeEnabled !== (branding?.chrome_enabled ?? true) ||
headerLayout !== (branding?.header_layout ?? "center") ||
normalizedName !== (branding?.system_name ?? "") ||
logoMode !== (branding?.logo_mode ?? "custom") ||
Boolean(pendingLogo);
const busy = updateBranding.isPending || deleteBranding.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片");
event.target.value = "";
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo 文件不能超过 10MB");
event.target.value = "";
return;
}
setPendingLogo(file);
setLogoMode("custom");
};
const handleLogoModeChange = (mode: GeneratedPageBrandLogoMode) => {
setLogoMode(mode);
if (mode !== "custom") {
setPendingLogo(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const logoModeDescription =
logoMode === "default"
? "保存后,生成网页会使用系统内置的默认头像。"
: logoMode === "none"
? "保存后,生成网页的品牌页头仅显示系统名称,不显示头像。"
: "上传的自定义 Logo 将显示在生成网页的品牌页头。";
const handleSave = async () => {
try {
const saved = await updateBranding.mutateAsync({
chromeEnabled,
headerLayout,
systemName: normalizedName,
logoMode,
logo: pendingLogo,
});
toast.success("生成网页标识已保存", {
description: saved.active
? "名称和头像显示方式已配置,之后新生成的 HTML 页面将自动加上品牌页头(不加页脚)。"
: "请填写系统名称,并在上传自定义、默认头像或不显示头像中完成选择。",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "生成网页标识保存失败",
);
}
};
const handleReset = async () => {
try {
await deleteBranding.mutateAsync();
setResetOpen(false);
toast.success("已清除生成网页标识", {
description: "之后生成网页时将恢复原有生成逻辑。",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "清除生成网页标识失败",
);
}
};
return (
<>
<SettingsSection
title="生成网页标识"
description="配置 AI 后续生成的 HTML 网页使用的名称与头像显示方式,并可单独开关平台的自动注入(只注入品牌页头,不加页脚)。这里只影响生成网页内容,不会修改 DeerFlow 自身的名称、导航或 Logo。"
>
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 rounded-lg border bg-muted/20 px-4 py-3">
<div className="min-w-0 space-y-1">
<label
htmlFor="generated-page-chrome-enabled"
className="text-sm font-medium"
>
自动注入品牌页头
</label>
<p className="text-xs leading-5 text-muted-foreground">
开启后,平台会给新生成的网页自动加上统一品牌页头(多页面含页面导航),不加页脚。
关闭后,生成网页完全不加页头、保持原有输出,也不要求页面自行展示名称或头像。
</p>
</div>
<Switch
id="generated-page-chrome-enabled"
checked={chromeEnabled}
onCheckedChange={setChromeEnabled}
disabled={isPending || busy}
className="mt-1"
/>
</div>
<div className="rounded-lg border bg-muted/20 px-4 py-3 text-sm leading-6">
<span className="font-medium">
{configurationWillBeActive
? "配置完整:"
: !chromeEnabled
? "自动注入已关闭:"
: "当前未启用:"}
</span>
{configurationWillBeActive
? logoMode === "none"
? "保存后,新生成网页将自动加上仅含系统名称的品牌页头(不显示头像、不加页脚)。"
: "保存后,新生成网页将自动加上品牌页头(名称 + 头像,不加页脚)。"
: !chromeEnabled
? "生成网页完全不加页头,也不要求页面自行展示名称或头像。"
: "请填写系统名称,并选择上传自定义、使用默认头像或不显示头像。"}
</div>
<div className="space-y-2">
<label
htmlFor="generated-page-brand-name"
className="text-sm font-medium"
>
网页中的系统名称
</label>
<Input
id="generated-page-brand-name"
value={systemName}
onChange={(event) => setSystemName(event.target.value)}
placeholder="请输入要在网页页头展示的系统名称"
maxLength={80}
disabled={isPending || busy}
aria-describedby="generated-page-brand-name-help"
/>
<div
id="generated-page-brand-name-help"
className="flex justify-between gap-4 text-xs text-muted-foreground"
>
<span>最多 80 个字符;需选择头像显示方式才会启用。</span>
<span className="tabular-nums">{systemName.length}/80</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">网页 Logo / 头像</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
可上传自定义 Logo、使用系统默认头像,或只显示系统名称。启用后会进入品牌页头。
</p>
</div>
<div
className="grid gap-2 sm:grid-cols-3"
role="group"
aria-label="网页 Logo 显示方式"
>
{[
["custom", "上传自定义"],
["default", "使用默认头像"],
["none", "不显示头像"],
].map(([mode, label]) => (
<Button
key={mode}
type="button"
variant={logoMode === mode ? "default" : "outline"}
className="h-11 justify-center"
aria-pressed={logoMode === mode}
onClick={() =>
handleLogoModeChange(mode as GeneratedPageBrandLogoMode)
}
disabled={isPending || busy}
>
{mode === "custom" ? (
<ImageUp aria-hidden />
) : mode === "default" ? (
<Image aria-hidden />
) : (
<ImageOff aria-hidden />
)}
{label}
</Button>
))}
</div>
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background p-3">
{displayedLogoUrl ? (
<img
src={displayedLogoUrl}
alt="网页 Logo 预览"
className="max-h-full max-w-full object-contain"
/>
) : (
<div className="text-center text-xs leading-5 text-muted-foreground">
{logoMode === "none" ? (
<ImageOff className="mx-auto mb-1.5 size-6" aria-hidden />
) : (
<ImageUp className="mx-auto mb-1.5 size-6" aria-hidden />
)}
{logoMode === "none"
? "当前选择不显示头像"
: "当前未上传自定义 Logo"}
</div>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
{logoMode === "custom" ? (
<>
<input
ref={fileInputRef}
id="generated-page-brand-logo"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleLogoChange}
disabled={isPending || busy}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || busy}
>
<ImageUp aria-hidden />
{displayedLogoUrl ? "更换 Logo" : "上传 Logo"}
</Button>
</div>
{pendingLogo && (
<p
className="truncate text-xs text-muted-foreground"
title={pendingLogo.name}
>
待上传:{pendingLogo.name}
</p>
)}
</>
) : (
<p className="text-xs leading-5 text-muted-foreground">
{logoModeDescription}
</p>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setResetOpen(true)}
disabled={!branding?.customized || busy}
>
<RotateCcw aria-hidden />
清除配置
</Button>
<Button
type="button"
className="h-11 min-w-28"
onClick={handleSave}
disabled={!hasChanges || isPending || busy}
>
{updateBranding.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateBranding.isPending ? "保存中..." : "保存网页标识"}
</Button>
</div>
</div>
</SettingsSection>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent aria-describedby="generated-page-brand-reset-description">
<DialogHeader>
<DialogTitle>清除生成网页标识</DialogTitle>
</DialogHeader>
<p
id="generated-page-brand-reset-description"
className="text-sm leading-6 text-muted-foreground"
>
将移除网页展示名称与 Logo/头像显示方式,并把自动注入开关恢复为开启。DeerFlow 自身界面不会发生变化,之后生成的 HTML 页面将恢复原有生成逻辑。
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setResetOpen(false)}
disabled={busy}
>
取消
</Button>
<Button variant="destructive" onClick={handleReset} disabled={busy}>
{deleteBranding.isPending && (
<Loader2 className="animate-spin" aria-hidden />
)}
{deleteBranding.isPending ? "清除中..." : "确认清除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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>
);
}
@@ -1,15 +1,19 @@
"use client"; "use client";
import { import {
BellIcon, ArchiveIcon,
ArrowRightLeftIcon,
BarChart3Icon,
BrainIcon, BrainIcon,
BookPlusIcon,
CpuIcon, CpuIcon,
DatabaseIcon, DatabaseIcon,
ArchiveIcon,
FileOutputIcon, FileOutputIcon,
HistoryIcon, HistoryIcon,
ImageIcon,
PaletteIcon, PaletteIcon,
SearchIcon, SearchIcon,
ShieldCheckIcon,
ShrinkIcon, ShrinkIcon,
SparklesIcon, SparklesIcon,
TypeIcon, TypeIcon,
@@ -29,9 +33,13 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AgentSkillMigrationSettingsPage } from "@/components/workspace/settings/agent-skill-migration-settings-page";
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; 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 { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { CallMetricsSettingsPage } from "@/components/workspace/settings/call-metrics-settings-page";
import { GeneratedPageBrandingSettingsPage } from "@/components/workspace/settings/generated-page-branding-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page"; import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page"; import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page"; import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
@@ -39,11 +47,13 @@ import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-ar
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page"; import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page"; import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-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 { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page"; import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page"; import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace"; import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
@@ -52,6 +62,7 @@ import { ServerDiagnosticsPanel } from "@/pages/ServerDiagnosticsPage";
type SettingsSection = type SettingsSection =
| "account" | "account"
| "appearance" | "appearance"
| "authentication"
| "conversation-context" | "conversation-context"
| "model-prompts" | "model-prompts"
| "server-diagnostics" | "server-diagnostics"
@@ -60,9 +71,13 @@ type SettingsSection =
| "chat-archive" | "chat-archive"
| "roundtable-rooms" | "roundtable-rooms"
| "knowledge-retrieval" | "knowledge-retrieval"
| "knowledge-batch-create"
| "appIdentity" | "appIdentity"
| "generatedPageBranding"
| "agentSkillMigration"
| "memory" | "memory"
| "fileWriting" | "fileWriting"
| "callMetrics"
| "retryKeepPartial" | "retryKeepPartial"
| "uploads" | "uploads"
| "tools" | "tools"
@@ -77,6 +92,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props; const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n(); const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const live = useLiveAccount();
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection); useState<SettingsSection>(defaultSection);
@@ -113,12 +129,17 @@ export function SettingsDialog(props: SettingsDialogProps) {
{ id: "roundtable-rooms", label: "会商并发", icon: UsersIcon }, { id: "roundtable-rooms", label: "会商并发", icon: UsersIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon }, { id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "appIdentity", label: "系统名称", icon: TypeIcon }, { id: "appIdentity", label: "系统名称", icon: TypeIcon },
{ id: "generatedPageBranding", label: "生成网页标识", icon: ImageIcon },
{ id: "agentSkillMigration", label: "智能体与技能迁移", icon: ArrowRightLeftIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon }, { id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon }, { id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "callMetrics", label: "调用统计", icon: BarChart3Icon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon }, { id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon }, { id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
] ]
: []), : []),
{ id: "knowledge-batch-create", label: "批量新增知识库", icon: BookPlusIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -210,15 +231,33 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "knowledge-retrieval" && isAdmin && ( {activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage /> <KnowledgeRetrievalSettingsPage />
)} )}
{activeSection === "knowledge-batch-create" && (
<KnowledgeBatchCreateSettingsPage />
)}
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "appIdentity" && isAdmin && ( {activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage /> <AppIdentitySettingsPage />
)} )}
{activeSection === "generatedPageBranding" && isAdmin && (
<GeneratedPageBrandingSettingsPage />
)}
{activeSection === "agentSkillMigration" && isAdmin && (
<AgentSkillMigrationSettingsPage />
)}
{activeSection === "memory" && isAdmin && ( {activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage /> <MemoryRuntimeSettingsPage />
)} )}
{activeSection === "fileWriting" && isAdmin && ( {activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage /> <WriteFileCompatibilitySettingsPage />
)} )}
{activeSection === "callMetrics" && isAdmin && (
<CallMetricsSettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && ( {activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage /> <RetryKeepPartialSettingsPage />
)} )}
@@ -13,6 +13,8 @@ import {
CopyIcon, CopyIcon,
EyeOffIcon, EyeOffIcon,
FileArchive, FileArchive,
FileDownIcon,
FileUpIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
Loader2, Loader2,
@@ -30,12 +32,19 @@ import {
UserIcon, UserIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
DateRangePicker,
type DateRangePickerProps,
type DateRangeValue,
} from "tdesign-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -70,10 +79,24 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import {
analyzeSkillMigrationSkip,
applyMigrationManifest,
exportSkillMigrationManifest,
getMigrationRoots,
preflightMigrationManifest,
} from "@/core/agent-migrations";
import type {
AgentSkillMigrationManifest,
MigrationPreflight,
MigrationResourcePreflight,
MigrationRoots,
} from "@/core/agent-migrations";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example"; import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { filterSkillsByCreatedAt } from "@/core/skill-created-at-filter";
import { import {
useDeleteBuiltinSkill, useDeleteBuiltinSkill,
useDeleteCustomSkill, useDeleteCustomSkill,
@@ -97,8 +120,10 @@ import {
type SkillAvailabilityControl, type SkillAvailabilityControl,
} from "@/core/skill-availability"; } from "@/core/skill-availability";
import { import {
useAgentSkillMigrationSettings,
usePublishSquares, usePublishSquares,
useSkillCompressionSettings, useSkillCompressionSettings,
useSkillVersioningSettings,
useViewMode, useViewMode,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { import {
@@ -155,6 +180,19 @@ type SkillSort =
| "name_asc" | "name_asc"
| "name_desc"; | "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 默认样式 */ /** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = { const rescanSuccessToastDarkOptions = {
classNames: { classNames: {
@@ -195,6 +233,7 @@ function SkillSettingsList({
onClose?: () => void; onClose?: () => void;
restrictWxTypes?: boolean; restrictWxTypes?: boolean;
}) { }) {
const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
const { theme } = useTheme(); const { theme } = useTheme();
const router = useRouter(); const router = useRouter();
@@ -205,6 +244,7 @@ function SkillSettingsList({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc"); const [sort, setSort] = useState<SkillSort>("time_desc");
const [createdAtRange, setCreatedAtRange] = useState<DateRangeValue>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Publisher filter: click a skill's 发布者 to keep only that user's skills. // Publisher filter: click a skill's 发布者 to keep only that user's skills.
// {id} is owner_user_id; {label} the desensitized display name. // {id} is owner_user_id; {label} the desensitized display name.
@@ -227,6 +267,41 @@ function SkillSettingsList({
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin"; const isAdmin = storedAuth?.system_role === "admin";
const { data: migrationSettings } = useAgentSkillMigrationSettings({
enabled: isAdmin,
});
const migrationEnabled = isAdmin && (migrationSettings?.enabled ?? false);
const [selectedMigrationNames, setSelectedMigrationNames] = useState<
Set<string>
>(new Set());
const [exportingMigration, setExportingMigration] = useState(false);
const [readingMigration, setReadingMigration] = useState(false);
const [applyingMigration, setApplyingMigration] = useState(false);
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [migrationManifest, setMigrationManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [migrationPreflight, setMigrationPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdminForMigration, setUseCurrentAdminForMigration] =
useState(false);
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
const migrationInputRef = useRef<HTMLInputElement>(null);
const createdAtPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
return {
今天: recentCalendarRange(1, now),
最近7天: recentCalendarRange(7, now),
最近30天: recentCalendarRange(30, now),
本月: [
formatLocalCalendarDate(monthStart),
formatLocalCalendarDate(now),
],
};
}, []);
// Name + tag filtering is resolved server-side via the /api/skills query. // Name + tag filtering is resolved server-side via the /api/skills query.
// A 岗位 (position) no longer *restricts* the list — it grants skills into the // A 岗位 (position) no longer *restricts* the list — it grants skills into the
@@ -238,8 +313,16 @@ function SkillSettingsList({
tagIds, tagIds,
sort, sort,
scope: isAdmin ? undefined : "mine", 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 关心的几个桶。 // 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。 // "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。 // "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
@@ -248,7 +331,7 @@ function SkillSettingsList({
const mine: Skill[] = []; const mine: Skill[] = [];
const stale: Skill[] = []; const stale: Skill[] = [];
const archived: Skill[] = []; const archived: Skill[] = [];
for (const skill of skills) { for (const skill of skillsInCreatedAtRange) {
const state = skill.state ?? "active"; const state = skill.state ?? "active";
const isCustom = skill.category === "custom"; const isCustom = skill.category === "custom";
const isOwnedByMe = const isOwnedByMe =
@@ -279,7 +362,7 @@ function SkillSettingsList({
staleVisible: stale, staleVisible: stale,
archivedVisible: archived, archivedVisible: archived,
}; };
}, [skills, currentUserId]); }, [skillsInCreatedAtRange, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose // "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool. // public skills come from the published pool.
@@ -334,7 +417,7 @@ function SkillSettingsList({
}, [page, totalPages]); }, [page, totalPages]);
useEffect(() => { useEffect(() => {
setPage(1); 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 // If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab. // to "我的" so the page never points at a dead tab.
@@ -375,6 +458,46 @@ function SkillSettingsList({
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page], [orderedSkills, page],
); );
const allFilteredMigrationSelected =
orderedSkills.length > 0 &&
orderedSkills.every((skill) => selectedMigrationNames.has(skill.name));
const migrationResources = useMemo(
() =>
migrationPreflight
? [
...migrationPreflight.skills,
...migrationPreflight.support_directories,
]
: [],
[migrationPreflight],
);
const migrationProblems = migrationResources.filter(
(item) => item.status !== "matched",
);
const strictUnresolvedMigrationOwners =
migrationPreflight?.owner_mappings.filter(
(owner) => owner.status === "unresolved",
) ?? [];
const skipAnalysis =
migrationManifest && migrationPreflight
? analyzeSkillMigrationSkip(migrationManifest, migrationPreflight)
: null;
const unresolvedMigrationOwners = skipInvalidSkills
? (skipAnalysis?.unresolvedOwners ?? [])
: strictUnresolvedMigrationOwners;
const migrationProblemsCanBeSkipped =
migrationProblems.length > 0 &&
!!skipAnalysis &&
skipAnalysis.skippedSkillNames.length > 0 &&
skipAnalysis.remainingSkillCount > 0 &&
skipAnalysis.unskippableProblemCount === 0;
const canApplyMigration =
!!migrationManifest &&
!!migrationPreflight &&
(migrationProblems.length === 0 ||
(skipInvalidSkills && migrationProblemsCanBeSkipped)) &&
(unresolvedMigrationOwners.length === 0 ||
useCurrentAdminForMigration);
const handleFillNames = async () => { const handleFillNames = async () => {
try { try {
@@ -422,6 +545,108 @@ function SkillSettingsList({
); );
} }
}; };
const toggleMigrationSelection = (name: string) => {
setSelectedMigrationNames((current) => {
const next = new Set(current);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleExportMigration = async () => {
if (selectedMigrationNames.size === 0) return;
setExportingMigration(true);
try {
const manifest = await exportSkillMigrationManifest([
...selectedMigrationNames,
]);
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `skill-sync-${manifest.bundle_id.slice(0, 8)}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
toast.success(`已导出 ${manifest.skills.length} 个技能的迁移清单`);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setExportingMigration(false);
}
};
const handleReadMigration = async (file: File) => {
if (file.size > 20 * 1024 * 1024) {
toast.error("技能迁移清单不能超过 20 MB");
return;
}
setReadingMigration(true);
setUseCurrentAdminForMigration(false);
setSkipInvalidSkills(false);
try {
const manifest = JSON.parse(
await file.text(),
) as AgentSkillMigrationManifest;
if (
manifest.schema_version !== 1 ||
!Array.isArray(manifest.agents) ||
!Array.isArray(manifest.skills) ||
!Array.isArray(manifest.support_directories)
) {
throw new Error("不是受支持的技能迁移清单");
}
if (manifest.skills.length === 0 || manifest.agents.length > 0) {
throw new Error(
"请选择从技能管理导出的清单;智能体清单请到智能体管理导入",
);
}
const [preflight, roots] = await Promise.all([
preflightMigrationManifest(manifest),
getMigrationRoots(),
]);
setMigrationManifest(manifest);
setMigrationPreflight(preflight);
setMigrationRoots(roots);
setMigrationDialogOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : "技能迁移清单读取失败");
} finally {
setReadingMigration(false);
}
};
const handleApplyMigration = async () => {
if (!migrationManifest || !canApplyMigration) return;
setApplyingMigration(true);
try {
const result = await applyMigrationManifest(
migrationManifest,
useCurrentAdminForMigration,
skipInvalidSkills,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["skills"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
]);
toast.success(
`同步完成:导入 ${result.skills_applied} 个技能${result.skills_skipped ? `,跳过 ${result.skills_skipped} 个` : ""}`,
);
setMigrationDialogOpen(false);
setMigrationManifest(null);
setMigrationPreflight(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setApplyingMigration(false);
}
};
return ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
@@ -503,6 +728,43 @@ function SkillSettingsList({
</Button> </Button>
{isAdmin && ( {isAdmin && (
<> <>
{migrationEnabled && (
<>
<Button
size="sm"
variant="outline"
onClick={() => void handleExportMigration()}
disabled={
selectedMigrationNames.size === 0 || exportingMigration
}
title="导出已选择技能的状态、归属和标签清单"
>
{exportingMigration ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileDownIcon className="size-4" />
)}
导出技能清单
{selectedMigrationNames.size > 0
? `(${selectedMigrationNames.size})`
: ""}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => migrationInputRef.current?.click()}
disabled={readingMigration}
title="识别已复制到运行目录的技能并导入清单信息"
>
{readingMigration ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileUpIcon className="size-4" />
)}
导入技能清单
</Button>
</>
)}
<ModelNameSelect <ModelNameSelect
value={fillModel} value={fillModel}
onChange={setFillModel} onChange={setFillModel}
@@ -562,7 +824,20 @@ function SkillSettingsList({
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
actions={ 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} /> <ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}> <Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger <SelectTrigger
@@ -587,8 +862,42 @@ function SkillSettingsList({
} }
/> />
{!isLoading && !error && ( {!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums"> <div className="flex min-h-9 flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-sm tabular-nums">
共 {orderedSkills.length} 个技能 共 {orderedSkills.length} 个技能
{migrationEnabled && selectedMigrationNames.size > 0
? ` · 已选择 ${selectedMigrationNames.size} 个`
: ""}
</p>
{migrationEnabled && orderedSkills.length > 0 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allFilteredMigrationSelected}
onClick={() =>
setSelectedMigrationNames((current) => {
const next = new Set(current);
orderedSkills.forEach((skill) => next.add(skill.name));
return next;
})
}
>
全选当前结果
</Button>
{selectedMigrationNames.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedMigrationNames(new Set())}
>
清空选择
</Button>
)}
</div>
)}
</div> </div>
)} )}
{publisher && ( {publisher && (
@@ -629,6 +938,19 @@ function SkillSettingsList({
availabilityControl="owner" availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"} layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher} onPublisherClick={setPublisher}
selected={
migrationEnabled && selectedMigrationNames.has(skill.name)
}
onSelectionToggle={
migrationEnabled
? () => toggleMigrationSelection(skill.name)
: undefined
}
className={cn(
migrationEnabled &&
selectedMigrationNames.has(skill.name) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
/> />
))} ))}
</div> </div>
@@ -660,6 +982,218 @@ function SkillSettingsList({
</Button> </Button>
</div> </div>
)} )}
{migrationEnabled && (
<input
ref={migrationInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择技能迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleReadMigration(file);
}}
/>
)}
<Dialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别技能目录并导入技能信息</DialogTitle>
<DialogDescription>
只同步技能归属、发布/上下线状态和标签,不覆盖、移动或删除任何目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">技能目录</span>
<code className="break-all">{migrationRoots.skills_root}</code>
{migrationRoots.extensions_config_path && (
<>
<span className="text-muted-foreground">状态配置</span>
<code className="break-all">
{migrationRoots.extensions_config_path}
</code>
</>
)}
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单中的技能及支持目录,再执行导入。
</p>
</div>
)}
{migrationManifest && migrationPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<SkillMigrationCount
label="技能"
value={migrationManifest.skills.length}
/>
<SkillMigrationCount
label="支持目录"
value={migrationManifest.support_directories.length}
/>
<SkillMigrationCount
label="目录已匹配"
value={
migrationResources.filter(
(resource) => resource.status === "matched",
).length
}
tone="success"
/>
<SkillMigrationCount
label="待处理问题"
value={
migrationProblems.length + unresolvedMigrationOwners.length
}
tone={
migrationProblems.length +
unresolvedMigrationOwners.length >
0
? "danger"
: "success"
}
/>
</div>
{migrationManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{migrationManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{migrationProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
{skipInvalidSkills
? "以下异常关联的技能将在导入时跳过"
: "目录预检未通过,可修正目录或跳过异常技能"}
</p>
<ul className="mt-2 space-y-2 text-xs">
{migrationProblems.slice(0, 50).map((item) => (
<SkillMigrationProblem
key={`${item.kind}:${item.key}`}
item={item}
/>
))}
</ul>
{migrationProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {migrationProblems.length - 50} 项未展示。
</p>
)}
{migrationProblemsCanBeSkipped && skipAnalysis && (
<label className="mt-3 flex min-h-11 cursor-pointer items-start gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Checkbox
className="mt-0.5"
checked={skipInvalidSkills}
onCheckedChange={(checked) =>
setSkipInvalidSkills(checked === true)
}
aria-label="跳过未通过目录预检的技能"
/>
<span>
<span className="block font-medium">
跳过异常技能,继续导入其余技能
</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
将跳过 {skipAnalysis.skippedSkillNames.length} 个,继续导入 {skipAnalysis.remainingSkillCount} 个;被跳过技能不会写入任何元数据。
</span>
</span>
</label>
)}
{!migrationProblemsCanBeSkipped && (
<p className="text-muted-foreground mt-3 text-xs">
当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。
</p>
)}
</div>
)}
{unresolvedMigrationOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedMigrationOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedMigrationOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdminForMigration}
onCheckedChange={setUseCurrentAdminForMigration}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{migrationProblems.length === 0 &&
unresolvedMigrationOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以导入技能信息</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹。
</p>
</div>
</div>
)}
{skipInvalidSkills &&
migrationProblemsCanBeSkipped &&
unresolvedMigrationOwners.length === 0 &&
skipAnalysis && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div>
<p className="font-medium">可以跳过异常技能后继续导入</p>
<p className="text-muted-foreground mt-0.5 text-xs">
服务器执行时会再次校验,并返回实际导入与跳过数量。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setMigrationDialogOpen(false)}
disabled={applyingMigration}
>
取消
</Button>
<Button
onClick={() => void handleApplyMigration()}
disabled={!canApplyMigration || applyingMigration}
>
{applyingMigration && (
<Loader2 className="size-4 animate-spin" />
)}
{skipInvalidSkills && skipAnalysis
? `跳过 ${skipAnalysis.skippedSkillNames.length} 个并导入`
: "导入技能信息"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<InstallSkillDialog <InstallSkillDialog
open={installOpen} open={installOpen}
onClose={() => setInstallOpen(false)} onClose={() => setInstallOpen(false)}
@@ -681,11 +1215,65 @@ function SkillSettingsList({
); );
} }
function SkillMigrationCount({
label,
value,
tone = "default",
}: {
label: string;
value: number;
tone?: "default" | "success" | "danger";
}) {
const valueClass =
tone === "success"
? "text-emerald-600 dark:text-emerald-400"
: tone === "danger"
? "text-destructive"
: "text-foreground";
return (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={cn("mt-1 text-xl font-semibold", valueClass)}>{value}</p>
</div>
);
}
function SkillMigrationProblem({
item,
}: {
item: MigrationResourcePreflight;
}) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
export function SkillRow({ export function SkillRow({
skill, skill,
currentUserId, currentUserId,
isAdmin, isAdmin,
availabilityControl, availabilityControl,
className,
selected = false,
onSelectionToggle,
layout = "row", layout = "row",
onPublisherClick, onPublisherClick,
knowledgeBindings = [], knowledgeBindings = [],
@@ -695,6 +1283,11 @@ export function SkillRow({
isAdmin: boolean; isAdmin: boolean;
/** Which permission boundary exposes the global online/offline control. */ /** Which permission boundary exposes the global online/offline control. */
availabilityControl?: SkillAvailabilityControl; availabilityControl?: SkillAvailabilityControl;
/** Optional visual state supplied by management surfaces (for example selection). */
className?: string;
/** When provided, clicking the row/card selects it instead of opening details. */
selected?: boolean;
onSelectionToggle?: () => void;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */ /** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card"; layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the /** Click the 发布者 line to filter the list to that publisher. Receives the
@@ -1055,6 +1648,17 @@ export function SkillRow({
const actionButtons = ( const actionButtons = (
<> <>
{onSelectionToggle && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setDetailOpen(true)}
title="查看技能详情"
>
<InfoIcon className="size-3.5" />
</Button>
)}
{canToggleAvailability && ( {canToggleAvailability && (
<Button <Button
size="sm" size="sm"
@@ -1215,11 +1819,23 @@ export function SkillRow({
className={cn( className={cn(
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white", "flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]", isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
className,
)} )}
> >
<div <div
className="min-w-0 flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)} role={onSelectionToggle ? "checkbox" : "button"}
aria-checked={onSelectionToggle ? selected : undefined}
tabIndex={0}
onClick={() =>
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
}
}}
> >
{badgeGroup} {badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
@@ -1236,12 +1852,24 @@ export function SkillRow({
className={cn( className={cn(
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white", "w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]", isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
className,
)} )}
variant="outline" variant="outline"
> >
<ItemContent <ItemContent
className="cursor-pointer" className="cursor-pointer"
onClick={() => setDetailOpen(true)} role={onSelectionToggle ? "checkbox" : "button"}
aria-checked={onSelectionToggle ? selected : undefined}
tabIndex={0}
onClick={() =>
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
}
}}
> >
<ItemTitle>{badgeGroup}</ItemTitle> <ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4"> <ItemDescription className="line-clamp-4">
@@ -1418,6 +2046,7 @@ function InstallSkillDialog({
const [nameZhModel, setNameZhModel] = useState<string | null>(null); const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const validateMutation = useValidateSkillUpload(); const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload(); const installMutation = useInstallSkillUpload();
const { data: skillVersioning } = useSkillVersioningSettings();
const setNameZhMutation = useSetSkillNameZh(); const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh(); const generateNameZhMutation = useGenerateSkillNameZh();
@@ -1506,10 +2135,18 @@ function InstallSkillDialog({
); );
const handleInstall = useCallback( const handleInstall = useCallback(
async (overwrite: boolean) => { async (
overwrite: boolean,
installMode?: "iterate" | "overwrite",
) => {
if (!file) return; if (!file) return;
try { try {
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory }); const result = await installMutation.mutateAsync({
file,
overwrite,
targetCategory,
installMode,
});
toast.success( toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace( (result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}", "{name}",
@@ -1564,6 +2201,10 @@ function InstallSkillDialog({
const canInstall = !!validation && !validationError && validation.can_install_as_is; const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite; const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite; const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const showIterationChoices =
skillVersioning?.enabled === true &&
targetCategory === "custom" &&
ownCustomCanOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other; const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it. // On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict = const unclaimedCustomConflict =
@@ -1789,6 +2430,12 @@ function InstallSkillDialog({
</span> </span>
</div> </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 && ( {isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}> <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" /> <AlertCircle className="mt-0.5 size-3.5 shrink-0" />
@@ -1810,20 +2457,37 @@ function InstallSkillDialog({
> >
{dialog.cancel} {dialog.cancel}
</Button> </Button>
{canOverwrite && ( {showIterationChoices && (
<Button <Button
variant="destructive" onClick={() => void handleInstall(true, "iterate")}
onClick={() => handleInstall(true)}
disabled={installMutation.isPending} disabled={installMutation.isPending}
> >
{installMutation.isPending && ( {installMutation.isPending && (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
)} )}
{dialog.replace} 迭代为新版本
</Button> </Button>
)} )}
{canOverwrite && (
<Button <Button
onClick={() => handleInstall(false)} 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} disabled={!canInstall || installMutation.isPending}
> >
{installMutation.isPending && ( {installMutation.isPending && (
@@ -1831,6 +2495,7 @@ function InstallSkillDialog({
)} )}
{installMutation.isPending ? dialog.installing : dialog.install} {installMutation.isPending ? dialog.installing : dialog.install}
</Button> </Button>
)}
</DialogFooter> </DialogFooter>
</> </>
)} )}
@@ -1,5 +1,13 @@
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import {
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -16,6 +24,7 @@ import {
useSkillKnowledgeBindings, useSkillKnowledgeBindings,
useSkillKnowledgeJobs, useSkillKnowledgeJobs,
} from "@/strategy-components/api/skill-knowledge"; } from "@/strategy-components/api/skill-knowledge";
import { cn } from "@/lib/utils";
const PHASE_LABELS: Record<string, string> = { const PHASE_LABELS: Record<string, string> = {
scanning: "扫描知识文件", scanning: "扫描知识文件",
@@ -42,6 +51,11 @@ const STATUS_LABELS: Record<string, string> = {
canceled: "已取消", canceled: "已取消",
}; };
const MIN_LEFT_PANEL_WIDTH = 320;
const MIN_RIGHT_PANEL_WIDTH = 420;
const PANEL_RESIZER_WIDTH = 12;
const PANEL_GRID_GAP = 16;
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) { export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
const visibleSkills = useSkills(); const visibleSkills = useSkills();
const allSkills = useAllSkillsAdmin(adminMode); const allSkills = useAllSkillsAdmin(adminMode);
@@ -56,6 +70,115 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
const bindings = useSkillKnowledgeBindings(undefined, !adminMode); const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
const jobs = useSkillKnowledgeJobs(!adminMode); const jobs = useSkillKnowledgeJobs(!adminMode);
const actions = useSkillKnowledgeActions(); const actions = useSkillKnowledgeActions();
const panelLayoutRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<{
pointerId: number;
startClientX: number;
startLeftPanelWidth: number;
} | null>(null);
const [leftPanelWidth, setLeftPanelWidth] = useState<number | null>(null);
const [isResizingPanels, setIsResizingPanels] = useState(false);
const getPanelLayoutMetrics = () => {
const container = panelLayoutRef.current;
if (!container || typeof window === "undefined") {
return { contentWidth: 0, paddingLeft: 0 };
}
const style = window.getComputedStyle(container);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
return {
contentWidth: Math.max(0, container.clientWidth - paddingLeft - paddingRight),
paddingLeft,
};
};
const getPanelResizeBounds = () => {
const { contentWidth } = getPanelLayoutMetrics();
const maximum = Math.max(
MIN_LEFT_PANEL_WIDTH,
contentWidth - MIN_RIGHT_PANEL_WIDTH - PANEL_RESIZER_WIDTH - PANEL_GRID_GAP * 2,
);
return { minimum: MIN_LEFT_PANEL_WIDTH, maximum };
};
const clampLeftPanelWidth = (width: number) => {
const { minimum, maximum } = getPanelResizeBounds();
return Math.min(Math.max(width, minimum), maximum);
};
const adjustLeftPanelWidth = (delta: number) => {
const { contentWidth } = getPanelLayoutMetrics();
const availableWidth = Math.max(
0,
contentWidth - PANEL_RESIZER_WIDTH - PANEL_GRID_GAP * 2,
);
const currentWidth = leftPanelWidth ?? Math.round(availableWidth * 0.45);
setLeftPanelWidth(clampLeftPanelWidth(currentWidth + delta));
};
const startPanelResize = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const container = panelLayoutRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const dividerRect = event.currentTarget.getBoundingClientRect();
const { paddingLeft } = getPanelLayoutMetrics();
resizeSessionRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startLeftPanelWidth: clampLeftPanelWidth(
dividerRect.left - containerRect.left - paddingLeft - PANEL_GRID_GAP,
),
};
event.currentTarget.setPointerCapture(event.pointerId);
setIsResizingPanels(true);
event.preventDefault();
};
const movePanelResize = (event: PointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
setLeftPanelWidth(
clampLeftPanelWidth(
session.startLeftPanelWidth + event.clientX - session.startClientX,
),
);
};
const finishPanelResize = (event: PointerEvent<HTMLDivElement>) => {
if (resizeSessionRef.current?.pointerId !== event.pointerId) return;
resizeSessionRef.current = null;
setIsResizingPanels(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handlePanelResizerKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowLeft":
adjustLeftPanelWidth(-32);
break;
case "ArrowRight":
adjustLeftPanelWidth(32);
break;
case "Home":
setLeftPanelWidth(getPanelResizeBounds().minimum);
break;
case "End":
setLeftPanelWidth(getPanelResizeBounds().maximum);
break;
default:
return;
}
event.preventDefault();
};
const panelLayoutStyle = leftPanelWidth === null
? undefined
: ({ "--skill-knowledge-left-panel-width": `${leftPanelWidth}px` } as CSSProperties);
const targets = useMemo<SkillKnowledgeTarget[]>(() => { const targets = useMemo<SkillKnowledgeTarget[]>(() => {
return (knowledgeBases.data ?? []) return (knowledgeBases.data ?? [])
@@ -143,7 +266,17 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
}; };
return ( return (
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6"> <div
ref={panelLayoutRef}
className={cn(
"grid size-full min-h-0 gap-4 overflow-auto p-4 lg:overflow-hidden lg:p-6",
leftPanelWidth === null
? "lg:grid-cols-[minmax(320px,0.9fr)_12px_minmax(420px,1.1fr)]"
: "lg:grid-cols-[minmax(320px,var(--skill-knowledge-left-panel-width))_12px_minmax(420px,1fr)]",
isResizingPanels && "select-none",
)}
style={panelLayoutStyle}
>
<section className="flex min-h-0 flex-col rounded-lg border bg-card"> <section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4"> <div className="border-b p-4">
<h2 className="font-semibold">归纳技能知识</h2> <h2 className="font-semibold">归纳技能知识</h2>
@@ -236,6 +369,27 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
</ScrollArea> </ScrollArea>
</section> </section>
<div
role="separator"
aria-label="调整归纳配置与任务面板宽度"
aria-orientation="vertical"
aria-valuemin={MIN_LEFT_PANEL_WIDTH}
aria-valuemax={getPanelResizeBounds().maximum}
aria-valuenow={leftPanelWidth ?? undefined}
aria-valuetext={leftPanelWidth === null ? "默认宽度" : `${leftPanelWidth} 像素`}
tabIndex={0}
title="拖动调整面板宽度,双击恢复默认"
className="group hidden touch-none cursor-col-resize items-stretch justify-center outline-none lg:flex"
onPointerDown={startPanelResize}
onPointerMove={movePanelResize}
onPointerUp={finishPanelResize}
onPointerCancel={finishPanelResize}
onKeyDown={handlePanelResizerKeyDown}
onDoubleClick={() => setLeftPanelWidth(null)}
>
<span className="w-px rounded-full bg-border transition-colors group-hover:w-0.5 group-hover:bg-primary group-focus-visible:w-0.5 group-focus-visible:bg-primary" />
</div>
<section className="flex min-h-0 flex-col gap-4 overflow-hidden"> <section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card"> <div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
@@ -257,10 +411,10 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
<Progress className="my-2" value={progress} /> <Progress className="my-2" value={progress} />
{job.items?.map((item) => ( {job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs"> <div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span> <span className="min-w-0 flex-1 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span> <span className="shrink-0 text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span> <span className="shrink-0">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>} {item.status === "failed" && <Button size="sm" variant="outline" className="shrink-0" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div> </div>
))} ))}
</div> </div>
@@ -272,7 +426,7 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
</div> </div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs"> <div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div> <div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div> <div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><div className="flex min-w-48 flex-1 items-center gap-2"><span className="min-w-0 flex-1 truncate font-medium">{binding.skill_name}</span><span className="min-w-0 flex-1 truncate text-muted-foreground">已归纳到 {binding.target_name}</span></div><Badge variant="outline" className="shrink-0">{STATUS_LABELS[binding.status] || binding.status}</Badge><div className="ml-auto flex shrink-0 items-center gap-1"><Button size="sm" variant="outline" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div></div>)}</div>
</div> </div>
</section> </section>
</div> </div>
@@ -7,14 +7,17 @@ import {
FilesIcon, FilesIcon,
FileTextIcon, FileTextIcon,
GlobeIcon, GlobeIcon,
HistoryIcon,
Loader2, Loader2,
PencilIcon, PencilIcon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
SparklesIcon, SparklesIcon,
UploadCloudIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Dialog as TDesignDialog } from "tdesign-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -38,15 +41,23 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useActivateSkillVersion,
useInstallSkillUpload,
useSetSkillAlwaysOn, useSetSkillAlwaysOn,
useSetSkillDetail, useSetSkillDetail,
useSetSkillNameZh, useSetSkillNameZh,
useSkill, useSkill,
useSkillContent, useSkillContent,
useSkillFiles, useSkillFiles,
useSkillVersions,
useValidateSkillUpload,
} from "@/core/skills/hooks"; } 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 type { Skill } from "@/core/skills/type";
import { formatDateTime } from "@/core/utils/datetime";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -109,7 +120,11 @@ export function SkillDetailDialog({
const setNameZh = useSetSkillNameZh(); const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh(); const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn(); const setAlwaysOn = useSetSkillAlwaysOn();
const activateVersion = useActivateSkillVersion();
const validateIteration = useValidateSkillUpload();
const installIteration = useInstallSkillUpload();
const { data: compression } = useSkillCompressionSettings(); const { data: compression } = useSkillCompressionSettings();
const { data: versioning } = useSkillVersioningSettings();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -118,10 +133,15 @@ export function SkillDetailDialog({
// Optional model override for the "AI 生成" button (null = configured default). // Optional model override for the "AI 生成" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null); const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null "overview" | "detail" | "display" | "source" | "files" | "versions" | null
>(null); >(null);
const [showRawSource, setShowRawSource] = useState(false); const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null); 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 canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]); const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
@@ -132,6 +152,12 @@ export function SkillDetailDialog({
if (!skill) return false; if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin"; return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]); }, [skill, isOwner]);
const canUseVersions =
versioning?.enabled === true &&
skill?.category === "custom" &&
!skill.builtin &&
canManage;
const canIterate = canUseVersions && isOwner;
const { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
@@ -144,6 +170,12 @@ export function SkillDetailDialog({
isLoading: isFilesLoading, isLoading: isFilesLoading,
error: filesError, error: filesError,
} = useSkillFiles(filesEnabled ? skillName : null); } = useSkillFiles(filesEnabled ? skillName : null);
const versionsEnabled = open && canUseVersions && activeTab === "versions";
const {
data: versionsData,
isLoading: isVersionsLoading,
error: versionsError,
} = useSkillVersions(versionsEnabled ? skillName : null, versionsEnabled);
useEffect(() => { useEffect(() => {
setEditing(false); setEditing(false);
@@ -151,17 +183,23 @@ export function SkillDetailDialog({
setActiveTab(null); setActiveTab(null);
setShowRawSource(false); setShowRawSource(false);
setOpenedFilePath(null); setOpenedFilePath(null);
setVersionToActivate(null);
setIterationFile(null);
}, [open, skillName]); }, [open, skillName]);
useEffect(() => { useEffect(() => {
setNameZhDraft(skill?.name_zh ?? ""); setNameZhDraft(skill?.name_zh ?? "");
}, [skill?.name, skill?.name_zh]); }, [skill?.name, skill?.name_zh]);
useEffect(() => {
if (!canUseVersions && activeTab === "versions") setActiveTab("overview");
}, [activeTab, canUseVersions]);
const skillBody = useMemo(() => { const skillBody = useMemo(() => {
return stripYamlFrontmatter(contentData?.content ?? ""); return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]); }, [contentData]);
const defaultTab: "detail" = "detail"; const defaultTab: "overview" = "overview";
const selectedTab = activeTab ?? defaultTab; const selectedTab = activeTab ?? defaultTab;
function startEditing() { function startEditing() {
@@ -218,11 +256,80 @@ export function SkillDetailDialog({
toast.success("Copied SKILL.md content"); 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 ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleDetailOpenChange}>
<DialogContent <DialogContent
className="flex flex-col gap-0 overflow-hidden p-0" className="flex flex-col gap-0 overflow-hidden p-0"
onInteractOutside={(event) => {
if (confirmationOpen) event.preventDefault();
}}
style={{ style={{
width: "min(48rem, calc(100vw - 2rem))", width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none", maxWidth: "none",
@@ -345,8 +452,10 @@ export function SkillDetailDialog({
)} )}
</div> </div>
)} )}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="sr-only">
{skill?.description ? desensitize(skill.description) : "-"} {skill?.description
? desensitize(skill.description)
: tx.introEmpty}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -368,7 +477,11 @@ export function SkillDetailDialog({
className="flex min-h-0 flex-1 flex-col gap-0" 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"> <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"> <TabsTrigger value="detail" className="text-xs">
<FileTextIcon className="mr-1 size-3.5" /> <FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle} {tx.detailSectionTitle}
@@ -385,9 +498,45 @@ export function SkillDetailDialog({
{tx.filesSectionTitle} {tx.filesSectionTitle}
</TabsTrigger> </TabsTrigger>
)} )}
{canUseVersions && (
<TabsTrigger value="versions" className="text-xs">
<HistoryIcon className="mr-1 size-3.5" />
版本
</TabsTrigger>
)}
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <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 && ( {canManage && compression?.enabled && skill && (
<div <div
className="mr-1 flex items-center gap-1.5" className="mr-1 flex items-center gap-1.5"
@@ -460,6 +609,25 @@ export function SkillDetailDialog({
</div> </div>
</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 <TabsContent
value="detail" value="detail"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" 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> </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 <TabsContent
value="source" value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" 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); if (!next) setOpenedFilePath(null);
}} }}
/> />
<TDesignDialog
visible={versionToActivate !== null}
header="切换技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定切换", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: activateVersion.isPending }}
confirmLoading={activateVersion.isPending}
closeBtn={!activateVersion.isPending}
closeOnEscKeydown={!activateVersion.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onCancel={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onConfirm={() => void confirmActivateVersion()}
>
<p className="text-sm leading-6 text-muted-foreground">
确认切换到 v{versionToActivate ?? ""}?切换后大模型只会看到该版本,技能调用次数保持不变。
</p>
</TDesignDialog>
<TDesignDialog
visible={iterationFile !== null}
header="迭代技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定迭代", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: installIteration.isPending }}
confirmLoading={installIteration.isPending}
closeBtn={!installIteration.isPending}
closeOnEscKeydown={!installIteration.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onCancel={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onConfirm={() => void confirmIteration()}
>
<div className="space-y-2 text-sm leading-6 text-muted-foreground">
<p>确认将此安装包迭代为新版本?当前版本会完整保留,技能调用次数保持不变。</p>
<p className="truncate text-xs" title={iterationFile?.name}>
安装包:{iterationFile?.name}
</p>
</div>
</TDesignDialog>
</> </>
); );
} }
@@ -5,13 +5,22 @@ import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react"; import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config"; import { useHtmlRuntimeSettings } from "@/core/system-settings/hooks";
import {
DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
getMapTileAttribution,
getMapTileUrl,
MESSAGE_MAP_DISPLAY_MAX_ZOOM,
MESSAGE_MAP_DISPLAY_MIN_ZOOM,
} from "@/core/utils/map-tile-config";
import { import {
coordinatesMatch, coordinatesMatch,
curvedLineCoordinates, curvedLineCoordinates,
flowArrowsForLines, flowArrowsForLines,
initialMapCamera, initialMapCamera,
messageMapRasterSource,
overlayPadding, overlayPadding,
resolveLineColor, resolveLineColor,
resolveMarkerColor, resolveMarkerColor,
@@ -24,16 +33,16 @@ import {
import type { MapSpec } from "./visualization-spec"; import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card"; import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification { function createStyle(minNativeZoom: number, maxNativeZoom: number): StyleSpecification {
return { return {
version: 8, version: 8,
sources: { sources: {
"google-raster": { "google-raster": messageMapRasterSource(
type: "raster", getMapTileUrl(),
tiles: [getMapTileUrl()], getMapTileAttribution(),
tileSize: 256, minNativeZoom,
attribution: getMapTileAttribution(), maxNativeZoom,
}, ),
}, },
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }], layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
}; };
@@ -172,11 +181,16 @@ function paintMapOverlay(
} }
export default function MapRenderer({ spec }: { spec: MapSpec }) { export default function MapRenderer({ spec }: { spec: MapSpec }) {
const { data: htmlRuntime } = useHtmlRuntimeSettings();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<SVGSVGElement>(null); const overlayRef = useRef<SVGSVGElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false); const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0); const [reloadToken, setReloadToken] = useState(0);
const minNativeZoom =
htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM;
const maxNativeZoom =
htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM;
const coordinates = useMemo(() => allCoordinates(spec), [spec]); const coordinates = useMemo(() => allCoordinates(spec), [spec]);
const lines = useMemo(() => styledLines(spec), [spec]); const lines = useMemo(() => styledLines(spec), [spec]);
const markerColors = useMemo( const markerColors = useMemo(
@@ -200,11 +214,11 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container, container,
style: createStyle(), style: createStyle(minNativeZoom, maxNativeZoom),
center: boot.center, center: boot.center,
zoom: boot.zoom, zoom: boot.zoom,
minZoom: 1, minZoom: MESSAGE_MAP_DISPLAY_MIN_ZOOM,
maxZoom: 20, maxZoom: MESSAGE_MAP_DISPLAY_MAX_ZOOM,
attributionControl: { compact: true }, attributionControl: { compact: true },
}); });
mapRef.current = map; mapRef.current = map;
@@ -330,7 +344,7 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
mapRef.current = null; mapRef.current = null;
map.remove(); map.remove();
}; };
}, [coordinates, lines, markerColors, reloadToken, spec]); }, [coordinates, lines, markerColors, maxNativeZoom, minNativeZoom, reloadToken, spec]);
return ( return (
<VisualizationCard> <VisualizationCard>
@@ -6,6 +6,7 @@ import {
curvedLineCoordinates, curvedLineCoordinates,
flowArrowsForLines, flowArrowsForLines,
initialMapCamera, initialMapCamera,
messageMapRasterSource,
overlayPadding, overlayPadding,
pointAndBearing, pointAndBearing,
polylineMetrics, polylineMetrics,
@@ -14,6 +15,18 @@ import {
svgPathFromCoordinates, svgPathFromCoordinates,
} from "./map-style.ts"; } from "./map-style.ts";
test("applies configured native zoom limits to the message-map raster source", () => {
const source = messageMapRasterSource(
"https://tiles.example/{z}/{x}/{y}.png",
"",
0,
8,
);
assert.equal(source.minzoom, 0);
assert.equal(source.maxzoom, 8);
assert.deepEqual(source.tiles, ["https://tiles.example/{z}/{x}/{y}.png"]);
});
test("assigns distinct colors when the model omits them", () => { test("assigns distinct colors when the model omits them", () => {
assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1)); assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1));
assert.equal(resolveMarkerColor(0, "#111111"), "#111111"); assert.equal(resolveMarkerColor(0, "#111111"), "#111111");
@@ -9,6 +9,22 @@ export const MAP_MARKER_PALETTE = [
"#4f46e5", "#4f46e5",
] as const; ] as const;
export function messageMapRasterSource(
tileUrl: string,
attribution: string,
minNativeZoom: number,
maxNativeZoom: number,
) {
return {
type: "raster" as const,
tiles: [tileUrl],
tileSize: 256,
attribution,
minzoom: minNativeZoom,
maxzoom: maxNativeZoom,
};
}
export type MapPoint = [number, number]; export type MapPoint = [number, number];
export function resolveMarkerColor(index: number, explicit?: string): string { export function resolveMarkerColor(index: number, explicit?: string): string {
@@ -5,6 +5,8 @@ import { useMemo } from "react";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer"; 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 { cn } from "@/lib/utils";
import { AuroraText } from "../ui/aurora-text"; import { AuroraText } from "../ui/aurora-text";
@@ -12,11 +14,15 @@ import { AuroraText } from "../ui/aurora-text";
export function Welcome({ export function Welcome({
className, className,
mode, mode,
pageDescription,
}: { }: {
className?: string; className?: string;
mode?: "ultra" | "pro" | "thinking" | "flash"; mode?: "ultra" | "pro" | "thinking" | "flash";
/** The actual interface mode is resolved by ChatPage. */
pageDescription?: GeneralQaPageDescription;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
const desensitizeText = useDisplayDesensitizer(); const desensitizeText = useDisplayDesensitizer();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isUltra = useMemo(() => mode === "ultra", [mode]); const isUltra = useMemo(() => mode === "ultra", [mode]);
@@ -26,6 +32,9 @@ export function Welcome({
} }
return ["var(--color-foreground)"]; return ["var(--color-foreground)"];
}, [isUltra]); }, [isUltra]);
const configured = pageDescription ?? pageDescriptions?.general_qa.full;
const normalTitle = configured?.welcome_title || t.welcome.greeting;
const normalDescription = configured?.welcome_description || "";
return ( return (
<div <div
className={cn( className={cn(
@@ -38,7 +47,7 @@ export function Welcome({
t.welcome.createYourOwnSkill t.welcome.createYourOwnSkill
) : ( ) : (
<div className="flex items-center"> <div className="flex items-center">
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText> <AuroraText colors={colors}>{desensitizeText(normalTitle)}</AuroraText>
</div> </div>
)} )}
</div> </div>
@@ -53,15 +62,11 @@ export function Welcome({
)} */} )} */}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground text-sm"> normalDescription ? (
{/* {t.welcome.description.includes("\n") ? ( <p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
<pre className="font-sans whitespace-pre"> {desensitizeText(normalDescription)}
{t.welcome.description} </p>
</pre> ) : null
) : (
<p>{t.welcome.description}</p>
)} */}
</div>
)} )}
</div> </div>
); );
@@ -2,24 +2,27 @@
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react"; import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks"; import { useAppSystemName } from "@/core/system-settings/hooks";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) { function BrandMark({ className }: { className?: string }) {
const { theme } = useTheme();
return ( return (
<div <div
className={cn( className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm", "flex size-9 shrink-0 items-center justify-center",
className, className,
)} )}
aria-hidden aria-hidden
> >
C <AppBrandImage theme={theme} className="size-full object-contain" />
</div> </div>
); );
} }
@@ -72,7 +75,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
{state === "collapsed" ? ( {state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2"> <div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0"> <div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" /> <BrandMark className="size-10" />
</div> </div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100"> <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" /> <SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
@@ -10,6 +10,7 @@ import {
Compass, Compass,
CpuIcon, CpuIcon,
DatabaseZapIcon, DatabaseZapIcon,
FileTextIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
ListTreeIcon, ListTreeIcon,
@@ -318,6 +319,16 @@ export function WorkspaceNavMenu() {
模型配置 模型配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/page-descriptions")
}
>
<FileTextIcon />
页面描述配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")} onClick={() => navigate("/page/workspace/admin/stream-truncations")}
@@ -19,6 +19,22 @@ import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/p
// 主题类型 // 主题类型
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`; export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
export type PresentationThemeContext = {
presentation_theme: 'light' | 'dark';
};
/**
* Snapshot only the current light/dark mode for generated HTML. Product
* accent palettes are intentionally not copied into generated pages.
*/
export function getPresentationThemeContext(
baseMode: 'light' | 'dark',
): PresentationThemeContext {
return {
presentation_theme: baseMode,
};
}
// 主题配置 // 主题配置
export interface ThemeConfig { export interface ThemeConfig {
type: BuiltinThemeId; type: BuiltinThemeId;
+58
View File
@@ -94,6 +94,33 @@ export interface AdminLeaderboardDimension {
last_seen_at: string; 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 { export interface AdminLeaderboardTrendPoint {
date: string; date: string;
runs: number; runs: number;
@@ -248,6 +275,37 @@ export async function getUserLeaderboard(
return result; 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 * Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`. * real-time records are included, then return the leaderboard for `range`.
+26
View File
@@ -8,6 +8,7 @@ import {
generateUserLeaderboardReport, generateUserLeaderboardReport,
getUserMemory, getUserMemory,
getUserLeaderboard, getUserLeaderboard,
getUserLeaderboardDetails,
refreshTodayLeaderboard, refreshTodayLeaderboard,
listUserQa, listUserQa,
listUserThreadMessages, listUserThreadMessages,
@@ -16,6 +17,7 @@ import {
updateAdminV1Fact, updateAdminV1Fact,
updateAdminV1Section, updateAdminV1Section,
} from "./api"; } from "./api";
import type { AdminLeaderboardDetailKind } from "./api";
import { import {
cancelActiveRun, cancelActiveRun,
cancelAllActiveRuns, 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 * Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers. * leaderboard query so the page shows the freshly recomputed numbers.
@@ -0,0 +1,265 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type MigrationResourceStatus =
| "matched"
| "missing"
| "hash_mismatch"
| "identity_mismatch"
| "invalid_path";
export interface MigrationOwnerReference {
source_user_id?: string | null;
email?: string | null;
}
export interface MigrationTag {
id: string;
name: string;
color: string;
description: string;
}
export interface AgentMigrationItem {
id: string;
relative_dir: string;
tree_hash: string;
name: string;
description: string;
owner?: MigrationOwnerReference | null;
published: boolean;
is_private: boolean;
square_id: string;
featured_order?: number | null;
created_at?: string | null;
updated_at?: string | null;
skills: string[];
tags: MigrationTag[];
}
export interface SkillMigrationItem {
name: string;
category: "public" | "custom";
relative_dir: string;
tree_hash: string;
owner?: MigrationOwnerReference | null;
published: boolean;
detail?: string | null;
name_zh?: string | null;
always_on: boolean;
square_id: string;
enabled: boolean;
state: "active" | "stale" | "archived";
pinned: boolean;
featured: boolean;
source: "agent" | "upload";
created_at?: string | null;
updated_at?: string | null;
tags: MigrationTag[];
}
export interface AgentSkillMigrationManifest {
schema_version: 1;
bundle_id: string;
created_at: string;
agents: AgentMigrationItem[];
skills: SkillMigrationItem[];
support_directories: Array<{
relative_dir: string;
tree_hash: string;
required_by: string[];
}>;
warnings: string[];
}
export interface MigrationResourcePreflight {
kind: "agent" | "skill" | "support";
key: string;
relative_dir: string;
status: MigrationResourceStatus;
message: string;
}
export interface MigrationPreflight {
ready: boolean;
agents: MigrationResourcePreflight[];
skills: MigrationResourcePreflight[];
support_directories: MigrationResourcePreflight[];
owner_mappings: Array<{
source_user_id?: string | null;
email?: string | null;
target_user_id?: string | null;
status: "matched" | "unresolved";
}>;
}
export interface MigrationRoots {
agents_root: string;
skills_root: string;
extensions_config_path?: string | null;
}
export interface MigrationApplyResult {
bundle_id: string;
agents_applied: number;
skills_applied: number;
skills_skipped: number;
skipped_skill_names: string[];
tags_created: number;
fallback_owner_count: number;
}
export interface SkillMigrationSkipAnalysis {
skippedSkillNames: string[];
remainingSkillCount: number;
unskippableProblemCount: number;
unresolvedOwners: MigrationPreflight["owner_mappings"];
}
export function analyzeSkillMigrationSkip(
manifest: AgentSkillMigrationManifest,
preflight: MigrationPreflight,
): SkillMigrationSkipAnalysis {
const failedSkillDirs = new Set(
preflight.skills
.filter((item) => item.status !== "matched")
.map((item) => item.relative_dir),
);
const skipped = new Set(
manifest.skills
.filter((item) => failedSkillDirs.has(item.relative_dir))
.map((item) => item.name),
);
const supportByDir = new Map(
manifest.support_directories.map((item) => [item.relative_dir, item]),
);
let unskippableProblemCount = preflight.agents.filter(
(item) => item.status !== "matched",
).length;
for (const problem of preflight.skills.filter(
(item) => item.status !== "matched",
)) {
if (!manifest.skills.some((item) => item.relative_dir === problem.relative_dir)) {
unskippableProblemCount += 1;
}
}
for (const problem of preflight.support_directories.filter(
(item) => item.status !== "matched",
)) {
const support = supportByDir.get(problem.relative_dir);
const affected = (support?.required_by ?? []).filter((name) =>
manifest.skills.some((item) => item.name === name),
);
if (affected.length === 0) {
unskippableProblemCount += 1;
} else {
affected.forEach((name) => skipped.add(name));
}
}
const remainingSkills = manifest.skills.filter(
(item) => !skipped.has(item.name),
);
const remainingOwnerKeys = new Set(
remainingSkills
.filter(
(item) =>
item.owner &&
(item.owner.source_user_id != null || item.owner.email != null),
)
.map(
(item) =>
`${item.owner?.source_user_id ?? ""}\u0000${item.owner?.email?.trim().toLowerCase() ?? ""}`,
),
);
const unresolvedOwners = preflight.owner_mappings.filter(
(owner) =>
owner.status === "unresolved" &&
remainingOwnerKeys.has(
`${owner.source_user_id ?? ""}\u0000${owner.email?.trim().toLowerCase() ?? ""}`,
),
);
return {
skippedSkillNames: manifest.skills
.filter((item) => skipped.has(item.name))
.map((item) => item.name),
remainingSkillCount: remainingSkills.length,
unskippableProblemCount,
unresolvedOwners,
};
}
const baseUrl = () => `${getBackendBaseURL()}/api/admin/agent-skill-migrations`;
async function parse<T>(response: Response): Promise<T> {
if (response.ok) return (await response.json()) as T;
const payload = (await response.json().catch(() => ({}))) as {
detail?: string | { message?: string };
};
const detail = payload.detail;
const message =
typeof detail === "string"
? detail
: detail?.message ?? `请求失败(${response.status})`;
throw new Error(message);
}
export async function getMigrationRoots(): Promise<MigrationRoots> {
return parse(await apiFetch(`${baseUrl()}/roots`));
}
export async function exportMigrationManifest(
agentIds: string[],
): Promise<AgentSkillMigrationManifest> {
return parse(
await apiFetch(`${baseUrl()}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_ids: agentIds }),
}),
);
}
export async function exportSkillMigrationManifest(
skillNames: string[],
): Promise<AgentSkillMigrationManifest> {
return parse(
await apiFetch(`${baseUrl()}/export-skills`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skill_names: skillNames }),
}),
);
}
export async function preflightMigrationManifest(
manifest: AgentSkillMigrationManifest,
): Promise<MigrationPreflight> {
return parse(
await apiFetch(`${baseUrl()}/preflight`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
}),
);
}
export async function applyMigrationManifest(
manifest: AgentSkillMigrationManifest,
useCurrentAdminForUnresolvedOwners: boolean,
skipInvalidSkills = false,
): Promise<MigrationApplyResult> {
return parse(
await apiFetch(`${baseUrl()}/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
manifest,
use_current_admin_for_unresolved_owners:
useCurrentAdminForUnresolvedOwners,
skip_invalid_skills: skipInvalidSkills,
}),
}),
);
}
@@ -0,0 +1 @@
export * from "./api";
+1
View File
@@ -449,6 +449,7 @@ export function useSyncAgentDirectory() {
}); });
} }
export function useAdminSyncAgentSkills() { export function useAdminSyncAgentSkills() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+11 -1
View File
@@ -53,7 +53,17 @@ export async function downloadArtifactFile({
isMock?: boolean; isMock?: boolean;
publicShareCode?: string; publicShareCode?: string;
}): Promise<void> { }): 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); const res = await apiFetch(url);
if (!res.ok) { if (!res.ok) {
throw new Error(await errorMessageFromResponse(res)); throw new Error(await errorMessageFromResponse(res));
+10 -3
View File
@@ -5,28 +5,35 @@ export function urlOfArtifact({
filepath, filepath,
threadId, threadId,
download = false, download = false,
offlineBundle = false,
isMock = false, isMock = false,
publicShareCode, publicShareCode,
}: { }: {
filepath: string; filepath: string;
threadId: string; threadId: string;
download?: boolean; download?: boolean;
offlineBundle?: boolean;
isMock?: boolean; isMock?: boolean;
publicShareCode?: string; 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 // A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead. // files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) { if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`; const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent( return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode, publicShareCode,
)}/files${normalized}`; )}/files${normalized}${suffix}`;
} }
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`; const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) { 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) { export function extractArtifactsFromThread(thread: AgentThread) {
+39 -4
View File
@@ -21,6 +21,14 @@ type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: st
| Extract<AccountAccess, { status: "unauthorized" }> | Extract<AccountAccess, { status: "unauthorized" }>
| { status: "checking" } | { status: "error"; message: string }; | { status: "checking" } | { status: "error"; message: string };
const INITIAL_CHECK_TIMEOUT_MS = 20_000;
const BACKGROUND_CHECK_TIMEOUT_MS = 45_000;
const BACKGROUND_WARNING_AFTER = 2;
function isTransientAuthFailure(error: unknown, aborted: boolean): boolean {
return aborted || error instanceof TypeError;
}
/** Keep protected pages unmounted until the configured authentication check succeeds. */ /** Keep protected pages unmounted until the configured authentication check succeeds. */
export function RequireAccount({ children, allowLegacyAnonymous = false }: { export function RequireAccount({ children, allowLegacyAnonymous = false }: {
children: ReactNode; children: ReactNode;
@@ -30,6 +38,9 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" }); const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false); const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
const lastAllowedAuth = useRef<ReturnType<typeof getStoredAuth>>(null);
const transientFailures = useRef(0);
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
@@ -51,11 +62,19 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const controller = new AbortController(); const controller = new AbortController();
const requestedAuth = getStoredAuth(); const requestedAuth = getStoredAuth();
let disposed = false; let disposed = false;
requestInFlight.current = true;
if (isRecentlyAuthenticatedLogin(requestedAuth)) { if (isRecentlyAuthenticatedLogin(requestedAuth)) {
setState({ status: "allowed", auth: requestedAuth }); setState({ status: "allowed", auth: requestedAuth });
return () => controller.abort(); return () => controller.abort();
} }
const timeout = setTimeout(() => controller.abort(), 20_000); const backgroundRecheck = lastAllowedAuth.current != null
&& JSON.stringify(lastAllowedAuth.current) === JSON.stringify(requestedAuth);
const timeout = setTimeout(
() => controller.abort(),
backgroundRecheck ? BACKGROUND_CHECK_TIMEOUT_MS : INITIAL_CHECK_TIMEOUT_MS,
);
// A background recheck must not unmount an active editor on every poll.
// A changed account/token still blocks immediately until its own check ends.
// Keep the protected tree mounted while a renewed token for the same // Keep the protected tree mounted while a renewed token for the same
// account is verified. Unmounting here destroys nested iframes and resets // account is verified. Unmounting here destroys nested iframes and resets
// an active chat even though the user's identity did not change. // an active chat even though the user's identity did not change.
@@ -77,6 +96,8 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
writingVerifiedAccount.current = true; writingVerifiedAccount.current = true;
try { try {
if (result.status === "unauthorized") { if (result.status === "unauthorized") {
lastAllowedAuth.current = null;
transientFailures.current = 0;
if (requestedAuth) clearStoredAuth(); if (requestedAuth) clearStoredAuth();
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) { } else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
setStoredAuth(result.auth); setStoredAuth(result.auth);
@@ -84,16 +105,30 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
} finally { } finally {
writingVerifiedAccount.current = false; writingVerifiedAccount.current = false;
} }
if (result.status === "allowed") {
lastAllowedAuth.current = result.auth;
transientFailures.current = 0;
}
setState(result); setState(result);
}).catch((error: unknown) => { }).catch((error: unknown) => {
if (disposed) return; if (disposed) return;
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试" const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试" : error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试"; : error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed" setState((previous) => {
const keepAllowed = previous.status === "allowed"
&& isSameAccountIdentity(previous.auth, requestedAuth) && isSameAccountIdentity(previous.auth, requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth) && isSameAccountSession(getStoredAuth(), requestedAuth);
? { ...previous, warning: message } : { status: "error", message }); if (!keepAllowed) return { status: "error", message };
// After a long Q&A the browser is still downloading artifacts / holding
// SSE, so the 15s login poll often waits in the 6-connection queue and
// aborts. One transient miss is not a real session failure.
if (isTransientAuthFailure(error, controller.signal.aborted)) {
transientFailures.current += 1;
if (transientFailures.current < BACKGROUND_WARNING_AFTER) return previous;
}
return { ...previous, warning: message };
});
}).finally(() => { }).finally(() => {
clearTimeout(timeout); clearTimeout(timeout);
}); });
@@ -1,65 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TaskEntryInputError,
buildMainAppUrl,
buildTaskEntryDestination,
buildTaskEntrySessionSearch,
parseTaskEntryParams,
} from "./task-entry.ts";
test("parses the dedicated single-agent task entry", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=XDFX&theme=dark-blue&hideJump=1",
);
assert.deepEqual(params, {
username: "admin",
password: "",
taskId: "108",
goPath: "xdfx",
theme: "dark-blue",
hideJump: true,
});
});
test("rejects missing identity, task, and roundtable routes", () => {
assert.throws(() => parseTaskEntryParams("?taskId=108&goPath=xdfx"), TaskEntryInputError);
assert.throws(() => parseTaskEntryParams("?username=admin&goPath=xdfx"), TaskEntryInputError);
assert.throws(
() => parseTaskEntryParams("?username=admin&taskId=108&goPath=xdfx-A"),
TaskEntryInputError,
);
});
test("forces isolated embed state and an allowlisted task target", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=xdfx&hideJump=true",
);
const session = new URLSearchParams(buildTaskEntrySessionSearch(params));
assert.equal(session.get("embed"), "1");
assert.equal(session.get("hideChrome"), "1");
assert.equal(session.get("isolate"), "1");
assert.equal(session.get("hideJump"), "1");
const destination = buildTaskEntryDestination(
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
params,
);
assert.equal(
destination,
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats?embed=1&hideChrome=1&isolate=1&hideJump=1",
);
assert.throws(() => buildTaskEntryDestination("https://example.com", params));
assert.throws(() => buildTaskEntryDestination("/page/workspace/chats/new", params));
});
test("returns to the main app at the same deployment prefix", () => {
assert.equal(
buildMainAppUrl(
"https://example.com/deerflow/task-entry.html?username=admin",
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
),
"https://example.com/deerflow/#/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
);
});
-82
View File
@@ -1,82 +0,0 @@
export const TASK_ENTRY_SUPPORTED_GOPATHS = ["3qfx", "rwfx", "xdfx"] as const;
export type TaskEntryGoPath = (typeof TASK_ENTRY_SUPPORTED_GOPATHS)[number];
export interface TaskEntryParams {
username: string;
password: string;
taskId: string;
goPath: TaskEntryGoPath;
theme: string;
hideJump: boolean;
}
export class TaskEntryInputError extends Error {
constructor(message: string) {
super(message);
this.name = "TaskEntryInputError";
}
}
function readFlag(value: string | null): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
export function parseTaskEntryParams(search: string): TaskEntryParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const username = (params.get("username") ?? "").trim();
const password = params.get("password") ?? "";
const taskId = (params.get("taskId") ?? "").trim();
const rawGoPath = (params.get("goPath") ?? "").trim().toLowerCase();
const theme = (params.get("theme") ?? "").trim();
if (!username) throw new TaskEntryInputError("入口缺少 username,无法登录任务问答。");
if (!taskId) throw new TaskEntryInputError("入口缺少 taskId,无法定位任务。");
if (!TASK_ENTRY_SUPPORTED_GOPATHS.includes(rawGoPath as TaskEntryGoPath)) {
throw new TaskEntryInputError("轻量入口仅支持 3qfx、rwfx 或 xdfx 单智能体任务问答。");
}
return {
username,
password,
taskId,
goPath: rawGoPath as TaskEntryGoPath,
theme,
hideJump: readFlag(params.get("hideJump")),
};
}
export function buildTaskEntrySessionSearch(params: TaskEntryParams): string {
const session = new URLSearchParams({
embed: "1",
hideChrome: "1",
isolate: "1",
});
if (params.hideJump) session.set("hideJump", "1");
if (params.theme) session.set("theme", params.theme);
return `?${session.toString()}`;
}
export function buildTaskEntryDestination(path: string, params: TaskEntryParams): string {
if (!path.startsWith("/page/strategy/task-management/") || path.includes("\\")) {
throw new Error("任务深链解析出了不受允许的目标页面。");
}
const url = new URL(path, "https://task-entry.invalid");
if (url.origin !== "https://task-entry.invalid") {
throw new Error("任务深链目标必须是系统内部页面。");
}
url.searchParams.set("embed", "1");
url.searchParams.set("hideChrome", "1");
url.searchParams.set("isolate", "1");
if (params.hideJump) url.searchParams.set("hideJump", "1");
if (params.theme) url.searchParams.set("theme", params.theme);
return `${url.pathname}${url.search}`;
}
export function buildMainAppUrl(currentHref: string, destination: string): string {
const appUrl = new URL(".", currentHref);
appUrl.search = "";
appUrl.hash = destination;
return appUrl.toString();
}
@@ -24,15 +24,6 @@ const headerRuntimeKeys: Record<BrandTheme, string> = {
dark: "VITE_BRAND_LOGO_DARK_URL", dark: "VITE_BRAND_LOGO_DARK_URL",
}; };
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_WELCOME_LIGHT_URL",
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
function runtimeConfigBaseUrl(): string | undefined { function runtimeConfigBaseUrl(): string | undefined {
if (typeof document === "undefined") { if (typeof document === "undefined") {
return undefined; return undefined;
@@ -77,11 +68,3 @@ export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
export function getHeaderBrandFallback(theme: BrandAssetTheme): string { export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)]; return headerFallbackAssets[resolveBrandTheme(theme)];
} }
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
@@ -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());
}
@@ -541,6 +541,8 @@ export const enUS: Translations = {
deleteConfirm: deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.", "Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted", deleteSuccess: "Skill deleted",
introSectionTitle: "Overview",
introEmpty: "No skill overview yet.",
detailSectionTitle: "Details", detailSectionTitle: "Details",
detailEmpty: "No details yet.", detailEmpty: "No details yet.",
detailEditAction: "Edit details", detailEditAction: "Edit details",
@@ -471,6 +471,8 @@ export interface Translations {
deleteAction: string; deleteAction: string;
deleteConfirm: string; deleteConfirm: string;
deleteSuccess: string; deleteSuccess: string;
introSectionTitle: string;
introEmpty: string;
detailSectionTitle: string; detailSectionTitle: string;
detailEmpty: string; detailEmpty: string;
detailEditAction: string; detailEditAction: string;
@@ -519,6 +519,8 @@ export const zhCN: Translations = {
deleteAction: "删除技能", deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。", deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除", deleteSuccess: "技能已删除",
introSectionTitle: "简介",
introEmpty: "暂无技能简介。",
detailSectionTitle: "详情", detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。", detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情", detailEditAction: "编辑详情",
+1
View File
@@ -5,6 +5,7 @@ export {
useKnowledgeRagEnabled, useKnowledgeRagEnabled,
} from "./knowledge-rag"; } from "./knowledge-rag";
export { export {
DEFAULT_FONT_SCALE,
getLastAgentId, getLastAgentId,
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
+3 -1
View File
@@ -11,6 +11,8 @@ import {
type ComposerSessionSettings, type ComposerSessionSettings,
} from "./composer-scope"; } from "./composer-scope";
export const DEFAULT_FONT_SCALE = 1.375;
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: { notification: {
enabled: true, enabled: true,
@@ -21,7 +23,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
artifactDirectoryModeEnabled: false, artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false, contextCompressionEnabled: false,
citationCleanupWords: [], citationCleanupWords: [],
fontScale: 1, fontScale: DEFAULT_FONT_SCALE,
fontScaleMode: "text", fontScaleMode: "text",
}, },
context: { context: {
@@ -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
@@ -160,6 +160,24 @@ export interface InstallSkillResponse {
overwritten?: boolean; 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 { export interface PreviewSkillInstallRequest {
thread_id: string; thread_id: string;
path: string; path: string;
@@ -271,13 +289,18 @@ export async function validateSkillUpload(
export async function installSkillUpload( export async function installSkillUpload(
file: File, file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {}, options: {
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
} = {},
): Promise<InstallSkillResponse> { ): Promise<InstallSkillResponse> {
const form = new FormData(); const form = new FormData();
form.append("file", file, file.name); form.append("file", file, file.name);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true"); if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory); 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 query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`, `${getBackendBaseURL()}/api/skills/install-upload${query}`,
@@ -289,6 +312,32 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>; 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( export async function duplicateSkill(
sourceName: string, sourceName: string,
newName: string, newName: string,
+52 -4
View File
@@ -10,6 +10,7 @@ import {
deleteCustomSkill, deleteCustomSkill,
deleteSkillFile, deleteSkillFile,
duplicateSkill, duplicateSkill,
activateSkillVersion,
enableSkill, enableSkill,
generateMissingSkillNameZh, generateMissingSkillNameZh,
generateSkillNameZh, generateSkillNameZh,
@@ -17,6 +18,7 @@ import {
getSkillContent, getSkillContent,
getSkillFileContent, getSkillFileContent,
getSkillFiles, getSkillFiles,
getSkillVersions,
installSkillUpload, installSkillUpload,
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile, saveSkillFile,
@@ -41,17 +43,24 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage). // forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = []; 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 search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const scope = params.scope; const scope = params.scope;
const enabled = params.enabled ?? true; const enabled = params.enabled ?? true;
const refetchOnMount = params.refetchOnMount;
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"], queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }), queryFn: () => loadSkills({ search, tagIds, sort, scope }),
enabled, enabled,
staleTime: 30_000, staleTime: 30_000,
refetchOnMount,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? EMPTY_SKILLS, isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -224,10 +233,49 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() { export function useInstallSkillUpload() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) => mutationFn: ({ file, overwrite, targetCategory, installMode }: {
installSkillUpload(file, { overwrite, targetCategory }), file: File;
onSuccess: () => { overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
}) => installSkillUpload(file, { overwrite, targetCategory, installMode }),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); 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] });
}, },
}); });
} }
+487 -80
View File
@@ -32,6 +32,86 @@ export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean; 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> { export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`, `${getBackendBaseURL()}/api/system-settings/strategy-projects`,
@@ -309,19 +389,37 @@ export interface HtmlMapSettings {
export interface HtmlRuntimeSettings { export interface HtmlRuntimeSettings {
base_url: string; base_url: string;
map_tile_url: string; map_tile_url: string;
message_map_tile_min_zoom: number;
message_map_tile_max_zoom: number;
} }
export type AppIdentityHeaderTitleFontMode = "adaptive" | "fixed";
export interface AppIdentitySettings { export interface AppIdentitySettings {
system_name: string; system_name: string;
header_title_font_mode: AppIdentityHeaderTitleFontMode;
header_title_font_size: number;
logo_url: string | null; logo_url: string | null;
logo_version: string | null; logo_version: string | null;
has_logo: boolean; 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 { export interface AppIdentityUpdate {
systemName: string; systemName: string;
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
headerTitleFontSize: number;
logo?: File | null; logo?: File | null;
removeLogo?: boolean; removeLogo?: boolean;
lightRedHeaderBackground?: File | null;
removeLightRedHeaderBackground?: boolean;
lightRedNavActive?: File | null;
removeLightRedNavActive?: boolean;
} }
export interface LeaderboardSettings { export interface LeaderboardSettings {
@@ -543,6 +641,57 @@ export interface AppearanceUpdate {
custom_themes?: CustomThemePreset[]; custom_themes?: CustomThemePreset[];
} }
export interface GeneratedPageBrandingSettings {
chrome_enabled: boolean;
header_layout: "left" | "center";
system_name: string | null;
footer_text: string | null;
logo_mode: GeneratedPageBrandLogoMode;
logo_url: string | null;
logo_version: string | null;
customized: boolean;
active: boolean;
}
export type GeneratedPageBrandLogoMode = "custom" | "default" | "none";
export interface GeneratedPageBrandingUpdate {
chromeEnabled?: boolean;
headerLayout?: "left" | "center";
systemName: string;
footerText?: string;
logoMode?: GeneratedPageBrandLogoMode;
logo?: File | null;
removeLogo?: boolean;
}
export interface AppIdentitySettings {
system_name: string;
header_title_font_mode: AppIdentityHeaderTitleFontMode;
header_title_font_size: number;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
light_red_header_background_url: string | null;
light_red_header_background_version: string | null;
has_light_red_header_background: boolean;
light_red_nav_active_url: string | null;
light_red_nav_active_version: string | null;
has_light_red_nav_active: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
headerTitleFontSize: number;
logo?: File | null;
removeLogo?: boolean;
lightRedHeaderBackground?: File | null;
removeLightRedHeaderBackground?: boolean;
lightRedNavActive?: File | null;
removeLightRedNavActive?: boolean;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> { export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/appearance`, `${getBackendBaseURL()}/api/system-settings/appearance`,
@@ -590,6 +739,201 @@ export async function updateAppearanceSettings(
return data; return data;
} }
export async function loadGeneratedPageBrandingSettings(): Promise<GeneratedPageBrandingSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
);
if (!response.ok) {
throw new Error(`Failed to load generated-page branding: ${response.status}`);
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-生成网页标识查看",
url: "/api/system-settings/generated-page-branding",
returnParam: data,
});
return data;
}
export async function updateGeneratedPageBrandingSettings(
update: GeneratedPageBrandingUpdate,
): Promise<GeneratedPageBrandingSettings> {
const body = new FormData();
body.set("chrome_enabled", String(update.chromeEnabled ?? true));
body.set("header_layout", update.headerLayout ?? "center");
body.set("system_name", update.systemName);
body.set("footer_text", update.footerText ?? "");
body.set("logo_mode", update.logoMode ?? "custom");
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update generated-page identity" }));
throw new Error(detail.detail ?? "Failed to update generated-page identity");
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-生成网页标识编辑",
url: "/api/system-settings/generated-page-branding",
reqParam: {
chrome_enabled: update.chromeEnabled ?? true,
header_layout: update.headerLayout ?? "center",
system_name: update.systemName,
footer_text: update.footerText ?? "",
logo_mode: update.logoMode ?? "custom",
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteGeneratedPageBrandingSettings(): Promise<GeneratedPageBrandingSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset generated-page identity" }));
throw new Error(detail.detail ?? "Failed to reset generated-page identity");
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-生成网页标识清除配置",
url: "/api/system-settings/generated-page-branding",
returnParam: data,
});
return data;
}
export async function loadAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
);
if (!response.ok) {
throw new Error(`Failed to load app identity: ${response.status}`);
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-系统名称查看",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export async function updateAppIdentitySettings(
update: AppIdentityUpdate,
): 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 },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update app identity" }));
throw new Error(detail.detail ?? "Failed to update app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-系统名称编辑",
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,
});
return data;
}
export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset app identity" }));
throw new Error(detail.detail ?? "Failed to reset app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-系统名称清除配置",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
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}${assetUrl.startsWith("/") ? "" : "/"}${assetUrl}`;
}
export function resolveGeneratedPageLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> { export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/citation-display`, `${getBackendBaseURL()}/api/system-settings/citation-display`,
@@ -699,85 +1043,6 @@ export async function updateHtmlMapSettings(
return data; return data;
} }
export async function loadAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
);
if (!response.ok) {
throw new Error(`Failed to load app identity: ${response.status}`);
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-系统名称查看",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export async function updateAppIdentitySettings(
update: AppIdentityUpdate,
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update app identity" }));
throw new Error(detail.detail ?? "Failed to update app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-系统名称编辑",
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset app identity" }));
throw new Error(detail.detail ?? "Failed to reset app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-系统名称清除配置",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> { export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/html-runtime`, `${getBackendBaseURL()}/api/system-settings/html-runtime`,
@@ -819,7 +1084,7 @@ export async function updateHtmlRuntimeSettings(
const data = (await response.json()) as HtmlRuntimeSettings; const data = (await response.json()) as HtmlRuntimeSettings;
writeYLog({ writeYLog({
optType: OptType.edit, optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-HTML运行地址编辑", optDetail: "CM助手-全量-系统设置-HTML运行地址与消息地图瓦片层级编辑",
url: "/api/system-settings/html-runtime", url: "/api/system-settings/html-runtime",
reqParam: body, reqParam: body,
returnParam: data, returnParam: data,
@@ -867,6 +1132,10 @@ export interface SkillCompressionUpdate {
keep_index_in_prompt?: boolean; keep_index_in_prompt?: boolean;
} }
export interface SkillVersioningSettings {
enabled: boolean;
}
export interface ModelConcurrencySettings { export interface ModelConcurrencySettings {
enabled: boolean; enabled: boolean;
default_limit: number; default_limit: number;
@@ -887,6 +1156,48 @@ export interface MemoryRuntimeSettings {
enabled: boolean; enabled: boolean;
} }
export interface AgentSkillMigrationSettings {
enabled: boolean;
}
export async function loadAgentSkillMigrationSettings(): Promise<AgentSkillMigrationSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-skill-migration`,
);
if (!response.ok) {
throw new Error(`无法读取智能体与技能迁移开关(HTTP ${response.status})`);
}
return (await response.json()) as AgentSkillMigrationSettings;
}
export async function updateAgentSkillMigrationSettings(
body: AgentSkillMigrationSettings,
): Promise<AgentSkillMigrationSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-skill-migration`,
{
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 AgentSkillMigrationSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-智能体与技能迁移开关",
url: "/api/system-settings/agent-skill-migration",
reqParam: body,
returnParam: data,
});
return data;
}
export interface ConversationContextSettings { export interface ConversationContextSettings {
max_run_steps: number; max_run_steps: number;
recent_turn_window_enabled: boolean; recent_turn_window_enabled: boolean;
@@ -1012,6 +1323,55 @@ export async function updateWriteFileCompatibilitySettings(
return data; 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> { export async function loadConversationContextSettings(): Promise<ConversationContextSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/conversation-context`, `${getBackendBaseURL()}/api/system-settings/conversation-context`,
@@ -1155,6 +1515,25 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
return data; 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> { export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/model-concurrency`, `${getBackendBaseURL()}/api/system-settings/model-concurrency`,
@@ -1230,6 +1609,34 @@ export async function updateSkillCompressionSettings(
return data; 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)。 */ /** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
export interface AgentModuleConfig { export interface AgentModuleConfig {
key: string; key: string;
+192 -41
View File
@@ -4,17 +4,21 @@ import { useLocalSettings } from "@/core/settings";
import { import {
loadAgentModuleConfig, loadAgentModuleConfig,
loadAgentSkillMigrationSettings,
loadAssistantKnowledgeVisibility, loadAssistantKnowledgeVisibility,
loadAppearanceSettings, loadAppearanceSettings,
loadAppIdentitySettings,
loadCallMetricsSettings,
loadGeneratedPageBrandingSettings,
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadConversationContextSettings, loadConversationContextSettings,
loadRoundtableRoomLimitSettings, loadRoundtableRoomLimitSettings,
loadAppIdentitySettings,
loadHtmlMapSettings, loadHtmlMapSettings,
loadHtmlRuntimeSettings, loadHtmlRuntimeSettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadOrdinaryKnowledgeRetrievalSettings, loadOrdinaryKnowledgeRetrievalSettings,
loadPageDescriptionsSettings,
loadMemoryRuntimeSettings, loadMemoryRuntimeSettings,
loadPluginManagementSettings, loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
@@ -22,20 +26,25 @@ import {
loadPublishSquares, loadPublishSquares,
loadRetryKeepPartialSettings, loadRetryKeepPartialSettings,
loadSkillCompressionSettings, loadSkillCompressionSettings,
loadSkillVersioningSettings,
loadStrategyProjectSettings, loadStrategyProjectSettings,
loadWxCommonAgents, loadWxCommonAgents,
loadWriteFileCompatibilitySettings, loadWriteFileCompatibilitySettings,
updateAgentModuleConfig, updateAgentModuleConfig,
updateAgentSkillMigrationSettings,
updateAppearanceSettings, updateAppearanceSettings,
updateAppIdentitySettings,
updateCallMetricsSettings,
updateGeneratedPageBrandingSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateConversationContextSettings, updateConversationContextSettings,
updateRoundtableRoomLimitSettings, updateRoundtableRoomLimitSettings,
updateAppIdentitySettings,
updateHtmlMapSettings, updateHtmlMapSettings,
updateHtmlRuntimeSettings, updateHtmlRuntimeSettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updateOrdinaryKnowledgeRetrievalSettings, updateOrdinaryKnowledgeRetrievalSettings,
updatePageDescriptionsSettings,
updateMemoryRuntimeSettings, updateMemoryRuntimeSettings,
updatePluginManagementSettings, updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
@@ -43,13 +52,18 @@ import {
updatePublishSquares, updatePublishSquares,
updateRetryKeepPartialSettings, updateRetryKeepPartialSettings,
updateSkillCompressionSettings, updateSkillCompressionSettings,
updateSkillVersioningSettings,
updateStrategyProjectSettings, updateStrategyProjectSettings,
updateWxCommonAgents, updateWxCommonAgents,
updateWriteFileCompatibilitySettings, updateWriteFileCompatibilitySettings,
saveAssistantKnowledgeVisibility, saveAssistantKnowledgeVisibility,
deleteAppIdentitySettings, deleteAppIdentitySettings,
deleteGeneratedPageBrandingSettings,
type AppearanceUpdate, type AppearanceUpdate,
type AgentSkillMigrationSettings,
type AppIdentityUpdate, type AppIdentityUpdate,
type CallMetricsSettings,
type GeneratedPageBrandingUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type ConversationContextUpdate, type ConversationContextUpdate,
type RoundtableRoomLimitUpdate, type RoundtableRoomLimitUpdate,
@@ -58,6 +72,7 @@ import {
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type OrdinaryKnowledgeRetrievalUpdate, type OrdinaryKnowledgeRetrievalUpdate,
type PageDescriptionsUpdate,
type MemoryRuntimeSettings, type MemoryRuntimeSettings,
type PluginManagementUpdate, type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
@@ -65,6 +80,7 @@ import {
type PublishSquaresUpdate, type PublishSquaresUpdate,
type RetryKeepPartialSettings, type RetryKeepPartialSettings,
type SkillCompressionUpdate, type SkillCompressionUpdate,
type SkillVersioningSettings,
type StrategyProjectSettingsUpdate, type StrategyProjectSettingsUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
@@ -75,18 +91,28 @@ const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const; const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const; const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const GENERATED_PAGE_BRANDING_KEY = [
"systemSettings",
"generatedPageBranding",
] as const;
const APP_IDENTITY_KEY = ["systemSettings", "appIdentity"] as const; const APP_IDENTITY_KEY = ["systemSettings", "appIdentity"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const; const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const; const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const; const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] 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 MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const; const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
export const AGENT_SKILL_MIGRATION_KEY = [
"systemSettings",
"agentSkillMigration",
] as const;
const WRITE_FILE_COMPATIBILITY_KEY = [ const WRITE_FILE_COMPATIBILITY_KEY = [
"systemSettings", "systemSettings",
"writeFileCompatibility", "writeFileCompatibility",
] as const; ] as const;
const CALL_METRICS_KEY = ["systemSettings", "callMetrics"] as const;
const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const; const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const;
const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const; const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const;
const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [ const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
@@ -95,6 +121,7 @@ const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
] as const; ] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const; const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const; const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
const PAGE_DESCRIPTIONS_KEY = ["systemSettings", "pageDescriptions"] as const;
export const ASSISTANT_KNOWLEDGE_VISIBILITY_KEY = [ export const ASSISTANT_KNOWLEDGE_VISIBILITY_KEY = [
"system-settings", "system-settings",
"knowledge-base-product-version", "knowledge-base-product-version",
@@ -145,6 +172,27 @@ export function useUpdateStrategyProjectSettings() {
}); });
} }
export function usePageDescriptionsSettings() {
return useQuery({
queryKey: PAGE_DESCRIPTIONS_KEY,
queryFn: loadPageDescriptionsSettings,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePageDescriptionsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PageDescriptionsUpdate) =>
updatePageDescriptionsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PAGE_DESCRIPTIONS_KEY, data);
},
});
}
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
queryKey: PROMPT_PREFIX_KEY, queryKey: PROMPT_PREFIX_KEY,
@@ -205,6 +253,30 @@ export function useUpdateMemoryRuntimeSettings() {
}); });
} }
export function useAgentSkillMigrationSettings({
enabled = true,
}: { enabled?: boolean } = {}) {
return useQuery({
queryKey: AGENT_SKILL_MIGRATION_KEY,
queryFn: loadAgentSkillMigrationSettings,
enabled,
staleTime: 10_000,
refetchOnWindowFocus: true,
retry: false,
});
}
export function useUpdateAgentSkillMigrationSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AgentSkillMigrationSettings) =>
updateAgentSkillMigrationSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(AGENT_SKILL_MIGRATION_KEY, data);
},
});
}
export function useWriteFileCompatibilitySettings() { export function useWriteFileCompatibilitySettings() {
return useQuery({ return useQuery({
queryKey: WRITE_FILE_COMPATIBILITY_KEY, queryKey: WRITE_FILE_COMPATIBILITY_KEY,
@@ -225,6 +297,28 @@ export function useUpdateWriteFileCompatibilitySettings() {
}); });
} }
export function useCallMetricsSettings() {
return useQuery({
queryKey: CALL_METRICS_KEY,
queryFn: loadCallMetricsSettings,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateCallMetricsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CallMetricsSettings) => updateCallMetricsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(CALL_METRICS_KEY, data);
void queryClient.invalidateQueries({
queryKey: ["admin-activity", "leaderboard"],
});
},
});
}
export function useConversationContextSettings() { export function useConversationContextSettings() {
return useQuery({ return useQuery({
queryKey: CONVERSATION_CONTEXT_KEY, queryKey: CONVERSATION_CONTEXT_KEY,
@@ -341,6 +435,79 @@ export function useUpdateAppearanceSettings() {
}); });
} }
export function useGeneratedPageBrandingSettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: GENERATED_PAGE_BRANDING_KEY,
queryFn: loadGeneratedPageBrandingSettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateGeneratedPageBrandingSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: GeneratedPageBrandingUpdate) =>
updateGeneratedPageBrandingSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(GENERATED_PAGE_BRANDING_KEY, data);
},
});
}
export function useDeleteGeneratedPageBrandingSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteGeneratedPageBrandingSettings,
onSuccess: (data) => {
queryClient.setQueryData(GENERATED_PAGE_BRANDING_KEY, data);
},
});
}
export function useAppIdentitySettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APP_IDENTITY_KEY,
queryFn: loadAppIdentitySettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useAppSystemName(fallback: string) {
const { data } = useAppIdentitySettings();
const configured = data?.system_name.trim();
return configured || fallback;
}
export function useUpdateAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppIdentityUpdate) => updateAppIdentitySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useDeleteAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAppIdentitySettings,
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
/** /**
* Effective card/list display mode for the skill or agent list. A user's own * Effective card/list display mode for the skill or agent list. A user's own
* choice (persisted in local settings) wins; otherwise the admin-configured * choice (persisted in local settings) wins; otherwise the admin-configured
@@ -461,45 +628,6 @@ export function useUpdateHtmlMapSettings() {
}); });
} }
export function useAppIdentitySettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APP_IDENTITY_KEY,
queryFn: loadAppIdentitySettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useAppSystemName(fallback: string) {
const { data } = useAppIdentitySettings();
const configured = data?.system_name.trim();
return configured || fallback;
}
export function useUpdateAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppIdentityUpdate) => updateAppIdentitySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useDeleteAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAppIdentitySettings,
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useHtmlRuntimeSettings() { export function useHtmlRuntimeSettings() {
return useQuery({ return useQuery({
queryKey: HTML_RUNTIME_KEY, queryKey: HTML_RUNTIME_KEY,
@@ -554,6 +682,29 @@ export function useUpdateSkillCompressionSettings() {
}); });
} }
/** Deployment-wide custom-skill versioning feature gate. */
export function useSkillVersioningSettings() {
return useQuery({
queryKey: SKILL_VERSIONING_KEY,
queryFn: () => loadSkillVersioningSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillVersioningSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillVersioningSettings) =>
updateSkillVersioningSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_VERSIONING_KEY, data);
void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
},
});
}
export function useModelConcurrencySettings() { export function useModelConcurrencySettings() {
return useQuery({ return useQuery({
queryKey: MODEL_CONCURRENCY_KEY, queryKey: MODEL_CONCURRENCY_KEY,
+389
View File
@@ -0,0 +1,389 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL, getLangGraphBaseURL } from "@/core/config";
import { downloadAsFile } from "@/core/threads/export";
import type { AgentThread } from "@/core/threads/types";
import { OptType, writeYLog } from "@/core/log/y-log";
export const CHAT_HISTORY_FORMAT = "deerflow-chat-history-ui";
export const CHAT_HISTORY_VERSION = 1;
export const CHAT_HISTORY_BATCH_SIZE = 1;
export interface TransferFile {
path: string;
content_b64: string;
size: number;
}
export interface TransferThread {
thread_id: string;
title: string;
assistant_id?: string | null;
metadata: Record<string, unknown>;
created_at?: string;
updated_at?: string;
values: Record<string, unknown>;
files: TransferFile[];
skipped_files?: string[];
}
export interface SkippedThread {
thread_id: string;
title?: string;
reason: string;
}
export interface TransferBundle {
format: string;
version: number;
exported_at?: string;
threads: TransferThread[];
skipped?: SkippedThread[];
}
export interface ExportBatchResult {
threads: TransferThread[];
skipped: SkippedThread[];
}
export interface ImportBatchResult {
imported: number;
skipped: SkippedThread[];
}
export type TransferProgress = {
total: number;
processed: number;
succeeded: number;
skipped: number;
};
export interface ExportAllStatus {
job_id: string;
status: "running" | "done" | "error" | string;
total: number;
processed: number;
succeeded: number;
skipped: SkippedThread[];
skipped_files: number;
error?: string | null;
}
const baseUrl = () => `${getBackendBaseURL()}/api/chat-history`;
async function parseError(response: Response, fallback: string): Promise<Error> {
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
const detail = data.detail;
const message =
typeof detail === "string" && detail
? detail
: `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
export function countSkippedFiles(threads: TransferThread[]): number {
return threads.reduce(
(sum, item) => sum + (item.skipped_files?.length ?? 0),
0,
);
}
export function skipReasonLabel(reason: string): string {
switch (reason) {
case "too_large":
return "体积过大";
case "export_failed":
return "导出失败";
case "import_failed":
return "导入失败";
case "exists":
return "目标已有同名记录";
case "not_found":
return "记录不存在";
case "system_thread":
return "系统对话";
case "invalid_id":
return "编号不合法";
default:
return reason || "已跳过";
}
}
export async function startExportAllJob(): Promise<string> {
const response = await apiFetch(`${baseUrl()}/export-all`, {
method: "POST",
});
if (!response.ok) {
throw await parseError(response, "启动导出失败");
}
const result = (await response.json()) as { job_id: string };
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-对话记录-后台导出",
url: `${baseUrl()}/export-all`,
reqParam: {},
returnParam: result,
});
return result.job_id;
}
export async function getExportAllStatus(jobId: string): Promise<ExportAllStatus> {
const response = await apiFetch(`${baseUrl()}/export-all/${jobId}`);
if (!response.ok) {
throw await parseError(response, "查询导出进度失败");
}
return (await response.json()) as ExportAllStatus;
}
export async function downloadExportAllJob(jobId: string) {
const response = await apiFetch(`${baseUrl()}/export-all/${jobId}/download`);
if (!response.ok) {
throw await parseError(response, "下载导出文件失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "deerflow-chats.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function wait(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export async function exportAllThreadsInBackground(
onProgress?: (progress: TransferProgress) => void,
): Promise<ExportAllStatus> {
const jobId = await startExportAllJob();
while (true) {
const status = await getExportAllStatus(jobId);
onProgress?.({
total: status.total,
processed: status.processed,
succeeded: status.succeeded,
skipped: status.skipped.length,
});
if (status.status === "done") {
if (status.succeeded === 0) {
return status;
}
await downloadExportAllJob(jobId);
return status;
}
if (status.status === "error") {
throw new Error(status.error || "导出失败");
}
await wait(400);
}
}
export async function listExportableThreadIds(): Promise<string[]> {
const ids: string[] = [];
const pageSize = 100;
let offset = 0;
while (true) {
const response = await apiFetch(`${getLangGraphBaseURL()}/threads/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: pageSize,
offset,
metadata: {},
}),
});
if (!response.ok) {
throw new Error("加载对话列表失败");
}
const rows = (await response.json()) as AgentThread[];
ids.push(...rows.map((row) => row.thread_id).filter(Boolean));
if (rows.length < pageSize) {
break;
}
offset += rows.length;
}
return ids;
}
export async function exportChatHistoryBatch(
threadIds: string[],
): Promise<ExportBatchResult> {
const response = await apiFetch(`${baseUrl()}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_ids: threadIds }),
});
if (!response.ok) {
throw await parseError(response, "导出失败");
}
const result = (await response.json()) as ExportBatchResult;
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-对话记录-分批导出",
url: `${baseUrl()}/export`,
reqParam: { count: threadIds.length },
returnParam: {
exported: result.threads.length,
skipped: result.skipped.length,
},
});
return result;
}
export async function importChatHistoryBatch(
threads: TransferThread[],
): Promise<ImportBatchResult> {
const response = await apiFetch(`${baseUrl()}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threads }),
});
if (!response.ok) {
throw await parseError(response, "导入失败");
}
const result = (await response.json()) as ImportBatchResult;
writeYLog({
optType: OptType.import,
optDetail: "CM助手-全量-对话记录-分批导入",
url: `${baseUrl()}/import`,
reqParam: { count: threads.length },
returnParam: result,
});
return result;
}
export function parseTransferBundle(raw: string): TransferThread[] {
const parsed = JSON.parse(raw) as Partial<TransferBundle> | TransferThread;
if (parsed && typeof parsed === "object" && "threads" in parsed) {
const bundle = parsed as TransferBundle;
if (
bundle.format &&
bundle.format !== CHAT_HISTORY_FORMAT
) {
throw new Error("这不是对话记录迁移文件");
}
if (!Array.isArray(bundle.threads) || bundle.threads.length === 0) {
throw new Error("文件里没有可导入的对话");
}
return bundle.threads;
}
if (parsed && typeof parsed === "object" && "thread_id" in parsed) {
return [parsed as TransferThread];
}
throw new Error("无法识别该文件格式");
}
function chunk<T>(items: T[], size: number): T[][] {
const groups: T[][] = [];
for (let index = 0; index < items.length; index += size) {
groups.push(items.slice(index, index + size));
}
return groups;
}
async function exportIdsInBatches(
threadIds: string[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ threads: TransferThread[]; skipped: SkippedThread[] }> {
const threads: TransferThread[] = [];
const skipped: SkippedThread[] = [];
const batches = chunk(threadIds, CHAT_HISTORY_BATCH_SIZE);
let processed = 0;
for (const batch of batches) {
try {
const result = await exportChatHistoryBatch(batch);
threads.push(...result.threads);
skipped.push(...result.skipped);
} catch {
for (const threadId of batch) {
try {
const result = await exportChatHistoryBatch([threadId]);
threads.push(...result.threads);
skipped.push(...result.skipped);
} catch {
skipped.push({ thread_id: threadId, reason: "export_failed" });
}
}
}
processed += batch.length;
onProgress?.({
total: threadIds.length,
processed,
succeeded: threads.length,
skipped: skipped.length,
});
}
return { threads, skipped };
}
export async function exportSelectedThreads(
threadIds: string[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ threads: TransferThread[]; skipped: SkippedThread[] }> {
if (threadIds.length === 0) {
throw new Error("没有可导出的对话");
}
return exportIdsInBatches(threadIds, onProgress);
}
export function downloadTransferBundle(
threads: TransferThread[],
skipped: SkippedThread[],
filename = "deerflow-chats",
) {
const bundle: TransferBundle = {
format: CHAT_HISTORY_FORMAT,
version: CHAT_HISTORY_VERSION,
exported_at: new Date().toISOString(),
threads,
skipped,
};
downloadAsFile(
JSON.stringify(bundle),
`${filename}.json`,
"application/json;charset=utf-8",
);
}
export async function importTransferThreads(
threads: TransferThread[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ imported: number; skipped: SkippedThread[] }> {
const skipped: SkippedThread[] = [];
let imported = 0;
const batches = chunk(threads, CHAT_HISTORY_BATCH_SIZE);
let processed = 0;
for (const batch of batches) {
try {
const result = await importChatHistoryBatch(batch);
imported += result.imported;
skipped.push(...result.skipped);
} catch {
for (const thread of batch) {
try {
const result = await importChatHistoryBatch([thread]);
imported += result.imported;
skipped.push(...result.skipped);
} catch {
skipped.push({
thread_id: thread.thread_id,
title: thread.title,
reason: "import_failed",
});
}
}
}
processed += batch.length;
onProgress?.({
total: threads.length,
processed,
succeeded: imported,
skipped: skipped.length,
});
}
return { imported, skipped };
}
@@ -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 type { Translations } from "../i18n";
import { hasToolCalls } from "../messages/utils"; import { hasToolCalls } from "../messages/utils";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation";
import { isInternalToolCallName } from "./visibility"; import { isInternalToolCallName } from "./visibility";
export function explainLastToolCall(message: AIMessage, t: Translations) { 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) { export function explainToolCall(toolCall: ToolCall, t: Translations) {
const invokedSkillName = extractInvokedSkillName(toolCall.name, toolCall.args);
if (toolCall.name === "web_search" || toolCall.name === "image_search") { if (toolCall.name === "web_search" || toolCall.name === "image_search") {
return t.toolCalls.searchFor(toolCall.args.query); return t.toolCalls.searchFor(toolCall.args.query);
} else if (toolCall.name === "web_fetch") { } else if (toolCall.name === "web_fetch") {
@@ -26,6 +31,8 @@ export function explainToolCall(toolCall: ToolCall, t: Translations) {
return t.toolCalls.presentFiles; return t.toolCalls.presentFiles;
} else if (toolCall.name === "write_todos") { } else if (toolCall.name === "write_todos") {
return t.toolCalls.writeTodos; return t.toolCalls.writeTodos;
} else if (invokedSkillName) {
return skillInvocationLabel(invokedSkillName);
} else if (toolCall.args.description) { } else if (toolCall.args.description) {
return toolCall.args.description; return toolCall.args.description;
} else { } else {
@@ -8,12 +8,20 @@ import test from "node:test";
import { import {
clientRectsFollowZoom, clientRectsFollowZoom,
divideCssPx, divideCssPx,
fontSpacingMode,
fontScalePresentation, fontScalePresentation,
normalizeFontScaleMode, normalizeFontScaleMode,
popperCounterZoom, popperCounterZoom,
toCssPx, toCssPx,
} from "./page-zoom"; } from "./page-zoom";
test("only 20px and 22px font scales use compact spacing", () => {
assert.equal(fontSpacingMode(1), "standard");
assert.equal(fontSpacingMode(1.125), "standard");
assert.equal(fontSpacingMode(1.25), "compact");
assert.equal(fontSpacingMode(1.375), "compact");
});
test("text mode keeps the token scale and does not zoom the page", () => { test("text mode keeps the token scale and does not zoom the page", () => {
const presentation = fontScalePresentation("text", 1.25); const presentation = fontScalePresentation("text", 1.25);
assert.equal(presentation.mode, "text"); assert.equal(presentation.mode, "text");
+7 -1
View File
@@ -23,6 +23,10 @@ export function normalizeFontScale(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1;
} }
export function fontSpacingMode(scale: number): "standard" | "compact" {
return normalizeFontScale(scale) >= 1.25 ? "compact" : "standard";
}
export function normalizeFontScaleMode(value: unknown): FontScaleMode { export function normalizeFontScaleMode(value: unknown): FontScaleMode {
return value === "page" ? "page" : "text"; return value === "page" ? "page" : "text";
} }
@@ -270,7 +274,8 @@ export function setPageZoomCompensation(zoom: number) {
export function applyFontScaleToDocument(mode: FontScaleMode, scale: number) { export function applyFontScaleToDocument(mode: FontScaleMode, scale: number) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const presentation = fontScalePresentation(mode, scale); const normalizedScale = normalizeFontScale(scale);
const presentation = fontScalePresentation(mode, normalizedScale);
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty("--ui-font-scale", presentation.cssVariable); root.style.setProperty("--ui-font-scale", presentation.cssVariable);
if (presentation.zoom == null) { if (presentation.zoom == null) {
@@ -283,5 +288,6 @@ export function applyFontScaleToDocument(mode: FontScaleMode, scale: number) {
} }
root.style.removeProperty("font-size"); root.style.removeProperty("font-size");
root.dataset.fontScaleMode = presentation.mode; root.dataset.fontScaleMode = presentation.mode;
root.dataset.fontSpacing = fontSpacingMode(normalizedScale);
setPageZoomCompensation(presentation.compensation); setPageZoomCompensation(presentation.compensation);
} }
@@ -3,6 +3,11 @@ import { readRuntimeEnv } from "@/env";
export const DEFAULT_MAP_TILE_URL = export const DEFAULT_MAP_TILE_URL =
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}"; "https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
export const DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM = 0;
export const DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM = 8;
export const MESSAGE_MAP_DISPLAY_MIN_ZOOM = 1;
export const MESSAGE_MAP_DISPLAY_MAX_ZOOM = 20;
/** Browser-side tile template used only by ordinary-Q&A message-list maps. */ /** Browser-side tile template used only by ordinary-Q&A message-list maps. */
export function getMapTileUrl(): string { export function getMapTileUrl(): string {
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL; return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
+16
View File
@@ -49,6 +49,11 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend. * flag so operations can switch the shell without rebuilding the frontend.
*/ */
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"), FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Red-white theme opt-in for the same navigation shell used by the dark
* theme. Only the artwork and colour treatment differ.
*/
LIGHT_RED_TOP_NAV_ENABLED: readRuntimeFlag("VITE_LIGHT_RED_TOP_NAV_ENABLED"),
/** /**
* Development-only default entry switch for the isolated Vite-native * Development-only default entry switch for the isolated Vite-native
* official-style workspace. It never starts or embeds a Next.js app. * official-style workspace. It never starts or embeds a Next.js app.
@@ -56,6 +61,13 @@ export const env = {
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag( USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY", "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 简介模式 * Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items. * type (A / B) so two deployments can show different left-nav items.
@@ -63,3 +75,7 @@ export const env = {
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"), COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
}; };
export function isLightRedTopNavEnabled(theme: string): boolean {
return theme === "light" && env.LIGHT_RED_TOP_NAV_ENABLED;
}
+4
View File
@@ -1,3 +1,7 @@
// TDesign imperative APIs (for example DialogPlugin) create their own React
// roots. React 19 requires the library's adapter to register createRoot first.
import "tdesign-react/es/_util/react-19-adapter";
import "./core/compat/abort-signal"; import "./core/compat/abort-signal";
import "@fontsource/noto-sans-sc/chinese-simplified-400.css"; import "@fontsource/noto-sans-sc/chinese-simplified-400.css";
import "@fontsource/noto-sans-sc/chinese-simplified-500.css"; import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
@@ -1,4 +1,5 @@
import { PenTool, Sliders, Sparkles } from 'lucide-react'; import { PenTool, Sliders, Sparkles } from 'lucide-react';
import { usePageDescriptionsSettings } from '@/core/system-settings/hooks';
/** /**
* AI 写作入口页左栏「欢迎/空状态」屏。 * AI 写作入口页左栏「欢迎/空状态」屏。
@@ -7,7 +8,7 @@ import { PenTool, Sliders, Sparkles } from 'lucide-react';
* 第一条消息,就回到正常的对话时间线。 * 第一条消息,就回到正常的对话时间线。
*/ */
const STEPS = [ const DEFAULT_STEPS = [
{ {
n: '1', n: '1',
title: '对白意图解析', title: '对白意图解析',
@@ -32,6 +33,19 @@ const STEPS = [
]; ];
export function WritingWelcome() { 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 ( return (
<div className="size-full overflow-y-auto"> <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"> <div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
@@ -39,8 +53,13 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6"> <div className="flex flex-1 flex-col justify-center gap-6">
<div> <div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white"> <h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「ai写作」 {title}
</h2> </h2>
{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"> <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"> <strong className="font-semibold text-slate-700 dark:text-white/80">
@@ -48,10 +67,11 @@ export function WritingWelcome() {
</strong> </strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。 ,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p> </p>
)}
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => ( {steps.map((step) => (
<div <div
key={step.n} 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" 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"
+506 -12
View File
@@ -1,10 +1,21 @@
"use client"; "use client";
import { AlertCircle, BotIcon, CheckCircle2, RefreshCwIcon, SearchIcon } from "lucide-react"; import {
import { useEffect, useState } from "react"; AlertCircle,
BotIcon,
CheckCircle2,
DownloadIcon,
Loader2Icon,
RefreshCwIcon,
SearchIcon,
UploadIcon,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -28,14 +39,28 @@ import {
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { AgentCard } from "@/components/workspace/agents/agent-card"; import { AgentCard } from "@/components/workspace/agents/agent-card";
import {
applyMigrationManifest,
exportMigrationManifest,
getMigrationRoots,
preflightMigrationManifest,
} from "@/core/agent-migrations";
import type {
AgentSkillMigrationManifest,
MigrationPreflight,
MigrationResourcePreflight,
MigrationRoots,
} from "@/core/agent-migrations";
import { import {
useAdminSyncAgentSkills, useAdminSyncAgentSkills,
useAllAgentsAdminPaginated, useAllAgentsAdminPaginated,
useSetAgentHidden, useSetAgentHidden,
} from "@/core/agents"; } from "@/core/agents";
import type { AgentSkillsSyncResult } from "@/core/agents/api"; import type { AgentSkillsSyncResult } from "@/core/agents/api";
import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */ /** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = { const successToastDarkOptions = {
@@ -66,11 +91,14 @@ const PAGE_SIZE = 12;
type HiddenFilter = "all" | "visible" | "hidden"; type HiddenFilter = "all" | "visible" | "hidden";
export default function AdminAgentsPage() { export default function AdminAgentsPage() {
const queryClient = useQueryClient();
const { theme } = useTheme(); const { theme } = useTheme();
const darkToastOpts = const darkToastOpts =
theme === "dark" theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions } ? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined }; : { success: undefined, error: undefined };
const { data: migrationSettings } = useAgentSkillMigrationSettings();
const migrationEnabled = migrationSettings?.enabled ?? false;
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hiddenFilter, setHiddenFilter] = useState<HiddenFilter>("all"); const [hiddenFilter, setHiddenFilter] = useState<HiddenFilter>("all");
@@ -92,8 +120,21 @@ export default function AdminAgentsPage() {
null, null,
); );
const [syncDialogOpen, setSyncDialogOpen] = useState(false); const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const [readingImport, setReadingImport] = useState(false);
const [applyingImport, setApplyingImport] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importManifest, setImportManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [importPreflight, setImportPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdmin, setUseCurrentAdmin] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
// 删除最后一页的最后一条后,当前页可能超出总页数 — 收敛回末页。
useEffect(() => { useEffect(() => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (!isLoading && total > 0 && page > totalPages) { if (!isLoading && total > 0 && page > totalPages) {
@@ -125,7 +166,6 @@ export default function AdminAgentsPage() {
} }
} }
/** 搜索改为服务端过滤:输入变化即回到第一页。 */
function handleSearchChange(value: string) { function handleSearchChange(value: string) {
setSearch(value); setSearch(value);
setPage(1); setPage(1);
@@ -136,7 +176,6 @@ export default function AdminAgentsPage() {
setPage(1); setPage(1);
} }
/** 隐藏/取消隐藏:隐藏后除本管理页外不再展示,但智能体仍可正常使用。 */
async function handleToggleHidden(agentId: string, agentName: string, next: boolean) { async function handleToggleHidden(agentId: string, agentName: string, next: boolean) {
try { try {
await setHidden.mutateAsync({ id: agentId, hidden: next }); await setHidden.mutateAsync({ id: agentId, hidden: next });
@@ -154,19 +193,191 @@ export default function AdminAgentsPage() {
} }
} }
const allPageSelected =
agents.length > 0 && agents.every((agent) => selectedIds.has(agent.id));
const importResources = useMemo(
() =>
importPreflight
? [
...importPreflight.agents,
...importPreflight.skills,
...importPreflight.support_directories,
]
: [],
[importPreflight],
);
const importProblems = importResources.filter(
(item) => item.status !== "matched",
);
const unresolvedOwners =
importPreflight?.owner_mappings.filter(
(owner) => owner.status === "unresolved",
) ?? [];
const canApplyImport =
!!importManifest &&
!!importPreflight &&
importProblems.length === 0 &&
(unresolvedOwners.length === 0 || useCurrentAdmin);
function toggleAgentSelection(id: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleExportManifest() {
if (selectedIds.size === 0) return;
setExporting(true);
try {
const manifest = await exportMigrationManifest([...selectedIds]);
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `agent-sync-${manifest.bundle_id.slice(0, 8)}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
const warningText = manifest.warnings.length
? `;另有 ${manifest.warnings.length} 条提醒,请查看清单 warnings`
: "";
toast.success(
`已导出 ${manifest.agents.length} 个智能体的迁移清单(包含技能名称关联)${warningText}`,
darkToastOpts.success,
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : String(err),
darkToastOpts.error,
);
} finally {
setExporting(false);
}
}
async function handleImportFile(file: File) {
if (file.size > 5 * 1024 * 1024) {
toast.error("迁移清单不能超过 5 MB", darkToastOpts.error);
return;
}
setReadingImport(true);
setUseCurrentAdmin(false);
try {
const parsed = JSON.parse(await file.text()) as AgentSkillMigrationManifest;
if (
parsed.schema_version !== 1 ||
!Array.isArray(parsed.agents) ||
!Array.isArray(parsed.skills) ||
!Array.isArray(parsed.support_directories)
) {
throw new Error("不是受支持的智能体迁移清单");
}
if (parsed.agents.length === 0 || parsed.skills.length > 0) {
throw new Error("请选择从智能体管理导出的清单;技能清单请到技能管理导入");
}
const [preflight, roots] = await Promise.all([
preflightMigrationManifest(parsed),
getMigrationRoots(),
]);
setImportManifest(parsed);
setImportPreflight(preflight);
setMigrationRoots(roots);
setImportDialogOpen(true);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "迁移清单读取失败",
darkToastOpts.error,
);
} finally {
setReadingImport(false);
}
}
async function handleApplyImport() {
if (!importManifest || !canApplyImport) return;
setApplyingImport(true);
try {
const result = await applyMigrationManifest(
importManifest,
useCurrentAdmin,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["agents"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
]);
toast.success(
`同步完成:${result.agents_applied} 个智能体`,
darkToastOpts.success,
);
setImportDialogOpen(false);
setImportManifest(null);
setImportPreflight(null);
} catch (err) {
toast.error(
err instanceof Error ? err.message : String(err),
darkToastOpts.error,
);
} finally {
setApplyingImport(false);
}
}
return ( return (
<WorkspaceContainer> <WorkspaceContainer>
<WorkspaceHeader /> <WorkspaceHeader />
<WorkspaceBody> <WorkspaceBody>
<div className="flex size-full min-h-0 flex-col"> <div className="flex size-full min-h-0 flex-col">
<div className="flex items-start justify-between gap-4 border-b px-6 py-4"> <div className="flex flex-col gap-4 border-b px-6 py-4 xl:flex-row xl:items-start xl:justify-between">
<div> <div>
<h1 className="text-xl font-semibold">智能体管理</h1> <h1 className="text-xl font-semibold">智能体管理</h1>
<p className="text-muted-foreground mt-0.5 text-sm"> <p className="text-muted-foreground mt-0.5 text-sm">
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。 管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
</p> </p>
{migrationEnabled && (
<p className="text-muted-foreground mt-1 text-xs">
清单只包含智能体信息和技能名称关联,不包含技能信息或目录文件;数据量大时可分批点击卡片选择后导出。
</p>
)}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex flex-wrap items-center gap-2 xl:justify-end">
{migrationEnabled && (
<>
<Button
variant="outline"
size="sm"
onClick={handleExportManifest}
disabled={selectedIds.size === 0 || exporting}
>
{exporting ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<DownloadIcon className="mr-1.5 size-4" />
)}
导出迁移清单
{selectedIds.size ? `(${selectedIds.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={readingImport}
>
{readingImport ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<UploadIcon className="mr-1.5 size-4" />
)}
识别目录并同步信息
</Button>
</>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -192,7 +403,7 @@ export default function AdminAgentsPage() {
<SelectItem value="hidden">已隐藏</SelectItem> <SelectItem value="hidden">已隐藏</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="relative w-64"> <div className="relative min-w-56 flex-1 xl:w-64 xl:flex-none">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" /> <SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input <Input
value={search} value={search}
@@ -224,21 +435,105 @@ export default function AdminAgentsPage() {
</div> </div>
) : ( ) : (
<> <>
{migrationEnabled ? (
<div className="mb-3 flex min-h-8 items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
点击智能体卡片选择 · 共 {total} 个智能体
{selectedIds.size > 0
? ` · 已选择 ${selectedIds.size} 个`
: ""}
{isFetching ? " · 正在加载…" : ""}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allPageSelected}
onClick={() =>
setSelectedIds((current) => {
const next = new Set(current);
agents.forEach((agent) => next.add(agent.id));
return next;
})
}
>
全选当前页
</Button>
{selectedIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
清空选择
</Button>
)}
</div>
</div>
) : (
<p className="text-muted-foreground mb-3 text-xs"> <p className="text-muted-foreground mb-3 text-xs">
共 {total} 个智能体。 共 {total} 个智能体。
{isFetching ? " 正在加载…" : ""} {isFetching ? " 正在加载…" : ""}
</p> </p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> )}
{agents.map((agent) => ( <div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
role={migrationEnabled ? "grid" : undefined}
aria-label={migrationEnabled ? "可选择的智能体" : undefined}
>
{agents.map((agent) => {
const card = (
<AgentCard <AgentCard
key={agent.id}
agent={agent} agent={agent}
className={cn(
migrationEnabled &&
selectedIds.has(agent.id) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
onToggleHidden={(next) => onToggleHidden={(next) =>
handleToggleHidden(agent.id, agent.name, next) handleToggleHidden(agent.id, agent.name, next)
} }
toggleHiddenPending={setHidden.isPending} toggleHiddenPending={setHidden.isPending}
/> />
))} );
if (!migrationEnabled) {
return <div key={agent.id}>{card}</div>;
}
const selected = selectedIds.has(agent.id);
return (
<div
key={agent.id}
role="gridcell"
aria-selected={selected}
tabIndex={0}
className="relative cursor-pointer rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={(event) => {
const target = event.target as Element;
if (
target.closest(
"button, a, input, textarea, select, [role='button'], [role='switch'], [role='checkbox']",
)
) {
return;
}
toggleAgentSelection(agent.id);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
toggleAgentSelection(agent.id);
}
}}
>
{card}
</div>
);
})}
</div> </div>
<Pagination <Pagination
page={page} page={page}
@@ -306,6 +601,205 @@ export default function AdminAgentsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择智能体迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleImportFile(file);
}}
/>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别智能体目录并同步信息</DialogTitle>
<DialogDescription>
只同步智能体归属、发布状态、标签和技能名称关联,不写入技能信息,也不覆盖、移动或删除目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">智能体目录</span>
<code className="break-all">{migrationRoots.agents_root}</code>
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单列出的相对目录,再执行同步;目标中未列入清单的目录不会被处理。
</p>
</div>
)}
{importManifest && importPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MigrationCount label="智能体" value={importManifest.agents.length} />
<MigrationCount
label="技能名称关联"
value={
new Set(importManifest.agents.flatMap((agent) => agent.skills))
.size
}
/>
<MigrationCount
label="目录已匹配"
value={
importResources.filter((item) => item.status === "matched")
.length
}
tone="success"
/>
<MigrationCount
label="待处理问题"
value={importProblems.length + unresolvedOwners.length}
tone={
importProblems.length + unresolvedOwners.length > 0
? "danger"
: "success"
}
/>
</div>
{importManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{importManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{importProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
目录预检未通过,请修正后重新选择清单
</p>
<ul className="mt-2 space-y-2 text-xs">
{importProblems.slice(0, 50).map((item) => (
<MigrationProblem key={`${item.kind}:${item.key}`} item={item} />
))}
</ul>
{importProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {importProblems.length - 50} 项未展示。
</p>
)}
</div>
)}
{unresolvedOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdmin}
onCheckedChange={setUseCurrentAdmin}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{importProblems.length === 0 && unresolvedOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以同步元数据</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setImportDialogOpen(false)}
disabled={applyingImport}
>
取消
</Button>
<Button
onClick={handleApplyImport}
disabled={!canApplyImport || applyingImport}
>
{applyingImport && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
同步清单信息
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer> </WorkspaceContainer>
); );
} }
function MigrationCount({
label,
value,
tone = "default",
}: {
label: string;
value: number;
tone?: "default" | "success" | "danger";
}) {
const valueClass =
tone === "success"
? "text-emerald-600 dark:text-emerald-400"
: tone === "danger"
? "text-destructive"
: "text-foreground";
return (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`mt-1 text-xl font-semibold ${valueClass}`}>{value}</p>
</div>
);
}
function MigrationProblem({ item }: { item: MigrationResourcePreflight }) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
+535 -3
View File
@@ -1,16 +1,31 @@
"use client"; "use client";
import { import {
CheckCircle2,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
DownloadIcon,
Loader2Icon,
Replace, Replace,
SearchIcon, SearchIcon,
SparklesIcon, SparklesIcon,
UploadIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
@@ -19,20 +34,53 @@ import {
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { SkillRow } from "@/components/workspace/settings/skill-settings-page"; import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import {
analyzeSkillMigrationSkip,
applyMigrationManifest,
exportSkillMigrationManifest,
getMigrationRoots,
preflightMigrationManifest,
} from "@/core/agent-migrations";
import type {
AgentSkillMigrationManifest,
MigrationPreflight,
MigrationResourcePreflight,
MigrationRoots,
} from "@/core/agent-migrations";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks"; import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { skillMatchesSearch } from "@/core/skills/search"; import { skillMatchesSearch } from "@/core/skills/search";
import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge"; import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge";
import { SkillAddressesPanel } from "./SkillAddressesPage"; import { SkillAddressesPanel } from "./SkillAddressesPage";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
export default function AdminSkillsPage() { export default function AdminSkillsPage() {
const queryClient = useQueryClient();
const { data: migrationSettings } = useAgentSkillMigrationSettings();
const migrationEnabled = migrationSettings?.enabled ?? false;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { skills, isLoading, error } = useAllSkillsAdmin(true); const { skills, isLoading, error } = useAllSkillsAdmin(true);
const knowledgeBindings = useSkillKnowledgeBindings(); const knowledgeBindings = useSkillKnowledgeBindings();
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const [readingImport, setReadingImport] = useState(false);
const [applyingImport, setApplyingImport] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importManifest, setImportManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [importPreflight, setImportPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdmin, setUseCurrentAdmin] = useState(false);
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const activeTab = useMemo(() => { const activeTab = useMemo(() => {
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
return tab === "addresses" ? tab : "skills"; return tab === "addresses" ? tab : "skills";
@@ -68,6 +116,147 @@ export default function AdminSkillsPage() {
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page], [filteredSkills, page],
); );
const allFilteredSelected =
filteredSkills.length > 0 &&
filteredSkills.every((skill) => selectedNames.has(skill.name));
const importResources = useMemo(
() =>
importPreflight
? [
...importPreflight.agents,
...importPreflight.skills,
...importPreflight.support_directories,
]
: [],
[importPreflight],
);
const importProblems = importResources.filter(
(item) => item.status !== "matched",
);
const strictUnresolvedOwners =
importPreflight?.owner_mappings.filter(
(owner) => owner.status === "unresolved",
) ?? [];
const skipAnalysis =
importManifest && importPreflight
? analyzeSkillMigrationSkip(importManifest, importPreflight)
: null;
const unresolvedOwners = skipInvalidSkills
? (skipAnalysis?.unresolvedOwners ?? [])
: strictUnresolvedOwners;
const problemsCanBeSkipped =
importProblems.length > 0 &&
!!skipAnalysis &&
skipAnalysis.skippedSkillNames.length > 0 &&
skipAnalysis.remainingSkillCount > 0 &&
skipAnalysis.unskippableProblemCount === 0;
const canApplyImport =
!!importManifest &&
!!importPreflight &&
(importProblems.length === 0 ||
(skipInvalidSkills && problemsCanBeSkipped)) &&
(unresolvedOwners.length === 0 || useCurrentAdmin);
function toggleSkillSelection(name: string) {
setSelectedNames((current) => {
const next = new Set(current);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
async function handleExportManifest() {
if (selectedNames.size === 0) return;
setExporting(true);
try {
const manifest = await exportSkillMigrationManifest([...selectedNames]);
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `skill-sync-${manifest.bundle_id.slice(0, 8)}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
const warningText = manifest.warnings.length
? `;另有 ${manifest.warnings.length} 条提醒,请查看清单 warnings`
: "";
toast.success(
`已导出 ${manifest.skills.length} 个技能的迁移清单${warningText}`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setExporting(false);
}
}
async function handleImportFile(file: File) {
if (file.size > 20 * 1024 * 1024) {
toast.error("技能迁移清单不能超过 20 MB");
return;
}
setReadingImport(true);
setUseCurrentAdmin(false);
setSkipInvalidSkills(false);
try {
const parsed = JSON.parse(await file.text()) as AgentSkillMigrationManifest;
if (
parsed.schema_version !== 1 ||
!Array.isArray(parsed.agents) ||
!Array.isArray(parsed.skills) ||
!Array.isArray(parsed.support_directories)
) {
throw new Error("不是受支持的技能迁移清单");
}
if (parsed.skills.length === 0 || parsed.agents.length > 0) {
throw new Error("请选择从技能管理导出的清单;智能体清单请到智能体管理导入");
}
const [preflight, roots] = await Promise.all([
preflightMigrationManifest(parsed),
getMigrationRoots(),
]);
setImportManifest(parsed);
setImportPreflight(preflight);
setMigrationRoots(roots);
setImportDialogOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : "技能迁移清单读取失败");
} finally {
setReadingImport(false);
}
}
async function handleApplyImport() {
if (!importManifest || !canApplyImport) return;
setApplyingImport(true);
try {
const result = await applyMigrationManifest(
importManifest,
useCurrentAdmin,
skipInvalidSkills,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["skills"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
]);
toast.success(
`同步完成:导入 ${result.skills_applied} 个技能${result.skills_skipped ? `,跳过 ${result.skills_skipped} 个` : ""}`,
);
setImportDialogOpen(false);
setImportManifest(null);
setImportPreflight(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setApplyingImport(false);
}
}
return ( return (
<WorkspaceContainer> <WorkspaceContainer>
@@ -98,11 +287,50 @@ export default function AdminSkillsPage() {
value="skills" value="skills"
className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden" className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden"
> >
<div className="flex items-start justify-between gap-4 border-b px-6 py-4"> <div className="flex flex-col gap-4 border-b px-6 py-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。 管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p> </p>
<div className="relative w-64 shrink-0"> {migrationEnabled && (
<p className="text-muted-foreground mt-1 text-xs">
技能清单独立保存技能归属、发布/上下线状态和标签,不包含目录文件;请先把技能目录复制到目标机器运行目录。
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
{migrationEnabled && (
<>
<Button
variant="outline"
size="sm"
onClick={handleExportManifest}
disabled={selectedNames.size === 0 || exporting}
>
{exporting ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<DownloadIcon className="mr-1.5 size-4" />
)}
导出技能清单
{selectedNames.size ? `(${selectedNames.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={readingImport}
>
{readingImport ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<UploadIcon className="mr-1.5 size-4" />
)}
识别目录并导入技能
</Button>
</>
)}
<div className="relative min-w-56 flex-1 xl:w-64 xl:flex-none">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" /> <SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input <Input
value={search} value={search}
@@ -112,6 +340,7 @@ export default function AdminSkillsPage() {
/> />
</div> </div>
</div> </div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6"> <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
{isLoading && ( {isLoading && (
@@ -136,9 +365,49 @@ export default function AdminSkillsPage() {
)} )}
{!isLoading && !error && filteredSkills.length > 0 && ( {!isLoading && !error && filteredSkills.length > 0 && (
<> <>
{migrationEnabled ? (
<div className="flex min-h-8 items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
点击技能行选择 · 共 {filteredSkills.length} 个技能
{selectedNames.size > 0
? ` · 已选择 ${selectedNames.size} 个`
: ""}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allFilteredSelected}
onClick={() =>
setSelectedNames((current) => {
const next = new Set(current);
filteredSkills.forEach((skill) =>
next.add(skill.name),
);
return next;
})
}
>
全选当前结果
</Button>
{selectedNames.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedNames(new Set())}
>
清空选择
</Button>
)}
</div>
</div>
) : (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
共 {filteredSkills.length} 个技能。 共 {filteredSkills.length} 个技能。
</p> </p>
)}
{pagedSkills.map((skill) => ( {pagedSkills.map((skill) => (
<SkillRow <SkillRow
key={skill.name} key={skill.name}
@@ -146,6 +415,19 @@ export default function AdminSkillsPage() {
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin isAdmin
availabilityControl="admin" availabilityControl="admin"
selected={
migrationEnabled && selectedNames.has(skill.name)
}
onSelectionToggle={
migrationEnabled
? () => toggleSkillSelection(skill.name)
: undefined
}
className={cn(
migrationEnabled &&
selectedNames.has(skill.name) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
knowledgeBindings={(knowledgeBindings.data ?? []).filter( knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name, (binding) => binding.skill_name === skill.name,
)} )}
@@ -189,6 +471,256 @@ export default function AdminSkillsPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</WorkspaceBody> </WorkspaceBody>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择技能迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleImportFile(file);
}}
/>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别技能目录并导入技能信息</DialogTitle>
<DialogDescription>
只同步清单中的归属、发布/上下线状态、标签和缓存,不覆盖、移动或删除任何技能目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">技能目录</span>
<code className="break-all">{migrationRoots.skills_root}</code>
{migrationRoots.extensions_config_path && (
<>
<span className="text-muted-foreground">状态配置</span>
<code className="break-all">
{migrationRoots.extensions_config_path}
</code>
</>
)}
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单列出的相对目录,再执行导入;目标中未列入清单的技能不会被处理。
</p>
</div>
)}
{importManifest && importPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MigrationCount label="技能" value={importManifest.skills.length} />
<MigrationCount
label="支持目录"
value={importManifest.support_directories.length}
/>
<MigrationCount
label="目录已匹配"
value={
importResources.filter((item) => item.status === "matched")
.length
}
tone="success"
/>
<MigrationCount
label="待处理问题"
value={importProblems.length + unresolvedOwners.length}
tone={
importProblems.length + unresolvedOwners.length > 0
? "danger"
: "success"
}
/>
</div>
{importManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{importManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{importProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
{skipInvalidSkills
? "以下异常关联的技能将在导入时跳过"
: "目录预检未通过,可修正目录或跳过异常技能"}
</p>
<ul className="mt-2 space-y-2 text-xs">
{importProblems.slice(0, 50).map((item) => (
<MigrationProblem
key={`${item.kind}:${item.key}`}
item={item}
/>
))}
</ul>
{importProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {importProblems.length - 50} 项未展示。
</p>
)}
{problemsCanBeSkipped && skipAnalysis && (
<label className="mt-3 flex min-h-11 cursor-pointer items-start gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Checkbox
className="mt-0.5"
checked={skipInvalidSkills}
onCheckedChange={(checked) =>
setSkipInvalidSkills(checked === true)
}
aria-label="跳过未通过目录预检的技能"
/>
<span>
<span className="block font-medium">
跳过异常技能,继续导入其余技能
</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
将跳过 {skipAnalysis.skippedSkillNames.length} 个,继续导入 {skipAnalysis.remainingSkillCount} 个;被跳过技能不会写入任何元数据。
</span>
</span>
</label>
)}
{!problemsCanBeSkipped && (
<p className="text-muted-foreground mt-3 text-xs">
当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。
</p>
)}
</div>
)}
{unresolvedOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdmin}
onCheckedChange={setUseCurrentAdmin}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{importProblems.length === 0 && unresolvedOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以导入技能信息</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。
</p>
</div>
</div>
)}
{skipInvalidSkills &&
problemsCanBeSkipped &&
unresolvedOwners.length === 0 &&
skipAnalysis && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div>
<p className="font-medium">可以跳过异常技能后继续导入</p>
<p className="text-muted-foreground mt-0.5 text-xs">
服务器执行时会再次校验,并返回实际导入与跳过数量。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setImportDialogOpen(false)}
disabled={applyingImport}
>
取消
</Button>
<Button
onClick={handleApplyImport}
disabled={!canApplyImport || applyingImport}
>
{applyingImport && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
{skipInvalidSkills && skipAnalysis
? `跳过 ${skipAnalysis.skippedSkillNames.length} 个并导入`
: "导入技能信息"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer> </WorkspaceContainer>
); );
} }
function MigrationCount({
label,
value,
tone = "default",
}: {
label: string;
value: number;
tone?: "default" | "success" | "danger";
}) {
const valueClass =
tone === "success"
? "text-emerald-600 dark:text-emerald-400"
: tone === "danger"
? "text-destructive"
: "text-foreground";
return (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`mt-1 text-xl font-semibold ${valueClass}`}>{value}</p>
</div>
);
}
function MigrationProblem({ item }: { item: MigrationResourcePreflight }) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
@@ -27,6 +27,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DateRangePicker, type DateRangePickerProps, type DateRangeValue } from "tdesign-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -42,11 +43,20 @@ import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor"; import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor";
import type { import type {
AdminLeaderboardDimension, AdminLeaderboardDimension,
AdminLeaderboardDetailItem,
AdminLeaderboardDetailKind,
AdminLeaderboardOverview, AdminLeaderboardOverview,
AdminLeaderboardResponse, AdminLeaderboardResponse,
AdminLeaderboardSkill, AdminLeaderboardSkill,
@@ -54,7 +64,7 @@ import type {
AdminLeaderboardUser, AdminLeaderboardUser,
} from "@/core/admin/api"; } from "@/core/admin/api";
import { streamUserLeaderboardReport } 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 { import {
ADMIN_QUERY_WINDOW_DAYS, ADMIN_QUERY_WINDOW_DAYS,
formatLocalDateInput, formatLocalDateInput,
@@ -70,6 +80,7 @@ type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__"; const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 10; const LEADERBOARD_PAGE_SIZE = 10;
const DETAIL_PAGE_SIZE = 15;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
@@ -776,11 +787,13 @@ function SkillRanking({
icon: Icon, icon: Icon,
items, items,
empty, empty,
onMore,
}: { }: {
title: string; title: string;
icon: LucideIcon; icon: LucideIcon;
items: AdminLeaderboardSkill[]; items: AdminLeaderboardSkill[];
empty: string; empty: string;
onMore?: () => void;
}) { }) {
const max = Math.max(1, ...items.map((item) => item.call_count)); const max = Math.max(1, ...items.map((item) => item.call_count));
return ( return (
@@ -790,7 +803,14 @@ function SkillRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2> <h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div> </div>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge> <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>
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>} {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, icon: Icon,
items, items,
empty, empty,
onMore,
}: { }: {
title: string; title: string;
icon: LucideIcon; icon: LucideIcon;
items: AdminLeaderboardDimension[]; items: AdminLeaderboardDimension[];
empty: string; empty: string;
onMore?: () => void;
}) { }) {
const max = Math.max(1, ...items.map((item) => item.count)); const max = Math.max(1, ...items.map((item) => item.count));
return ( return (
@@ -848,7 +870,14 @@ function DimensionRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2> <h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div> </div>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge> <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>
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>} {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 }) { function Metric({ label, value }: { label: string; value: number }) {
return ( return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]"> <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 [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false); const [showGroupStats, setShowGroupStats] = useState(false);
const [userPage, setUserPage] = useState(1); const [userPage, setUserPage] = useState(1);
const [detailKind, setDetailKind] = useState<AdminLeaderboardDetailKind | null>(null);
const [detailRange, setDetailRange] = useState<RangeState>(() => defaultRange(7));
const apiRange = useMemo( const apiRange = useMemo(
() => rangeForApi(range), () => rangeForApi(range),
[range], [range],
@@ -968,6 +1257,11 @@ export default function AdminUserLeaderboardPage() {
setRange(clampRangeToAdminWindow(defaultRange(days))); setRange(clampRangeToAdminWindow(defaultRange(days)));
}; };
const openLeaderboardDetails = (kind: AdminLeaderboardDetailKind) => {
setDetailRange(clampRangeToAdminWindow(range));
setDetailKind(kind);
};
const handleReportOpenChange = (open: boolean) => { const handleReportOpenChange = (open: boolean) => {
setReportOpen(open); setReportOpen(open);
if (!open) { if (!open) {
@@ -1438,19 +1732,27 @@ export default function AdminUserLeaderboardPage() {
</section> </section>
<div className="grid gap-5 xl:grid-cols-2"> <div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" /> <SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" onMore={() => openLeaderboardDetails("skill")} />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" /> <SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" onMore={() => openLeaderboardDetails("tool")} />
</div> </div>
<div className="grid gap-5 xl:grid-cols-3"> <div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" /> <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="当前时间段暂无运行状态" /> <DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div> </div>
</> </>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
<LeaderboardDetailDrawer
kind={detailKind}
range={detailRange}
onRangeChange={setDetailRange}
onOpenChange={(open) => {
if (!open) setDetailKind(null);
}}
/>
</main> </main>
</div> </div>
); );
+7 -15
View File
@@ -31,8 +31,6 @@ import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { listThreadArtifacts } from "@/core/artifacts/listing"; import { listThreadArtifacts } from "@/core/artifacts/listing";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ConsumerMaterialsExportTrigger } from "@/components/workspace/consumer-materials-export-trigger";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector"; import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
@@ -45,7 +43,10 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext"; import {
getPresentationThemeContext,
useTheme,
} from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { import {
@@ -137,8 +138,6 @@ import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */
const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
@@ -1166,16 +1165,13 @@ export default function AgentChatPage({
settings.context.writing_mode && writingArtifactPath settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath } ? { writing_artifact_path: writingArtifactPath }
: {}; : {};
const pageReportThemeContext = const pagePresentationThemeContext = getPresentationThemeContext(baseMode);
agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {};
void sendMessage(threadId, message, { void sendMessage(threadId, message, {
...writingExtraContext, ...writingExtraContext,
...(shouldApplyPrefix ? { prompt_prefix_enabled: true } : {}), ...(shouldApplyPrefix ? { prompt_prefix_enabled: true } : {}),
...(extraContext ?? {}), ...(extraContext ?? {}),
agent_id, agent_id,
...pageReportThemeContext, ...pagePresentationThemeContext,
}); });
}, },
[ [
@@ -1338,9 +1334,7 @@ export default function AgentChatPage({
await stopActiveThreadBeforeClarificationReply(threadId, thread); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { await sendMessage(threadId, { text: answer, files: [] }, {
agent_id, agent_id,
...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID ...getPresentationThemeContext(baseMode),
? { presentation_theme: baseMode }
: {}),
}); });
}, },
[sendMessage, thread, threadId, agent_id, baseMode], [sendMessage, thread, threadId, agent_id, baseMode],
@@ -1608,8 +1602,6 @@ export default function AgentChatPage({
<PlusSquare /> {t.agents.newChat} <PlusSquare /> {t.agents.newChat}
</Button> </Button>
</Tooltip> </Tooltip>
<ConsumerMaterialsExportTrigger threadId={threadId} />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
</header> </header>
+14 -5
View File
@@ -51,7 +51,10 @@ import {
type ArtifactLibrarySource, type ArtifactLibrarySource,
} from "@/core/artifacts/library"; } from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks"; 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 { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime"; import { formatDateTime } from "@/core/utils/datetime";
import { import {
@@ -328,6 +331,12 @@ function LoadingRows() {
export default function ArtifactLibraryPage() { export default function ArtifactLibraryPage() {
const { t } = useI18n(); const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName); 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 [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all"); const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
@@ -337,8 +346,8 @@ export default function ArtifactLibraryPage() {
const forceRefreshRef = useRef(false); const forceRefreshRef = useRef(false);
useEffect(() => { useEffect(() => {
document.title = `资料库 - ${appName}`; document.title = `${pageTitle} - ${appName}`;
}, [appName]); }, [appName, pageTitle]);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -478,10 +487,10 @@ export default function ArtifactLibraryPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Library className="size-6 text-primary" /> <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> </div>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。 {pageDescription}
</p> </p>
</div> </div>
<div className="flex flex-col items-start gap-1.5 sm:items-end"> <div className="flex flex-col items-start gap-1.5 sm:items-end">
@@ -48,17 +48,10 @@ import {
type MappingTypeFilter = "all" | "builtin" | "custom"; type MappingTypeFilter = "all" | "builtin" | "custom";
/** 配置管理页默认不展示的独立槽位;后端仍保留,goPath 可继续解析。 */
const HIDDEN_MAPPING_CODES = new Set(["3q-1", "3q-2", "3q-3", "3q-4", "3q-5", "3q-6"]);
function mappingTypeLabel(row: BusinessMapping) { function mappingTypeLabel(row: BusinessMapping) {
return row.isCustom ? "自定义类型" : "内置"; return row.isCustom ? "自定义类型" : "内置";
} }
function isHiddenMapping(row: BusinessMapping) {
return HIDDEN_MAPPING_CODES.has(row.businessCode);
}
export default function BusinessChainMappingPage() { export default function BusinessChainMappingPage() {
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -78,7 +71,7 @@ export default function BusinessChainMappingPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all"); const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all");
const [typeFilter, setTypeFilter] = useState<MappingTypeFilter>("custom"); const [typeFilter, setTypeFilter] = useState<MappingTypeFilter>("all");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [customName, setCustomName] = useState(""); const [customName, setCustomName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -114,16 +107,11 @@ export default function BusinessChainMappingPage() {
[chains], [chains],
); );
const visibleRows = useMemo(
() => rows.filter((row) => !isHiddenMapping(row)),
[rows],
);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = visibleRows.length; const total = rows.length;
const boundAgents = visibleRows.filter((r) => r.agentId !== null).length; const boundAgents = rows.filter((r) => r.agentId !== null).length;
return { total, boundAgents }; return { total, boundAgents };
}, [visibleRows]); }, [rows]);
const typeOptions: { label: string; value: MappingTypeFilter }[] = [ const typeOptions: { label: string; value: MappingTypeFilter }[] = [
{ label: "全部类型", value: "all" }, { label: "全部类型", value: "all" },
@@ -132,7 +120,7 @@ export default function BusinessChainMappingPage() {
]; ];
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
return visibleRows.filter((r) => { return rows.filter((r) => {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const matchesSearch = const matchesSearch =
r.label.toLowerCase().includes(q) || r.label.toLowerCase().includes(q) ||
@@ -146,7 +134,7 @@ export default function BusinessChainMappingPage() {
if (statusFilter === "pending") return matchesSearch && matchesType && !isConfigured; if (statusFilter === "pending") return matchesSearch && matchesType && !isConfigured;
return matchesSearch && matchesType; return matchesSearch && matchesType;
}); });
}, [visibleRows, searchQuery, statusFilter, typeFilter]); }, [rows, searchQuery, statusFilter, typeFilter]);
const beginEdit = (row: BusinessMapping) => { const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode); setEditingCode(row.businessCode);
@@ -640,7 +628,7 @@ export default function BusinessChainMappingPage() {
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} /> <AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p> <p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
<button <button
onClick={() => { setSearchQuery(""); setStatusFilter("all"); setTypeFilter("custom"); }} onClick={() => { setSearchQuery(""); setStatusFilter("all"); setTypeFilter("all"); }}
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")} className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
> >
重置筛选条件 重置筛选条件
@@ -661,7 +649,7 @@ export default function BusinessChainMappingPage() {
会商程序会在对应业务的业务链生效时开启多体交叉核验证。 会商程序会在对应业务的业务链生效时开启多体交叉核验证。
</span> </span>
<span className="font-mono text-[10px]"> <span className="font-mono text-[10px]">
显示 {filteredRows.length} / {visibleRows.length} 条 显示 {filteredRows.length} / {rows.length} 条
</span> </span>
</div> </div>
</motion.div> </motion.div>
@@ -686,7 +674,7 @@ export default function BusinessChainMappingPage() {
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}> <p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
1. 点击右上角「新增自定义配置」,填写名称。新增项自动归入「自定义类型」;名称就是登录链接的 <code>goPath</code>:<code>goPath=名称</code> 进入单智能体,<code>goPath=名称-A</code> 进入已绑定业务链。 1. 点击右上角「新增自定义配置」,填写名称。新增项自动归入「自定义类型」;名称就是登录链接的 <code>goPath</code>:<code>goPath=名称</code> 进入单智能体,<code>goPath=名称-A</code> 进入已绑定业务链。
<br /> <br />
2. 打开页面默认展示「自定义类型」;可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。 2. 可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。
<br /> <br />
3. 将鼠标悬停在任一业务行右侧,点击出现的{" "} 3. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。 <span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
+40 -9
View File
@@ -11,6 +11,7 @@ import {
useConversationContextSettings, useConversationContextSettings,
useLayoutMode, useLayoutMode,
useOrdinaryKnowledgeRetrievalSettings, useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
usePromptPrefixSettings, usePromptPrefixSettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences"; import { useUserPreferencesQuery } from "@/core/user-preferences";
@@ -20,8 +21,6 @@ import {
useSpecificChatMode, useSpecificChatMode,
useThreadChat, useThreadChat,
} from "@/components/workspace/chats"; } from "@/components/workspace/chats";
import { ConsumerMaterialsExportTrigger } from "@/components/workspace/consumer-materials-export-trigger";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger"; import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector"; import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
@@ -108,6 +107,10 @@ import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag"; import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { isHiddenFromUIMessage } from "@/core/messages/utils";
import {
getPresentationThemeContext,
useTheme,
} from "@/contexts/ThemeContext";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
import { env } from "@/env"; import { env } from "@/env";
@@ -156,6 +159,7 @@ function clearBrowserContextParamsFromHash() {
export default function ChatPage() { export default function ChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { baseMode } = useTheme();
const router = useRouter(); const router = useRouter();
const navigate = useNavigate(); const navigate = useNavigate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -168,6 +172,7 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar(); useSidebar();
const { t } = useI18n(); const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」 // 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
const [effectiveLayoutMode] = useLayoutMode(); const [effectiveLayoutMode] = useLayoutMode();
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
@@ -792,7 +797,9 @@ export default function ChatPage() {
// context; the backend tags the message and PromptPrefixMiddleware // context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only. Writing mode adds the // injects the prefix into the LLM request only. Writing mode adds the
// active markdown artifact path so the agent keeps editing the canvas. // active markdown artifact path so the agent keeps editing the canvas.
const extraContext: Record<string, unknown> = {}; const extraContext: Record<string, unknown> = {
...getPresentationThemeContext(baseMode),
};
if (shouldApplyPrefix) { if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true; extraContext.prompt_prefix_enabled = true;
} }
@@ -843,6 +850,7 @@ export default function ChatPage() {
routeBase, routeBase,
setIsNewThread, setIsNewThread,
setThreadId, setThreadId,
baseMode,
], ],
); );
@@ -850,9 +858,13 @@ export default function ChatPage() {
async (answer: string) => { async (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
await stopActiveThreadBeforeClarificationReply(threadId, thread); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }); await sendMessage(
threadId,
{ text: answer, files: [] },
getPresentationThemeContext(baseMode),
);
}, },
[sendMessage, thread, threadId], [sendMessage, thread, threadId, baseMode],
); );
useEffect(() => { useEffect(() => {
@@ -901,7 +913,7 @@ export default function ChatPage() {
void sendMessage( void sendMessage(
threadId, threadId,
{ text: prompt, files: [] }, { text: prompt, files: [] },
undefined, getPresentationThemeContext(baseMode),
{ {
additionalKwargs: { additionalKwargs: {
browser_context: { browser_context: {
@@ -934,6 +946,7 @@ export default function ChatPage() {
knowledgeRetrievalMode, knowledgeRetrievalMode,
knowledgeRetrievalSettingsLoading, knowledgeRetrievalSettingsLoading,
sendMessage, sendMessage,
baseMode,
]); ]);
// Persist the picked agent the moment it changes so the next new chat // Persist the picked agent the moment it changes so the next new chat
@@ -1003,6 +1016,17 @@ export default function ChatPage() {
const compactChrome = const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname); chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; 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({ const composerWidthMode = resolveComposerWidthMode({
fullChatMode, fullChatMode,
wxFullChat: pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX), wxFullChat: pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX),
@@ -1139,8 +1163,6 @@ export default function ChatPage() {
新建对话 新建对话
</Button> </Button>
)} )}
<ConsumerMaterialsExportTrigger threadId={threadId} />
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} /> <KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧, {/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
@@ -1231,11 +1253,15 @@ export default function ChatPage() {
</div> </div>
{isLandingState && mounted && {isLandingState && mounted &&
(compactLanding ? ( (compactLanding ? (
<CompactWelcome className="chat-landing-content-lift mb-6" /> <CompactWelcome
className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/>
) : ( ) : (
<Welcome <Welcome
mode={settings.context.mode} mode={settings.context.mode}
className="chat-landing-content-lift mb-6" className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/> />
))} ))}
{mounted ? ( {mounted ? (
@@ -1263,6 +1289,11 @@ export default function ChatPage() {
<InputBox <InputBox
className="w-full" className="w-full"
isNewThread={isLandingState} isNewThread={isLandingState}
placeholder={
isLandingState
? qaPageDescription?.input_placeholder
: undefined
}
compact={compactLanding} compact={compactLanding}
composerHeightPercent={composerHeightPercent} composerHeightPercent={composerHeightPercent}
threadId={threadId} threadId={threadId}
+101 -7
View File
@@ -5,7 +5,7 @@ import {
ChevronRight, ChevronRight,
Compass, Compass,
Clock, Clock,
DownloadIcon, Download,
FileJson, FileJson,
FileText, FileText,
MessageSquare, MessageSquare,
@@ -17,6 +17,7 @@ import {
Share2, Share2,
Star, Star,
Trash2, Trash2,
Upload,
Users2, Users2,
type LucideIcon, type LucideIcon,
X, X,
@@ -29,6 +30,10 @@ import { Button as TButton, Dialog } from "tdesign-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
ChatHistoryExportDialog,
ChatHistoryImportDialog,
} from "@/components/workspace/chat-history-transfer-dialog";
import { import {
ImportConversationsDialog, ImportConversationsDialog,
ShareConversationDialog, ShareConversationDialog,
@@ -50,10 +55,16 @@ import {
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks"; import { useAppSystemName } from "@/core/system-settings/hooks";
import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks";
import { import {
exportThreadAsJSON, exportThreadAsJSON,
exportThreadAsMarkdown, exportThreadAsMarkdown,
} from "@/core/threads/export"; } from "@/core/threads/export";
import {
downloadTransferBundle,
exportSelectedThreads,
skipReasonLabel,
} from "@/core/threads/transfer";
import { import {
useClearUserChatThreads, useClearUserChatThreads,
useDeleteThread, useDeleteThread,
@@ -171,6 +182,11 @@ export default function ChatsPage({
const [chatFilter, setChatFilter] = useState<ChatFilter>("all"); const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [exportRecordsOpen, setExportRecordsOpen] = useState(false);
const [importRecordsOpen, setImportRecordsOpen] = useState(false);
const [exportingThreadId, setExportingThreadId] = useState<string | null>(null);
const { data: migrationSettings } = useAgentSkillMigrationSettings();
const transferEnabled = migrationSettings?.enabled === true;
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。 // 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null); const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。 // 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
@@ -268,6 +284,42 @@ export default function ChatsPage({
[t], [t],
); );
const handleExportThreadRecord = useCallback(
async (thread: AgentThread) => {
if (exportingThreadId) return;
setExportingThreadId(thread.thread_id);
try {
const result = await exportSelectedThreads([thread.thread_id]);
if (result.threads.length === 0) {
const reason = result.skipped[0]?.reason;
toast.error(
reason
? `这条对话无法导出:${skipReasonLabel(reason)}`
: "这条对话无法导出",
);
return;
}
const title = titleOfThread(thread, t.pages.newChat);
downloadTransferBundle(
result.threads,
result.skipped,
title.replace(/[^\p{L}\p{N}_\- ]/gu, "").trim() || "conversation",
);
const dropped = result.threads[0]?.skipped_files?.length ?? 0;
toast.success(
dropped > 0
? `已导出这条对话,已跳过 ${dropped} 个过大附件`
: "已导出这条对话(含附件)",
);
} catch {
toast.error("导出失败");
} finally {
setExportingThreadId(null);
}
},
[exportingThreadId, t.pages.newChat],
);
// Conversations are paginated on the backend: each page is one bounded // Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is // query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small // never fetched in one go. Pinned conversations come from their own small
@@ -435,6 +487,19 @@ export default function ChatsPage({
</div> </div>
) : ( ) : (
<div className="flex shrink-0 items-center" data-history-menu> <div className="flex shrink-0 items-center" data-history-menu>
{transferEnabled && (
<Button
variant="ghost"
size="icon-sm"
className="size-8"
title="导出记录"
aria-label="导出记录"
disabled={exportingThreadId === thread.thread_id}
onClick={() => void handleExportThreadRecord(thread)}
>
<Download className="size-4 text-muted-foreground" />
</Button>
)}
<HistoryActionsMenu <HistoryActionsMenu
label={t.common.more} label={t.common.more}
deleteLabel={t.common.delete} deleteLabel={t.common.delete}
@@ -594,19 +659,28 @@ export default function ChatsPage({
); );
})} })}
</div> </div>
</div> {activeTab === "chats" && transferEnabled && (
{activeTab === "chats" && ( <div className="mt-3 flex flex-wrap items-center justify-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6" onClick={() => setExportRecordsOpen(true)}
onClick={() => setImportOpen(true)}
> >
<DownloadIcon className="size-4" /> <Download className="size-4" />
{t.threadShare.importChats} 导出记录
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => setImportRecordsOpen(true)}
>
<Upload className="size-4" />
导入记录
</Button>
</div>
)} )}
</div> </div>
</div>
</header> </header>
<main className="min-h-0 flex-1"> <main className="min-h-0 flex-1">
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7"> <ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
@@ -683,6 +757,14 @@ export default function ChatsPage({
<span>清空</span> <span>清空</span>
</button> </button>
)} )}
<button
type="button"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 transition-all hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
onClick={() => setImportOpen(true)}
>
<Download className="size-3.5" />
<span>{t.threadShare.importChats}</span>
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && ( {pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button <button
type="button" type="button"
@@ -821,6 +903,18 @@ export default function ChatsPage({
open={importOpen} open={importOpen}
onOpenChange={setImportOpen} onOpenChange={setImportOpen}
/> />
{transferEnabled && (
<>
<ChatHistoryExportDialog
open={exportRecordsOpen}
onOpenChange={setExportRecordsOpen}
/>
<ChatHistoryImportDialog
open={importRecordsOpen}
onOpenChange={setImportRecordsOpen}
/>
</>
)}
<HistoryRenameDialog <HistoryRenameDialog
open={renameThreadId !== null} open={renameThreadId !== null}
initialTitle={renameValue} initialTitle={renameValue}
@@ -40,6 +40,7 @@ import {
useNoteWikilinks, useNoteWikilinks,
useVaultPage, useVaultPage,
} from "@/core/knowledge/hooks"; } from "@/core/knowledge/hooks";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types"; import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import MarkdownRenderer, { import MarkdownRenderer, {
@@ -537,6 +538,8 @@ export default function KnowledgeBasePage({
} = {}) { } = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>(); const { noteId } = useParams<{ noteId: string }>();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
// --- list / tree state --- // --- list / tree state ---
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -649,6 +652,8 @@ export default function KnowledgeBasePage({
return ( return (
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950"> <div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader <WikiHeader
title={pageDescription?.title || undefined}
description={pageDescription?.description || undefined}
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)} onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)} onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)} onBatchImport={() => setBatchOpen(true)}
+5 -2
View File
@@ -36,6 +36,7 @@ import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme"; 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 { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -140,6 +141,8 @@ export default function LightAppListPage() {
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme(); const { theme } = useTheme();
const lightAccent = getLightAccent(theme); const lightAccent = getLightAccent(theme);
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_list;
const { data: apps = [], isLoading } = useLightApps({ const { data: apps = [], isLoading } = useLightApps({
appType: "window_open", 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 className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span> </span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]"> <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表 {pageDescription?.title || "应用列表"}
</h1> </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 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> </span>
</div> </div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]"> <p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。 {pageDescription?.description || "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。"}
</p> </p>
</div> </div>
@@ -36,6 +36,7 @@ import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useSetResourceTags } from "@/core/tags"; import { useSetResourceTags } from "@/core/tags";
import { import {
useLightAppActions, useLightAppActions,
@@ -142,6 +143,8 @@ export default function LightAppManagementPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search }); const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions(); const actions = useLightAppActions();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_management;
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null); const [editing, setEditing] = useState<LightApp | null>(null);
@@ -327,9 +330,10 @@ export default function LightAppManagementPage() {
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900"> <h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理 {pageDescription?.title || "应用管理"}
</h1> </h1>
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500"> <p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
{pageDescription?.description || <>
注册轻应用。 注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600"> <code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open window.open
@@ -339,6 +343,7 @@ export default function LightAppManagementPage() {
iframe iframe
</code> </code>
类型挂载到所选菜单下并注入侧栏。 类型挂载到所选菜单下并注入侧栏。
</>}
</p> </p>
</div> </div>
+8 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { LoaderCircle, LogIn, UserPlus } from 'lucide-react'; import { LoaderCircle, LogIn, UserPlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,7 @@ import {
} from '@/core/auth/account-api'; } from '@/core/auth/account-api';
import { import {
LOGIN_CREDENTIAL_PARAMS, LOGIN_CREDENTIAL_PARAMS,
accountPageSearch,
claimSsoLoginStart, claimSsoLoginStart,
loginParamsForEntry, loginParamsForEntry,
shouldStartSso, shouldStartSso,
@@ -333,6 +334,7 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
const qs = target.toString(); const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly; return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]); }, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
const formSearch = accountPageSearch(searchParams.toString(), routeKeyTarget || undefined);
const externalParams = useMemo( const externalParams = useMemo(
() => ({ () => ({
theme: searchParams.get('theme') ?? '', theme: searchParams.get('theme') ?? '',
@@ -373,6 +375,7 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
sessionExpired && !resumeExternalLogin, sessionExpired && !resumeExternalLogin,
); );
// 登录设置固定使用原有外部免登模式,不再请求登录设置接口。 // 登录设置固定使用原有外部免登模式,不再请求登录设置接口。
// URL 已带显式免登凭证的直达入口可立即登录;后端在每个登录端点都会复核登录开关。
const explicitSsoReady = Boolean( const explicitSsoReady = Boolean(
explicitSso && !creatingAccount && !needsSetup && explicitSso && !creatingAccount && !needsSetup &&
!(sessionExpired && !resumeExternalLogin), !(sessionExpired && !resumeExternalLogin),
@@ -1034,6 +1037,10 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
setAccountPassword(''); setAccountPassword('');
setConfirmPassword(''); setConfirmPassword('');
}}>返回账号登录</Button> }}>返回账号登录</Button>
) : registering ? (
<Link className="text-primary hover:underline" to={`/login${formSearch}`} onClick={clearOuterLoginCredentials}>已有账号,返回登录</Link>
) : !isBusy && loginPolicy.password_login_enabled && loginPolicy.registration_enabled ? (
<Link className="text-primary hover:underline" to={`/register${formSearch}`} onClick={clearOuterLoginCredentials}>还没有账号?注册账号</Link>
) : null} ) : null}
{!registering && loginPolicy.sso_enabled && canUseForm && ( {!registering && loginPolicy.sso_enabled && canUseForm && (
<p className="text-xs text-muted-foreground">支持外部系统免登,可从原系统的统一登录入口进入。</p> <p className="text-xs text-muted-foreground">支持外部系统免登,可从原系统的统一登录入口进入。</p>
@@ -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>
);
}
+2 -2
View File
@@ -148,8 +148,8 @@ function PageLayout() {
// 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起时主内容不留左内边距。 // 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起时主内容不留左内边距。
? (collapsed ? "pl-0" : "pl-[118px]") ? (collapsed ? "pl-0" : "pl-[118px]")
: lightNarrowSidebar : lightNarrowSidebar
// 浅色新版窄栏:展开三级列时 96→236px,主内容左内边距随之过渡 // 浅色新版窄栏:展开三级列时 96→236px,收起时不渲染侧栏、主内容不留内边距。
? `transition-[padding] duration-300 ${lightExpanded ? "pl-[236px]" : "pl-[96px]"}` ? `transition-[padding] duration-300 ${collapsed ? "pl-0" : lightExpanded ? "pl-[236px]" : "pl-[96px]"}`
// 浅色旧版手风琴(wujie 嵌入):pl-16 ↔ pl-64 平滑收展 // 浅色旧版手风琴(wujie 嵌入):pl-16 ↔ pl-64 平滑收展
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}` : `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
}`} }`}
+6
View File
@@ -0,0 +1,6 @@
import LoginPage from "./LoginPage";
/** Reuse the login completion path so registration also retains task deep links. */
export default function RegisterPage() {
return <LoginPage mode="register" />;
}
@@ -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 { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel"; import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
export default function SkillsPage({ export default function SkillsPage({
restrictWxTypes = false, restrictWxTypes = false,
@@ -13,6 +14,8 @@ export default function SkillsPage({
restrictWxTypes?: boolean; restrictWxTypes?: boolean;
} = {}) { } = {}) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.skills;
const tab = useMemo( const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"), () => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams], [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"> <header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div> <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"> <p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。 {pageDescription?.description || "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。"}
</p> </p>
</div> </div>
<TabsList> <TabsList>
@@ -60,6 +60,9 @@ const AdminUserLeaderboardSettingsPage = lazy(
); );
const MenuManagementPage = lazy(() => import("./MenuManagementPage")); const MenuManagementPage = lazy(() => import("./MenuManagementPage"));
const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage")); const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage"));
const PageDescriptionSettingsPage = lazy(
() => import("./PageDescriptionSettingsPage"),
);
const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage")); const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage"));
const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage")); const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage"));
const LlmWikiKnowledgePage = lazy(() => import("./LlmWikiKnowledgePage")); 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 <Route
path="admin/tags" path="admin/tags"
element={ element={
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more