diff --git a/.gitignore b/.gitignore index b63d5dd7..cbc8979e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ hermes-agent-main/ offline-backend-20260512/backend/tests/* !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 !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 @@ -131,6 +132,9 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_gongwen_documents.py # Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. !offline-backend-20260512/backend/tests/test_gauss_database.py +!offline-backend-20260512/backend/tests/test_gauss_mysql_compat.py +!offline-backend-20260512/backend/tests/test_chat_history_migration_seed.py +!offline-backend-20260512/backend/tests/test_gauss_data_adaptation_seed.py !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py !offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py @@ -260,8 +264,51 @@ docs/backups offline-backend-20260512/backend/skills/* !offline-backend-20260512/backend/skills/public/ offline-backend-20260512/backend/skills/public/* +!offline-backend-20260512/backend/skills/public/_shared/ +!offline-backend-20260512/backend/skills/public/_shared/** !offline-backend-20260512/backend/skills/public/analysis-mindmap/ !offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md +!offline-backend-20260512/backend/skills/public/facebook-page-generator/ +!offline-backend-20260512/backend/skills/public/facebook-page-generator/** +!offline-backend-20260512/backend/tests/test_facebook_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/x-page-generator/ +!offline-backend-20260512/backend/skills/public/x-page-generator/** +!offline-backend-20260512/backend/tests/test_x_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/tiktok-page-generator/ +!offline-backend-20260512/backend/skills/public/tiktok-page-generator/** +!offline-backend-20260512/backend/tests/test_tiktok_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/youtube-page-generator/ +!offline-backend-20260512/backend/skills/public/youtube-page-generator/** +!offline-backend-20260512/backend/tests/test_youtube_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/threads-page-generator/ +!offline-backend-20260512/backend/skills/public/threads-page-generator/** +!offline-backend-20260512/backend/tests/test_threads_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/cnn-page-generator/ +!offline-backend-20260512/backend/skills/public/cnn-page-generator/** +!offline-backend-20260512/backend/tests/test_cnn_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/bbc-page-generator/ +!offline-backend-20260512/backend/skills/public/bbc-page-generator/** +!offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/xinhua-page-generator/ +!offline-backend-20260512/backend/skills/public/xinhua-page-generator/** +!offline-backend-20260512/backend/tests/test_xinhua_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/white-house-page-generator/ +!offline-backend-20260512/backend/skills/public/white-house-page-generator/** +!offline-backend-20260512/backend/tests/test_white_house_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/manila-times-page-generator/ +!offline-backend-20260512/backend/skills/public/manila-times-page-generator/** +!offline-backend-20260512/backend/tests/test_manila_times_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/gma-news-page-generator/ +!offline-backend-20260512/backend/skills/public/gma-news-page-generator/** +!offline-backend-20260512/backend/tests/test_gma_news_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/ +!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/** +!offline-backend-20260512/backend/tests/test_yahoo_japan_page_generator_skill.py +!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/ +!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/** +!offline-backend-20260512/backend/tests/test_yomiuri_news_page_generator_skill.py +!offline-backend-20260512/backend/tests/test_fast_page_generator_specs.py +!offline-backend-20260512/backend/tests/test_sync_builtin_skills.py # Built-in skill-creator ported from bytedance/deer-flow (product source too). !offline-backend-20260512/backend/skills/public/skill-creator/ !offline-backend-20260512/backend/skills/public/skill-creator/** diff --git a/AGENTS.md b/AGENTS.md index 004d365e..05451f5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,24 @@ 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` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。 +- **系统名称与 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。 +- **平台模板套件 (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 并打印名单,不能静默启动成“技能不可用”。 +- **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`。 +- **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..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:` 窗口事件强制重放(忽略已看过记忆)。 diff --git a/frontend-web/package.json b/frontend-web/package.json index 66aeb370..5959e517 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -20,7 +20,6 @@ "test:users-paged": "node scripts/test-users-paged.mjs", "test:auth-entry": "node scripts/test-auth-entry.mjs", "test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs", - "test:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.test.mjs", "test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs", "test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts", "test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts", diff --git a/frontend-web/public/brand-assets/brand-logo-dark-1.png b/frontend-web/public/brand-assets/brand-logo-dark-1.png new file mode 100644 index 00000000..426f78a9 Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-dark-1.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-dark.png b/frontend-web/public/brand-assets/brand-logo-dark.png index 426f78a9..22853dfe 100644 Binary files a/frontend-web/public/brand-assets/brand-logo-dark.png and b/frontend-web/public/brand-assets/brand-logo-dark.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-light-2.png b/frontend-web/public/brand-assets/brand-logo-light-2.png new file mode 100644 index 00000000..22853dfe Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-light-2.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-lightblue-1.png b/frontend-web/public/brand-assets/brand-logo-lightblue-1.png new file mode 100644 index 00000000..c25098e4 Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-lightblue-1.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-lightblue.png b/frontend-web/public/brand-assets/brand-logo-lightblue.png index c25098e4..22853dfe 100644 Binary files a/frontend-web/public/brand-assets/brand-logo-lightblue.png and b/frontend-web/public/brand-assets/brand-logo-lightblue.png differ diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index c02873c8..6976adb4 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -13,7 +13,11 @@ window.__CM_RUNTIME_CONFIG__ = { // 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。 // 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。 VITE_WELCOME_LOGO_VISIBLE: "false", - // 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。 + // 红白主题是否使用浅红色科技感顶栏,并完整复用深色主题的导航布局。 + // "true" 启用;"false" 保持原来的红白主题顶栏。 + VITE_LIGHT_RED_TOP_NAV_ENABLED: "true", + // 科技感顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。 + // 同时作用于深色主题和启用后的浅红色顶栏。 VITE_DARK_LOGO_POSITION: "center", // 红白主题的顶栏系统 Logo 图片地址。 VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png", @@ -23,13 +27,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png", // 深色主题的顶栏系统 Logo 图片地址。 VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png", - // 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 - VITE_BRAND_WELCOME_LIGHT_URL: "", - // 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 - VITE_BRAND_WELCOME_LIGHTBLUE_URL: "", - VITE_BRAND_WELCOME_LIGHTGREEN_URL: "", - // 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 - VITE_BRAND_WELCOME_DARK_URL: "", + // 简洁模式欢迎区复用上面的 VITE_BRAND_LOGO_* 系统 Logo,不再单独配置欢迎页图片。 // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow", @@ -42,7 +40,7 @@ window.__CM_RUNTIME_CONFIG__ = { // 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。 // 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。 - VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png", + VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13", // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。 // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index f7b3c925..d746ec06 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -28,7 +28,6 @@ import { } from "@/core/embed/embed-session"; import { HashRouter, useLocation } from "react-router-dom"; - import { isPositionRoundtableStandaloneMode, POSITION_ROUNDTABLE_ROUTE, @@ -36,6 +35,7 @@ import { const LandingPage = lazy(() => import("./pages/LandingPage")); const LoginPage = lazy(() => import("./pages/LoginPage")); +const RegisterPage = lazy(() => import("./pages/RegisterPage")); const PageRoutes = lazy(() => import("./pages/PageRoutes")); const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage")); const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage")); @@ -202,6 +202,10 @@ export function App() { positionStandaloneMode ? standaloneLoginRedirect : } /> + } + /> } /> {/* Anonymous standalone tool: no workspace navigation or login gate. */} \ No newline at end of file diff --git a/frontend-web/src/assets/img/top-bg-light-red.svg b/frontend-web/src/assets/img/top-bg-light-red.svg new file mode 100644 index 00000000..1187b16b --- /dev/null +++ b/frontend-web/src/assets/img/top-bg-light-red.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/components/font-scale.tsx b/frontend-web/src/components/font-scale.tsx index 1ba2cdda..0229324b 100644 --- a/frontend-web/src/components/font-scale.tsx +++ b/frontend-web/src/components/font-scale.tsx @@ -14,7 +14,7 @@ import { * 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。 * 「整页缩放」改在 `` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变; * 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。 - * `fontScale === 1` 即默认 16px。 + * 默认倍率为 `1.375`,即 22px。 */ export const BASE_FONT_PX = 16; @@ -24,6 +24,7 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] { value: 1, label: "标准", px: 16 }, { value: 1.125, label: "大", px: 18 }, { value: 1.25, label: "特大", px: 20 }, + { value: 1.375, label: "超大", px: 22 }, ]; export const FONT_SCALE_MODE_OPTIONS: { diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 157ba030..ce7e5d76 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -443,6 +443,22 @@ 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: url("../assets/img/nav-active-light-red.svg"); +} + .third-submenu-bar { position: relative; z-index: 34; @@ -753,7 +769,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after { color: var(--brand-900); } -/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */ +/* 侧边栏底部:设置入口 + 收起面板按钮 */ .light-sidebar__footer { margin-top: auto; display: flex; @@ -763,6 +779,58 @@ html[data-custom-theme="true"] .light-topnav-item--active::after { border-top: 1px solid var(--border); } +/* 侧边栏收起后的左下角浮动展开入口(交互同深色 sidebar-collapse-btn--floating,配色用浅色主题变量) */ +.light-sidebar-expand-btn { + position: fixed; + left: 10px; + bottom: 10px; + z-index: 60; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 36px; + height: 36px; + padding: 0 10px; + border: 1px solid var(--brand-200); + border-radius: 8px; + background: #ffffff; + color: var(--brand-900); + font-family: var(--font-sans); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + cursor: pointer; + overflow: hidden; + transition: width 0.22s ease, background-color 0.2s ease, border-color 0.2s ease; +} + +.light-sidebar-expand-btn:hover { + width: 104px; + background: var(--brand-200); +} + +.light-sidebar-expand-btn__icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + color: var(--brand-900); +} + +.light-sidebar-expand-btn__label { + font-size: 14px; + line-height: 1; + white-space: nowrap; + max-width: 0; + opacity: 0; + transform: translateX(-4px); + transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease; +} + +.light-sidebar-expand-btn:hover .light-sidebar-expand-btn__label { + max-width: 64px; + opacity: 1; + transform: translateX(0); +} + /* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */ .light-sidebar-subnav { display: flex; diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 001c3319..35707ad6 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -13,6 +13,8 @@ import { MessageSquareText, MousePointerClick, BriefcaseBusiness, + PanelLeftClose, + PanelLeftOpen, Settings, ShieldAlert, ShieldCheck, @@ -100,14 +102,24 @@ export function PageSidebar({ /> ); } - return ; + return ( + + ); } /** * 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。 - * 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。 + * 收起交互与深色侧边栏一致:收起后整栏隐藏,仅左下角保留浮动展开入口。 */ -function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { +function SecondaryNavSidebar({ + collapsed, + onToggleCollapsed, + headerHidden, +}: PageSidebarProps) { const isAdmin = getStoredAuth()?.system_role === "admin"; const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const { t } = useI18n(); @@ -151,6 +163,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname); const expanded = thirdItems.length > 0; + // 收起时整栏不占宽度,仅保留左下角浮动入口以便随时恢复(交互同深色侧边栏)。 + if (collapsed) { + return ( + + ); + } + return ( <> ) : null} + diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 666189ea..244303be 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -96,6 +96,8 @@ import { cn } from "@/lib/utils"; interface AgentCardProps { agent: Agent; + /** Optional visual state supplied by management surfaces (for example selection). */ + className?: string; /** "card" (default) renders the full gallery card; "list" renders a compact * single-row layout for the list view. All dialogs/actions are shared. */ layout?: "card" | "list"; @@ -220,6 +222,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) { export function AgentCard({ agent, + className, layout = "card", onPublisherClick, onToggleHidden, @@ -928,8 +931,9 @@ export function AgentCard({ const cardLayout = ( @@ -992,8 +996,9 @@ export function AgentCard({ const listLayout = (
diff --git a/frontend-web/src/components/workspace/chat-history-transfer-dialog.tsx b/frontend-web/src/components/workspace/chat-history-transfer-dialog.tsx new file mode 100644 index 00000000..e4c44673 --- /dev/null +++ b/frontend-web/src/components/workspace/chat-history-transfer-dialog.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Progress } from "@/components/ui/progress"; +import { + exportAllThreadsInBackground, + importTransferThreads, + parseTransferBundle, + skipReasonLabel, + type SkippedThread, + type TransferProgress, +} from "@/core/threads/transfer"; + +function ProgressPanel({ + progress, + label, +}: { + progress: TransferProgress | null; + label: string; +}) { + if (!progress) { + return ( +
+ + {label} +
+ ); + } + const percent = + progress.total > 0 + ? Math.round((progress.processed / progress.total) * 100) + : 0; + return ( +
+ +

+ 已处理 {progress.processed} / {progress.total} 条,成功{" "} + {progress.succeeded},跳过 {progress.skipped} +

+
+ ); +} + +function SkippedList({ items }: { items: SkippedThread[] }) { + if (items.length === 0) return null; + return ( +
+ {items.slice(0, 20).map((item) => ( +

+ {item.title || item.thread_id}:{skipReasonLabel(item.reason)} +

+ ))} + {items.length > 20 &&

其余 {items.length - 20} 条已省略

} +
+ ); +} + +export function ChatHistoryExportDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [skipped, setSkipped] = useState([]); + + async function handleExport() { + if (running) return; + setRunning(true); + setProgress(null); + setSkipped([]); + try { + setProgress({ + total: 0, + processed: 0, + succeeded: 0, + skipped: 0, + }); + const result = await exportAllThreadsInBackground(setProgress); + setSkipped(result.skipped); + if (result.succeeded === 0) { + toast.message( + result.total === 0 ? "没有可导出的对话" : "没有成功导出的对话", + ); + return; + } + const extras = [ + result.skipped.length > 0 ? `跳过 ${result.skipped.length} 条` : "", + result.skipped_files > 0 ? `跳过 ${result.skipped_files} 个过大附件` : "", + ].filter(Boolean); + toast.success( + extras.length > 0 + ? `已导出 ${result.succeeded} 条,${extras.join(",")}` + : `已导出 ${result.succeeded} 条对话`, + ); + if (result.skipped.length === 0) { + onOpenChange(false); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "导出失败"); + } finally { + setRunning(false); + } + } + + return ( + + + + 导出对话记录 + + 后台逐条打包当前账号的对话消息和附件,进度按条前进。单条上限 50MB,超出部分会跳过大附件,消息仍导出;一条失败不影响其余记录。 + + + {running || progress ? ( + + ) : ( +

+ 导出文件可拷到另一台机器,在本页「导入记录」还原。 +

+ )} + +
+ + +
+
+
+ ); +} + +export function ChatHistoryImportDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const inputRef = useRef(null); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [skipped, setSkipped] = useState([]); + + async function handleFile(file: File) { + if (running) return; + setRunning(true); + setProgress(null); + setSkipped([]); + try { + const threads = parseTransferBundle(await file.text()); + setProgress({ + total: threads.length, + processed: 0, + succeeded: 0, + skipped: 0, + }); + const result = await importTransferThreads(threads, setProgress); + setSkipped(result.skipped); + void queryClient.invalidateQueries({ queryKey: ["threads"] }); + if (result.imported === 0) { + toast.message( + result.skipped.length > 0 + ? `没有导入新对话,跳过 ${result.skipped.length} 条` + : "没有导入新对话", + ); + return; + } + toast.success( + result.skipped.length > 0 + ? `已导入 ${result.imported} 条,跳过 ${result.skipped.length} 条` + : `已导入 ${result.imported} 条对话`, + ); + if (result.skipped.length === 0) { + onOpenChange(false); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "导入失败"); + } finally { + setRunning(false); + if (inputRef.current) inputRef.current.value = ""; + } + } + + return ( + + + + 导入对话记录 + + 选择从另一台机器导出的 JSON 文件。逐条写入,进度按条前进。已存在或失败的记录会跳过,不影响其余记录。 + + + {running || progress ? ( + + ) : ( +

+ 导入后归属当前登录账号,可继续查看和提问。附件会一并还原。 +

+ )} + + { + const file = event.target.files?.[0]; + if (file) void handleFile(file); + }} + /> +
+ + +
+
+
+ ); +} diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index fb90e4ad..2c2d1b02 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -449,7 +449,7 @@ function HumanMessageBlock({ setEditing(false); } }} - className="min-h-20 resize-none text-[15px] leading-7" + className="min-h-20 resize-none text-[calc(15px*var(--ui-font-scale,1))] leading-7" />
) : null} {(() => { + const rewriteItemsAboveFile = documentRewriteItems.filter( + (item) => item.operation !== "generate", + ); + const rewriteItemsBelowFile = documentRewriteItems.filter( + (item) => item.operation === "generate", + ); + let didRenderRewriteAboveFile = false; let didRenderLiveKnowledgeRetrieval = false; const grouped = groupMessages( displayMessages, diff --git a/frontend-web/src/components/workspace/settings/agent-skill-migration-settings-page.tsx b/frontend-web/src/components/workspace/settings/agent-skill-migration-settings-page.tsx new file mode 100644 index 00000000..23d507b8 --- /dev/null +++ b/frontend-web/src/components/workspace/settings/agent-skill-migration-settings-page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { ArrowRightLeftIcon, Loader2Icon } from "lucide-react"; +import { toast } from "sonner"; + +import { Switch } from "@/components/ui/switch"; +import { + useAgentSkillMigrationSettings, + useUpdateAgentSkillMigrationSettings, +} from "@/core/system-settings/hooks"; + +export function AgentSkillMigrationSettingsPage() { + const { data, isLoading, isError } = useAgentSkillMigrationSettings(); + const update = useUpdateAgentSkillMigrationSettings(); + const enabled = data?.enabled ?? false; + + const handleChange = async (checked: boolean) => { + try { + await update.mutateAsync({ enabled: checked }); + toast.success( + checked + ? "智能体与技能迁移功能已开启" + : "智能体与技能迁移功能已关闭", + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }; + + return ( +
+
+

+ + 智能体与技能迁移 +

+

+ 控制智能体/技能迁移入口,以及对话页的导出记录、导入记录和卡片导出按钮。默认关闭,只有管理员显式开启后才可使用。 +

+
+ +
+
+
启用跨服务器迁移
+

+ 开启后,对话页会显示记录导出和导入按钮;智能体与技能请先复制目录,再通过清单同步归属、状态和标签。 +

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

+ 无法读取迁移开关,系统将按关闭处理。 +

+ )} +

+ 关闭后前端入口和对应接口都会停用;已经复制或导入的内容不会被删除或修改。 +

+
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 3da961d2..04384518 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -41,7 +41,7 @@ import { migrateAuthToSession } from "@/core/auth/scoped-storage"; import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { useI18n } from "@/core/i18n/hooks"; -import { useLocalSettings } from "@/core/settings"; +import { DEFAULT_FONT_SCALE, useLocalSettings } from "@/core/settings"; import { AGENT_COMPOSER_WIDTH_MAX, AGENT_COMPOSER_WIDTH_MIN, @@ -79,6 +79,11 @@ import type { CompactShortcutButton, CompactShortcutRouteParam, } from "@/core/system-settings/api"; +import { + DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM, + DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM, + MESSAGE_MAP_DISPLAY_MAX_ZOOM, +} from "@/core/utils/map-tile-config"; import { cn } from "@/lib/utils"; import { createClientId } from "@/lib/client-id"; import { @@ -492,10 +497,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const [htmlRuntimeDraft, setHtmlRuntimeDraft] = useState<{ base_url: string; map_tile_url: string; + message_map_tile_min_zoom: string; + message_map_tile_max_zoom: string; } | null>(null); const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? ""; const runtimeTileUrl = htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? ""; + const runtimeTileMinZoom = + htmlRuntimeDraft?.message_map_tile_min_zoom ?? + String( + htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM, + ); + const runtimeTileMaxZoom = + htmlRuntimeDraft?.message_map_tile_max_zoom ?? + String( + htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM, + ); + const parsedRuntimeTileMinZoom = Number(runtimeTileMinZoom); + const parsedRuntimeTileMaxZoom = Number(runtimeTileMaxZoom); + const runtimeTileZoomValid = + runtimeTileMinZoom.trim() !== "" && + runtimeTileMaxZoom.trim() !== "" && + Number.isInteger(parsedRuntimeTileMinZoom) && + Number.isInteger(parsedRuntimeTileMaxZoom) && + parsedRuntimeTileMinZoom >= 0 && + parsedRuntimeTileMaxZoom <= MESSAGE_MAP_DISPLAY_MAX_ZOOM && + parsedRuntimeTileMinZoom <= parsedRuntimeTileMaxZoom; + const htmlRuntimeDirty = + !!htmlRuntime && + (runtimeBaseUrl !== htmlRuntime.base_url || + runtimeTileUrl !== htmlRuntime.map_tile_url || + parsedRuntimeTileMinZoom !== + (htmlRuntime.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM) || + parsedRuntimeTileMaxZoom !== + (htmlRuntime.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM)); const globalDefault = appearance?.default_theme ?? "light"; const globalSkillView = appearance?.default_skill_view ?? "list"; const globalAgentView = appearance?.default_agent_view ?? "card"; @@ -1220,11 +1255,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
+ setHtmlRuntimeDraft({ + base_url: runtimeBaseUrl, + map_tile_url: runtimeTileUrl, + message_map_tile_min_zoom: event.target.value, + message_map_tile_max_zoom: runtimeTileMaxZoom, + }) + } + className="mt-1" + /> +
+
+ + + setHtmlRuntimeDraft({ + base_url: runtimeBaseUrl, + map_tile_url: runtimeTileUrl, + message_map_tile_min_zoom: runtimeTileMinZoom, + message_map_tile_max_zoom: event.target.value, + }) + } + className="mt-1" + /> +
+
+ {!runtimeTileZoomValid ? ( +

+ 请输入 0~{MESSAGE_MAP_DISPLAY_MAX_ZOOM} 的整数,且最小真实层级不能大于最大真实层级。 +

+ ) : null} +
diff --git a/frontend-web/src/components/workspace/settings/generated-page-branding-settings-page.tsx b/frontend-web/src/components/workspace/settings/generated-page-branding-settings-page.tsx new file mode 100644 index 00000000..aeee8a3a --- /dev/null +++ b/frontend-web/src/components/workspace/settings/generated-page-branding-settings-page.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { + Image, + ImageOff, + ImageUp, + Loader2, + RotateCcw, +} from "lucide-react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, +} from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + useDeleteGeneratedPageBrandingSettings, + useGeneratedPageBrandingSettings, + useUpdateGeneratedPageBrandingSettings, +} from "@/core/system-settings/hooks"; +import { + resolveGeneratedPageLogoUrl, + type GeneratedPageBrandLogoMode, +} from "@/core/system-settings/api"; + +import { SettingsSection } from "./settings-section"; + +const MAX_LOGO_BYTES = 10 * 1024 * 1024; +const ACCEPTED_LOGO_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/webp", +]); +const DEFAULT_LOGO_URL = "/api/public/html-runtime/brand-logo-dark.png"; + +export function GeneratedPageBrandingSettingsPage() { + const { data: branding, isPending } = useGeneratedPageBrandingSettings(); + const updateBranding = useUpdateGeneratedPageBrandingSettings(); + const deleteBranding = useDeleteGeneratedPageBrandingSettings(); + const fileInputRef = useRef(null); + const [systemName, setSystemName] = useState(""); + const [chromeEnabled, setChromeEnabled] = useState(true); + const [headerLayout, setHeaderLayout] = useState<"left" | "center">("center"); + const [logoMode, setLogoMode] = + useState("custom"); + const [pendingLogo, setPendingLogo] = useState(null); + const [resetOpen, setResetOpen] = useState(false); + + useEffect(() => { + setChromeEnabled(branding?.chrome_enabled ?? true); + setHeaderLayout(branding?.header_layout === "left" ? "left" : "center"); + setSystemName(branding?.system_name ?? ""); + setLogoMode(branding?.logo_mode ?? "custom"); + setPendingLogo(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, [ + branding?.logo_version, + branding?.system_name, + branding?.footer_text, + branding?.logo_mode, + branding?.chrome_enabled, + branding?.header_layout, + ]); + + const previewUrl = useMemo( + () => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined), + [pendingLogo], + ); + + useEffect( + () => () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }, + [previewUrl], + ); + + const currentLogoUrl = resolveGeneratedPageLogoUrl(branding?.logo_url); + const defaultLogoUrl = resolveGeneratedPageLogoUrl(DEFAULT_LOGO_URL); + const displayedLogoUrl = + previewUrl ?? + (logoMode === "default" + ? defaultLogoUrl + : logoMode === "custom" && branding?.logo_mode === "custom" + ? currentLogoUrl + : undefined); + const normalizedName = systemName.trim(); + const configurationWillBeActive = Boolean( + normalizedName && + chromeEnabled && + (logoMode !== "custom" || displayedLogoUrl), + ); + const hasChanges = + chromeEnabled !== (branding?.chrome_enabled ?? true) || + headerLayout !== (branding?.header_layout ?? "center") || + normalizedName !== (branding?.system_name ?? "") || + logoMode !== (branding?.logo_mode ?? "custom") || + Boolean(pendingLogo); + const busy = updateBranding.isPending || deleteBranding.isPending; + + const handleLogoChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!ACCEPTED_LOGO_TYPES.has(file.type)) { + toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片"); + event.target.value = ""; + return; + } + if (file.size > MAX_LOGO_BYTES) { + toast.error("Logo 文件不能超过 10MB"); + event.target.value = ""; + return; + } + setPendingLogo(file); + setLogoMode("custom"); + }; + + const handleLogoModeChange = (mode: GeneratedPageBrandLogoMode) => { + setLogoMode(mode); + if (mode !== "custom") { + setPendingLogo(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const logoModeDescription = + logoMode === "default" + ? "保存后,生成网页会使用系统内置的默认头像。" + : logoMode === "none" + ? "保存后,生成网页的品牌页头仅显示系统名称,不显示头像。" + : "上传的自定义 Logo 将显示在生成网页的品牌页头。"; + + const handleSave = async () => { + try { + const saved = await updateBranding.mutateAsync({ + chromeEnabled, + headerLayout, + systemName: normalizedName, + logoMode, + logo: pendingLogo, + }); + toast.success("生成网页标识已保存", { + description: saved.active + ? "名称和头像显示方式已配置,之后新生成的 HTML 页面将自动加上品牌页头(不加页脚)。" + : "请填写系统名称,并在上传自定义、默认头像或不显示头像中完成选择。", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "生成网页标识保存失败", + ); + } + }; + + const handleReset = async () => { + try { + await deleteBranding.mutateAsync(); + setResetOpen(false); + toast.success("已清除生成网页标识", { + description: "之后生成网页时将恢复原有生成逻辑。", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "清除生成网页标识失败", + ); + } + }; + + return ( + <> + +
+
+
+ +

+ 开启后,平台会给新生成的网页自动加上统一品牌页头(多页面含页面导航),不加页脚。 + 关闭后,生成网页完全不加页头、保持原有输出,也不要求页面自行展示名称或头像。 +

+
+ +
+ +
+ + {configurationWillBeActive + ? "配置完整:" + : !chromeEnabled + ? "自动注入已关闭:" + : "当前未启用:"} + + {configurationWillBeActive + ? logoMode === "none" + ? "保存后,新生成网页将自动加上仅含系统名称的品牌页头(不显示头像、不加页脚)。" + : "保存后,新生成网页将自动加上品牌页头(名称 + 头像,不加页脚)。" + : !chromeEnabled + ? "生成网页完全不加页头,也不要求页面自行展示名称或头像。" + : "请填写系统名称,并选择上传自定义、使用默认头像或不显示头像。"} +
+ +
+ + setSystemName(event.target.value)} + placeholder="请输入要在网页页头展示的系统名称" + maxLength={80} + disabled={isPending || busy} + aria-describedby="generated-page-brand-name-help" + /> +
+ 最多 80 个字符;需选择头像显示方式才会启用。 + {systemName.length}/80 +
+
+ +
+
+
网页 Logo / 头像
+

+ 可上传自定义 Logo、使用系统默认头像,或只显示系统名称。启用后会进入品牌页头。 +

+
+ +
+ {[ + ["custom", "上传自定义"], + ["default", "使用默认头像"], + ["none", "不显示头像"], + ].map(([mode, label]) => ( + + ))} +
+ +
+
+ {displayedLogoUrl ? ( + 网页 Logo 预览 + ) : ( +
+ {logoMode === "none" ? ( + + ) : ( + + )} + {logoMode === "none" + ? "当前选择不显示头像" + : "当前未上传自定义 Logo"} +
+ )} +
+ +
+ {logoMode === "custom" ? ( + <> + +
+ +
+ {pendingLogo && ( +

+ 待上传:{pendingLogo.name} +

+ )} + + ) : ( +

+ {logoModeDescription} +

+ )} +
+
+
+ +
+ + +
+
+
+ + + + + 清除生成网页标识 + +

+ 将移除网页展示名称与 Logo/头像显示方式,并把自动注入开关恢复为开启。DeerFlow 自身界面不会发生变化,之后生成的 HTML 页面将恢复原有生成逻辑。 +

+ + + + +
+
+ + ); +} diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index 7d12aa83..0e352dbb 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -1,15 +1,17 @@ "use client"; import { - BellIcon, + ArchiveIcon, + ArrowRightLeftIcon, BrainIcon, CpuIcon, DatabaseIcon, - ArchiveIcon, FileOutputIcon, HistoryIcon, + ImageIcon, PaletteIcon, SearchIcon, + ShieldCheckIcon, ShrinkIcon, SparklesIcon, TypeIcon, @@ -29,9 +31,12 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; +import { AgentSkillMigrationSettingsPage } from "@/components/workspace/settings/agent-skill-migration-settings-page"; +import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; -import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page"; import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page"; +import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page"; +import { GeneratedPageBrandingSettingsPage } from "@/components/workspace/settings/generated-page-branding-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"; @@ -44,6 +49,7 @@ import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings- import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page"; import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace"; @@ -52,6 +58,7 @@ import { ServerDiagnosticsPanel } from "@/pages/ServerDiagnosticsPage"; type SettingsSection = | "account" | "appearance" + | "authentication" | "conversation-context" | "model-prompts" | "server-diagnostics" @@ -61,6 +68,8 @@ type SettingsSection = | "roundtable-rooms" | "knowledge-retrieval" | "appIdentity" + | "generatedPageBranding" + | "agentSkillMigration" | "memory" | "fileWriting" | "retryKeepPartial" @@ -77,6 +86,7 @@ export function SettingsDialog(props: SettingsDialogProps) { const { defaultSection = "appearance", ...dialogProps } = props; const { t } = useI18n(); const isAdmin = getStoredAuth()?.system_role === "admin"; + const live = useLiveAccount(); const [activeSection, setActiveSection] = useState(defaultSection); @@ -113,10 +123,13 @@ export function SettingsDialog(props: SettingsDialogProps) { { id: "roundtable-rooms", label: "会商并发", icon: UsersIcon }, { id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon }, { id: "appIdentity", label: "系统名称", icon: TypeIcon }, + { id: "generatedPageBranding", label: "生成网页标识", icon: ImageIcon }, + { id: "agentSkillMigration", label: "智能体与技能迁移", icon: ArrowRightLeftIcon }, { id: "memory", label: "问答记忆", icon: BrainIcon }, { id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon }, { id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon }, { id: "uploads", label: "上传设置", icon: UploadCloudIcon }, + { id: "authentication", label: "登录与注册", icon: ShieldCheckIcon }, ] : []), { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, @@ -210,9 +223,21 @@ export function SettingsDialog(props: SettingsDialogProps) { {activeSection === "knowledge-retrieval" && isAdmin && ( )} + {activeSection === "authentication" && isAdmin && ( + props.onOpenChange?.(false)} + /> + )} {activeSection === "appIdentity" && isAdmin && ( )} + {activeSection === "generatedPageBranding" && isAdmin && ( + + )} + {activeSection === "agentSkillMigration" && isAdmin && ( + + )} {activeSection === "memory" && isAdmin && ( )} diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 5ba89fa3..b747cb5c 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -11,8 +11,10 @@ import { ChevronRightIcon, ClockIcon, CopyIcon, - EyeOffIcon, - FileArchive, + EyeOffIcon, + FileArchive, + FileDownIcon, + FileUpIcon, GlobeIcon, InfoIcon, Loader2, @@ -29,13 +31,15 @@ import { Upload, UserIcon, XIcon, -} from "lucide-react"; -import { useRouter } from "next/navigation"; +} from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -69,7 +73,20 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; -import { useTheme } from "@/contexts/ThemeContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import { + analyzeSkillMigrationSkip, + applyMigrationManifest, + exportSkillMigrationManifest, + getMigrationRoots, + preflightMigrationManifest, +} from "@/core/agent-migrations"; +import type { + AgentSkillMigrationManifest, + MigrationPreflight, + MigrationResourcePreflight, + MigrationRoots, +} from "@/core/agent-migrations"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example"; @@ -97,6 +114,7 @@ import { type SkillAvailabilityControl, } from "@/core/skill-availability"; import { + useAgentSkillMigrationSettings, usePublishSquares, useSkillCompressionSettings, useViewMode, @@ -188,14 +206,15 @@ const rescanErrorToastDarkOptions = { icon: , } as const; -function SkillSettingsList({ +function SkillSettingsList({ onClose, restrictWxTypes = false, }: { onClose?: () => void; restrictWxTypes?: boolean; -}) { - const { t } = useI18n(); +}) { + const queryClient = useQueryClient(); + const { t } = useI18n(); const { theme } = useTheme(); const router = useRouter(); const [filter, setFilter] = useState("mine"); @@ -224,9 +243,31 @@ function SkillSettingsList({ const fillNamesMutation = useGenerateMissingSkillNameZh(); // Optional model override for the admin "一键补充中文名" action (null = default). const [fillModel, setFillModel] = useState(null); - const storedAuth = useMemo(() => getStoredAuth(), []); - const currentUserId = storedAuth?.user_id ?? null; - const isAdmin = storedAuth?.system_role === "admin"; + const storedAuth = useMemo(() => getStoredAuth(), []); + const currentUserId = storedAuth?.user_id ?? null; + const isAdmin = storedAuth?.system_role === "admin"; + const { data: migrationSettings } = useAgentSkillMigrationSettings({ + enabled: isAdmin, + }); + const migrationEnabled = isAdmin && (migrationSettings?.enabled ?? false); + const [selectedMigrationNames, setSelectedMigrationNames] = useState< + Set + >(new Set()); + const [exportingMigration, setExportingMigration] = useState(false); + const [readingMigration, setReadingMigration] = useState(false); + const [applyingMigration, setApplyingMigration] = useState(false); + const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); + const [migrationManifest, setMigrationManifest] = + useState(null); + const [migrationPreflight, setMigrationPreflight] = + useState(null); + const [migrationRoots, setMigrationRoots] = useState( + null, + ); + const [useCurrentAdminForMigration, setUseCurrentAdminForMigration] = + useState(false); + const [skipInvalidSkills, setSkipInvalidSkills] = useState(false); + const migrationInputRef = useRef(null); // Name + tag filtering is resolved server-side via the /api/skills query. // A 岗位 (position) no longer *restricts* the list — it grants skills into the @@ -371,10 +412,50 @@ function SkillSettingsList({ setFilter("mine"); } }, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]); - const pagedSkills = useMemo( + const pagedSkills = useMemo( () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [orderedSkills, page], - ); + ); + const allFilteredMigrationSelected = + orderedSkills.length > 0 && + orderedSkills.every((skill) => selectedMigrationNames.has(skill.name)); + const migrationResources = useMemo( + () => + migrationPreflight + ? [ + ...migrationPreflight.skills, + ...migrationPreflight.support_directories, + ] + : [], + [migrationPreflight], + ); + const migrationProblems = migrationResources.filter( + (item) => item.status !== "matched", + ); + const strictUnresolvedMigrationOwners = + migrationPreflight?.owner_mappings.filter( + (owner) => owner.status === "unresolved", + ) ?? []; + const skipAnalysis = + migrationManifest && migrationPreflight + ? analyzeSkillMigrationSkip(migrationManifest, migrationPreflight) + : null; + const unresolvedMigrationOwners = skipInvalidSkills + ? (skipAnalysis?.unresolvedOwners ?? []) + : strictUnresolvedMigrationOwners; + const migrationProblemsCanBeSkipped = + migrationProblems.length > 0 && + !!skipAnalysis && + skipAnalysis.skippedSkillNames.length > 0 && + skipAnalysis.remainingSkillCount > 0 && + skipAnalysis.unskippableProblemCount === 0; + const canApplyMigration = + !!migrationManifest && + !!migrationPreflight && + (migrationProblems.length === 0 || + (skipInvalidSkills && migrationProblemsCanBeSkipped)) && + (unresolvedMigrationOwners.length === 0 || + useCurrentAdminForMigration); const handleFillNames = async () => { try { @@ -398,7 +479,7 @@ function SkillSettingsList({ } }; - const handleRescan = async () => { + const handleRescan = async () => { try { const result = await reconcileMutation.mutateAsync(); if (result.adopted.length > 0) { @@ -419,10 +500,112 @@ function SkillSettingsList({ toast.error( err instanceof Error ? err.message : String(err), theme === "dark" ? rescanErrorToastDarkOptions : undefined, + ); + } + }; + + const toggleMigrationSelection = (name: string) => { + setSelectedMigrationNames((current) => { + const next = new Set(current); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const handleExportMigration = async () => { + if (selectedMigrationNames.size === 0) return; + setExportingMigration(true); + try { + const manifest = await exportSkillMigrationManifest([ + ...selectedMigrationNames, + ]); + const blob = new Blob([JSON.stringify(manifest, null, 2)], { + type: "application/json;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `skill-sync-${manifest.bundle_id.slice(0, 8)}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + toast.success(`已导出 ${manifest.skills.length} 个技能的迁移清单`); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setExportingMigration(false); + } + }; + + const handleReadMigration = async (file: File) => { + if (file.size > 20 * 1024 * 1024) { + toast.error("技能迁移清单不能超过 20 MB"); + return; + } + setReadingMigration(true); + setUseCurrentAdminForMigration(false); + setSkipInvalidSkills(false); + try { + const manifest = JSON.parse( + await file.text(), + ) as AgentSkillMigrationManifest; + if ( + manifest.schema_version !== 1 || + !Array.isArray(manifest.agents) || + !Array.isArray(manifest.skills) || + !Array.isArray(manifest.support_directories) + ) { + throw new Error("不是受支持的技能迁移清单"); + } + if (manifest.skills.length === 0 || manifest.agents.length > 0) { + throw new Error( + "请选择从技能管理导出的清单;智能体清单请到智能体管理导入", + ); + } + const [preflight, roots] = await Promise.all([ + preflightMigrationManifest(manifest), + getMigrationRoots(), + ]); + setMigrationManifest(manifest); + setMigrationPreflight(preflight); + setMigrationRoots(roots); + setMigrationDialogOpen(true); + } catch (err) { + toast.error(err instanceof Error ? err.message : "技能迁移清单读取失败"); + } finally { + setReadingMigration(false); + } + }; + + const handleApplyMigration = async () => { + if (!migrationManifest || !canApplyMigration) return; + setApplyingMigration(true); + try { + const result = await applyMigrationManifest( + migrationManifest, + useCurrentAdminForMigration, + skipInvalidSkills, + ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["skills"] }), + queryClient.invalidateQueries({ queryKey: ["tags"] }), + ]); + toast.success( + `同步完成:导入 ${result.skills_applied} 个技能${result.skills_skipped ? `,跳过 ${result.skills_skipped} 个` : ""}`, ); - } - }; - return ( + setMigrationDialogOpen(false); + setMigrationManifest(null); + setMigrationPreflight(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setApplyingMigration(false); + } + }; + + return (
@@ -501,9 +684,46 @@ function SkillSettingsList({ ? t.settings.skills.rescanSkillsRunning : t.settings.skills.rescanSkills} - {isAdmin && ( - <> - + {migrationEnabled && ( + <> + + + + )} + } /> - {!isLoading && !error && ( -
- 共 {orderedSkills.length} 个技能 -
- )} + {!isLoading && !error && ( +
+

+ 共 {orderedSkills.length} 个技能 + {migrationEnabled && selectedMigrationNames.size > 0 + ? ` · 已选择 ${selectedMigrationNames.size} 个` + : ""} +

+ {migrationEnabled && orderedSkills.length > 0 && ( +
+ + {selectedMigrationNames.size > 0 && ( + + )} +
+ )} +
+ )} {publisher && (
筛选发布者: @@ -620,20 +874,33 @@ function SkillSettingsList({ : "flex flex-col gap-3", )} > - {pagedSkills.map((skill) => ( - ( + + onPublisherClick={setPublisher} + selected={ + migrationEnabled && selectedMigrationNames.has(skill.name) + } + onSelectionToggle={ + migrationEnabled + ? () => toggleMigrationSelection(skill.name) + : undefined + } + className={cn( + migrationEnabled && + selectedMigrationNames.has(skill.name) && + "border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20", + )} + /> ))}
)} - {!isLoading && !error && totalPages > 1 && ( + {!isLoading && !error && totalPages > 1 && (
-
- )} - +
+ )} + {migrationEnabled && ( + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleReadMigration(file); + }} + /> + )} + + + + 识别技能目录并导入技能信息 + + 只同步技能归属、发布/上下线状态和标签,不覆盖、移动或删除任何目录文件。 + + + + {migrationRoots && ( +
+

目标机器运行目录

+
+ 技能目录 + {migrationRoots.skills_root} + {migrationRoots.extensions_config_path && ( + <> + 状态配置 + + {migrationRoots.extensions_config_path} + + + )} +
+

+ 请先完整复制清单中的技能及支持目录,再执行导入。 +

+
+ )} + + {migrationManifest && migrationPreflight && ( +
+
+ + + resource.status === "matched", + ).length + } + tone="success" + /> + + 0 + ? "danger" + : "success" + } + /> +
+ + {migrationManifest.warnings.length > 0 && ( +
+

源机器导出提醒

+
    + {migrationManifest.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {migrationProblems.length > 0 && ( +
+

+ {skipInvalidSkills + ? "以下异常关联的技能将在导入时跳过" + : "目录预检未通过,可修正目录或跳过异常技能"} +

+
    + {migrationProblems.slice(0, 50).map((item) => ( + + ))} +
+ {migrationProblems.length > 50 && ( +

+ 另有 {migrationProblems.length - 50} 项未展示。 +

+ )} + {migrationProblemsCanBeSkipped && skipAnalysis && ( + + )} + {!migrationProblemsCanBeSkipped && ( +

+ 当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。 +

+ )} +
+ )} + + {unresolvedMigrationOwners.length > 0 && ( +
+

+ 有 {unresolvedMigrationOwners.length} 个源创建者无法按邮箱匹配 +

+
+ {unresolvedMigrationOwners.slice(0, 10).map((owner) => ( +

+ {owner.email || owner.source_user_id || "未知用户"} +

+ ))} +
+ +
+ )} + + {migrationProblems.length === 0 && + unresolvedMigrationOwners.length === 0 && ( +
+ +
+

预检通过,可以导入技能信息

+

+ 执行时服务器还会再次校验目录指纹。 +

+
+
+ )} + {skipInvalidSkills && + migrationProblemsCanBeSkipped && + unresolvedMigrationOwners.length === 0 && + skipAnalysis && ( +
+ +
+

可以跳过异常技能后继续导入

+

+ 服务器执行时会再次校验,并返回实际导入与跳过数量。 +

+
+
+ )} +
+ )} + + + + + +
+
+ setInstallOpen(false)} isAdmin={isAdmin} @@ -678,15 +1157,69 @@ function SkillSettingsList({ skills={archivedVisible} />
- ); -} - -export function SkillRow({ + ); +} + +function SkillMigrationCount({ + label, + value, + tone = "default", +}: { + label: string; + value: number; + tone?: "default" | "success" | "danger"; +}) { + const valueClass = + tone === "success" + ? "text-emerald-600 dark:text-emerald-400" + : tone === "danger" + ? "text-destructive" + : "text-foreground"; + return ( +
+

{label}

+

{value}

+
+ ); +} + +function SkillMigrationProblem({ + item, +}: { + item: MigrationResourcePreflight; +}) { + const statusLabels: Record = { + matched: "已匹配", + missing: "目录或关键文件缺失", + hash_mismatch: "目录内容不一致", + identity_mismatch: "ID/名称不一致", + invalid_path: "目录路径不安全", + }; + return ( +
  • +
    + {item.key} + {statusLabels[item.status]} +
    + + {item.relative_dir} + + {item.message && ( +

    {item.message}

    + )} +
  • + ); +} + +export function SkillRow({ skill, currentUserId, isAdmin, availabilityControl, - layout = "row", + className, + selected = false, + onSelectionToggle, + layout = "row", onPublisherClick, knowledgeBindings = [], }: { @@ -695,6 +1228,11 @@ export function SkillRow({ isAdmin: boolean; /** Which permission boundary exposes the global online/offline control. */ availabilityControl?: SkillAvailabilityControl; + /** Optional visual state supplied by management surfaces (for example selection). */ + className?: string; + /** When provided, clicking the row/card selects it instead of opening details. */ + selected?: boolean; + onSelectionToggle?: () => void; /** "row" = full-width horizontal item (default); "card" = grid card cell. */ layout?: "row" | "card"; /** Click the 发布者 line to filter the list to that publisher. Receives the @@ -1053,8 +1591,19 @@ export function SkillRow({ ); - const actionButtons = ( - <> + const actionButtons = ( + <> + {onSelectionToggle && ( + + )} {canToggleAvailability && ( + + + )} + {selectedIds.size > 0 && ( + + )} + + + ) : ( +

    + 共 {total} 个智能体。 + {isFetching ? " 正在加载…" : ""} +

    + )} +
    + {agents.map((agent) => { + const card = ( + + handleToggleHidden(agent.id, agent.name, next) + } + toggleHiddenPending={setHidden.isPending} + /> + ); + if (!migrationEnabled) { + return
    {card}
    ; + } + const selected = selectedIds.has(agent.id); + return ( +
    { + const target = event.target as Element; + if ( + target.closest( + "button, a, input, textarea, select, [role='button'], [role='switch'], [role='checkbox']", + ) + ) { + return; + } + toggleAgentSelection(agent.id); + }} + onKeyDown={(event) => { + if ( + event.target === event.currentTarget && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + toggleAgentSelection(agent.id); + } + }} + > + {card} +
    + ); + })}
    + + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleImportFile(file); + }} + /> + + + + + 识别智能体目录并同步信息 + + 只同步智能体归属、发布状态、标签和技能名称关联,不写入技能信息,也不覆盖、移动或删除目录文件。 + + + + {migrationRoots && ( +
    +

    目标机器运行目录

    +
    + 智能体目录 + {migrationRoots.agents_root} +
    +

    + 请先完整复制清单列出的相对目录,再执行同步;目标中未列入清单的目录不会被处理。 +

    +
    + )} + + {importManifest && importPreflight && ( +
    +
    + + agent.skills)) + .size + } + /> + item.status === "matched") + .length + } + tone="success" + /> + 0 + ? "danger" + : "success" + } + /> +
    + + {importManifest.warnings.length > 0 && ( +
    +

    源机器导出提醒

    +
      + {importManifest.warnings.map((warning) => ( +
    • {warning}
    • + ))} +
    +
    + )} + + {importProblems.length > 0 && ( +
    +

    + 目录预检未通过,请修正后重新选择清单 +

    +
      + {importProblems.slice(0, 50).map((item) => ( + + ))} +
    + {importProblems.length > 50 && ( +

    + 另有 {importProblems.length - 50} 项未展示。 +

    + )} +
    + )} + + {unresolvedOwners.length > 0 && ( +
    +

    + 有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配 +

    +
    + {unresolvedOwners.slice(0, 10).map((owner) => ( +

    + {owner.email || owner.source_user_id || "未知用户"} +

    + ))} +
    + +
    + )} + + {importProblems.length === 0 && unresolvedOwners.length === 0 && ( +
    + +
    +

    预检通过,可以同步元数据

    +

    + 执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。 +

    +
    +
    + )} +
    + )} + + + + + +
    +
    ); } + +function MigrationCount({ + label, + value, + tone = "default", +}: { + label: string; + value: number; + tone?: "default" | "success" | "danger"; +}) { + const valueClass = + tone === "success" + ? "text-emerald-600 dark:text-emerald-400" + : tone === "danger" + ? "text-destructive" + : "text-foreground"; + return ( +
    +

    {label}

    +

    {value}

    +
    + ); +} + +function MigrationProblem({ item }: { item: MigrationResourcePreflight }) { + const statusLabels: Record = { + matched: "已匹配", + missing: "目录或关键文件缺失", + hash_mismatch: "目录内容不一致", + identity_mismatch: "ID/名称不一致", + invalid_path: "目录路径不安全", + }; + return ( +
  • +
    + {item.key} + {statusLabels[item.status]} +
    + + {item.relative_dir} + + {item.message && ( +

    {item.message}

    + )} +
  • + ); +} diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx index ff8e668a..183cc36c 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -1,16 +1,31 @@ "use client"; import { + CheckCircle2, ChevronLeftIcon, ChevronRightIcon, + DownloadIcon, + Loader2Icon, Replace, SearchIcon, SparklesIcon, + UploadIcon, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -19,20 +34,53 @@ import { WorkspaceHeader, } from "@/components/workspace/workspace-container"; import { SkillRow } from "@/components/workspace/settings/skill-settings-page"; +import { + analyzeSkillMigrationSkip, + applyMigrationManifest, + exportSkillMigrationManifest, + getMigrationRoots, + preflightMigrationManifest, +} from "@/core/agent-migrations"; +import type { + AgentSkillMigrationManifest, + MigrationPreflight, + MigrationResourcePreflight, + MigrationRoots, +} from "@/core/agent-migrations"; import { getStoredAuth } from "@/core/auth"; import { useAllSkillsAdmin } from "@/core/skills/hooks"; import { skillMatchesSearch } from "@/core/skills/search"; +import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks"; +import { cn } from "@/lib/utils"; import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge"; import { SkillAddressesPanel } from "./SkillAddressesPage"; const PAGE_SIZE = 10; export default function AdminSkillsPage() { + const queryClient = useQueryClient(); + const { data: migrationSettings } = useAgentSkillMigrationSettings(); + const migrationEnabled = migrationSettings?.enabled ?? false; const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const { skills, isLoading, error } = useAllSkillsAdmin(true); const knowledgeBindings = useSkillKnowledgeBindings(); + const [selectedNames, setSelectedNames] = useState>(new Set()); + const [exporting, setExporting] = useState(false); + const [readingImport, setReadingImport] = useState(false); + const [applyingImport, setApplyingImport] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importManifest, setImportManifest] = + useState(null); + const [importPreflight, setImportPreflight] = + useState(null); + const [migrationRoots, setMigrationRoots] = useState( + null, + ); + const [useCurrentAdmin, setUseCurrentAdmin] = useState(false); + const [skipInvalidSkills, setSkipInvalidSkills] = useState(false); + const importInputRef = useRef(null); const activeTab = useMemo(() => { const tab = searchParams.get("tab"); return tab === "addresses" ? tab : "skills"; @@ -68,6 +116,147 @@ export default function AdminSkillsPage() { () => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filteredSkills, page], ); + const allFilteredSelected = + filteredSkills.length > 0 && + filteredSkills.every((skill) => selectedNames.has(skill.name)); + + const importResources = useMemo( + () => + importPreflight + ? [ + ...importPreflight.agents, + ...importPreflight.skills, + ...importPreflight.support_directories, + ] + : [], + [importPreflight], + ); + const importProblems = importResources.filter( + (item) => item.status !== "matched", + ); + const strictUnresolvedOwners = + importPreflight?.owner_mappings.filter( + (owner) => owner.status === "unresolved", + ) ?? []; + const skipAnalysis = + importManifest && importPreflight + ? analyzeSkillMigrationSkip(importManifest, importPreflight) + : null; + const unresolvedOwners = skipInvalidSkills + ? (skipAnalysis?.unresolvedOwners ?? []) + : strictUnresolvedOwners; + const problemsCanBeSkipped = + importProblems.length > 0 && + !!skipAnalysis && + skipAnalysis.skippedSkillNames.length > 0 && + skipAnalysis.remainingSkillCount > 0 && + skipAnalysis.unskippableProblemCount === 0; + const canApplyImport = + !!importManifest && + !!importPreflight && + (importProblems.length === 0 || + (skipInvalidSkills && problemsCanBeSkipped)) && + (unresolvedOwners.length === 0 || useCurrentAdmin); + + function toggleSkillSelection(name: string) { + setSelectedNames((current) => { + const next = new Set(current); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + async function handleExportManifest() { + if (selectedNames.size === 0) return; + setExporting(true); + try { + const manifest = await exportSkillMigrationManifest([...selectedNames]); + const blob = new Blob([JSON.stringify(manifest, null, 2)], { + type: "application/json;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `skill-sync-${manifest.bundle_id.slice(0, 8)}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + const warningText = manifest.warnings.length + ? `;另有 ${manifest.warnings.length} 条提醒,请查看清单 warnings` + : ""; + toast.success( + `已导出 ${manifest.skills.length} 个技能的迁移清单${warningText}`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setExporting(false); + } + } + + async function handleImportFile(file: File) { + if (file.size > 20 * 1024 * 1024) { + toast.error("技能迁移清单不能超过 20 MB"); + return; + } + setReadingImport(true); + setUseCurrentAdmin(false); + setSkipInvalidSkills(false); + try { + const parsed = JSON.parse(await file.text()) as AgentSkillMigrationManifest; + if ( + parsed.schema_version !== 1 || + !Array.isArray(parsed.agents) || + !Array.isArray(parsed.skills) || + !Array.isArray(parsed.support_directories) + ) { + throw new Error("不是受支持的技能迁移清单"); + } + if (parsed.skills.length === 0 || parsed.agents.length > 0) { + throw new Error("请选择从技能管理导出的清单;智能体清单请到智能体管理导入"); + } + const [preflight, roots] = await Promise.all([ + preflightMigrationManifest(parsed), + getMigrationRoots(), + ]); + setImportManifest(parsed); + setImportPreflight(preflight); + setMigrationRoots(roots); + setImportDialogOpen(true); + } catch (err) { + toast.error(err instanceof Error ? err.message : "技能迁移清单读取失败"); + } finally { + setReadingImport(false); + } + } + + async function handleApplyImport() { + if (!importManifest || !canApplyImport) return; + setApplyingImport(true); + try { + const result = await applyMigrationManifest( + importManifest, + useCurrentAdmin, + skipInvalidSkills, + ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["skills"] }), + queryClient.invalidateQueries({ queryKey: ["tags"] }), + ]); + toast.success( + `同步完成:导入 ${result.skills_applied} 个技能${result.skills_skipped ? `,跳过 ${result.skills_skipped} 个` : ""}`, + ); + setImportDialogOpen(false); + setImportManifest(null); + setImportPreflight(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setApplyingImport(false); + } + } return ( @@ -98,18 +287,58 @@ export default function AdminSkillsPage() { value="skills" className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden" > -
    -

    - 管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。 -

    -
    - - setSearch(e.target.value)} - placeholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔" - className="pl-8" - /> +
    +
    +

    + 管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。 +

    + {migrationEnabled && ( +

    + 技能清单独立保存技能归属、发布/上下线状态和标签,不包含目录文件;请先把技能目录复制到目标机器运行目录。 +

    + )} +
    +
    + {migrationEnabled && ( + <> + + + + )} +
    + + setSearch(e.target.value)} + placeholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔" + className="pl-8" + /> +
    @@ -136,9 +365,49 @@ export default function AdminSkillsPage() { )} {!isLoading && !error && filteredSkills.length > 0 && ( <> -

    - 共 {filteredSkills.length} 个技能。 -

    + {migrationEnabled ? ( +
    +

    + 点击技能行选择 · 共 {filteredSkills.length} 个技能 + {selectedNames.size > 0 + ? ` · 已选择 ${selectedNames.size} 个` + : ""} +

    +
    + + {selectedNames.size > 0 && ( + + )} +
    +
    + ) : ( +

    + 共 {filteredSkills.length} 个技能。 +

    + )} {pagedSkills.map((skill) => ( toggleSkillSelection(skill.name) + : undefined + } + className={cn( + migrationEnabled && + selectedNames.has(skill.name) && + "border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20", + )} knowledgeBindings={(knowledgeBindings.data ?? []).filter( (binding) => binding.skill_name === skill.name, )} @@ -189,6 +471,256 @@ export default function AdminSkillsPage() { + + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleImportFile(file); + }} + /> + + + + + 识别技能目录并导入技能信息 + + 只同步清单中的归属、发布/上下线状态、标签和缓存,不覆盖、移动或删除任何技能目录文件。 + + + + {migrationRoots && ( +
    +

    目标机器运行目录

    +
    + 技能目录 + {migrationRoots.skills_root} + {migrationRoots.extensions_config_path && ( + <> + 状态配置 + + {migrationRoots.extensions_config_path} + + + )} +
    +

    + 请先完整复制清单列出的相对目录,再执行导入;目标中未列入清单的技能不会被处理。 +

    +
    + )} + + {importManifest && importPreflight && ( +
    +
    + + + item.status === "matched") + .length + } + tone="success" + /> + 0 + ? "danger" + : "success" + } + /> +
    + + {importManifest.warnings.length > 0 && ( +
    +

    源机器导出提醒

    +
      + {importManifest.warnings.map((warning) => ( +
    • {warning}
    • + ))} +
    +
    + )} + + {importProblems.length > 0 && ( +
    +

    + {skipInvalidSkills + ? "以下异常关联的技能将在导入时跳过" + : "目录预检未通过,可修正目录或跳过异常技能"} +

    +
      + {importProblems.slice(0, 50).map((item) => ( + + ))} +
    + {importProblems.length > 50 && ( +

    + 另有 {importProblems.length - 50} 项未展示。 +

    + )} + {problemsCanBeSkipped && skipAnalysis && ( + + )} + {!problemsCanBeSkipped && ( +

    + 当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。 +

    + )} +
    + )} + + {unresolvedOwners.length > 0 && ( +
    +

    + 有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配 +

    +
    + {unresolvedOwners.slice(0, 10).map((owner) => ( +

    + {owner.email || owner.source_user_id || "未知用户"} +

    + ))} +
    + +
    + )} + + {importProblems.length === 0 && unresolvedOwners.length === 0 && ( +
    + +
    +

    预检通过,可以导入技能信息

    +

    + 执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。 +

    +
    +
    + )} + {skipInvalidSkills && + problemsCanBeSkipped && + unresolvedOwners.length === 0 && + skipAnalysis && ( +
    + +
    +

    可以跳过异常技能后继续导入

    +

    + 服务器执行时会再次校验,并返回实际导入与跳过数量。 +

    +
    +
    + )} +
    + )} + + + + + +
    +
    ); } + +function MigrationCount({ + label, + value, + tone = "default", +}: { + label: string; + value: number; + tone?: "default" | "success" | "danger"; +}) { + const valueClass = + tone === "success" + ? "text-emerald-600 dark:text-emerald-400" + : tone === "danger" + ? "text-destructive" + : "text-foreground"; + return ( +
    +

    {label}

    +

    {value}

    +
    + ); +} + +function MigrationProblem({ item }: { item: MigrationResourcePreflight }) { + const statusLabels: Record = { + matched: "已匹配", + missing: "目录或关键文件缺失", + hash_mismatch: "目录内容不一致", + identity_mismatch: "ID/名称不一致", + invalid_path: "目录路径不安全", + }; + return ( +
  • +
    + {item.key} + {statusLabels[item.status]} +
    + + {item.relative_dir} + + {item.message && ( +

    {item.message}

    + )} +
  • + ); +} diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index a498a177..9a23525b 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -54,7 +54,7 @@ import type { AdminLeaderboardUser, } from "@/core/admin/api"; import { streamUserLeaderboardReport } from "@/core/admin/api"; -import { useRefreshTodayLeaderboard, useUserLeaderboard } from "@/core/admin/hooks"; +import { useRefreshTodayLeaderboard, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks"; import { ADMIN_QUERY_WINDOW_DAYS, formatLocalDateInput, diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index c5e1e9c7..683aca8c 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -31,8 +31,6 @@ import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { listThreadArtifacts } from "@/core/artifacts/listing"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; -import { ConsumerMaterialsExportTrigger } from "@/components/workspace/consumer-materials-export-trigger"; -import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector"; import { MessageList } from "@/components/workspace/messages"; @@ -45,7 +43,10 @@ import { Tooltip } from "@/components/workspace/tooltip"; import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { getAPIClient } from "@/core/api"; import { getStoredAuth } from "@/core/auth"; -import { useTheme } from "@/contexts/ThemeContext"; +import { + getPresentationThemeContext, + useTheme, +} from "@/contexts/ThemeContext"; import { useI18n } from "@/core/i18n/hooks"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { @@ -137,8 +138,6 @@ import type { Message } from "@langchain/langgraph-sdk"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; -/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */ -const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; @@ -1166,16 +1165,13 @@ export default function AgentChatPage({ settings.context.writing_mode && writingArtifactPath ? { writing_artifact_path: writingArtifactPath } : {}; - const pageReportThemeContext = - agent_id === PAGE_REPORT_DESIGNER_AGENT_ID - ? { presentation_theme: baseMode } - : {}; + const pagePresentationThemeContext = getPresentationThemeContext(baseMode); void sendMessage(threadId, message, { ...writingExtraContext, ...(shouldApplyPrefix ? { prompt_prefix_enabled: true } : {}), ...(extraContext ?? {}), agent_id, - ...pageReportThemeContext, + ...pagePresentationThemeContext, }); }, [ @@ -1338,9 +1334,7 @@ export default function AgentChatPage({ await stopActiveThreadBeforeClarificationReply(threadId, thread); await sendMessage(threadId, { text: answer, files: [] }, { agent_id, - ...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID - ? { presentation_theme: baseMode } - : {}), + ...getPresentationThemeContext(baseMode), }); }, [sendMessage, thread, threadId, agent_id, baseMode], @@ -1608,8 +1602,6 @@ export default function AgentChatPage({ {t.agents.newChat} - -
    diff --git a/frontend-web/src/pages/BusinessChainMappingPage.tsx b/frontend-web/src/pages/BusinessChainMappingPage.tsx index 862c35bb..e102365a 100644 --- a/frontend-web/src/pages/BusinessChainMappingPage.tsx +++ b/frontend-web/src/pages/BusinessChainMappingPage.tsx @@ -48,17 +48,10 @@ import { type MappingTypeFilter = "all" | "builtin" | "custom"; -/** 配置管理页默认不展示的独立槽位;后端仍保留,goPath 可继续解析。 */ -const HIDDEN_MAPPING_CODES = new Set(["3q-1", "3q-2", "3q-3", "3q-4", "3q-5", "3q-6"]); - function mappingTypeLabel(row: BusinessMapping) { return row.isCustom ? "自定义类型" : "内置"; } -function isHiddenMapping(row: BusinessMapping) { - return HIDDEN_MAPPING_CODES.has(row.businessCode); -} - export default function BusinessChainMappingPage() { const navigate = useRouteNavigate(); const { theme } = useTheme(); @@ -78,7 +71,7 @@ export default function BusinessChainMappingPage() { const [saving, setSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all"); - const [typeFilter, setTypeFilter] = useState("custom"); + const [typeFilter, setTypeFilter] = useState("all"); const [createOpen, setCreateOpen] = useState(false); const [customName, setCustomName] = useState(""); const [creating, setCreating] = useState(false); @@ -114,16 +107,11 @@ export default function BusinessChainMappingPage() { [chains], ); - const visibleRows = useMemo( - () => rows.filter((row) => !isHiddenMapping(row)), - [rows], - ); - const stats = useMemo(() => { - const total = visibleRows.length; - const boundAgents = visibleRows.filter((r) => r.agentId !== null).length; + const total = rows.length; + const boundAgents = rows.filter((r) => r.agentId !== null).length; return { total, boundAgents }; - }, [visibleRows]); + }, [rows]); const typeOptions: { label: string; value: MappingTypeFilter }[] = [ { label: "全部类型", value: "all" }, @@ -132,7 +120,7 @@ export default function BusinessChainMappingPage() { ]; const filteredRows = useMemo(() => { - return visibleRows.filter((r) => { + return rows.filter((r) => { const q = searchQuery.toLowerCase(); const matchesSearch = r.label.toLowerCase().includes(q) || @@ -146,7 +134,7 @@ export default function BusinessChainMappingPage() { if (statusFilter === "pending") return matchesSearch && matchesType && !isConfigured; return matchesSearch && matchesType; }); - }, [visibleRows, searchQuery, statusFilter, typeFilter]); + }, [rows, searchQuery, statusFilter, typeFilter]); const beginEdit = (row: BusinessMapping) => { setEditingCode(row.businessCode); @@ -640,7 +628,7 @@ export default function BusinessChainMappingPage() {

    未找到符合筛选条件的业务配置

    @@ -686,7 +674,7 @@ export default function BusinessChainMappingPage() {

    1. 点击右上角「新增自定义配置」,填写名称。新增项自动归入「自定义类型」;名称就是登录链接的 goPath:goPath=名称 进入单智能体,goPath=名称-A 进入已绑定业务链。
    - 2. 打开页面默认展示「自定义类型」;可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。 + 2. 可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。
    3. 将鼠标悬停在任一业务行右侧,点击出现的{" "} 「编辑」。 diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 0ff0cde4..5c631f3b 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -20,8 +20,6 @@ import { useSpecificChatMode, useThreadChat, } from "@/components/workspace/chats"; -import { ConsumerMaterialsExportTrigger } from "@/components/workspace/consumer-materials-export-trigger"; -import { ExportTrigger } from "@/components/workspace/export-trigger"; import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger"; import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector"; import { InputBox } from "@/components/workspace/input-box"; @@ -108,6 +106,10 @@ import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from " import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag"; import { useRouteBase } from "@/core/route-base"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; +import { + getPresentationThemeContext, + useTheme, +} from "@/contexts/ThemeContext"; import { textOfMessage } from "@/core/threads/utils"; import { OptType, writeYLog } from "@/core/log/y-log"; import { env } from "@/env"; @@ -156,6 +158,7 @@ function clearBrowserContextParamsFromHash() { export default function ChatPage() { const routeBase = useRouteBase(); + const { baseMode } = useTheme(); const router = useRouter(); const navigate = useNavigate(); const searchParams = useSearchParams(); @@ -792,7 +795,9 @@ export default function ChatPage() { // context; the backend tags the message and PromptPrefixMiddleware // injects the prefix into the LLM request only. Writing mode adds the // active markdown artifact path so the agent keeps editing the canvas. - const extraContext: Record = {}; + const extraContext: Record = { + ...getPresentationThemeContext(baseMode), + }; if (shouldApplyPrefix) { extraContext.prompt_prefix_enabled = true; } @@ -843,6 +848,7 @@ export default function ChatPage() { routeBase, setIsNewThread, setThreadId, + baseMode, ], ); @@ -850,9 +856,13 @@ export default function ChatPage() { async (answer: string) => { setHasStartedConversation(true); await stopActiveThreadBeforeClarificationReply(threadId, thread); - await sendMessage(threadId, { text: answer, files: [] }); + await sendMessage( + threadId, + { text: answer, files: [] }, + getPresentationThemeContext(baseMode), + ); }, - [sendMessage, thread, threadId], + [sendMessage, thread, threadId, baseMode], ); useEffect(() => { @@ -901,7 +911,7 @@ export default function ChatPage() { void sendMessage( threadId, { text: prompt, files: [] }, - undefined, + getPresentationThemeContext(baseMode), { additionalKwargs: { browser_context: { @@ -934,6 +944,7 @@ export default function ChatPage() { knowledgeRetrievalMode, knowledgeRetrievalSettingsLoading, sendMessage, + baseMode, ]); // Persist the picked agent the moment it changes so the next new chat @@ -1139,8 +1150,6 @@ export default function ChatPage() { 新建对话 )} - - {/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧, diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index 4f2e5238..02afe00d 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -5,7 +5,7 @@ import { ChevronRight, Compass, Clock, - DownloadIcon, + Download, FileJson, FileText, MessageSquare, @@ -17,6 +17,7 @@ import { Share2, Star, Trash2, + Upload, Users2, type LucideIcon, X, @@ -29,6 +30,10 @@ import { Button as TButton, Dialog } from "tdesign-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + ChatHistoryExportDialog, + ChatHistoryImportDialog, +} from "@/components/workspace/chat-history-transfer-dialog"; import { ImportConversationsDialog, ShareConversationDialog, @@ -50,10 +55,16 @@ import { import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; import { useAppSystemName } from "@/core/system-settings/hooks"; +import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks"; import { exportThreadAsJSON, exportThreadAsMarkdown, } from "@/core/threads/export"; +import { + downloadTransferBundle, + exportSelectedThreads, + skipReasonLabel, +} from "@/core/threads/transfer"; import { useClearUserChatThreads, useDeleteThread, @@ -171,6 +182,11 @@ export default function ChatsPage({ const [chatFilter, setChatFilter] = useState("all"); const [page, setPage] = useState(1); const [importOpen, setImportOpen] = useState(false); + const [exportRecordsOpen, setExportRecordsOpen] = useState(false); + const [importRecordsOpen, setImportRecordsOpen] = useState(false); + const [exportingThreadId, setExportingThreadId] = useState(null); + const { data: migrationSettings } = useAgentSkillMigrationSettings(); + const transferEnabled = migrationSettings?.enabled === true; // 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。 const [tabTotal, setTabTotal] = useState(null); // 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。 @@ -268,6 +284,42 @@ export default function ChatsPage({ [t], ); + const handleExportThreadRecord = useCallback( + async (thread: AgentThread) => { + if (exportingThreadId) return; + setExportingThreadId(thread.thread_id); + try { + const result = await exportSelectedThreads([thread.thread_id]); + if (result.threads.length === 0) { + const reason = result.skipped[0]?.reason; + toast.error( + reason + ? `这条对话无法导出:${skipReasonLabel(reason)}` + : "这条对话无法导出", + ); + return; + } + const title = titleOfThread(thread, t.pages.newChat); + downloadTransferBundle( + result.threads, + result.skipped, + title.replace(/[^\p{L}\p{N}_\- ]/gu, "").trim() || "conversation", + ); + const dropped = result.threads[0]?.skipped_files?.length ?? 0; + toast.success( + dropped > 0 + ? `已导出这条对话,已跳过 ${dropped} 个过大附件` + : "已导出这条对话(含附件)", + ); + } catch { + toast.error("导出失败"); + } finally { + setExportingThreadId(null); + } + }, + [exportingThreadId, t.pages.newChat], + ); + // Conversations are paginated on the backend: each page is one bounded // query (title search + favorites filter included), so a long history is // never fetched in one go. Pinned conversations come from their own small @@ -435,6 +487,19 @@ export default function ChatsPage({ ) : (

    + {transferEnabled && ( + + )} + {activeTab === "chats" && transferEnabled && ( +
    + + +
    + )}
    - {activeTab === "chats" && ( - - )}
    @@ -683,6 +757,14 @@ export default function ChatsPage({ 清空 )} + {pinnedThreadsCount != null && pinnedThreadsCount > 0 && ( + ) : registering ? ( + 已有账号,返回登录 + ) : !isBusy && loginPolicy.password_login_enabled && loginPolicy.registration_enabled ? ( + 还没有账号?注册账号 ) : null} {!registering && loginPolicy.sso_enabled && canUseForm && (

    支持外部系统免登,可从原系统的统一登录入口进入。

    diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index c662cb5e..df0c4b92 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -144,12 +144,12 @@ function PageLayout() { className={`flex-1 overflow-hidden ${ !pageSidebarAllowed ? "" - : isDark - // 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起时主内容不留左内边距。 - ? (collapsed ? "pl-0" : "pl-[118px]") - : lightNarrowSidebar - // 浅色新版窄栏:展开三级列时 96→236px,主内容左内边距随之过渡 - ? `transition-[padding] duration-300 ${lightExpanded ? "pl-[236px]" : "pl-[96px]"}` + : isDark + // 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起时主内容不留左内边距。 + ? (collapsed ? "pl-0" : "pl-[118px]") + : lightNarrowSidebar + // 浅色新版窄栏:展开三级列时 96→236px,收起时不渲染侧栏、主内容不留内边距。 + ? `transition-[padding] duration-300 ${collapsed ? "pl-0" : lightExpanded ? "pl-[236px]" : "pl-[96px]"}` // 浅色旧版手风琴(wujie 嵌入):pl-16 ↔ pl-64 平滑收展 : `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}` }`} diff --git a/frontend-web/src/pages/RegisterPage.tsx b/frontend-web/src/pages/RegisterPage.tsx new file mode 100644 index 00000000..745219e6 --- /dev/null +++ b/frontend-web/src/pages/RegisterPage.tsx @@ -0,0 +1,6 @@ +import LoginPage from "./LoginPage"; + +/** Reuse the login completion path so registration also retains task deep links. */ +export default function RegisterPage() { + return ; +} diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index c7d0e783..0ec2ec4f 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -616,6 +616,20 @@ export async function streamMultiAgent( if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); + if (res.status === 409) { + let activeCount = 0; + let maxRooms = 0; + try { + const body = JSON.parse(text) as { + detail?: { message?: string; active_count?: number; max_rooms?: number }; + }; + activeCount = body.detail?.active_count ?? 0; + maxRooms = body.detail?.max_rooms ?? 0; + } catch { + // 非 JSON 响应不影响对用户展示的统一提示。 + } + throw new RoundtableRoomFullError(activeCount, maxRooms); + } // 用户实际遇到的报错(含进第三步常见的 502):HTTP 层就失败、SSE 都没建起来。 reportRoundtableError({ stage: stageForRoundtableAgent(req.agentType, req.agentName), diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 98ce1d74..0c4659e1 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -20,6 +20,7 @@ import { artifactPreviewKey, useArtifacts, } from "@/components/workspace/artifacts"; +import { isRoundtableRoomFullError } from "@/roundtable-planning/api/rooms"; import { isRoundtableRoomFullError, type RoundtableRoomFullError, diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 97b74a90..fa40de89 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1284,6 +1284,12 @@ export default function RoundtablePlanningPage({ roomSessionId: roomSessionIdRef.current, }; + roomScopeRef.current = { + draftId: drafts.currentDraftId, + taskId: draftStorageTaskId ?? null, + roomSessionId: roomSessionIdRef.current, + }; + // ─── 角色面板转场 ──────────────────────────────────────── // Step 1: 仅指挥员 + 任务理解智能体两行(状态跟随 intent init)。 // Step 2: 完整席位(loadedFromDraft 时直接 "在线",否则 "正在上线")。 @@ -2368,6 +2374,7 @@ export default function RoundtablePlanningPage({ }, [ currentStep, step2.hasConsensus, + step2.setIsRoundTableRunning, step2.stopOrchestration, triggerToast, ]); diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx index 8e63c4de..8c89ef0a 100644 --- a/frontend-web/src/strategy-components/Header.tsx +++ b/frontend-web/src/strategy-components/Header.tsx @@ -20,7 +20,7 @@ import { getStoredAuth } from "@/core/auth"; import { useLiveAccount } from "@/core/auth/use-live-account"; import { AppBrandImage } from "@/core/branding/app-brand-image"; 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 { @@ -55,6 +55,7 @@ type HeaderProps = { const Header = ({ collapsed }: HeaderProps) => { const { theme, baseMode } = useTheme(); const isDark = baseMode === "dark"; + const isLightRedTopNav = isLightRedTopNavEnabled(theme); const isCustomTheme = theme.startsWith("custom-theme-"); const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow"; const headerTone = isCustomTheme @@ -69,9 +70,9 @@ const Header = ({ collapsed }: HeaderProps) => { : isPaleLightTheme ? "text-[var(--foreground)]" : "text-white"; - const darkLogoPosition = + const topNavLogoPosition = readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center"; - const isDarkLogoOnLeft = darkLogoPosition === "left"; + const isTopNavLogoOnLeft = topNavLogoPosition === "left"; const systemName = useAppSystemName("CM业务中心"); const [notificationOpen, setNotificationOpen] = useState(false); const { data: unreadCount = 0 } = useUnreadCount(); @@ -86,12 +87,15 @@ const Header = ({ collapsed }: HeaderProps) => { 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(); const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname); @@ -272,19 +276,27 @@ const Header = ({ collapsed }: HeaderProps) => { open={notificationOpen} onOpenChange={setNotificationOpen} /> -
    +
    {showTopNav ? ( <> {/* 左分区(flex-1):菜单靠右贴近中间基座 */}
    - {isDarkLogoOnLeft && ( + {isTopNavLogoOnLeft && (
    { {/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
    - {darkLogoPosition === "center" && ( + {topNavLogoPosition === "center" && ( { )}
    - {/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */} - {showTopNav && thirdSubNavItems.length ? ( + {/* 浅红主题保留原浅色侧栏三级列;顶部三级横栏仍只属于深色主题。 */} + {showThirdSubNav && thirdSubNavItems.length ? ( ", re.IGNORECASE) +_HEADING_RE = re.compile(r"]*>([\s\S]*?)", re.IGNORECASE) +_TAG_RE = re.compile(r"<[^>]+>") +_NON_CONTENT_RE = re.compile( + r"]*>[\s\S]*?|]*>[\s\S]*?|", + re.IGNORECASE, +) +_BRAND_GROUP_RE = re.compile( + r"<(?P[a-z][\w:-]*)\b(?=[^>]*\bdata-df-brand(?:\s|=|>))[^>]*>" + r"[\s\S]*?", + re.IGNORECASE, +) +_BRAND_SINGLE_RE = re.compile( + r"<[a-z][\w:-]*\b(?=[^>]*\bdata-df-brand(?:\s|=|>))[^>]*/\s*>", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class _LogicalPage: + start: int + end: int + opening_tag: str + page_id: str + page_key: str + label: str + + +def _attribute(tag: str, name: str) -> str | None: + match = re.search( + rf"(? list[str]: + return (_attribute(tag, "class") or "").split() + + +def _set_attribute(tag: str, name: str, value: str) -> str: + escaped = html_lib.escape(value, quote=True) + pattern = re.compile( + rf"(?" + + +def _remove_attribute(tag: str, name: str) -> str: + return re.sub( + rf"\s+(?]+))?", + "", + tag, + flags=re.IGNORECASE, + ) + + +def _safe_page_suffix(value: str, index: int) -> str: + suffix = re.sub(r"[^a-z0-9_-]+", "-", value.strip().lower()).strip("-_") + return suffix or str(index + 1) + + +def _plain_text(markup: str) -> str: + clean = _NON_CONTENT_RE.sub(" ", markup) + clean = _TAG_RE.sub(" ", clean) + return re.sub(r"\s+", " ", html_lib.unescape(clean)).strip() + + +def _short_label(value: str, index: int) -> str: + normalized = re.sub(r"\s+", " ", value).strip() or f"第 {index + 1} 页" + return normalized if len(normalized) <= 18 else normalized[:17].rstrip() + "…" + + +def _logical_pages(html: str) -> list[_LogicalPage]: + candidates: list[re.Match[str]] = [] + for match in _OPEN_ELEMENT_RE.finditer(html): + tag = match.group(0) + if "page" not in _classes(tag) or _attribute(tag, "data-page") is None: + continue + candidates.append(match) + + used_ids: set[str] = set() + pages: list[_LogicalPage] = [] + for index, match in enumerate(candidates): + tag = match.group(0) + page_key = _attribute(tag, "data-page") or str(index + 1) + requested_id = _attribute(tag, "id") or "" + page_id = requested_id if requested_id.startswith("page-") else f"page-{_safe_page_suffix(page_key, index)}" + base_id = page_id + serial = 2 + while page_id in used_ids: + page_id = f"{base_id}-{serial}" + serial += 1 + used_ids.add(page_id) + + next_start = candidates[index + 1].start() if index + 1 < len(candidates) else len(html) + chunk = html[match.end() : next_start] + explicit_label = _attribute(tag, "data-page-title") or _attribute(tag, "aria-label") + heading = _HEADING_RE.search(chunk) + inferred_label = _plain_text(heading.group(1)) if heading else page_key.replace("-", " ") + label = _short_label(explicit_label or inferred_label, index) + pages.append( + _LogicalPage( + start=match.start(), + end=match.end(), + opening_tag=tag, + page_id=page_id, + page_key=page_key, + label=label, + ) + ) + return pages + + +def _prepare_logical_pages(html: str, pages: list[_LogicalPage]) -> str: + replacements: list[tuple[int, int, str]] = [] + for index, page in enumerate(pages): + tag = _set_attribute(page.opening_tag, "id", page.page_id) + tag = _set_attribute(tag, "data-df-page-title", page.label) + classes = [item for item in _classes(tag) if item not in {"active", "is-active", "df-page-active"}] + if index == 0: + classes.extend(["active", "is-active", "df-page-active"]) + tag = _remove_attribute(tag, "hidden") + tag = _set_attribute(tag, "aria-hidden", "false") + else: + tag = _remove_attribute(tag, "hidden") + tag = tag[:-1].rstrip() + " hidden>" + tag = _set_attribute(tag, "aria-hidden", "true") + tag = _set_attribute(tag, "class", " ".join(dict.fromkeys(classes))) + replacements.append((page.start, page.end, tag)) + + for start, end, replacement in reversed(replacements): + html = html[:start] + replacement + html[end:] + return html + + +def _hide_replaced_page_navigation(html: str, page_ids: set[str]) -> str: + def replace(match: re.Match[str]) -> str: + attrs = match.group("attrs") + if NAV_MARKER in attrs or "data-df-replaced-page-nav" in attrs: + return match.group(0) + targets = set( + re.findall(r"\bhref\s*=\s*[\"']#(page-[^\"']+)[\"']", match.group("body"), flags=re.IGNORECASE) + ) + if len(targets & page_ids) < 2: + return match.group(0) + opening = f'{html_lib.escape(page.label)}' + ) + return ( + f'" + ) + + +def _style_markup() -> str: + runtime_base = _html_runtime_asset_url("").rstrip("/") + return f"""""" + + +def _script_markup(first_page_id: str, *, multi_page: bool) -> str: + multi_value = "true" if multi_page else "false" + return f"""""" + + +_BODY_TOP_SPACING_RE = re.compile( + r"(padding|margin)-top\s*:\s*([0-9]+(?:\.[0-9]+)?)(?:px|rem|em|vh|vw|%)", + re.IGNORECASE, +) +_BODY_TOP_SHORTHAND_RE = re.compile( + r"(padding|margin)\s*:\s*([0-9]+(?:\.[0-9]+)?)(?:px|rem|em|vh|vw|%)\s+", + re.IGNORECASE, +) +_BODY_RULE_RE = re.compile( + r"(?P^|[}>{])(?P\s*)" + r"(?P(?:html\s*,\s*)?body)\s*\{(?P[^{}]*)\}", + re.IGNORECASE, +) + + +def _normalize_body_top_spacing(html: str) -> str: + """Zero the top padding/margin on html/body so the sticky header hugs the top. + + Models routinely write ``body { padding: 48px 0 }`` to clear their own + header; with the platform header injected that leaves a colored gap above + it. Only html/body rules are touched — inner wrappers keep their spacing. + """ + + def fix_rule(match: re.Match[str]) -> str: + block = match.group("block") + fixed = _BODY_TOP_SPACING_RE.sub( + lambda m: f"{m.group(1)}-top: 0" if float(m.group(2)) > 0 else m.group(0), + block, + ) + fixed = _BODY_TOP_SHORTHAND_RE.sub(lambda m: f"{m.group(1)}: 0 ", fixed) + if fixed == block: + return match.group(0) + # The prefix is a real CSS delimiter: it may be the closing brace of + # the previous rule or the opening brace of an enclosing @media block. + # Dropping it makes responsive declarations leak into the desktop + # layout (for example the X mobile navigation becomes globally active). + return ( + f'{match.group("prefix")}{match.group("spacing")}' + f'{match.group("selector")}{{{fixed}}}' + ) + + return _BODY_RULE_RE.sub(lambda m: fix_rule(m), html) + + +def _hide_authored_top_navigation(html: str) -> str: + """Hide model-authored top navs that clash with the injected header. + + Only ``nav``/``header`` blocks located BEFORE the first real content + container (main/div/section/h1) are treated as top bars; navigation + nested inside the content stays untouched. + """ + + body_open = re.search(r"]*>", html, flags=re.IGNORECASE) + body_close = re.search(r"", html, flags=re.IGNORECASE) + if body_open is None or body_close is None: + return html + head = html[: body_open.end()] + body = html[body_open.end() : body_close.start()] + tail = html[body_close.start() :] + + content_start = re.search(r"<(?:main|div|section|h1)\b", body, flags=re.IGNORECASE) + top_zone_end = content_start.start() if content_start else min(len(body), 1500) + top_zone = body[:top_zone_end] + rest = body[top_zone_end:] + + def hide(match: re.Match[str]) -> str: + tag, attrs, inner = match.group("tag"), match.group("attrs"), match.group("body") + if NAV_MARKER in attrs or "data-df-replaced-page-nav" in attrs: + return match.group(0) + if "