sync update from LAN repo (source master @ c21a2a4a)
This commit is contained in:
1 parent
ea9dcff9c0
commit
66a443e915
140 files changed
+13777
-1040
No files matched your search
@@ -88,11 +88,18 @@ offline-backend-20260512/backend/skill_evolution.json
|
||||
# Automatic config.yaml backups written by the admin models page on every save
|
||||
offline-backend-20260512/backend/.model-config-backups/
|
||||
skills/
|
||||
# Harness skill-versioning logic is product source, not a runtime skill package.
|
||||
!offline-backend-20260512/backend/packages/harness/deerflow/skills/
|
||||
offline-backend-20260512/backend/packages/harness/deerflow/skills/*
|
||||
!offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_skill_versioning.py
|
||||
!offline-backend-20260512/backend/tests/test_large_file_safety.py
|
||||
!offline-backend-20260512/backend/tests/test_account_authentication.py
|
||||
# HTML 结构化模板(no.1)引擎/存储/路由回归
|
||||
!offline-backend-20260512/backend/tests/test_structured_templates.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_roundtable_room_limit.py
|
||||
|
||||
@@ -94,6 +94,26 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.py`。
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items.
|
||||
- **生成网页标识 (Generated Page Branding, admin-only write)**: 管理员在设置弹窗「生成网页标识」中填写网页展示名称、上传 PNG/JPG/WebP Logo(≤10MB)、单独移除 Logo 或整体清除配置。配置保存在 runtime `system_settings.json -> generated_page_branding`,图片保存在 runtime `generated-page-branding/`,`GET /api/system-settings/generated-page-branding` 全员可读,`PUT/DELETE` 仅管理员;生成 HTML 通过公开只读 `/api/public/html-runtime/generated-page-logo` 获取图片。**只有网页展示名称非空且 Logo 文件真实存在时功能才启用**;缺少任意一项(含单独移除 Logo)都不注入任何内容,完全保持原有网页生成逻辑。该配置只约束后续生成的 HTML 网页内容,不修改 DeerFlow 自身的系统名称、主导航、欢迎区、工作区标题或 Logo。启用时,HTML 写入与 `page_report_template validate` 会由 `html_runtime/generated_page_chrome.py` 确定性注入平台套壳:页头**通栏全宽、贴顶、无圆角**,**背景直接用全量模式导航栏设计图**(`html_runtime/assets/topbar-light.svg` 浅色红调 / `topbar-dark.svg` 深色深蓝,已注册进网关 `html_runtime.py` 的 `_STATIC_ASSETS` 白名单以 `/api/public/html-runtime/topbar-*.svg` 提供;页头运行时按 body 明暗 tone 自动切图),**只注入页头、不注入页脚**;设置弹窗不再提供「品牌位置」选择,新配置默认使用 `center`:「Logo+系统名称」居中锁孔(26px 白色金属渐变字 + 深浅辉光,落在设计图中央发光基座上)+ 页面导航**分列两侧**白字页签(当前项白色加粗 + 白色墨条)。`generated_page_branding.header_layout` 字段继续兼容存量配置,既有 `left` 仍按品牌居左 + 导航居中单行渲染。页签均为通栏等高下划线式(当前项主色 + 底部渐变墨条),多页强制首个逻辑页初始可见。开关 `chrome_enabled` **默认开启**;明确关闭时完全回归无注入:不注入页头、也不对页面提任何品牌要求(校验不检查品牌,模型不会被迫自写)。页脚与「网页页脚文字」设置项均已按需求移除(`FOOTER_MARKER` 常量仅为旧页面兼容保留)。模板套件的兜底导航条同款(品牌左 + 导航中、通栏贴顶),平台注入时经 `body:has([data-df-generated-header])` + 兜底脚本双保险自动隐藏。后处理会隐藏旧的多页导航、移除模型写入的 `data-df-brand` 重复标识且保持幂等;lead prompt 只让模型生成正文和逻辑页。注入点共四处且全部**账号无关**(admin 配置对全部账号生效,非 admin 仅无配置入口):**`page_report_template prepare`(工作副本创建时即预注入页头,模型从第一秒起编辑的就是带品牌导航的页面,brief 的 `platform_branding.pre_injected` 标记此状态)**、`write_file`/`str_replace`(`sandbox/tools.py` 的 `maybe_fix_generated_html`)、`page_report_template validate`、以及 `present_files` 交付兜底(`present_file_tool._ensure_generated_html_chrome`,覆盖模型经 bash 等旁路写出的 HTML;幂等,>4MB 跳过);注入时还会**归一化 body/html 顶部 padding/margin(清零)**(防弱模型为自想页头留白)并**隐藏模型自写的顶部 nav/header**(仅正文首个内容容器之前的,正文内导航不受影响);品牌配置每次实时读取 `system_settings.json`,无缓存无账号隔离。
|
||||
- **生成网页默认明暗模式 (Generated Page Theme)**: `ChatPage` 与 `AgentChatPage` 每次发送只把当前界面的明暗模式作为 `presentation_theme=light|dark` 上下文传入。用户未明确指定网页主题时,自由生成 HTML、显式模板的 `auto` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。
|
||||
- **生成网页离线下载包 (Generated HTML Offline Bundle)**: 产物面板下载 `.htm/.html/.xhtml` 时不再只保存单个 HTML,而是请求 `GET /api/threads/{thread_id}/artifacts/{path}?download=true&offline_bundle=true`(公开只读分享的文件端点同样支持),下载 `{原名}-离线包.zip`。纯引擎 `deerflow/html_runtime/offline_bundle.py` 把入口统一为 `index.html`,只将平台信任的 `/api/public/html-runtime/*` JS/CSS/GeoJSON/品牌资源、认知态势图运行目录和签名 `/api/public/html-images/*` 图片复制到 `assets/` 并改写为相对地址;不得联网抓取任意第三方 URL。包内还带 `manifest.json`、中文 `README.txt` 和实际使用库的许可证。Leaflet 在线瓦片及用户主动引用的公网图片/API 不复制,保留原地址并列入 `external_urls`。非 HTML 下载、HTML 预览/新窗口打开及流式未落盘的临时下载行为不变。
|
||||
- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。顶部导航名称字号可选 `adaptive`(默认,30px 上限、24px 下限)或 `fixed`(16–40px);配置字段为 `app_identity.header_title_font_mode/header_title_font_size`,固定模式不再按分辨率缩放,超长名称仍单行完整显示。红色主题还可分别上传导航栏背景图与菜单选中样式图(PNG/JPG/WebP/SVG,不设应用层文件大小限制),字段为 `light_red_header_background_*` / `light_red_nav_active_*`;选择文件或恢复默认时顶部导航即时预览,未配置时继续使用 `top-bg-light-red.svg` / `nav-active-light-red.svg`。其余配置在 `system_settings.json -> app_identity`,图片统一放在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片通过 `/api/public/app-identity/*` 读取。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。
|
||||
- **平台模板套件 (Bootstrap page-template-suite 技能)**: 内置技能 `skills/public/page-template-suite`(public 类,缺省启用)让「画页面」走平台预制 Bootstrap 模板而不是从零手写。**套件只提供多页面门户(最低 2 个逻辑页),不做单页面 HTML**(单页请求自动回落共享响应式文档模板):5 套设计 × 明暗两版共 10 个 `deerflow/page_reports/assets/report-bootstrap-*-template.html`(图表门户 3 页 / 甘特双页「进度总览+里程碑风险」/ 瓦片地图门户 3 页 / 文字杂志双页「正文论述+脉络结论」/ 研判简报双页「核心结论+风险行动」),profile 注册于 `page_reports/templates.py`(id `bootstrap-charts-portal-{light,dark}`、`bootstrap-gantt-{light,dark}`、`bootstrap-map-portal-{light,dark}`、`bootstrap-text-magazine-{light,dark}`、`bootstrap-text-briefing-{light,dark}`)。显式 `template_id` 时**页数以模板为准**(prepare 会把 brief 的页数对齐到模板页数,技能无需依赖用户说出「多页」)。模板源文件用 `__DF_HTML_RUNTIME_BASE__` 占位符引用 Gateway 运行库(Bootstrap/Alpine/ECharts/Leaflet/deerflow-runtime.js),`page_report_template prepare` 在会话副本中替换为设置里保存的 HTML 运行库地址,部署无关;图表走 `data-df-chart` 严格 JSON(甘特 = 时间轴 + 阶段重叠柱 `barGap:-100%`),地图门户走 `data-df-map` Leaflet 瓦片契约(瓦片地址来自设置里保存的地图瓦片地址,需管理员在「外观设置 → HTML 地图」选择「地图网页」引擎,引擎不符时 prepare 的 brief 会带 `map_engine_warning`)。prepare 的 brief 还带 `platform_branding`(管理员「生成网页标识」的名称/Logo/页脚文字与注入规则,正文禁止自写品牌)和 `runtime_base_url`;`_choose_page_profile` 现在**显式 `template_id` 优先**(单页文档模式也能选中套件模板,此前单页一律回落共享文档模板),未显式指定时旧选择行为不变。多页门户模板自带锚点切页兜底脚本(检测 `data-df-generated-header` 让位平台套壳,勿用 `data-df-generated-chrome` 字面量——会被注入幂等检查误判)。测试 `tests/test_page_template_suite.py`(10 模板 prepare→validate 全绿 + 品牌注入 + 居左/居中布局 + 引擎门控)。
|
||||
- **Docker 内置技能同步**: Linux Docker 启动时不能让旧的持久化 `/app/skills` 遮住当前版本的 `backend/skills/public`。共享实现 `app/builtin_skill_sync.py` 会把 bundled public 覆盖同步到 live public 根、保留 `custom/`,并强制校验 Facebook/X/TikTok/YouTube/Threads/CNN/BBC/新华社/白宫官网/马尼拉时报/GMA News/雅虎日本/读卖新闻共 13 个硬路由页面技能;它使用跨进程文件锁和必需包内容摘要,多 worker 首次只复制一次,后续相同版本只校验跳过。两个受支持启动脚本仍在 Gateway 前调用 `backend/scripts/sync_builtin_skills.py`,把当前源码 `backend/skills` 只读挂载为 `/app/bundled-skills`、live skills 读写挂载到 `/app/skills`;同时 Gateway lifespan 在加载配置后、技能 ownership 迁移/MySQL 目录投影/提示词缓存之前再次调用同一共享实现,所以镜像默认入口、Compose 或外部发版脚本绕过 Shell 时也会自动补齐。任一硬路由技能缺失会 fail-fast 并打印名单,不能静默启动成“技能不可用”。
|
||||
- **自定义技能迭代与版本切换**: 管理员设置弹窗的「自定义技能迭代」总开关默认关闭(`GET/PUT /api/system-settings/skill-versioning`);关闭时保持原同名覆盖逻辑。开启后仅 `skills/custom` 参与:本人上传同名技能可选「迭代为新版本」(保留完整历史包并激活新版)或「直接覆盖」(清除旧版本链,以本次内容重新作为 v1);本人技能详情顶部也提供「迭代新版本」安装包入口,详情弹窗「版本」页可切换任一快照。历史包保存在 runtime `skill-versions/<name>/vNNNNNN`,位于技能扫描/沙箱挂载目录之外;`skills/custom/<name>` 永远只保留一个活动包,所以大模型只能发现当前激活版本。版本元数据与调用统计解耦并继续以稳定技能名计数,上传迭代和版本切换都不清零外部调用次数。实现 `deerflow/skills/versioning.py`、`app/gateway/routers/skills.py` 的 `/versions` 接口,测试 `tests/test_skill_versioning.py` 与 `tests/test_strategy_project_settings.py`。
|
||||
- **Facebook 页面生成技能**: 内置公共技能 `skills/public/facebook-page-generator` 只在用户明确要求 Facebook/Facebook 风格网页时触发;`lead_agent/prompt.py` 的普通问答 HTML 分流会强制读取该技能,并排除通用 `page-report-generation`、模板套件和从零手写 HTML。单账号单动态场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总评论数与实际展示数分离,按 `commentTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条评论,并在一次进程内完成七模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;动态与评论正文必须为简体中文。多帖子场景仍使用 `scripts/render_facebook_page.py` 的 `init → validate → render` 流程确定性注入 `assets/facebook-template.html`,模型不得重写模板 DOM/CSS;渲染器拒绝覆盖已有文件并对 `</script>` 注入做转义。最终单文件 HTML 保留右上角设置按钮、分模块 JSON 编辑弹窗、实时校验与「应用并渲染」,以及响应式、深浅主题、发布/点赞/评论/搜索交互;浏览器缓存键包含初始 JSON 的 FNV-1a 指纹,同一制品刷新保留手动编辑,不同新制品不会再被沙箱同源旧 JSON 覆盖。测试 `tests/test_facebook_page_generator_skill.py`。
|
||||
- **X 页面生成技能**: 内置公共技能 `skills/public/x-page-generator` 只在用户明确要求 X 平台、Twitter/推特或 X 风格社交时间线网页时触发;专用路由会强制读取技能并绕开通用 HTML 生成器。单账号单推文场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总回复数与实际展示数分离,按 `replyTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条回复,并在一次进程内完成六模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;推文与回复正文必须为简体中文。多帖子场景仍通过 `scripts/render_x_page.py` 的 `init → validate → render` 流程注入固定三栏模板与内联运行时。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留深浅主题、发布、回复、点赞、转发、收藏、搜索、关注与响应式移动布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_x_page_generator_skill.py`。
|
||||
- **TikTok 页面生成技能**: 内置公共技能 `skills/public/tiktok-page-generator` 只在用户明确要求 TikTok、抖音国际版或 TikTok 风格短视频信息流网页时触发。默认只写小型六模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_tiktok_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索、方向键切换和移动端全屏布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_tiktok_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **YouTube 页面生成技能**: 内置公共技能 `skills/public/youtube-page-generator` 只在用户明确要求 YouTube 或 YouTube 风格视频门户、视频首页、频道页时触发。默认只写小型七模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_youtube_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、分类筛选、搜索、响应式视频网格、Shorts 横向列表、观看弹窗、点赞、收藏、订阅和评论;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_youtube_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **Threads 页面生成技能**: 内置公共技能 `skills/public/threads-page-generator` 只在用户明确要求 Threads 或 Threads 风格串文首页、文本社交信息流时触发。默认只写 `accountName`、`postText`、`topic`、`displayReplyCount` 等高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动映射六模块、补齐作者/统计/中文热评、保留示例视觉并一次完成校验与渲染,禁止模型猜测 `$set` 深层路径或读取默认数据。只有结构改造才回退 `scripts/render_threads_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板和移动端导航;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_threads_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **CNN 页面生成技能**: 内置公共技能 `skills/public/cnn-page-generator` 只在用户明确要求 CNN 或 CNN 风格全球新闻门户、新闻首页时触发。默认只写以 `topic` 为核心、可选 `headline`/`breaking`/栏目标题数组的高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动生成完整中文十模块与明确标注的非实时示意数据,禁止模型猜测 `$set` 深层路径、读取生成后的合并数据或探路后二次渲染。只有结构改造才回退 `scripts/render_cnn_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_cnn_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **BBC 页面生成技能**: 内置公共技能 `skills/public/bbc-page-generator` 只在用户明确要求 BBC 或 BBC 风格国际公共新闻门户、新闻首页时触发。默认只写小型十模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_bbc_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_bbc_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **新华社页面生成技能**: 内置公共技能 `skills/public/xinhua-page-generator` 只在用户明确要求新华社、新华网或新华社风格中文通讯社门户、新闻首页时触发。默认只写小型十模块中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_xinhua_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_xinhua_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **白宫官网页面生成技能**: 内置公共技能 `skills/public/white-house-page-generator` 只在用户明确要求白宫官网、White House 风格政府首页或美国行政机构门户时触发。技能把材料整理为 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_white_house_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈;默认内容明确为概念模板,不冒充政府官网,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_white_house_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **马尼拉时报页面生成技能**: 内置公共技能 `skills/public/manila-times-page-generator` 只在用户明确要求马尼拉时报、The Manila Times 或其风格的菲律宾大报门户时触发。技能把材料整理为 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_manila_times_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、菲律宾旗色带、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_manila_times_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **GMA News 页面生成技能**: 内置公共技能 `skills/public/gma-news-page-generator` 只在用户明确要求 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面时触发。技能把材料整理为 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可保留,其余读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_gma_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、即时新闻条、天气切换、吸顶导航、头条视频、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、播客、彩票开奖和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_gma_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **雅虎日本页面生成技能**: 内置公共技能 `skills/public/yahoo-japan-page-generator` 只在用户明确要求雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面时触发。技能把材料整理为 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yahoo_japan_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多类型搜索、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yahoo_japan_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **读卖新闻页面生成技能**: 内置公共技能 `skills/public/yomiuri-news-page-generator` 只在用户明确要求读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面时触发。技能把材料整理为 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yomiuri_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类切换、社论、视频字幕和邮件订阅;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yomiuri_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
|
||||
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
|
||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||
# VITE_TURBO_MODE_VISIBLE=false
|
||||
# 知识库视图切换:true=显示“列表 / 卡片”切换;false 或未设=固定为列表(默认)。
|
||||
# 部署后优先改 dist/runtime-config.js 的 VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED,刷新即可。
|
||||
# VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED=false
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||
|
||||
@@ -147,6 +147,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 静态站点模式开关;一般部署业务系统时保持 false。
|
||||
VITE_STATIC_WEBSITE_ONLY: "false",
|
||||
|
||||
// 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表;
|
||||
// 改为 true 并刷新页面后,才允许用户切换到卡片视图。
|
||||
VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false",
|
||||
|
||||
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
|
||||
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||
|
||||
@@ -93,6 +93,21 @@ const requiredLegacyFallbacks = [
|
||||
pattern: /AbortSignal\.timeout = function/,
|
||||
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
|
||||
},
|
||||
{
|
||||
file: "vite.config.ts",
|
||||
pattern: /export function jsx\(\)\{return null\}/,
|
||||
description: "Stale Vite deps chunks must locally export jsx for Chrome 102",
|
||||
},
|
||||
{
|
||||
file: "vite.config.ts",
|
||||
pattern: /export const \$\{name\} = __jsxRuntime\.\$\{name\}/,
|
||||
description: "Vite jsx-runtime CJS interop must expose a named jsx export",
|
||||
},
|
||||
{
|
||||
file: "src/components/workspace/question-navigator.tsx",
|
||||
pattern: /animateScrollTop\(parent, Math\.max\(0, Math\.min\(nextTop, maxScrollTop\(parent\)\)\)\)/,
|
||||
description: "Question list anchors must set nested scrollTop for Chrome 102",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAYAAAD77Wr8AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAP+lSURBVHhe7P1VdJxJuif89lqz9+5iM4Mkg2zLIGZmxmRmVIJYJpktMrOLqauaqpmqqbq4u6D3/jbOnHVuz8C5mIuZOff/s54n4s18M5WS5erqge+ci9+KiCfifVMm2c5/Rrzf+O//5T//1/8T/DdpubraSnOr819WkHyNR1+XXk//+v9LRpmvXV7qdZnn0qWvW4l6/eNem36f9NfPVHtcj3OPldZlmlvu3plqK0v/vZL2+/Q/C/9dsdLaDB41r16zmrXLyXR9ptpX81/+63/7f6ss+TkUP4+pr5WcW+7XKtOa9PpfKvN90398qV9v6vVLf33+j5P2+3eJ5ebU160k/TrpfzzOPb4W/ylDTakr/vN//R8sWaO+InVtZunr0q9V3+//7y/wn6T0+tdpuddQ6n+x/ygtN17q/yOl1v6jirhPau2rS35NS+f+9/D/UrVKXz33fyP/MUMtHa1ZSfq69OsfNfe4vs57/c+W/nP3KOrrlrtPhtf4H1LGNbKmrElZtwrq6zJJX/9VpN/z/xf891VIv2Yl6deudB917VHrVjP3l0i/X/rrfBXpr/G/2v/OX1d6TT230vz/DF/Hr2nK9f/pP6rQOL0m/cfUsfLvlyXr/o/znxL91B8T1ZdS/n2YXn8syv9d0uvLzWf4P9H/Ksq/19Pr//eX6f+36f/v/frQ/5nTa1+LR71HsNL7Eem15d5zeNQ90usZ3st4pPRrl7vPcvW/pvSv7a+Aft755/5/pcSvoerPQsoa1Xt/6fPyz5D6vV7lvd/U94CTfwb/25I/k+l/DtPry1F/7cnrkt/bls59HVK/b6Z/DX8d4r23pfWvw196b+X6v+Qej/L13TvTr9vSX+OVLHnPa9XUP470ucwyvWZy/PjvyS53f+XnIn0+fd1q65mkfy0rXbfadcpapf+N30yPI5PfTo+x9HrClJRez2hCWqauvg/fl2rKNcJvpyeF4xMJv6OW5+T6KTlWrXkk1bUpr7Oc4yrpc8utSyfX/Ob4FH57QvgNm5amEmvT66njdMr8FN+bayePJ6WvP6mguQzzmZycxq9Xcuq4ROMp0So1mj8hKeszjN89cRzvniTTwqnjePfUCdU9pvDrk3TPE/j16TS8Tkqfe5w1X4Xqvu+eJifx61Mn8bvpSbw/EcNHo2H2wdgwPhwNS0F8OBLEh3ESkq2sjYQEWjMWwofjIXwwEcb7ZDKEDydkbTyMDyekcVFjPFYk6x9NhFmiPhHEhxMBfDgeXGos1UcK1XyilhDAR2N+ifrquqo/TvcRaz8e9eODyWH87uQE3p05hV+dPY1fnTmFX505yX49cxK/Pk2/Ryfxu+kxvDcZx3tTI/jd8XH5e/KE+D3BfWqFR/++Ta4Rvw/TruM6/X4jqnsr66XEev5zp/wZpD/j0gmFaj7lz6tqLf/Zl9+fTkyylHnV90WBakLyexB9D1/BVKrfTY2x306NCpOj+N3EKH5LJkfwu8kR/F6ivlL73cQIfjcex2/HYimoxnWJx1RXzYlaHL8fjeH3I1EhHhFGo0lUp3Ysg/Eo3hun+Qh+PyKufW8kgvd4PIzfxyWaY6pamvfiYSE2rELjdCG8F1WhcSyEP6hFQ3ifRIL4Q1R4PxrA+9zKWiSwovcyjN8b9ifG76twbVh4P+JnfyC0ftiP99XCxMc+CPsZj0NefCBRn8dhFXVd5UNqg168H/SImmo91cmHkrKeUS1xvQfvBwTqfxgSqJ+cc+ODgBsfEr8bH5BEzYUP/RL1FUotzUd+p+Bz4iOvRH11Xc3rYB96nQlKTe1D1RoxtgseOz4iXoW8xm3LwKpiw0ceicYeyW3BR64M+BoLPnSZ8RHLtEb42GXBx04LPnKaEz52mASnCR/T9U4TPnIY8JHdgI8dEvV5rMcndjWDpMfHdh0+tumSrU2Hj2xafGTT4GObBp9YNfjEomWfWrX41EY0+NRKhvAJsZBBfErMwiemAfapeQCfmvrZJ8Y+fMp62SeGXnxq6MEfib47SdeNT7ntZH8i2g72GWtnf9K0JXymacMXQ6QVnw+14POhZnwx1IzPB4Uvhprw+WAjPh9oxBeDjfhyQPhC+nKgAX/ur8eXffX4gvQr6vBFX9KXsv28t5Z90VuLL0mPogZfkO501fhSov7n3K+RqvFlVxX7QrZCNb7orJIqVarweWclo/HnHcIXHRX4klE/6fPOCnzeUYHP26WOCnzRUY4vpS/ak76U1H3yZ/Vcm9RelpCcK8OXajwn17UJ6nmlpvZ5axk+z1RXXZ8+l7xvKb5oK8VnraX4vG2pL6jeKub/1FoiKeNSfNpSwv4ofSL9saWUUf+j5hJ80lyCP7WI6/5IWkr4vvTa9BqftZbgj63iXsr9ldekOt3nY9W96VoF3ZeuU16b8H1aSvCZRH2FUlPQ+g+aS/Bhs/haFcqYvtbEta2rIb9++vHKa5SfjxTKz4X8MdDP0YdN4nW5bUr9elLQnFyb6Mv2k6YSfNpcij82l3KbiuYk+WuW+vUK9PUsh+5L/iQpY3X9k6ZSfNBYkqD8eD5gxfiouTjxeyJd+v2UH8fH8lrysfw9RT8Omv+8pYwpX4/yY+U18veh+D2R/lrK7xf6cZfhy9YyfNlSxn26j/o65X5M/hryPej3kHwdNWVejX5uP2+h3/Nlq8ZfUyaqP9v0PYDXUi0xX54g/rwrY/HnXkiv0/ce9fey5PcqJr+X0feWz9sF8f1EfA/kOs+V4zNel/xeSTXFZyl9+f1Wou/LRN1X1/j7M3+PrsRnEn1v/yzxPV75O0CSf0983lW9FP/dUoPPJOoLtfi8py7hM0VvHf7E6vGZok/RgM/7GxM+U/cHmvB5vzTQjM8HWvDZQAs+H1TQ38MC/d38+VA7+0xDOvC5pgNfaDvwubYTn2m78Lmum30mW6bvwWeGXvzJ0IvPjH34zEit0u8XTP34k7kffzINCGbJMphkHUr4I9PgT3Ztwh9tevYnux5/sukEux5/tOvxKXEY8KnTyD5xiJa5zCom9onLLLipZuGW+x5rwiduFRozGz712tkn1HoE7ifqDnyc4MQnxOfExz4H+8RPYwfjmrKW+n5nqoBLxY2Pgx4hpBL04CMppS/neaysDXuTQl58RMLEt2ofhtJQTZFeS58P+4VhSV1LkGsTa5I+GFaJkMCqpPx/jaj+r/dBVFDXkv8PFP+/TEjMUV+F5tL/z6r6vyujfjwsxMJLaxkNCzEir+P/W6v+Ty3HYo3wHv2/PR5JXE/z4v/loq5c896IkJij/+vLWgrlOvlewHtjsqUxvb+gvB9A7xOQsQi/l8CU9xzU7ztkeh9CvY6MRfG7cfEex5J7jMfwe/l+x+9GYwL1FWnviSytJ99b+c1YHL/hVukLifdZlPdjqD8xgt/TezQTynsxI0kTAr+Po3pfh2vj8r0d1VzK+z7Ke0ET6tcUeEx19XX8NSxzT+X1lNdUKD8edS2d6msRP85HX/f7iVGBX1OspWsVia9FjesxvDc6jA/ofdpYEH+IBfn9JH6/i34uJ+m9szFu6df7/XgQH0U8+CTixsdRKeLCJxEXPh52c53nhiX1Ol4r6h8pcxHRV8afRD0S9YXEdYqoi9HrfDrsYZ8Q+XV9EnZJ9LWIuljjxifDLon6bnwapnsISo0p9wi58GnIhT9Kn4bFmHwSFD4O0X08+GPYw+3Hw158SO+F8Xt+8r1Apnr/j6n+rEr05/kPaiP0/UN+H6E+SVujfO9Y8v1iJfSe4IjE7xGqvofJ73vK9yuujSjCSfw9VH6/GxnG+0w1z2voe2sIfxiRrfL9WHkN1XuQie+tsTDejytCCR+MyH5MigeFmGyVPv99IN+rpHXK3xvKuhi9ZxkQYwW/j6nUZct/Z2Wg+vvqkWIS9fnvwSATX+PSvyNTpP2dmF4T46CQ+Ps1/e9c5WtOvk8q/t5WviYf3h8mNB9k4lr5d3tUtnytWJuoyznl7/PENYnrfPiA1itzUfEebvJrUd0v5Z7iutT3d+W91K+hQvdU+t8Y8fox4g1ixBeSgmLsDciW5hTKfAAjHj9GPMoaauk+/tR1Kdel11aop7+uN4hRX2iJMX8IY74gxvwBjPkCGPUHpRBG/WFJ1MZSBBLXJAUxRvdk1FdRrvMFMe4PCYFwsu+nPqH5IMYDRKkra5a5jtYx6g/LeWVNGGMBxbCqvwry2vHgMMaDEYn6S02o8deQoa6Scn1ISMxTX5E+XiIspV3zqOt4PiKEhzHJIpgMR5c1NRzFJFHXJHVfbenaCKbCw7JNXZN6bUzWY5gajuFkMITLLgfumDR4Rd+NVwzduG/sx3WTBlfMWly36HHHqsddq5Hdthpwm2s63LXqcMdqwB2LmLtrN+G23YRbNlqnT7hlEfg6iy5Nav2uWcvumJV5Le4SqpskuSaVBvfMGtxnWtyTdWqpLua0khgzkwb3jKK9b9LggXTfNIS71Jo1eNE4gJcMvbhtGsJ5px0TwRBGonGMxEYwGo1jNBLDaCQqhMKY8vlxyuPBWY8LM14vjvuDmMz0+0b9+1b5vaqqJ38vRzAWikryzwr/mQxjLBjGWGiY6wL1wxgNDjP6s8nrAmGMBmRNdb24h6jxvRLEn+nRQBgj9L2KrvUPY8QX5u+L/H1MuWdgmNfwnDeU/B7JaJwq7g0g7vVLQcLine truncated
|
||||
|
After Width: | Height: | Size: 193 KiB |
@@ -443,6 +443,25 @@
|
||||
background-size: 152px 42px;
|
||||
}
|
||||
|
||||
/* 浅红科技感顶栏保持深色版的尺寸与排布,只替换文字光效和选中底图。 */
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item:hover {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 10px rgba(196, 51, 40, 0.4);
|
||||
}
|
||||
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item--active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 12px rgba(177, 38, 28, 0.48);
|
||||
background-image: var(
|
||||
--light-red-nav-active-background-image,
|
||||
url("../assets/img/nav-active-light-red.svg")
|
||||
);
|
||||
}
|
||||
|
||||
.third-submenu-bar {
|
||||
position: relative;
|
||||
z-index: 34;
|
||||
|
||||
@@ -45,6 +45,8 @@ function HeaderButton({
|
||||
|
||||
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
|
||||
export function WikiHeader({
|
||||
title = "知识库管理",
|
||||
description,
|
||||
onToggleSidebar,
|
||||
onImportFile,
|
||||
onBatchImport,
|
||||
@@ -55,6 +57,8 @@ export function WikiHeader({
|
||||
onManageTemplates,
|
||||
extraActions,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
onBatchImport: () => void;
|
||||
@@ -66,8 +70,13 @@ export function WikiHeader({
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<header
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900",
|
||||
description ? "min-h-14 py-2" : "h-14",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
@@ -76,8 +85,15 @@ export function WikiHeader({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
知识库管理
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-0.5 max-w-xl truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
usePageDescriptionsSettings,
|
||||
usePrivateSquareStatus,
|
||||
usePublishSquares,
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
||||
WX_RESTRICTED_TAB_NANJING_DB,
|
||||
@@ -51,6 +56,8 @@ export function AgentGallery({
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const routeBase = useRouteBase();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.agents;
|
||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -455,9 +462,11 @@ export function AgentGallery({
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pageDescription?.title || t.agents.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
{pageDescription?.description || t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
label={
|
||||
language === "html" && !useWriteFileStreaming && !onDownloadClick
|
||||
? "下载离线包"
|
||||
: t.common.download
|
||||
}
|
||||
tooltip={
|
||||
language === "html" && !useWriteFileStreaming && !onDownloadClick
|
||||
? "下载包含网页运行库的 ZIP 离线包"
|
||||
: t.common.download
|
||||
}
|
||||
onClick={async () => {
|
||||
if (onDownloadClick) {
|
||||
onDownloadClick();
|
||||
|
||||
@@ -130,7 +130,9 @@ export function ArtifactFileList({
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
{t.common.download}
|
||||
{!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file))
|
||||
? "下载离线包"
|
||||
: t.common.download}
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { AppBrandImage } from "@/core/branding/app-brand-image";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
import { useAppSystemName } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
useAppSystemName,
|
||||
usePageDescriptionsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -12,11 +16,25 @@ import { cn } from "@/lib/utils";
|
||||
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
|
||||
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
||||
*/
|
||||
export function CompactWelcome({ className }: { className?: string }) {
|
||||
export function CompactWelcome({
|
||||
className,
|
||||
pageDescription,
|
||||
}: {
|
||||
className?: string;
|
||||
/** The actual interface mode is resolved by ChatPage. */
|
||||
pageDescription?: GeneralQaPageDescription;
|
||||
}) {
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const { theme } = useTheme();
|
||||
const systemName = useAppSystemName("");
|
||||
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
|
||||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||||
const configured = pageDescription ?? pageDescriptions?.general_qa.compact;
|
||||
const welcomeTitle =
|
||||
configured?.welcome_title ||
|
||||
(systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手");
|
||||
const welcomeDescription =
|
||||
configured?.welcome_description ||
|
||||
"基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。";
|
||||
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
|
||||
|
||||
return (
|
||||
@@ -46,7 +64,7 @@ export function CompactWelcome({ className }: { className?: string }) {
|
||||
{desensitizeText(welcomeTitle)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
{desensitizeText(welcomeDescription)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -140,6 +140,7 @@ export function InputBox({
|
||||
knowledgeRetrieval,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
placeholder,
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
@@ -227,6 +228,8 @@ export function InputBox({
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
/** Page-specific prompt. Omit to keep the localized product default. */
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
@@ -861,7 +864,7 @@ export function InputBox({
|
||||
data-composer-height={composerMinHeight ? "" : undefined}
|
||||
style={composerHeightStyle}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
placeholder={placeholder || t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
|
||||
@@ -63,6 +63,10 @@ import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
||||
import {
|
||||
extractInvokedSkillName,
|
||||
skillInvocationLabel,
|
||||
} from "@/core/tools/skill-invocation";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -371,6 +375,10 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const invokedSkillName = extractInvokedSkillName(name, args);
|
||||
const invokedSkillLabel = invokedSkillName
|
||||
? skillInvocationLabel(invokedSkillName)
|
||||
: undefined;
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
@@ -566,7 +574,9 @@ function ToolCall({
|
||||
} else if (name === "read_file") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
if (invokedSkillLabel) {
|
||||
description = invokedSkillLabel;
|
||||
} else if (!description) {
|
||||
description = t.toolCalls.readFile;
|
||||
}
|
||||
const { path } = args as { path: string; content: string };
|
||||
@@ -598,11 +608,10 @@ function ToolCall({
|
||||
/>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
return t.toolCalls.executeCommand;
|
||||
}
|
||||
const description =
|
||||
invokedSkillLabel ||
|
||||
(args as { description?: string })?.description ||
|
||||
t.toolCalls.executeCommand;
|
||||
const command: string | undefined = (args as { command: string })?.command;
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -653,7 +662,7 @@ function ToolCall({
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={description ?? t.toolCalls.useTool(name)}
|
||||
label={invokedSkillLabel ?? description ?? t.toolCalls.useTool(name)}
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
|
||||
@@ -27,21 +27,146 @@ const PANEL_GUTTER_MARGIN = 24;
|
||||
/** Questions needed before the navigator shows up at all. */
|
||||
const MIN_QUESTIONS = 2;
|
||||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
||||
/** Matches `scroll-mt-20` so the target sits below the overlay chat header. */
|
||||
const QUESTION_SCROLL_TOP_OFFSET = 80;
|
||||
/** Re-apply after the drawer overlay unmounts; StickToBottom may snap on resize. */
|
||||
const SHEET_SCROLL_RETRY_MS = 80;
|
||||
|
||||
function verticalOverflow(node: HTMLElement): string {
|
||||
const style = window.getComputedStyle(node);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY && overflowY !== "visible") return overflowY;
|
||||
const parts = style.overflow.split(" ");
|
||||
return parts[1] || parts[0] || "";
|
||||
}
|
||||
|
||||
function isVerticallyScrollable(node: HTMLElement): boolean {
|
||||
const overflowY = verticalOverflow(node);
|
||||
return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
|
||||
}
|
||||
|
||||
/** Walk up from a message anchor to the element that actually scrolls. */
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const overflowY = window.getComputedStyle(node).overflowY;
|
||||
if (
|
||||
(overflowY === "auto" || overflowY === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight
|
||||
) {
|
||||
while (node && node !== document.body) {
|
||||
if (isVerticallyScrollable(node) && node.scrollHeight > node.clientHeight + 1) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
if (document.scrollingElement instanceof HTMLElement) {
|
||||
return document.scrollingElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
let questionScrollFrame = 0;
|
||||
let questionScrollTarget: { scroller: HTMLElement; top: number } | null = null;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
try {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nested-container smooth scroll that Chrome 102 can actually run. */
|
||||
function animateScrollTop(scroller: HTMLElement, top: number): void {
|
||||
if (
|
||||
questionScrollTarget?.scroller === scroller &&
|
||||
Math.abs(questionScrollTarget.top - top) < 8
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame);
|
||||
const from = scroller.scrollTop;
|
||||
const delta = top - from;
|
||||
questionScrollTarget = { scroller, top };
|
||||
if (Math.abs(delta) < 1 || prefersReducedMotion()) {
|
||||
scroller.scrollTop = top;
|
||||
questionScrollFrame = 0;
|
||||
return;
|
||||
}
|
||||
const duration = Math.min(480, Math.max(240, Math.abs(delta) * 0.45));
|
||||
const start = performance.now();
|
||||
const stopListening = () => {
|
||||
scroller.removeEventListener("wheel", cancelOnUserScroll);
|
||||
scroller.removeEventListener("touchmove", cancelOnUserScroll);
|
||||
};
|
||||
const cancelOnUserScroll = () => {
|
||||
if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame);
|
||||
questionScrollFrame = 0;
|
||||
questionScrollTarget = null;
|
||||
stopListening();
|
||||
};
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const eased = 1 - (1 - t) * (1 - t) * (1 - t);
|
||||
scroller.scrollTop = from + delta * eased;
|
||||
if (t < 1) {
|
||||
questionScrollFrame = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
questionScrollFrame = 0;
|
||||
stopListening();
|
||||
};
|
||||
scroller.addEventListener("wheel", cancelOnUserScroll, { passive: true });
|
||||
scroller.addEventListener("touchmove", cancelOnUserScroll, { passive: true });
|
||||
questionScrollFrame = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function scrollChildIntoParent(
|
||||
el: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
topOffset = 0,
|
||||
): void {
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const nextTop = parent.scrollTop + (elRect.top - parentRect.top) - topOffset;
|
||||
animateScrollTop(parent, Math.max(0, Math.min(nextTop, maxScrollTop(parent))));
|
||||
}
|
||||
|
||||
function maxScrollTop(parent: HTMLElement): number {
|
||||
return Math.max(0, parent.scrollHeight - parent.clientHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome 102 (and other nested-scroller hosts) often no-ops
|
||||
* `scrollIntoView({ behavior: "smooth" })` when the conversation is not the
|
||||
* document. Animate `scrollTop` on the real overflow container instead.
|
||||
*/
|
||||
function scrollElementIntoContainer(el: HTMLElement, topOffset = 0): void {
|
||||
const scroller = findScrollParent(el);
|
||||
if (!scroller || scroller === el) {
|
||||
try {
|
||||
el.scrollIntoView(true);
|
||||
} catch {
|
||||
// ignore hosts that reject the boolean form
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
scroller.dispatchEvent(
|
||||
new WheelEvent("wheel", { deltaY: -1, bubbles: true }),
|
||||
);
|
||||
} catch {
|
||||
// WheelEvent is available on Chrome 102; keep scrollTop even if this fails.
|
||||
}
|
||||
scrollChildIntoParent(el, scroller, topOffset);
|
||||
}
|
||||
|
||||
function scrollItemNearestInParent(
|
||||
item: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
): void {
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
if (itemRect.top < parentRect.top) {
|
||||
parent.scrollTop += itemRect.top - parentRect.top;
|
||||
} else if (itemRect.bottom > parentRect.bottom) {
|
||||
parent.scrollTop += itemRect.bottom - parentRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
function readCollapsedPreference(): boolean {
|
||||
@@ -113,6 +238,7 @@ export function QuestionNavigator({
|
||||
return;
|
||||
}
|
||||
const firstAnchor = document.getElementById(questions[0]!.anchorId);
|
||||
if (!firstAnchor) return;
|
||||
const scroller = findScrollParent(firstAnchor);
|
||||
if (!scroller) return;
|
||||
|
||||
@@ -145,17 +271,20 @@ export function QuestionNavigator({
|
||||
|
||||
const scrollToQuestion = useCallback((anchorId: string) => {
|
||||
const scroll = () => {
|
||||
document
|
||||
.getElementById(anchorId)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
const el = document.getElementById(anchorId);
|
||||
if (!el) return false;
|
||||
scrollElementIntoContainer(el, QUESTION_SCROLL_TOP_OFFSET);
|
||||
return true;
|
||||
};
|
||||
if (!document.getElementById(anchorId)) {
|
||||
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
|
||||
requestAnimationFrame(() => requestAnimationFrame(scroll));
|
||||
} else {
|
||||
scroll();
|
||||
}
|
||||
setActiveId(anchorId);
|
||||
if (scroll()) return;
|
||||
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scroll()) return;
|
||||
window.setTimeout(scroll, 50);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
@@ -246,8 +375,12 @@ export function QuestionNavigator({
|
||||
questions={questions}
|
||||
activeId={activeId}
|
||||
onSelect={(anchorId) => {
|
||||
setSheetOpen(false);
|
||||
scrollToQuestion(anchorId);
|
||||
setSheetOpen(false);
|
||||
window.setTimeout(
|
||||
() => scrollToQuestion(anchorId),
|
||||
SHEET_SCROLL_RETRY_MS,
|
||||
);
|
||||
}}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
@@ -272,17 +405,22 @@ function QuestionCardList({
|
||||
className?: string;
|
||||
}) {
|
||||
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
|
||||
// nudges this inner list — the card is already inside the on-screen panel,
|
||||
// so no outer container moves.
|
||||
// Keep the active card visible as the chat scrolls. Scroll only this inner
|
||||
// list — `scrollIntoView({ block: "nearest" })` on Chrome 102 also moves
|
||||
// ancestor conversation scrollers, which undoes the jump to the question.
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
|
||||
const item = itemRefs.current.get(activeId);
|
||||
const nav = navRef.current;
|
||||
if (!item || !nav) return;
|
||||
scrollItemNearestInParent(item, nav);
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label="问题导航"
|
||||
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import defaultLightRedNavActive from "@/assets/img/nav-active-light-red.svg";
|
||||
import defaultLightRedHeaderBackground from "@/assets/img/top-bg-light-red.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,17 +22,35 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
getHeaderBrandAsset,
|
||||
getHeaderBrandFallback,
|
||||
} from "@/core/branding/brand-assets";
|
||||
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
|
||||
import {
|
||||
clearHeaderTitlePreview,
|
||||
setHeaderTitlePreview,
|
||||
} from "@/core/branding/header-title-preview";
|
||||
import {
|
||||
clearLightRedNavigationPreview,
|
||||
setLightRedNavigationPreview,
|
||||
} from "@/core/branding/light-red-navigation-preview";
|
||||
import {
|
||||
resolveAppIdentityAssetUrl,
|
||||
resolveAppIdentityLogoUrl,
|
||||
type AppIdentityHeaderTitleFontMode,
|
||||
} from "@/core/system-settings/api";
|
||||
import {
|
||||
useAppIdentitySettings,
|
||||
useDeleteAppIdentitySettings,
|
||||
useUpdateAppIdentitySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
|
||||
HEADER_TITLE_FIXED_MIN_FONT_SIZE,
|
||||
HEADER_TITLE_MAX_FONT_SIZE,
|
||||
} from "@/strategy-components/header-title-fit";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -33,6 +60,121 @@ const ACCEPTED_LOGO_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
]);
|
||||
const ACCEPTED_NAVIGATION_IMAGE_TYPES = new Set([
|
||||
...ACCEPTED_LOGO_TYPES,
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
const isAcceptedNavigationImage = (file: File) =>
|
||||
ACCEPTED_NAVIGATION_IMAGE_TYPES.has(file.type) ||
|
||||
(file.type === "" && file.name.toLowerCase().endsWith(".svg"));
|
||||
|
||||
const clampHeaderTitleFontSize = (value: number) =>
|
||||
Math.min(
|
||||
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
|
||||
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, Math.round(value)),
|
||||
);
|
||||
|
||||
interface NavigationImageUploadCardProps {
|
||||
inputId: string;
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
title: string;
|
||||
description: string;
|
||||
previewUrl: string;
|
||||
previewAlt: string;
|
||||
previewImageClassName: string;
|
||||
pendingFile: File | null;
|
||||
hasCustomImage: boolean;
|
||||
restoringDefault: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRestoreDefault: () => void;
|
||||
}
|
||||
|
||||
function NavigationImageUploadCard({
|
||||
inputId,
|
||||
inputRef,
|
||||
title,
|
||||
description,
|
||||
previewUrl,
|
||||
previewAlt,
|
||||
previewImageClassName,
|
||||
pendingFile,
|
||||
hasCustomImage,
|
||||
restoringDefault,
|
||||
disabled,
|
||||
onChange,
|
||||
onRestoreDefault,
|
||||
}: NavigationImageUploadCardProps) {
|
||||
return (
|
||||
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(220px,2fr)_minmax(180px,1fr)]">
|
||||
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-slate-950/90 p-2">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={previewAlt}
|
||||
className={previewImageClassName}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg"
|
||||
aria-label={`${title}文件`}
|
||||
className="sr-only"
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ImageUp aria-hidden />
|
||||
{hasCustomImage || pendingFile ? "更换图片" : "上传图片"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={onRestoreDefault}
|
||||
disabled={
|
||||
disabled ||
|
||||
(!hasCustomImage && !pendingFile) ||
|
||||
(restoringDefault && !pendingFile)
|
||||
}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
{pendingFile && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={pendingFile.name}
|
||||
>
|
||||
待上传:{pendingFile.name}
|
||||
</p>
|
||||
)}
|
||||
{restoringDefault && !pendingFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
保存后使用当前内置图片。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppIdentitySettingsPage() {
|
||||
const { theme } = useTheme();
|
||||
@@ -43,22 +185,72 @@ export function AppIdentitySettingsPage() {
|
||||
const updateIdentity = useUpdateAppIdentitySettings();
|
||||
const deleteIdentity = useDeleteAppIdentitySettings();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const headerBackgroundInputRef = useRef<HTMLInputElement>(null);
|
||||
const navActiveInputRef = useRef<HTMLInputElement>(null);
|
||||
const [systemName, setSystemName] = useState("");
|
||||
const [headerTitleFontMode, setHeaderTitleFontMode] =
|
||||
useState<AppIdentityHeaderTitleFontMode>("adaptive");
|
||||
const [headerTitleFontSize, setHeaderTitleFontSize] = useState(
|
||||
HEADER_TITLE_MAX_FONT_SIZE,
|
||||
);
|
||||
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
|
||||
const [removeLogo, setRemoveLogo] = useState(false);
|
||||
const [pendingHeaderBackground, setPendingHeaderBackground] =
|
||||
useState<File | null>(null);
|
||||
const [removeHeaderBackground, setRemoveHeaderBackground] = useState(false);
|
||||
const [pendingNavActive, setPendingNavActive] = useState<File | null>(null);
|
||||
const [removeNavActive, setRemoveNavActive] = useState(false);
|
||||
const [navigationImagesTouched, setNavigationImagesTouched] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSystemName(identity?.system_name ?? "");
|
||||
setHeaderTitleFontMode(identity?.header_title_font_mode ?? "adaptive");
|
||||
setHeaderTitleFontSize(
|
||||
clampHeaderTitleFontSize(
|
||||
identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE,
|
||||
),
|
||||
);
|
||||
setPendingLogo(null);
|
||||
setRemoveLogo(false);
|
||||
setPendingHeaderBackground(null);
|
||||
setRemoveHeaderBackground(false);
|
||||
setPendingNavActive(null);
|
||||
setRemoveNavActive(false);
|
||||
setNavigationImagesTouched(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
|
||||
if (headerBackgroundInputRef.current) {
|
||||
headerBackgroundInputRef.current.value = "";
|
||||
}
|
||||
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
|
||||
}, [
|
||||
identity?.system_name,
|
||||
identity?.header_title_font_mode,
|
||||
identity?.header_title_font_size,
|
||||
identity?.logo_version,
|
||||
identity?.has_logo,
|
||||
identity?.light_red_header_background_version,
|
||||
identity?.has_light_red_header_background,
|
||||
identity?.light_red_nav_active_version,
|
||||
identity?.has_light_red_nav_active,
|
||||
]);
|
||||
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
|
||||
[pendingLogo],
|
||||
);
|
||||
const headerBackgroundPreviewUrl = useMemo(
|
||||
() =>
|
||||
pendingHeaderBackground
|
||||
? URL.createObjectURL(pendingHeaderBackground)
|
||||
: undefined,
|
||||
[pendingHeaderBackground],
|
||||
);
|
||||
const navActivePreviewUrl = useMemo(
|
||||
() =>
|
||||
pendingNavActive ? URL.createObjectURL(pendingNavActive) : undefined,
|
||||
[pendingNavActive],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -67,16 +259,86 @@ export function AppIdentitySettingsPage() {
|
||||
[previewUrl],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (headerBackgroundPreviewUrl) {
|
||||
URL.revokeObjectURL(headerBackgroundPreviewUrl);
|
||||
}
|
||||
},
|
||||
[headerBackgroundPreviewUrl],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (navActivePreviewUrl) URL.revokeObjectURL(navActivePreviewUrl);
|
||||
},
|
||||
[navActivePreviewUrl],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearHeaderTitlePreview();
|
||||
clearLightRedNavigationPreview();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const savedLogoUrl = removeLogo
|
||||
? undefined
|
||||
: resolveAppIdentityLogoUrl(identity?.logo_url);
|
||||
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
|
||||
const savedHeaderBackgroundUrl = removeHeaderBackground
|
||||
? undefined
|
||||
: resolveAppIdentityAssetUrl(
|
||||
identity?.light_red_header_background_url,
|
||||
);
|
||||
const savedNavActiveUrl = removeNavActive
|
||||
? undefined
|
||||
: resolveAppIdentityAssetUrl(identity?.light_red_nav_active_url);
|
||||
const displayedHeaderBackgroundUrl =
|
||||
headerBackgroundPreviewUrl ??
|
||||
savedHeaderBackgroundUrl ??
|
||||
defaultLightRedHeaderBackground;
|
||||
const displayedNavActiveUrl =
|
||||
navActivePreviewUrl ?? savedNavActiveUrl ?? defaultLightRedNavActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigationImagesTouched) return;
|
||||
setLightRedNavigationPreview({
|
||||
headerBackgroundUrl:
|
||||
headerBackgroundPreviewUrl ?? savedHeaderBackgroundUrl ?? null,
|
||||
navActiveUrl: navActivePreviewUrl ?? savedNavActiveUrl ?? null,
|
||||
});
|
||||
}, [
|
||||
headerBackgroundPreviewUrl,
|
||||
navigationImagesTouched,
|
||||
navActivePreviewUrl,
|
||||
savedHeaderBackgroundUrl,
|
||||
savedNavActiveUrl,
|
||||
]);
|
||||
|
||||
const normalizedName = systemName.trim().replace(/\s+/g, " ");
|
||||
const hasChanges =
|
||||
normalizedName !== (identity?.system_name ?? "") ||
|
||||
headerTitleFontMode !==
|
||||
(identity?.header_title_font_mode ?? "adaptive") ||
|
||||
clampHeaderTitleFontSize(headerTitleFontSize) !==
|
||||
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) ||
|
||||
Boolean(pendingLogo) ||
|
||||
removeLogo;
|
||||
const customized = Boolean(identity?.system_name || identity?.has_logo);
|
||||
removeLogo ||
|
||||
Boolean(pendingHeaderBackground) ||
|
||||
removeHeaderBackground ||
|
||||
Boolean(pendingNavActive) ||
|
||||
removeNavActive;
|
||||
const customized = Boolean(
|
||||
identity?.system_name ||
|
||||
identity?.has_logo ||
|
||||
identity?.has_light_red_header_background ||
|
||||
identity?.has_light_red_nav_active ||
|
||||
(identity?.header_title_font_mode ?? "adaptive") !== "adaptive" ||
|
||||
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) !==
|
||||
HEADER_TITLE_MAX_FONT_SIZE,
|
||||
);
|
||||
const busy = updateIdentity.isPending || deleteIdentity.isPending;
|
||||
|
||||
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -102,26 +364,82 @@ export function AppIdentitySettingsPage() {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleHeaderBackgroundChange = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!isAcceptedNavigationImage(file)) {
|
||||
toast.error("导航栏背景图仅支持 PNG、JPG、WebP 或 SVG 图片");
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setPendingHeaderBackground(file);
|
||||
setRemoveHeaderBackground(false);
|
||||
setNavigationImagesTouched(true);
|
||||
};
|
||||
|
||||
const handleNavActiveChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!isAcceptedNavigationImage(file)) {
|
||||
toast.error("菜单选中样式图仅支持 PNG、JPG、WebP 或 SVG 图片");
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setPendingNavActive(file);
|
||||
setRemoveNavActive(false);
|
||||
setNavigationImagesTouched(true);
|
||||
};
|
||||
|
||||
const handleUseDefaultHeaderBackground = () => {
|
||||
setPendingHeaderBackground(null);
|
||||
setRemoveHeaderBackground(true);
|
||||
setNavigationImagesTouched(true);
|
||||
if (headerBackgroundInputRef.current) {
|
||||
headerBackgroundInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseDefaultNavActive = () => {
|
||||
setPendingNavActive(null);
|
||||
setRemoveNavActive(true);
|
||||
setNavigationImagesTouched(true);
|
||||
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateIdentity.mutateAsync({
|
||||
systemName: normalizedName,
|
||||
headerTitleFontMode,
|
||||
headerTitleFontSize: clampHeaderTitleFontSize(headerTitleFontSize),
|
||||
logo: pendingLogo,
|
||||
removeLogo,
|
||||
lightRedHeaderBackground: pendingHeaderBackground,
|
||||
removeLightRedHeaderBackground: removeHeaderBackground,
|
||||
lightRedNavActive: pendingNavActive,
|
||||
removeLightRedNavActive: removeNavActive,
|
||||
});
|
||||
toast.success("系统名称已保存", {
|
||||
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
|
||||
setNavigationImagesTouched(false);
|
||||
clearHeaderTitlePreview();
|
||||
clearLightRedNavigationPreview();
|
||||
toast.success("系统名称配置已保存", {
|
||||
description: "导航名称、Logo 与红色主题图片已更新。",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
|
||||
toast.error(error instanceof Error ? error.message : "系统名称配置保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await deleteIdentity.mutateAsync();
|
||||
setNavigationImagesTouched(false);
|
||||
clearHeaderTitlePreview();
|
||||
clearLightRedNavigationPreview();
|
||||
setResetOpen(false);
|
||||
toast.success("已恢复默认系统名称和 Logo");
|
||||
toast.success("已恢复默认系统名称、Logo、字号模式和导航图片");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
|
||||
}
|
||||
@@ -131,7 +449,7 @@ export function AppIdentitySettingsPage() {
|
||||
<>
|
||||
<SettingsSection
|
||||
title="系统名称"
|
||||
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
|
||||
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo,并设置顶部导航中的名称字号及红色主题图片。未上传自定义图片时继续使用当前内置图片。这里不会改「生成网页标识」。"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
@@ -152,6 +470,131 @@ export function AppIdentitySettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">导航栏名称字号</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
自适应会根据导航栏凹槽的可用宽度自动调整;固定大小始终使用指定字号。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={headerTitleFontMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
const mode = value as AppIdentityHeaderTitleFontMode;
|
||||
setHeaderTitleFontMode(mode);
|
||||
setHeaderTitlePreview({
|
||||
mode,
|
||||
fontSize: clampHeaderTitleFontSize(headerTitleFontSize),
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
aria-label="导航栏名称字号模式"
|
||||
disabled={isPending || busy}
|
||||
className="w-full sm:w-fit"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="adaptive"
|
||||
className="h-11 flex-1 px-5 sm:flex-none"
|
||||
>
|
||||
自适应
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="fixed"
|
||||
className="h-11 flex-1 px-5 sm:flex-none"
|
||||
>
|
||||
固定大小
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{headerTitleFontMode === "fixed" ? (
|
||||
<div className="grid gap-3 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(0,1fr)_112px] sm:items-center">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="app-identity-header-font-size-range"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
固定字号
|
||||
</label>
|
||||
<input
|
||||
id="app-identity-header-font-size-range"
|
||||
type="range"
|
||||
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
|
||||
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={clampHeaderTitleFontSize(headerTitleFontSize)}
|
||||
onChange={(event) => {
|
||||
const fontSize = Number(event.target.value);
|
||||
setHeaderTitleFontSize(fontSize);
|
||||
setHeaderTitlePreview({
|
||||
mode: headerTitleFontMode,
|
||||
fontSize,
|
||||
});
|
||||
}}
|
||||
disabled={isPending || busy}
|
||||
className="h-11 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-valuetext={`${clampHeaderTitleFontSize(headerTitleFontSize)} 像素`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs tabular-nums text-muted-foreground">
|
||||
<span>{HEADER_TITLE_FIXED_MIN_FONT_SIZE}px</span>
|
||||
<span>{HEADER_TITLE_FIXED_MAX_FONT_SIZE}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="app-identity-header-font-size"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
字号数值
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="app-identity-header-font-size"
|
||||
type="number"
|
||||
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
|
||||
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={headerTitleFontSize}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value)) {
|
||||
setHeaderTitleFontSize(value);
|
||||
setHeaderTitlePreview({
|
||||
mode: headerTitleFontMode,
|
||||
fontSize: clampHeaderTitleFontSize(value),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const fontSize =
|
||||
clampHeaderTitleFontSize(headerTitleFontSize);
|
||||
setHeaderTitleFontSize(fontSize);
|
||||
setHeaderTitlePreview({
|
||||
mode: headerTitleFontMode,
|
||||
fontSize,
|
||||
});
|
||||
}}
|
||||
disabled={isPending || busy}
|
||||
className="h-11 pr-9 tabular-nums"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground sm:col-span-2">
|
||||
拖动滑杆时顶部导航会同步预览。固定模式不会随分辨率缩放;名称过长时仍保持单行并完整显示,可能超出装饰区域。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-xs leading-5 text-muted-foreground">
|
||||
当前使用自适应模式:最大 30px,空间不足时逐步缩小,最小 24px。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">系统 Logo</div>
|
||||
@@ -226,6 +669,47 @@ export function AppIdentitySettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">红色主题导航图片</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
分别配置顶部导航栏背景和菜单选中样式。选择图片或恢复默认时,页面顶部会立即同步预览;保存后对所有用户生效。
|
||||
</p>
|
||||
</div>
|
||||
<NavigationImageUploadCard
|
||||
inputId="app-identity-light-red-header-background"
|
||||
inputRef={headerBackgroundInputRef}
|
||||
title="导航栏背景图"
|
||||
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用 1920×64 或相同比例的横向通栏图片。"
|
||||
previewUrl={displayedHeaderBackgroundUrl}
|
||||
previewAlt="红色导航栏背景图预览"
|
||||
previewImageClassName="h-full w-full object-cover"
|
||||
pendingFile={pendingHeaderBackground}
|
||||
hasCustomImage={Boolean(
|
||||
identity?.has_light_red_header_background,
|
||||
)}
|
||||
restoringDefault={removeHeaderBackground}
|
||||
disabled={isPending || busy}
|
||||
onChange={handleHeaderBackgroundChange}
|
||||
onRestoreDefault={handleUseDefaultHeaderBackground}
|
||||
/>
|
||||
<NavigationImageUploadCard
|
||||
inputId="app-identity-light-red-nav-active"
|
||||
inputRef={navActiveInputRef}
|
||||
title="菜单选中样式图"
|
||||
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用与当前 152×42 比例接近的透明图片。"
|
||||
previewUrl={displayedNavActiveUrl}
|
||||
previewAlt="红色导航菜单选中样式图预览"
|
||||
previewImageClassName="max-h-full max-w-full object-contain"
|
||||
pendingFile={pendingNavActive}
|
||||
hasCustomImage={Boolean(identity?.has_light_red_nav_active)}
|
||||
restoringDefault={removeNavActive}
|
||||
disabled={isPending || busy}
|
||||
onChange={handleNavActiveChange}
|
||||
onRestoreDefault={handleUseDefaultNavActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -244,7 +728,7 @@ export function AppIdentitySettingsPage() {
|
||||
disabled={!hasChanges || isPending || busy}
|
||||
>
|
||||
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
|
||||
{updateIdentity.isPending ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() {
|
||||
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
|
||||
<DialogTitle>清除系统名称配置?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
|
||||
清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
|
||||
|
||||
@@ -92,6 +92,7 @@ import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
import SkillVersioningSettingsPage from "@/pages/SkillVersioningSettingsPage";
|
||||
|
||||
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -1557,6 +1558,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
<Separator />
|
||||
<SkillCompressionSettingsPage embedded />
|
||||
<Separator />
|
||||
<SkillVersioningSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCallMetricsSettings,
|
||||
useUpdateCallMetricsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function CallMetricsSettingsPage() {
|
||||
const { data, isError, isLoading } = useCallMetricsSettings();
|
||||
const update = useUpdateCallMetricsSettings();
|
||||
const enabled = data?.enabled ?? true;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "工具与技能调用统计已开启" : "工具与技能调用统计已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<BarChart3Icon className="size-5" />
|
||||
工具与技能调用统计
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、
|
||||
技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">记录工具与技能调用</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="记录工具与技能调用统计"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取调用统计开关,系统将按当前部署配置执行。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { buildWikiKnowledgeBaseCreateInput } from "@/core/llmwiki/knowledge-base-form";
|
||||
import {
|
||||
useLlmWikiActions,
|
||||
useLlmWikiEmbeddingModels,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
type BatchCreateResult = {
|
||||
createdNames: string[];
|
||||
failed: Array<{ name: string; message: string }>;
|
||||
};
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
}
|
||||
|
||||
function parseKnowledgeBaseNames(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
return value
|
||||
.split(/[,,\n\r]+/)
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => {
|
||||
if (!name || seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function KnowledgeBatchCreateSettingsPage() {
|
||||
const actions = useLlmWikiActions();
|
||||
const [namesText, setNamesText] = useState("");
|
||||
const [embeddingModelId, setEmbeddingModelId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<BatchCreateResult | null>(null);
|
||||
const embeddingModels = useLlmWikiEmbeddingModels();
|
||||
const availableEmbeddingModels = useMemo(
|
||||
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
|
||||
[embeddingModels.data?.models],
|
||||
);
|
||||
const names = useMemo(() => parseKnowledgeBaseNames(namesText), [namesText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddingModels.data || availableEmbeddingModels.length === 0) return;
|
||||
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
|
||||
const defaultId = embeddingModels.data.default_model_id;
|
||||
setEmbeddingModelId(
|
||||
availableEmbeddingModels.some((model) => model.id === defaultId)
|
||||
? defaultId!
|
||||
: availableEmbeddingModels[0]!.id,
|
||||
);
|
||||
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data]);
|
||||
|
||||
const modelSelectionUnavailable =
|
||||
embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
|
||||
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
|
||||
const suffix = [model.provider?.trim(), model.model_name?.trim()]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return suffix ? `${model.name}(${suffix})` : model.name;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (names.length === 0 || modelSelectionUnavailable) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
actions.create.reset();
|
||||
|
||||
const nextResult: BatchCreateResult = { createdNames: [], failed: [] };
|
||||
for (const name of names) {
|
||||
try {
|
||||
await actions.create.mutateAsync(buildWikiKnowledgeBaseCreateInput({
|
||||
name,
|
||||
description: "",
|
||||
embeddingModelId,
|
||||
wikiConfig: {},
|
||||
}));
|
||||
nextResult.createdNames.push(name);
|
||||
} catch (error) {
|
||||
nextResult.failed.push({ name, message: messageOf(error) });
|
||||
}
|
||||
}
|
||||
|
||||
setResult(nextResult);
|
||||
setSubmitting(false);
|
||||
if (nextResult.createdNames.length > 0) {
|
||||
toast.success(`已创建 ${nextResult.createdNames.length} 个知识库`);
|
||||
}
|
||||
if (nextResult.failed.length > 0) {
|
||||
setNamesText(nextResult.failed.map((item) => item.name).join(","));
|
||||
toast.error(`${nextResult.failed.length} 个知识库创建失败,可修改后重试`);
|
||||
} else {
|
||||
setNamesText("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="批量新增知识库"
|
||||
description="输入多个知识库名称后,系统会在浏览器端按顺序逐个创建到你的个人知识库。"
|
||||
>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="knowledge-batch-names">
|
||||
知识库名称
|
||||
</label>
|
||||
<Textarea
|
||||
id="knowledge-batch-names"
|
||||
value={namesText}
|
||||
onChange={(event) => setNamesText(event.target.value)}
|
||||
rows={7}
|
||||
maxLength={10000}
|
||||
disabled={submitting}
|
||||
placeholder="例如:产品手册,项目资料库,常见问题"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
使用中文逗号、英文逗号或换行分隔;重复名称会自动忽略。已识别 {names.length} 个名称。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="knowledge-batch-embedding-model">
|
||||
原文向量模型
|
||||
</label>
|
||||
{embeddingModels.isLoading ? (
|
||||
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />正在读取知识服务模型
|
||||
</div>
|
||||
) : embeddingModels.isError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive" role="alert">
|
||||
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>
|
||||
重新读取
|
||||
</Button>
|
||||
</div>
|
||||
) : availableEmbeddingModels.length ? (
|
||||
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={submitting}>
|
||||
<SelectTrigger id="knowledge-batch-embedding-model" className="w-full">
|
||||
<SelectValue placeholder="请选择向量模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{availableEmbeddingModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
知识服务中没有可用的 Embedding 模型,请先在知识服务中启用向量模型。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={result.failed.length
|
||||
? "rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
|
||||
: "rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-300"}
|
||||
role={result.failed.length ? "alert" : "status"}
|
||||
>
|
||||
{result.createdNames.length > 0 && <p className="font-medium">已创建 {result.createdNames.length} 个知识库</p>}
|
||||
{result.failed.length > 0 && (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-medium"><AlertCircleIcon className="size-4" />{result.failed.length} 个名称未创建</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs leading-5">
|
||||
{result.failed.slice(0, 5).map((item) => <li key={item.name}>{item.name}:{item.message}</li>)}
|
||||
{result.failed.length > 5 && <li>其余 {result.failed.length - 5} 个失败项已保留在输入框中。</li>}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => void handleCreate()} disabled={submitting || names.length === 0 || modelSelectionUnavailable}>
|
||||
{submitting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
|
||||
{submitting ? "正在创建" : `创建${names.length ? `(${names.length})` : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export function RoundtableRoomSettingsPage() {
|
||||
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
|
||||
String(data?.max_hold_minutes ?? 30),
|
||||
);
|
||||
const [leaseMinutes, setLeaseMinutes] = useState(
|
||||
String(data?.lease_minutes ?? 5),
|
||||
);
|
||||
const [maxCycles, setMaxCycles] = useState(String(data?.max_cycles ?? 8));
|
||||
const [activeCount, setActiveCount] = useState<number | null>(null);
|
||||
|
||||
@@ -38,6 +41,12 @@ export function RoundtableRoomSettingsPage() {
|
||||
}
|
||||
}, [data?.max_hold_minutes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.lease_minutes != null) {
|
||||
setLeaseMinutes(String(data.lease_minutes));
|
||||
}
|
||||
}, [data?.lease_minutes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.max_cycles != null) setMaxCycles(String(data.max_cycles));
|
||||
}, [data?.max_cycles]);
|
||||
@@ -65,6 +74,7 @@ export function RoundtableRoomSettingsPage() {
|
||||
enabled?: boolean;
|
||||
max_rooms?: number;
|
||||
max_hold_minutes?: number;
|
||||
lease_minutes?: number;
|
||||
max_cycles?: number;
|
||||
write_file_compatibility_enabled?: boolean;
|
||||
}) => {
|
||||
@@ -94,6 +104,15 @@ export function RoundtableRoomSettingsPage() {
|
||||
void save({ max_hold_minutes: parsed });
|
||||
};
|
||||
|
||||
const saveLeaseMinutes = () => {
|
||||
const parsed = Number(leaseMinutes);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 60) {
|
||||
toast.error("房间租约请填写 1 到 60 的整数");
|
||||
return;
|
||||
}
|
||||
void save({ lease_minutes: parsed });
|
||||
};
|
||||
|
||||
const saveMaxCycles = () => {
|
||||
const parsed = Number(maxCycles);
|
||||
if (!Number.isInteger(parsed) || parsed < 2 || parsed > 100) {
|
||||
@@ -111,7 +130,7 @@ export function RoundtableRoomSettingsPage() {
|
||||
会商并发与收口
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
配置第二步的全站并发名额、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
|
||||
配置第二步的全站并发名额、房间租约、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -187,6 +206,39 @@ export function RoundtableRoomSettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
|
||||
<label className="space-y-1">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Clock3Icon className="size-4" />
|
||||
房间租约
|
||||
</span>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
默认 5 分钟,可改为 1 到 60 分钟。页面大约每 15 秒续一次;超过这个时间没有续上,房间会释放。保存后,下一次准入和心跳立即按新时长续租。它不会延长单个房间的最长占用时间。
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={leaseMinutes}
|
||||
disabled={isError || update.isPending}
|
||||
onChange={(event) => setLeaseMinutes(event.target.value)}
|
||||
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
|
||||
aria-label="会商房间租约分钟数"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">分钟</span>
|
||||
</div>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isError || update.isPending}
|
||||
onClick={saveLeaseMinutes}
|
||||
>
|
||||
保存租约
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
|
||||
<label className="space-y-1">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BarChart3Icon,
|
||||
BrainIcon,
|
||||
BookPlusIcon,
|
||||
CpuIcon,
|
||||
DatabaseIcon,
|
||||
ArchiveIcon,
|
||||
@@ -32,6 +33,7 @@ import { AccountSettingsPage } from "@/components/workspace/settings/account-set
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
|
||||
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
|
||||
import { CallMetricsSettingsPage } from "@/components/workspace/settings/call-metrics-settings-page";
|
||||
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
|
||||
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
|
||||
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
|
||||
@@ -39,6 +41,7 @@ import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-ar
|
||||
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
|
||||
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page";
|
||||
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
|
||||
import { KnowledgeBatchCreateSettingsPage } from "@/components/workspace/settings/knowledge-batch-create-settings-page";
|
||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
|
||||
@@ -60,9 +63,11 @@ type SettingsSection =
|
||||
| "chat-archive"
|
||||
| "roundtable-rooms"
|
||||
| "knowledge-retrieval"
|
||||
| "knowledge-batch-create"
|
||||
| "appIdentity"
|
||||
| "memory"
|
||||
| "fileWriting"
|
||||
| "callMetrics"
|
||||
| "retryKeepPartial"
|
||||
| "uploads"
|
||||
| "tools"
|
||||
@@ -115,10 +120,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{ id: "appIdentity", label: "系统名称", icon: TypeIcon },
|
||||
{ id: "memory", label: "问答记忆", icon: BrainIcon },
|
||||
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
|
||||
{ id: "callMetrics", label: "调用统计", icon: BarChart3Icon },
|
||||
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
|
||||
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
|
||||
]
|
||||
: []),
|
||||
{ id: "knowledge-batch-create", label: "批量新增知识库", icon: BookPlusIcon },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
@@ -210,6 +217,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "knowledge-retrieval" && isAdmin && (
|
||||
<KnowledgeRetrievalSettingsPage />
|
||||
)}
|
||||
{activeSection === "knowledge-batch-create" && (
|
||||
<KnowledgeBatchCreateSettingsPage />
|
||||
)}
|
||||
{activeSection === "appIdentity" && isAdmin && (
|
||||
<AppIdentitySettingsPage />
|
||||
)}
|
||||
@@ -219,6 +229,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "fileWriting" && isAdmin && (
|
||||
<WriteFileCompatibilitySettingsPage />
|
||||
)}
|
||||
{activeSection === "callMetrics" && isAdmin && (
|
||||
<CallMetricsSettingsPage />
|
||||
)}
|
||||
{activeSection === "retryKeepPartial" && isAdmin && (
|
||||
<RetryKeepPartialSettingsPage />
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DateRangePicker,
|
||||
type DateRangePickerProps,
|
||||
type DateRangeValue,
|
||||
} from "tdesign-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -74,6 +79,7 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import { filterSkillsByCreatedAt } from "@/core/skill-created-at-filter";
|
||||
import {
|
||||
useDeleteBuiltinSkill,
|
||||
useDeleteCustomSkill,
|
||||
@@ -99,6 +105,7 @@ import {
|
||||
import {
|
||||
usePublishSquares,
|
||||
useSkillCompressionSettings,
|
||||
useSkillVersioningSettings,
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
@@ -155,6 +162,19 @@ type SkillSort =
|
||||
| "name_asc"
|
||||
| "name_desc";
|
||||
|
||||
function formatLocalCalendarDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function recentCalendarRange(days: number, now = new Date()): [string, string] {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - Math.max(1, days) + 1);
|
||||
return [formatLocalCalendarDate(start), formatLocalCalendarDate(now)];
|
||||
}
|
||||
|
||||
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||||
const rescanSuccessToastDarkOptions = {
|
||||
classNames: {
|
||||
@@ -205,6 +225,7 @@ function SkillSettingsList({
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||
const [createdAtRange, setCreatedAtRange] = useState<DateRangeValue>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
|
||||
// {id} is owner_user_id; {label} the desensitized display name.
|
||||
@@ -227,6 +248,19 @@ function SkillSettingsList({
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const createdAtPresets = useMemo<DateRangePickerProps["presets"]>(() => {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return {
|
||||
今天: recentCalendarRange(1, now),
|
||||
最近7天: recentCalendarRange(7, now),
|
||||
最近30天: recentCalendarRange(30, now),
|
||||
本月: [
|
||||
formatLocalCalendarDate(monthStart),
|
||||
formatLocalCalendarDate(now),
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
|
||||
@@ -238,8 +272,16 @@ function SkillSettingsList({
|
||||
tagIds,
|
||||
sort,
|
||||
scope: isAdmin ? undefined : "mine",
|
||||
// The same catalogue query is also used while chatting. Always refetch
|
||||
// when Skill Management mounts so a just-invoked skill's counter is fresh.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const skillsInCreatedAtRange = useMemo(
|
||||
() => filterSkillsByCreatedAt(skills, createdAtRange),
|
||||
[skills, createdAtRange],
|
||||
);
|
||||
|
||||
// 把可见技能分类成 UI 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
@@ -248,7 +290,7 @@ function SkillSettingsList({
|
||||
const mine: Skill[] = [];
|
||||
const stale: Skill[] = [];
|
||||
const archived: Skill[] = [];
|
||||
for (const skill of skills) {
|
||||
for (const skill of skillsInCreatedAtRange) {
|
||||
const state = skill.state ?? "active";
|
||||
const isCustom = skill.category === "custom";
|
||||
const isOwnedByMe =
|
||||
@@ -279,7 +321,7 @@ function SkillSettingsList({
|
||||
staleVisible: stale,
|
||||
archivedVisible: archived,
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
}, [skillsInCreatedAtRange, currentUserId]);
|
||||
|
||||
// "mine" tab shows my own skills; every other tab value is a square id whose
|
||||
// public skills come from the published pool.
|
||||
@@ -334,7 +376,7 @@ function SkillSettingsList({
|
||||
}, [page, totalPages]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort, publisher?.id, filter]);
|
||||
}, [search, tagIds, sort, publisher?.id, filter, createdAtRange]);
|
||||
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the page never points at a dead tab.
|
||||
@@ -562,7 +604,20 @@ function SkillSettingsList({
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
||||
<DateRangePicker
|
||||
className="w-full sm:w-[300px]"
|
||||
value={createdAtRange}
|
||||
valueType="YYYY-MM-DD"
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
presets={createdAtPresets}
|
||||
presetsPlacement="bottom"
|
||||
clearable
|
||||
rangeInputProps={{ label: "创建时间" }}
|
||||
popupProps={{ overlayClassName: "skill-created-at-range-popup" }}
|
||||
onChange={setCreatedAtRange}
|
||||
/>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||
<SelectTrigger
|
||||
@@ -749,8 +804,8 @@ export function SkillRow({
|
||||
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
|
||||
toast.success(
|
||||
enabled
|
||||
? t.settings.skills.onlineSuccess
|
||||
: t.settings.skills.offlineSuccess,
|
||||
? t.settings.skills.onlineSuccess
|
||||
: t.settings.skills.offlineSuccess,
|
||||
);
|
||||
setConfirmOfflineOpen(false);
|
||||
} catch (err) {
|
||||
@@ -1076,8 +1131,8 @@ export function SkillRow({
|
||||
}}
|
||||
title={
|
||||
isOffline
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction
|
||||
}
|
||||
>
|
||||
{setEnabled.isPending ? (
|
||||
@@ -1088,8 +1143,8 @@ export function SkillRow({
|
||||
<PowerOff className="size-3.5" />
|
||||
)}
|
||||
{isOffline
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction}
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -1322,9 +1377,9 @@ export function SkillRow({
|
||||
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
|
||||
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.offlineConfirmDescription.replace(
|
||||
{t.settings.skills.offlineConfirmDescription.replace(
|
||||
"{name}",
|
||||
desensitize(skill.name),
|
||||
)}
|
||||
@@ -1345,7 +1400,7 @@ export function SkillRow({
|
||||
>
|
||||
{setEnabled.isPending
|
||||
? t.common.loading
|
||||
: t.settings.skills.offlineAction}
|
||||
: t.settings.skills.offlineAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1418,6 +1473,7 @@ function InstallSkillDialog({
|
||||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
const { data: skillVersioning } = useSkillVersioningSettings();
|
||||
const setNameZhMutation = useSetSkillNameZh();
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
|
||||
@@ -1506,10 +1562,18 @@ function InstallSkillDialog({
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (overwrite: boolean) => {
|
||||
async (
|
||||
overwrite: boolean,
|
||||
installMode?: "iterate" | "overwrite",
|
||||
) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||||
const result = await installMutation.mutateAsync({
|
||||
file,
|
||||
overwrite,
|
||||
targetCategory,
|
||||
installMode,
|
||||
});
|
||||
toast.success(
|
||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||
"{name}",
|
||||
@@ -1564,6 +1628,10 @@ function InstallSkillDialog({
|
||||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||
const showIterationChoices =
|
||||
skillVersioning?.enabled === true &&
|
||||
targetCategory === "custom" &&
|
||||
ownCustomCanOverwrite;
|
||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
|
||||
const unclaimedCustomConflict =
|
||||
@@ -1789,6 +1857,12 @@ function InstallSkillDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showIterationChoices && (
|
||||
<div className="bg-muted/40 mt-1 rounded-md border px-3 py-2 text-xs leading-relaxed">
|
||||
<span className="font-medium">请选择更新方式:</span>
|
||||
迭代会保留旧版本并把本次上传设为最新版;直接覆盖会清除旧版本链,从本次上传重新作为第 1 版记录。两种方式都不会清零技能调用次数。
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
@@ -1810,27 +1884,45 @@ function InstallSkillDialog({
|
||||
>
|
||||
{dialog.cancel}
|
||||
</Button>
|
||||
{canOverwrite && (
|
||||
{showIterationChoices && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleInstall(true)}
|
||||
onClick={() => void handleInstall(true, "iterate")}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{dialog.replace}
|
||||
迭代为新版本
|
||||
</Button>
|
||||
)}
|
||||
{canOverwrite && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
void handleInstall(
|
||||
true,
|
||||
showIterationChoices ? "overwrite" : undefined,
|
||||
)
|
||||
}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{showIterationChoices ? "直接覆盖" : dialog.replace}
|
||||
</Button>
|
||||
)}
|
||||
{!showIterationChoices && (
|
||||
<Button
|
||||
onClick={() => void handleInstall(false)}
|
||||
disabled={!canInstall || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleInstall(false)}
|
||||
disabled={!canInstall || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,14 +7,17 @@ import {
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
HistoryIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
UploadCloudIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Dialog as TDesignDialog } from "tdesign-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,15 +41,23 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useGenerateSkillNameZh,
|
||||
useActivateSkillVersion,
|
||||
useInstallSkillUpload,
|
||||
useSetSkillAlwaysOn,
|
||||
useSetSkillDetail,
|
||||
useSetSkillNameZh,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
useSkillVersions,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
useSkillCompressionSettings,
|
||||
useSkillVersioningSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
@@ -109,7 +120,11 @@ export function SkillDetailDialog({
|
||||
const setNameZh = useSetSkillNameZh();
|
||||
const generateNameZh = useGenerateSkillNameZh();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const activateVersion = useActivateSkillVersion();
|
||||
const validateIteration = useValidateSkillUpload();
|
||||
const installIteration = useInstallSkillUpload();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const { data: versioning } = useSkillVersioningSettings();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -118,10 +133,15 @@ export function SkillDetailDialog({
|
||||
// Optional model override for the "AI 生成" button (null = configured default).
|
||||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
"overview" | "detail" | "display" | "source" | "files" | "versions" | null
|
||||
>(null);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||
const [versionToActivate, setVersionToActivate] = useState<number | null>(null);
|
||||
const [iterationFile, setIterationFile] = useState<File | null>(null);
|
||||
const iterationFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const confirmationOpen =
|
||||
versionToActivate !== null || iterationFile !== null;
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||
@@ -132,6 +152,12 @@ export function SkillDetailDialog({
|
||||
if (!skill) return false;
|
||||
return isOwner || getStoredAuth()?.system_role === "admin";
|
||||
}, [skill, isOwner]);
|
||||
const canUseVersions =
|
||||
versioning?.enabled === true &&
|
||||
skill?.category === "custom" &&
|
||||
!skill.builtin &&
|
||||
canManage;
|
||||
const canIterate = canUseVersions && isOwner;
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
@@ -144,6 +170,12 @@ export function SkillDetailDialog({
|
||||
isLoading: isFilesLoading,
|
||||
error: filesError,
|
||||
} = useSkillFiles(filesEnabled ? skillName : null);
|
||||
const versionsEnabled = open && canUseVersions && activeTab === "versions";
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: isVersionsLoading,
|
||||
error: versionsError,
|
||||
} = useSkillVersions(versionsEnabled ? skillName : null, versionsEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
@@ -151,17 +183,23 @@ export function SkillDetailDialog({
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
setOpenedFilePath(null);
|
||||
setVersionToActivate(null);
|
||||
setIterationFile(null);
|
||||
}, [open, skillName]);
|
||||
|
||||
useEffect(() => {
|
||||
setNameZhDraft(skill?.name_zh ?? "");
|
||||
}, [skill?.name, skill?.name_zh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseVersions && activeTab === "versions") setActiveTab("overview");
|
||||
}, [activeTab, canUseVersions]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" = "detail";
|
||||
const defaultTab: "overview" = "overview";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
@@ -218,11 +256,80 @@ export function SkillDetailDialog({
|
||||
toast.success("Copied SKILL.md content");
|
||||
}
|
||||
|
||||
function handleActivateVersion(version: number) {
|
||||
if (!skill || version === versionsData?.active_version) return;
|
||||
setVersionToActivate(version);
|
||||
}
|
||||
|
||||
async function confirmActivateVersion() {
|
||||
if (!skill || versionToActivate === null) return;
|
||||
try {
|
||||
await activateVersion.mutateAsync({
|
||||
skillName: skill.name,
|
||||
version: versionToActivate,
|
||||
});
|
||||
toast.success(`已切换到 v${versionToActivate}`);
|
||||
setVersionToActivate(null);
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "版本切换失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIterationFile(file: File | undefined) {
|
||||
if (!file || !skill || !canIterate) return;
|
||||
try {
|
||||
const validation = await validateIteration.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
if (
|
||||
validation.skill_name !== skill.name ||
|
||||
validation.conflict_kind !== "custom" ||
|
||||
!validation.owned_by_me ||
|
||||
!validation.can_overwrite
|
||||
) {
|
||||
throw new Error(`请选择与当前技能同名的安装包:${skill.name}`);
|
||||
}
|
||||
setIterationFile(file);
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
|
||||
} finally {
|
||||
if (iterationFileRef.current) iterationFileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmIteration() {
|
||||
if (!iterationFile) return;
|
||||
try {
|
||||
await installIteration.mutateAsync({
|
||||
file: iterationFile,
|
||||
overwrite: true,
|
||||
targetCategory: "custom",
|
||||
installMode: "iterate",
|
||||
});
|
||||
toast.success("已迭代为新版本");
|
||||
setIterationFile(null);
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetailOpenChange(nextOpen: boolean) {
|
||||
// TDesign renders its modal through a portal. While it is open, Radix sees
|
||||
// clicks inside that portal as interactions outside this detail dialog.
|
||||
// Keep the parent mounted until the nested confirmation has finished.
|
||||
if (!nextOpen && confirmationOpen) return;
|
||||
onOpenChange(nextOpen);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleDetailOpenChange}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
onInteractOutside={(event) => {
|
||||
if (confirmationOpen) event.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
width: "min(48rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
@@ -345,8 +452,10 @@ export function SkillDetailDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
{skill?.description ? desensitize(skill.description) : "-"}
|
||||
<DialogDescription className="sr-only">
|
||||
{skill?.description
|
||||
? desensitize(skill.description)
|
||||
: tx.introEmpty}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +477,11 @@ export function SkillDetailDialog({
|
||||
className="flex min-h-0 flex-1 flex-col gap-0"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsList className="h-8 min-w-0 max-w-full overflow-x-auto">
|
||||
<TabsTrigger value="overview" className="text-xs">
|
||||
<SparklesIcon className="mr-1 size-3.5" />
|
||||
{tx.introSectionTitle}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="detail" className="text-xs">
|
||||
<FileTextIcon className="mr-1 size-3.5" />
|
||||
{tx.detailSectionTitle}
|
||||
@@ -385,9 +498,45 @@ export function SkillDetailDialog({
|
||||
{tx.filesSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canUseVersions && (
|
||||
<TabsTrigger value="versions" className="text-xs">
|
||||
<HistoryIcon className="mr-1 size-3.5" />
|
||||
版本
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canIterate && (
|
||||
<>
|
||||
<input
|
||||
ref={iterationFileRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) =>
|
||||
void handleIterationFile(event.currentTarget.files?.[0])
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={
|
||||
validateIteration.isPending ||
|
||||
installIteration.isPending
|
||||
}
|
||||
onClick={() => iterationFileRef.current?.click()}
|
||||
>
|
||||
{validateIteration.isPending || installIteration.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<UploadCloudIcon className="size-3.5" />
|
||||
)}
|
||||
迭代新版本
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canManage && compression?.enabled && skill && (
|
||||
<div
|
||||
className="mr-1 flex items-center gap-1.5"
|
||||
@@ -460,6 +609,25 @@ export function SkillDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="overview"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{skill.description ? (
|
||||
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
|
||||
<p className="text-sm leading-7 whitespace-pre-wrap">
|
||||
{desensitize(skill.description)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<SparklesIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.introEmpty}</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="detail"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
@@ -554,6 +722,80 @@ export function SkillDetailDialog({
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canUseVersions && (
|
||||
<TabsContent
|
||||
value="versions"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<div className="mb-4 rounded-lg border bg-muted/30 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
版本切换只替换当前生效的完整技能包,历史版本不会进入大模型上下文;技能名称和累计调用次数保持不变。
|
||||
</div>
|
||||
{isVersionsLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : versionsError ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{versionsError instanceof Error
|
||||
? versionsError.message
|
||||
: String(versionsError)}
|
||||
</div>
|
||||
) : versionsData?.versions.length ? (
|
||||
<div className="space-y-3">
|
||||
{versionsData.versions.map((version) => (
|
||||
<div
|
||||
key={version.version}
|
||||
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
v{version.version}
|
||||
</span>
|
||||
{version.active && (
|
||||
<Badge className="text-[11px]">当前生效</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDateTime(version.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm">
|
||||
{version.summary || "技能内容更新"}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 font-mono text-[11px]">
|
||||
{version.sha256.slice(0, 12)}
|
||||
{version.based_on_version
|
||||
? ` · 基于 v${version.based_on_version}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
{!version.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
disabled={activateVersion.isPending}
|
||||
onClick={() => void handleActivateVersion(version.version)}
|
||||
>
|
||||
{activateVersion.isPending && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
切换到此版本
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-10 text-center text-sm">
|
||||
暂无版本记录
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
@@ -597,6 +839,59 @@ export function SkillDetailDialog({
|
||||
if (!next) setOpenedFilePath(null);
|
||||
}}
|
||||
/>
|
||||
<TDesignDialog
|
||||
visible={versionToActivate !== null}
|
||||
header="切换技能版本"
|
||||
placement="center"
|
||||
width={440}
|
||||
zIndex={3200}
|
||||
confirmBtn={{ content: "确定切换", theme: "primary" }}
|
||||
cancelBtn={{ content: "取消", disabled: activateVersion.isPending }}
|
||||
confirmLoading={activateVersion.isPending}
|
||||
closeBtn={!activateVersion.isPending}
|
||||
closeOnEscKeydown={!activateVersion.isPending}
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
onClose={() => {
|
||||
if (!activateVersion.isPending) setVersionToActivate(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!activateVersion.isPending) setVersionToActivate(null);
|
||||
}}
|
||||
onConfirm={() => void confirmActivateVersion()}
|
||||
>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
确认切换到 v{versionToActivate ?? ""}?切换后大模型只会看到该版本,技能调用次数保持不变。
|
||||
</p>
|
||||
</TDesignDialog>
|
||||
<TDesignDialog
|
||||
visible={iterationFile !== null}
|
||||
header="迭代技能版本"
|
||||
placement="center"
|
||||
width={440}
|
||||
zIndex={3200}
|
||||
confirmBtn={{ content: "确定迭代", theme: "primary" }}
|
||||
cancelBtn={{ content: "取消", disabled: installIteration.isPending }}
|
||||
confirmLoading={installIteration.isPending}
|
||||
closeBtn={!installIteration.isPending}
|
||||
closeOnEscKeydown={!installIteration.isPending}
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
onClose={() => {
|
||||
if (!installIteration.isPending) setIterationFile(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!installIteration.isPending) setIterationFile(null);
|
||||
}}
|
||||
onConfirm={() => void confirmIteration()}
|
||||
>
|
||||
<div className="space-y-2 text-sm leading-6 text-muted-foreground">
|
||||
<p>确认将此安装包迭代为新版本?当前版本会完整保留,技能调用次数保持不变。</p>
|
||||
<p className="truncate text-xs" title={iterationFile?.name}>
|
||||
安装包:{iterationFile?.name}
|
||||
</p>
|
||||
</div>
|
||||
</TDesignDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,18 @@ import { useEffect } from "react";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useAppSystemName } from "@/core/system-settings/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
|
||||
export function ThreadTitle({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
export function ThreadTitle({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
@@ -51,9 +51,9 @@ export function ThreadTitle({
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
|
||||
<span className="block truncate" title={visibleTitle}>
|
||||
{visibleTitle}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useMemo } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AuroraText } from "../ui/aurora-text";
|
||||
@@ -12,11 +14,15 @@ import { AuroraText } from "../ui/aurora-text";
|
||||
export function Welcome({
|
||||
className,
|
||||
mode,
|
||||
pageDescription,
|
||||
}: {
|
||||
className?: string;
|
||||
mode?: "ultra" | "pro" | "thinking" | "flash";
|
||||
/** The actual interface mode is resolved by ChatPage. */
|
||||
pageDescription?: GeneralQaPageDescription;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const searchParams = useSearchParams();
|
||||
const isUltra = useMemo(() => mode === "ultra", [mode]);
|
||||
@@ -26,6 +32,9 @@ export function Welcome({
|
||||
}
|
||||
return ["var(--color-foreground)"];
|
||||
}, [isUltra]);
|
||||
const configured = pageDescription ?? pageDescriptions?.general_qa.full;
|
||||
const normalTitle = configured?.welcome_title || t.welcome.greeting;
|
||||
const normalDescription = configured?.welcome_description || "";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -38,7 +47,7 @@ export function Welcome({
|
||||
t.welcome.createYourOwnSkill
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
|
||||
<AuroraText colors={colors}>{desensitizeText(normalTitle)}</AuroraText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -53,15 +62,11 @@ export function Welcome({
|
||||
)} */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{/* {t.welcome.description.includes("\n") ? (
|
||||
<pre className="font-sans whitespace-pre">
|
||||
{t.welcome.description}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{t.welcome.description}</p>
|
||||
)} */}
|
||||
</div>
|
||||
normalDescription ? (
|
||||
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
|
||||
{desensitizeText(normalDescription)}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Compass,
|
||||
CpuIcon,
|
||||
DatabaseZapIcon,
|
||||
FileTextIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
ListTreeIcon,
|
||||
@@ -318,6 +319,16 @@ export function WorkspaceNavMenu() {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/page-descriptions")
|
||||
}
|
||||
>
|
||||
<FileTextIcon />
|
||||
页面描述配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
|
||||
|
||||
@@ -94,6 +94,33 @@ export interface AdminLeaderboardDimension {
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export type AdminLeaderboardDetailKind = "skill" | "tool" | "agent";
|
||||
|
||||
export interface AdminLeaderboardDetailItem {
|
||||
name: string;
|
||||
count: number;
|
||||
user_count: number;
|
||||
total_tokens: number;
|
||||
avg_duration_ms: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardDetailSummary {
|
||||
count: number;
|
||||
item_count: number;
|
||||
user_count: number;
|
||||
total_tokens: number;
|
||||
avg_duration_ms: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardDetailResponse {
|
||||
kind: AdminLeaderboardDetailKind;
|
||||
range_days: number;
|
||||
since: string;
|
||||
until: string;
|
||||
summary: AdminLeaderboardDetailSummary;
|
||||
items: AdminLeaderboardDetailItem[];
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardTrendPoint {
|
||||
date: string;
|
||||
runs: number;
|
||||
@@ -248,6 +275,37 @@ export async function getUserLeaderboard(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getUserLeaderboardDetails(
|
||||
kind: AdminLeaderboardDetailKind,
|
||||
range: {
|
||||
since: string;
|
||||
until: string;
|
||||
days?: number;
|
||||
},
|
||||
limit = 500,
|
||||
): Promise<AdminLeaderboardDetailResponse> {
|
||||
const params = new URLSearchParams({
|
||||
kind,
|
||||
days: String(range.days ?? 30),
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
limit: String(limit),
|
||||
});
|
||||
const url = `${base()}/leaderboard/details?${params}`;
|
||||
const result = await parse<AdminLeaderboardDetailResponse>(
|
||||
await apiFetch(url),
|
||||
"Failed to load leaderboard details",
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "CM助手-全量-管理后台-查询排行榜维度详情",
|
||||
url,
|
||||
reqParam: { kind, ...range, limit },
|
||||
returnParam: { itemCount: result.items.length },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
|
||||
* real-time records are included, then return the leaderboard for `range`.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generateUserLeaderboardReport,
|
||||
getUserMemory,
|
||||
getUserLeaderboard,
|
||||
getUserLeaderboardDetails,
|
||||
refreshTodayLeaderboard,
|
||||
listUserQa,
|
||||
listUserThreadMessages,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
updateAdminV1Fact,
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
import type { AdminLeaderboardDetailKind } from "./api";
|
||||
import {
|
||||
cancelActiveRun,
|
||||
cancelAllActiveRuns,
|
||||
@@ -131,6 +133,30 @@ export function useUserLeaderboard(
|
||||
});
|
||||
}
|
||||
|
||||
/** Expanded, fresh detail data shown from a leaderboard card's “more” drawer. */
|
||||
export function useUserLeaderboardDetails(
|
||||
kind: AdminLeaderboardDetailKind | null,
|
||||
range: {
|
||||
since: string;
|
||||
until: string;
|
||||
days?: number;
|
||||
},
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"admin-activity",
|
||||
"leaderboard-details",
|
||||
kind,
|
||||
range.since,
|
||||
range.until,
|
||||
],
|
||||
queryFn: () => getUserLeaderboardDetails(kind!, range),
|
||||
enabled: enabled && !!kind,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-rebuild today's snapshot, then prime + invalidate the matching
|
||||
* leaderboard query so the page shows the freshly recomputed numbers.
|
||||
|
||||
@@ -53,7 +53,17 @@ export async function downloadArtifactFile({
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
|
||||
const isHtmlArtifact = /\.(?:html?|xhtml)$/i.test(getFileName(filepath));
|
||||
const url = urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
download: true,
|
||||
// Older deployments and mock endpoints safely ignore this query and return
|
||||
// the original HTML, which preserves backwards compatibility.
|
||||
offlineBundle: isHtmlArtifact && !isMock,
|
||||
});
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
|
||||
@@ -5,28 +5,35 @@ export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
offlineBundle = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
download?: boolean;
|
||||
offlineBundle?: boolean;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (download) query.set("download", "true");
|
||||
if (offlineBundle) query.set("offline_bundle", "true");
|
||||
const queryString = query.toString();
|
||||
const suffix = queryString ? `?${queryString}` : "";
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
)}/files${normalized}${suffix}`;
|
||||
}
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${suffix}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${suffix}`;
|
||||
}
|
||||
|
||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearHeaderTitlePreview,
|
||||
getHeaderTitlePreview,
|
||||
setHeaderTitlePreview,
|
||||
subscribeHeaderTitlePreview,
|
||||
} from "./header-title-preview";
|
||||
|
||||
test("publishes live header title previews and clears unsaved values", () => {
|
||||
clearHeaderTitlePreview();
|
||||
let updates = 0;
|
||||
const unsubscribe = subscribeHeaderTitlePreview(() => {
|
||||
updates += 1;
|
||||
});
|
||||
|
||||
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
|
||||
assert.deepEqual(getHeaderTitlePreview(), {
|
||||
mode: "fixed",
|
||||
fontSize: 34,
|
||||
});
|
||||
assert.equal(updates, 1);
|
||||
|
||||
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
|
||||
assert.equal(updates, 1);
|
||||
|
||||
clearHeaderTitlePreview();
|
||||
assert.equal(getHeaderTitlePreview(), null);
|
||||
assert.equal(updates, 2);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export type HeaderTitlePreview = {
|
||||
mode: "adaptive" | "fixed";
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
let currentPreview: HeaderTitlePreview | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getHeaderTitlePreview(): HeaderTitlePreview | null {
|
||||
return currentPreview;
|
||||
}
|
||||
|
||||
export function subscribeHeaderTitlePreview(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setHeaderTitlePreview(preview: HeaderTitlePreview): void {
|
||||
if (
|
||||
currentPreview?.mode === preview.mode &&
|
||||
currentPreview.fontSize === preview.fontSize
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentPreview = { ...preview };
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function clearHeaderTitlePreview(): void {
|
||||
if (currentPreview === null) {
|
||||
return;
|
||||
}
|
||||
currentPreview = null;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearLightRedNavigationPreview,
|
||||
getLightRedNavigationPreview,
|
||||
setLightRedNavigationPreview,
|
||||
subscribeLightRedNavigationPreview,
|
||||
} from "./light-red-navigation-preview";
|
||||
|
||||
test("publishes temporary red navigation image URLs and clears them", () => {
|
||||
clearLightRedNavigationPreview();
|
||||
let updates = 0;
|
||||
const unsubscribe = subscribeLightRedNavigationPreview(() => {
|
||||
updates += 1;
|
||||
});
|
||||
|
||||
setLightRedNavigationPreview({
|
||||
headerBackgroundUrl: "blob:header",
|
||||
navActiveUrl: "blob:active",
|
||||
});
|
||||
assert.deepEqual(getLightRedNavigationPreview(), {
|
||||
headerBackgroundUrl: "blob:header",
|
||||
navActiveUrl: "blob:active",
|
||||
});
|
||||
assert.equal(updates, 1);
|
||||
|
||||
clearLightRedNavigationPreview();
|
||||
assert.equal(getLightRedNavigationPreview(), null);
|
||||
assert.equal(updates, 2);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface LightRedNavigationPreview {
|
||||
headerBackgroundUrl: string | null;
|
||||
navActiveUrl: string | null;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let currentPreview: LightRedNavigationPreview | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function getLightRedNavigationPreview() {
|
||||
return currentPreview;
|
||||
}
|
||||
|
||||
export function subscribeLightRedNavigationPreview(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setLightRedNavigationPreview(
|
||||
preview: LightRedNavigationPreview,
|
||||
) {
|
||||
if (
|
||||
currentPreview?.headerBackgroundUrl === preview.headerBackgroundUrl &&
|
||||
currentPreview.navActiveUrl === preview.navActiveUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentPreview = { ...preview };
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function clearLightRedNavigationPreview() {
|
||||
if (currentPreview === null) return;
|
||||
currentPreview = null;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
@@ -498,7 +498,7 @@ export const enUS: Translations = {
|
||||
badgePublishedTitle: "Visible to all users",
|
||||
badgeUnpublished: "Unpublished",
|
||||
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
|
||||
badgeOffline: "Offline",
|
||||
badgeOffline: "Offline",
|
||||
badgeOfflineTitle: "Retained in the system but unavailable to models",
|
||||
badgeStale: "Stale",
|
||||
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
||||
@@ -541,6 +541,8 @@ export const enUS: Translations = {
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
deleteSuccess: "Skill deleted",
|
||||
introSectionTitle: "Overview",
|
||||
introEmpty: "No skill overview yet.",
|
||||
detailSectionTitle: "Details",
|
||||
detailEmpty: "No details yet.",
|
||||
detailEditAction: "Edit details",
|
||||
|
||||
@@ -471,6 +471,8 @@ export interface Translations {
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
introSectionTitle: string;
|
||||
introEmpty: string;
|
||||
detailSectionTitle: string;
|
||||
detailEmpty: string;
|
||||
detailEditAction: string;
|
||||
|
||||
@@ -477,8 +477,8 @@ export const zhCN: Translations = {
|
||||
badgePublishedTitle: "该技能已对所有用户可见",
|
||||
badgeUnpublished: "未发布",
|
||||
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
|
||||
badgeOffline: "已下线",
|
||||
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
|
||||
badgeOffline: "已下线",
|
||||
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
|
||||
badgeStale: "过时",
|
||||
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||
badgeArchived: "归档",
|
||||
@@ -519,6 +519,8 @@ export const zhCN: Translations = {
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
introSectionTitle: "简介",
|
||||
introEmpty: "暂无技能简介。",
|
||||
detailSectionTitle: "详情",
|
||||
detailEmpty: "暂无详情说明。",
|
||||
detailEditAction: "编辑详情",
|
||||
|
||||
@@ -24,6 +24,9 @@ export type HtmlPageConfig = {
|
||||
model_name?: string | null;
|
||||
/** 仅当参考选了内置模板(tpl:<id>)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */
|
||||
template_extract?: boolean;
|
||||
/** 仅当参考选了内置模板(tpl:<id>)且开启抽取时生效:抽取结果写回模板 data.json
|
||||
* (默认数据持久化,页面/后续任务沿用);系统名称等 MANIFEST 不受影响。 */
|
||||
template_data_writeback?: boolean;
|
||||
};
|
||||
|
||||
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-expect-error Node's type-stripping runner requires the explicit suffix.
|
||||
import { filterSkillsByCreatedAt } from "./skill-created-at-filter.ts";
|
||||
|
||||
const skills = [
|
||||
{ name: "before", created_at: "2026-10-31T23:59:59" },
|
||||
{ name: "start", created_at: "2026-11-01T00:00:00" },
|
||||
{ name: "middle", created_at: "2026-11-05T12:00:00" },
|
||||
{ name: "end", created_at: "2026-11-07T23:59:59" },
|
||||
{ name: "after", created_at: "2026-11-08T00:00:00" },
|
||||
{ name: "unknown", created_at: null },
|
||||
];
|
||||
|
||||
test("keeps the original list when no complete range is selected", () => {
|
||||
assert.equal(filterSkillsByCreatedAt(skills, []), skills);
|
||||
assert.equal(filterSkillsByCreatedAt(skills, ["2026-11-01"]), skills);
|
||||
});
|
||||
|
||||
test("filters creation time inclusively by local calendar day", () => {
|
||||
assert.deepEqual(
|
||||
filterSkillsByCreatedAt(skills, ["2026-11-01", "2026-11-07"]).map(
|
||||
(skill) => skill.name,
|
||||
),
|
||||
["start", "middle", "end"],
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a reversed range without changing its meaning", () => {
|
||||
assert.deepEqual(
|
||||
filterSkillsByCreatedAt(skills, ["2026-11-07", "2026-11-01"]).map(
|
||||
(skill) => skill.name,
|
||||
),
|
||||
["start", "middle", "end"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Skill } from "./skills/type";
|
||||
|
||||
export type SkillCreatedAtRange = readonly (string | number | Date)[];
|
||||
|
||||
function localDayStart(value: string | number | Date) {
|
||||
let date: Date;
|
||||
if (typeof value === "string") {
|
||||
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
date = dateOnly
|
||||
? new Date(
|
||||
Number(dateOnly[1]),
|
||||
Number(dateOnly[2]) - 1,
|
||||
Number(dateOnly[3]),
|
||||
)
|
||||
: new Date(value);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
/** Filter skills by an inclusive local-calendar creation date range. */
|
||||
export function filterSkillsByCreatedAt<T extends Pick<Skill, "created_at">>(
|
||||
skills: T[],
|
||||
range: SkillCreatedAtRange,
|
||||
): T[] {
|
||||
if (range.length < 2) return skills;
|
||||
const first = localDayStart(range[0]);
|
||||
const second = localDayStart(range[1]);
|
||||
if (first === null || second === null) return skills;
|
||||
const start = Math.min(first, second);
|
||||
const endDate = new Date(Math.max(first, second));
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
const end = endDate.getTime();
|
||||
return skills.filter((skill) => {
|
||||
if (!skill.created_at) return false;
|
||||
const createdAt = new Date(skill.created_at).getTime();
|
||||
return !Number.isNaN(createdAt) && createdAt >= start && createdAt <= end;
|
||||
});
|
||||
}
|
||||
@@ -159,6 +159,24 @@ export interface InstallSkillResponse {
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillVersionEntry {
|
||||
version: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
created_by?: string | null;
|
||||
action: string;
|
||||
summary?: string | null;
|
||||
based_on_version?: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SkillVersionsResponse {
|
||||
enabled: boolean;
|
||||
skill_name: string;
|
||||
active_version?: number | null;
|
||||
versions: SkillVersionEntry[];
|
||||
}
|
||||
|
||||
export interface PreviewSkillInstallRequest {
|
||||
thread_id: string;
|
||||
@@ -271,13 +289,18 @@ export async function validateSkillUpload(
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||
options: {
|
||||
overwrite?: boolean;
|
||||
targetCategory?: "public" | "custom";
|
||||
installMode?: "iterate" | "overwrite";
|
||||
} = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const params = new URLSearchParams();
|
||||
if (options.overwrite) params.set("overwrite", "true");
|
||||
if (options.targetCategory) params.set("target_category", options.targetCategory);
|
||||
if (options.installMode) params.set("install_mode", options.installMode);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||
@@ -289,6 +312,32 @@ export async function installSkillUpload(
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillVersions(
|
||||
skillName: string,
|
||||
): Promise<SkillVersionsResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill versions");
|
||||
}
|
||||
return response.json() as Promise<SkillVersionsResponse>;
|
||||
}
|
||||
|
||||
export async function activateSkillVersion(
|
||||
skillName: string,
|
||||
version: number,
|
||||
): Promise<SkillVersionsResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions/${version}/activate`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to activate skill version");
|
||||
}
|
||||
return response.json() as Promise<SkillVersionsResponse>;
|
||||
}
|
||||
|
||||
export async function duplicateSkill(
|
||||
sourceName: string,
|
||||
newName: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deleteCustomSkill,
|
||||
deleteSkillFile,
|
||||
duplicateSkill,
|
||||
activateSkillVersion,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
generateSkillNameZh,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getSkillContent,
|
||||
getSkillFileContent,
|
||||
getSkillFiles,
|
||||
getSkillVersions,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
@@ -41,17 +43,24 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
|
||||
const EMPTY_SKILLS: Skill[] = [];
|
||||
|
||||
export function useSkills(params: ListSkillsParams & { enabled?: boolean } = {}) {
|
||||
export function useSkills(
|
||||
params: ListSkillsParams & {
|
||||
enabled?: boolean;
|
||||
refetchOnMount?: boolean | "always";
|
||||
} = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const scope = params.scope;
|
||||
const enabled = params.enabled ?? true;
|
||||
const refetchOnMount = params.refetchOnMount;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
||||
@@ -224,10 +233,49 @@ export function useValidateSkillUpload() {
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||
installSkillUpload(file, { overwrite, targetCategory }),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ file, overwrite, targetCategory, installMode }: {
|
||||
file: File;
|
||||
overwrite?: boolean;
|
||||
targetCategory?: "public" | "custom";
|
||||
installMode?: "iterate" | "overwrite";
|
||||
}) => installSkillUpload(file, { overwrite, targetCategory, installMode }),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["skill-versions", result.skill_name],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["skill", result.skill_name],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["skill-content", result.skill_name],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["skill-files", result.skill_name],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillVersions(skillName: string | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-versions", skillName],
|
||||
queryFn: () => getSkillVersions(skillName!),
|
||||
enabled: enabled && !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateSkillVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, version }: { skillName: string; version: number }) =>
|
||||
activateSkillVersion(skillName, version),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill-versions", skillName], data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,86 @@ export interface StrategyProjectSettingsUpdate {
|
||||
taiwan_strait_demo_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface GeneralQaPageDescription {
|
||||
welcome_title: string;
|
||||
welcome_description: string;
|
||||
input_placeholder: string;
|
||||
}
|
||||
|
||||
export interface GeneralQaPageDescriptions {
|
||||
full: GeneralQaPageDescription;
|
||||
compact: GeneralQaPageDescription;
|
||||
}
|
||||
|
||||
export interface PageHeaderDescription {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AIWritingGuideCard {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AIWritingPageDescription extends PageHeaderDescription {
|
||||
guide_cards: AIWritingGuideCard[];
|
||||
}
|
||||
|
||||
export interface PageDescriptionsSettings {
|
||||
general_qa: GeneralQaPageDescriptions;
|
||||
knowledge_base: PageHeaderDescription;
|
||||
ai_writing: AIWritingPageDescription;
|
||||
strategy_projects: PageHeaderDescription;
|
||||
deep_research: PageHeaderDescription;
|
||||
gongwen: PageHeaderDescription;
|
||||
light_app_management: PageHeaderDescription;
|
||||
light_app_list: PageHeaderDescription;
|
||||
agents: PageHeaderDescription;
|
||||
business_chains: PageHeaderDescription;
|
||||
skills: PageHeaderDescription;
|
||||
knowledge_management: PageHeaderDescription;
|
||||
}
|
||||
|
||||
export type PageDescriptionsUpdate = PageDescriptionsSettings;
|
||||
|
||||
export async function loadPageDescriptionsSettings(): Promise<PageDescriptionsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`无法读取页面描述配置(HTTP ${response.status})`);
|
||||
}
|
||||
return (await response.json()) as PageDescriptionsSettings;
|
||||
}
|
||||
|
||||
export async function updatePageDescriptionsSettings(
|
||||
body: PageDescriptionsUpdate,
|
||||
): Promise<PageDescriptionsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "保存页面描述配置失败" }));
|
||||
throw new Error(detail.detail ?? "保存页面描述配置失败");
|
||||
}
|
||||
const data = (await response.json()) as PageDescriptionsSettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-页面描述配置编辑",
|
||||
url: "/api/system-settings/page-descriptions",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||
@@ -311,17 +391,33 @@ export interface HtmlRuntimeSettings {
|
||||
map_tile_url: string;
|
||||
}
|
||||
|
||||
export type AppIdentityHeaderTitleFontMode = "adaptive" | "fixed";
|
||||
|
||||
export interface AppIdentitySettings {
|
||||
system_name: string;
|
||||
header_title_font_mode: AppIdentityHeaderTitleFontMode;
|
||||
header_title_font_size: number;
|
||||
logo_url: string | null;
|
||||
logo_version: string | null;
|
||||
has_logo: boolean;
|
||||
light_red_header_background_url: string | null;
|
||||
light_red_header_background_version: string | null;
|
||||
has_light_red_header_background: boolean;
|
||||
light_red_nav_active_url: string | null;
|
||||
light_red_nav_active_version: string | null;
|
||||
has_light_red_nav_active: boolean;
|
||||
}
|
||||
|
||||
export interface AppIdentityUpdate {
|
||||
systemName: string;
|
||||
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
|
||||
headerTitleFontSize: number;
|
||||
logo?: File | null;
|
||||
removeLogo?: boolean;
|
||||
lightRedHeaderBackground?: File | null;
|
||||
removeLightRedHeaderBackground?: boolean;
|
||||
lightRedNavActive?: File | null;
|
||||
removeLightRedNavActive?: boolean;
|
||||
}
|
||||
|
||||
export interface LeaderboardSettings {
|
||||
@@ -721,8 +817,22 @@ export async function updateAppIdentitySettings(
|
||||
): Promise<AppIdentitySettings> {
|
||||
const body = new FormData();
|
||||
body.set("system_name", update.systemName);
|
||||
body.set("header_title_font_mode", update.headerTitleFontMode);
|
||||
body.set("header_title_font_size", String(update.headerTitleFontSize));
|
||||
if (update.logo) body.set("logo", update.logo);
|
||||
if (update.removeLogo) body.set("remove_logo", "true");
|
||||
if (update.lightRedHeaderBackground) {
|
||||
body.set("light_red_header_background", update.lightRedHeaderBackground);
|
||||
}
|
||||
if (update.removeLightRedHeaderBackground) {
|
||||
body.set("remove_light_red_header_background", "true");
|
||||
}
|
||||
if (update.lightRedNavActive) {
|
||||
body.set("light_red_nav_active", update.lightRedNavActive);
|
||||
}
|
||||
if (update.removeLightRedNavActive) {
|
||||
body.set("remove_light_red_nav_active", "true");
|
||||
}
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/app-identity`,
|
||||
{ method: "PUT", body },
|
||||
@@ -740,8 +850,17 @@ export async function updateAppIdentitySettings(
|
||||
url: "/api/system-settings/app-identity",
|
||||
reqParam: {
|
||||
system_name: update.systemName,
|
||||
header_title_font_mode: update.headerTitleFontMode,
|
||||
header_title_font_size: update.headerTitleFontSize,
|
||||
logo_name: update.logo?.name,
|
||||
remove_logo: Boolean(update.removeLogo),
|
||||
light_red_header_background_name:
|
||||
update.lightRedHeaderBackground?.name,
|
||||
remove_light_red_header_background: Boolean(
|
||||
update.removeLightRedHeaderBackground,
|
||||
),
|
||||
light_red_nav_active_name: update.lightRedNavActive?.name,
|
||||
remove_light_red_nav_active: Boolean(update.removeLightRedNavActive),
|
||||
},
|
||||
returnParam: data,
|
||||
});
|
||||
@@ -772,10 +891,16 @@ export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings>
|
||||
export function resolveAppIdentityLogoUrl(
|
||||
logoUrl: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!logoUrl) return undefined;
|
||||
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
|
||||
return resolveAppIdentityAssetUrl(logoUrl);
|
||||
}
|
||||
|
||||
export function resolveAppIdentityAssetUrl(
|
||||
assetUrl: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!assetUrl) return undefined;
|
||||
if (/^https?:\/\//i.test(assetUrl)) return assetUrl;
|
||||
const base = getBackendBaseURL();
|
||||
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
|
||||
return `${base}${assetUrl.startsWith("/") ? "" : "/"}${assetUrl}`;
|
||||
}
|
||||
|
||||
export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
|
||||
@@ -867,6 +992,10 @@ export interface SkillCompressionUpdate {
|
||||
keep_index_in_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillVersioningSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
@@ -906,6 +1035,7 @@ export interface RoundtableRoomLimitSettings {
|
||||
enabled: boolean;
|
||||
max_rooms: number;
|
||||
max_hold_minutes: number;
|
||||
lease_minutes: number;
|
||||
max_cycles: number;
|
||||
write_file_compatibility_enabled: boolean;
|
||||
}
|
||||
@@ -914,6 +1044,7 @@ export interface RoundtableRoomLimitUpdate {
|
||||
enabled?: boolean;
|
||||
max_rooms?: number;
|
||||
max_hold_minutes?: number;
|
||||
lease_minutes?: number;
|
||||
max_cycles?: number;
|
||||
write_file_compatibility_enabled?: boolean;
|
||||
}
|
||||
@@ -1012,6 +1143,55 @@ export async function updateWriteFileCompatibilitySettings(
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface CallMetricsSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function loadCallMetricsSettings(): Promise<CallMetricsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`无法读取调用统计开关(HTTP ${response.status})`);
|
||||
}
|
||||
const data = (await response.json()) as CallMetricsSettings;
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-系统设置-调用统计查看",
|
||||
url: "/api/system-settings/call-metrics",
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateCallMetricsSettings(
|
||||
body: CallMetricsSettings,
|
||||
): Promise<CallMetricsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({
|
||||
detail: "保存调用统计开关失败",
|
||||
}));
|
||||
throw new Error(detail.detail ?? "保存调用统计开关失败");
|
||||
}
|
||||
const data = (await response.json()) as CallMetricsSettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-调用统计开关",
|
||||
url: "/api/system-settings/call-metrics",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadConversationContextSettings(): Promise<ConversationContextSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/conversation-context`,
|
||||
@@ -1155,6 +1335,25 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadSkillVersioningSettings(): Promise<SkillVersioningSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load skill versioning settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as SkillVersioningSettings;
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-系统设置-技能迭代查看",
|
||||
url: "/api/system-settings/skill-versioning",
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
|
||||
@@ -1230,6 +1429,34 @@ export async function updateSkillCompressionSettings(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSkillVersioningSettings(
|
||||
body: SkillVersioningSettings,
|
||||
): Promise<SkillVersioningSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update skill versioning" }));
|
||||
throw new Error(detail.detail ?? "Failed to update skill versioning");
|
||||
}
|
||||
const data = (await response.json()) as SkillVersioningSettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-技能迭代编辑",
|
||||
url: "/api/system-settings/skill-versioning",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
|
||||
export interface AgentModuleConfig {
|
||||
key: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadAgentModuleConfig,
|
||||
loadAssistantKnowledgeVisibility,
|
||||
loadAppearanceSettings,
|
||||
loadCallMetricsSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadConversationContextSettings,
|
||||
loadRoundtableRoomLimitSettings,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
loadLeaderboardSettings,
|
||||
loadModelConcurrencySettings,
|
||||
loadOrdinaryKnowledgeRetrievalSettings,
|
||||
loadPageDescriptionsSettings,
|
||||
loadMemoryRuntimeSettings,
|
||||
loadPluginManagementSettings,
|
||||
loadPrivateSquareStatus,
|
||||
@@ -22,11 +24,13 @@ import {
|
||||
loadPublishSquares,
|
||||
loadRetryKeepPartialSettings,
|
||||
loadSkillCompressionSettings,
|
||||
loadSkillVersioningSettings,
|
||||
loadStrategyProjectSettings,
|
||||
loadWxCommonAgents,
|
||||
loadWriteFileCompatibilitySettings,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCallMetricsSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateConversationContextSettings,
|
||||
updateRoundtableRoomLimitSettings,
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
updateLeaderboardSettings,
|
||||
updateModelConcurrencySettings,
|
||||
updateOrdinaryKnowledgeRetrievalSettings,
|
||||
updatePageDescriptionsSettings,
|
||||
updateMemoryRuntimeSettings,
|
||||
updatePluginManagementSettings,
|
||||
updatePrivateSquareSettings,
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
updatePublishSquares,
|
||||
updateRetryKeepPartialSettings,
|
||||
updateSkillCompressionSettings,
|
||||
updateSkillVersioningSettings,
|
||||
updateStrategyProjectSettings,
|
||||
updateWxCommonAgents,
|
||||
updateWriteFileCompatibilitySettings,
|
||||
@@ -50,6 +56,7 @@ import {
|
||||
deleteAppIdentitySettings,
|
||||
type AppearanceUpdate,
|
||||
type AppIdentityUpdate,
|
||||
type CallMetricsSettings,
|
||||
type CitationDisplayUpdate,
|
||||
type ConversationContextUpdate,
|
||||
type RoundtableRoomLimitUpdate,
|
||||
@@ -58,6 +65,7 @@ import {
|
||||
type LeaderboardSettingsUpdate,
|
||||
type ModelConcurrencyUpdate,
|
||||
type OrdinaryKnowledgeRetrievalUpdate,
|
||||
type PageDescriptionsUpdate,
|
||||
type MemoryRuntimeSettings,
|
||||
type PluginManagementUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
@@ -65,6 +73,7 @@ import {
|
||||
type PublishSquaresUpdate,
|
||||
type RetryKeepPartialSettings,
|
||||
type SkillCompressionUpdate,
|
||||
type SkillVersioningSettings,
|
||||
type StrategyProjectSettingsUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
@@ -81,12 +90,14 @@ const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
|
||||
const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const SKILL_VERSIONING_KEY = ["systemSettings", "skillVersioning"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
|
||||
const WRITE_FILE_COMPATIBILITY_KEY = [
|
||||
"systemSettings",
|
||||
"writeFileCompatibility",
|
||||
] as const;
|
||||
const CALL_METRICS_KEY = ["systemSettings", "callMetrics"] as const;
|
||||
const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const;
|
||||
const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const;
|
||||
const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
|
||||
@@ -95,6 +106,7 @@ const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
|
||||
] as const;
|
||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||
const PAGE_DESCRIPTIONS_KEY = ["systemSettings", "pageDescriptions"] as const;
|
||||
export const ASSISTANT_KNOWLEDGE_VISIBILITY_KEY = [
|
||||
"system-settings",
|
||||
"knowledge-base-product-version",
|
||||
@@ -145,6 +157,27 @@ export function useUpdateStrategyProjectSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageDescriptionsSettings() {
|
||||
return useQuery({
|
||||
queryKey: PAGE_DESCRIPTIONS_KEY,
|
||||
queryFn: loadPageDescriptionsSettings,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageDescriptionsSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PageDescriptionsUpdate) =>
|
||||
updatePageDescriptionsSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PAGE_DESCRIPTIONS_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
queryKey: PROMPT_PREFIX_KEY,
|
||||
@@ -225,6 +258,28 @@ export function useUpdateWriteFileCompatibilitySettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCallMetricsSettings() {
|
||||
return useQuery({
|
||||
queryKey: CALL_METRICS_KEY,
|
||||
queryFn: loadCallMetricsSettings,
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCallMetricsSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CallMetricsSettings) => updateCallMetricsSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(CALL_METRICS_KEY, data);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["admin-activity", "leaderboard"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConversationContextSettings() {
|
||||
return useQuery({
|
||||
queryKey: CONVERSATION_CONTEXT_KEY,
|
||||
@@ -554,6 +609,29 @@ export function useUpdateSkillCompressionSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Deployment-wide custom-skill versioning feature gate. */
|
||||
export function useSkillVersioningSettings() {
|
||||
return useQuery({
|
||||
queryKey: SKILL_VERSIONING_KEY,
|
||||
queryFn: () => loadSkillVersioningSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSkillVersioningSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: SkillVersioningSettings) =>
|
||||
updateSkillVersioningSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(SKILL_VERSIONING_KEY, data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelConcurrencySettings() {
|
||||
return useQuery({
|
||||
queryKey: MODEL_CONCURRENCY_KEY,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import {
|
||||
extractInvokedSkillName,
|
||||
skillInvocationLabel,
|
||||
} from "./skill-invocation.ts";
|
||||
|
||||
test("finds a public skill executed directly by a weak model", () => {
|
||||
assert.equal(
|
||||
extractInvokedSkillName("bash", {
|
||||
command: "python /mnt/skills/public/facebook-page-generator/scripts/fast_page.py --spec /tmp/spec.json",
|
||||
}),
|
||||
"facebook-page-generator",
|
||||
);
|
||||
});
|
||||
|
||||
test("finds custom skills in host paths and nested arguments", () => {
|
||||
assert.equal(
|
||||
extractInvokedSkillName("read_file", {
|
||||
request: { path: String.raw`F:\runtime\skills\custom\analysis-helper\SKILL.md` },
|
||||
}),
|
||||
"analysis-helper",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the explicit name for skill_view", () => {
|
||||
assert.equal(extractInvokedSkillName("skill_view", { name: "report-maker" }), "report-maker");
|
||||
});
|
||||
|
||||
test("does not label ordinary tool paths as skill calls", () => {
|
||||
assert.equal(
|
||||
extractInvokedSkillName("bash", { command: "python /mnt/user-data/workspace/run.py" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a deterministic skill label without model-authored description", () => {
|
||||
assert.equal(skillInvocationLabel("report-maker"), "调用技能「report-maker」");
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const SKILL_NAME_ARG_TOOLS = new Set([
|
||||
"skill_view",
|
||||
"skill_manage",
|
||||
"skill_archive",
|
||||
]);
|
||||
|
||||
const SKILL_PATH_PATTERN =
|
||||
/(?:^|[\\/])skills[\\/](?:public|custom)[\\/]([^\\/\s"'`;&|<>()]+)(?=[\\/]|$)/i;
|
||||
|
||||
function findSkillNameInValue(
|
||||
value: unknown,
|
||||
seen: Set<object>,
|
||||
): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return SKILL_PATH_PATTERN.exec(value)?.[1];
|
||||
}
|
||||
if (!value || typeof value !== "object" || seen.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(value);
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: Object.values(value as Record<string, unknown>);
|
||||
for (const nested of values) {
|
||||
const name = findSkillNameInValue(nested, seen);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve a skill from actual tool arguments rather than model-authored prose. */
|
||||
export function extractInvokedSkillName(
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
): string | undefined {
|
||||
if (
|
||||
SKILL_NAME_ARG_TOOLS.has(toolName) &&
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
typeof (args as Record<string, unknown>).name === "string"
|
||||
) {
|
||||
const name = ((args as Record<string, unknown>).name as string).trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return findSkillNameInValue(args, new Set());
|
||||
}
|
||||
|
||||
export function skillInvocationLabel(skillName: string): string {
|
||||
return `调用技能「${skillName}」`;
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import type { AIMessage } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Translations } from "../i18n";
|
||||
import { hasToolCalls } from "../messages/utils";
|
||||
import {
|
||||
extractInvokedSkillName,
|
||||
skillInvocationLabel,
|
||||
} from "./skill-invocation";
|
||||
import { isInternalToolCallName } from "./visibility";
|
||||
|
||||
export function explainLastToolCall(message: AIMessage, t: Translations) {
|
||||
@@ -18,6 +22,7 @@ export function explainLastToolCall(message: AIMessage, t: Translations) {
|
||||
}
|
||||
|
||||
export function explainToolCall(toolCall: ToolCall, t: Translations) {
|
||||
const invokedSkillName = extractInvokedSkillName(toolCall.name, toolCall.args);
|
||||
if (toolCall.name === "web_search" || toolCall.name === "image_search") {
|
||||
return t.toolCalls.searchFor(toolCall.args.query);
|
||||
} else if (toolCall.name === "web_fetch") {
|
||||
@@ -26,6 +31,8 @@ export function explainToolCall(toolCall: ToolCall, t: Translations) {
|
||||
return t.toolCalls.presentFiles;
|
||||
} else if (toolCall.name === "write_todos") {
|
||||
return t.toolCalls.writeTodos;
|
||||
} else if (invokedSkillName) {
|
||||
return skillInvocationLabel(invokedSkillName);
|
||||
} else if (toolCall.args.description) {
|
||||
return toolCall.args.description;
|
||||
} else {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const env = {
|
||||
* flag so operations can switch the shell without rebuilding the frontend.
|
||||
*/
|
||||
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
|
||||
/** Red-white theme opt-in for the same navigation shell used by dark mode. */
|
||||
LIGHT_RED_TOP_NAV_ENABLED: readRuntimeFlag("VITE_LIGHT_RED_TOP_NAV_ENABLED"),
|
||||
/**
|
||||
* Development-only default entry switch for the isolated Vite-native
|
||||
* official-style workspace. It never starts or embeds a Next.js app.
|
||||
@@ -56,6 +58,13 @@ export const env = {
|
||||
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
|
||||
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY",
|
||||
),
|
||||
/**
|
||||
* Knowledge-base card/list switch. Off by default so the compact list is
|
||||
* always used unless a deployment explicitly opts into the alternate view.
|
||||
*/
|
||||
KNOWLEDGE_VIEW_SWITCH_ENABLED: readRuntimeFlag(
|
||||
"VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED",
|
||||
),
|
||||
/**
|
||||
* Compact-menu variant for this website. Pair with the admin 简介模式
|
||||
* type (A / B) so two deployments can show different left-nav items.
|
||||
@@ -63,3 +72,7 @@ export const env = {
|
||||
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
|
||||
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
|
||||
export function isLightRedTopNavEnabled(theme: string): boolean {
|
||||
return theme === "light" && env.LIGHT_RED_TOP_NAV_ENABLED;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PenTool, Sliders, Sparkles } from 'lucide-react';
|
||||
import { usePageDescriptionsSettings } from '@/core/system-settings/hooks';
|
||||
|
||||
/**
|
||||
* AI 写作入口页左栏「欢迎/空状态」屏。
|
||||
@@ -7,7 +8,7 @@ import { PenTool, Sliders, Sparkles } from 'lucide-react';
|
||||
* 第一条消息,就回到正常的对话时间线。
|
||||
*/
|
||||
|
||||
const STEPS = [
|
||||
const DEFAULT_STEPS = [
|
||||
{
|
||||
n: '1',
|
||||
title: '对白意图解析',
|
||||
@@ -32,6 +33,19 @@ const STEPS = [
|
||||
];
|
||||
|
||||
export function WritingWelcome() {
|
||||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||||
const configured = pageDescriptions?.ai_writing;
|
||||
const title = configured?.title || '欢迎使用「ai写作」';
|
||||
const description = configured?.description || '';
|
||||
const steps = DEFAULT_STEPS.map((step, index) => {
|
||||
const configuredCard = configured?.guide_cards[index];
|
||||
return {
|
||||
...step,
|
||||
title: configuredCard?.title || step.title,
|
||||
desc: configuredCard?.description || step.desc,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-y-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
|
||||
@@ -39,19 +53,25 @@ export function WritingWelcome() {
|
||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||
欢迎使用「ai写作」
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||
<strong className="font-semibold text-slate-700 dark:text-white/80">
|
||||
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
|
||||
</strong>
|
||||
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
|
||||
</p>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
{description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||
<strong className="font-semibold text-slate-700 dark:text-white/80">
|
||||
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
|
||||
</strong>
|
||||
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step) => (
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.n}
|
||||
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DateRangePicker, type DateRangePickerProps, type DateRangeValue } from "tdesign-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,11 +43,20 @@ import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor";
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardDetailItem,
|
||||
AdminLeaderboardDetailKind,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
@@ -54,7 +64,7 @@ import type {
|
||||
AdminLeaderboardUser,
|
||||
} from "@/core/admin/api";
|
||||
import { streamUserLeaderboardReport } from "@/core/admin/api";
|
||||
import { useRefreshTodayLeaderboard, useUserLeaderboard } from "@/core/admin/hooks";
|
||||
import { useRefreshTodayLeaderboard, useUserLeaderboard, useUserLeaderboardDetails } from "@/core/admin/hooks";
|
||||
import {
|
||||
ADMIN_QUERY_WINDOW_DAYS,
|
||||
formatLocalDateInput,
|
||||
@@ -70,6 +80,7 @@ type RangeState = { since: string; until: string; days?: number };
|
||||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||||
const DEFAULT_REPORT_MODEL = "__default__";
|
||||
const LEADERBOARD_PAGE_SIZE = 10;
|
||||
const DETAIL_PAGE_SIZE = 15;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
@@ -776,11 +787,13 @@ function SkillRanking({
|
||||
icon: Icon,
|
||||
items,
|
||||
empty,
|
||||
onMore,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminLeaderboardSkill[];
|
||||
empty: string;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.call_count));
|
||||
return (
|
||||
@@ -790,7 +803,14 @@ function SkillRanking({
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
{onMore && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
|
||||
更多
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
@@ -834,11 +854,13 @@ function DimensionRanking({
|
||||
icon: Icon,
|
||||
items,
|
||||
empty,
|
||||
onMore,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminLeaderboardDimension[];
|
||||
empty: string;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.count));
|
||||
return (
|
||||
@@ -848,7 +870,14 @@ function DimensionRanking({
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
{onMore && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
|
||||
更多
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
@@ -886,6 +915,264 @@ function DimensionRanking({
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(value: number): string {
|
||||
if (value <= 0) return "-";
|
||||
if (value < 1_000) return `${fmtNumber(value)} ms`;
|
||||
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)} 秒`;
|
||||
}
|
||||
|
||||
function LeaderboardDetailRankingBars({
|
||||
kind,
|
||||
items,
|
||||
}: {
|
||||
kind: AdminLeaderboardDetailKind;
|
||||
items: AdminLeaderboardDetailItem[];
|
||||
}) {
|
||||
const topItems = items.slice(0, 10);
|
||||
if (topItems.length === 0) {
|
||||
return <div className="flex h-40 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">当前时间段暂无{kind === "agent" ? "智能体运行" : "调用"}数据</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(1, ...topItems.map((item) => item.count));
|
||||
const verb = kind === "agent" ? "运行" : "调用";
|
||||
return (
|
||||
<div className="space-y-3" role="img" aria-label={`${kind === "agent" ? "智能体" : kind === "skill" ? "技能" : "工具"}${verb}次数前 ${topItems.length} 名柱状图`}>
|
||||
{topItems.map((item, index) => {
|
||||
const width = Math.max(4, (item.count / max) * 100);
|
||||
return (
|
||||
<div key={item.name} className="grid grid-cols-[24px_minmax(120px,1fr)_minmax(180px,2fr)_64px] items-center gap-3">
|
||||
<span className="text-sm font-medium tabular-nums text-[#80868B]">{index + 1}</span>
|
||||
<span className="truncate text-sm font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</span>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-right text-sm font-semibold tabular-nums text-[#202124] dark:text-white">{fmtNumber(item.count)} 次</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaderboardDetailDrawer({
|
||||
kind,
|
||||
range,
|
||||
onRangeChange,
|
||||
onOpenChange,
|
||||
}: {
|
||||
kind: AdminLeaderboardDetailKind | null;
|
||||
range: RangeState;
|
||||
onRangeChange: (range: RangeState) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const dateBounds = getAdminQueryDateBounds();
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<"count" | "users">("count");
|
||||
const [page, setPage] = useState(1);
|
||||
const apiRange = useMemo(() => rangeForApi(range), [range]);
|
||||
const { data, isLoading, error } = useUserLeaderboardDetails(kind, apiRange, !!kind);
|
||||
const rangePickerValue = useMemo<DateRangeValue>(() => [range.since, range.until], [range.since, range.until]);
|
||||
const rangePickerPresets = useMemo<DateRangePickerProps["presets"]>(() => {
|
||||
const lastSevenDays = defaultRange(7);
|
||||
const lastThirtyDays = defaultRange(30);
|
||||
return {
|
||||
"近 7 天": [lastSevenDays.since, lastSevenDays.until] as [string, string],
|
||||
"近 30 天": [lastThirtyDays.since, lastThirtyDays.until] as [string, string],
|
||||
};
|
||||
}, []);
|
||||
const detailMeta = kind === "skill"
|
||||
? { title: "技能调用统计", plural: "技能", verb: "调用", unit: "次调用", icon: Sparkles }
|
||||
: kind === "tool"
|
||||
? { title: "工具调用统计", plural: "工具", verb: "调用", unit: "次调用", icon: Activity }
|
||||
: { title: "Agent 使用统计", plural: "智能体", verb: "运行", unit: "次运行", icon: Bot };
|
||||
const DetailIcon = detailMeta.icon;
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
const items = (data?.items ?? []).filter((item) => !needle || item.name.toLowerCase().includes(needle));
|
||||
return [...items].sort((left, right) => {
|
||||
if (sort === "users") return right.user_count - left.user_count || right.count - left.count;
|
||||
return right.count - left.count || right.user_count - left.user_count;
|
||||
});
|
||||
}, [data?.items, search, sort]);
|
||||
const pageCount = Math.max(1, Math.ceil(filteredItems.length / DETAIL_PAGE_SIZE));
|
||||
const visibleItems = filteredItems.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE);
|
||||
const summary = data?.summary;
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [kind, apiRange.since, apiRange.until, search, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount));
|
||||
}, [pageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!kind) {
|
||||
setSearch("");
|
||||
setSort("count");
|
||||
setPage(1);
|
||||
}
|
||||
}, [kind]);
|
||||
|
||||
const setPickerRange = (value: DateRangeValue) => {
|
||||
if (value.length < 2) return;
|
||||
const normalizeDate = (date: DateRangeValue[number]) => {
|
||||
if (date instanceof Date) return formatLocalDateInput(date);
|
||||
if (typeof date === "number") return formatLocalDateInput(new Date(date));
|
||||
return String(date).slice(0, 10);
|
||||
};
|
||||
const since = normalizeDate(value[0]);
|
||||
const until = normalizeDate(value[1]);
|
||||
if (!dateFromInput(since) || !dateFromInput(until)) return;
|
||||
onRangeChange(clampRangeToAdminWindow({ since, until }));
|
||||
};
|
||||
|
||||
const metric = (label: string, value: string, description: string) => (
|
||||
<div className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]" title={description}>
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums text-[#231815] dark:text-white">{value}</div>
|
||||
<div className="mt-1 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{description}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={!!kind} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:w-[min(94vw,1080px)] sm:max-w-none">
|
||||
<SheetHeader className="shrink-0 border-b border-[#F1D6D6] bg-white px-5 pb-4 pt-4 pr-12 text-left dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<DetailIcon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
{detailMeta.title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1 leading-5">
|
||||
直接聚合原始统计记录;图表和明细会随当前时间范围重新汇总,不受主榜单快照刷新影响。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="shrink-0 space-y-3 border-b border-[#F1D6D6] bg-[#FFF7F7] px-5 py-3 dark:border-[#213D5E] dark:bg-[#011226]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-[#202124] dark:text-white">统计区间</span>
|
||||
<DateRangePicker
|
||||
value={rangePickerValue}
|
||||
valueType="YYYY-MM-DD"
|
||||
format="YYYY-MM-DD"
|
||||
presets={rangePickerPresets}
|
||||
clearable={false}
|
||||
disableDate={{ before: dateBounds.min, after: dateBounds.max }}
|
||||
onChange={setPickerRange}
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
<span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">仅支持近 30 天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-5">
|
||||
{isLoading && (
|
||||
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载{detailMeta.plural}明细
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-4 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
|
||||
明细加载失败:{error instanceof Error ? error.message : "请稍后重试"}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && summary && (
|
||||
<>
|
||||
<section>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#202124] dark:text-white">整体概览</h3>
|
||||
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{range.since} 至 {range.until} · {fmtNumber(summary.item_count)} 个{detailMeta.plural}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{fmtNumber(summary.count)} {detailMeta.unit}</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{metric(`总${detailMeta.verb}量`, `${fmtNumber(summary.count)} 次`, `所选时间内的${detailMeta.verb}总数`) }
|
||||
{metric("覆盖用户", `${fmtNumber(summary.user_count)} 人`, "至少使用过一次该维度的去重用户数")}
|
||||
{metric(kind === "agent" ? "总 Token" : "平均耗时", kind === "agent" ? fmtNumber(summary.total_tokens) : formatDuration(summary.avg_duration_ms), kind === "agent" ? "此类 Agent 运行累计的模型 Token" : "按每次完成调用计算的平均耗时")}
|
||||
{metric("活跃条目", `${fmtNumber(summary.item_count)} 项`, "当前条件下产生过统计记录的名称数量")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}{detailMeta.verb} Top 10</h3>
|
||||
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">按当前筛选的完整时间范围累计排序,数值越大柱越长。</p>
|
||||
</div>
|
||||
<Badge variant="outline">{range.since} 至 {range.until}</Badge>
|
||||
</div>
|
||||
<LeaderboardDetailRankingBars kind={kind!} items={data?.items ?? []} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}明细</h3>
|
||||
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">共 {fmtNumber(filteredItems.length)} 项;可按名称筛选、按调用量或用户数排序。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
|
||||
<Input aria-label={`搜索${detailMeta.plural}名称`} value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`搜索${detailMeta.plural}名称`} className="h-9 w-52 bg-white pl-8 dark:bg-[#011226]" />
|
||||
</div>
|
||||
<Select value={sort} onValueChange={(value) => setSort(value as "count" | "users")}>
|
||||
<SelectTrigger className="h-9 w-32 bg-white text-xs dark:bg-[#011226]" aria-label="明细排序方式">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="count">按调用量</SelectItem>
|
||||
<SelectItem value="users">按用户数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-[#5F6368] dark:text-slate-400">当前筛选条件下暂无{detailMeta.plural}数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead className="border-y border-[#F1D6D6] bg-[#FFF7F7] text-xs text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#011226] dark:text-[#7FACF2]">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 font-medium">名称</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">{detailMeta.verb}量</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">用户</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">{kind === "agent" ? "Token" : "平均耗时"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleItems.map((item: AdminLeaderboardDetailItem) => (
|
||||
<tr key={item.name} className="border-b border-[#F6E6E6] last:border-0 dark:border-[#17375E]">
|
||||
<td className="max-w-64 truncate px-3 py-3 font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</td>
|
||||
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.count)}</td>
|
||||
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.user_count)}</td>
|
||||
<td className="px-3 py-3 text-right tabular-nums">{kind === "agent" ? fmtNumber(item.total_tokens) : formatDuration(item.avg_duration_ms)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
|
||||
<span>第 {page} / {pageCount} 页</span>
|
||||
<Pagination page={page} pageSize={DETAIL_PAGE_SIZE} total={filteredItems.length} onPageChange={setPage} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
|
||||
@@ -904,6 +1191,8 @@ export default function AdminUserLeaderboardPage() {
|
||||
const [groupRules, setGroupRules] = useState("zero,two");
|
||||
const [showGroupStats, setShowGroupStats] = useState(false);
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
const [detailKind, setDetailKind] = useState<AdminLeaderboardDetailKind | null>(null);
|
||||
const [detailRange, setDetailRange] = useState<RangeState>(() => defaultRange(7));
|
||||
const apiRange = useMemo(
|
||||
() => rangeForApi(range),
|
||||
[range],
|
||||
@@ -968,6 +1257,11 @@ export default function AdminUserLeaderboardPage() {
|
||||
setRange(clampRangeToAdminWindow(defaultRange(days)));
|
||||
};
|
||||
|
||||
const openLeaderboardDetails = (kind: AdminLeaderboardDetailKind) => {
|
||||
setDetailRange(clampRangeToAdminWindow(range));
|
||||
setDetailKind(kind);
|
||||
};
|
||||
|
||||
const handleReportOpenChange = (open: boolean) => {
|
||||
setReportOpen(open);
|
||||
if (!open) {
|
||||
@@ -1438,19 +1732,27 @@ export default function AdminUserLeaderboardPage() {
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
|
||||
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
|
||||
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" onMore={() => openLeaderboardDetails("skill")} />
|
||||
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" onMore={() => openLeaderboardDetails("tool")} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
|
||||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
|
||||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" onMore={() => openLeaderboardDetails("agent")} />
|
||||
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<LeaderboardDetailDrawer
|
||||
kind={detailKind}
|
||||
range={detailRange}
|
||||
onRangeChange={setDetailRange}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailKind(null);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,10 @@ import {
|
||||
type ArtifactLibrarySource,
|
||||
} from "@/core/artifacts/library";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useAppSystemName } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
useAppSystemName,
|
||||
usePageDescriptionsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import {
|
||||
@@ -328,6 +331,12 @@ function LoadingRows() {
|
||||
export default function ArtifactLibraryPage() {
|
||||
const { t } = useI18n();
|
||||
const appName = useAppSystemName(t.pages.appName);
|
||||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||||
const configuredDescription = pageDescriptions?.knowledge_base;
|
||||
const pageTitle = configuredDescription?.title || "资料库";
|
||||
const pageDescription =
|
||||
configuredDescription?.description ||
|
||||
"汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。";
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
|
||||
@@ -337,8 +346,8 @@ export default function ArtifactLibraryPage() {
|
||||
const forceRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `资料库 - ${appName}`;
|
||||
}, [appName]);
|
||||
document.title = `${pageTitle} - ${appName}`;
|
||||
}, [appName, pageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -478,10 +487,10 @@ export default function ArtifactLibraryPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Library className="size-6 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{pageTitle}</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。
|
||||
{pageDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5 sm:items-end">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useConversationContextSettings,
|
||||
useLayoutMode,
|
||||
useOrdinaryKnowledgeRetrievalSettings,
|
||||
usePageDescriptionsSettings,
|
||||
usePromptPrefixSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useUserPreferencesQuery } from "@/core/user-preferences";
|
||||
@@ -168,6 +169,7 @@ export default function ChatPage() {
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
|
||||
const [effectiveLayoutMode] = useLayoutMode();
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
@@ -1003,6 +1005,17 @@ export default function ChatPage() {
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
// Configuration follows the user-facing interface mode, rather than the
|
||||
// internal welcome component name. The compact-looking welcome component
|
||||
// is also used by the full-chrome surface, so tying settings to that name
|
||||
// would invert the administrator's "全量 / 简洁" configuration.
|
||||
const qaDescriptionMode =
|
||||
fullChatMode ||
|
||||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
|
||||
effectiveLayoutMode === "full"
|
||||
? "full"
|
||||
: "compact";
|
||||
const qaPageDescription = pageDescriptions?.general_qa[qaDescriptionMode];
|
||||
const composerWidthMode = resolveComposerWidthMode({
|
||||
fullChatMode,
|
||||
wxFullChat: pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX),
|
||||
@@ -1231,11 +1244,15 @@ export default function ChatPage() {
|
||||
</div>
|
||||
{isLandingState && mounted &&
|
||||
(compactLanding ? (
|
||||
<CompactWelcome className="chat-landing-content-lift mb-6" />
|
||||
<CompactWelcome
|
||||
className="chat-landing-content-lift mb-6"
|
||||
pageDescription={qaPageDescription}
|
||||
/>
|
||||
) : (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
className="chat-landing-content-lift mb-6"
|
||||
pageDescription={qaPageDescription}
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
@@ -1263,6 +1280,11 @@ export default function ChatPage() {
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
placeholder={
|
||||
isLandingState
|
||||
? qaPageDescription?.input_placeholder
|
||||
: undefined
|
||||
}
|
||||
compact={compactLanding}
|
||||
composerHeightPercent={composerHeightPercent}
|
||||
threadId={threadId}
|
||||
|
||||
@@ -4,19 +4,23 @@
|
||||
* HTML 模板管理(仅 admin):管理定时任务「HTML 模板」库——
|
||||
* 列表 / 预览 / 在线编辑 / 上传覆盖 / 新增 / 删除 / 重置内置。
|
||||
*
|
||||
* 后端 `/api/html-templates`(存储 `{base_dir}/html-templates/<id>/`,内置 27 个
|
||||
* 模板启动 seed、缺失才复制不覆盖修改)。改完即时生效:调度器每次执行直接读
|
||||
* 存储文件,无需重启。内置模板可编辑/上传覆盖/重置回随包原版但不可删除;自建
|
||||
* 模板可删。上传的模板若带单行 `const DATA={…};` 注入行会自动成为「可转换」
|
||||
* 模板(定时任务的「JSON 转换填充」管线零代码接入)。
|
||||
* 视觉与任务分析定时(task-analysis.css)同一套浅红规范:TDesign 控件 + lucide
|
||||
* 图标 + ht- 前缀样式。结构化模板(kind=structured,如 no.1)多一个「编辑数据」
|
||||
* 页签:表单化编辑默认数据并持久化到后台 data.json(详见
|
||||
* html-template-data-editor.tsx);预览页右下角的齿轮按钮是模板 runtime 自带的
|
||||
* 页面级 JSON 编辑器,仅改浏览器本地。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
Braces,
|
||||
Code2,
|
||||
Download,
|
||||
Eye,
|
||||
FileCode2,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -27,27 +31,21 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Dialog, Input, Popconfirm, Tabs, Tag, Textarea } from "tdesign-react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
formatTemplateSize,
|
||||
useHtmlTemplateActions,
|
||||
useHtmlTemplateContent,
|
||||
useHtmlTemplateRawContent,
|
||||
useHtmlTemplatesAdmin,
|
||||
type HtmlTemplateMeta,
|
||||
} from "@/strategy-components/api/html-templates";
|
||||
import HtmlTemplateDataEditor from "./html-template-data-editor";
|
||||
import HtmlTemplatePageConfig from "./html-template-page-config";
|
||||
|
||||
import "./html-templates.css";
|
||||
|
||||
function formatTime(value?: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
@@ -55,14 +53,18 @@ function formatTime(value?: string | null): string {
|
||||
|
||||
/** 从文件名推一个合法模板 id(no.28.html → no28;不合法字符折成 -)。 */
|
||||
function slugifyId(raw: string): string {
|
||||
return raw
|
||||
.replace(/\.(html?|htm)$/i, "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^[._-]+/, "")
|
||||
.slice(0, 64) || "";
|
||||
return (
|
||||
raw
|
||||
.replace(/\.(html?|htm)$/i, "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^[._-]+/, "")
|
||||
.slice(0, 64) || ""
|
||||
);
|
||||
}
|
||||
|
||||
type DetailTab = "preview" | "data" | "config" | "html";
|
||||
|
||||
export default function HtmlTemplatesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: templates = [], isLoading, refetch } = useHtmlTemplatesAdmin();
|
||||
@@ -70,29 +72,37 @@ export default function HtmlTemplatesPage() {
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"preview" | "edit">("preview");
|
||||
const [tab, setTab] = useState<DetailTab>("preview");
|
||||
|
||||
// 选中项的内容(服务端权威)与本地编辑草稿;selectedId/服务端内容变化时重置。
|
||||
const { data: serverHtml, isLoading: contentLoading } = useHtmlTemplateContent(selectedId);
|
||||
const [htmlDraft, setHtmlDraft] = useState<string | null>(null);
|
||||
const [metaDraft, setMetaDraft] = useState<{ name: string; description: string } | null>(null);
|
||||
useEffect(() => {
|
||||
setHtmlDraft(serverHtml ?? null);
|
||||
}, [serverHtml, selectedId]);
|
||||
const selected = useMemo(
|
||||
() => templates.find((t) => t.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
const structured = selected?.kind === "structured";
|
||||
|
||||
// 预览用组合后的完整文档;编辑用正文原文(结构化模板 = 带 __PAGE_* 标记的骨架)。
|
||||
const { data: serverHtml, isLoading: contentLoading } = useHtmlTemplateContent(selectedId);
|
||||
const { data: rawHtml } = useHtmlTemplateRawContent(selectedId, structured);
|
||||
const editSource = structured ? (rawHtml ?? null) : (serverHtml ?? null);
|
||||
const [htmlDraft, setHtmlDraft] = useState<string | null>(null);
|
||||
const [metaDraft, setMetaDraft] = useState<{ name: string; description: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHtmlDraft(editSource);
|
||||
}, [editSource, selectedId]);
|
||||
useEffect(() => {
|
||||
setMetaDraft(selected ? { name: selected.name, description: selected.description } : null);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId && templates.length > 0) setSelectedId(templates[0].id);
|
||||
if (selectedId && !templates.some((t) => t.id === selectedId)) {
|
||||
setSelectedId(templates[0]?.id ?? null);
|
||||
}
|
||||
}, [selectedId, templates]);
|
||||
// 切到非结构化模板时收起「编辑数据」页签。
|
||||
useEffect(() => {
|
||||
if ((tab === "data" || tab === "config") && !structured) setTab("preview");
|
||||
}, [tab, structured]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -102,7 +112,7 @@ export default function HtmlTemplatesPage() {
|
||||
);
|
||||
}, [templates, search]);
|
||||
|
||||
const htmlDirty = htmlDraft !== null && htmlDraft !== (serverHtml ?? "");
|
||||
const htmlDirty = htmlDraft !== null && htmlDraft !== (editSource ?? "");
|
||||
const metaDirty =
|
||||
!!selected &&
|
||||
!!metaDraft &&
|
||||
@@ -158,10 +168,9 @@ export default function HtmlTemplatesPage() {
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!selected) return;
|
||||
if (!window.confirm(`确认把内置模板「${selected.name}」重置回随包原版?当前修改将丢失。`)) return;
|
||||
try {
|
||||
await actions.reset.mutateAsync(selected.id);
|
||||
toast.success("已重置回随包原版");
|
||||
toast.success(`已把「${selected.name}」重置回随包原版`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "重置失败");
|
||||
}
|
||||
@@ -169,12 +178,6 @@ export default function HtmlTemplatesPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除模板「${selected.name}」?引用它的定时任务将无法执行,该操作不可撤销。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(selected.id);
|
||||
toast.success("模板已删除");
|
||||
@@ -232,331 +235,358 @@ export default function HtmlTemplatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/70 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5 text-[var(--color-main,#0062D9)]" />
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">HTML 模板管理</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
定时任务的 HTML 模板库(共 {templates.length} 个)。编辑/上传覆盖即时生效,无需重启;内置模板可重置回原版,自建模板可删除。
|
||||
</div>
|
||||
</div>
|
||||
<section className="ht-page">
|
||||
<header className="ht-page-head">
|
||||
<div className="ht-page-head-copy">
|
||||
<h1>HTML 模板管理</h1>
|
||||
<p>
|
||||
定时任务的 HTML 模板库(共 {templates.length} 个)。编辑即时生效、无需重启;内置模板可重置回原版,
|
||||
自建模板可删除;结构化模板支持「编辑数据」并持久化到后台。
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> 新建模板
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ht-page-head-actions">
|
||||
<Button theme="primary" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} /> 新建模板
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="ht-body">
|
||||
{/* 左:模板列表 */}
|
||||
<div className="flex w-72 min-w-60 flex-col border-r border-border/70">
|
||||
<div className="relative p-3">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-6 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<aside className="ht-list">
|
||||
<div className="ht-list-search">
|
||||
<Input
|
||||
clearable
|
||||
placeholder="搜索 id / 名称 / 描述"
|
||||
prefixIcon={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索 id / 名称"
|
||||
className="pl-8"
|
||||
onChange={(value) => setSearch(String(value ?? ""))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{isLoading && <div className="px-2 py-4 text-xs text-muted-foreground">加载中…</div>}
|
||||
<div className="ht-list-items">
|
||||
{isLoading && <div className="ht-list-empty">正在加载模板列表…</div>}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">没有匹配的模板</div>
|
||||
<div className="ht-list-empty">没有匹配的模板</div>
|
||||
)}
|
||||
{filtered.map((t) => (
|
||||
<TemplateListItem
|
||||
<button
|
||||
key={t.id}
|
||||
template={t}
|
||||
active={t.id === selectedId}
|
||||
type="button"
|
||||
className={`ht-tpl-item${t.id === selectedId ? " is-active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedId(t.id);
|
||||
setTab("preview");
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<span className="ht-tpl-item-top">
|
||||
<FileCode2 size={15} />
|
||||
<span className="ht-tpl-item-name">{t.name}</span>
|
||||
</span>
|
||||
<span className="ht-tpl-item-meta">
|
||||
<span>{t.id}</span>
|
||||
<span>{formatTemplateSize(t.size)}</span>
|
||||
<span>{t.builtin ? "内置" : "自建"}</span>
|
||||
{t.convertible && <span className="ht-dot" title="可转换填充" />}
|
||||
{t.kind === "structured" && (
|
||||
<span className="ht-dot is-struct" title="结构化模板(可编辑数据)" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右:详情 / 编辑 */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading ? "加载模板列表…" : "选择左侧模板查看详情"}
|
||||
{!selected ? (
|
||||
<div className="ht-detail">
|
||||
<div className="ht-preview-state">
|
||||
{isLoading ? "正在加载模板列表…" : "选择左侧模板查看详情"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 详情头:名称/描述编辑 + 徽标 + 操作 */}
|
||||
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={selected.builtin ? "secondary" : "outline"}>
|
||||
{selected.builtin ? "内置" : "自建"}
|
||||
</Badge>
|
||||
<Badge variant={selected.convertible ? "default" : "outline"}>
|
||||
{selected.convertible ? "可转换填充" : "只展示"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
id:{selected.id} · {formatTemplateSize(selected.size)} · 更新于{" "}
|
||||
{formatTime(selected.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-40 flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">名称</span>
|
||||
<Input
|
||||
value={metaDraft?.name ?? ""}
|
||||
onChange={(e) =>
|
||||
setMetaDraft((v) => (v ? { ...v, name: e.target.value } : v))
|
||||
}
|
||||
className="h-8 w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-40 flex-1 flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">描述(定时任务下拉中展示)</span>
|
||||
<Input
|
||||
value={metaDraft?.description ?? ""}
|
||||
onChange={(e) =>
|
||||
setMetaDraft((v) => (v ? { ...v, description: e.target.value } : v))
|
||||
}
|
||||
className="h-8"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSaveMeta}
|
||||
disabled={!metaDirty || actions.update.isPending}
|
||||
>
|
||||
<Pencil className="h-4 w-4" /> 保存信息
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={overwriteInputRef}
|
||||
type="file"
|
||||
accept=".html,.htm,text/html"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleOverwriteFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => overwriteInputRef.current?.click()}
|
||||
disabled={actions.update.isPending}
|
||||
>
|
||||
<Upload className="h-4 w-4" /> 上传 HTML 覆盖
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" /> 下载
|
||||
</Button>
|
||||
{selected.builtin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={actions.reset.isPending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> 重置原版
|
||||
</Button>
|
||||
)}
|
||||
{!selected.builtin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={actions.remove.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> 删除
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
title="刷新列表"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ht-detail">
|
||||
<div className="ht-detail-head">
|
||||
<div className="ht-detail-tags">
|
||||
<Tag size="small" theme={selected.builtin ? "default" : "warning"} variant="light">
|
||||
{selected.builtin ? "内置" : "自建"}
|
||||
</Tag>
|
||||
{structured && (
|
||||
<Tag size="small" theme="primary" variant="light">
|
||||
结构化 · 可编辑数据
|
||||
</Tag>
|
||||
)}
|
||||
<Tag
|
||||
size="small"
|
||||
theme={selected.convertible ? "success" : "default"}
|
||||
variant="light"
|
||||
>
|
||||
{selected.convertible ? "可转换填充" : "只展示"}
|
||||
</Tag>
|
||||
<span className="ht-detail-meta">
|
||||
id:{selected.id} · {formatTemplateSize(selected.size)} · 更新于{" "}
|
||||
{formatTime(selected.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 预览 / 编辑 */}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "preview" | "edit")}
|
||||
className="flex min-h-0 flex-1 flex-col gap-0"
|
||||
>
|
||||
<div className="border-b border-border/70 px-5 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="preview">预览</TabsTrigger>
|
||||
<TabsTrigger value="edit">编辑 HTML</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="preview" className="min-h-0 flex-1 p-0">
|
||||
{contentLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
加载模板内容…
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
title={`模板预览 ${selected.name}`}
|
||||
srcDoc={serverHtml ?? ""}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="edit" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<Textarea
|
||||
value={htmlDraft ?? ""}
|
||||
onChange={(e) => setHtmlDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="模板 HTML 正文…"
|
||||
className="min-h-0 flex-1 resize-none rounded-md border font-mono text-xs leading-relaxed"
|
||||
<div className="ht-detail-form">
|
||||
<label className="ht-detail-field">
|
||||
<span>名称</span>
|
||||
<Input
|
||||
style={{ width: 200 }}
|
||||
value={metaDraft?.name ?? ""}
|
||||
onChange={(value) =>
|
||||
setMetaDraft((v) => (v ? { ...v, name: String(value ?? "") } : v))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
保存后即时生效;带单行
|
||||
<code className="mx-1 rounded bg-muted px-1">const DATA={…};</code>
|
||||
的模板自动成为「可转换填充」型
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveHtml}
|
||||
disabled={!htmlDirty || actions.update.isPending}
|
||||
>
|
||||
{actions.update.isPending ? "保存中…" : "保存内容"}
|
||||
</label>
|
||||
<label className="ht-detail-field" style={{ flex: 1 }}>
|
||||
<span>描述(定时任务下拉中展示)</span>
|
||||
<Input
|
||||
value={metaDraft?.description ?? ""}
|
||||
placeholder="可选"
|
||||
onChange={(value) =>
|
||||
setMetaDraft((v) => (v ? { ...v, description: String(value ?? "") } : v))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
disabled={!metaDirty || actions.update.isPending}
|
||||
onClick={() => void handleSaveMeta()}
|
||||
>
|
||||
<Pencil size={14} /> 保存信息
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ht-detail-ops">
|
||||
<input
|
||||
ref={overwriteInputRef}
|
||||
type="file"
|
||||
accept=".html,.htm,text/html"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleOverwriteFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onClick={() => overwriteInputRef.current?.click()}
|
||||
disabled={actions.update.isPending}
|
||||
>
|
||||
<Upload size={14} /> 上传 HTML 覆盖
|
||||
</Button>
|
||||
<Button size="small" variant="outline" onClick={handleDownload}>
|
||||
<Download size={14} /> 下载
|
||||
</Button>
|
||||
{selected.builtin ? (
|
||||
<Popconfirm
|
||||
content={`重置后「${selected.name}」的本地修改将丢失,确定?`}
|
||||
theme="danger"
|
||||
onConfirm={() => void handleReset()}
|
||||
>
|
||||
<Button size="small" variant="outline" disabled={actions.reset.isPending}>
|
||||
<RotateCcw size={14} /> 重置原版
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
content="删除后引用它的定时任务将无法执行,确定?"
|
||||
theme="danger"
|
||||
placement="bottom-right"
|
||||
onConfirm={() => void handleDelete()}
|
||||
>
|
||||
<Button size="small" variant="outline" theme="danger">
|
||||
<Trash2 size={14} /> 删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<div className="ht-ops-gap" />
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => refetch()}
|
||||
title="刷新列表"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "ht-spin" : undefined} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ht-detail-body">
|
||||
<Tabs
|
||||
className="ht-detail-tabs"
|
||||
value={tab}
|
||||
onChange={(value) => setTab(value as DetailTab)}
|
||||
>
|
||||
<Tabs.TabPanel
|
||||
value="preview"
|
||||
label={
|
||||
<span className="ht-tab-label">
|
||||
<Eye size={14} /> 预览
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="ht-preview-pane">
|
||||
{contentLoading ? (
|
||||
<div className="ht-preview-state">正在加载模板内容…</div>
|
||||
) : (
|
||||
<iframe
|
||||
title={`模板预览 ${selected.name}`}
|
||||
srcDoc={serverHtml ?? ""}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs.TabPanel>
|
||||
{structured && (
|
||||
<Tabs.TabPanel
|
||||
value="data"
|
||||
label={
|
||||
<span className="ht-tab-label">
|
||||
<Braces size={14} /> 编辑数据
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<HtmlTemplateDataEditor key={selected.id} templateId={selected.id} />
|
||||
</Tabs.TabPanel>
|
||||
)}
|
||||
{structured && (
|
||||
<Tabs.TabPanel
|
||||
value="config"
|
||||
label={
|
||||
<span className="ht-tab-label">
|
||||
<LayoutGrid size={14} /> 字段与模块
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<HtmlTemplatePageConfig key={selected.id} templateId={selected.id} />
|
||||
</Tabs.TabPanel>
|
||||
)}
|
||||
<Tabs.TabPanel
|
||||
value="html"
|
||||
label={
|
||||
<span className="ht-tab-label">
|
||||
<Code2 size={14} /> 编辑 HTML
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="ht-html-pane">
|
||||
<Textarea
|
||||
value={htmlDraft ?? ""}
|
||||
onChange={(value) => setHtmlDraft(String(value ?? ""))}
|
||||
spellCheck={false}
|
||||
placeholder={structured ? "结构化模板骨架(须保留注入标记)…" : "模板 HTML 正文…"}
|
||||
/>
|
||||
<div className="ht-pane-toolbar">
|
||||
<span className="ht-pane-hint">
|
||||
{structured ? (
|
||||
<>
|
||||
这里编辑的是骨架,必须保留
|
||||
<code>__PAGE_DATA__</code>
|
||||
<code>__PAGE_MANIFEST__</code>
|
||||
<code>__PAGE_RUNTIME__</code>
|
||||
标记;改数据请用「编辑数据」页签
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
保存后即时生效;带单行 <code>const DATA={…};</code>{" "}
|
||||
的模板自动成为「可转换填充」型
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="primary"
|
||||
disabled={!htmlDirty || actions.update.isPending}
|
||||
onClick={() => void handleSaveHtml()}
|
||||
>
|
||||
保存内容
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建模板 */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 HTML 模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={createFileRef}
|
||||
type="file"
|
||||
accept=".html,.htm,text/html"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleCreateFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => createFileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" /> 上传 .html 文件
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
或直接在下方粘贴 HTML;上传后 id/名称可用文件名预填
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
模板 id(字母/数字/.-/_,创建后不可改)
|
||||
</span>
|
||||
<Input
|
||||
value={createForm.templateId}
|
||||
onChange={(e) => setCreateForm((v) => ({ ...v, templateId: e.target.value }))}
|
||||
placeholder="例如 no28 或 my-dashboard"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">名称(下拉中展示)</span>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((v) => ({ ...v, name: e.target.value }))}
|
||||
placeholder="例如 no.28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">描述(可选)</span>
|
||||
<Input
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((v) => ({ ...v, description: e.target.value }))}
|
||||
placeholder="模板用途说明"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">模板 HTML 正文</span>
|
||||
<Textarea
|
||||
value={createForm.html}
|
||||
onChange={(e) => setCreateForm((v) => ({ ...v, html: e.target.value }))}
|
||||
spellCheck={false}
|
||||
placeholder="<!doctype html>…"
|
||||
className="h-56 resize-y font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
className="ht-create-dialog"
|
||||
visible={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
header="新建 HTML 模板"
|
||||
width={680}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={actions.create.isPending}>
|
||||
{actions.create.isPending ? "创建中…" : "创建"}
|
||||
<Button theme="primary" loading={actions.create.isPending} onClick={() => void handleCreate()}>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="ht-create-grid">
|
||||
<div className="ht-detail-ops" style={{ border: 0, padding: 0 }}>
|
||||
<input
|
||||
ref={createFileRef}
|
||||
type="file"
|
||||
accept=".html,.htm,text/html"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleCreateFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="small" variant="outline" onClick={() => createFileRef.current?.click()}>
|
||||
<Upload size={14} /> 上传 .html 文件
|
||||
</Button>
|
||||
<span className="ht-pane-hint">或直接在下方粘贴 HTML;上传后 id/名称可用文件名预填</span>
|
||||
</div>
|
||||
<div className="ht-create-row">
|
||||
<label className="ht-create-field">
|
||||
<span>模板 id(字母/数字/.-/_,创建后不可改)</span>
|
||||
<Input
|
||||
value={createForm.templateId}
|
||||
onChange={(value) => setCreateForm((v) => ({ ...v, templateId: String(value ?? "") }))}
|
||||
placeholder="例如 no28 或 my-dashboard"
|
||||
/>
|
||||
</label>
|
||||
<label className="ht-create-field">
|
||||
<span>名称(下拉中展示)</span>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(value) => setCreateForm((v) => ({ ...v, name: String(value ?? "") }))}
|
||||
placeholder="例如 no.28"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="ht-create-field">
|
||||
<span>描述(可选)</span>
|
||||
<Input
|
||||
value={createForm.description}
|
||||
onChange={(value) =>
|
||||
setCreateForm((v) => ({ ...v, description: String(value ?? "") }))
|
||||
}
|
||||
placeholder="模板用途说明"
|
||||
/>
|
||||
</label>
|
||||
<label className="ht-create-field">
|
||||
<span>模板 HTML 正文</span>
|
||||
<Textarea
|
||||
value={createForm.html}
|
||||
onChange={(value) => setCreateForm((v) => ({ ...v, html: String(value ?? "") }))}
|
||||
spellCheck={false}
|
||||
placeholder="<!doctype html>…"
|
||||
autosize={{ minRows: 8, maxRows: 14 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateListItem({
|
||||
template,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
template: HtmlTemplateMeta;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"mb-1 flex w-full flex-col items-start gap-1 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:bg-accent/60",
|
||||
active && "border-primary/30 bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-1.5">
|
||||
<FileCode2 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{template.name}</span>
|
||||
{template.convertible && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
|
||||
title="可转换填充(带 const DATA= 注入行)"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex w-full items-center gap-1.5 pl-5 text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 truncate">{template.id}</span>
|
||||
<span className="shrink-0">{template.builtin ? "内置" : "自建"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useNoteWikilinks,
|
||||
useVaultPage,
|
||||
} from "@/core/knowledge/hooks";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import MarkdownRenderer, {
|
||||
@@ -537,6 +538,8 @@ export default function KnowledgeBasePage({
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.knowledge_management;
|
||||
|
||||
// --- list / tree state ---
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -649,6 +652,8 @@ export default function KnowledgeBasePage({
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
|
||||
<WikiHeader
|
||||
title={pageDescription?.title || undefined}
|
||||
description={pageDescription?.description || undefined}
|
||||
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
|
||||
onImportFile={() => setImportOpen(true)}
|
||||
onBatchImport={() => setBatchOpen(true)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { isCustomThemeId } from "@/core/theme/custom-theme";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
@@ -140,6 +141,8 @@ export default function LightAppListPage() {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const lightAccent = getLightAccent(theme);
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.light_app_list;
|
||||
|
||||
const { data: apps = [], isLoading } = useLightApps({
|
||||
appType: "window_open",
|
||||
@@ -199,14 +202,14 @@ export default function LightAppListPage() {
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
|
||||
应用列表
|
||||
{pageDescription?.title || "应用列表"}
|
||||
</h1>
|
||||
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
|
||||
导航工作台
|
||||
</span>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
|
||||
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
|
||||
{pageDescription?.description || "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Select } from "tdesign-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import { useSetResourceTags } from "@/core/tags";
|
||||
import {
|
||||
useLightAppActions,
|
||||
@@ -142,6 +143,8 @@ export default function LightAppManagementPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: apps = [], isLoading } = useLightApps({ search });
|
||||
const actions = useLightAppActions();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.light_app_management;
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LightApp | null>(null);
|
||||
@@ -327,18 +330,20 @@ export default function LightAppManagementPage() {
|
||||
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
|
||||
应用管理
|
||||
{pageDescription?.title || "应用管理"}
|
||||
</h1>
|
||||
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
|
||||
注册轻应用。
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
window.open
|
||||
</code>
|
||||
类型在「应用列表」以卡片展示;
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
iframe
|
||||
</code>
|
||||
类型挂载到所选菜单下并注入侧栏。
|
||||
{pageDescription?.description || <>
|
||||
注册轻应用。
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
window.open
|
||||
</code>
|
||||
类型在「应用列表」以卡片展示;
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
iframe
|
||||
</code>
|
||||
类型挂载到所选菜单下并注入侧栏。
|
||||
</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
DatabaseIcon,
|
||||
FileTextIcon,
|
||||
FolderKanbanIcon,
|
||||
GitBranchIcon,
|
||||
LandmarkIcon,
|
||||
Layout,
|
||||
List,
|
||||
Loader2Icon,
|
||||
MonitorIcon,
|
||||
PenLineIcon,
|
||||
RotateCcwIcon,
|
||||
SaveIcon,
|
||||
SparklesIcon,
|
||||
TelescopeIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type {
|
||||
AIWritingGuideCard,
|
||||
AIWritingPageDescription,
|
||||
GeneralQaPageDescription,
|
||||
PageDescriptionsSettings,
|
||||
} from "@/core/system-settings/api";
|
||||
import {
|
||||
usePageDescriptionsSettings,
|
||||
useUpdatePageDescriptionsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageTarget =
|
||||
| "qa-compact"
|
||||
| "qa-full"
|
||||
| "knowledge-base"
|
||||
| "ai-writing"
|
||||
| "strategy-projects"
|
||||
| "deep-research"
|
||||
| "gongwen"
|
||||
| "light-app-management"
|
||||
| "light-app-list"
|
||||
| "agents"
|
||||
| "business-chains"
|
||||
| "skills"
|
||||
| "knowledge-management";
|
||||
type HeaderTarget = Exclude<PageTarget, "qa-compact" | "qa-full">;
|
||||
type HeaderKey =
|
||||
| "knowledge_base"
|
||||
| "ai_writing"
|
||||
| "strategy_projects"
|
||||
| "deep_research"
|
||||
| "gongwen"
|
||||
| "light_app_management"
|
||||
| "light_app_list"
|
||||
| "agents"
|
||||
| "business_chains"
|
||||
| "skills"
|
||||
| "knowledge_management";
|
||||
|
||||
const EMPTY_QA: GeneralQaPageDescription = {
|
||||
welcome_title: "",
|
||||
welcome_description: "",
|
||||
input_placeholder: "",
|
||||
};
|
||||
const EMPTY_HEADER = { title: "", description: "" };
|
||||
const DEFAULT_AI_WRITING_GUIDE_CARDS: AIWritingGuideCard[] = [
|
||||
{
|
||||
title: "对白意图解析",
|
||||
description: "在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。",
|
||||
},
|
||||
{
|
||||
title: "参数多端对齐",
|
||||
description: "右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。",
|
||||
},
|
||||
{
|
||||
title: "深度排版落成",
|
||||
description: "点击右侧红色创作按钮,精美的行业或创意文档极速铺开。",
|
||||
},
|
||||
];
|
||||
const EMPTY_AI_WRITING: AIWritingPageDescription = {
|
||||
...EMPTY_HEADER,
|
||||
guide_cards: DEFAULT_AI_WRITING_GUIDE_CARDS.map(() => ({
|
||||
title: "",
|
||||
description: "",
|
||||
})),
|
||||
};
|
||||
const EMPTY_SETTINGS: PageDescriptionsSettings = {
|
||||
general_qa: { full: EMPTY_QA, compact: EMPTY_QA },
|
||||
knowledge_base: EMPTY_HEADER,
|
||||
ai_writing: EMPTY_AI_WRITING,
|
||||
strategy_projects: EMPTY_HEADER,
|
||||
deep_research: EMPTY_HEADER,
|
||||
gongwen: EMPTY_HEADER,
|
||||
light_app_management: EMPTY_HEADER,
|
||||
light_app_list: EMPTY_HEADER,
|
||||
agents: EMPTY_HEADER,
|
||||
business_chains: EMPTY_HEADER,
|
||||
skills: EMPTY_HEADER,
|
||||
knowledge_management: EMPTY_HEADER,
|
||||
};
|
||||
|
||||
const HEADER_KEY_BY_TARGET: Record<HeaderTarget, HeaderKey> = {
|
||||
"knowledge-base": "knowledge_base",
|
||||
"ai-writing": "ai_writing",
|
||||
"strategy-projects": "strategy_projects",
|
||||
"deep-research": "deep_research",
|
||||
gongwen: "gongwen",
|
||||
"light-app-management": "light_app_management",
|
||||
"light-app-list": "light_app_list",
|
||||
agents: "agents",
|
||||
"business-chains": "business_chains",
|
||||
skills: "skills",
|
||||
"knowledge-management": "knowledge_management",
|
||||
};
|
||||
|
||||
const PAGES: Array<{
|
||||
id: PageTarget;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
path: string;
|
||||
defaultTitle: string;
|
||||
defaultDescription: string;
|
||||
}> = [
|
||||
{
|
||||
id: "qa-compact",
|
||||
title: "通用问答 · 简洁模式",
|
||||
subtitle: "紧凑工作台的新对话欢迎区",
|
||||
path: "/page/workspace/chats/new",
|
||||
defaultTitle: "欢迎使用CM助手",
|
||||
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
|
||||
},
|
||||
{
|
||||
id: "qa-full",
|
||||
title: "通用问答 · 全量模式",
|
||||
subtitle: "完整工作台的新对话欢迎区",
|
||||
path: "/page/workspace/chats/new",
|
||||
defaultTitle: "欢迎使用CM助手",
|
||||
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
|
||||
},
|
||||
{
|
||||
id: "knowledge-base",
|
||||
title: "资料库",
|
||||
subtitle: "资料库首页的标题与说明",
|
||||
path: "/page/workspace/library",
|
||||
defaultTitle: "资料库",
|
||||
defaultDescription: "汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。",
|
||||
},
|
||||
{
|
||||
id: "ai-writing",
|
||||
title: "AI 写作",
|
||||
subtitle: "AI 写作工作台的欢迎区",
|
||||
path: "/page/canvas/ai-writing",
|
||||
defaultTitle: "欢迎使用「ai写作」",
|
||||
defaultDescription: "这是一个专业的自动化写作系统。你只需在下方对话框中输入你的本意(例如:「帮我写一篇 1500 字的周报」),系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
title: "我的项目",
|
||||
subtitle: "策略项目列表的标题与说明",
|
||||
path: "/page/strategy/qa/projects",
|
||||
defaultTitle: "我的项目",
|
||||
defaultDescription: "集中查看普通问答、策略优选和方案成果",
|
||||
},
|
||||
{
|
||||
id: "deep-research",
|
||||
title: "深度研究",
|
||||
subtitle: "深度研究工作台的欢迎区",
|
||||
path: "/page/strategy/qa/deep-research",
|
||||
defaultTitle: "开始一项深度研究",
|
||||
defaultDescription: "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。",
|
||||
},
|
||||
{
|
||||
id: "gongwen",
|
||||
title: "公文助手",
|
||||
subtitle: "公文助手工作台的欢迎区",
|
||||
path: "/page/strategy/qa/gongwen",
|
||||
defaultTitle: "开始一份公文工作",
|
||||
defaultDescription: "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。",
|
||||
},
|
||||
{
|
||||
id: "light-app-management",
|
||||
title: "应用管理",
|
||||
subtitle: "轻应用登记页的标题与说明",
|
||||
path: "/page/strategy/light-app/management",
|
||||
defaultTitle: "应用管理",
|
||||
defaultDescription: "注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。",
|
||||
},
|
||||
{
|
||||
id: "light-app-list",
|
||||
title: "应用列表",
|
||||
subtitle: "轻应用启动器的标题与说明",
|
||||
path: "/page/strategy/light-app/apps",
|
||||
defaultTitle: "应用列表",
|
||||
defaultDescription: "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。",
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
title: "智能体管理",
|
||||
subtitle: "智能体列表页的标题与说明",
|
||||
path: "/page/strategy/agents",
|
||||
defaultTitle: "智能体",
|
||||
defaultDescription: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
||||
},
|
||||
{
|
||||
id: "business-chains",
|
||||
title: "业务链管理",
|
||||
subtitle: "业务链条配置页的标题与说明",
|
||||
path: "/page/strategy/business-chains",
|
||||
defaultTitle: "业务链条配置",
|
||||
defaultDescription: "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。",
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
title: "技能管理",
|
||||
subtitle: "技能工作台的标题与说明",
|
||||
path: "/page/strategy/skills",
|
||||
defaultTitle: "技能",
|
||||
defaultDescription: "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。",
|
||||
},
|
||||
{
|
||||
id: "knowledge-management",
|
||||
title: "知识库管理",
|
||||
subtitle: "知识库工作台的标题与说明",
|
||||
path: "/page/workspace/knowledge",
|
||||
defaultTitle: "知识库管理",
|
||||
defaultDescription: "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_INPUT_PLACEHOLDER = "今天我能为你做些什么?";
|
||||
|
||||
export default function PageDescriptionSettingsPage() {
|
||||
const { data, isLoading, error } = usePageDescriptionsSettings();
|
||||
const update = useUpdatePageDescriptionsSettings();
|
||||
const [draft, setDraft] = useState<PageDescriptionsSettings>(EMPTY_SETTINGS);
|
||||
const [activeTarget, setActiveTarget] = useState<PageTarget>("qa-compact");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setDraft(data);
|
||||
}, [data]);
|
||||
|
||||
const currentPage = PAGES.find((item) => item.id === activeTarget) ?? PAGES[0];
|
||||
const isQa = activeTarget === "qa-compact" || activeTarget === "qa-full";
|
||||
const qaMode = activeTarget === "qa-compact" ? "compact" : "full";
|
||||
const qaDraft = draft.general_qa[qaMode];
|
||||
const headerKey: HeaderKey = isQa
|
||||
? "knowledge_base"
|
||||
: HEADER_KEY_BY_TARGET[activeTarget as HeaderTarget];
|
||||
const headerDraft = draft[headerKey];
|
||||
|
||||
const updateQa = (patch: Partial<GeneralQaPageDescription>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
general_qa: {
|
||||
...current.general_qa,
|
||||
[qaMode]: { ...current.general_qa[qaMode], ...patch },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const updateHeader = (patch: typeof EMPTY_HEADER) => {
|
||||
setDraft((current) => ({ ...current, [headerKey]: { ...current[headerKey], ...patch } }));
|
||||
};
|
||||
|
||||
const updateAiWritingCard = (
|
||||
index: number,
|
||||
patch: Partial<AIWritingGuideCard>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
ai_writing: {
|
||||
...current.ai_writing,
|
||||
guide_cards: current.ai_writing.guide_cards.map((card, cardIndex) =>
|
||||
cardIndex === index ? { ...card, ...patch } : card,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetActive = () => {
|
||||
if (isQa) updateQa(EMPTY_QA);
|
||||
else if (activeTarget === "ai-writing") {
|
||||
setDraft((current) => ({ ...current, ai_writing: EMPTY_AI_WRITING }));
|
||||
} else updateHeader(EMPTY_HEADER);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const normalizeQa = (value: GeneralQaPageDescription): GeneralQaPageDescription => ({
|
||||
welcome_title: value.welcome_title.trim(),
|
||||
welcome_description: value.welcome_description.trim(),
|
||||
input_placeholder: value.input_placeholder.trim(),
|
||||
});
|
||||
const normalizeHeader = (value: typeof EMPTY_HEADER) => ({
|
||||
title: value.title.trim(),
|
||||
description: value.description.trim(),
|
||||
});
|
||||
const normalizeAiWriting = (value: AIWritingPageDescription) => ({
|
||||
...normalizeHeader(value),
|
||||
guide_cards: value.guide_cards.map((card) => ({
|
||||
title: card.title.trim(),
|
||||
description: card.description.trim(),
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
general_qa: { full: normalizeQa(draft.general_qa.full), compact: normalizeQa(draft.general_qa.compact) },
|
||||
knowledge_base: normalizeHeader(draft.knowledge_base),
|
||||
ai_writing: normalizeAiWriting(draft.ai_writing),
|
||||
strategy_projects: normalizeHeader(draft.strategy_projects),
|
||||
deep_research: normalizeHeader(draft.deep_research),
|
||||
gongwen: normalizeHeader(draft.gongwen),
|
||||
light_app_management: normalizeHeader(draft.light_app_management),
|
||||
light_app_list: normalizeHeader(draft.light_app_list),
|
||||
agents: normalizeHeader(draft.agents),
|
||||
business_chains: normalizeHeader(draft.business_chains),
|
||||
skills: normalizeHeader(draft.skills),
|
||||
knowledge_management: normalizeHeader(draft.knowledge_management),
|
||||
});
|
||||
toast.success("页面描述已保存,重新进入对应页面后即可查看新文案。");
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "保存页面描述配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
const title = isQa ? qaDraft.welcome_title : headerDraft.title;
|
||||
const description = isQa ? qaDraft.welcome_description : headerDraft.description;
|
||||
const previewTitle = title || currentPage.defaultTitle;
|
||||
const previewDescription = description || currentPage.defaultDescription;
|
||||
const previewPlaceholder = qaDraft.input_placeholder || DEFAULT_INPUT_PLACEHOLDER;
|
||||
const aiWritingPreviewCards = draft.ai_writing.guide_cards.map((card, index) => ({
|
||||
title: card.title || DEFAULT_AI_WRITING_GUIDE_CARDS[index].title,
|
||||
description: card.description || DEFAULT_AI_WRITING_GUIDE_CARDS[index].description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6 sm:py-8 lg:overflow-hidden">
|
||||
<header className="shrink-0">
|
||||
<h1 className="text-xl font-semibold tracking-tight">页面描述配置</h1>
|
||||
<p className="text-muted-foreground mt-1 max-w-3xl text-sm leading-relaxed">
|
||||
按页面维护用户可见的标题和说明文字。通用问答的简洁模式与全量模式可分别配置;留空会沿用页面默认文案。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-72 items-center justify-center" aria-label="正在加载页面描述配置">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed p-8 text-center text-sm">加载页面描述配置失败:{error.message}</div>
|
||||
) : (
|
||||
<div className="grid gap-5 lg:min-h-0 lg:flex-1 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside className="rounded-xl border bg-card p-3 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
|
||||
<p className="text-muted-foreground sticky top-0 z-10 bg-card px-2 pb-2 text-xs font-medium tracking-wide">可配置页面</p>
|
||||
<div className="grid gap-1">
|
||||
{PAGES.map((item) => {
|
||||
const active = activeTarget === item.id;
|
||||
const Icon = item.id.startsWith("qa-")
|
||||
? MonitorIcon
|
||||
: item.id === "knowledge-base"
|
||||
? DatabaseIcon
|
||||
: item.id === "ai-writing"
|
||||
? PenLineIcon
|
||||
: item.id === "strategy-projects"
|
||||
? FolderKanbanIcon
|
||||
: item.id === "deep-research"
|
||||
? TelescopeIcon
|
||||
: item.id === "gongwen"
|
||||
? LandmarkIcon
|
||||
: item.id === "light-app-management"
|
||||
? Layout
|
||||
: item.id === "light-app-list"
|
||||
? List
|
||||
: item.id === "agents"
|
||||
? BotIcon
|
||||
: item.id === "business-chains"
|
||||
? GitBranchIcon
|
||||
: item.id === "skills"
|
||||
? SparklesIcon
|
||||
: DatabaseIcon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTarget(item.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
active ? "bg-primary/10 text-foreground ring-1 ring-primary/20" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md", active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0"><span className="block text-sm font-medium">{item.title}</span><span className="mt-0.5 block text-xs leading-relaxed">{item.subtitle}</span></span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 space-y-5 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
|
||||
<section className="rounded-xl border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2"><FileTextIcon className="text-primary size-4" aria-hidden /><h2 className="text-base font-semibold">{currentPage.title}</h2></div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">配置 <code className="font-mono text-xs">{currentPage.path}</code> 的页面文案。</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetActive} disabled={update.isPending}><RotateCcwIcon className="mr-1.5 size-4" aria-hidden />恢复默认文案</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-sm font-medium">{isQa ? "欢迎标题" : "页面标题"}</span>
|
||||
<span className="text-muted-foreground text-xs">留空时使用系统默认文案。</span>
|
||||
<Input value={title} maxLength={80} onChange={(event) => isQa ? updateQa({ welcome_title: event.target.value }) : updateHeader({ title: event.target.value, description: headerDraft.description })} placeholder={currentPage.defaultTitle} />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-sm font-medium">说明文字</span>
|
||||
<span className="text-muted-foreground text-xs">建议用一句话说明该页面能为用户做什么。</span>
|
||||
<Textarea rows={4} value={description} maxLength={500} onChange={(event) => isQa ? updateQa({ welcome_description: event.target.value }) : updateHeader({ title: headerDraft.title, description: event.target.value })} placeholder={currentPage.defaultDescription} />
|
||||
</label>
|
||||
{isQa && <label className="grid gap-1.5"><span className="text-sm font-medium">输入框提示语</span><span className="text-muted-foreground text-xs">在用户开始输入前显示于问答输入框中。</span><Input value={qaDraft.input_placeholder} maxLength={120} onChange={(event) => updateQa({ input_placeholder: event.target.value })} placeholder={DEFAULT_INPUT_PLACEHOLDER} /></label>}
|
||||
{activeTarget === "ai-writing" && (
|
||||
<fieldset className="grid gap-3 rounded-lg border bg-muted/20 p-4">
|
||||
<legend className="px-1 text-sm font-medium">引导卡片</legend>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
三张卡片按页面既有顺序展示;可分别修改标题和说明,留空则保留默认文案。
|
||||
</p>
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
{draft.ai_writing.guide_cards.map((card, index) => (
|
||||
<section key={index} className="grid gap-3 rounded-md border bg-card p-3">
|
||||
<h3 className="text-sm font-medium">卡片 {index + 1}</h3>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">标题</span>
|
||||
<Input
|
||||
value={card.title}
|
||||
maxLength={80}
|
||||
onChange={(event) =>
|
||||
updateAiWritingCard(index, { title: event.target.value })
|
||||
}
|
||||
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].title}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">说明</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={card.description}
|
||||
maxLength={240}
|
||||
onChange={(event) =>
|
||||
updateAiWritingCard(index, {
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].description}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-muted/25 p-4 sm:p-5" aria-live="polite">
|
||||
<div className="mb-4 flex items-center gap-2"><MonitorIcon className="text-muted-foreground size-4" aria-hidden /><h2 className="text-sm font-semibold">页面预览</h2><span className="text-muted-foreground text-xs">{currentPage.title}</span></div>
|
||||
<div className="rounded-lg border bg-background px-5 py-8 text-center shadow-sm sm:px-10">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{previewTitle}</h3>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-relaxed">{previewDescription}</p>
|
||||
{isQa && <div className="text-muted-foreground mt-6 rounded-xl border bg-card px-4 py-3 text-left text-sm shadow-xs">{previewPlaceholder}</div>}
|
||||
{activeTarget === "ai-writing" && (
|
||||
<div className="mt-6 grid gap-3 text-left md:grid-cols-3">
|
||||
{aiWritingPreviewCards.map((card, index) => (
|
||||
<div key={index} className="rounded-lg border bg-card p-3">
|
||||
<p className="text-sm font-semibold">{index + 1}. {card.title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">{card.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end"><Button type="button" onClick={handleSave} disabled={update.isPending} className={cn("min-w-28", update.isPending && "cursor-wait")}>{update.isPending ? <Loader2Icon className="mr-1.5 size-4 animate-spin" aria-hidden /> : <SaveIcon className="mr-1.5 size-4" aria-hidden />}保存配置</Button></div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,9 @@ const emptyForm = {
|
||||
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退
|
||||
// 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。
|
||||
html_template_extract: true,
|
||||
// 「抽取信息写回模板数据」开关:默认关闭。开启后执行时把抽取结果写回所选模板的
|
||||
// data.json(默认数据持久化,页面/后续任务沿用);系统名称等 MANIFEST 不受影响。
|
||||
html_template_writeback: false,
|
||||
};
|
||||
|
||||
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
|
||||
@@ -334,6 +337,8 @@ export default function ScheduledTasksPage({
|
||||
html_model_name: html.model_name ?? "",
|
||||
// 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。
|
||||
html_template_extract: html.template_extract !== false,
|
||||
// 老任务未存该键视为关闭。
|
||||
html_template_writeback: html.template_data_writeback === true,
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -410,6 +415,10 @@ export default function ScheduledTasksPage({
|
||||
template_extract: form.html_reference_favorite_id.startsWith("tpl:")
|
||||
? form.html_template_extract
|
||||
: false,
|
||||
// 仅当参考是内置模板且有抽取时有意义:抽取结果写回模板 data.json(系统设置不动)。
|
||||
template_data_writeback: form.html_reference_favorite_id.startsWith("tpl:")
|
||||
? form.html_template_writeback
|
||||
: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1424,6 +1433,24 @@ export default function ScheduledTasksPage({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 参考选了内置模板(tpl:<id>)且开启抽取时:抽取结果写回模板默认数据。 */}
|
||||
{form.html_reference_favorite_id.startsWith("tpl:") &&
|
||||
selectedTplRef?.convertible !== false && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">抽取信息写回模板数据</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后执行时会把按任务要求抽取到的信息写回所选模板的默认数据(data.json):本次页面、模板管理页预览与后续任务都基于更新后的数据。系统名称等系统设置不受影响。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.html_template_writeback}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_template_writeback: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">生成模型</p>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { HistoryIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useSkillVersioningSettings,
|
||||
useUpdateSkillVersioningSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function SkillVersioningSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = useSkillVersioningSettings();
|
||||
const update = useUpdateSkillVersioningSettings();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setEnabled(data.enabled);
|
||||
}, [data]);
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await update.mutateAsync({ enabled });
|
||||
toast.success(enabled ? "技能迭代已开启" : "技能迭代已关闭");
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
|
||||
自定义技能迭代
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
|
||||
开启后,同名自定义技能上传时可选择“迭代为新版本”或“直接覆盖”,并可在技能详情中切换版本。
|
||||
旧版本不会提供给大模型,技能调用次数也不会因迭代或切换而清零。关闭后保持原有覆盖逻辑。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-12 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<HistoryIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">启用自定义技能迭代</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
仅作用于 custom 技能。第一次开启时会为现有自定义技能建立初始版本,公共/内置技能不参与迭代。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label="启用自定义技能迭代"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/40 text-muted-foreground rounded-lg border px-4 py-3 text-xs leading-relaxed">
|
||||
“迭代为新版本”会保留完整旧技能包;“直接覆盖”会按旧逻辑替换内容,并从当前上传内容重新作为第 1 版开始记录。
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void handleSave()} disabled={update.isPending}>
|
||||
{update.isPending && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
|
||||
export default function SkillsPage({
|
||||
restrictWxTypes = false,
|
||||
@@ -13,6 +14,8 @@ export default function SkillsPage({
|
||||
restrictWxTypes?: boolean;
|
||||
} = {}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.skills;
|
||||
const tab = useMemo(
|
||||
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
|
||||
[searchParams],
|
||||
@@ -33,9 +36,9 @@ export default function SkillsPage({
|
||||
>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">技能</h1>
|
||||
<h1 className="text-xl font-semibold">{pageDescription?.title || "技能"}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
|
||||
{pageDescription?.description || "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。"}
|
||||
</p>
|
||||
</div>
|
||||
<TabsList>
|
||||
|
||||
@@ -60,6 +60,9 @@ const AdminUserLeaderboardSettingsPage = lazy(
|
||||
);
|
||||
const MenuManagementPage = lazy(() => import("./MenuManagementPage"));
|
||||
const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage"));
|
||||
const PageDescriptionSettingsPage = lazy(
|
||||
() => import("./PageDescriptionSettingsPage"),
|
||||
);
|
||||
const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage"));
|
||||
const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage"));
|
||||
const LlmWikiKnowledgePage = lazy(() => import("./LlmWikiKnowledgePage"));
|
||||
@@ -1381,6 +1384,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/page-descriptions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PageDescriptionSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
useOrdinaryKnowledgeRetrievalSettings,
|
||||
usePageDescriptionsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { PageHeaderDescription } from "@/core/system-settings/api";
|
||||
import { normalizeKnowledgeRetrievalMode, resolveKnowledgeRetrievalRequestContext, resolveSmartKnowledgeRetrievalAvailability, type KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
|
||||
@@ -672,6 +676,8 @@ function DeepResearchWorkbenchInner({
|
||||
const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState<Record<string, string[]>>({});
|
||||
const [classicMode, setClassicMode] = useState(false);
|
||||
const retrievalSettings = useOrdinaryKnowledgeRetrievalSettings();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const deepResearchPageDescription = pageDescriptions.data?.deep_research;
|
||||
const [draftRetrievalMode, setDraftRetrievalMode] = useState<KnowledgeRetrievalMode>("classic");
|
||||
const [draftKnowledgeBaseIds, setDraftKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
@@ -3261,7 +3267,10 @@ function DeepResearchWorkbenchInner({
|
||||
<main className="flex h-full min-w-0 flex-col">
|
||||
{!session || (isChatSession && collectorEmpty) ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<ResearchWelcome onStart={() => document.getElementById("deep-research-composer")?.focus()} />
|
||||
<ResearchWelcome
|
||||
pageDescription={deepResearchPageDescription}
|
||||
onStart={() => document.getElementById("deep-research-composer")?.focus()}
|
||||
/>
|
||||
</div>
|
||||
) : isChatSession ? (
|
||||
<>
|
||||
@@ -3786,12 +3795,22 @@ function StatusBadge({ status }: { status: DeepResearchSession["status"] }) {
|
||||
return <Badge variant="outline" className={`border-0 px-2 py-0.5 text-[11px] ${tone}`}>{STATUS_LABELS[status]}</Badge>;
|
||||
}
|
||||
|
||||
function ResearchWelcome({ onStart }: { onStart: () => void }) {
|
||||
function ResearchWelcome({
|
||||
pageDescription,
|
||||
onStart,
|
||||
}: {
|
||||
pageDescription?: PageHeaderDescription;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[440px] flex-col items-center justify-center text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Telescope className="h-7 w-7" /></div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">开始一项深度研究</h1>
|
||||
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{pageDescription?.title || "开始一项深度研究"}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">
|
||||
{pageDescription?.description || "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。"}
|
||||
</p>
|
||||
<Button className="mt-6" onClick={onStart}><Sparkles className="mr-2 h-4 w-4" />输入研究课题</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useLocalSettings } from "@/core/settings/hooks";
|
||||
import type { PageHeaderDescription } from "@/core/system-settings/api";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
stopActiveThreadBeforeClarificationReply,
|
||||
useRenameThread,
|
||||
@@ -248,6 +250,8 @@ function GongwenWorkbenchInner() {
|
||||
const documentQuery = useGongwenDocument(selectedDocumentId);
|
||||
const actions = useGongwenActions();
|
||||
const renameGongwenThread = useRenameThread();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const gongwenPageDescription = pageDescriptions.data?.gongwen;
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const templates = templatesQuery.data ?? [];
|
||||
@@ -618,6 +622,7 @@ function GongwenWorkbenchInner() {
|
||||
return <>
|
||||
{view === "chat" ? (
|
||||
<GongwenAgentConversationLayout
|
||||
pageDescription={gongwenPageDescription}
|
||||
templates={templates}
|
||||
documents={documents}
|
||||
documentsLoading={documentsQuery.isLoading}
|
||||
@@ -755,6 +760,7 @@ function GongwenConversationLayout(props: {
|
||||
}
|
||||
|
||||
function GongwenAgentConversationLayout(props: {
|
||||
pageDescription?: PageHeaderDescription;
|
||||
templates: GongwenTemplate[];
|
||||
documents: GongwenDocument[];
|
||||
documentsLoading: boolean;
|
||||
@@ -846,7 +852,7 @@ function GongwenAgentConversationLayout(props: {
|
||||
scrollButtonClassName="bottom-3"
|
||||
onClarificationSubmit={props.onClarificationSubmit}
|
||||
onEditResubmit={props.onEditResubmit}
|
||||
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">开始一份公文工作</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
|
||||
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">{props.pageDescription?.title || "开始一份公文工作"}</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">{props.pageDescription?.description || "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。"}</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
|
||||
|
||||
<div className="shrink-0 border-t border-border/70 bg-background px-4 py-4 sm:px-6">
|
||||
<div className="space-y-3 rounded-2xl border border-border bg-card p-3 shadow-[0_8px_30px_rgba(0,0,0,0.06)]">
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 结构化模板「编辑数据」:按模块编辑 data.json(DATA 顶层键,中文显示名来自后端
|
||||
* 按 MANIFEST.页面 解析)与 manifest.json(「系统设置」模块:系统名称/导航页面/
|
||||
* 交互规则),保存整体 PUT 持久化到后台。
|
||||
*
|
||||
* 性能要点:每个模块的草稿就是一段 JSON 文本(字符串比较即脏检查,不在每次按键
|
||||
* 时对整份数据做 stringify/parse);校验只在输入的模块上即时做,保存前再统一解析。
|
||||
* MANIFEST 仅在修改过时随保存一起提交(未动不回写)。操作按钮放在顶部工具栏,
|
||||
* 编辑区占满剩余高度、自身滚动。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button, Textarea } from "tdesign-react";
|
||||
|
||||
import {
|
||||
useHtmlTemplateActions,
|
||||
useHtmlTemplateData,
|
||||
type HtmlTemplateModuleInfo,
|
||||
} from "@/strategy-components/api/html-templates";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MANIFEST_KEY = "__manifest__";
|
||||
const MANIFEST_LABEL = "系统设置";
|
||||
|
||||
export default function HtmlTemplateDataEditor({
|
||||
templateId,
|
||||
}: {
|
||||
templateId: string;
|
||||
}) {
|
||||
const actions = useHtmlTemplateActions();
|
||||
const { data: payload, isLoading } = useHtmlTemplateData(templateId, true);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
/** DATA 各模块一段 pretty JSON 文本;MANIFEST 单独一段。 */
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [manifestDraft, setManifestDraft] = useState("");
|
||||
const [baselines, setBaselines] = useState<Record<string, string>>({});
|
||||
const [manifestBaseline, setManifestBaseline] = useState("");
|
||||
const [errors, setErrors] = useState<Record<string, string | null>>({});
|
||||
const [manifestError, setManifestError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const m of payload.modules) {
|
||||
next[m.key] = JSON.stringify(payload.data[m.key] ?? null, null, 2);
|
||||
}
|
||||
setDrafts(next);
|
||||
setBaselines(next);
|
||||
setErrors({});
|
||||
const manifestText = JSON.stringify(payload.manifest ?? {}, null, 2);
|
||||
setManifestDraft(manifestText);
|
||||
setManifestBaseline(manifestText);
|
||||
setManifestError(null);
|
||||
setActiveKey((prev) => (prev != null && prev in next ? prev : (payload.modules[0]?.key ?? null)));
|
||||
}, [payload]);
|
||||
|
||||
const modules: HtmlTemplateModuleInfo[] = useMemo(
|
||||
() => [...(payload?.modules ?? []), { key: MANIFEST_KEY, label: MANIFEST_LABEL }],
|
||||
[payload],
|
||||
);
|
||||
const moduleOf = (key: string) => modules.find((m) => m.key === key);
|
||||
const activeText =
|
||||
activeKey === MANIFEST_KEY ? manifestDraft : (activeKey ? (drafts[activeKey] ?? "") : "");
|
||||
const activeError =
|
||||
activeKey === MANIFEST_KEY ? manifestError : (activeKey ? errors[activeKey] : null);
|
||||
const manifestDirty = manifestDraft !== manifestBaseline;
|
||||
const dirty =
|
||||
manifestDirty ||
|
||||
modules.some(
|
||||
(m) => m.key !== MANIFEST_KEY && (drafts[m.key] ?? "") !== (baselines[m.key] ?? ""),
|
||||
);
|
||||
const allValid =
|
||||
!manifestError && modules.every((m) => m.key === MANIFEST_KEY || !errors[m.key]);
|
||||
|
||||
/** 模块条数徽标:直接取服务端数据的计数(保存后 refetch 自动更新)。 */
|
||||
const countOf = useMemo(() => {
|
||||
const counts: Record<string, string> = {};
|
||||
for (const m of payload?.modules ?? []) {
|
||||
const value = payload?.data[m.key];
|
||||
counts[m.key] = Array.isArray(value) ? `${value.length} 条` : "对象";
|
||||
}
|
||||
counts[MANIFEST_KEY] = "配置";
|
||||
return counts;
|
||||
}, [payload]);
|
||||
|
||||
const handleInput = (key: string, text: string) => {
|
||||
if (key === MANIFEST_KEY) {
|
||||
setManifestDraft(text);
|
||||
try {
|
||||
JSON.parse(text);
|
||||
setManifestError(null);
|
||||
} catch (error) {
|
||||
setManifestError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDrafts((prev) => ({ ...prev, [key]: text }));
|
||||
try {
|
||||
JSON.parse(text);
|
||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||
} catch (error) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!payload) return;
|
||||
if (!allValid) {
|
||||
toast.error("存在 JSON 无效的模块,请先修正");
|
||||
return;
|
||||
}
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const m of payload.modules) {
|
||||
data[m.key] = JSON.parse(drafts[m.key] ?? "null");
|
||||
}
|
||||
const manifest = manifestDirty
|
||||
? (JSON.parse(manifestDraft) as Record<string, unknown>)
|
||||
: undefined;
|
||||
try {
|
||||
await actions.updateData.mutateAsync({ id: templateId, data, manifest });
|
||||
toast.success(
|
||||
manifestDirty
|
||||
? "默认数据与系统设置已保存到后台,定时任务与预览即时生效"
|
||||
: "默认数据已保存到后台(data.json),定时任务与预览即时生效",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setDrafts(baselines);
|
||||
setErrors({});
|
||||
setManifestDraft(manifestBaseline);
|
||||
setManifestError(null);
|
||||
};
|
||||
|
||||
if (isLoading || !payload) {
|
||||
return <div className="ht-preview-state">正在加载模板数据…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ht-data">
|
||||
<nav className="ht-data-nav">
|
||||
{modules.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
className={cn("ht-data-nav-item", m.key === activeKey && "is-active")}
|
||||
onClick={() => setActiveKey(m.key)}
|
||||
>
|
||||
<span className="ht-data-nav-label">
|
||||
<span className="ht-data-nav-label-text">{m.label}</span>
|
||||
<span className="ht-data-nav-count">{countOf[m.key] ?? "—"}</span>
|
||||
</span>
|
||||
<span className="ht-data-nav-key">
|
||||
{m.key === MANIFEST_KEY ? "manifest.json" : m.key}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ht-data-main">
|
||||
<div className="ht-data-toolbar">
|
||||
<div className="ht-data-toolbar-title">
|
||||
<b>{activeKey ? (moduleOf(activeKey)?.label ?? activeKey) : ""}</b>
|
||||
<span>{activeKey === MANIFEST_KEY ? "manifest.json" : activeKey}</span>
|
||||
</div>
|
||||
<div className="ht-toolbar-gap" />
|
||||
<span className={cn("ht-data-status", activeError ? "is-bad" : "is-ok")}>
|
||||
{activeError ? `JSON 无效:${activeError}` : "JSON 有效"}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
disabled={!dirty || actions.updateData.isPending}
|
||||
onClick={discard}
|
||||
>
|
||||
放弃修改
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
theme="primary"
|
||||
loading={actions.updateData.isPending}
|
||||
disabled={!dirty || !allValid}
|
||||
onClick={() => void handleSave()}
|
||||
title="保存后持久化到后台;页面产物右下角的齿轮编辑器仅改浏览器本地,不落库"
|
||||
>
|
||||
保存到后台
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ht-data-scroll ht-data-json">
|
||||
<Textarea
|
||||
value={activeText}
|
||||
onChange={(value) => {
|
||||
if (activeKey) handleInput(activeKey, String(value ?? ""));
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder="模块 JSON…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 结构化模板「字段与模块」页签:
|
||||
*
|
||||
* - 字段显示:按页面分组展示字段目录(fields.json,含中文说明),开关控制每个
|
||||
* 字段在页面详情/分区中的显示与隐藏(按显示名全局生效);保存 = 隐藏名单整体
|
||||
* 写入 manifest 的「字段显示」。
|
||||
* - 自定义模块:自然语言描述 → 后台调用模型按模板主题生成 HTML 片段(服务端
|
||||
* 消毒)→ 完成后自动加入列表(生成期间不阻塞界面,列表显示生成中占位卡);
|
||||
* 也支持手动粘贴 HTML。保存进 manifest 的「自定义模块」,页面把它渲染到指定
|
||||
* 页面(tab)底部;定时任务产物自动带上。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff, Loader2, MoreHorizontal, Pencil, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button, Dialog, Input, Select, Switch, Tag, Textarea } from "tdesign-react";
|
||||
|
||||
import {
|
||||
generateCustomModuleDirect,
|
||||
invalidateHtmlTemplateCaches,
|
||||
updateHtmlTemplateCustomModules,
|
||||
updateHtmlTemplateFieldVisibility,
|
||||
useHtmlTemplateFields,
|
||||
type HtmlTemplateCustomModule,
|
||||
type HtmlTemplateFieldInfo,
|
||||
} from "@/strategy-components/api/html-templates";
|
||||
|
||||
/** 预览用最小样式:复刻模板的关键类(panel/metric/chip 等),让片段所见即所得。 */
|
||||
const PREVIEW_CSS = `
|
||||
body{margin:0;background:#f5f5f6;color:#262626;font-family:"PingFang SC","Microsoft YaHei",sans-serif;padding:12px}
|
||||
.panel{background:#fff;border:1px solid #e5e5e5;border-radius:16px;padding:18px;margin-bottom:14px}
|
||||
.panel-title{font-weight:800;margin-bottom:14px;display:flex;align-items:center}
|
||||
.panel-title:before{content:"";width:5px;height:18px;background:#c8102e;border-radius:5px;margin-right:9px}
|
||||
.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.metric{background:#fff;border:1px solid #e5e5e5;border-radius:16px;padding:17px}
|
||||
.metric-label{font-size:13px;color:#737373}.metric-value{font-size:30px;font-weight:800;color:#c8102e;margin-top:8px}
|
||||
.metric-unit{font-size:12px;margin-left:4px}.metric-note{font-size:12px;color:#888;margin-top:6px}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.chip{display:inline-flex;border-radius:999px;padding:4px 9px;background:#f3f3f3;color:#555;font-size:11px;margin:2px}
|
||||
.chip-red{background:#fff1f3;color:#c8102e}.chip-green{background:#edf9f2;color:#168a4a}.chip-orange{background:#fff6e8;color:#c56a00}
|
||||
.text-list{display:grid;gap:9px}.text-item{padding:10px 12px;border-left:4px solid #e5a6b1;background:#fff8f9;border-radius:7px;font-size:13px}
|
||||
.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||
.info-item{background:#fafafa;border-radius:10px;padding:11px}.info-item label{display:block;font-size:11px;color:#888;margin-bottom:5px}
|
||||
.bar-row{display:grid;grid-template-columns:150px 1fr 38px;gap:10px;align-items:center;font-size:12px;margin:10px 0}
|
||||
.bar{height:9px;background:#f1f1f1;border-radius:20px;overflow:hidden}
|
||||
.bar i{display:block;height:100%;background:linear-gradient(90deg,#c8102e,#ed6b7e);border-radius:20px}
|
||||
`;
|
||||
|
||||
function previewDoc(fragment: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>${PREVIEW_CSS}</style></head><body>${fragment}</body></html>`;
|
||||
}
|
||||
|
||||
/** 后台生成任务(不阻塞界面,完成后自动加入模块草稿)。 */
|
||||
interface GenJob {
|
||||
id: string;
|
||||
page: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type DialogMode = "ai" | "manual" | "edit";
|
||||
|
||||
export default function HtmlTemplatePageConfig({ templateId }: { templateId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: payload, isLoading } = useHtmlTemplateFields(templateId, true);
|
||||
|
||||
// 字段可见性草稿:隐藏名单(Set);抽取锁定草稿:不接受抽取更新的字段名单
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
const [extractLocked, setExtractLocked] = useState<Set<string>>(new Set());
|
||||
// 自定义模块草稿 + 后台生成任务
|
||||
const [modulesDraft, setModulesDraft] = useState<HtmlTemplateCustomModule[]>([]);
|
||||
const [jobs, setJobs] = useState<GenJob[]>([]);
|
||||
const [dirtyModules, setDirtyModules] = useState(false);
|
||||
const [savingFields, setSavingFields] = useState(false);
|
||||
const [savingModules, setSavingModules] = useState(false);
|
||||
|
||||
// 弹窗(ai 生成 / 手动粘贴 / 编辑既有模块)
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("ai");
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
const [form, setForm] = useState({ page: "", title: "", description: "" });
|
||||
const [htmlText, setHtmlText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
setHidden(new Set(payload.fields.filter((f) => !f.visible).map((f) => f.label)));
|
||||
setExtractLocked(new Set(payload.fields.filter((f) => !f.extract).map((f) => f.label)));
|
||||
setModulesDraft(payload.customModules);
|
||||
setDirtyModules(false);
|
||||
}, [payload]);
|
||||
|
||||
const pages = payload?.pages ?? [];
|
||||
const pageLabel = (key: string) => pages.find((p) => p.key === key)?.label ?? key;
|
||||
|
||||
/** 按字段主页面分组,组序跟随 manifest.页面。 */
|
||||
const groups = useMemo(() => {
|
||||
const order = pages.map((p) => p.label);
|
||||
const map = new Map<string, HtmlTemplateFieldInfo[]>();
|
||||
for (const f of payload?.fields ?? []) {
|
||||
const key = f.pages[0] ?? "其它";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(f);
|
||||
}
|
||||
const sortedKeys = [...map.keys()].sort((a, b) => {
|
||||
const ia = order.indexOf(a);
|
||||
const ib = order.indexOf(b);
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||||
});
|
||||
return sortedKeys
|
||||
.map((k) => [k, map.get(k)!] as const)
|
||||
.filter(([, list]) => list.length > 0);
|
||||
}, [payload, pages]);
|
||||
|
||||
const fieldsDirty = useMemo(() => {
|
||||
if (!payload) return false;
|
||||
const serverHidden = new Set(payload.fields.filter((f) => !f.visible).map((f) => f.label));
|
||||
const serverLocked = new Set(payload.fields.filter((f) => !f.extract).map((f) => f.label));
|
||||
return (
|
||||
serverHidden.size !== hidden.size ||
|
||||
[...serverHidden].some((label) => !hidden.has(label)) ||
|
||||
serverLocked.size !== extractLocked.size ||
|
||||
[...serverLocked].some((label) => !extractLocked.has(label))
|
||||
);
|
||||
}, [payload, hidden, extractLocked]);
|
||||
|
||||
const toggleField = (label: string, visible: boolean) => {
|
||||
setHidden((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (visible) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveFields = async () => {
|
||||
setSavingFields(true);
|
||||
try {
|
||||
await updateHtmlTemplateFieldVisibility(templateId, [...hidden], [...extractLocked]);
|
||||
toast.success("字段显示设置已保存,页面预览与定时任务产物即时生效");
|
||||
await invalidateHtmlTemplateCaches(queryClient);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSavingFields(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveModules = async () => {
|
||||
setSavingModules(true);
|
||||
try {
|
||||
await updateHtmlTemplateCustomModules(templateId, modulesDraft);
|
||||
toast.success("自定义模块已保存,页面预览与定时任务产物即时生效");
|
||||
await invalidateHtmlTemplateCaches(queryClient);
|
||||
setDirtyModules(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSavingModules(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** AI 后台生成:点完立即关弹窗,完成后自动加入列表并提示。 */
|
||||
const startBackgroundGeneration = () => {
|
||||
if (!form.page) {
|
||||
toast.error("请选择挂载页面");
|
||||
return;
|
||||
}
|
||||
if (form.description.trim().length < 4) {
|
||||
toast.error("请先描述想要新增的展示内容");
|
||||
return;
|
||||
}
|
||||
const job: GenJob = {
|
||||
id: `job-${Date.now()}`,
|
||||
page: form.page,
|
||||
title: form.title.trim() || "自定义模块",
|
||||
description: form.description.trim(),
|
||||
};
|
||||
setJobs((prev) => [...prev, job]);
|
||||
setDialogOpen(false);
|
||||
toast.info("已在后台开始生成,完成后会自动加入列表");
|
||||
generateCustomModuleDirect(templateId, {
|
||||
page: job.page,
|
||||
title: job.title,
|
||||
description: job.description,
|
||||
})
|
||||
.then((html) => {
|
||||
setModulesDraft((prev) => [
|
||||
...prev,
|
||||
{ 编号: job.id, 页面: job.page, 标题: job.title, html },
|
||||
]);
|
||||
setDirtyModules(true);
|
||||
toast.success(`「${job.title}」已生成并加入列表,预览确认后点「保存模块列表」`);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`「${job.title}」生成失败:` + (error instanceof Error ? error.message : String(error)));
|
||||
})
|
||||
.finally(() => {
|
||||
setJobs((prev) => prev.filter((j) => j.id !== job.id));
|
||||
});
|
||||
};
|
||||
|
||||
const openDialog = (mode: DialogMode, index?: number) => {
|
||||
setDialogMode(mode);
|
||||
if (mode === "edit" && index != null) {
|
||||
const m = modulesDraft[index];
|
||||
setEditIndex(index);
|
||||
setForm({ page: m.页面, title: m.标题 ?? "", description: "" });
|
||||
setHtmlText(m.html);
|
||||
} else {
|
||||
setEditIndex(null);
|
||||
setForm({ page: pages[0]?.key ?? "", title: "", description: "" });
|
||||
setHtmlText("");
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
/** 手动粘贴 / 编辑模式提交:直接写入草稿。 */
|
||||
const submitManual = () => {
|
||||
if (!form.page) {
|
||||
toast.error("请选择挂载页面");
|
||||
return;
|
||||
}
|
||||
const html = htmlText.trim();
|
||||
if (!html) {
|
||||
toast.error("请粘贴或填写模块 HTML");
|
||||
return;
|
||||
}
|
||||
const item: HtmlTemplateCustomModule = {
|
||||
编号: `custom-${Date.now()}`,
|
||||
页面: form.page,
|
||||
标题: form.title.trim() || "自定义模块",
|
||||
html,
|
||||
};
|
||||
if (dialogMode === "edit" && editIndex != null) {
|
||||
setModulesDraft((prev) =>
|
||||
prev.map((m, i) => (i === editIndex ? { ...m, 页面: item.页面, 标题: item.标题, html: item.html } : m)),
|
||||
);
|
||||
toast.success("模块已更新,记得点「保存模块列表」");
|
||||
} else {
|
||||
setModulesDraft((prev) => [...prev, item]);
|
||||
toast.success("已加入列表,记得点「保存模块列表」");
|
||||
}
|
||||
setDirtyModules(true);
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
if (isLoading || !payload) {
|
||||
return <div className="ht-preview-state">正在加载字段目录…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ht-pageconfig">
|
||||
{/* ── 字段显示 ── */}
|
||||
<section className="ht-pc-block">
|
||||
<header className="ht-pc-block-head">
|
||||
<div>
|
||||
<h3>字段显示</h3>
|
||||
<p>「显示」控制字段在页面上的渲染;「抽取」控制定时任务抽取的数据是否更新该字段(关闭 = 保持模板现有数据,默认开启)。按字段名全局生效,同名字段一起切换。</p>
|
||||
</div>
|
||||
<div className="ht-pc-block-ops">
|
||||
<span className="ht-pane-hint">
|
||||
{payload.fields.length - hidden.size} / {payload.fields.length} 显示中
|
||||
</span>
|
||||
<Button size="small" variant="outline" onClick={() => setHidden(new Set())}>
|
||||
全部显示
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
theme="primary"
|
||||
loading={savingFields}
|
||||
disabled={!fieldsDirty}
|
||||
onClick={() => void handleSaveFields()}
|
||||
>
|
||||
保存字段设置
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ht-pc-scroll">
|
||||
{groups.map(([group, fields]) => (
|
||||
<div className="ht-pc-group" key={group}>
|
||||
<div className="ht-pc-group-title">{group}</div>
|
||||
<div className="ht-pc-fields">
|
||||
{fields.map((f) => (
|
||||
<FieldCard
|
||||
key={f.label}
|
||||
field={f}
|
||||
visible={!hidden.has(f.label)}
|
||||
extract={!extractLocked.has(f.label)}
|
||||
onToggleVisible={(v) => toggleField(f.label, v)}
|
||||
onToggleExtract={(v) => {
|
||||
setExtractLocked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (v) next.delete(f.label);
|
||||
else next.add(f.label);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 自定义模块 ── */}
|
||||
<section className="ht-pc-block">
|
||||
<header className="ht-pc-block-head">
|
||||
<div>
|
||||
<h3>自定义模块</h3>
|
||||
<p>用一段自然语言描述想新增的展示内容,AI 在后台按模板主题生成紧凑模块(指标卡/短条目,不写大段文字);也支持手动粘贴 HTML。</p>
|
||||
</div>
|
||||
<div className="ht-pc-block-ops">
|
||||
<Button size="small" variant="outline" onClick={() => openDialog("manual")}>
|
||||
<Plus size={14} /> 粘贴 HTML
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
theme="primary"
|
||||
onClick={() => openDialog("ai")}
|
||||
>
|
||||
<Sparkles size={14} /> AI 生成模块
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
theme="primary"
|
||||
loading={savingModules}
|
||||
disabled={!dirtyModules}
|
||||
onClick={() => void handleSaveModules()}
|
||||
>
|
||||
保存模块列表
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ht-pc-scroll">
|
||||
{modulesDraft.length === 0 && jobs.length === 0 && (
|
||||
<div className="ht-list-empty">
|
||||
暂无自定义模块,点击「AI 生成模块」用一句话描述想要的展示内容。
|
||||
</div>
|
||||
)}
|
||||
{jobs.map((job) => (
|
||||
<div className="ht-pc-module ht-pc-job" key={job.id}>
|
||||
<div className="ht-pc-module-head">
|
||||
<Loader2 size={14} className="ht-spin" />
|
||||
<b>{job.title}</b>
|
||||
<Tag size="small" variant="light">
|
||||
挂载:{pageLabel(job.page)}
|
||||
</Tag>
|
||||
<span className="ht-pane-hint">AI 生成中,完成后自动加入…</span>
|
||||
</div>
|
||||
<div className="ht-pc-job-desc">{job.description}</div>
|
||||
</div>
|
||||
))}
|
||||
{modulesDraft.map((m, i) => (
|
||||
<div className="ht-pc-module" key={m.编号 ?? i}>
|
||||
<div className="ht-pc-module-head">
|
||||
<b>{m.标题 || "自定义模块"}</b>
|
||||
<Tag size="small" variant="light">
|
||||
挂载:{pageLabel(m.页面)}
|
||||
</Tag>
|
||||
<div className="ht-ops-gap" />
|
||||
<Button size="small" variant="text" onClick={() => openDialog("edit", i)}>
|
||||
<Pencil size={14} /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="danger"
|
||||
onClick={() => {
|
||||
setModulesDraft((prev) => prev.filter((_, j) => j !== i));
|
||||
setDirtyModules(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
<iframe
|
||||
title={`模块预览 ${m.标题 ?? i}`}
|
||||
srcDoc={previewDoc(m.html)}
|
||||
sandbox="allow-same-origin"
|
||||
className="ht-pc-module-preview"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 模块弹窗(AI 生成 / 手动粘贴 / 编辑) */}
|
||||
<Dialog
|
||||
className="ht-create-dialog"
|
||||
visible={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
header={
|
||||
dialogMode === "ai"
|
||||
? "AI 生成自定义模块"
|
||||
: dialogMode === "manual"
|
||||
? "粘贴 HTML 新增模块"
|
||||
: "编辑自定义模块"
|
||||
}
|
||||
width={720}
|
||||
footer={
|
||||
dialogMode === "ai" ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="primary" onClick={startBackgroundGeneration}>
|
||||
<Sparkles size={14} /> 后台生成
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="primary" onClick={submitManual}>
|
||||
{dialogMode === "edit" ? "保存修改" : "添加到列表"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="ht-create-grid">
|
||||
<div className="ht-create-row">
|
||||
<label className="ht-create-field">
|
||||
<span>挂载页面</span>
|
||||
<Select
|
||||
value={form.page}
|
||||
options={pages.map((p) => ({ label: p.label, value: p.key }))}
|
||||
onChange={(v) => setForm((s) => ({ ...s, page: String(v ?? "") }))}
|
||||
placeholder="选择挂载的页面"
|
||||
/>
|
||||
</label>
|
||||
<label className="ht-create-field">
|
||||
<span>模块标题(可选)</span>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(v) => setForm((s) => ({ ...s, title: String(v ?? "") }))}
|
||||
placeholder="例如:近期动态速览"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{dialogMode === "ai" ? (
|
||||
<label className="ht-create-field">
|
||||
<span>想新增什么内容?(AI 会生成紧凑的指标卡/短条目模块,不会写大段文字)</span>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(v) => setForm((s) => ({ ...s, description: String(v ?? "") }))}
|
||||
placeholder="例如:三张指标卡——本季度事件 42 起、同比 +18%、重点区域 5 个;下面 3 条最新动态短条目。"
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="ht-create-field">
|
||||
<span>模块 HTML(最外层一个 div;禁止 script/事件属性)</span>
|
||||
<Textarea
|
||||
value={htmlText}
|
||||
onChange={(v) => setHtmlText(String(v ?? ""))}
|
||||
spellCheck={false}
|
||||
placeholder='<div class="panel">…</div>'
|
||||
autosize={{ minRows: 6, maxRows: 14 }}
|
||||
className="ht-mono"
|
||||
/>
|
||||
</label>
|
||||
{htmlText.trim() && (
|
||||
<>
|
||||
<div className="ht-pc-module-head">
|
||||
<b>预览</b>
|
||||
</div>
|
||||
<iframe
|
||||
title="模块预览"
|
||||
srcDoc={previewDoc(htmlText)}
|
||||
sandbox="allow-same-origin"
|
||||
className="ht-pc-module-preview"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单个字段卡:右上角「⋮」菜单里放「页面显示 / 接受抽取更新」两个选项。 */
|
||||
function FieldCard({
|
||||
field,
|
||||
visible,
|
||||
extract,
|
||||
onToggleVisible,
|
||||
onToggleExtract,
|
||||
}: {
|
||||
field: HtmlTemplateFieldInfo;
|
||||
visible: boolean;
|
||||
extract: boolean;
|
||||
onToggleVisible: (v: boolean) => void;
|
||||
onToggleExtract: (v: boolean) => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const close = (ev: MouseEvent) => {
|
||||
if (!(ev.target as HTMLElement | null)?.closest?.(".ht-pc-field")) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div className={"ht-pc-field" + (visible ? "" : " is-off")}>
|
||||
<div className="ht-pc-field-copy">
|
||||
<span className="ht-pc-field-label">
|
||||
{visible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
{field.label}
|
||||
{field.pages.length > 1 && (
|
||||
<span className="ht-pc-field-pages">也在 {field.pages.slice(1).join("、")}</span>
|
||||
)}
|
||||
{!field.hasKey && <span className="ht-pc-tag is-layout">布局分区</span>}
|
||||
{!visible && <span className="ht-pc-tag is-off">已隐藏</span>}
|
||||
{!extract && <span className="ht-pc-tag is-lock">抽取已锁定</span>}
|
||||
</span>
|
||||
<span className="ht-pc-field-desc">{field.description}</span>
|
||||
</div>
|
||||
<div className="ht-pc-field-menu">
|
||||
<button
|
||||
type="button"
|
||||
className={"ht-pc-kebab" + (menuOpen ? " is-open" : "")}
|
||||
aria-label="字段选项"
|
||||
title="字段选项"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="ht-pc-menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ht-pc-menu-row">
|
||||
<div className="ht-pc-menu-copy">
|
||||
<b>页面显示</b>
|
||||
<span>{visible ? "当前显示;关闭后页面不再渲染该字段" : "当前隐藏;开启后页面恢复渲染该字段"}</span>
|
||||
</div>
|
||||
<Switch value={visible} onChange={(v) => onToggleVisible(!!v)} />
|
||||
</div>
|
||||
<div className="ht-pc-menu-row">
|
||||
<div className="ht-pc-menu-copy">
|
||||
<b>接受抽取更新</b>
|
||||
<span>
|
||||
{field.hasKey
|
||||
? extract
|
||||
? "定时任务抽取的数据会更新该字段(默认)"
|
||||
: "已锁定:抽取数据不覆盖,保持模板现有数据"
|
||||
: "布局分区无独立数据字段,开关状态仅作标记"}
|
||||
</span>
|
||||
</div>
|
||||
<Switch value={extract} onChange={(v) => onToggleExtract(!!v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
/* ============================================================
|
||||
HTML 模板管理 —— 与任务分析定时(task-analysis.css)同一套
|
||||
浅红色 UI 规范主题。ht- 前缀 + 复用 --mat-* 变量命名。
|
||||
============================================================ */
|
||||
|
||||
.ht-page,
|
||||
.ht-create-dialog {
|
||||
--zs-001: #cf1322;
|
||||
--zs-001-02: #fef6f6;
|
||||
--zs-001-03: #feeded;
|
||||
--zs-001-04: #fddadc;
|
||||
--zs-001-09: #f5222d;
|
||||
--zs-001-10: #bc0d1e;
|
||||
--zs-001-11: #a8071a;
|
||||
--fs-001-03: #fff6ea;
|
||||
--fs-001-09: #ff9900;
|
||||
--fs-001-10: #c66d00;
|
||||
--fs-002-02: #f0faf6;
|
||||
--fs-002-03: #eefcf7;
|
||||
--fs-002-09: #2fd69a;
|
||||
--fs-002-10: #189d72;
|
||||
--fs-002-11: #0e6650;
|
||||
--fs-003-03: #effafe;
|
||||
--fs-003-09: #45c3f3;
|
||||
--fs-004: #222222;
|
||||
--fs-004-02: #fafafa;
|
||||
--fs-004-03: #f5f5f5;
|
||||
--fs-004-04: #e8e8e8;
|
||||
--fs-004-05: #dbdbdb;
|
||||
--fs-004-06: #c1c1c1;
|
||||
--fs-004-07: #a6a6a6;
|
||||
--fs-004-08: #7f7f7f;
|
||||
--fs-004-09: #575757;
|
||||
--fs-004-10: #3d3d3d;
|
||||
|
||||
--mat-primary: var(--zs-001-09);
|
||||
--mat-primary-deep: var(--zs-001);
|
||||
--mat-primary-light: var(--zs-001-02);
|
||||
--mat-ink: var(--fs-004);
|
||||
--mat-ink-soft: var(--fs-004-09);
|
||||
--mat-ink-muted: var(--fs-004-07);
|
||||
--mat-canvas: var(--fs-004-02);
|
||||
--mat-panel: #ffffff;
|
||||
--mat-line: var(--fs-004-04);
|
||||
--mat-line-strong: var(--fs-004-05);
|
||||
--mat-shadow-1: 0 2px 8px rgba(34, 34, 34, 0.08);
|
||||
--mat-shadow-2: 0 4px 16px rgba(34, 34, 34, 0.12);
|
||||
--mat-font: "Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC",
|
||||
"PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--mat-font-mono: ui-monospace, SFMono-Regular, Consolas, "Courier New", monospace;
|
||||
|
||||
--td-brand-color: #f5222d;
|
||||
--td-brand-color-hover: #cf1322;
|
||||
--td-brand-color-focus: #feeded;
|
||||
--td-brand-color-active: #a8071a;
|
||||
--td-brand-color-disabled: #fa9196;
|
||||
--td-brand-color-light: #fef6f6;
|
||||
--td-brand-color-light-hover: #feeded;
|
||||
}
|
||||
|
||||
.ht-create-dialog {
|
||||
color: var(--mat-ink);
|
||||
font-family: var(--mat-font);
|
||||
}
|
||||
|
||||
.ht-page {
|
||||
background: var(--mat-canvas);
|
||||
color: var(--mat-ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--mat-font);
|
||||
/* 撑满外层面板(官方入口 .official-main-panel 有确定高度)且自身不滚动:
|
||||
页头固定,左列/预览/编辑区各自内部滚动。 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ht-page *,
|
||||
.ht-create-dialog * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* TDesign 控件圆角统一 2px(与 ta- 规范一致) */
|
||||
.ht-page .t-button,
|
||||
.ht-create-dialog .t-button,
|
||||
.ht-page .t-input,
|
||||
.ht-create-dialog .t-input,
|
||||
.ht-page .t-textarea__inner,
|
||||
.ht-create-dialog .t-textarea__inner,
|
||||
.ht-page .t-select,
|
||||
.ht-create-dialog .t-select,
|
||||
.ht-page .t-tag,
|
||||
.ht-create-dialog .t-tag,
|
||||
.ht-page .t-radio-group,
|
||||
.ht-page .t-pagination {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 页头 ---------- */
|
||||
|
||||
.ht-page-head {
|
||||
align-items: flex-end;
|
||||
background: var(--mat-panel);
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 20px;
|
||||
}
|
||||
|
||||
.ht-page-head h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.ht-page-head-copy > p {
|
||||
color: var(--mat-ink-soft);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.ht-page-head-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ht-page-head .t-button__text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ---------- 主体:左列表 + 右详情 ---------- */
|
||||
|
||||
.ht-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.ht-list {
|
||||
background: var(--mat-panel);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--mat-shadow-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 296px;
|
||||
}
|
||||
|
||||
.ht-list-search {
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ht-list-items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ht-tpl-item {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ht-tpl-item:hover {
|
||||
background: var(--fs-004-02);
|
||||
}
|
||||
|
||||
.ht-tpl-item.is-active {
|
||||
background: var(--mat-primary-light);
|
||||
border-color: var(--zs-001-04);
|
||||
}
|
||||
|
||||
.ht-tpl-item-top {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ht-tpl-item-top svg {
|
||||
color: var(--fs-004-07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ht-tpl-item.is-active .ht-tpl-item-top svg {
|
||||
color: var(--mat-primary);
|
||||
}
|
||||
|
||||
.ht-tpl-item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ht-tpl-item.is-active .ht-tpl-item-name {
|
||||
color: var(--mat-primary-deep);
|
||||
}
|
||||
|
||||
.ht-tpl-item-meta {
|
||||
color: var(--fs-004-07);
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ht-tpl-item-meta .ht-dot {
|
||||
background: var(--fs-002-09);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ht-tpl-item-meta .ht-dot.is-struct {
|
||||
background: var(--fs-003-09);
|
||||
}
|
||||
|
||||
.ht-list-empty {
|
||||
color: var(--fs-004-07);
|
||||
font-size: 13px;
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- 详情面板 ---------- */
|
||||
|
||||
.ht-detail {
|
||||
background: var(--mat-panel);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--mat-shadow-1);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ht-detail-head {
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 14px;
|
||||
}
|
||||
|
||||
.ht-detail-tags {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ht-detail-meta {
|
||||
color: var(--fs-004-07);
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ht-detail-form {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ht-detail-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ht-detail-field > span {
|
||||
color: var(--mat-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ht-detail-ops {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.ht-detail-ops .ht-ops-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ht-detail-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* TDesign Tabs 适配 */
|
||||
.ht-detail-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tab-panel {
|
||||
/* 面板是 TDesign 的普通块级容器:改为受约束的 flex 列,让预览/编辑数据/编辑 HTML
|
||||
各自的内部滚动生效(否则内容把面板撑高,左侧模块导航永远不溢出、无滚动条)。 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__header {
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__nav-item {
|
||||
border-radius: 0;
|
||||
color: var(--mat-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__nav-item:hover {
|
||||
color: var(--mat-primary);
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__nav-item.t-is-active {
|
||||
color: var(--mat-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .t-tabs__nav-item.t-is-active::after {
|
||||
background: var(--mat-primary);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.ht-detail-tabs .ht-tab-label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ht-spin {
|
||||
animation: ht-rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ht-rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 预览 */
|
||||
.ht-preview-pane {
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-preview-pane iframe {
|
||||
border: 0;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ht-preview-state {
|
||||
align-items: center;
|
||||
color: var(--fs-004-07);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 编辑 HTML */
|
||||
.ht-html-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ht-html-pane textarea {
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* TDesign Textarea 外层容器参与 flex 拉伸,编辑框占满面板剩余高度 */
|
||||
.ht-html-pane .t-textarea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-html-pane .t-textarea__inner {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-pane-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ht-pane-hint {
|
||||
color: var(--fs-004-07);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ht-pane-hint code {
|
||||
background: var(--fs-004-03);
|
||||
border-radius: 2px;
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
/* ---------- 编辑数据(友好化) ---------- */
|
||||
|
||||
.ht-data {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-data-nav {
|
||||
border-right: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
width: 208px;
|
||||
}
|
||||
|
||||
.ht-data-nav-item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.ht-data-nav-item:hover {
|
||||
background: var(--fs-004-02);
|
||||
}
|
||||
|
||||
.ht-data-nav-item.is-active {
|
||||
background: var(--mat-primary-light);
|
||||
}
|
||||
|
||||
.ht-data-nav-label {
|
||||
align-items: center;
|
||||
color: var(--mat-ink);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ht-data-nav-label-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ht-data-nav-item.is-active .ht-data-nav-label {
|
||||
color: var(--mat-primary-deep);
|
||||
}
|
||||
|
||||
.ht-data-nav-count {
|
||||
background: var(--fs-004-03);
|
||||
border-radius: 999px;
|
||||
color: var(--fs-004-08);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.ht-data-nav-item.is-active .ht-data-nav-count {
|
||||
background: var(--zs-001-04);
|
||||
color: var(--mat-primary-deep);
|
||||
}
|
||||
|
||||
.ht-data-nav-key {
|
||||
color: var(--fs-004-07);
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ht-data-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ht-data-toolbar {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ht-data-toolbar-title {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ht-data-toolbar-title b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ht-data-toolbar-title span {
|
||||
color: var(--fs-004-07);
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ht-data-toolbar .ht-toolbar-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ht-data-status {
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.ht-data-status.is-ok {
|
||||
background: var(--fs-002-03);
|
||||
color: var(--fs-002-10);
|
||||
}
|
||||
|
||||
.ht-data-status.is-bad {
|
||||
background: var(--zs-001-03);
|
||||
color: var(--mat-primary-deep);
|
||||
}
|
||||
|
||||
.ht-data-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ht-data-json {
|
||||
display: flex;
|
||||
overflow: hidden; /* 容器不滚,textarea 自己滚 */
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
/* TDesign Textarea 外层参与 flex 拉伸,内层 textarea 撑满高度 */
|
||||
.ht-data-json .t-textarea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ht-data-json textarea {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- 字段与模块(页面配置页签) ---------- */
|
||||
|
||||
.ht-pageconfig {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ht-pc-block {
|
||||
background: var(--fs-004-02);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ht-pc-block-head {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.ht-pc-block-head h3 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.ht-pc-block-head p {
|
||||
color: var(--fs-004-08);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.ht-pc-block-ops {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ht-pc-scroll {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.ht-pc-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ht-pc-group-title {
|
||||
color: var(--mat-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ht-pc-group-title::before {
|
||||
background: var(--mat-primary);
|
||||
border-radius: 2px;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
height: 13px;
|
||||
margin-right: 7px;
|
||||
vertical-align: -2px;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.ht-pc-fields {
|
||||
align-items: start; /* 卡片按各自内容高度,不随行内最高卡拉伸(避免卡下大片空白) */
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
}
|
||||
|
||||
.ht-pc-field {
|
||||
align-items: center;
|
||||
align-self: start; /* 双保险:即使 grid 对齐被覆盖也不被行内拉伸 */
|
||||
background: var(--mat-panel);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
height: fit-content;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.ht-pc-field.is-off {
|
||||
background: var(--fs-004-03);
|
||||
}
|
||||
|
||||
.ht-pc-field-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ht-pc-field-label {
|
||||
align-items: center;
|
||||
color: var(--mat-ink);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ht-pc-field-label svg {
|
||||
color: var(--fs-004-07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ht-pc-field.is-off .ht-pc-field-label {
|
||||
color: var(--fs-004-08);
|
||||
}
|
||||
|
||||
.ht-pc-field-pages {
|
||||
color: var(--fs-004-07);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ht-pc-field-desc {
|
||||
color: var(--fs-004-08);
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ht-pc-module {
|
||||
background: var(--mat-panel);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ht-pc-module-head {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--mat-line);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.ht-pc-module-head b {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ht-pc-module-preview {
|
||||
background: var(--fs-004-02);
|
||||
border: 0;
|
||||
display: block;
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ht-mono textarea {
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- 新建弹窗 ---------- */
|
||||
|
||||
.ht-create-dialog .t-dialog__body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ht-create-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ht-create-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.ht-create-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ht-create-field > span {
|
||||
color: var(--mat-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ht-create-field textarea {
|
||||
font-family: var(--mat-font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ht-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ht-list {
|
||||
width: 100%;
|
||||
max-height: 264px;
|
||||
}
|
||||
|
||||
.ht-list-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
}
|
||||
|
||||
.ht-create-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 后台生成任务占位卡 */
|
||||
.ht-pc-job .ht-pc-job-desc {
|
||||
color: var(--fs-004-08);
|
||||
font-size: 12px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.ht-pc-job svg {
|
||||
color: var(--mat-primary);
|
||||
}
|
||||
|
||||
/* 字段卡 ⋮ 菜单与状态标签 */
|
||||
.ht-pc-field-menu {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ht-pc-kebab {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--fs-004-07);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.ht-pc-kebab:hover,
|
||||
.ht-pc-kebab.is-open {
|
||||
background: var(--fs-004-03);
|
||||
color: var(--mat-ink);
|
||||
}
|
||||
|
||||
.ht-pc-menu {
|
||||
background: var(--mat-panel);
|
||||
border: 1px solid var(--mat-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--mat-shadow-2);
|
||||
min-width: 250px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.ht-pc-menu-row {
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.ht-pc-menu-row + .ht-pc-menu-row {
|
||||
border-top: 1px solid var(--fs-004-03);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ht-pc-menu-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ht-pc-menu-copy b {
|
||||
color: var(--mat-ink);
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ht-pc-menu-copy span {
|
||||
color: var(--fs-004-08);
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ht-pc-tag {
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.ht-pc-tag.is-off {
|
||||
background: var(--fs-004-03);
|
||||
color: var(--fs-004-08);
|
||||
}
|
||||
|
||||
.ht-pc-tag.is-lock {
|
||||
background: var(--fs-001-03);
|
||||
color: var(--fs-001-10);
|
||||
}
|
||||
|
||||
.ht-pc-tag.is-layout {
|
||||
background: var(--fs-004-03);
|
||||
color: var(--fs-004-07);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import {
|
||||
isRoundtableCapacityFailure,
|
||||
RoundtableRoomFullError,
|
||||
isRoundtableBusyFailure,
|
||||
isRoundtableRoomFullFailure,
|
||||
roundtableErrorForDisplay,
|
||||
roundtableRoomFullError,
|
||||
} from "@/roundtable-planning/lib/capacity-error";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
|
||||
@@ -12,29 +14,33 @@ import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/s
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
function roomFullError(
|
||||
function throwRoundtableRunError(
|
||||
rawDetail: string,
|
||||
status: number | null,
|
||||
context: string,
|
||||
): RoundtableRoomFullError {
|
||||
let activeCount = 0;
|
||||
let maxRooms = 0;
|
||||
try {
|
||||
const body = JSON.parse(rawDetail) as {
|
||||
detail?: { active_count?: number; max_rooms?: number };
|
||||
active_count?: number;
|
||||
max_rooms?: number;
|
||||
};
|
||||
activeCount = body.detail?.active_count ?? body.active_count ?? 0;
|
||||
maxRooms = body.detail?.max_rooms ?? body.max_rooms ?? 0;
|
||||
} catch {
|
||||
// 包装后的 502 / SSE 文本通常不是纯 JSON;计数仅供诊断,不影响统一提示。
|
||||
): never {
|
||||
if (isRoundtableRoomFullFailure(status, rawDetail)) {
|
||||
throw roundtableRoomFullError(rawDetail, status, context);
|
||||
}
|
||||
console.error(`[roundtable-capacity] ${context}`, {
|
||||
status,
|
||||
detail: rawDetail,
|
||||
});
|
||||
return new RoundtableRoomFullError(activeCount, maxRooms);
|
||||
throw new Error(
|
||||
roundtableErrorForDisplay(rawDetail, status) || "会商暂时无法继续,请稍后重试",
|
||||
);
|
||||
}
|
||||
|
||||
function isClassifiedRoundtableFailure(
|
||||
status: number | null,
|
||||
detail: string,
|
||||
): boolean {
|
||||
if (status === 409 || status === 429) return true;
|
||||
if (isRoundtableRoomFullFailure(status, detail) || isRoundtableBusyFailure(status, detail)) {
|
||||
return true;
|
||||
}
|
||||
const displayed = roundtableErrorForDisplay(detail, status);
|
||||
return displayed != null && displayed !== detail;
|
||||
}
|
||||
|
||||
export interface SeatReadyEvent {
|
||||
@@ -119,8 +125,8 @@ export async function initMultiAgent(
|
||||
httpStatus: res.status,
|
||||
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
|
||||
});
|
||||
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
|
||||
throw roomFullError(text, res.status, "init HTTP request rejected");
|
||||
if (isClassifiedRoundtableFailure(res.status, text)) {
|
||||
throwRoundtableRunError(text, res.status, "init HTTP request rejected");
|
||||
}
|
||||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||
}
|
||||
@@ -215,8 +221,8 @@ export async function initMultiAgent(
|
||||
stage: "step2_init", event: "init_error_frame",
|
||||
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
|
||||
});
|
||||
if (isRoundtableCapacityFailure(null, errorDetail)) {
|
||||
throw roomFullError(errorDetail, null, "init SSE error frame");
|
||||
if (isClassifiedRoundtableFailure(null, errorDetail)) {
|
||||
throwRoundtableRunError(errorDetail, null, "init SSE error frame");
|
||||
}
|
||||
throw new Error(`init multi-agent failed: ${errorDetail}`);
|
||||
}
|
||||
@@ -627,8 +633,8 @@ export async function streamMultiAgent(
|
||||
detail: { agentType: req.agentType },
|
||||
});
|
||||
reported = true;
|
||||
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
|
||||
throw roomFullError(text, res.status, "run/stream HTTP request rejected");
|
||||
if (isClassifiedRoundtableFailure(res.status, text)) {
|
||||
throwRoundtableRunError(text, res.status, "run/stream HTTP request rejected");
|
||||
}
|
||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
@@ -998,9 +1004,14 @@ export async function streamMultiAgent(
|
||||
agentName: req.displayName || req.agentName,
|
||||
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
|
||||
});
|
||||
if (isRoundtableCapacityFailure(null, result.content)) {
|
||||
const displayed = roundtableErrorForDisplay(result.content);
|
||||
if (
|
||||
isRoundtableRoomFullFailure(null, result.content) ||
|
||||
isRoundtableBusyFailure(null, result.content) ||
|
||||
(displayed != null && displayed !== result.content)
|
||||
) {
|
||||
reported = true;
|
||||
throw roomFullError(
|
||||
throwRoundtableRunError(
|
||||
result.content,
|
||||
null,
|
||||
`run/stream SSE error frame (${req.displayName || req.agentName})`,
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
isRoundtableCapacityFailure,
|
||||
isRoundtableRoomFullError,
|
||||
RoundtableRoomFullError,
|
||||
roundtableCardFailure,
|
||||
roundtableErrorForDisplay,
|
||||
ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
} from "@/roundtable-planning/lib/capacity-error";
|
||||
|
||||
@@ -20,9 +20,9 @@ import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import {
|
||||
isRoundtableCapacityFailure,
|
||||
RoundtableRoomFullError,
|
||||
roundtableErrorForDisplay,
|
||||
ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
RoundtableRoomFullError,
|
||||
sanitizeRoundtableDialogue,
|
||||
} from "../lib/capacity-error";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
import { seatModeToRunParams } from "../lib/seat-mode";
|
||||
@@ -57,6 +57,15 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
logCapacityError(ctx, text);
|
||||
throw new RoundtableRoomFullError();
|
||||
}
|
||||
const displayed = roundtableErrorForDisplay(text, res.status);
|
||||
if (
|
||||
res.status === 409 ||
|
||||
res.status === 429 ||
|
||||
(displayed != null && displayed !== text)
|
||||
) {
|
||||
logCapacityError(ctx, text);
|
||||
throw new Error(displayed || "会商暂时无法继续,请稍后重试");
|
||||
}
|
||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
@@ -84,14 +93,15 @@ function withAvatar(nodes: unknown): DispatchChainNode[] {
|
||||
function mapJob(raw: Record<string, unknown>): RoundtableJob {
|
||||
const id = String(raw.id ?? "");
|
||||
const rawError = (raw.error as string | null) ?? null;
|
||||
if (isRoundtableCapacityFailure(null, rawError)) {
|
||||
logCapacityError(id, rawError ?? "");
|
||||
if (rawError && (isRoundtableCapacityFailure(null, rawError) || roundtableErrorForDisplay(rawError) !== rawError)) {
|
||||
logCapacityError(id, rawError);
|
||||
}
|
||||
const dialogues = Array.isArray(raw.dialogues)
|
||||
? (raw.dialogues as JobDialogue[]).map((dialogue) => {
|
||||
if (!isRoundtableCapacityFailure(null, dialogue.content)) return dialogue;
|
||||
const sanitized = sanitizeRoundtableDialogue(dialogue.content);
|
||||
if (sanitized === dialogue.content) return dialogue;
|
||||
logCapacityError(id, dialogue.content);
|
||||
return { ...dialogue, content: ROUNDTABLE_ROOM_FULL_MESSAGE };
|
||||
return { ...dialogue, content: sanitized };
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
|
||||
@@ -25,7 +25,9 @@ import { Sparkles } from "lucide-react";
|
||||
|
||||
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import {
|
||||
isRoundtableCapacityFailure,
|
||||
isRoundtableBusyFailure,
|
||||
isRoundtableRoomFullFailure,
|
||||
roundtableErrorForDisplay,
|
||||
ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
} from "@/roundtable-planning/lib/capacity-error";
|
||||
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
|
||||
@@ -88,10 +90,13 @@ export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
|
||||
if (job.status === "done") {
|
||||
setNotice("后台研讨已完成!可在「历史记录」查看结论与报告");
|
||||
} else if (job.status === "error") {
|
||||
const displayed = roundtableErrorForDisplay(job.error);
|
||||
setNotice(
|
||||
isRoundtableCapacityFailure(null, job.error)
|
||||
isRoundtableRoomFullFailure(null, job.error)
|
||||
? ROUNDTABLE_ROOM_FULL_MESSAGE
|
||||
: "后台任务执行出错,请在「历史记录」中查看",
|
||||
: isRoundtableBusyFailure(null, job.error) || (displayed && displayed !== job.error)
|
||||
? displayed || ROUNDTABLE_ROOM_FULL_MESSAGE
|
||||
: "后台任务执行出错,请在「历史记录」中查看",
|
||||
);
|
||||
}
|
||||
// cancelled:用户主动取消,不提示。
|
||||
|
||||
@@ -82,6 +82,10 @@ import type {
|
||||
Step2Dialogue,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { chainToDagPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import {
|
||||
resolveDagMilestoneStates,
|
||||
resolveLiveDagNodeStates,
|
||||
} from "@/roundtable-planning/lib/dag-flow-state";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
|
||||
import {
|
||||
@@ -368,61 +372,29 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
},
|
||||
[agentNameToRole],
|
||||
);
|
||||
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
|
||||
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
|
||||
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
|
||||
const effectiveDagNodeStates = useMemo(() => {
|
||||
const merged: Record<string, ChainNodeState> = {};
|
||||
const deliveredNames = new Set(
|
||||
step2RoundtableDialogues
|
||||
.filter(
|
||||
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
|
||||
)
|
||||
.map((d) => String(d.sender ?? "")),
|
||||
);
|
||||
for (const [aid, meta] of Object.entries(agentNameToRole)) {
|
||||
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
|
||||
}
|
||||
// 串行业务链(chain 模式):流式中的席位气泡点亮为 active(dagNodeStates 只在 dag
|
||||
// 编排里维护,chain 靠对话推导;完成后由上面的「子智能体交付」规则置 done)。
|
||||
if (orchestrationMode === "chain") {
|
||||
const streamingSenders = new Set(
|
||||
step2RoundtableDialogues
|
||||
.filter((d) => d.streaming)
|
||||
.map((d) => String(d.sender ?? "")),
|
||||
);
|
||||
for (const [aid, meta] of Object.entries(agentNameToRole)) {
|
||||
if (streamingSenders.has(meta.displayName)) merged[aid.toLowerCase()] = "active";
|
||||
}
|
||||
}
|
||||
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
|
||||
for (const s of parallelBatch) {
|
||||
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
|
||||
}
|
||||
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
|
||||
return merged;
|
||||
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch, orchestrationMode]);
|
||||
// 流程图跟当前发言走:席位气泡已经在输出时,节点就是进行中。
|
||||
// dagNodeStates 里残留的 pending 不能把这个实时状态盖掉,否则总控会一直转圈。
|
||||
const effectiveDagNodeStates = useMemo(
|
||||
() =>
|
||||
resolveLiveDagNodeStates({
|
||||
agentNameToRole,
|
||||
dialogues: step2RoundtableDialogues,
|
||||
dagNodeStates,
|
||||
parallelBatch,
|
||||
}),
|
||||
[dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch],
|
||||
);
|
||||
|
||||
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
|
||||
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
|
||||
const states = Object.values(effectiveDagNodeStates);
|
||||
const anyActive = states.includes("active");
|
||||
const anyStarted = states.some((s) => s !== "pending");
|
||||
const allDone = states.length > 0 && states.every((s) => s === "done");
|
||||
const coordinator: ChainNodeState = anyActive
|
||||
? "done"
|
||||
: isOrchestrating
|
||||
? "active"
|
||||
: anyStarted
|
||||
? "done"
|
||||
: "pending";
|
||||
const consensus: ChainNodeState = hasConsensus
|
||||
? "done"
|
||||
: allDone
|
||||
? "active"
|
||||
: "pending";
|
||||
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
|
||||
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
|
||||
const { dagCoordinatorState, dagConsensusState } = useMemo(
|
||||
() =>
|
||||
resolveDagMilestoneStates({
|
||||
seatStates: effectiveDagNodeStates,
|
||||
dialogues: step2RoundtableDialogues,
|
||||
isOrchestrating,
|
||||
hasConsensus,
|
||||
}),
|
||||
[effectiveDagNodeStates, step2RoundtableDialogues, isOrchestrating, hasConsensus],
|
||||
);
|
||||
|
||||
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
|
||||
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/workspace/artifacts";
|
||||
import {
|
||||
isRoundtableRoomFullError,
|
||||
roundtableCardFailure,
|
||||
type RoundtableRoomFullError,
|
||||
} from "@/roundtable-planning/api/rooms";
|
||||
import {
|
||||
@@ -2356,7 +2357,8 @@ export function useStep2Orchestration(
|
||||
continue;
|
||||
}
|
||||
console.error(`[special:${a.agentId}] error:`, err);
|
||||
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(a.bubbleId, failure.content || "(已停止)", failure.badge);
|
||||
return { content: "", aborted: false, invalid: true };
|
||||
}
|
||||
}
|
||||
@@ -2438,10 +2440,11 @@ export function useStep2Orchestration(
|
||||
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
|
||||
break;
|
||||
}
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(
|
||||
leaderBubbleId,
|
||||
`(错误) ${(err as Error).message}`,
|
||||
"调用失败",
|
||||
failure.content || "(已停止)",
|
||||
failure.badge,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -2599,7 +2602,7 @@ export function useStep2Orchestration(
|
||||
handleRoomFull(err);
|
||||
return;
|
||||
}
|
||||
triggerToast(`研讨异常: ${(err as Error).message}`);
|
||||
triggerToast(`研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
clearAllStreamingSpeakers();
|
||||
@@ -2730,10 +2733,11 @@ export function useStep2Orchestration(
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(
|
||||
leaderBubbleId,
|
||||
`(错误) ${(err as Error).message}`,
|
||||
"调用失败",
|
||||
failure.content || "(已停止)",
|
||||
failure.badge,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -2838,7 +2842,8 @@ export function useStep2Orchestration(
|
||||
throw err;
|
||||
} else {
|
||||
console.error(`[chain-special:${seat.agent_id}] error:`, err);
|
||||
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(subBubbleId, failure.content || "(已停止)", failure.badge);
|
||||
seatInvalid = true;
|
||||
}
|
||||
} finally {
|
||||
@@ -2949,7 +2954,8 @@ export function useStep2Orchestration(
|
||||
throw err;
|
||||
} else {
|
||||
console.error("chain closeout error:", err);
|
||||
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(closeoutBubbleId, failure.content || "(已停止)", failure.badge);
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(closeoutAborter);
|
||||
@@ -2961,7 +2967,7 @@ export function useStep2Orchestration(
|
||||
handleRoomFull(err);
|
||||
return;
|
||||
}
|
||||
triggerToast(`链条研讨异常: ${(err as Error).message}`);
|
||||
triggerToast(`链条研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
clearAllStreamingSpeakers();
|
||||
@@ -3138,10 +3144,11 @@ export function useStep2Orchestration(
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(
|
||||
leaderBubbleId,
|
||||
`(错误) ${(err as Error).message}`,
|
||||
"调用失败",
|
||||
failure.content || "(已停止)",
|
||||
failure.badge,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -3297,7 +3304,8 @@ export function useStep2Orchestration(
|
||||
throw err;
|
||||
} else {
|
||||
console.error(`[dag-special:${agentId}] error:`, err);
|
||||
finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
const failure = roundtableCardFailure(err);
|
||||
finalizeDialogue(bubbleId, failure.content || "(已停止)", failure.badge);
|
||||
markBatchStatus(agentId, "error");
|
||||
}
|
||||
return { seatName, invalid: true, content: "" };
|
||||
@@ -3386,7 +3394,7 @@ export function useStep2Orchestration(
|
||||
handleRoomFull(err);
|
||||
return;
|
||||
}
|
||||
triggerToast(`依赖编排研讨异常: ${(err as Error).message}`);
|
||||
triggerToast(`依赖编排研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
clearAllStreamingSpeakers();
|
||||
|
||||
@@ -2,33 +2,108 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isRoundtableBusyFailure,
|
||||
isRoundtableCapacityFailure,
|
||||
roomDecisionMessage,
|
||||
roundtableCardFailure,
|
||||
roundtableErrorForDisplay,
|
||||
RoundtableRoomFullError,
|
||||
ROUNDTABLE_BUSY_MESSAGE,
|
||||
ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
} from "./capacity-error";
|
||||
sanitizeRoundtableDialogue,
|
||||
} from "./capacity-error.ts";
|
||||
|
||||
test("recognizes a direct HTTP 429", () => {
|
||||
assert.equal(isRoundtableCapacityFailure(429, ""), true);
|
||||
test("a direct HTTP 429 is model or worker busy, not a full room", () => {
|
||||
assert.equal(isRoundtableCapacityFailure(429, ""), false);
|
||||
assert.equal(isRoundtableBusyFailure(429, ""), true);
|
||||
assert.equal(roundtableErrorForDisplay("", 429), ROUNDTABLE_BUSY_MESSAGE);
|
||||
});
|
||||
|
||||
test("recognizes a 429 wrapped by the internal run stream gateway", () => {
|
||||
test("a 429 wrapped by the internal run stream stays busy and hides the upstream URL", () => {
|
||||
const detail =
|
||||
"[502] upstream http://127.0.0.1:8001/api/threads/thread-secret/runs/stream returned HTTP 429: busy";
|
||||
assert.equal(isRoundtableCapacityFailure(502, detail), true);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_ROOM_FULL_MESSAGE);
|
||||
assert.equal(isRoundtableCapacityFailure(502, detail), false);
|
||||
assert.equal(isRoundtableBusyFailure(502, detail), true);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
|
||||
});
|
||||
|
||||
test("recognizes local concurrency and worker saturation messages", () => {
|
||||
test("recognizes local concurrency and worker saturation messages as busy", () => {
|
||||
const samples = [
|
||||
"This account has reached the concurrency limit for the selected model",
|
||||
"执行节点繁忙,请稍后重试",
|
||||
"等待执行超时,请稍后重试",
|
||||
"selected model is busy",
|
||||
];
|
||||
for (const detail of samples) {
|
||||
assert.equal(isRoundtableCapacityFailure(502, detail), false);
|
||||
assert.equal(isRoundtableBusyFailure(502, detail), true);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
test("only the room-full code opens the room-full message", () => {
|
||||
const detail = JSON.stringify({
|
||||
detail: { code: "ROUNDTABLE_ROOM_FULL", message: "当前会商房间暂时已满,请稍候片刻后再试。" },
|
||||
});
|
||||
assert.equal(isRoundtableCapacityFailure(409, detail), true);
|
||||
assert.equal(isRoundtableBusyFailure(409, detail), false);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 409), ROUNDTABLE_ROOM_FULL_MESSAGE);
|
||||
assert.equal(roomDecisionMessage("ROUNDTABLE_ROOM_FULL"), ROUNDTABLE_ROOM_FULL_MESSAGE);
|
||||
});
|
||||
|
||||
test("lease expiry and max hold keep their own messages", () => {
|
||||
const lease = JSON.stringify({ code: "ROOM_LEASE_EXPIRED" });
|
||||
const maxHold = JSON.stringify({ code: "ROOM_MAX_HOLD_EXCEEDED" });
|
||||
assert.equal(isRoundtableCapacityFailure(409, lease), false);
|
||||
assert.equal(isRoundtableCapacityFailure(409, maxHold), false);
|
||||
assert.equal(roundtableErrorForDisplay(lease, 409), roomDecisionMessage("ROOM_LEASE_EXPIRED"));
|
||||
assert.equal(
|
||||
isRoundtableCapacityFailure(502, "This account has reached the concurrency limit for the selected model"),
|
||||
true,
|
||||
roundtableErrorForDisplay(maxHold, 409),
|
||||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
|
||||
);
|
||||
assert.equal(isRoundtableCapacityFailure(502, "执行节点繁忙,请稍后重试"), true);
|
||||
assert.equal(isRoundtableCapacityFailure(502, "等待执行超时,请稍后重试"), true);
|
||||
});
|
||||
|
||||
test("a plain HTTP 409 is not treated as a full room", () => {
|
||||
const detail = "thread already has an active run";
|
||||
assert.equal(isRoundtableCapacityFailure(409, detail), false);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 409), detail);
|
||||
});
|
||||
|
||||
test("does not hide an unrelated gateway error", () => {
|
||||
const detail = "upstream returned HTTP 502: connection refused";
|
||||
assert.equal(isRoundtableCapacityFailure(502, detail), false);
|
||||
assert.equal(isRoundtableBusyFailure(502, detail), false);
|
||||
assert.equal(roundtableErrorForDisplay(detail, 502), detail);
|
||||
});
|
||||
|
||||
test("long seat deliveries that mention concurrency stay intact", () => {
|
||||
const prose = `${"席位认为当前方案需要同时考虑资源并发和预算限制,建议先完成情报核对,再决定是否扩大行动范围。".repeat(20)}`;
|
||||
assert.ok(prose.length > 800);
|
||||
assert.equal(sanitizeRoundtableDialogue(prose), prose);
|
||||
});
|
||||
|
||||
test("coordinator card does not relabel other failures as a full room", () => {
|
||||
const lease = roundtableCardFailure(
|
||||
new Error(JSON.stringify({ code: "ROOM_LEASE_EXPIRED" })),
|
||||
);
|
||||
assert.equal(lease.roomFull, false);
|
||||
assert.equal(lease.badge, "调用失败");
|
||||
assert.equal(lease.content.includes("当前房间已满"), false);
|
||||
assert.equal(lease.content.includes("会商连接已断开"), true);
|
||||
|
||||
const busy = roundtableCardFailure(new Error("执行节点繁忙,请稍后重试"));
|
||||
assert.equal(busy.badge, "调用失败");
|
||||
assert.equal(busy.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
|
||||
assert.equal(busy.content.includes(ROUNDTABLE_BUSY_MESSAGE), true);
|
||||
|
||||
const full = roundtableCardFailure(new RoundtableRoomFullError());
|
||||
assert.equal(full.roomFull, true);
|
||||
assert.equal(full.badge, "已停止");
|
||||
assert.equal(full.content.includes("调用失败"), false);
|
||||
assert.equal(full.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
|
||||
});
|
||||
|
||||
test("error-shaped dialogue with an upstream address is replaced", () => {
|
||||
const detail = "[502] upstream http://10.0.0.8/api/threads/abc/runs/stream returned HTTP 500";
|
||||
assert.equal(sanitizeRoundtableDialogue(detail), "会商暂时无法继续,请稍后重试");
|
||||
});
|
||||
@@ -1,8 +1,63 @@
|
||||
/** 会商容量不足时唯一允许展示给业务用户的文案。 */
|
||||
/** 会商名额真正用尽时,唯一允许展示给业务用户的文案。 */
|
||||
export const ROUNDTABLE_ROOM_FULL_MESSAGE = "当前房间已满,请稍后";
|
||||
|
||||
/** 模型并发或执行节点忙,不是会商房间名额。 */
|
||||
export const ROUNDTABLE_BUSY_MESSAGE = "当前模型或执行节点繁忙,请稍后重试";
|
||||
|
||||
const GENERIC_ROUNDTABLE_FAILURE = "会商暂时无法继续,请稍后重试";
|
||||
|
||||
const ROOM_DECISION_MESSAGES: Record<string, string> = {
|
||||
ROUNDTABLE_ROOM_FULL: ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
ROOM_LEASE_EXPIRED: "会商连接已断开,请重新恢复研讨。",
|
||||
ROOM_MAX_HOLD_EXCEEDED:
|
||||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
|
||||
ROOM_STORE_UNAVAILABLE: "会商并发服务暂不可用,请稍后再试。",
|
||||
ROOM_SESSION_REQUIRED: "会商会话标识缺失,请刷新页面后重试。",
|
||||
DRAFT_REQUIRED: "进入第二步需要先保存会商草稿",
|
||||
};
|
||||
|
||||
export function roomDecisionMessage(code: string | null | undefined): string | null {
|
||||
if (!code) return null;
|
||||
return ROOM_DECISION_MESSAGES[code] ?? null;
|
||||
}
|
||||
|
||||
function isBusyText(text: string): boolean {
|
||||
return (
|
||||
/returned\s+http\s+429\b/i.test(text) ||
|
||||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
|
||||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
|
||||
/\btoo\s+many\s+requests\b/i.test(text) ||
|
||||
/\bconcurrency\s+limit\b/i.test(text) ||
|
||||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
|
||||
/执行节点繁忙|等待执行超时|模型繁忙/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function readRoomCode(detail: string): string {
|
||||
const match = detail.match(/["']code["']\s*:\s*["']([A-Z0-9_]+)["']/);
|
||||
if (match?.[1]) return match[1];
|
||||
try {
|
||||
const body = JSON.parse(detail) as {
|
||||
code?: unknown;
|
||||
detail?: { code?: unknown };
|
||||
};
|
||||
const code = body.detail?.code ?? body.code;
|
||||
return typeof code === "string" ? code : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isRoomFullText(text: string, code: string): boolean {
|
||||
if (code === "ROUNDTABLE_ROOM_FULL") return true;
|
||||
return (
|
||||
text === ROUNDTABLE_ROOM_FULL_MESSAGE ||
|
||||
text.includes("当前会商房间暂时已满")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会商运行过程中遇到的容量错误。
|
||||
* 会商运行过程中遇到的名额已满。
|
||||
*
|
||||
* 原始响应只能写入控制台 / 诊断日志,不能放进 message,避免内网地址、thread id
|
||||
* 等实现细节经现有的 toast / 对话气泡 / 错误面板泄露到页面。
|
||||
@@ -27,37 +82,133 @@ export function isRoundtableRoomFullError(error: unknown): error is RoundtableRo
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 会把内部 `/runs/stream` 的 429 包装成 502 或 SSE error 帧,因此不能只看
|
||||
* 最外层 HTTP 状态。这里同时识别包装后的状态文本和本地并发闸门的稳定错误文案。
|
||||
* 总控/席位卡片上的失败展示。
|
||||
* 名额用尽不写进卡片(由房间弹窗说明,标签也不再是「调用失败」)。
|
||||
* 租约断开、模型繁忙仍标「调用失败」,但不得改写成房间已满文案。
|
||||
*/
|
||||
export function roundtableCardFailure(error: unknown): {
|
||||
roomFull: boolean;
|
||||
badge: string;
|
||||
content: string;
|
||||
} {
|
||||
if (isRoundtableRoomFullError(error)) {
|
||||
return {
|
||||
roomFull: true,
|
||||
badge: "已停止",
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
const raw = error instanceof Error ? error.message : String(error ?? "");
|
||||
const displayed = roundtableErrorForDisplay(raw);
|
||||
const message =
|
||||
displayed && displayed !== ROUNDTABLE_ROOM_FULL_MESSAGE
|
||||
? displayed
|
||||
: "会商暂时无法继续,请稍后重试";
|
||||
return {
|
||||
roomFull: false,
|
||||
badge: "调用失败",
|
||||
content: `(错误) ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 只有会商房间名额用尽才返回 true。模型繁忙、租约丢失、最长占用都不是。 */
|
||||
export function isRoundtableRoomFullFailure(
|
||||
status: number | null | undefined,
|
||||
detail: string | null | undefined,
|
||||
): boolean {
|
||||
const text = (detail ?? "").trim();
|
||||
if (!text && status !== 409) return false;
|
||||
return isRoomFullText(text, readRoomCode(text));
|
||||
}
|
||||
|
||||
/** 模型并发或执行节点忙。调用方不得据此释放会商房间。 */
|
||||
export function isRoundtableBusyFailure(
|
||||
status: number | null | undefined,
|
||||
detail: string | null | undefined,
|
||||
): boolean {
|
||||
if (isRoundtableRoomFullFailure(status, detail)) return false;
|
||||
if (status === 429) return true;
|
||||
const text = (detail ?? "").trim();
|
||||
return text ? isBusyText(text) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧名保留给调用方。语义收窄为「房间名额已满」,不再把 429 / 节点繁忙算进去。
|
||||
*/
|
||||
export function isRoundtableCapacityFailure(
|
||||
status: number | null | undefined,
|
||||
detail: string | null | undefined,
|
||||
): boolean {
|
||||
if (status === 429) return true;
|
||||
|
||||
const text = (detail ?? "").trim();
|
||||
if (!text) return false;
|
||||
if (text === ROUNDTABLE_ROOM_FULL_MESSAGE) return true;
|
||||
|
||||
return (
|
||||
/returned\s+http\s+429\b/i.test(text) ||
|
||||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
|
||||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
|
||||
/\btoo\s+many\s+requests\b/i.test(text) ||
|
||||
/\bconcurrency\s+limit\b/i.test(text) ||
|
||||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
|
||||
/执行节点繁忙|等待执行超时|并发(?:数|量)?.*(?:上限|限制)|模型繁忙/.test(text)
|
||||
);
|
||||
return isRoundtableRoomFullFailure(status, detail);
|
||||
}
|
||||
|
||||
/** 页面可展示的错误详情;容量错误永远折叠为统一文案。 */
|
||||
function readCounts(detail: string): { activeCount: number; maxRooms: number } {
|
||||
try {
|
||||
const body = JSON.parse(detail) as {
|
||||
detail?: { active_count?: number; max_rooms?: number };
|
||||
active_count?: number;
|
||||
max_rooms?: number;
|
||||
};
|
||||
return {
|
||||
activeCount: body.detail?.active_count ?? body.active_count ?? 0,
|
||||
maxRooms: body.detail?.max_rooms ?? body.max_rooms ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return { activeCount: 0, maxRooms: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export function roundtableRoomFullError(
|
||||
rawDetail: string,
|
||||
status: number | null,
|
||||
context: string,
|
||||
): RoundtableRoomFullError {
|
||||
const counts = readCounts(rawDetail);
|
||||
console.error(`[roundtable-capacity] ${context}`, {
|
||||
status,
|
||||
detail: rawDetail,
|
||||
});
|
||||
return new RoundtableRoomFullError(counts.activeCount, counts.maxRooms);
|
||||
}
|
||||
|
||||
function containsTransportDetail(text: string): boolean {
|
||||
return /https?:\/\//i.test(text) || /\/api\/threads\//i.test(text);
|
||||
}
|
||||
|
||||
/** 页面可展示的错误详情。房间已满、节点繁忙和已知房间错误都折叠成固定文案。 */
|
||||
export function roundtableErrorForDisplay(
|
||||
detail: string | null | undefined,
|
||||
status?: number | null,
|
||||
): string | null {
|
||||
if (detail == null) return null;
|
||||
return isRoundtableCapacityFailure(status, detail)
|
||||
? ROUNDTABLE_ROOM_FULL_MESSAGE
|
||||
: detail;
|
||||
if (detail == null && status !== 429) return null;
|
||||
const text = (detail ?? "").trim();
|
||||
if (isRoundtableRoomFullFailure(status, text)) return ROUNDTABLE_ROOM_FULL_MESSAGE;
|
||||
if (isRoundtableBusyFailure(status, text)) return ROUNDTABLE_BUSY_MESSAGE;
|
||||
if (!text) return detail ?? null;
|
||||
const code = readRoomCode(text);
|
||||
const known = ROOM_DECISION_MESSAGES[code];
|
||||
if (known) return known;
|
||||
if (containsTransportDetail(text)) return GENERIC_ROUNDTABLE_FAILURE;
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只改写明显是错误载荷的短文本。正常席位交付即使提到「并发」「限制」也保持原文。
|
||||
*/
|
||||
export function sanitizeRoundtableDialogue(content: string): string {
|
||||
const text = content.trim();
|
||||
if (!text) return content;
|
||||
const errorShaped =
|
||||
text.length <= 800 ||
|
||||
/^\[\d{3}\]/.test(text) ||
|
||||
text.includes("returned HTTP") ||
|
||||
text.includes("ROUNDTABLE_ROOM_") ||
|
||||
text.includes("ROOM_LEASE_") ||
|
||||
text.includes("ROOM_MAX_HOLD_") ||
|
||||
text.includes("ROOM_STORE_") ||
|
||||
text.includes("ROOM_SESSION_");
|
||||
if (!errorShaped) return content;
|
||||
const display = roundtableErrorForDisplay(text);
|
||||
if (!display || display === text) return content;
|
||||
return display;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDagMilestoneStates, resolveLiveDagNodeStates } from "./dag-flow-state.ts";
|
||||
|
||||
const roles = {
|
||||
"seat-01": { displayName: "测试01" },
|
||||
"seat-02": { displayName: "测试02" },
|
||||
};
|
||||
|
||||
test("a streaming first seat takes the in-progress marker off the coordinator", () => {
|
||||
const seats = resolveLiveDagNodeStates({
|
||||
agentNameToRole: roles,
|
||||
dialogues: [
|
||||
{ sender: "总控协调", streaming: false, confidence: "派活说明", content: "派给测试01" },
|
||||
{ sender: "测试01", streaming: true, confidence: "调度中", content: "" },
|
||||
],
|
||||
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
|
||||
parallelBatch: [],
|
||||
});
|
||||
assert.equal(seats["seat-01"], "active");
|
||||
assert.equal(seats["seat-02"], "pending");
|
||||
const milestones = resolveDagMilestoneStates({
|
||||
seatStates: seats,
|
||||
dialogues: [
|
||||
{ sender: "总控协调", streaming: false },
|
||||
{ sender: "测试01", streaming: true },
|
||||
],
|
||||
isOrchestrating: true,
|
||||
hasConsensus: false,
|
||||
});
|
||||
assert.equal(milestones.coordinator, "done");
|
||||
});
|
||||
|
||||
test("the coordinator stays in progress while it is still the one speaking", () => {
|
||||
const seats = resolveLiveDagNodeStates({
|
||||
agentNameToRole: roles,
|
||||
dialogues: [{ sender: "总控协调", streaming: true, confidence: "派活中", content: "" }],
|
||||
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
|
||||
parallelBatch: [],
|
||||
});
|
||||
const milestones = resolveDagMilestoneStates({
|
||||
seatStates: seats,
|
||||
dialogues: [{ sender: "总控协调", streaming: true }],
|
||||
isOrchestrating: true,
|
||||
hasConsensus: false,
|
||||
});
|
||||
assert.equal(milestones.coordinator, "active");
|
||||
assert.equal(seats["seat-01"], "pending");
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ChainNodeState } from "./job-types.ts";
|
||||
|
||||
/** 与 seat-filter 的 COORDINATOR_SENDER 相同。这里单独写出,避免测试去拉前端模块图。 */
|
||||
const COORDINATOR_SENDER = "总控协调";
|
||||
|
||||
export interface DagFlowDialogue {
|
||||
sender?: string;
|
||||
streaming?: boolean;
|
||||
confidence?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface DagFlowSeatMeta {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图节点状态。
|
||||
*
|
||||
* 实时发言以对话气泡为准:席位气泡已经在流式输出时,节点必须是进行中,
|
||||
* 不能被一份还停在 pending 的 dagNodeStates 盖回去。总控只在自己发言、
|
||||
* 且还没有任何席位进行中时保持进行中。
|
||||
*/
|
||||
export function resolveLiveDagNodeStates(input: {
|
||||
agentNameToRole: Record<string, DagFlowSeatMeta>;
|
||||
dialogues: readonly DagFlowDialogue[];
|
||||
dagNodeStates: Record<string, ChainNodeState>;
|
||||
parallelBatch: readonly { agentId: string; status: string }[];
|
||||
}): Record<string, ChainNodeState> {
|
||||
const merged: Record<string, ChainNodeState> = {};
|
||||
const keyOf = (value: string) => value.trim().toLowerCase();
|
||||
const streamingSenders = new Set(
|
||||
input.dialogues.filter((d) => d.streaming).map((d) => keyOf(String(d.sender ?? ""))),
|
||||
);
|
||||
const deliveredSenders = new Set(
|
||||
input.dialogues
|
||||
.filter(
|
||||
(d) =>
|
||||
d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
|
||||
)
|
||||
.map((d) => keyOf(String(d.sender ?? ""))),
|
||||
);
|
||||
|
||||
const matches = (agentId: string, displayName: string, senders: Set<string>) =>
|
||||
senders.has(keyOf(displayName)) || senders.has(keyOf(agentId));
|
||||
|
||||
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
|
||||
if (matches(agentId, meta.displayName, deliveredSenders)) {
|
||||
merged[keyOf(agentId)] = "done";
|
||||
}
|
||||
}
|
||||
for (const seat of input.parallelBatch) {
|
||||
merged[keyOf(seat.agentId)] = seat.status === "done" ? "done" : "active";
|
||||
}
|
||||
for (const [agentId, state] of Object.entries(input.dagNodeStates)) {
|
||||
const key = keyOf(agentId);
|
||||
if (state === "pending" && (merged[key] === "active" || merged[key] === "done")) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = state;
|
||||
}
|
||||
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
|
||||
if (matches(agentId, meta.displayName, streamingSenders)) {
|
||||
merged[keyOf(agentId)] = "active";
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function resolveDagMilestoneStates(input: {
|
||||
seatStates: Record<string, ChainNodeState>;
|
||||
dialogues: readonly DagFlowDialogue[];
|
||||
isOrchestrating: boolean;
|
||||
hasConsensus: boolean;
|
||||
}): { coordinator: ChainNodeState; consensus: ChainNodeState } {
|
||||
const states = Object.values(input.seatStates);
|
||||
const anyActive = states.includes("active");
|
||||
const anyStarted = states.some((state) => state !== "pending");
|
||||
const coordinatorStreaming = input.dialogues.some(
|
||||
(d) => d.streaming && String(d.sender ?? "") === COORDINATOR_SENDER,
|
||||
);
|
||||
const coordinator: ChainNodeState = anyActive
|
||||
? "done"
|
||||
: coordinatorStreaming || (input.isOrchestrating && !anyStarted)
|
||||
? "active"
|
||||
: anyStarted
|
||||
? "done"
|
||||
: "pending";
|
||||
const allDone = states.length > 0 && states.every((state) => state === "done");
|
||||
const consensus: ChainNodeState = input.hasConsensus ? "done" : allDone ? "active" : "pending";
|
||||
return { coordinator, consensus };
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useRouteNavigate } from "@/core/route-base";
|
||||
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
@@ -101,6 +102,8 @@ export default function BusinessChainListPage() {
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
// 管理员:额外多一个「全部链条」筛选,且可编辑 / 发布 / 删除任意账号的链条。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.business_chains;
|
||||
|
||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||
@@ -333,13 +336,10 @@ export default function BusinessChainListPage() {
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="flex items-center text-lg font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
|
||||
业务链条配置
|
||||
<span className="ml-2 rounded-md border border-[#CF1322]/30 bg-[#FFF1F0] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#CF1322] dark:border-[#A8071A]/40 dark:bg-[#A8071A]/20 dark:text-[#FF7875]">
|
||||
Orchestrator v2
|
||||
</span>
|
||||
{pageDescription?.title || "业务链条配置"}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-xl text-xs text-zinc-500 dark:text-[#6E88B0]">
|
||||
可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||||
{pageDescription?.description || "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -164,6 +164,10 @@ import {
|
||||
releaseRoundtableRoom,
|
||||
ROUNDTABLE_ROOM_FULL_MESSAGE,
|
||||
} from "@/roundtable-planning/api/rooms";
|
||||
import {
|
||||
isRoundtableRoomFullFailure,
|
||||
roomDecisionMessage,
|
||||
} from "@/roundtable-planning/lib/capacity-error";
|
||||
import { isRoundtableRoomExecutionActive } from "@/roundtable-planning/lib/room-lifecycle";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -492,10 +496,13 @@ export default function RoundtablePlanningPage({
|
||||
roomSessionId: roomSessionIdRef.current,
|
||||
});
|
||||
const roomHoldRef = useRef(false);
|
||||
const roomBlockRef = useRef<string | null>(null);
|
||||
const roomReleasePromiseRef = useRef<Promise<unknown> | null>(null);
|
||||
const roomAcquirePromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const roomHandoffRef = useRef(false);
|
||||
const roomEffectGenRef = useRef(0);
|
||||
const [roomFull, setRoomFull] = useState(false);
|
||||
const [roomLeaseEpoch, setRoomLeaseEpoch] = useState(0);
|
||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
||||
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
|
||||
@@ -1347,14 +1354,30 @@ export default function RoundtablePlanningPage({
|
||||
const taskId = draftStorageTaskId ?? null;
|
||||
roomSessionIdRef.current = createRoomSessionId();
|
||||
const roomSessionId = roomSessionIdRef.current;
|
||||
roomBlockRef.current = null;
|
||||
roomScopeRef.current = { draftId, taskId, roomSessionId };
|
||||
const acquirePromise = (async () => {
|
||||
try {
|
||||
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
|
||||
if (!decision.ok) {
|
||||
setRoomFull(true);
|
||||
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
|
||||
roomBlockRef.current = decision.code;
|
||||
}
|
||||
if (
|
||||
decision.code === "ROUNDTABLE_ROOM_FULL" ||
|
||||
isRoundtableRoomFullFailure(409, decision.message)
|
||||
) {
|
||||
setRoomFull(true);
|
||||
} else {
|
||||
triggerToast(
|
||||
decision.message ||
|
||||
roomDecisionMessage(decision.code) ||
|
||||
"暂时不能进入第二步",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
roomBlockRef.current = null;
|
||||
roomHoldRef.current = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -1371,6 +1394,85 @@ export default function RoundtablePlanningPage({
|
||||
},
|
||||
[drafts, draftStorageTaskId, triggerToast],
|
||||
);
|
||||
const reacquireLiveRoom = useCallback(async (force = false) => {
|
||||
if (!force && roomHoldRef.current) return true;
|
||||
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
|
||||
roomHoldRef.current = false;
|
||||
triggerToast(
|
||||
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
|
||||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (roomAcquirePromiseRef.current) return roomAcquirePromiseRef.current;
|
||||
const pendingRelease = roomReleasePromiseRef.current;
|
||||
if (pendingRelease) {
|
||||
try {
|
||||
await pendingRelease;
|
||||
} catch {
|
||||
// 释放是尽力而为,失败也不阻止用户重新占用同一个房间。
|
||||
}
|
||||
}
|
||||
const scope = roomScopeRef.current;
|
||||
if (!scope.draftId || !scope.roomSessionId) {
|
||||
triggerToast("会商草稿尚未保存,暂时不能恢复");
|
||||
return false;
|
||||
}
|
||||
const { draftId, taskId, roomSessionId } = scope;
|
||||
const acquirePromise = (async () => {
|
||||
try {
|
||||
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
|
||||
if (!decision.ok) {
|
||||
roomHoldRef.current = false;
|
||||
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
|
||||
roomBlockRef.current = decision.code;
|
||||
}
|
||||
if (
|
||||
decision.code === "ROUNDTABLE_ROOM_FULL" ||
|
||||
isRoundtableRoomFullFailure(409, decision.message)
|
||||
) {
|
||||
setRoomFull(true);
|
||||
} else {
|
||||
triggerToast(
|
||||
decision.message ||
|
||||
roomDecisionMessage(decision.code) ||
|
||||
"暂时不能恢复会商",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
roomBlockRef.current = null;
|
||||
roomHoldRef.current = true;
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
setRoomLeaseEpoch((epoch) => epoch + 1);
|
||||
return true;
|
||||
} catch (error) {
|
||||
roomHoldRef.current = false;
|
||||
triggerToast(
|
||||
`无法确认会商房间:${error instanceof Error ? error.message : "请稍后再试"}`,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
roomAcquirePromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
roomAcquirePromiseRef.current = acquirePromise;
|
||||
return acquirePromise;
|
||||
}, [drafts, triggerToast]);
|
||||
const resumeLiveRoundtable = useCallback(() => {
|
||||
void (async () => {
|
||||
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
|
||||
triggerToast(
|
||||
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
|
||||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const admitted = await reacquireLiveRoom(true);
|
||||
if (!admitted) return;
|
||||
step2.resumeRoundtable();
|
||||
})();
|
||||
}, [reacquireLiveRoom, step2, triggerToast]);
|
||||
const handleConfirmAgents = useCallback(
|
||||
(result: SeatSelectionResult) => {
|
||||
void (async () => {
|
||||
@@ -2320,16 +2422,31 @@ export default function RoundtablePlanningPage({
|
||||
return;
|
||||
}
|
||||
roomHoldRef.current = false;
|
||||
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
|
||||
roomBlockRef.current = decision.code;
|
||||
}
|
||||
step2.stopOrchestration();
|
||||
step2.setIsRoundTableRunning(false);
|
||||
if (
|
||||
decision.code === "ROUNDTABLE_ROOM_FULL" ||
|
||||
isRoundtableRoomFullFailure(409, decision.message)
|
||||
) {
|
||||
setRoomFull(true);
|
||||
}
|
||||
triggerToast(
|
||||
decision.message || "本次会商房间已释放,研讨已停止。记录仍留在当前页。",
|
||||
);
|
||||
void releaseRoundtableRoom({
|
||||
const releasePromise = releaseRoundtableRoom({
|
||||
draftId,
|
||||
taskId: scope.taskId,
|
||||
roomSessionId: scope.roomSessionId,
|
||||
}).catch(() => {});
|
||||
roomReleasePromiseRef.current = releasePromise;
|
||||
void releasePromise.finally(() => {
|
||||
if (roomReleasePromiseRef.current === releasePromise) {
|
||||
roomReleasePromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 心跳请求失败只能说明本次续租请求没有得到响应,不能据此终止仍在
|
||||
@@ -2369,7 +2486,9 @@ export default function RoundtablePlanningPage({
|
||||
currentStep,
|
||||
step2.hasConsensus,
|
||||
step2.stopOrchestration,
|
||||
step2.setIsRoundTableRunning,
|
||||
triggerToast,
|
||||
roomLeaseEpoch,
|
||||
]);
|
||||
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
|
||||
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
|
||||
@@ -2891,7 +3010,7 @@ export default function RoundtablePlanningPage({
|
||||
openArtifactFromStep={step2.openArtifactFromStep}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
onPauseRoundtable={step2.pauseRoundtable}
|
||||
onResumeRoundtable={step2.resumeRoundtable}
|
||||
onResumeRoundtable={resumeLiveRoundtable}
|
||||
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
|
||||
setClarificationDraft={step2.setClarificationDraft}
|
||||
setCustomAgentInput={step2.setCustomAgentInput}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Bell, ChevronDown, ListTree, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,8 +25,16 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { AppBrandImage } from "@/core/branding/app-brand-image";
|
||||
import {
|
||||
getHeaderTitlePreview,
|
||||
subscribeHeaderTitlePreview,
|
||||
} from "@/core/branding/header-title-preview";
|
||||
import {
|
||||
getLightRedNavigationPreview,
|
||||
subscribeLightRedNavigationPreview,
|
||||
} from "@/core/branding/light-red-navigation-preview";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
import { isLightRedTopNavEnabled, readRuntimeEnv } from "@/env";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
import {
|
||||
@@ -31,10 +45,18 @@ import {
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
import { useAppSystemName } from "@/core/system-settings/hooks";
|
||||
import { resolveAppIdentityAssetUrl } from "@/core/system-settings/api";
|
||||
import { useAppIdentitySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import {
|
||||
calculateHeaderTitleFit,
|
||||
HEADER_TITLE_MAX_FONT_SIZE,
|
||||
HEADER_TITLE_MAX_LETTER_SPACING,
|
||||
resolveHeaderTitleFit,
|
||||
type HeaderTitleFit,
|
||||
} from "./header-title-fit";
|
||||
|
||||
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
|
||||
cn(
|
||||
@@ -69,10 +91,49 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
: isPaleLightTheme
|
||||
? "text-[var(--foreground)]"
|
||||
: "text-white";
|
||||
const darkLogoPosition =
|
||||
const isLightRedTopNav = isLightRedTopNavEnabled(theme);
|
||||
const topNavLogoPosition =
|
||||
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
||||
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
||||
const systemName = useAppSystemName("CM业务中心");
|
||||
const isTopNavLogoOnLeft = topNavLogoPosition === "left";
|
||||
const { data: appIdentity } = useAppIdentitySettings();
|
||||
const headerTitlePreview = useSyncExternalStore(
|
||||
subscribeHeaderTitlePreview,
|
||||
getHeaderTitlePreview,
|
||||
getHeaderTitlePreview,
|
||||
);
|
||||
const lightRedNavigationPreview = useSyncExternalStore(
|
||||
subscribeLightRedNavigationPreview,
|
||||
getLightRedNavigationPreview,
|
||||
getLightRedNavigationPreview,
|
||||
);
|
||||
const systemName = appIdentity?.system_name.trim() || "CM业务中心";
|
||||
const headerTitleFontMode =
|
||||
headerTitlePreview?.mode ??
|
||||
(appIdentity?.header_title_font_mode === "fixed" ? "fixed" : "adaptive");
|
||||
const headerTitleFontSize =
|
||||
headerTitlePreview?.fontSize ??
|
||||
appIdentity?.header_title_font_size ??
|
||||
HEADER_TITLE_MAX_FONT_SIZE;
|
||||
const savedLightRedHeaderBackgroundUrl = resolveAppIdentityAssetUrl(
|
||||
appIdentity?.light_red_header_background_url,
|
||||
);
|
||||
const savedLightRedNavActiveUrl = resolveAppIdentityAssetUrl(
|
||||
appIdentity?.light_red_nav_active_url,
|
||||
);
|
||||
const lightRedHeaderBackgroundUrl = lightRedNavigationPreview
|
||||
? lightRedNavigationPreview.headerBackgroundUrl
|
||||
: savedLightRedHeaderBackgroundUrl;
|
||||
const lightRedNavActiveUrl = lightRedNavigationPreview
|
||||
? lightRedNavigationPreview.navActiveUrl
|
||||
: savedLightRedNavActiveUrl;
|
||||
const lightRedArtworkStyle = {
|
||||
"--light-red-top-nav-background-image": lightRedHeaderBackgroundUrl
|
||||
? `url("${lightRedHeaderBackgroundUrl}")`
|
||||
: undefined,
|
||||
"--light-red-nav-active-background-image": lightRedNavActiveUrl
|
||||
? `url("${lightRedNavActiveUrl}")`
|
||||
: undefined,
|
||||
} as CSSProperties;
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const navigate = useNavigate();
|
||||
@@ -82,16 +143,94 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
const isAdmin = !auth || auth.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const displaySystemName = desensitizeText(systemName);
|
||||
const centerTitleRef = useRef<HTMLDivElement>(null);
|
||||
const centerTitleMeasureRef = useRef<HTMLSpanElement>(null);
|
||||
const [centerTitleFit, setCenterTitleFit] = useState<HeaderTitleFit>({
|
||||
fontSize: HEADER_TITLE_MAX_FONT_SIZE,
|
||||
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
|
||||
lines: 1,
|
||||
});
|
||||
const renderedCenterTitleFit = resolveHeaderTitleFit({
|
||||
mode: headerTitleFontMode,
|
||||
fixedFontSize: headerTitleFontSize,
|
||||
adaptiveFit: centerTitleFit,
|
||||
});
|
||||
const { data: myPosition } = useMyPosition();
|
||||
const positionName = myPosition?.name?.trim() || "";
|
||||
|
||||
const displayName = liveAccount.displayName;
|
||||
// 深色侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
|
||||
const showTopNav = isDark && !isWujieEnv();
|
||||
// 深色与可选浅红色皮肤共用同一套导航结构;两者只替换背景与选中态配色。
|
||||
// 侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
|
||||
const showTopNav = (isDark || isLightRedTopNav) && !isWujieEnv();
|
||||
// 浅红主题继续使用原浅色侧栏承载三级菜单,避免与顶部横栏重复。
|
||||
const showThirdSubNav = isDark && !isWujieEnv();
|
||||
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
|
||||
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
|
||||
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
|
||||
const showLightTopNav = !isDark && !isWujieEnv();
|
||||
const showLightTopNav = !isDark && !isLightRedTopNav && !isWujieEnv();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showTopNav || headerTitleFontMode === "fixed") {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = centerTitleRef.current;
|
||||
const measure = centerTitleMeasureRef.current;
|
||||
if (!title || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const updateTitleFit = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const titleStyle = window.getComputedStyle(title);
|
||||
const horizontalPadding =
|
||||
Number.parseFloat(titleStyle.paddingLeft) +
|
||||
Number.parseFloat(titleStyle.paddingRight);
|
||||
const logo = title.querySelector<HTMLElement>(".header-center-title__logo");
|
||||
const logoWidth = logo?.getBoundingClientRect().width ?? 0;
|
||||
const gap = logoWidth > 0 ? Number.parseFloat(titleStyle.columnGap) || 0 : 0;
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
title.clientWidth - horizontalPadding - logoWidth - gap,
|
||||
);
|
||||
const nextFit = calculateHeaderTitleFit({
|
||||
availableWidth,
|
||||
naturalWidth: measure.getBoundingClientRect().width,
|
||||
characterCount: Array.from(displaySystemName).length,
|
||||
});
|
||||
|
||||
setCenterTitleFit((currentFit) =>
|
||||
currentFit.fontSize === nextFit.fontSize &&
|
||||
currentFit.letterSpacing === nextFit.letterSpacing &&
|
||||
currentFit.lines === nextFit.lines
|
||||
? currentFit
|
||||
: nextFit,
|
||||
);
|
||||
};
|
||||
|
||||
updateTitleFit();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateTitleFit);
|
||||
resizeObserver.observe(title);
|
||||
|
||||
void document.fonts?.ready.then(updateTitleFit);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
displaySystemName,
|
||||
headerTitleFontMode,
|
||||
showTopNav,
|
||||
topNavLogoPosition,
|
||||
]);
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
|
||||
|
||||
@@ -272,17 +411,23 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
|
||||
<header
|
||||
className={cn(
|
||||
"strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4",
|
||||
isLightRedTopNav && "strategy-app-header--light-red-top-nav",
|
||||
)}
|
||||
style={isLightRedTopNav ? lightRedArtworkStyle : undefined}
|
||||
>
|
||||
{showTopNav ? (
|
||||
<>
|
||||
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-1 items-center",
|
||||
isDarkLogoOnLeft ? "justify-between" : "justify-end",
|
||||
isTopNavLogoOnLeft ? "justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{isDarkLogoOnLeft && (
|
||||
{isTopNavLogoOnLeft && (
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-950/55"
|
||||
title="系统 Logo"
|
||||
@@ -304,15 +449,29 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
</div>
|
||||
|
||||
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
|
||||
<div className="header-center-title">
|
||||
{darkLogoPosition === "center" && (
|
||||
<div ref={centerTitleRef} className="header-center-title">
|
||||
{topNavLogoPosition === "center" && (
|
||||
<AppBrandImage
|
||||
theme={theme}
|
||||
className="header-center-title__logo"
|
||||
/>
|
||||
)}
|
||||
<span className="header-center-title__label">
|
||||
{desensitizeText(systemName)}
|
||||
<span
|
||||
className="header-center-title__label"
|
||||
data-lines={renderedCenterTitleFit.lines}
|
||||
style={{
|
||||
fontSize: `${renderedCenterTitleFit.fontSize}px`,
|
||||
letterSpacing: `${renderedCenterTitleFit.letterSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{displaySystemName}
|
||||
</span>
|
||||
<span
|
||||
ref={centerTitleMeasureRef}
|
||||
className="header-center-title__measure"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displaySystemName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -343,7 +502,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
</div>
|
||||
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
|
||||
{desensitizeText(systemName)}
|
||||
{displaySystemName}
|
||||
</span>
|
||||
</div>
|
||||
<nav className="light-topnav" aria-label="一级菜单">
|
||||
@@ -377,7 +536,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={cn("text-sm font-semibold", headerTextClass)}>
|
||||
{desensitizeText(systemName)}
|
||||
{displaySystemName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -389,7 +548,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
)}
|
||||
</header>
|
||||
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
|
||||
{showTopNav && thirdSubNavItems.length ? (
|
||||
{showThirdSubNav && thirdSubNavItems.length ? (
|
||||
<nav className="third-submenu-bar" aria-label="三级菜单">
|
||||
<div className="third-submenu-list">
|
||||
{thirdSubNavItems.map((item) => {
|
||||
|
||||
@@ -22,9 +22,11 @@ export interface HtmlTemplateMeta {
|
||||
description: string;
|
||||
/** 内置模板(不可删除,可编辑/上传覆盖/重置回随包原版)。 */
|
||||
builtin: boolean;
|
||||
/** true = 带 `const DATA=` 注入行,可「JSON 转换填充」;false = 只展示。 */
|
||||
/** true = 支持「JSON 转换填充」;false = 只展示。 */
|
||||
convertible: boolean;
|
||||
/** 正文大小(字节)。 */
|
||||
/** "structured" = 结构化模板(骨架+runtime+data.json,支持「编辑数据」持久化);缺省 single。 */
|
||||
kind?: "single" | "structured";
|
||||
/** 正文大小(字节;结构化模板为各组成文件之和)。 */
|
||||
size?: number;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -36,6 +38,7 @@ interface ServerMeta {
|
||||
description?: string | null;
|
||||
builtin?: boolean;
|
||||
convertible?: boolean;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -48,6 +51,7 @@ function toMeta(s: ServerMeta): HtmlTemplateMeta {
|
||||
description: s.description ?? "",
|
||||
builtin: !!s.builtin,
|
||||
convertible: !!s.convertible,
|
||||
kind: s.kind === "structured" ? "structured" : "single",
|
||||
size: s.size ?? undefined,
|
||||
createdAt: s.created_at ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
@@ -81,14 +85,178 @@ export async function listHtmlTemplates(): Promise<HtmlTemplateMeta[]> {
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function getHtmlTemplateContent(templateId: string): Promise<string> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/content`, {
|
||||
method: "GET",
|
||||
});
|
||||
/**
|
||||
* 模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物);
|
||||
* `raw: true` 返回正文原文(结构化模板 = 带 `__PAGE_*` 标记的骨架,编辑用)。
|
||||
*/
|
||||
export async function getHtmlTemplateContent(
|
||||
templateId: string,
|
||||
opts?: { raw?: boolean },
|
||||
): Promise<string> {
|
||||
const suffix = opts?.raw ? "?raw=1" : "";
|
||||
const res = await apiFetch(
|
||||
`${BASE()}/${encodeURIComponent(templateId)}/content${suffix}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const data = await readJson<{ html: string }>(res, "get html template content");
|
||||
return data.html ?? "";
|
||||
}
|
||||
|
||||
/** 结构化模板的一个数据模块(DATA 顶层键)及其中文显示名。 */
|
||||
export interface HtmlTemplateModuleInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 结构化模板的默认数据 + MANIFEST + 模块中文名(管理页「编辑数据」用)。 */
|
||||
export async function getHtmlTemplateData(
|
||||
templateId: string,
|
||||
): Promise<{
|
||||
data: Record<string, unknown>;
|
||||
manifest: Record<string, unknown>;
|
||||
modules: HtmlTemplateModuleInfo[];
|
||||
}> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/data`, {
|
||||
method: "GET",
|
||||
});
|
||||
const payload = await readJson<{
|
||||
data: Record<string, unknown>;
|
||||
manifest?: Record<string, unknown> | null;
|
||||
modules?: { key: string; label?: string }[] | null;
|
||||
}>(res, "get html template data");
|
||||
return {
|
||||
data: payload.data ?? {},
|
||||
manifest: payload.manifest ?? {},
|
||||
modules: (payload.modules ?? []).map((m) => ({
|
||||
key: m.key,
|
||||
label: m.label || m.key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 字段目录的一条字段(fields.json × 当前可见性 × 抽取锁定)。 */
|
||||
export interface HtmlTemplateFieldInfo {
|
||||
label: string;
|
||||
pages: string[];
|
||||
description: string;
|
||||
visible: boolean;
|
||||
/** 是否接受定时任务抽取数据更新(默认 true;关闭后该字段保持模板现有数据)。 */
|
||||
extract: boolean;
|
||||
/** false = 布局分区(无独立数据键),抽取开关无意义。 */
|
||||
hasKey: boolean;
|
||||
}
|
||||
|
||||
/** 自定义模块(挂在指定页面底部的 AI 生成 HTML 片段,存于 manifest)。 */
|
||||
export interface HtmlTemplateCustomModule {
|
||||
编号?: string;
|
||||
页面: string;
|
||||
标题?: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface HtmlTemplatePageOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 字段目录 + 页面清单 + 自定义模块列表(「字段与模块」页签数据源)。 */
|
||||
export async function getHtmlTemplateFields(templateId: string): Promise<{
|
||||
fields: HtmlTemplateFieldInfo[];
|
||||
pages: HtmlTemplatePageOption[];
|
||||
customModules: HtmlTemplateCustomModule[];
|
||||
}> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/fields`, {
|
||||
method: "GET",
|
||||
});
|
||||
const data = await readJson<{
|
||||
fields?: (Omit<HtmlTemplateFieldInfo, "extract" | "hasKey"> & {
|
||||
extract?: boolean | null;
|
||||
has_key?: boolean | null;
|
||||
})[] | null;
|
||||
pages?: HtmlTemplatePageOption[] | null;
|
||||
custom_modules?: HtmlTemplateCustomModule[] | null;
|
||||
}>(res, "get html template fields");
|
||||
return {
|
||||
fields: (data.fields ?? []).map((f) => ({
|
||||
...f,
|
||||
extract: f.extract !== false,
|
||||
// 兼容旧缓存缺 has_key 的响应:缺失按有键处理(宁可多开不可全禁)。
|
||||
hasKey: f.has_key !== false,
|
||||
})),
|
||||
pages: data.pages ?? [],
|
||||
customModules: data.custom_modules ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 整体替换字段隐藏名单(可选一并替换抽取锁定名单;未列出的字段全部显示/接受抽取)。 */
|
||||
export async function updateHtmlTemplateFieldVisibility(
|
||||
templateId: string,
|
||||
hidden: string[],
|
||||
extractLocked?: string[],
|
||||
): Promise<HtmlTemplateMeta> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/fields`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
hidden,
|
||||
...(extractLocked !== undefined ? { extract_locked: extractLocked } : {}),
|
||||
}),
|
||||
});
|
||||
return toMeta(await readJson<ServerMeta>(res, "update html template fields"));
|
||||
}
|
||||
|
||||
/** 自定义模块列表整体替换(后端逐条消毒后入库)。 */
|
||||
export async function updateHtmlTemplateCustomModules(
|
||||
templateId: string,
|
||||
modules: HtmlTemplateCustomModule[],
|
||||
): Promise<HtmlTemplateMeta> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/custom-modules`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modules }),
|
||||
});
|
||||
return toMeta(await readJson<ServerMeta>(res, "update html template custom modules"));
|
||||
}
|
||||
|
||||
/** 按自然语言描述调用模型生成自定义模块 HTML 片段(已消毒,未入库)。 */
|
||||
export async function generateHtmlTemplateCustomModule(
|
||||
templateId: string,
|
||||
input: { page: string; title?: string; description: string; modelName?: string },
|
||||
): Promise<string> {
|
||||
const res = await apiFetch(
|
||||
`${BASE()}/${encodeURIComponent(templateId)}/custom-modules/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
page: input.page,
|
||||
title: input.title ?? "",
|
||||
description: input.description,
|
||||
model_name: input.modelName ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await readJson<{ html: string }>(res, "generate custom module");
|
||||
return data.html ?? "";
|
||||
}
|
||||
|
||||
/** 整体替换结构化模板的默认数据(manifest 给出时一并持久化 manifest.json)。 */
|
||||
export async function updateHtmlTemplateData(
|
||||
templateId: string,
|
||||
data: Record<string, unknown>,
|
||||
manifest?: Record<string, unknown>,
|
||||
): Promise<HtmlTemplateMeta> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/data`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
...(manifest !== undefined ? { manifest } : {}),
|
||||
}),
|
||||
});
|
||||
return toMeta(await readJson<ServerMeta>(res, "update html template data"));
|
||||
}
|
||||
|
||||
export async function createHtmlTemplate(input: {
|
||||
templateId: string;
|
||||
name: string;
|
||||
@@ -142,6 +310,19 @@ export async function resetHtmlTemplate(templateId: string): Promise<HtmlTemplat
|
||||
|
||||
export const HTML_TEMPLATES_KEY = "html-templates";
|
||||
const TEMPLATE_CONTENT_KEY = "html-template-content";
|
||||
const TEMPLATE_DATA_KEY = "html-template-data";
|
||||
const TEMPLATE_FIELDS_KEY = "html-template-fields";
|
||||
|
||||
/** 失效模板相关的全部查询(列表/内容/数据/字段)——任何写操作后调用。 */
|
||||
export function invalidateHtmlTemplateCaches(queryClient: {
|
||||
invalidateQueries: (filter: { queryKey: unknown[] }) => Promise<unknown>;
|
||||
}): Promise<unknown> {
|
||||
return Promise.all(
|
||||
[HTML_TEMPLATES_KEY, TEMPLATE_CONTENT_KEY, TEMPLATE_DATA_KEY, TEMPLATE_FIELDS_KEY].map(
|
||||
(key) => queryClient.invalidateQueries({ queryKey: [key] }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useHtmlTemplatesAdmin(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -153,9 +334,10 @@ export function useHtmlTemplatesAdmin(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 可预览的完整文档(结构化模板为默认数据组合产物)。 */
|
||||
export function useHtmlTemplateContent(templateId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [TEMPLATE_CONTENT_KEY, templateId],
|
||||
queryKey: [TEMPLATE_CONTENT_KEY, templateId, "doc"],
|
||||
queryFn: () => getHtmlTemplateContent(templateId as string),
|
||||
enabled: !!templateId,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -163,11 +345,50 @@ export function useHtmlTemplateContent(templateId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 正文原文(结构化模板 = 带 `__PAGE_*` 标记的骨架,编辑用)。 */
|
||||
export function useHtmlTemplateRawContent(templateId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [TEMPLATE_CONTENT_KEY, templateId, "raw"],
|
||||
queryFn: () => getHtmlTemplateContent(templateId as string, { raw: true }),
|
||||
enabled: !!templateId && enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 结构化模板的默认数据(仅 kind=structured 时启用)。 */
|
||||
export function useHtmlTemplateData(templateId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [TEMPLATE_DATA_KEY, templateId],
|
||||
queryFn: () => getHtmlTemplateData(templateId as string),
|
||||
enabled: !!templateId && enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 字段目录 + 页面清单 + 自定义模块(「字段与模块」页签)。 */
|
||||
export function useHtmlTemplateFields(templateId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [TEMPLATE_FIELDS_KEY, templateId],
|
||||
queryFn: () => getHtmlTemplateFields(templateId as string),
|
||||
enabled: !!templateId && enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateCustomModuleDirect(
|
||||
templateId: string,
|
||||
input: { page: string; title?: string; description: string; modelName?: string },
|
||||
): Promise<string> {
|
||||
return generateHtmlTemplateCustomModule(templateId, input);
|
||||
}
|
||||
|
||||
export function useHtmlTemplateActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [HTML_TEMPLATES_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [TEMPLATE_CONTENT_KEY] });
|
||||
void invalidateHtmlTemplateCaches(queryClient);
|
||||
};
|
||||
return {
|
||||
create: useMutation({ mutationFn: createHtmlTemplate, onSuccess: invalidate }),
|
||||
@@ -178,6 +399,14 @@ export function useHtmlTemplateActions() {
|
||||
}) => updateHtmlTemplate(vars.id, vars.patch),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
updateData: useMutation({
|
||||
mutationFn: (vars: {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
manifest?: Record<string, unknown>;
|
||||
}) => updateHtmlTemplateData(vars.id, vars.data, vars.manifest),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteHtmlTemplate, onSuccess: invalidate }),
|
||||
reset: useMutation({ mutationFn: resetHtmlTemplate, onSuccess: invalidate }),
|
||||
};
|
||||
|
||||
+235
-15
@@ -28,6 +28,7 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,6 +38,14 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
@@ -48,7 +57,11 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
|
||||
import { env } from "@/env";
|
||||
import {
|
||||
useOrdinaryKnowledgeRetrievalSettings,
|
||||
usePageDescriptionsSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
formatWikiDirectoryPaths,
|
||||
type WikiDirectoryTreeNode,
|
||||
@@ -87,9 +100,17 @@ import {
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
type WorkspaceTab = "personal" | "public" | "settings";
|
||||
type KnowledgeSort = "name-asc" | "name-desc" | "created-desc" | "created-asc";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
const WIKI_INSTRUCTION_MAX_LENGTH = 4000;
|
||||
const WIKI_FOCUS_INSTRUCTION_MAX_LENGTH = 2000;
|
||||
const knowledgePageSizeOptions = [10, 20, 50] as const;
|
||||
type KnowledgePageSize = (typeof knowledgePageSizeOptions)[number];
|
||||
const knowledgeNameCollator = new Intl.Collator("zh-Hans-CN", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
const publicationLabels: Record<string, string> = {
|
||||
private: "仅自己",
|
||||
@@ -106,6 +127,34 @@ function codePointLength(value: string): number {
|
||||
return Array.from(value).length;
|
||||
}
|
||||
|
||||
function knowledgeNameInitial(value: string): string | null {
|
||||
const match = value.trim().match(/^([a-z])\d+-/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function knowledgeCreatedAtTimestamp(item: LlmWikiKnowledgeBase): number {
|
||||
const value = item.created_at || item.remote_created_at;
|
||||
const timestamp = value ? Date.parse(value) : Number.NaN;
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function compareKnowledgeBases(
|
||||
left: LlmWikiKnowledgeBase,
|
||||
right: LlmWikiKnowledgeBase,
|
||||
sort: KnowledgeSort,
|
||||
): number {
|
||||
const nameOrder = knowledgeNameCollator.compare(left.name, right.name);
|
||||
const createdOrder = knowledgeCreatedAtTimestamp(left) - knowledgeCreatedAtTimestamp(right);
|
||||
const order = sort === "name-asc"
|
||||
? nameOrder
|
||||
: sort === "name-desc"
|
||||
? -nameOrder
|
||||
: sort === "created-asc"
|
||||
? createdOrder
|
||||
: -createdOrder;
|
||||
return order || nameOrder || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function isWikiKnowledgeBase(knowledgeBase: LlmWikiKnowledgeBase): boolean {
|
||||
if (typeof knowledgeBase.wiki_generation_managed === "boolean") {
|
||||
return knowledgeBase.wiki_generation_managed;
|
||||
@@ -163,11 +212,20 @@ function resultPreview(result: LlmWikiSearchResult) {
|
||||
export function WeKnoraKnowledgeWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const pageDescription = pageDescriptions.data?.knowledge_management;
|
||||
const knowledgeManagementSettings =
|
||||
useOrdinaryKnowledgeRetrievalSettings(isAdmin);
|
||||
const advancedManagementEnabled =
|
||||
knowledgeManagementSettings.data?.advanced_management_enabled === true;
|
||||
const [tab, setTab] = useState<WorkspaceTab>("personal");
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("list");
|
||||
const [sort, setSort] = useState<KnowledgeSort>("name-asc");
|
||||
const [nameInitialFilter, setNameInitialFilter] = useState<string | null>(null);
|
||||
const [knowledgePage, setKnowledgePage] = useState(1);
|
||||
const [knowledgePageSize, setKnowledgePageSize] = useState<KnowledgePageSize>(20);
|
||||
const viewSwitchEnabled = env.KNOWLEDGE_VIEW_SWITCH_ENABLED;
|
||||
const effectiveViewMode = viewSwitchEnabled ? viewMode : "list";
|
||||
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
|
||||
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
|
||||
const actions = useLlmWikiActions();
|
||||
@@ -177,12 +235,28 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
|
||||
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
|
||||
const availableNameInitials = useMemo(() => {
|
||||
if (tab !== "personal") return [];
|
||||
return [...new Set(
|
||||
(knowledge.data ?? [])
|
||||
.filter((item) => item.is_owner && item.remote_status !== "missing")
|
||||
.map((item) => knowledgeNameInitial(item.name))
|
||||
.filter((initial): initial is string => initial !== null),
|
||||
)].sort(knowledgeNameCollator.compare);
|
||||
}, [knowledge.data, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResponse(null);
|
||||
setSelectedSearchResult(null);
|
||||
setKnowledgePage(1);
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nameInitialFilter && !availableNameInitials.includes(nameInitialFilter)) {
|
||||
setNameInitialFilter(null);
|
||||
}
|
||||
}, [availableNameInitials, nameInitialFilter]);
|
||||
|
||||
const busy = actions.create.isPending || actions.update.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||
const setCreateDialogOpen = (open: boolean) => {
|
||||
if (open) actions.create.reset();
|
||||
@@ -195,6 +269,10 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
|
||||
};
|
||||
const changeKnowledgePageSize = (pageSize: KnowledgePageSize) => {
|
||||
setKnowledgePageSize(pageSize);
|
||||
setKnowledgePage(1);
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
const query = searchText.trim();
|
||||
@@ -236,10 +314,14 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">知识库管理</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pageDescription?.title || "知识库管理"}
|
||||
</h1>
|
||||
<Badge variant="secondary">知识服务</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pageDescription?.description || "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
||||
{tab !== "settings" && (
|
||||
@@ -252,7 +334,9 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
|
||||
</Button>
|
||||
)}
|
||||
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
|
||||
{tab === "personal" && <>
|
||||
<Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,18 +351,57 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
|
||||
</TabsList>
|
||||
{tab !== "settings" && (
|
||||
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||
<Select
|
||||
value={sort}
|
||||
onValueChange={(value) => {
|
||||
setSort(value as KnowledgeSort);
|
||||
setKnowledgePage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36 shrink-0" aria-label="知识库排序">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="name-asc">名称:A–Z</SelectItem>
|
||||
<SelectItem value="name-desc">名称:Z–A</SelectItem>
|
||||
<SelectItem value="created-desc">创建时间:最新</SelectItem>
|
||||
<SelectItem value="created-asc">创建时间:最早</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tab === "personal" && availableNameInitials.length > 0 && (
|
||||
<Select
|
||||
value={nameInitialFilter ?? "all"}
|
||||
onValueChange={(value) => {
|
||||
setNameInitialFilter(value === "all" ? null : value);
|
||||
setKnowledgePage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28 shrink-0" aria-label="按名称首字母筛选">
|
||||
<SelectValue placeholder="首字母" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">全部字母</SelectItem>
|
||||
{availableNameInitials.map((initial) => (
|
||||
<SelectItem key={initial} value={initial}>{initial}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{viewSwitchEnabled && <ViewModeToggle value={viewMode} onChange={setViewMode} className="shrink-0" />}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
<Button variant="secondary" className="shrink-0 rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
|
||||
</div>
|
||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
<TabsContent value="personal" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={nameInitialFilter} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
<TabsContent value="public" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={null} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||
</Tabs>
|
||||
|
||||
@@ -472,6 +595,13 @@ function WikiConfigurationStatus({
|
||||
}
|
||||
|
||||
function KnowledgeGrid({
|
||||
viewMode,
|
||||
sort,
|
||||
nameInitialFilter,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
@@ -484,6 +614,13 @@ function KnowledgeGrid({
|
||||
onPublish,
|
||||
onDelete,
|
||||
}: {
|
||||
viewMode: "card" | "list";
|
||||
sort: KnowledgeSort;
|
||||
nameInitialFilter: string | null;
|
||||
page: number;
|
||||
pageSize: KnowledgePageSize;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: KnowledgePageSize) => void;
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
@@ -498,11 +635,92 @@ function KnowledgeGrid({
|
||||
}) {
|
||||
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
|
||||
const visibleRows = rows.filter((item) => item.remote_status !== "missing");
|
||||
if (!visibleRows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
|
||||
const visibleRows = rows.filter(
|
||||
(item) => item.remote_status !== "missing" &&
|
||||
(!nameInitialFilter || knowledgeNameInitial(item.name) === nameInitialFilter),
|
||||
);
|
||||
if (!visibleRows.length) {
|
||||
return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{nameInitialFilter ? `没有名称以 ${nameInitialFilter} 开头的知识库` : emptyText}</div>;
|
||||
}
|
||||
const sortedRows = [...visibleRows].sort((left, right) => compareKnowledgeBases(left, right, sort));
|
||||
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
|
||||
const currentPage = Math.min(Math.max(page, 1), totalPages);
|
||||
const pageRows = sortedRows.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
const pagination = (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>共 {sortedRows.length} 个知识库 · 第 {currentPage}/{totalPages} 页</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value) as KnowledgePageSize)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28" aria-label="每页知识库数量">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{knowledgePageSizeOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>{option} 条/页</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={sortedRows.length}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (viewMode === "list") {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{pageRows.map((item) => (
|
||||
<Item key={item.id} variant="outline" size="sm" className="w-full">
|
||||
<ItemContent
|
||||
className="min-w-0 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`查看知识库 ${item.name}`}
|
||||
onClick={() => onOpen(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ItemTitle className="max-w-full flex-wrap">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>
|
||||
{isWikiKnowledgeBase(item) && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}
|
||||
<WikiConfigurationStatus knowledgeBase={item} />
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">{item.description || "暂无描述"}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="flex-wrap justify-end">
|
||||
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>
|
||||
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onResearch(item)}><Microscope className="mr-2 size-4" />深度研究</Button>
|
||||
{onTest && <Button size="sm" variant="outline" className="rounded-full" onClick={() => onTest(item)}><Search className="mr-2 size-4" />检索测试</Button>}
|
||||
{item.can_write && !item.is_conversation_deposit && <Button size="sm" variant="outline" className="rounded-full" disabled={busy} onClick={() => onEdit(item)} aria-label={`编辑知识库 ${item.name}`}><Edit3 className="mr-2 size-4" />编辑</Button>}
|
||||
{item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
|
||||
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`删除知识库 ${item.name}`} title="删除知识库" disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
{pagination}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleRows.map((item) => (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{pageRows.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className="group overflow-hidden rounded-2xl border-border/70 bg-card/95 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg"
|
||||
@@ -531,6 +749,8 @@ function KnowledgeGrid({
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{pagination}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
calculateHeaderTitleFit,
|
||||
resolveHeaderTitleFit,
|
||||
} from "./header-title-fit";
|
||||
|
||||
test("keeps the existing 30px title treatment when the name fits", () => {
|
||||
assert.deepEqual(
|
||||
calculateHeaderTitleFit({ availableWidth: 500, naturalWidth: 420 }),
|
||||
{ fontSize: 30, letterSpacing: 4, lines: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("shrinks a long name proportionally while keeping it on one line", () => {
|
||||
const fit = calculateHeaderTitleFit({
|
||||
availableWidth: 360,
|
||||
naturalWidth: 420,
|
||||
characterCount: 12,
|
||||
});
|
||||
|
||||
assert.equal(fit.lines, 1);
|
||||
assert.ok(fit.fontSize < 30);
|
||||
assert.ok(fit.fontSize > 24);
|
||||
assert.equal(fit.letterSpacing, 0);
|
||||
});
|
||||
|
||||
test("tightens character spacing before reducing the current maximum font size", () => {
|
||||
assert.deepEqual(
|
||||
calculateHeaderTitleFit({
|
||||
availableWidth: 390,
|
||||
naturalWidth: 420,
|
||||
characterCount: 12,
|
||||
}),
|
||||
{ fontSize: 30, letterSpacing: 1.41, lines: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps one line at the readable floor and allows intentional overflow", () => {
|
||||
assert.deepEqual(
|
||||
calculateHeaderTitleFit({
|
||||
availableWidth: 300,
|
||||
naturalWidth: 900,
|
||||
characterCount: 30,
|
||||
}),
|
||||
{ fontSize: 24, letterSpacing: 0, lines: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("does not wrap or shrink below 24px for extreme names", () => {
|
||||
const fit = calculateHeaderTitleFit({
|
||||
availableWidth: 300,
|
||||
naturalWidth: 2400,
|
||||
characterCount: 80,
|
||||
});
|
||||
|
||||
assert.deepEqual(fit, { fontSize: 24, letterSpacing: 0, lines: 1 });
|
||||
});
|
||||
|
||||
test("uses the configured font size without adaptive shrinking in fixed mode", () => {
|
||||
assert.deepEqual(
|
||||
resolveHeaderTitleFit({
|
||||
mode: "fixed",
|
||||
fixedFontSize: 34,
|
||||
adaptiveFit: { fontSize: 24, letterSpacing: 0, lines: 1 },
|
||||
}),
|
||||
{ fontSize: 34, letterSpacing: 4, lines: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds fixed font sizes to the supported navigation range", () => {
|
||||
const adaptiveFit = { fontSize: 28, letterSpacing: 1, lines: 1 };
|
||||
|
||||
assert.equal(
|
||||
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 8, adaptiveFit })
|
||||
.fontSize,
|
||||
16,
|
||||
);
|
||||
assert.equal(
|
||||
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 60, adaptiveFit })
|
||||
.fontSize,
|
||||
40,
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveHeaderTitleFit({
|
||||
mode: "adaptive",
|
||||
fixedFontSize: 40,
|
||||
adaptiveFit,
|
||||
}),
|
||||
adaptiveFit,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
export const HEADER_TITLE_MAX_FONT_SIZE = 30;
|
||||
export const HEADER_TITLE_MIN_FONT_SIZE = 24;
|
||||
export const HEADER_TITLE_MAX_LETTER_SPACING = 4;
|
||||
export const HEADER_TITLE_MAX_LINES = 1;
|
||||
export const HEADER_TITLE_FIXED_MIN_FONT_SIZE = 16;
|
||||
export const HEADER_TITLE_FIXED_MAX_FONT_SIZE = 40;
|
||||
|
||||
export type HeaderTitleFit = {
|
||||
fontSize: number;
|
||||
letterSpacing: number;
|
||||
lines: number;
|
||||
};
|
||||
|
||||
type ResolveHeaderTitleFitOptions = {
|
||||
mode: "adaptive" | "fixed";
|
||||
fixedFontSize: number;
|
||||
adaptiveFit: HeaderTitleFit;
|
||||
};
|
||||
|
||||
type CalculateHeaderTitleFitOptions = {
|
||||
availableWidth: number;
|
||||
naturalWidth: number;
|
||||
characterCount?: number;
|
||||
maxFontSize?: number;
|
||||
minFontSize?: number;
|
||||
maxLetterSpacing?: number;
|
||||
maxLines?: number;
|
||||
};
|
||||
|
||||
const roundDown = (value: number) => Math.floor(value * 100) / 100;
|
||||
|
||||
export function resolveHeaderTitleFit({
|
||||
mode,
|
||||
fixedFontSize,
|
||||
adaptiveFit,
|
||||
}: ResolveHeaderTitleFitOptions): HeaderTitleFit {
|
||||
if (mode !== "fixed") {
|
||||
return adaptiveFit;
|
||||
}
|
||||
|
||||
const normalizedFontSize = Number.isFinite(fixedFontSize)
|
||||
? Math.min(
|
||||
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
|
||||
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, fixedFontSize),
|
||||
)
|
||||
: HEADER_TITLE_MAX_FONT_SIZE;
|
||||
|
||||
return {
|
||||
fontSize: roundDown(normalizedFontSize),
|
||||
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the full system name into the decorative title slot without ellipsis.
|
||||
* The current 30px treatment remains the upper bound. The title stays on one
|
||||
* line and stops shrinking at 24px; overflow is intentional beyond that point.
|
||||
*/
|
||||
export function calculateHeaderTitleFit({
|
||||
availableWidth,
|
||||
naturalWidth,
|
||||
characterCount = 1,
|
||||
maxFontSize = HEADER_TITLE_MAX_FONT_SIZE,
|
||||
minFontSize = HEADER_TITLE_MIN_FONT_SIZE,
|
||||
maxLetterSpacing = HEADER_TITLE_MAX_LETTER_SPACING,
|
||||
maxLines = HEADER_TITLE_MAX_LINES,
|
||||
}: CalculateHeaderTitleFitOptions): HeaderTitleFit {
|
||||
if (
|
||||
!Number.isFinite(availableWidth) ||
|
||||
!Number.isFinite(naturalWidth) ||
|
||||
availableWidth <= 0 ||
|
||||
naturalWidth <= 0 ||
|
||||
maxFontSize <= 0
|
||||
) {
|
||||
return {
|
||||
fontSize: maxFontSize,
|
||||
letterSpacing: maxLetterSpacing,
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep a one-pixel safety margin for browser sub-pixel text measurement.
|
||||
const safeAvailableWidth = Math.max(1, availableWidth - 1);
|
||||
const safeCharacterCount = Math.max(1, characterCount);
|
||||
const maximumSpacingWidth = maxLetterSpacing * safeCharacterCount;
|
||||
const naturalGlyphWidth = Math.max(1, naturalWidth - maximumSpacingWidth);
|
||||
const minimumScale = Math.min(1, Math.max(0.01, minFontSize / maxFontSize));
|
||||
|
||||
if (naturalWidth <= safeAvailableWidth) {
|
||||
return {
|
||||
fontSize: maxFontSize,
|
||||
letterSpacing: maxLetterSpacing,
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Preserve the existing maximum font size as long as possible by tightening
|
||||
// only the decorative character spacing first.
|
||||
if (naturalGlyphWidth <= safeAvailableWidth) {
|
||||
return {
|
||||
fontSize: maxFontSize,
|
||||
letterSpacing: roundDown(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxLetterSpacing,
|
||||
(safeAvailableWidth - naturalGlyphWidth) / safeCharacterCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const singleLineScale = safeAvailableWidth / naturalGlyphWidth;
|
||||
if (singleLineScale >= minimumScale) {
|
||||
return {
|
||||
fontSize: roundDown(maxFontSize * singleLineScale),
|
||||
letterSpacing: 0,
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (maxLines <= 1) {
|
||||
return {
|
||||
fontSize: minFontSize,
|
||||
letterSpacing: 0,
|
||||
lines: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const requiredLines = Math.ceil(
|
||||
(naturalGlyphWidth * minimumScale) / safeAvailableWidth,
|
||||
);
|
||||
const lines = Math.min(Math.max(2, requiredLines), Math.max(1, maxLines));
|
||||
// Hold the readable minimum where possible. If even maxLines cannot contain
|
||||
// the name, shrink just enough to preserve every character.
|
||||
const scale = Math.min(
|
||||
minimumScale,
|
||||
(safeAvailableWidth * lines) / naturalGlyphWidth,
|
||||
);
|
||||
|
||||
return {
|
||||
fontSize: roundDown(maxFontSize * scale),
|
||||
letterSpacing: 0,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/core/artifacts/download";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
usePageDescriptionsSettings,
|
||||
useStrategyProjectSettings,
|
||||
useUpdateStrategyProjectSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
@@ -321,6 +322,8 @@ export default function StrategyProjectsPage({
|
||||
isError: demoSettingError,
|
||||
} = useStrategyProjectSettings();
|
||||
const updateStrategyProjectSettings = useUpdateStrategyProjectSettings();
|
||||
const pageDescriptions = usePageDescriptionsSettings();
|
||||
const projectsPageDescription = pageDescriptions.data?.strategy_projects;
|
||||
const strategyAngleCatalog = useStrategyAngleCatalog();
|
||||
const createAngles = strategyAngleCatalog.angles;
|
||||
const demoEnabled =
|
||||
@@ -472,10 +475,12 @@ export default function StrategyProjectsPage({
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FolderKanban className="size-4" />
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight">我的项目</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{projectsPageDescription?.title || "我的项目"}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-xs text-muted-foreground sm:text-sm">
|
||||
集中查看普通问答、策略优选和方案成果
|
||||
{projectsPageDescription?.description || "集中查看普通问答、策略优选和方案成果"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
|
||||
@@ -1528,19 +1528,38 @@ html.dark .strategy-app-header {
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
|
||||
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
|
||||
/* 红白主题可通过 runtime-config.js 选择复用深色顶栏结构,仅替换为浅红设计稿。 */
|
||||
.strategy-app-header.strategy-app-header--light-red-top-nav {
|
||||
background-image: var(
|
||||
--light-red-top-nav-background-image,
|
||||
url("../assets/img/top-bg-light-red.svg")
|
||||
);
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
/* 深色顶栏:居中系统名称落在背景图中间的发光梯形基座上。
|
||||
背景原稿为 1920px 宽,基座槽位约 445px;视口超过原稿宽度后随背景等比放大。
|
||||
固定这个视觉槽位后,用户名称不再反向撑宽容器、挤动左右菜单。 */
|
||||
.header-center-title {
|
||||
flex: 0 0 auto;
|
||||
--header-center-slot-width: max(445px, 23.18vw);
|
||||
/* 凹槽内侧亮边在 1920px 原稿中约 352px 宽;两边各留约 9px,
|
||||
字号测量按 334px 安全区随背景等比放大,但外层槽宽保持不变,
|
||||
因此不会挪动左右导航,也不会改变 Logo 与文字的 10px 间距。 */
|
||||
--header-center-title-safe-width: max(334px, 17.4vw);
|
||||
width: var(--header-center-slot-width);
|
||||
flex: 0 0 var(--header-center-slot-width);
|
||||
box-sizing: border-box;
|
||||
height: 64px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
|
||||
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
|
||||
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
|
||||
padding: 0 8.07vw;
|
||||
padding: 0
|
||||
calc(
|
||||
(var(--header-center-slot-width) - var(--header-center-title-safe-width)) /
|
||||
2
|
||||
);
|
||||
/* 文字略微上移,贴合基座视觉中心 */
|
||||
transform: translateY(-6px);
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
|
||||
@@ -1565,7 +1584,12 @@ html.dark .strategy-app-header {
|
||||
|
||||
.header-center-title__label {
|
||||
display: block;
|
||||
max-width: none;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
/* 白→浅蓝→白 金属质感渐变文字 */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -1582,6 +1606,38 @@ html.dark .strategy-app-header {
|
||||
filter: drop-shadow(0 0 14px rgba(120, 200, 255, 0.35));
|
||||
}
|
||||
|
||||
.header-center-title__label[data-lines="2"],
|
||||
.header-center-title__label[data-lines="3"] {
|
||||
line-height: 1.05;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 始终以现有最大字号测量名称的自然宽度,不参与布局和无障碍树。 */
|
||||
.header-center-title__measure {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.strategy-app-header--light-red-top-nav .header-center-title__label {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#ffffff 0%,
|
||||
#fff8f7 42%,
|
||||
#ffffff 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
filter: drop-shadow(0 0 12px rgba(161, 32, 24, 0.38));
|
||||
}
|
||||
|
||||
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(CSS 变量管不到的地方)────────── */
|
||||
html.lightblue .strategy-app-header {
|
||||
background-image: url("../assets/img/blue-header.png");
|
||||
|
||||
+89
-12
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -8,11 +8,63 @@ import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
||||
|
||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
const JSX_RUNTIME_NAMED_EXPORTS = [
|
||||
"jsx",
|
||||
"jsxs",
|
||||
"jsxDEV",
|
||||
"createElement",
|
||||
"Fragment",
|
||||
] as const;
|
||||
|
||||
const JSX_RUNTIME_DEP_FILES = new Set([
|
||||
"react_jsx-runtime.js",
|
||||
"react_jsx-dev-runtime.js",
|
||||
]);
|
||||
|
||||
function hasNamedJsxExport(code: string): boolean {
|
||||
return (
|
||||
/\bexport\s+const\s+jsx\b/.test(code) ||
|
||||
/\bexport\s+function\s+jsx\b/.test(code) ||
|
||||
/\bexport\s*\{[^}]*\bjsx\b/.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
// Vite prebundles CJS `react/jsx-runtime` as `export default require_jsx_runtime()`.
|
||||
// App source is rewritten to a default import, but a stale Chrome 102 tab may still
|
||||
// execute `import { jsx } from '.../react_jsx-runtime.js'`. Named re-exports from a
|
||||
// default-only module throw SyntaxError at link time, so the page never reloads.
|
||||
function withNamedJsxExports(code: string): string {
|
||||
if (hasNamedJsxExport(code)) return code;
|
||||
return code.replace(
|
||||
/export\s+default\s+([^;]+);/,
|
||||
(_match, expr: string) =>
|
||||
[
|
||||
`const __jsxRuntime = ${String(expr).trim()};`,
|
||||
"export default __jsxRuntime;",
|
||||
...JSX_RUNTIME_NAMED_EXPORTS.map(
|
||||
(name) => `export const ${name} = __jsxRuntime.${name};`,
|
||||
),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function staleDepsModuleSource(): string {
|
||||
return [
|
||||
"export function jsx(){return null}",
|
||||
"export function jsxs(){return null}",
|
||||
"export function jsxDEV(){return null}",
|
||||
"export const Fragment = Symbol.for('react.fragment');",
|
||||
"export default {jsx, jsxs, jsxDEV, Fragment};",
|
||||
"try{const k='__vite_stale_chunk_reloaded__';const n=Number(sessionStorage.getItem(k)||'0');" +
|
||||
"if(n<3){sessionStorage.setItem(k,String(n+1));location.reload();}}catch(e){}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force),
|
||||
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk,
|
||||
// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。
|
||||
// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload()
|
||||
// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。
|
||||
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk。
|
||||
// Chrome 在链接 `import { jsx } from ...` 时若没有本地 named export,会直接
|
||||
// SyntaxError,模块体根本不会执行,页面就白屏卡死。缺失 chunk 一律补本地
|
||||
// jsx 导出再刷新;真实 jsx-runtime 也补上 named export,兼容旧模块图。
|
||||
const staleDepsChunkSelfReload = (): Plugin => ({
|
||||
name: "stale-deps-chunk-self-reload",
|
||||
configureServer(server) {
|
||||
@@ -23,17 +75,28 @@ const staleDepsChunkSelfReload = (): Plugin => ({
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const filePath = path.join(depsDir, decodeURIComponent(match[1]));
|
||||
const fileName = path.basename(decodeURIComponent(match[1]));
|
||||
if (fileName !== decodeURIComponent(match[1])) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const filePath = path.join(depsDir, fileName);
|
||||
if (existsSync(filePath)) {
|
||||
if (!JSX_RUNTIME_DEP_FILES.has(fileName)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/javascript");
|
||||
res.end(withNamedJsxExports(readFileSync(filePath, "utf8")));
|
||||
return;
|
||||
}
|
||||
if (!/^chunk-[A-Za-z0-9_-]+\.js$/.test(fileName)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/javascript");
|
||||
res.end(
|
||||
"if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" +
|
||||
"sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" +
|
||||
"location.reload();}",
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end(staleDepsModuleSource());
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -91,7 +154,21 @@ export default defineConfig({
|
||||
// 下混用新旧缓存 chunk,报 "does not provide an export named 'refractor'"
|
||||
// 并整树白屏(清 node_modules/.vite + 重启 + 硬刷新才恢复)。启动即预打包可避免。
|
||||
optimizeDeps: {
|
||||
include: ["react-syntax-highlighter", "refractor"],
|
||||
include: [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"react-syntax-highlighter",
|
||||
"refractor",
|
||||
],
|
||||
esbuildOptions: {
|
||||
target: "chrome100",
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
target: "chrome100",
|
||||
},
|
||||
// Relative URLs make the same build work at the domain root and at any
|
||||
// Nginx path prefix. A fixed prefix can still be supplied at release time.
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Model-independent skill invocation attribution (2026-11-07)
|
||||
|
||||
Never derive skill-use UI or counters from model narration or the optional tool
|
||||
`description`. The canonical signals are a successful `skill_view`, a successful
|
||||
`read_file` under `/mnt/skills/{public,custom}/<skill>/...`, and an executed
|
||||
`bash` command containing such a skill path. `sandbox/tools.py` resolves names
|
||||
through the active skill view so a directory/frontmatter-name mismatch remains
|
||||
correct; both public and custom skills count.
|
||||
|
||||
`skills/usage.py::bump_use(..., event_key=run_id)` de-duplicates all file/script
|
||||
signals to one use per skill per user run. It updates `.usage.json` and schedules
|
||||
an absolute, monotonic update of the `skills` MySQL catalog projection on the
|
||||
engine home loop. Keep this bridge inside `deerflow.*`; the harness must not
|
||||
import `app.*`. The skill-card counter is intentionally independent of the
|
||||
heavier full-tool audit switch. `skill_metrics_enabled` defaults on and mounts
|
||||
`ToolMetricsMiddleware(skill_only=True)` while `tool_metrics_enabled` is off;
|
||||
non-skill calls pass through without DB work, but attributed skill calls still
|
||||
feed audit pages and leaderboards. Frontend labels are recovered by
|
||||
`core/tools/skill-invocation.ts` from explicit skill-tool arguments or skill
|
||||
paths. Regression coverage lives in `test_skill_usage_signals.py`,
|
||||
`test_skill_view_fast_path.py`, and `skill-invocation.test.ts`.
|
||||
|
||||
## Cold agent construction (2026-10-30)
|
||||
|
||||
`runtime/runs/worker.py` dispatches opted-in synchronous factories through
|
||||
@@ -549,7 +571,10 @@ handoff replaces the foreground lease with the durable job occupancy. There is
|
||||
no per-user pool. Admission is serialized by a database gate row, and the cap
|
||||
is enforced at acquire, init, every model-producing run, and background-job
|
||||
start. Full pools return HTTP 409; an enabled gate with missing storage or a
|
||||
missing session id fails closed. The switch applies on the next admission
|
||||
missing session id fails closed. Foreground leases last ``lease_minutes``
|
||||
(default 5, range 1–60) and are renewed by the browser heartbeat; changing the
|
||||
setting applies on the next admission or heartbeat and never extends
|
||||
``max_hold_minutes``. The switch applies on the next admission
|
||||
without a restart. Position and eight-department roundtables are not counted.
|
||||
|
||||
Roundtable backend jobs use a database unique active-dedupe key plus a leased
|
||||
@@ -837,7 +862,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
|
||||
| **Task Deep-link Threads** (`/api/task-deeplink-threads`) | Dedicated shared history index for the `3qfx` / `rwfx` / `xdfx` single-agent task workspaces. `GET /?task_id=&go_path=&limit=` performs an indexed `(task_id, go_path)` lookup in `task_deeplink_threads` and joins `threads_meta` by `thread_id` for the current title/status/timestamps; LangGraph checkpoints remain the source of truth for messages. Thread metadata carrying `taskRecordId + taskGoPath` is dual-written atomically by both SQL and memory thread stores and deleted with the thread. `3qfx/rwfx` use the task id as `taskRecordId`; `xdfx` uses the original action id while retaining the resolved real task id in `metadata.taskId`. `thread_type=task_deeplink` keeps these rows out of general recent chats; explicit `taskId` searches still opt out of that exclusion for compatibility. Alembic `20261019_01` is schema-only and intentionally does **not** backfill legacy task threads. Frontend hook: `core/threads/use-task-deeplink-threads.ts`. Tests: `packages/harness/tests/test_task_deeplink_threads.py`, `packages/harness/tests/test_thread_meta_task_lookup.py`. |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) or whose `thread_type` is a known system type (`task-analysis` seat runs included; older seat rows are also matched by `task_analysis_seat`) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`, `/api/strategy-project-artifacts/{id}/download`) | `GET /{path}` streams thread artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `GET /api/threads/{id}/artifact-preview?path=` returns bounded text, using streaming statistics plus deterministic head/middle/tail samples above the database threshold. `GET /api/artifact-library` combines every current-user normal thread's authoritative `outputs/` files with completed strategy-selection syntheses projected from the owner-scoped `parallel_agent_tasks.messages` snapshot. Strategy items use `source_type=strategy`, carry `strategy_project_id` / `strategy_name` / `strategy_stale`, and explicitly distinguish `strategy_source_kind=strategy_selection` from an `analysis_project_mode=strategy` thread (`analysis_project`); virtual items also expose `download_url`, so clients never infer routing from sentinel thread/path values. Search/source filters cover both. `workflowStale=true` keeps the last completed historical synthesis visible and downloadable. The dedicated strategy download route revalidates current-user ownership and snapshot completion and serves Markdown without pretending the virtual item is a thread artifact. Uploads and image/audio/video media remain excluded. Library metadata is persisted in `artifact_library_items`; list/search/filter/count/pagination read only this user-scoped SQL index. A generation-fenced `artifact_library_sync_states` lease schedules low-concurrency filesystem/task reconciliation in the background on first/stale reads or `refresh=true`, keeps the last successful snapshot visible during refresh/failure, and reports `sync_status` / `syncing` / `indexed_at` / `sync_error` to the client. Files themselves remain authoritative on disk and are never copied into the database. |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`, `/api/strategy-project-artifacts/{id}/download`) | `GET /{path}` streams thread artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. HTML/XHTML additionally accepts `offline_bundle=true`: it returns `{stem}-离线包.zip` with `index.html`, the referenced trusted Gateway runtime/image dependencies rewritten under `assets/`, a manifest/README and relevant third-party licenses; public view-share files support the same query. The builder never fetches arbitrary remote URLs, and records remaining Leaflet tile/user-supplied external URLs in the manifest. `GET /api/threads/{id}/artifact-preview?path=` returns bounded text, using streaming statistics plus deterministic head/middle/tail samples above the database threshold. `GET /api/artifact-library` combines every current-user normal thread's authoritative `outputs/` files with completed strategy-selection syntheses projected from the owner-scoped `parallel_agent_tasks.messages` snapshot. Strategy items use `source_type=strategy`, carry `strategy_project_id` / `strategy_name` / `strategy_stale`, and explicitly distinguish `strategy_source_kind=strategy_selection` from an `analysis_project_mode=strategy` thread (`analysis_project`); virtual items also expose `download_url`, so clients never infer routing from sentinel thread/path values. Search/source filters cover both. `workflowStale=true` keeps the last completed historical synthesis visible and downloadable. The dedicated strategy download route revalidates current-user ownership and snapshot completion and serves Markdown without pretending the virtual item is a thread artifact. Uploads and image/audio/video media remain excluded. Library metadata is persisted in `artifact_library_items`; list/search/filter/count/pagination read only this user-scoped SQL index. A generation-fenced `artifact_library_sync_states` lease schedules low-concurrency filesystem/task reconciliation in the background on first/stale reads or `refresh=true`, keeps the last successful snapshot visible during refresh/failure, and reports `sync_status` / `syncing` / `indexed_at` / `sync_error` to the client. Files themselves remain authoritative on disk and are never copied into the database. |
|
||||
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables `prompt_prefix.ordinary_qa_markdown_format_enabled` in `system_settings.json`; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
|
||||
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. The compact writer validates generated Markdown against that frozen selection: `fixed` requires every non-placeholder heading with the same level and order, while `adaptive` retries only when no core template heading survives. That correction shares the single existing retry budget (never more than two writer calls), and its deterministic fallback is outline-shaped as well. When `custom_outline` is empty (the workbench default — the user did not pick a report structure), chat collection first calls `POST /sessions/{id}/intent-outline` after the collector turn ends: the chosen model plans a Markdown skeleton from the topic plus collected materials, the outline is frozen into the session snapshot and shown in the「开始撰写」form for editing. If that step is skipped or fails, `basic`/`quick` still call `generate_intent_outline` at write time, emit `plan_created` (`source: intent`), then write through the same shared writer; generation failure is a recoverable `intent_outline_fallback` warning and does not abort the report. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
|
||||
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# DeerFlow Backend
|
||||
|
||||
## 技能调用识别与统计(2026-11-07)
|
||||
|
||||
技能调用的展示和“调用次数”不再依赖模型主动说明。后端以真实工具信号为准:
|
||||
成功的 `skill_view`、对 `/mnt/skills/{public,custom}/<name>/...` 的文件读取,
|
||||
以及 `bash` 直接执行该目录下的技能脚本,都会识别为对应技能调用。一次问答中同一技能即使先读
|
||||
`SKILL.md`、再读参考文件并执行多个脚本,也只累计一次;不同问答分别累计。
|
||||
|
||||
计数会同时更新 `.usage.json` 和技能列表使用的数据库投影,因此内置技能与自定义技能都能在
|
||||
页面刷新后看到最新调用次数。对话步骤也直接从 `skill_view` 参数或真实技能路径显示
|
||||
“调用技能「名称」”;弱模型未填写工具描述、未在正文里自述技能名时仍可识别。
|
||||
完整工具审计仍由 `runtime_performance.tool_metrics_enabled` 独立控制;默认开启的
|
||||
`skill_metrics_enabled` 只把可归因到具体技能的低频调用写入统计表,供技能排行和调用统计使用。
|
||||
|
||||
## 首次问答初始化响应性(2026-10-30)
|
||||
|
||||
普通问答、开放问答接口和工作流智能体节点的模型 SDK 加载、客户端初始化及
|
||||
@@ -1715,7 +1728,9 @@ Multi-agent roundtable step 2 has a separate room cap. Administrators change
|
||||
it under Settings → 会商并发 (`roundtable_room_limit`, default on, 3 rooms)
|
||||
without restarting. The count is the number of live roundtable executions
|
||||
across every account (foreground leases/live LangGraph runs plus executing
|
||||
background jobs), not the current login. Merely opening a tab or viewing chat
|
||||
background jobs), not the current login. A foreground lease stays occupied for
|
||||
``lease_minutes`` without a renewal (default 5 minutes, range 1–60) and is
|
||||
refreshed by the page heartbeat. Merely opening a tab or viewing chat
|
||||
history does not count, but two tabs that both run the same saved draft count
|
||||
as two rooms. All seats and Step 3 work in one execution share one room;
|
||||
foreground-to-background handoff keeps one room. A full pool keeps the user on
|
||||
@@ -1815,11 +1830,12 @@ Beta(8,2) 先验抑制小样本极值;零样本返回 `score: null` 和
|
||||
实际加载的 `config.yaml`(`DEER_FLOW_CONFIG_PATH` 指定文件优先)支持以下独立开关。
|
||||
`skill_view_reuse_enabled` 和 `skill_view_cache_enabled` 在配置里不写时默认开启;
|
||||
要恢复每次工具调用都全量刷新技能视图,把对应项写成 `false` 后重启。
|
||||
模型计时与线程池开关默认关闭,工具统计写入也默认暂停:
|
||||
模型计时与线程池开关默认关闭,普通工具审计默认暂停,低频技能统计默认保留:
|
||||
|
||||
```yaml
|
||||
runtime_performance:
|
||||
tool_metrics_enabled: false
|
||||
skill_metrics_enabled: true
|
||||
skill_view_reuse_enabled: true
|
||||
skill_view_cache_enabled: true
|
||||
model_timing_nonblocking_enabled: true
|
||||
@@ -1827,13 +1843,17 @@ runtime_performance:
|
||||
asyncio_thread_pool_size: 100
|
||||
```
|
||||
|
||||
- `tool_metrics_enabled`:工具/技能调用统计写入开关,**缺省为 false(暂停)**,旧部署配置
|
||||
没有此项时也暂停。主智能体和子智能体不再装载工具统计中间件,不创建统计写库任务、
|
||||
不执行技能统计合并查询,从源头减少与登录共用业务数据库连接池的竞争。
|
||||
- `tool_metrics_enabled`:普通工具审计写入开关,**缺省为 false(暂停)**,旧部署配置
|
||||
没有此项时也暂停。主智能体和子智能体不会为普通工具创建统计写库任务,
|
||||
从源头减少与登录共用业务数据库连接池的竞争。
|
||||
正常工具调用、错误处理、问答消息/checkpoint 和模型调用统计继续运行;已有
|
||||
`tool_call_metrics` 记录与查询/导出接口保留。暂停期间工具/技能审计明细及排行榜的
|
||||
工具/技能调用计数不再新增,且不缓存或事后补录。恢复时设为 `true` 并重启后台。
|
||||
`tool_call_metrics` 记录与查询/导出接口保留。暂停期间普通工具调用计数不再新增,
|
||||
且不缓存或事后补录。恢复时设为 `true` 并重启后台。
|
||||
部署新代码并重启后才对所有运行实例生效;这不消除历史统计查询或其他数据库操作的开销。
|
||||
- `skill_metrics_enabled`:只记录从 `skill_view` 参数或
|
||||
`/mnt/skills/{public,custom}/<name>/...` 工具路径确定出的技能调用,缺省为 `true`。
|
||||
非技能工具直接透传,不执行技能合并查询或写库;因此普通工具审计暂停时,技能排行与
|
||||
技能调用统计仍会更新。若连这部分低频写入也要暂停,显式设为 `false` 并重启。
|
||||
- `skill_view_reuse_enabled`:一次沙箱工具调用共用一份已验证技能视图,路径检查、
|
||||
本地/AIO 沙箱初始化、命令转换和输出脱敏不再反复刷新。下一次工具调用重新验证,
|
||||
不跨问答永久沿用启停状态。
|
||||
|
||||
@@ -186,6 +186,15 @@ def current_user_id(request: Request) -> str:
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _lease_seconds(settings: Any) -> int:
|
||||
minutes = getattr(settings, "lease_minutes", 5)
|
||||
try:
|
||||
minutes = int(minutes)
|
||||
except (TypeError, ValueError):
|
||||
minutes = 5
|
||||
return max(1, min(60, minutes)) * 60
|
||||
|
||||
|
||||
def _store(request: Request):
|
||||
return getattr(request.app.state, "roundtable_room_store", None)
|
||||
|
||||
@@ -232,6 +241,7 @@ async def admit_roundtable_room(
|
||||
enabled=settings.enabled,
|
||||
max_rooms=settings.max_rooms,
|
||||
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
|
||||
lease_seconds=_lease_seconds(settings),
|
||||
extra_keys=extra_keys,
|
||||
)
|
||||
|
||||
@@ -265,6 +275,7 @@ async def renew_roundtable_room(
|
||||
enabled=settings.enabled,
|
||||
max_rooms=settings.max_rooms,
|
||||
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
|
||||
lease_seconds=_lease_seconds(settings),
|
||||
extra_keys=extra_keys,
|
||||
)
|
||||
|
||||
@@ -425,6 +436,7 @@ async def enforce_roundtable_run(
|
||||
enabled=settings.enabled,
|
||||
max_rooms=settings.max_rooms,
|
||||
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
|
||||
lease_seconds=_lease_seconds(settings),
|
||||
extra_keys=await backend_occupancy_keys(request),
|
||||
)
|
||||
if not decision.get("ok", False):
|
||||
|
||||
@@ -30,7 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, case, desc, func, not_, or_, select, text
|
||||
from sqlalchemy import and_, case, desc, func, literal, not_, or_, select, text
|
||||
|
||||
from app.gateway.admin_query_window import (
|
||||
ADMIN_QUERY_WINDOW_DAYS,
|
||||
@@ -279,6 +279,41 @@ class AdminLeaderboardDimension(BaseModel):
|
||||
last_seen_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardDetailItem(BaseModel):
|
||||
"""One row in the expanded skill, tool, or agent analytics drawer."""
|
||||
|
||||
name: str
|
||||
count: int = 0
|
||||
user_count: int = 0
|
||||
total_tokens: int = 0
|
||||
avg_duration_ms: int = 0
|
||||
|
||||
|
||||
class AdminLeaderboardDetailSummary(BaseModel):
|
||||
count: int = 0
|
||||
item_count: int = 0
|
||||
user_count: int = 0
|
||||
total_tokens: int = 0
|
||||
avg_duration_ms: int = 0
|
||||
|
||||
|
||||
class AdminLeaderboardDetailResponse(BaseModel):
|
||||
"""Fresh, drill-down analytics for a leaderboard dimension.
|
||||
|
||||
Unlike the dashboard's compact daily snapshots, this response is queried
|
||||
directly. It lets an administrator inspect the entire matching dimension
|
||||
(up to the explicit limit) and does not lose long-tail rows because they
|
||||
were outside a daily top-N snapshot.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
range_days: int
|
||||
since: str
|
||||
until: str
|
||||
summary: AdminLeaderboardDetailSummary
|
||||
items: list[AdminLeaderboardDetailItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminLeaderboardTrendPoint(BaseModel):
|
||||
date: str
|
||||
runs: int = 0
|
||||
@@ -961,6 +996,125 @@ async def _query_run_dimensions(
|
||||
]
|
||||
|
||||
|
||||
async def _query_leaderboard_dimension_details(
|
||||
session,
|
||||
since: datetime,
|
||||
until: datetime,
|
||||
*,
|
||||
kind: str,
|
||||
limit: int,
|
||||
exclude_user_ids: set[str],
|
||||
include_scheduled: bool,
|
||||
include_admins: bool,
|
||||
include_failed: bool,
|
||||
) -> tuple[
|
||||
AdminLeaderboardDetailSummary,
|
||||
list[AdminLeaderboardDetailItem],
|
||||
]:
|
||||
"""Load complete, fresh detail statistics for one leaderboard dimension.
|
||||
|
||||
The main leaderboard deliberately reads compact daily snapshots so it can
|
||||
remain quick under load. The drawer is an intentional admin drill-down,
|
||||
therefore it runs a bounded aggregate against the source metric tables and
|
||||
returns exact unique-user counts for the selected interval.
|
||||
"""
|
||||
|
||||
if kind in {"skill", "tool"}:
|
||||
name_col = ToolCallMetricRow.skill_name if kind == "skill" else ToolCallMetricRow.tool_name
|
||||
base_filters = [
|
||||
_tool_time_filter(since, until),
|
||||
name_col.is_not(None),
|
||||
name_col != "",
|
||||
]
|
||||
|
||||
def apply_scope(stmt):
|
||||
return _apply_tool_scope(
|
||||
stmt,
|
||||
exclude_user_ids=exclude_user_ids,
|
||||
include_admins=include_admins,
|
||||
include_scheduled=include_scheduled,
|
||||
)
|
||||
|
||||
count_col = func.count(ToolCallMetricRow.id)
|
||||
user_count_col = func.count(func.distinct(ToolCallMetricRow.user_id))
|
||||
total_tokens_col = literal(0)
|
||||
avg_duration_col = func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0)
|
||||
elif kind == "agent":
|
||||
name_col = RunRow.assistant_id
|
||||
base_filters = [
|
||||
_run_time_filter(since, until),
|
||||
name_col.is_not(None),
|
||||
name_col != "",
|
||||
]
|
||||
|
||||
def apply_scope(stmt):
|
||||
return _apply_run_scope(
|
||||
stmt,
|
||||
user_column=RunRow.user_id,
|
||||
run_id_column=RunRow.run_id,
|
||||
exclude_user_ids=exclude_user_ids,
|
||||
include_admins=include_admins,
|
||||
include_failed=include_failed,
|
||||
include_scheduled=include_scheduled,
|
||||
)
|
||||
|
||||
count_col = func.count(RunRow.run_id)
|
||||
user_count_col = func.count(func.distinct(RunRow.user_id))
|
||||
total_tokens_col = func.coalesce(func.sum(RunRow.total_tokens), 0)
|
||||
# Run rows have no reliable wall-clock duration. Keep this explicit at
|
||||
# zero instead of inferring elapsed time from an asynchronously updated
|
||||
# journal timestamp.
|
||||
avg_duration_col = literal(0)
|
||||
else:
|
||||
raise ValueError(f"Unknown leaderboard detail kind: {kind}")
|
||||
|
||||
item_stmt = (
|
||||
select(
|
||||
name_col.label("name"),
|
||||
count_col.label("count"),
|
||||
user_count_col.label("user_count"),
|
||||
total_tokens_col.label("total_tokens"),
|
||||
avg_duration_col.label("avg_duration_ms"),
|
||||
)
|
||||
.where(*base_filters)
|
||||
.group_by(name_col)
|
||||
.order_by(count_col.desc(), name_col.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
item_stmt = apply_scope(item_stmt)
|
||||
|
||||
summary_stmt = select(
|
||||
count_col.label("count"),
|
||||
func.count(func.distinct(name_col)).label("item_count"),
|
||||
user_count_col.label("user_count"),
|
||||
total_tokens_col.label("total_tokens"),
|
||||
avg_duration_col.label("avg_duration_ms"),
|
||||
).where(*base_filters)
|
||||
summary_stmt = apply_scope(summary_stmt)
|
||||
|
||||
item_rows = (await _execute_leaderboard_query(session, f"{kind}_detail.items", item_stmt)).all()
|
||||
summary_row = (await _execute_leaderboard_query(session, f"{kind}_detail.summary", summary_stmt)).one()
|
||||
|
||||
summary = AdminLeaderboardDetailSummary(
|
||||
count=int(summary_row[0] or 0),
|
||||
item_count=int(summary_row[1] or 0),
|
||||
user_count=int(summary_row[2] or 0),
|
||||
total_tokens=int(summary_row[3] or 0),
|
||||
avg_duration_ms=int(summary_row[4] or 0),
|
||||
)
|
||||
items = [
|
||||
AdminLeaderboardDetailItem(
|
||||
name=row[0] or "",
|
||||
count=int(row[1] or 0),
|
||||
user_count=int(row[2] or 0),
|
||||
total_tokens=int(row[3] or 0),
|
||||
avg_duration_ms=int(row[4] or 0),
|
||||
)
|
||||
for row in item_rows
|
||||
]
|
||||
return summary, items
|
||||
|
||||
|
||||
async def _query_model_rankings(
|
||||
session,
|
||||
since: datetime,
|
||||
@@ -1876,6 +2030,59 @@ async def get_user_leaderboard(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/leaderboard/details", response_model=AdminLeaderboardDetailResponse)
|
||||
async def get_user_leaderboard_details(
|
||||
request: Request,
|
||||
kind: str = Query(description="One of: skill, tool, agent", pattern="^(skill|tool|agent)$"),
|
||||
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
|
||||
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
||||
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
) -> AdminLeaderboardDetailResponse:
|
||||
"""Return fresh drill-down statistics for one leaderboard card.
|
||||
|
||||
This endpoint intentionally reads the source metric tables instead of the
|
||||
asynchronous daily leaderboard snapshots. A drawer represents an explicit
|
||||
admin investigation, where exact unique-user counts, all matching entries,
|
||||
and complete detail rows are more useful than a compact cache.
|
||||
"""
|
||||
|
||||
await _require_admin(request)
|
||||
session_factory = get_session_factory()
|
||||
if session_factory is None:
|
||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||
|
||||
settings = load_system_settings().leaderboard
|
||||
since_dt, until_dt, range_days = resolve_admin_query_window(
|
||||
days=days,
|
||||
since=since,
|
||||
until=until,
|
||||
)
|
||||
excluded, include_scheduled, include_admins, include_failed, _settings_hash = _resolve_leaderboard_snapshot_scope(settings)
|
||||
async with session_factory() as session:
|
||||
await _set_analytics_read_timeout(session)
|
||||
summary, items = await _query_leaderboard_dimension_details(
|
||||
session,
|
||||
since_dt,
|
||||
until_dt,
|
||||
kind=kind,
|
||||
limit=limit,
|
||||
exclude_user_ids=excluded,
|
||||
include_scheduled=include_scheduled,
|
||||
include_admins=include_admins,
|
||||
include_failed=include_failed,
|
||||
)
|
||||
|
||||
return AdminLeaderboardDetailResponse(
|
||||
kind=kind,
|
||||
range_days=range_days,
|
||||
since=_iso(since_dt),
|
||||
until=_iso(until_dt),
|
||||
summary=summary,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/leaderboard/snapshots", response_model=AdminLeaderboardSnapshotStatusResponse)
|
||||
async def get_leaderboard_snapshot_status(
|
||||
request: Request,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Application chrome name and logo.
|
||||
"""Application chrome name, logo, and optional red-theme artwork.
|
||||
|
||||
Stored separately from ``generated_page_branding`` and the
|
||||
``generated-page-branding/`` directory. An empty name or a missing logo leaves
|
||||
the built-in title and static theme logos in place.
|
||||
``generated-page-branding/`` directory. Missing custom images leave the
|
||||
built-in theme artwork in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -18,6 +20,8 @@ from app.gateway.routers.system_settings import _require_admin
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
from deerflow.config.system_settings import (
|
||||
AppIdentitySettings,
|
||||
app_identity_light_red_header_background_path,
|
||||
app_identity_light_red_nav_active_path,
|
||||
app_identity_logo_path,
|
||||
load_system_settings,
|
||||
save_system_settings,
|
||||
@@ -26,25 +30,48 @@ from deerflow.config.system_settings import (
|
||||
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
|
||||
public_router = APIRouter(prefix="/api/public/app-identity", tags=["app-identity"])
|
||||
|
||||
_MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
_PUBLIC_LOGO_PATH = "/api/public/app-identity/logo"
|
||||
_PUBLIC_LIGHT_RED_HEADER_PATH = "/api/public/app-identity/light-red-header-background"
|
||||
_PUBLIC_LIGHT_RED_ACTIVE_PATH = "/api/public/app-identity/light-red-nav-active"
|
||||
|
||||
|
||||
class AppIdentityResponse(BaseModel):
|
||||
system_name: str = ""
|
||||
header_title_font_mode: str = "adaptive"
|
||||
header_title_font_size: int = 30
|
||||
logo_url: str | None = None
|
||||
logo_version: str | None = None
|
||||
has_logo: bool = False
|
||||
light_red_header_background_url: str | None = None
|
||||
light_red_header_background_version: str | None = None
|
||||
has_light_red_header_background: bool = False
|
||||
light_red_nav_active_url: str | None = None
|
||||
light_red_nav_active_version: str | None = None
|
||||
has_light_red_nav_active: bool = False
|
||||
|
||||
|
||||
def _detect_logo(data: bytes) -> tuple[str, str]:
|
||||
def _detect_image(
|
||||
data: bytes,
|
||||
label: str,
|
||||
*,
|
||||
allow_svg: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png", "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg", "image/jpeg"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return ".webp", "image/webp"
|
||||
raise HTTPException(status_code=422, detail="Logo 仅支持 PNG、JPG 或 WebP 图片")
|
||||
if allow_svg:
|
||||
try:
|
||||
root = ElementTree.fromstring(data)
|
||||
except ElementTree.ParseError:
|
||||
root = None
|
||||
if root is not None and root.tag.rsplit("}", 1)[-1].lower() == "svg":
|
||||
return ".svg", "image/svg+xml"
|
||||
supported = "PNG、JPG、WebP 或 SVG" if allow_svg else "PNG、JPG 或 WebP"
|
||||
raise HTTPException(status_code=422, detail=f"{label}仅支持 {supported} 图片")
|
||||
|
||||
|
||||
def _normalize_system_name(value: str | None) -> str:
|
||||
@@ -54,21 +81,61 @@ def _normalize_system_name(value: str | None) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_header_title_font_mode(value: str) -> str:
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized not in {"adaptive", "fixed"}:
|
||||
raise HTTPException(status_code=422, detail="系统名称字号模式仅支持自适应或固定大小")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_header_title_font_size(value: int) -> int:
|
||||
normalized = int(value)
|
||||
if normalized < 16 or normalized > 40:
|
||||
raise HTTPException(status_code=422, detail="系统名称固定字号必须在 16px 到 40px 之间")
|
||||
return normalized
|
||||
|
||||
|
||||
def _response(settings: AppIdentitySettings) -> AppIdentityResponse:
|
||||
path = app_identity_logo_path(settings)
|
||||
version = settings.logo_version if path is not None else None
|
||||
header_background_path = app_identity_light_red_header_background_path(settings)
|
||||
header_background_version = (
|
||||
settings.light_red_header_background_version
|
||||
if header_background_path is not None
|
||||
else None
|
||||
)
|
||||
nav_active_path = app_identity_light_red_nav_active_path(settings)
|
||||
nav_active_version = (
|
||||
settings.light_red_nav_active_version if nav_active_path is not None else None
|
||||
)
|
||||
return AppIdentityResponse(
|
||||
system_name=settings.system_name,
|
||||
header_title_font_mode=settings.header_title_font_mode,
|
||||
header_title_font_size=settings.header_title_font_size,
|
||||
logo_url=f"{_PUBLIC_LOGO_PATH}?v={version}" if version else None,
|
||||
logo_version=version,
|
||||
has_logo=path is not None,
|
||||
light_red_header_background_url=(
|
||||
f"{_PUBLIC_LIGHT_RED_HEADER_PATH}?v={header_background_version}"
|
||||
if header_background_version
|
||||
else None
|
||||
),
|
||||
light_red_header_background_version=header_background_version,
|
||||
has_light_red_header_background=header_background_path is not None,
|
||||
light_red_nav_active_url=(
|
||||
f"{_PUBLIC_LIGHT_RED_ACTIVE_PATH}?v={nav_active_version}"
|
||||
if nav_active_version
|
||||
else None
|
||||
),
|
||||
light_red_nav_active_version=nav_active_version,
|
||||
has_light_red_nav_active=nav_active_path is not None,
|
||||
)
|
||||
|
||||
|
||||
def _write_logo(data: bytes, suffix: str) -> str:
|
||||
def _write_asset(data: bytes, suffix: str, prefix: str) -> str:
|
||||
directory = runtime_home() / "app-identity"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"logo-{uuid4().hex}{suffix}"
|
||||
filename = f"{prefix}-{uuid4().hex}{suffix}"
|
||||
destination = directory / filename
|
||||
temporary = directory / f".{filename}.tmp"
|
||||
try:
|
||||
@@ -79,9 +146,40 @@ def _write_logo(data: bytes, suffix: str) -> str:
|
||||
return filename
|
||||
|
||||
|
||||
async def _read_uploaded_image(
|
||||
upload: UploadFile,
|
||||
label: str,
|
||||
*,
|
||||
allow_svg: bool = False,
|
||||
max_bytes: int | None = _MAX_IMAGE_BYTES,
|
||||
) -> tuple[bytes, str, str]:
|
||||
data = await upload.read(max_bytes + 1) if max_bytes is not None else await upload.read()
|
||||
await upload.close()
|
||||
if not data:
|
||||
raise HTTPException(status_code=422, detail=f"{label}文件不能为空")
|
||||
if max_bytes is not None and len(data) > max_bytes:
|
||||
raise HTTPException(status_code=413, detail=f"{label}不能超过 10MB")
|
||||
suffix, media_type = _detect_image(data, label, allow_svg=allow_svg)
|
||||
return data, suffix, media_type
|
||||
|
||||
|
||||
def _public_asset_headers(media_type: str | None) -> dict[str, str]:
|
||||
headers = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
if media_type == "image/svg+xml":
|
||||
headers.update(
|
||||
{
|
||||
"Content-Security-Policy": (
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
@router.get("/app-identity", response_model=AppIdentityResponse)
|
||||
async def get_app_identity_settings() -> AppIdentityResponse:
|
||||
"""Return the application name and uploaded logo. Any logged-in user may read it."""
|
||||
"""Return the application identity settings readable by all logged-in users."""
|
||||
|
||||
return _response(load_system_settings().app_identity)
|
||||
|
||||
@@ -89,68 +187,160 @@ async def get_app_identity_settings() -> AppIdentityResponse:
|
||||
@router.put("/app-identity", response_model=AppIdentityResponse)
|
||||
async def update_app_identity_settings(
|
||||
request: Request,
|
||||
system_name: str = Form(default=""),
|
||||
remove_logo: bool = Form(default=False),
|
||||
logo: UploadFile | None = File(default=None),
|
||||
system_name: Annotated[str, Form()] = "",
|
||||
remove_logo: Annotated[bool, Form()] = False,
|
||||
logo: Annotated[UploadFile | None, File()] = None,
|
||||
header_title_font_mode: Annotated[str | None, Form()] = None,
|
||||
header_title_font_size: Annotated[int | None, Form()] = None,
|
||||
light_red_header_background: Annotated[UploadFile | None, File()] = None,
|
||||
remove_light_red_header_background: Annotated[bool, Form()] = False,
|
||||
light_red_nav_active: Annotated[UploadFile | None, File()] = None,
|
||||
remove_light_red_nav_active: Annotated[bool, Form()] = False,
|
||||
) -> AppIdentityResponse:
|
||||
"""Save the application chrome name and optional logo. Admin only."""
|
||||
"""Save the application chrome identity and optional red-theme artwork."""
|
||||
|
||||
await _require_admin(request)
|
||||
if logo is not None and remove_logo:
|
||||
raise HTTPException(status_code=422, detail="上传 Logo 时不能同时恢复静态 Logo")
|
||||
if light_red_header_background is not None and remove_light_red_header_background:
|
||||
raise HTTPException(status_code=422, detail="上传导航栏背景图时不能同时恢复默认图片")
|
||||
if light_red_nav_active is not None and remove_light_red_nav_active:
|
||||
raise HTTPException(status_code=422, detail="上传菜单选中样式图时不能同时恢复默认图片")
|
||||
|
||||
current = load_system_settings()
|
||||
previous_logo = app_identity_logo_path(current.app_identity)
|
||||
previous_header_background = app_identity_light_red_header_background_path(
|
||||
current.app_identity
|
||||
)
|
||||
previous_nav_active = app_identity_light_red_nav_active_path(current.app_identity)
|
||||
update: dict[str, object] = {"system_name": _normalize_system_name(system_name)}
|
||||
new_logo_path = None
|
||||
if logo is not None:
|
||||
data = await logo.read(_MAX_LOGO_BYTES + 1)
|
||||
await logo.close()
|
||||
if not data:
|
||||
raise HTTPException(status_code=422, detail="Logo 文件不能为空")
|
||||
if len(data) > _MAX_LOGO_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Logo 文件不能超过 10MB")
|
||||
suffix, media_type = _detect_logo(data)
|
||||
filename = await asyncio.to_thread(_write_logo, data, suffix)
|
||||
new_logo_path = runtime_home() / "app-identity" / filename
|
||||
update.update(
|
||||
{
|
||||
"logo_filename": filename,
|
||||
"logo_media_type": media_type,
|
||||
"logo_version": uuid4().hex,
|
||||
}
|
||||
if header_title_font_mode is not None:
|
||||
update["header_title_font_mode"] = _normalize_header_title_font_mode(header_title_font_mode)
|
||||
if header_title_font_size is not None:
|
||||
update["header_title_font_size"] = _normalize_header_title_font_size(header_title_font_size)
|
||||
logo_asset = (
|
||||
await _read_uploaded_image(logo, "Logo") if logo is not None else None
|
||||
)
|
||||
header_background_asset = (
|
||||
await _read_uploaded_image(
|
||||
light_red_header_background,
|
||||
"导航栏背景图",
|
||||
allow_svg=True,
|
||||
max_bytes=None,
|
||||
)
|
||||
elif remove_logo:
|
||||
update.update(
|
||||
{
|
||||
"logo_filename": None,
|
||||
"logo_media_type": None,
|
||||
"logo_version": None,
|
||||
}
|
||||
if light_red_header_background is not None
|
||||
else None
|
||||
)
|
||||
nav_active_asset = (
|
||||
await _read_uploaded_image(
|
||||
light_red_nav_active,
|
||||
"菜单选中样式图",
|
||||
allow_svg=True,
|
||||
max_bytes=None,
|
||||
)
|
||||
if light_red_nav_active is not None
|
||||
else None
|
||||
)
|
||||
|
||||
updated = current.app_identity.model_copy(update=update)
|
||||
staged_paths = []
|
||||
try:
|
||||
if logo_asset is not None:
|
||||
data, suffix, media_type = logo_asset
|
||||
filename = await asyncio.to_thread(_write_asset, data, suffix, "logo")
|
||||
staged_paths.append(runtime_home() / "app-identity" / filename)
|
||||
update.update(
|
||||
{
|
||||
"logo_filename": filename,
|
||||
"logo_media_type": media_type,
|
||||
"logo_version": uuid4().hex,
|
||||
}
|
||||
)
|
||||
elif remove_logo:
|
||||
update.update(
|
||||
{
|
||||
"logo_filename": None,
|
||||
"logo_media_type": None,
|
||||
"logo_version": None,
|
||||
}
|
||||
)
|
||||
|
||||
if header_background_asset is not None:
|
||||
data, suffix, media_type = header_background_asset
|
||||
filename = await asyncio.to_thread(
|
||||
_write_asset, data, suffix, "light-red-header"
|
||||
)
|
||||
staged_paths.append(runtime_home() / "app-identity" / filename)
|
||||
update.update(
|
||||
{
|
||||
"light_red_header_background_filename": filename,
|
||||
"light_red_header_background_media_type": media_type,
|
||||
"light_red_header_background_version": uuid4().hex,
|
||||
}
|
||||
)
|
||||
elif remove_light_red_header_background:
|
||||
update.update(
|
||||
{
|
||||
"light_red_header_background_filename": None,
|
||||
"light_red_header_background_media_type": None,
|
||||
"light_red_header_background_version": None,
|
||||
}
|
||||
)
|
||||
|
||||
if nav_active_asset is not None:
|
||||
data, suffix, media_type = nav_active_asset
|
||||
filename = await asyncio.to_thread(
|
||||
_write_asset, data, suffix, "light-red-active"
|
||||
)
|
||||
staged_paths.append(runtime_home() / "app-identity" / filename)
|
||||
update.update(
|
||||
{
|
||||
"light_red_nav_active_filename": filename,
|
||||
"light_red_nav_active_media_type": media_type,
|
||||
"light_red_nav_active_version": uuid4().hex,
|
||||
}
|
||||
)
|
||||
elif remove_light_red_nav_active:
|
||||
update.update(
|
||||
{
|
||||
"light_red_nav_active_filename": None,
|
||||
"light_red_nav_active_media_type": None,
|
||||
"light_red_nav_active_version": None,
|
||||
}
|
||||
)
|
||||
|
||||
updated = current.app_identity.model_copy(update=update)
|
||||
saved = save_system_settings(current.model_copy(update={"app_identity": updated}))
|
||||
except Exception:
|
||||
if new_logo_path is not None:
|
||||
await asyncio.to_thread(new_logo_path.unlink, missing_ok=True)
|
||||
for staged_path in staged_paths:
|
||||
await asyncio.to_thread(staged_path.unlink, missing_ok=True)
|
||||
raise
|
||||
if previous_logo is not None and previous_logo != app_identity_logo_path(saved.app_identity):
|
||||
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
|
||||
previous_paths = (previous_logo, previous_header_background, previous_nav_active)
|
||||
saved_paths = {
|
||||
app_identity_logo_path(saved.app_identity),
|
||||
app_identity_light_red_header_background_path(saved.app_identity),
|
||||
app_identity_light_red_nav_active_path(saved.app_identity),
|
||||
}
|
||||
for previous_path in previous_paths:
|
||||
if previous_path is not None and previous_path not in saved_paths:
|
||||
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
|
||||
return _response(saved.app_identity)
|
||||
|
||||
|
||||
@router.delete("/app-identity", response_model=AppIdentityResponse)
|
||||
async def delete_app_identity_settings(request: Request) -> AppIdentityResponse:
|
||||
"""Clear the application name and uploaded logo. Admin only."""
|
||||
"""Clear the application identity and uploaded theme artwork. Admin only."""
|
||||
|
||||
await _require_admin(request)
|
||||
current = load_system_settings()
|
||||
previous_logo = app_identity_logo_path(current.app_identity)
|
||||
previous_paths = (
|
||||
app_identity_logo_path(current.app_identity),
|
||||
app_identity_light_red_header_background_path(current.app_identity),
|
||||
app_identity_light_red_nav_active_path(current.app_identity),
|
||||
)
|
||||
saved = save_system_settings(current.model_copy(update={"app_identity": AppIdentitySettings()}))
|
||||
if previous_logo is not None:
|
||||
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
|
||||
for previous_path in previous_paths:
|
||||
if previous_path is not None:
|
||||
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
|
||||
return _response(saved.app_identity)
|
||||
|
||||
|
||||
@@ -166,5 +356,35 @@ async def get_app_identity_logo() -> FileResponse:
|
||||
path,
|
||||
media_type=settings.logo_media_type or "image/png",
|
||||
content_disposition_type="inline",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
headers=_public_asset_headers(settings.logo_media_type),
|
||||
)
|
||||
|
||||
|
||||
@public_router.get("/light-red-header-background")
|
||||
async def get_light_red_header_background() -> FileResponse:
|
||||
settings = load_system_settings().app_identity
|
||||
path = app_identity_light_red_header_background_path(settings)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="未上传红色导航栏背景图")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=settings.light_red_header_background_media_type or "image/png",
|
||||
content_disposition_type="inline",
|
||||
headers=_public_asset_headers(
|
||||
settings.light_red_header_background_media_type
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@public_router.get("/light-red-nav-active")
|
||||
async def get_light_red_nav_active() -> FileResponse:
|
||||
settings = load_system_settings().app_identity
|
||||
path = app_identity_light_red_nav_active_path(settings)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="未上传红色菜单选中样式图")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=settings.light_red_nav_active_media_type or "image/png",
|
||||
content_disposition_type="inline",
|
||||
headers=_public_asset_headers(settings.light_red_nav_active_media_type),
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -11,12 +13,16 @@ from urllib.parse import quote
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.operation_timing import OperationTiming
|
||||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.config.system_settings import generated_page_logo_path, load_system_settings
|
||||
from deerflow.html_runtime.addresses import html_map_tile_url, html_runtime_base_url
|
||||
from deerflow.html_runtime.offline_bundle import build_html_offline_bundle
|
||||
from deerflow.runtime.thread_paths import aresolve_path_user_id
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_io import file_io
|
||||
@@ -213,6 +219,44 @@ def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | Non
|
||||
return headers
|
||||
|
||||
|
||||
def _remove_temporary_bundle(path: str) -> None:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary HTML offline bundle: %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def build_html_offline_bundle_response(actual_path: Path) -> FileResponse:
|
||||
"""Build and stream a portable ZIP for one generated HTML artifact."""
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix="deerflow-html-offline-", suffix=".zip")
|
||||
os.close(descriptor)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
branding = load_system_settings().generated_page_branding
|
||||
logo_path = generated_page_logo_path(branding)
|
||||
await file_io.run_until_complete(
|
||||
build_html_offline_bundle,
|
||||
actual_path,
|
||||
temporary_path,
|
||||
runtime_base_url=html_runtime_base_url(),
|
||||
map_tile_url=html_map_tile_url(),
|
||||
generated_logo_path=logo_path,
|
||||
)
|
||||
except BaseException:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
download_name = f"{actual_path.stem}-离线包.zip"
|
||||
return FileResponse(
|
||||
temporary_path,
|
||||
filename=download_name,
|
||||
media_type="application/zip",
|
||||
headers={"Cache-Control": "private, no-store"},
|
||||
background=BackgroundTask(_remove_temporary_bundle, str(temporary_path)),
|
||||
)
|
||||
|
||||
|
||||
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
||||
"""Check if file is text by examining content for null bytes."""
|
||||
try:
|
||||
@@ -988,10 +1032,20 @@ async def preview_artifact(
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Get Artifact File",
|
||||
description="Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, while active web content is always downloaded.",
|
||||
description=(
|
||||
"Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, "
|
||||
"while active web content is always downloaded. HTML files can be exported with their DeerFlow "
|
||||
"runtime dependencies as a portable ZIP."
|
||||
),
|
||||
)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def get_artifact(thread_id: str, path: str, request: Request, download: bool = False) -> Response:
|
||||
async def get_artifact(
|
||||
thread_id: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
download: bool = False,
|
||||
offline_bundle: bool = False,
|
||||
) -> Response:
|
||||
"""Get an artifact file by its path.
|
||||
|
||||
The endpoint automatically detects file types and returns appropriate content types.
|
||||
@@ -1018,6 +1072,8 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
||||
download (bool): If true, forces attachment download for file types that are
|
||||
otherwise returned inline or as plain text. Active HTML/XHTML/SVG content
|
||||
is always downloaded regardless of this flag.
|
||||
offline_bundle (bool): If true for an HTML/XHTML file, returns a ZIP containing
|
||||
``index.html`` plus the packaged DeerFlow JS/CSS/image runtime dependencies.
|
||||
|
||||
Example:
|
||||
- Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt`
|
||||
@@ -1075,6 +1131,11 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(actual_path)
|
||||
|
||||
if offline_bundle:
|
||||
if actual_path.suffix.lower() not in {".htm", ".html", ".xhtml"}:
|
||||
raise HTTPException(status_code=400, detail="Offline bundles are available for HTML files only")
|
||||
return await build_html_offline_bundle_response(actual_path)
|
||||
|
||||
if download:
|
||||
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers=_build_attachment_headers(actual_path.name))
|
||||
|
||||
|
||||
@@ -5,11 +5,17 @@
|
||||
读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置)
|
||||
仅管理员——与敏感词管理同一鉴权口径。旧端点 ``GET /api/scheduled-tasks/html-templates``
|
||||
保持不变(内部同样读存储),本路由是它的管理超集。
|
||||
|
||||
结构化模板(kind="structured",如 no.1)额外暴露默认数据的读写接口:
|
||||
``GET /{id}/data`` 读、``PUT /{id}/data`` 整体替换(管理页「编辑数据」的持久化入口,
|
||||
替换前经转换契约校验)。``GET /{id}/content`` 默认返回组合后的完整文档(预览用),
|
||||
``?raw=1`` 返回骨架原文(编辑用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -17,7 +23,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.runtime.scheduler import template_store
|
||||
from deerflow.runtime.scheduler import template_fill, template_store
|
||||
|
||||
router = APIRouter(prefix="/api/html-templates", tags=["html-templates"])
|
||||
|
||||
@@ -48,6 +54,33 @@ class TemplateUpdateRequest(BaseModel):
|
||||
html: str | None = Field(default=None, description="新的模板正文(上传覆盖走这里)")
|
||||
|
||||
|
||||
class TemplateDataUpdateRequest(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="新的默认数据(整体替换 data.json,须通过契约校验)")
|
||||
manifest: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="可选:新的 MANIFEST(整体替换 manifest.json,系统名称/导航页面/交互规则)",
|
||||
)
|
||||
|
||||
|
||||
class FieldVisibilityUpdateRequest(BaseModel):
|
||||
hidden: list[str] = Field(default_factory=list, description="要隐藏的字段显示名列表(其余字段全部显示)")
|
||||
extract_locked: list[str] | None = Field(
|
||||
default=None,
|
||||
description="可选:不接受抽取更新的字段名单(None=不动;给出则整体替换,空表=全部接受)",
|
||||
)
|
||||
|
||||
|
||||
class CustomModuleListUpdateRequest(BaseModel):
|
||||
modules: list[dict[str, Any]] = Field(..., description="自定义模块全量列表(整体替换)")
|
||||
|
||||
|
||||
class CustomModuleGenerateRequest(BaseModel):
|
||||
page: str = Field(..., description="挂载的页面编号(manifest.页面 的 编号)")
|
||||
title: str = Field(default="", description="模块标题(留空由模型起名)")
|
||||
description: str = Field(..., min_length=4, description="用自然语言描述想新增的展示内容")
|
||||
model_name: str | None = Field(default=None, description="可选:指定生成模型")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_html_templates(request: Request) -> dict:
|
||||
"""模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。"""
|
||||
@@ -69,15 +102,230 @@ async def get_html_template(template_id: str, request: Request) -> dict[str, Any
|
||||
|
||||
|
||||
@router.get("/{template_id}/content")
|
||||
async def get_html_template_content(template_id: str, request: Request) -> dict:
|
||||
async def get_html_template_content(
|
||||
template_id: str, request: Request, raw: bool = False
|
||||
) -> dict:
|
||||
"""模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物);
|
||||
``?raw=1`` 返回正文原文(结构化模板 = 带 __PAGE_* 标记的骨架,编辑用)。"""
|
||||
|
||||
async def load() -> dict:
|
||||
try:
|
||||
html = await asyncio.to_thread(template_store.read_template_html, template_id)
|
||||
except template_store.TemplateStoreError:
|
||||
raise _not_found(template_id)
|
||||
if raw:
|
||||
try:
|
||||
html = await asyncio.to_thread(template_store.read_template_html, template_id)
|
||||
except template_store.TemplateStoreError:
|
||||
raise _not_found(template_id)
|
||||
else:
|
||||
try:
|
||||
html = await asyncio.to_thread(template_fill.read_template_text, template_id)
|
||||
except template_fill.TemplateError:
|
||||
raise _not_found(template_id)
|
||||
return {"template_id": template_id, "html": html}
|
||||
|
||||
return await cached_catalog(request, "html_templates", catalog_key("content", template_id), load)
|
||||
suffix = "raw" if raw else "doc"
|
||||
return await cached_catalog(request, "html_templates", catalog_key("content", template_id, suffix), load)
|
||||
|
||||
|
||||
@router.get("/{template_id}/data")
|
||||
async def get_html_template_data(template_id: str, request: Request) -> dict[str, Any]:
|
||||
"""结构化模板的默认数据(data.json) + MANIFEST + 各模块中文名(管理页「编辑数据」用)。"""
|
||||
|
||||
async def load() -> dict[str, Any]:
|
||||
try:
|
||||
data, manifest, modules = await asyncio.gather(
|
||||
asyncio.to_thread(template_store.read_template_data, template_id),
|
||||
asyncio.to_thread(template_store.read_template_manifest, template_id),
|
||||
asyncio.to_thread(template_fill.structured_module_labels, template_id),
|
||||
)
|
||||
except (template_store.TemplateStoreError, template_fill.TemplateError) as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"data": data,
|
||||
"manifest": manifest,
|
||||
"modules": modules,
|
||||
}
|
||||
|
||||
return await cached_catalog(request, "html_templates", catalog_key("data", template_id), load)
|
||||
|
||||
|
||||
@router.put("/{template_id}/data")
|
||||
async def update_html_template_data(
|
||||
template_id: str, body: TemplateDataUpdateRequest, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""整体替换结构化模板的默认数据/MANIFEST 并持久化(仅管理员;先经契约校验)。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
meta = template_store.write_template_data(
|
||||
template_id, body.data, manifest=body.manifest
|
||||
)
|
||||
except template_store.TemplateStoreError as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
await invalidate_catalog(request, "html_templates")
|
||||
return meta
|
||||
|
||||
|
||||
# ── 字段显示 / 自定义模块 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{template_id}/fields")
|
||||
async def get_html_template_fields(template_id: str, request: Request) -> dict[str, Any]:
|
||||
"""字段目录(fields.json,含中文说明)× 当前可见性 + 页面清单 + 自定义模块列表。"""
|
||||
|
||||
async def load() -> dict[str, Any]:
|
||||
try:
|
||||
catalog, manifest = await asyncio.gather(
|
||||
asyncio.to_thread(template_store.read_template_fields, template_id),
|
||||
asyncio.to_thread(template_store.read_template_manifest, template_id),
|
||||
)
|
||||
except template_store.TemplateStoreError as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
hidden = manifest.get("字段显示")
|
||||
hidden = hidden if isinstance(hidden, dict) else {}
|
||||
locks = manifest.get("字段抽取关闭")
|
||||
locks = locks if isinstance(locks, dict) else {}
|
||||
fields = [
|
||||
{
|
||||
"label": str(f.get("label") or ""),
|
||||
"pages": [str(p) for p in (f.get("pages") or [])],
|
||||
"description": str(f.get("description") or ""),
|
||||
"visible": hidden.get(str(f.get("label"))) is not False,
|
||||
# 是否接受定时任务抽取数据更新(默认 True);布局分区无独立数据键,恒为 True 且前端禁用开关。
|
||||
"extract": locks.get(str(f.get("label"))) is not True,
|
||||
"has_key": bool(f.get("keys")),
|
||||
}
|
||||
for f in catalog
|
||||
]
|
||||
pages = [
|
||||
{"key": str(p.get("编号") or ""), "label": str(p.get("名称") or p.get("编号") or "")}
|
||||
for p in manifest.get("页面") or []
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
custom = manifest.get("自定义模块")
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"fields": fields,
|
||||
"pages": pages,
|
||||
"custom_modules": custom if isinstance(custom, list) else [],
|
||||
}
|
||||
|
||||
return await cached_catalog(request, "html_templates", catalog_key("fields", template_id), load)
|
||||
|
||||
|
||||
@router.put("/{template_id}/fields")
|
||||
async def update_html_template_fields(
|
||||
template_id: str, body: FieldVisibilityUpdateRequest, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""整体替换字段显示/隐藏配置(可选一并替换抽取锁定名单;仅管理员)。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
meta = template_store.write_template_field_visibility(template_id, body.hidden)
|
||||
if body.extract_locked is not None:
|
||||
template_store.write_template_field_locks(template_id, body.extract_locked)
|
||||
except template_store.TemplateStoreError as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
await invalidate_catalog(request, "html_templates")
|
||||
return meta
|
||||
|
||||
|
||||
@router.put("/{template_id}/custom-modules")
|
||||
async def update_html_template_custom_modules(
|
||||
template_id: str, body: CustomModuleListUpdateRequest, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""自定义模块列表整体替换(入库前逐条消毒;仅管理员)。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
meta = template_store.write_template_custom_modules(template_id, body.modules)
|
||||
except template_store.TemplateStoreError as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
await invalidate_catalog(request, "html_templates")
|
||||
return meta
|
||||
|
||||
|
||||
_CUSTOM_MODULE_PROMPT = """你是资深前端设计师,为知识数据展示系统(红白主题)绘制一个自定义展示模块的 HTML 片段。
|
||||
|
||||
主题与可直接使用的样式(不要自造 CSS,尽量复用):
|
||||
- 颜色: 背景 #f5f5f6、卡片白 #fff、主红 #c8102e、深红 #8f1024、浅红底 #fff1f3、正文 #262626、次要 #737373
|
||||
- 布局类: panel(白卡容器) panel-title(红色竖条标题) grid2(两列) metrics(三列指标组)
|
||||
- 组件类: metric metric-label metric-value metric-unit metric-note(指标卡) | chip chip-red chip-green chip-orange(标签)
|
||||
- 组件类: text-list text-item(条目列表) | info-grid info-item(信息格) | bar-row bar(横条统计) | entry-grid entry-card(入口卡)
|
||||
- 也可以用少量 inline style 微调(颜色只允许上述色板)
|
||||
|
||||
硬性约束:
|
||||
1. 只输出一个最外层 <div> 包裹的 HTML 片段,不要 <html>/<head>/<body>/<script>/<style>/<iframe>/<form>
|
||||
2. 禁止任何 on* 事件属性、javascript: 链接、外部资源引用(图片/字体/CDN)
|
||||
3. 内容用简体中文;基于下面的描述合理组织版式;没有的数据写「待补充」,不要编造精确数字
|
||||
4. **文字极简(最重要)**:整个模块总文字量 ≤120 字;任何一条文字 ≤30 字;严禁出现大段连续正文或 <p> 长段落
|
||||
5. **呈现方式**:数字/统计一律用 metric 指标卡(大数字+短标签+一句 ≤20 字注释);分类/状态用 chip 标签;多条信息用 text-item 短条目(每条一行短句);条目间用并列结构,不要写成文章
|
||||
6. 只输出片段本身,不要任何解释文字或 markdown 代码块
|
||||
|
||||
挂载页面:{page}
|
||||
模块标题:{title}
|
||||
模块描述:{description}"""
|
||||
|
||||
|
||||
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.IGNORECASE | re.DOTALL)
|
||||
_THINK_TAG_RE = re.compile(r"</?think>", re.IGNORECASE)
|
||||
_FENCE_HTML_RE = re.compile(r"```(?:html)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_fragment(raw: str) -> str:
|
||||
text = str(raw or "").strip()
|
||||
text = _THINK_BLOCK_RE.sub("", text)
|
||||
text = _THINK_TAG_RE.sub("", text).strip()
|
||||
fence = _FENCE_HTML_RE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
body = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
|
||||
if body:
|
||||
text = body.group(1).strip()
|
||||
# 思考文字混在片段前(无标签包裹)时,从第一个 "<div" 起截取。
|
||||
div_at = text.find("<div")
|
||||
if div_at > 0:
|
||||
text = text[div_at:].strip()
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/{template_id}/custom-modules/generate")
|
||||
async def generate_html_template_custom_module(
|
||||
template_id: str, body: CustomModuleGenerateRequest, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""按用户的自然语言描述调用模型生成自定义模块 HTML 片段(已消毒,未入库)。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
# 模板必须存在且页面编号合法,防止生成挂在不存在的页面上。
|
||||
manifest = await asyncio.to_thread(template_store.read_template_manifest, template_id)
|
||||
except template_store.TemplateStoreError as exc:
|
||||
status = 404 if "不存在" in str(exc) else 400
|
||||
raise HTTPException(status_code=status, detail=str(exc))
|
||||
valid_pages = {
|
||||
str(p.get("编号") or "") for p in manifest.get("页面") or [] if isinstance(p, dict)
|
||||
}
|
||||
if body.page not in valid_pages:
|
||||
raise HTTPException(status_code=400, detail=f"未知页面编号:{body.page}")
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
prompt = _CUSTOM_MODULE_PROMPT.format(
|
||||
page=body.page, title=body.title or "(由你起一个简短标题)", description=body.description
|
||||
)
|
||||
model = create_chat_model(name=body.model_name or None, thinking_enabled=False)
|
||||
response = await model.ainvoke([HumanMessage(content=prompt)])
|
||||
content = getattr(response, "content", response)
|
||||
if isinstance(content, list):
|
||||
content = "\n".join(
|
||||
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
|
||||
)
|
||||
fragment = template_store.sanitize_html_fragment(_extract_fragment(str(content)))
|
||||
if "<div" not in fragment:
|
||||
raise HTTPException(status_code=502, detail="模型未返回有效的 HTML 片段,请调整描述后重试")
|
||||
return {"template_id": template_id, "page": body.page, "html": fragment}
|
||||
|
||||
|
||||
@router.post("")
|
||||
|
||||
@@ -681,6 +681,9 @@ def _embed_allowed_weknora_ids(ctx: WeKnoraEmbedContext) -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
|
||||
# Keep this payload bounded: it is signed directly into the browser cookie.
|
||||
# The visible knowledge-base list is hydrated only for the embedded page or
|
||||
# its list API call, after the cookie has been verified.
|
||||
return {
|
||||
"mapping_id": ctx.mapping_id,
|
||||
"weknora_id": ctx.weknora_id,
|
||||
@@ -688,11 +691,79 @@ def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
|
||||
"is_admin": ctx.is_admin,
|
||||
"can_write": ctx.can_write,
|
||||
"is_conversation_deposit": ctx.is_conversation_deposit,
|
||||
"allowed_weknora_ids": list(_embed_allowed_weknora_ids(ctx)),
|
||||
"exp": ctx.exp,
|
||||
}
|
||||
|
||||
|
||||
async def _with_visible_embed_knowledge_bases(
|
||||
request: Request,
|
||||
ctx: WeKnoraEmbedContext,
|
||||
) -> WeKnoraEmbedContext:
|
||||
"""Hydrate the iframe-only picker allowlist without putting it in a cookie."""
|
||||
|
||||
visible_rows = await _store(request).list_visible(
|
||||
ctx.user_id,
|
||||
scope="all",
|
||||
# Mirror the regular picker. An administrator must not receive another
|
||||
# user's private bases merely because the iframe uses an admin token.
|
||||
is_admin=False,
|
||||
)
|
||||
return replace(
|
||||
ctx,
|
||||
allowed_weknora_ids=tuple(
|
||||
dict.fromkeys(
|
||||
value
|
||||
for value in (
|
||||
ctx.weknora_id,
|
||||
*(str(item.get("weknora_id") or "") for item in visible_rows),
|
||||
)
|
||||
if value
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _embed_directory_time(value: Any) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
async def _local_embed_knowledge_base_directory(
|
||||
request: Request,
|
||||
ctx: WeKnoraEmbedContext,
|
||||
) -> Response:
|
||||
"""Serve the iframe picker from DeerFlow mappings, never WeKnora's full directory."""
|
||||
|
||||
rows = await _store(request).list_visible(
|
||||
ctx.user_id,
|
||||
scope="all",
|
||||
# Match the normal picker: the WeKnora service-admin token must not
|
||||
# make an administrator's private libraries discoverable to others.
|
||||
is_admin=False,
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(row.get("weknora_id") or ""),
|
||||
"name": str(row.get("name") or ""),
|
||||
"description": str(row.get("description") or ""),
|
||||
"type": str(row.get("kb_type") or "document"),
|
||||
"status": "active",
|
||||
"created_at": _embed_directory_time(row.get("created_at")),
|
||||
"updated_at": _embed_directory_time(row.get("updated_at")),
|
||||
}
|
||||
for row in rows
|
||||
if str(row.get("weknora_id") or "")
|
||||
]
|
||||
response = _json_response(
|
||||
{"success": True, "data": items, "total": len(items)},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _weknora_id_from_platform_path(path: str) -> str | None:
|
||||
prefix = "/platform/knowledge-bases/"
|
||||
if not path.startswith(prefix):
|
||||
@@ -708,15 +779,13 @@ async def _switch_embed_context(
|
||||
ctx: WeKnoraEmbedContext,
|
||||
weknora_id: str,
|
||||
) -> WeKnoraEmbedContext:
|
||||
"""Resolve a permitted native-UI switch to the exact cmzs mapping.
|
||||
"""Resolve a native-UI switch to the exact cmzs mapping.
|
||||
|
||||
The embedded WeKnora UI uses a service-admin token, so every target remains
|
||||
constrained to an ID signed into this short-lived session and is checked
|
||||
again against the cmzs visibility rules before its page can be proxied.
|
||||
The embedded WeKnora UI uses a service-admin token, so the target must be
|
||||
checked against the current cmzs visibility rules on every switch. This is
|
||||
deliberately dynamic rather than a signed ID list in the browser cookie.
|
||||
"""
|
||||
|
||||
if weknora_id not in _embed_allowed_weknora_ids(ctx):
|
||||
raise HTTPException(status_code=403, detail="Knowledge base is outside the embedded session scope")
|
||||
store = _store(request)
|
||||
mapped = await store.get_by_weknora_id(weknora_id)
|
||||
if mapped is None:
|
||||
@@ -2807,13 +2876,6 @@ async def _prepare_weknora_embed_session(
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
_runtime_for_iframe_proxy(request)
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||||
visible_rows = await _store(request).list_visible(
|
||||
user_id,
|
||||
scope="all",
|
||||
# The iframe must mirror the knowledge-base picker, not grant an
|
||||
# administrator an unlisted user's private knowledge bases.
|
||||
is_admin=False,
|
||||
)
|
||||
writable = None
|
||||
if not _is_conversation_deposit_mapping(row):
|
||||
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||
@@ -2825,7 +2887,6 @@ async def _prepare_weknora_embed_session(
|
||||
is_admin=is_admin,
|
||||
can_write=writable is not None,
|
||||
is_conversation_deposit=_is_conversation_deposit_mapping(row),
|
||||
allowed_weknora_ids=tuple(dict.fromkeys(value for value in (weknora_id, *(str(item.get("weknora_id") or "") for item in visible_rows)) if value)),
|
||||
exp=int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||||
)
|
||||
return weknora_id, _embed_context_payload(ctx)
|
||||
@@ -2885,6 +2946,7 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
|
||||
requested_weknora_id = _weknora_id_from_platform_path(requested)
|
||||
if requested_weknora_id is not None:
|
||||
selected_ctx = await _switch_embed_context(request, ctx, requested_weknora_id)
|
||||
selected_ctx = await _with_visible_embed_knowledge_bases(request, selected_ctx)
|
||||
response = await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=_runtime_for_iframe_proxy(request).web_base_url,
|
||||
@@ -2909,6 +2971,7 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
|
||||
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
|
||||
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
ctx = await _with_visible_embed_knowledge_bases(request, ctx)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=runtime.web_base_url,
|
||||
@@ -2958,6 +3021,8 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return Response(status_code=204)
|
||||
ctx = await _read_embed_context(request)
|
||||
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
|
||||
return await _local_embed_knowledge_base_directory(request, ctx)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
@@ -2982,6 +3047,8 @@ async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Respon
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return Response(status_code=204)
|
||||
ctx = await _read_embed_context(request)
|
||||
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
|
||||
return await _local_embed_knowledge_base_directory(request, ctx)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -35,6 +35,7 @@ from app.gateway.skill_catalog_cache import (
|
||||
load_or_warm_skill_catalog_snapshot,
|
||||
load_skill_catalog_projection,
|
||||
refresh_skill_catalog_projection,
|
||||
set_skill_catalog_enabled,
|
||||
)
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@@ -369,6 +370,24 @@ class SkillRollbackRequest(BaseModel):
|
||||
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
||||
|
||||
|
||||
class SkillVersionEntryResponse(BaseModel):
|
||||
version: int
|
||||
sha256: str
|
||||
created_at: str
|
||||
created_by: str | None = None
|
||||
action: str
|
||||
summary: str | None = None
|
||||
based_on_version: int | None = None
|
||||
active: bool = False
|
||||
|
||||
|
||||
class SkillVersionsResponse(BaseModel):
|
||||
enabled: bool
|
||||
skill_name: str
|
||||
active_version: int | None = None
|
||||
versions: list[SkillVersionEntryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _skill_to_response(
|
||||
skill: Skill,
|
||||
ownership: dict[str, Any] | None = None,
|
||||
@@ -1305,6 +1324,10 @@ async def install_skill_upload(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||
overwrite: bool = False,
|
||||
install_mode: Literal["iterate", "overwrite"] | None = Query(
|
||||
default=None,
|
||||
description="Same-name custom upload mode when versioning is enabled: iterate or overwrite.",
|
||||
),
|
||||
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
@@ -1339,9 +1362,31 @@ async def install_skill_upload(
|
||||
target_category=target_category,
|
||||
conflict_kind=validation.get("conflict_kind"),
|
||||
)
|
||||
if (overwrite and not can_install_as_is and not can_overwrite) or (not overwrite and not can_install_as_is):
|
||||
requested_overwrite = overwrite or install_mode in {"iterate", "overwrite"}
|
||||
if (requested_overwrite and not can_install_as_is and not can_overwrite) or (
|
||||
not requested_overwrite and not can_install_as_is
|
||||
):
|
||||
raise HTTPException(status_code=409, detail=conflict_message)
|
||||
if install_mode == "iterate":
|
||||
from deerflow.skills.versioning import record_custom_skill_version, skill_versioning_enabled
|
||||
|
||||
if not skill_versioning_enabled():
|
||||
raise HTTPException(status_code=409, detail="技能迭代功能未开启")
|
||||
if target_category != SkillCategory.CUSTOM or not _owned_by_me or not can_overwrite:
|
||||
raise HTTPException(status_code=409, detail="只有本人已有的同名自定义技能可以迭代")
|
||||
overwrite = True
|
||||
await asyncio.to_thread(
|
||||
record_custom_skill_version,
|
||||
preview_name,
|
||||
storage=storage,
|
||||
action="baseline",
|
||||
author=user_id,
|
||||
summary="同名上传迭代前的活动版本",
|
||||
)
|
||||
elif install_mode == "overwrite":
|
||||
overwrite = True
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
|
||||
storage.invalidate_skills_cache()
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
mark_uploaded(result["skill_name"])
|
||||
@@ -1360,8 +1405,49 @@ async def install_skill_upload(
|
||||
await skill_store.ensure_legacy(
|
||||
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
|
||||
)
|
||||
try:
|
||||
from deerflow.skills.versioning import (
|
||||
record_custom_skill_version,
|
||||
reset_custom_skill_versions,
|
||||
skill_versioning_enabled,
|
||||
)
|
||||
|
||||
if skill_versioning_enabled():
|
||||
if result.get("overwritten") and install_mode == "iterate":
|
||||
await asyncio.to_thread(
|
||||
record_custom_skill_version,
|
||||
result["skill_name"],
|
||||
storage=storage,
|
||||
action="upload_iteration",
|
||||
author=user_id,
|
||||
summary="上传同名技能并迭代为新版本",
|
||||
)
|
||||
elif result.get("overwritten"):
|
||||
await asyncio.to_thread(
|
||||
reset_custom_skill_versions,
|
||||
result["skill_name"],
|
||||
storage=storage,
|
||||
author=user_id,
|
||||
summary="上传同名技能并直接覆盖",
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
record_custom_skill_version,
|
||||
result["skill_name"],
|
||||
storage=storage,
|
||||
action="upload",
|
||||
author=user_id,
|
||||
summary="上传技能初始版本",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to persist custom skill version after upload: %s", result["skill_name"])
|
||||
# The package is already installed. Do not report the whole
|
||||
# upload as failed (which would invite an unsafe duplicate
|
||||
# retry); opening the version list lazily snapshots the current
|
||||
# package and repairs the manifest.
|
||||
await _refresh_catalog_snapshot(config)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(result["skill_name"])
|
||||
return SkillInstallResponse(**result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -1603,6 +1689,7 @@ async def install_skill(
|
||||
target_category=target_category,
|
||||
require_skill_suffix=False,
|
||||
)
|
||||
storage.invalidate_skills_cache()
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
mark_uploaded(result.skill_name)
|
||||
@@ -1616,8 +1703,16 @@ async def install_skill(
|
||||
await skill_store.ensure_legacy(
|
||||
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
|
||||
)
|
||||
await _record_custom_version(
|
||||
result.skill_name,
|
||||
storage=storage,
|
||||
action="artifact_install",
|
||||
author=user_id,
|
||||
summary="从会话产物安装技能初始版本",
|
||||
)
|
||||
await _refresh_catalog_snapshot(config)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(result.skill_name)
|
||||
return result
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -1730,6 +1825,14 @@ async def duplicate_skill(
|
||||
mark_uploaded(new_name)
|
||||
except Exception:
|
||||
pass
|
||||
storage.invalidate_skills_cache()
|
||||
await _record_custom_version(
|
||||
new_name,
|
||||
storage=storage,
|
||||
action="duplicate",
|
||||
author=user_id,
|
||||
summary=f"从技能 {source_name} 复制创建",
|
||||
)
|
||||
await _refresh_catalog_snapshot(config)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(
|
||||
@@ -1784,6 +1887,13 @@ async def reconcile_custom_skills(
|
||||
mark_uploaded(skill.name)
|
||||
except Exception:
|
||||
pass
|
||||
await _record_custom_version(
|
||||
skill.name,
|
||||
storage=storage,
|
||||
action="reconcile",
|
||||
author=user_id,
|
||||
summary="接管已有自定义技能并建立初始版本",
|
||||
)
|
||||
adopted.append(skill.name)
|
||||
|
||||
if adopted:
|
||||
@@ -1869,8 +1979,16 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
||||
if scan.decision == "block":
|
||||
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
||||
await _record_custom_version(
|
||||
skill_name,
|
||||
storage=storage,
|
||||
action="baseline",
|
||||
author=None,
|
||||
summary="编辑前的活动版本",
|
||||
)
|
||||
prev_content = storage.read_custom_skill(skill_name)
|
||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
|
||||
storage.invalidate_skills_cache()
|
||||
await _refresh_catalog_snapshot(config)
|
||||
storage.append_history(
|
||||
skill_name,
|
||||
@@ -1889,7 +2007,15 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
bump_patch(skill_name)
|
||||
except Exception:
|
||||
pass
|
||||
await _record_custom_version(
|
||||
skill_name,
|
||||
storage=storage,
|
||||
action="human_edit",
|
||||
author=None,
|
||||
summary="编辑 SKILL.md",
|
||||
)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(skill_name)
|
||||
return await get_custom_skill(skill_name, config)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1941,6 +2067,12 @@ async def delete_custom_skill(
|
||||
await skill_store.delete_admin(skill_name)
|
||||
else:
|
||||
await skill_store.delete(skill_name, user_id)
|
||||
try:
|
||||
from deerflow.skills.versioning import delete_custom_skill_versions
|
||||
|
||||
await asyncio.to_thread(delete_custom_skill_versions, skill_name)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete version history for %s", skill_name, exc_info=True)
|
||||
await _refresh_catalog_snapshot(config)
|
||||
|
||||
try:
|
||||
@@ -2279,6 +2411,108 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/custom/{skill_name}/versions",
|
||||
response_model=SkillVersionsResponse,
|
||||
summary="List Custom Skill Versions",
|
||||
)
|
||||
async def get_custom_skill_versions(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillVersionsResponse:
|
||||
"""List complete-package snapshots for one owned/admin-managed custom skill."""
|
||||
|
||||
try:
|
||||
skill_name = SkillStorage.validate_skill_name(skill_name.replace("\r\n", "").replace("\n", ""))
|
||||
await _get_editable_skill_or_raise(
|
||||
skill_store,
|
||||
skill_name,
|
||||
_current_user_id(request),
|
||||
is_admin=_is_admin_user(request),
|
||||
)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
if not storage.custom_skill_exists(skill_name):
|
||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||
from deerflow.skills.versioning import list_custom_skill_versions
|
||||
|
||||
payload = await asyncio.to_thread(
|
||||
list_custom_skill_versions,
|
||||
skill_name,
|
||||
storage=storage,
|
||||
author=_current_user_id(request),
|
||||
)
|
||||
return SkillVersionsResponse.model_validate(payload)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list versions for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list skill versions: {e}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/custom/{skill_name}/versions/{version}/activate",
|
||||
response_model=SkillVersionsResponse,
|
||||
summary="Activate Custom Skill Version",
|
||||
)
|
||||
async def activate_custom_skill_version_endpoint(
|
||||
skill_name: str,
|
||||
version: int,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillVersionsResponse:
|
||||
"""Switch the sole model-visible package without touching usage counters."""
|
||||
|
||||
try:
|
||||
skill_name = SkillStorage.validate_skill_name(skill_name.replace("\r\n", "").replace("\n", ""))
|
||||
await _get_editable_skill_or_raise(
|
||||
skill_store,
|
||||
skill_name,
|
||||
_current_user_id(request),
|
||||
is_admin=_is_admin_user(request),
|
||||
)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
if not storage.custom_skill_exists(skill_name):
|
||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||
from deerflow.skills.versioning import (
|
||||
activate_custom_skill_version,
|
||||
list_custom_skill_versions,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
activate_custom_skill_version,
|
||||
skill_name,
|
||||
version,
|
||||
storage=storage,
|
||||
)
|
||||
storage.invalidate_skills_cache()
|
||||
await _refresh_catalog_snapshot(config)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(skill_name)
|
||||
payload = await asyncio.to_thread(
|
||||
list_custom_skill_versions,
|
||||
skill_name,
|
||||
storage=storage,
|
||||
author=_current_user_id(request),
|
||||
)
|
||||
return SkillVersionsResponse.model_validate(payload)
|
||||
except HTTPException:
|
||||
raise
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to activate %s version %s: %s", skill_name, version, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to activate skill version: {e}")
|
||||
|
||||
|
||||
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
||||
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
try:
|
||||
@@ -2309,10 +2543,26 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
||||
if scan.decision == "block":
|
||||
storage.append_history(skill_name, history_entry)
|
||||
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
||||
await _record_custom_version(
|
||||
skill_name,
|
||||
storage=storage,
|
||||
action="baseline",
|
||||
author=None,
|
||||
summary="旧历史回滚前的活动版本",
|
||||
)
|
||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
||||
storage.invalidate_skills_cache()
|
||||
await _refresh_catalog_snapshot(config)
|
||||
storage.append_history(skill_name, history_entry)
|
||||
await _record_custom_version(
|
||||
skill_name,
|
||||
storage=storage,
|
||||
action="legacy_rollback",
|
||||
author=None,
|
||||
summary="从旧编辑历史恢复 SKILL.md",
|
||||
)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(skill_name)
|
||||
return await get_custom_skill(skill_name, config)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -2775,6 +3025,28 @@ async def _sync_skill_view(name: str) -> None:
|
||||
logger.warning("Failed to sync skill %s into the sandbox view", name, exc_info=True)
|
||||
|
||||
|
||||
async def _record_custom_version(
|
||||
name: str,
|
||||
*,
|
||||
storage: SkillStorage,
|
||||
action: str,
|
||||
author: str | None,
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""Capture one complete custom-skill package when versioning is enabled."""
|
||||
|
||||
from deerflow.skills.versioning import record_custom_skill_version
|
||||
|
||||
await asyncio.to_thread(
|
||||
record_custom_skill_version,
|
||||
name,
|
||||
storage=storage,
|
||||
action=action,
|
||||
author=author,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}/files/content",
|
||||
response_model=SkillFileSaveResponse,
|
||||
@@ -2826,7 +3098,18 @@ async def save_skill_file(
|
||||
# SKILL.md edits must keep their frontmatter valid (name must match).
|
||||
storage.validate_skill_markdown_content(skill.name, body.content)
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
await _record_custom_version(
|
||||
skill.name,
|
||||
storage=storage,
|
||||
action="baseline",
|
||||
author=_current_user_id(request),
|
||||
summary="文件编辑前的活动版本",
|
||||
)
|
||||
|
||||
_write_skill_text_file_atomically(target, body.content)
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
storage.invalidate_skills_cache()
|
||||
|
||||
try:
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
@@ -2852,6 +3135,15 @@ async def save_skill_file(
|
||||
pass
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
await _record_custom_version(
|
||||
skill.name,
|
||||
storage=storage,
|
||||
action="human_edit_file",
|
||||
author=_current_user_id(request),
|
||||
summary=f"编辑文件 {relative_posix}",
|
||||
)
|
||||
|
||||
await _sync_skill_view(skill.name)
|
||||
|
||||
stat = target.stat()
|
||||
@@ -2911,10 +3203,19 @@ async def delete_skill_file(
|
||||
prev_content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
await _record_custom_version(
|
||||
skill.name,
|
||||
storage=get_or_new_skill_storage(app_config=config),
|
||||
action="baseline",
|
||||
author=_current_user_id(request),
|
||||
summary="文件删除前的活动版本",
|
||||
)
|
||||
target.unlink()
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
storage.invalidate_skills_cache()
|
||||
try:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
@@ -2928,6 +3229,13 @@ async def delete_skill_file(
|
||||
)
|
||||
except Exception as history_err: # noqa: BLE001
|
||||
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
|
||||
await _record_custom_version(
|
||||
skill.name,
|
||||
storage=storage,
|
||||
action="human_delete_file",
|
||||
author=_current_user_id(request),
|
||||
summary=f"删除文件 {relative_posix}",
|
||||
)
|
||||
else:
|
||||
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
|
||||
await _sync_skill_view(skill.name)
|
||||
@@ -3010,6 +3318,11 @@ async def update_skill(
|
||||
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
await _sync_skill_view(skill_name)
|
||||
# The list endpoint reads skills.enabled from the catalog projection.
|
||||
# Write that one row before the client refetches. A missing row still
|
||||
# needs the full snapshot so a newly discovered skill can appear.
|
||||
if not await set_skill_catalog_enabled(skill_name, request.enabled):
|
||||
await _refresh_catalog_snapshot(config)
|
||||
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||
|
||||
@@ -16,15 +16,20 @@ from app.gateway.deps import get_current_user_from_request, get_optional_user_fr
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.system_settings import (
|
||||
BUILTIN_THEME_IDS,
|
||||
AIWritingPageDescription,
|
||||
AppearanceRouteParam,
|
||||
AuthenticationSettings,
|
||||
CallMetricsSettings,
|
||||
CompactShortcutButton,
|
||||
ConversationContextSettings,
|
||||
CustomThemePreset,
|
||||
GeneralQaPageDescriptions,
|
||||
HtmlMapSettings,
|
||||
LeaderboardSettings,
|
||||
ModelConcurrencySettings,
|
||||
ModelPromptSuffix,
|
||||
PageDescriptionsSettings,
|
||||
PageHeaderDescription,
|
||||
PluginDomainAgentMapping,
|
||||
PluginManagementSettings,
|
||||
PromptPrefixSettings,
|
||||
@@ -33,6 +38,7 @@ from deerflow.config.system_settings import (
|
||||
RetryKeepPartialSettings,
|
||||
RoundtableRoomLimitSettings,
|
||||
SkillCompressionSettings,
|
||||
SkillVersioningSettings,
|
||||
StrategyProjectSettings,
|
||||
WordHeadingNumberingStyle,
|
||||
WriteFileCompatibilitySettings,
|
||||
@@ -208,6 +214,91 @@ class StrategyProjectSettingsUpdateRequest(BaseModel):
|
||||
taiwan_strait_demo_enabled: bool | None = None
|
||||
|
||||
|
||||
class PageDescriptionsResponse(BaseModel):
|
||||
general_qa: GeneralQaPageDescriptions = Field(default_factory=GeneralQaPageDescriptions)
|
||||
knowledge_base: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
ai_writing: AIWritingPageDescription = Field(default_factory=AIWritingPageDescription)
|
||||
strategy_projects: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
deep_research: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
gongwen: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
light_app_management: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
light_app_list: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
agents: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
business_chains: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
skills: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
knowledge_management: PageHeaderDescription = Field(default_factory=PageHeaderDescription)
|
||||
|
||||
|
||||
class PageDescriptionsUpdateRequest(BaseModel):
|
||||
general_qa: GeneralQaPageDescriptions | None = None
|
||||
knowledge_base: PageHeaderDescription | None = None
|
||||
ai_writing: AIWritingPageDescription | None = None
|
||||
strategy_projects: PageHeaderDescription | None = None
|
||||
deep_research: PageHeaderDescription | None = None
|
||||
gongwen: PageHeaderDescription | None = None
|
||||
light_app_management: PageHeaderDescription | None = None
|
||||
light_app_list: PageHeaderDescription | None = None
|
||||
agents: PageHeaderDescription | None = None
|
||||
business_chains: PageHeaderDescription | None = None
|
||||
skills: PageHeaderDescription | None = None
|
||||
knowledge_management: PageHeaderDescription | None = None
|
||||
|
||||
|
||||
def _page_descriptions_response(
|
||||
settings: PageDescriptionsSettings,
|
||||
) -> PageDescriptionsResponse:
|
||||
return PageDescriptionsResponse(
|
||||
general_qa=settings.general_qa,
|
||||
knowledge_base=settings.knowledge_base,
|
||||
ai_writing=settings.ai_writing,
|
||||
strategy_projects=settings.strategy_projects,
|
||||
deep_research=settings.deep_research,
|
||||
gongwen=settings.gongwen,
|
||||
light_app_management=settings.light_app_management,
|
||||
light_app_list=settings.light_app_list,
|
||||
agents=settings.agents,
|
||||
business_chains=settings.business_chains,
|
||||
skills=settings.skills,
|
||||
knowledge_management=settings.knowledge_management,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/page-descriptions", response_model=PageDescriptionsResponse)
|
||||
async def get_page_descriptions() -> PageDescriptionsResponse:
|
||||
return _page_descriptions_response(load_system_settings().page_descriptions)
|
||||
|
||||
|
||||
@router.put("/page-descriptions", response_model=PageDescriptionsResponse)
|
||||
async def update_page_descriptions(
|
||||
body: PageDescriptionsUpdateRequest,
|
||||
request: Request,
|
||||
) -> PageDescriptionsResponse:
|
||||
await _require_admin(request)
|
||||
current = load_system_settings()
|
||||
updated = PageDescriptionsSettings(
|
||||
general_qa=body.general_qa or current.page_descriptions.general_qa,
|
||||
knowledge_base=body.knowledge_base or current.page_descriptions.knowledge_base,
|
||||
ai_writing=body.ai_writing or current.page_descriptions.ai_writing,
|
||||
strategy_projects=body.strategy_projects or current.page_descriptions.strategy_projects,
|
||||
deep_research=body.deep_research or current.page_descriptions.deep_research,
|
||||
gongwen=body.gongwen or current.page_descriptions.gongwen,
|
||||
light_app_management=(
|
||||
body.light_app_management or current.page_descriptions.light_app_management
|
||||
),
|
||||
light_app_list=body.light_app_list or current.page_descriptions.light_app_list,
|
||||
agents=body.agents or current.page_descriptions.agents,
|
||||
business_chains=body.business_chains or current.page_descriptions.business_chains,
|
||||
skills=body.skills or current.page_descriptions.skills,
|
||||
knowledge_management=(
|
||||
body.knowledge_management or current.page_descriptions.knowledge_management
|
||||
),
|
||||
)
|
||||
saved = save_system_settings(
|
||||
current.model_copy(update={"page_descriptions": updated}),
|
||||
)
|
||||
return _page_descriptions_response(saved.page_descriptions)
|
||||
|
||||
|
||||
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
||||
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
||||
@@ -491,6 +582,14 @@ class SkillCompressionUpdateRequest(BaseModel):
|
||||
keep_index_in_prompt: bool | None = None
|
||||
|
||||
|
||||
class SkillVersioningResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether custom-skill version history is active")
|
||||
|
||||
|
||||
class SkillVersioningUpdateRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ModelConcurrencyResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether per-model concurrency limiting is active")
|
||||
default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override")
|
||||
@@ -532,6 +631,17 @@ class WriteFileCompatibilityUpdateRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class CallMetricsResponse(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether new tool and skill calls are recorded for management statistics",
|
||||
)
|
||||
|
||||
|
||||
class CallMetricsUpdateRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class RetryKeepPartialResponse(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
@@ -579,6 +689,7 @@ class RoundtableRoomLimitResponse(BaseModel):
|
||||
enabled: bool = True
|
||||
max_rooms: int = 3
|
||||
max_hold_minutes: int = 30
|
||||
lease_minutes: int = 5
|
||||
max_cycles: int = 8
|
||||
write_file_compatibility_enabled: bool = False
|
||||
|
||||
@@ -587,6 +698,7 @@ class RoundtableRoomLimitUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
max_rooms: int | None = Field(default=None, ge=1, le=20)
|
||||
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
|
||||
lease_minutes: int | None = Field(default=None, ge=1, le=60)
|
||||
max_cycles: int | None = Field(default=None, ge=2, le=100)
|
||||
write_file_compatibility_enabled: bool | None = None
|
||||
|
||||
@@ -667,6 +779,32 @@ async def update_write_file_compatibility_setting(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/call-metrics", response_model=CallMetricsResponse)
|
||||
async def get_call_metrics_setting(request: Request) -> CallMetricsResponse:
|
||||
"""Return the administrator-controlled tool/skill metrics switch."""
|
||||
|
||||
await _require_admin(request)
|
||||
return CallMetricsResponse(enabled=load_system_settings().call_metrics.enabled)
|
||||
|
||||
|
||||
@router.put("/call-metrics", response_model=CallMetricsResponse)
|
||||
async def update_call_metrics_setting(
|
||||
body: CallMetricsUpdateRequest,
|
||||
request: Request,
|
||||
) -> CallMetricsResponse:
|
||||
"""Enable or pause new tool/skill metric writes without a restart."""
|
||||
|
||||
await _require_admin(request)
|
||||
current = load_system_settings()
|
||||
saved = save_system_settings(
|
||||
current.model_copy(
|
||||
update={"call_metrics": CallMetricsSettings(enabled=body.enabled)}
|
||||
)
|
||||
)
|
||||
logger.info("Tool and skill call metrics switch changed: enabled=%s", saved.call_metrics.enabled)
|
||||
return CallMetricsResponse(enabled=saved.call_metrics.enabled)
|
||||
|
||||
|
||||
@router.get("/retry-keep-partial", response_model=RetryKeepPartialResponse)
|
||||
async def get_retry_keep_partial_setting() -> RetryKeepPartialResponse:
|
||||
"""Return the retry-keeps-partial display switch (readable by chat clients)."""
|
||||
@@ -734,6 +872,7 @@ def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> Ro
|
||||
enabled=settings.enabled,
|
||||
max_rooms=settings.max_rooms,
|
||||
max_hold_minutes=settings.max_hold_minutes,
|
||||
lease_minutes=settings.lease_minutes,
|
||||
max_cycles=settings.max_cycles,
|
||||
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
|
||||
)
|
||||
@@ -760,6 +899,8 @@ async def update_roundtable_room_limit_setting(
|
||||
updates["max_rooms"] = body.max_rooms
|
||||
if body.max_hold_minutes is not None:
|
||||
updates["max_hold_minutes"] = body.max_hold_minutes
|
||||
if body.lease_minutes is not None:
|
||||
updates["lease_minutes"] = body.lease_minutes
|
||||
if body.max_cycles is not None:
|
||||
updates["max_cycles"] = body.max_cycles
|
||||
if body.write_file_compatibility_enabled is not None:
|
||||
@@ -767,10 +908,11 @@ async def update_roundtable_room_limit_setting(
|
||||
updated = current.roundtable_room_limit.model_copy(update=updates)
|
||||
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
|
||||
logger.info(
|
||||
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
|
||||
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s lease_minutes=%s max_cycles=%s write_file_compatibility=%s",
|
||||
saved.roundtable_room_limit.enabled,
|
||||
saved.roundtable_room_limit.max_rooms,
|
||||
saved.roundtable_room_limit.max_hold_minutes,
|
||||
saved.roundtable_room_limit.lease_minutes,
|
||||
saved.roundtable_room_limit.max_cycles,
|
||||
saved.roundtable_room_limit.write_file_compatibility_enabled,
|
||||
)
|
||||
@@ -917,6 +1059,42 @@ async def update_skill_compression(body: SkillCompressionUpdateRequest, request:
|
||||
return _skill_compression_to_response(saved.skill_compression)
|
||||
|
||||
|
||||
@router.get("/skill-versioning", response_model=SkillVersioningResponse)
|
||||
async def get_skill_versioning() -> SkillVersioningResponse:
|
||||
"""Return the custom-skill versioning gate for skill detail clients."""
|
||||
|
||||
return SkillVersioningResponse(enabled=load_system_settings().skill_versioning.enabled)
|
||||
|
||||
|
||||
@router.put("/skill-versioning", response_model=SkillVersioningResponse)
|
||||
async def update_skill_versioning(
|
||||
body: SkillVersioningUpdateRequest,
|
||||
request: Request,
|
||||
) -> SkillVersioningResponse:
|
||||
"""Enable/disable custom-skill version capture and switching. Admin only."""
|
||||
|
||||
await _require_admin(request)
|
||||
current = load_system_settings()
|
||||
previous = current.skill_versioning.enabled
|
||||
updated = SkillVersioningSettings(enabled=body.enabled)
|
||||
saved = save_system_settings(current.model_copy(update={"skill_versioning": updated}))
|
||||
|
||||
if body.enabled and not previous:
|
||||
try:
|
||||
from deerflow.skills.versioning import reconcile_custom_skill_versions
|
||||
|
||||
actor = await get_optional_user_from_request(request)
|
||||
actor_id = str(getattr(actor, "id", "") or "") or None
|
||||
await asyncio.to_thread(reconcile_custom_skill_versions, author=actor_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to capture custom-skill baselines after enabling versioning")
|
||||
# The switch remains enabled. Individual skills are bootstrapped
|
||||
# lazily when their version list is first opened or next changed.
|
||||
|
||||
logger.info("Custom skill versioning switch changed: enabled=%s", saved.skill_versioning.enabled)
|
||||
return SkillVersioningResponse(enabled=saved.skill_versioning.enabled)
|
||||
|
||||
|
||||
@router.get("/model-concurrency", response_model=ModelConcurrencyResponse)
|
||||
async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse:
|
||||
"""Return the per-model concurrency-limit configuration. Admin only."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_checkpointer, get_thread_share_store, get_thread_store
|
||||
from app.gateway.routers.artifacts import build_html_offline_bundle_response
|
||||
from app.gateway.thread_copy import copy_thread_state
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
@@ -316,6 +317,7 @@ def _public_share_disposition(mime_type: str) -> str:
|
||||
async def get_public_share_file(
|
||||
share_code: str,
|
||||
path: str,
|
||||
offline_bundle: bool = False,
|
||||
share_store: ThreadShareStore = Depends(get_thread_share_store),
|
||||
) -> FileResponse:
|
||||
"""Serve a file referenced by a view-shared conversation.
|
||||
@@ -342,6 +344,11 @@ async def get_public_share_file(
|
||||
if not actual_path.exists() or not actual_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if offline_bundle:
|
||||
if actual_path.suffix.lower() not in {".htm", ".html", ".xhtml"}:
|
||||
raise HTTPException(status_code=400, detail="Offline bundles are available for HTML files only")
|
||||
return await build_html_offline_bundle_response(actual_path)
|
||||
|
||||
mime_type = mimetypes.guess_type(actual_path.name)[0] or "application/octet-stream"
|
||||
return FileResponse(
|
||||
actual_path,
|
||||
|
||||
@@ -252,6 +252,29 @@ async def refresh_skill_catalog_projection(
|
||||
return len(catalog)
|
||||
|
||||
|
||||
async def set_skill_catalog_enabled(name: str, enabled: bool) -> bool:
|
||||
"""Persist one skill's online flag in the catalog row the list API reads.
|
||||
|
||||
The extensions file is the runtime source of truth. The skills page reads
|
||||
``skills.enabled`` instead, so a toggle that only rewrites the file looks
|
||||
unchanged after the list refreshes. Updating this single row avoids a full
|
||||
catalog rebuild, which rewrites every skill and can roll the flag back if
|
||||
an unrelated row fails.
|
||||
"""
|
||||
|
||||
session_factory = get_session_factory()
|
||||
if session_factory is None or not name:
|
||||
return False
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
update(SkillRow)
|
||||
.where(SkillRow.name == name)
|
||||
.values(enabled=bool(enabled), updated_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
return int(result.rowcount or 0) > 0
|
||||
|
||||
|
||||
def _build_catalog_payload(skills: list[Any], usage_entries: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
payload: list[dict[str, Any]] = []
|
||||
defaults = {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
config_version: 8
|
||||
log_level: info
|
||||
|
||||
# 高并发运行时优化。技能视图两项不写也默认开启,要关掉才写成 false。修改后需重启后台。
|
||||
# 高并发运行时优化。工具/技能统计与技能视图默认开启;若确有写库压力才改成 false。修改后需重启后台。
|
||||
runtime_performance:
|
||||
tool_metrics_enabled: false # 暂停工具/技能统计写库;不影响问答与工具执行。恢复统计时设 true 并重启
|
||||
tool_metrics_enabled: true # 记录普通工具调用,供综合管理的工具排行/调用统计使用
|
||||
skill_metrics_enabled: true # 记录可归因到具体技能的调用,供综合管理的技能排行/调用统计使用
|
||||
skill_view_reuse_enabled: true # 默认开启;false 才关闭。单次工具调用复用一次技能视图
|
||||
skill_view_cache_enabled: true # 默认开启;false 才关闭。校验版本后复用未变化的技能视图
|
||||
model_timing_nonblocking_enabled: true # 模型计时日志独立队列,不争抢工具线程池
|
||||
@@ -661,7 +662,10 @@ ai_writing:
|
||||
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
llmwiki:
|
||||
weknora:
|
||||
api_base_url: "http://119.254.86.251:8088/deerflow-api"
|
||||
# The restored WeKnora deployment exposes its API at the site root.
|
||||
# The historical /deerflow-api prefix is a frontend-only route there and
|
||||
# turns API mutations (POST/OPTIONS) into nginx 405 responses.
|
||||
api_base_url: "http://119.254.86.251:8088"
|
||||
web_base_url: "http://119.254.86.251:8088"
|
||||
admin_email: "lqq@qq.com"
|
||||
admin_password: "1qaz@WSX"
|
||||
|
||||
@@ -217,9 +217,9 @@ or_(
|
||||
---
|
||||
---
|
||||
|
||||
## 十、已知限制
|
||||
## 十、实现状态与已知限制
|
||||
|
||||
1. **`use_count` 永不增长**:`bump_use()` 已定义但全后端无调用点。"使用一个技能"的语义模糊(技能是 prompt 知识,不是工具),尚未接线。`view_count`(`skill_view` 触发)与 `patch_count`(回滚触发)是正常工作的。
|
||||
1. **`use_count` 按问答轮次统计**:成功 `skill_view`、读取 `/mnt/skills/{public,custom}/<name>/...` 文件或通过 `bash` 执行该目录脚本,都视为真实调用;同一 run 内同一技能只加一次,不依赖模型自述。计数同步写入 `.usage.json` 与技能列表数据库投影。
|
||||
2. **`.usage.json` 全局单文件**:不按用户分文件;因技能名全局唯一,一个技能只有一份 state/计数,这是设计取舍。
|
||||
3. **enabled 开关**:`extensions_config.json` 的技能 `enabled` 是全局的(非 per-user),前端开关已移除,归档功能覆盖了"对 Agent 隐藏技能"的需求。
|
||||
4. **历史物理归档**:早期 curator 曾把技能目录物理移到 `custom/.archive/`,这些遗留目录不会出现在新的归档抽屉里(列表 API 扫不到);新流程已统一为软归档。
|
||||
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user