diff --git a/.gitignore b/.gitignore index cbc8979e..c1031d99 100644 --- a/.gitignore +++ b/.gitignore @@ -88,9 +88,14 @@ offline-backend-20260512/backend/skill_evolution.json # Automatic config.yaml backups written by the admin models page on every save offline-backend-20260512/backend/.model-config-backups/ skills/ +# Harness skill-versioning logic is product source, not a runtime skill package. +!offline-backend-20260512/backend/packages/harness/deerflow/skills/ +offline-backend-20260512/backend/packages/harness/deerflow/skills/* +!offline-backend-20260512/backend/packages/harness/deerflow/skills/versioning.py hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_skill_versioning.py !offline-backend-20260512/backend/tests/test_large_file_safety.py !offline-backend-20260512/backend/tests/test_account_authentication.py !offline-backend-20260512/backend/tests/test_agent_skill_migration.py diff --git a/AGENTS.md b/AGENTS.md index 05451f5c..66c86fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,9 +96,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items. - **生成网页标识 (Generated Page Branding, admin-only write)**: 管理员在设置弹窗「生成网页标识」中填写网页展示名称、上传 PNG/JPG/WebP Logo(≤10MB)、单独移除 Logo 或整体清除配置。配置保存在 runtime `system_settings.json -> generated_page_branding`,图片保存在 runtime `generated-page-branding/`,`GET /api/system-settings/generated-page-branding` 全员可读,`PUT/DELETE` 仅管理员;生成 HTML 通过公开只读 `/api/public/html-runtime/generated-page-logo` 获取图片。**只有网页展示名称非空且 Logo 文件真实存在时功能才启用**;缺少任意一项(含单独移除 Logo)都不注入任何内容,完全保持原有网页生成逻辑。该配置只约束后续生成的 HTML 网页内容,不修改 DeerFlow 自身的系统名称、主导航、欢迎区、工作区标题或 Logo。启用时,HTML 写入与 `page_report_template validate` 会由 `html_runtime/generated_page_chrome.py` 确定性注入平台套壳:页头**通栏全宽、贴顶、无圆角**,**背景直接用全量模式导航栏设计图**(`html_runtime/assets/topbar-light.svg` 浅色红调 / `topbar-dark.svg` 深色深蓝,已注册进网关 `html_runtime.py` 的 `_STATIC_ASSETS` 白名单以 `/api/public/html-runtime/topbar-*.svg` 提供;页头运行时按 body 明暗 tone 自动切图),**只注入页头、不注入页脚**;设置弹窗不再提供「品牌位置」选择,新配置默认使用 `center`:「Logo+系统名称」居中锁孔(26px 白色金属渐变字 + 深浅辉光,落在设计图中央发光基座上)+ 页面导航**分列两侧**白字页签(当前项白色加粗 + 白色墨条)。`generated_page_branding.header_layout` 字段继续兼容存量配置,既有 `left` 仍按品牌居左 + 导航居中单行渲染。页签均为通栏等高下划线式(当前项主色 + 底部渐变墨条),多页强制首个逻辑页初始可见。开关 `chrome_enabled` **默认开启**;明确关闭时完全回归无注入:不注入页头、也不对页面提任何品牌要求(校验不检查品牌,模型不会被迫自写)。页脚与「网页页脚文字」设置项均已按需求移除(`FOOTER_MARKER` 常量仅为旧页面兼容保留)。模板套件的兜底导航条同款(品牌左 + 导航中、通栏贴顶),平台注入时经 `body:has([data-df-generated-header])` + 兜底脚本双保险自动隐藏。后处理会隐藏旧的多页导航、移除模型写入的 `data-df-brand` 重复标识且保持幂等;lead prompt 只让模型生成正文和逻辑页。注入点共四处且全部**账号无关**(admin 配置对全部账号生效,非 admin 仅无配置入口):**`page_report_template prepare`(工作副本创建时即预注入页头,模型从第一秒起编辑的就是带品牌导航的页面,brief 的 `platform_branding.pre_injected` 标记此状态)**、`write_file`/`str_replace`(`sandbox/tools.py` 的 `maybe_fix_generated_html`)、`page_report_template validate`、以及 `present_files` 交付兜底(`present_file_tool._ensure_generated_html_chrome`,覆盖模型经 bash 等旁路写出的 HTML;幂等,>4MB 跳过);注入时还会**归一化 body/html 顶部 padding/margin(清零)**(防弱模型为自想页头留白)并**隐藏模型自写的顶部 nav/header**(仅正文首个内容容器之前的,正文内导航不受影响);品牌配置每次实时读取 `system_settings.json`,无缓存无账号隔离。 - **生成网页默认明暗模式 (Generated Page Theme)**: `ChatPage` 与 `AgentChatPage` 每次发送只把当前界面的明暗模式作为 `presentation_theme=light|dark` 上下文传入。用户未明确指定网页主题时,自由生成 HTML、显式模板的 `auto` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。 -- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。配置在 `system_settings.json -> app_identity`,图片在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片地址 `GET /api/public/app-identity/logo`。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。 +- **生成网页离线下载包 (Generated HTML Offline Bundle)**: 产物面板下载 `.htm/.html/.xhtml` 时不再只保存单个 HTML,而是请求 `GET /api/threads/{thread_id}/artifacts/{path}?download=true&offline_bundle=true`(公开只读分享的文件端点同样支持),下载 `{原名}-离线包.zip`。纯引擎 `deerflow/html_runtime/offline_bundle.py` 把入口统一为 `index.html`,只将平台信任的 `/api/public/html-runtime/*` JS/CSS/GeoJSON/品牌资源、认知态势图运行目录和签名 `/api/public/html-images/*` 图片复制到 `assets/` 并改写为相对地址;不得联网抓取任意第三方 URL。包内还带 `manifest.json`、中文 `README.txt` 和实际使用库的许可证。Leaflet 在线瓦片及用户主动引用的公网图片/API 不复制,保留原地址并列入 `external_urls`。非 HTML 下载、HTML 预览/新窗口打开及流式未落盘的临时下载行为不变。 +- **系统名称与 Logo(与生成网页标识分开)**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。顶部导航名称字号可选 `adaptive`(默认,30px 上限、24px 下限)或 `fixed`(16–40px);配置字段为 `app_identity.header_title_font_mode/header_title_font_size`,固定模式不再按分辨率缩放,超长名称仍单行完整显示。红色主题还可分别上传导航栏背景图与菜单选中样式图(PNG/JPG/WebP/SVG,不设应用层文件大小限制),字段为 `light_red_header_background_*` / `light_red_nav_active_*`;选择文件或恢复默认时顶部导航即时预览,未配置时继续使用 `top-bg-light-red.svg` / `nav-active-light-red.svg`。其余配置在 `system_settings.json -> app_identity`,图片统一放在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片通过 `/api/public/app-identity/*` 读取。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。 - **平台模板套件 (Bootstrap page-template-suite 技能)**: 内置技能 `skills/public/page-template-suite`(public 类,缺省启用)让「画页面」走平台预制 Bootstrap 模板而不是从零手写。**套件只提供多页面门户(最低 2 个逻辑页),不做单页面 HTML**(单页请求自动回落共享响应式文档模板):5 套设计 × 明暗两版共 10 个 `deerflow/page_reports/assets/report-bootstrap-*-template.html`(图表门户 3 页 / 甘特双页「进度总览+里程碑风险」/ 瓦片地图门户 3 页 / 文字杂志双页「正文论述+脉络结论」/ 研判简报双页「核心结论+风险行动」),profile 注册于 `page_reports/templates.py`(id `bootstrap-charts-portal-{light,dark}`、`bootstrap-gantt-{light,dark}`、`bootstrap-map-portal-{light,dark}`、`bootstrap-text-magazine-{light,dark}`、`bootstrap-text-briefing-{light,dark}`)。显式 `template_id` 时**页数以模板为准**(prepare 会把 brief 的页数对齐到模板页数,技能无需依赖用户说出「多页」)。模板源文件用 `__DF_HTML_RUNTIME_BASE__` 占位符引用 Gateway 运行库(Bootstrap/Alpine/ECharts/Leaflet/deerflow-runtime.js),`page_report_template prepare` 在会话副本中替换为设置里保存的 HTML 运行库地址,部署无关;图表走 `data-df-chart` 严格 JSON(甘特 = 时间轴 + 阶段重叠柱 `barGap:-100%`),地图门户走 `data-df-map` Leaflet 瓦片契约(瓦片地址来自设置里保存的地图瓦片地址,需管理员在「外观设置 → HTML 地图」选择「地图网页」引擎,引擎不符时 prepare 的 brief 会带 `map_engine_warning`)。prepare 的 brief 还带 `platform_branding`(管理员「生成网页标识」的名称/Logo/页脚文字与注入规则,正文禁止自写品牌)和 `runtime_base_url`;`_choose_page_profile` 现在**显式 `template_id` 优先**(单页文档模式也能选中套件模板,此前单页一律回落共享文档模板),未显式指定时旧选择行为不变。多页门户模板自带锚点切页兜底脚本(检测 `data-df-generated-header` 让位平台套壳,勿用 `data-df-generated-chrome` 字面量——会被注入幂等检查误判)。测试 `tests/test_page_template_suite.py`(10 模板 prepare→validate 全绿 + 品牌注入 + 居左/居中布局 + 引擎门控)。 - **Docker 内置技能同步**: Linux Docker 启动时不能让旧的持久化 `/app/skills` 遮住当前版本的 `backend/skills/public`。共享实现 `app/builtin_skill_sync.py` 会把 bundled public 覆盖同步到 live public 根、保留 `custom/`,并强制校验 Facebook/X/TikTok/YouTube/Threads/CNN/BBC/新华社/白宫官网/马尼拉时报/GMA News/雅虎日本/读卖新闻共 13 个硬路由页面技能;它使用跨进程文件锁和必需包内容摘要,多 worker 首次只复制一次,后续相同版本只校验跳过。两个受支持启动脚本仍在 Gateway 前调用 `backend/scripts/sync_builtin_skills.py`,把当前源码 `backend/skills` 只读挂载为 `/app/bundled-skills`、live skills 读写挂载到 `/app/skills`;同时 Gateway lifespan 在加载配置后、技能 ownership 迁移/MySQL 目录投影/提示词缓存之前再次调用同一共享实现,所以镜像默认入口、Compose 或外部发版脚本绕过 Shell 时也会自动补齐。任一硬路由技能缺失会 fail-fast 并打印名单,不能静默启动成“技能不可用”。 +- **自定义技能迭代与版本切换**: 管理员设置弹窗的「自定义技能迭代」总开关默认关闭(`GET/PUT /api/system-settings/skill-versioning`);关闭时保持原同名覆盖逻辑。开启后仅 `skills/custom` 参与:本人上传同名技能可选「迭代为新版本」(保留完整历史包并激活新版)或「直接覆盖」(清除旧版本链,以本次内容重新作为 v1);本人技能详情顶部也提供「迭代新版本」安装包入口,详情弹窗「版本」页可切换任一快照。历史包保存在 runtime `skill-versions//vNNNNNN`,位于技能扫描/沙箱挂载目录之外;`skills/custom/` 永远只保留一个活动包,所以大模型只能发现当前激活版本。版本元数据与调用统计解耦并继续以稳定技能名计数,上传迭代和版本切换都不清零外部调用次数。实现 `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;渲染器拒绝覆盖已有文件并对 `` 注入做转义。最终单文件 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`。 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 643809a0..ce1e9024 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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)--- diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 6976adb4..dca52888 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -145,6 +145,10 @@ window.__CM_RUNTIME_CONFIG__ = { // 静态站点模式开关;一般部署业务系统时保持 false。 VITE_STATIC_WEBSITE_ONLY: "false", + // 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表; + // 改为 true 并刷新页面后,才允许用户切换到卡片视图。 + VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false", + // 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标, // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 VITE_FORCE_COLLAPSED_SIDEBAR: "false", diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index 0e57723f..6cbc5cfe 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -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\);/, diff --git a/frontend-web/src/assets/img/top-bg-light-red.svg b/frontend-web/src/assets/img/top-bg-light-red.svg index 1187b16b..b460b268 100644 --- a/frontend-web/src/assets/img/top-bg-light-red.svg +++ b/frontend-web/src/assets/img/top-bg-light-red.svg @@ -1 +1 @@ - + diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index ce7e5d76..e08e25db 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -456,7 +456,10 @@ .strategy-app-header--light-red-top-nav .top-submenu-item--active { color: #ffffff; text-shadow: 0 0 12px rgba(177, 38, 28, 0.48); - background-image: url("../assets/img/nav-active-light-red.svg"); + background-image: var( + --light-red-nav-active-background-image, + url("../assets/img/nav-active-light-red.svg") + ); } .third-submenu-bar { diff --git a/frontend-web/src/components/wiki/WikiHeader.tsx b/frontend-web/src/components/wiki/WikiHeader.tsx index 1381250b..7f2baaaa 100644 --- a/frontend-web/src/components/wiki/WikiHeader.tsx +++ b/frontend-web/src/components/wiki/WikiHeader.tsx @@ -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 ( -
-
+
+
-
- 知识库管理 +
+
+ {title} +
+ {description ? ( +

+ {description} +

+ ) : null}
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index f0ae2639..6607ac04 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -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 */}
-

