Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7053c37a1e | ||
|
|
754b61572c |
No files matched your search
@@ -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/**
|
||||||
|
|||||||
@@ -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>` 窗口事件强制重放(忽略已看过记忆)。
|
||||||
|
|||||||
@@ -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)---
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 5.6 MiB |
@@ -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\);/,
|
||||||
|
|||||||
@@ -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 |
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -449,6 +449,7 @@ export function useSyncAgentDirectory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useAdminSyncAgentSkills() {
|
export function useAdminSyncAgentSkills() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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: "编辑详情",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
useKnowledgeRagEnabled,
|
useKnowledgeRagEnabled,
|
||||||
} from "./knowledge-rag";
|
} from "./knowledge-rag";
|
||||||
export {
|
export {
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
getLastAgentId,
|
getLastAgentId,
|
||||||
isThreadRagModeLocked,
|
isThreadRagModeLocked,
|
||||||
lockThreadRagMode,
|
lockThreadRagMode,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}」`;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>。
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"}`
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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,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={
|
||||||
|
|||||||