{t.agents.title}

+

+ {pageDescription?.title || t.agents.title} +

- {t.agents.description} + {pageDescription?.description || t.agents.description}

diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 092d2415..d03dfbe4 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({ )} { if (onDownloadClick) { onDownloadClick(); diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index 10fc066b..1876bd58 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -130,7 +130,9 @@ export function ArtifactFileList({ ) : ( )} - {t.common.download} + {!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file)) + ? "下载离线包" + : t.common.download} diff --git a/frontend-web/src/components/workspace/compact-welcome.tsx b/frontend-web/src/components/workspace/compact-welcome.tsx index 6aa6b7b5..44e31f75 100644 --- a/frontend-web/src/components/workspace/compact-welcome.tsx +++ b/frontend-web/src/components/workspace/compact-welcome.tsx @@ -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),红/蓝/深蓝主题自适应。 * 非紧凑模式仍用 ,本组件不参与。 */ -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)}

- 基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 + {desensitizeText(welcomeDescription)}

); diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 81a11080..96389623 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -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} /> diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 2bbfbe9b..9fcd7eb1 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -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 ( ); diff --git a/frontend-web/src/components/workspace/question-navigator.tsx b/frontend-web/src/components/workspace/question-navigator.tsx index aec0694c..c23e1fd5 100644 --- a/frontend-web/src/components/workspace/question-navigator.tsx +++ b/frontend-web/src/components/workspace/question-navigator.tsx @@ -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()); + const navRef = useRef(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 (
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() { - 清除系统名称和 Logo? + 清除系统名称配置?

- 清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。 + 清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。

diff --git a/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx b/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx new file mode 100644 index 00000000..1c4c694b --- /dev/null +++ b/frontend-web/src/components/workspace/settings/call-metrics-settings-page.tsx @@ -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 ( +
+
+

+ + 工具与技能调用统计 +

+

+ 为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、 + 技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。 +

+
+ +
+
+
记录工具与技能调用
+

+ 关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。 +

+
+ {isLoading ? ( + + ) : ( + + )} +
+ + {isError && ( +

+ 无法读取调用统计开关,系统将按当前部署配置执行。 +

+ )} +

+ 保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。 +

+
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx b/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx new file mode 100644 index 00000000..5591a34e --- /dev/null +++ b/frontend-web/src/components/workspace/settings/knowledge-batch-create-settings-page.tsx @@ -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(); + 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(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 ( + +
+
+ +