sync snapshot from LAN repo (source master-zg @ 40658c74)

This commit is contained in:
gozeros committed 2026-11-05 10:58:49 +08:00
1 parent ea9dcff9c0
commit 754b61572c
179 files changed
+26085 -8788

No files matched your search

+47
View File
@@ -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/**
+18
View File
@@ -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;渲染器拒绝覆盖已有文件并对 `</script>` 注入做转义。最终单文件 HTML 保留右上角设置按钮、分模块 JSON 编辑弹窗、实时校验与「应用并渲染」,以及响应式、深浅主题、发布/点赞/评论/搜索交互;浏览器缓存键包含初始 JSON 的 FNV-1a 指纹,同一制品刷新保留手动编辑,不同新制品不会再被沙箱同源旧 JSON 覆盖。测试 `tests/test_facebook_page_generator_skill.py`。
- **X 页面生成技能**: 内置公共技能 `skills/public/x-page-generator` 只在用户明确要求 X 平台、Twitter/推特或 X 风格社交时间线网页时触发;专用路由会强制读取技能并绕开通用 HTML 生成器。单账号单推文场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总回复数与实际展示数分离,按 `replyTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条回复,并在一次进程内完成六模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;推文与回复正文必须为简体中文。多帖子场景仍通过 `scripts/render_x_page.py` 的 `init → validate → render` 流程注入固定三栏模板与内联运行时。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留深浅主题、发布、回复、点赞、转发、收藏、搜索、关注与响应式移动布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_x_page_generator_skill.py`。
- **TikTok 页面生成技能**: 内置公共技能 `skills/public/tiktok-page-generator` 只在用户明确要求 TikTok、抖音国际版或 TikTok 风格短视频信息流网页时触发。默认只写小型六模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_tiktok_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索、方向键切换和移动端全屏布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_tiktok_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **YouTube 页面生成技能**: 内置公共技能 `skills/public/youtube-page-generator` 只在用户明确要求 YouTube 或 YouTube 风格视频门户、视频首页、频道页时触发。默认只写小型七模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_youtube_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、分类筛选、搜索、响应式视频网格、Shorts 横向列表、观看弹窗、点赞、收藏、订阅和评论;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_youtube_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **Threads 页面生成技能**: 内置公共技能 `skills/public/threads-page-generator` 只在用户明确要求 Threads 或 Threads 风格串文首页、文本社交信息流时触发。默认只写 `accountName`、`postText`、`topic`、`displayReplyCount` 等高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动映射六模块、补齐作者/统计/中文热评、保留示例视觉并一次完成校验与渲染,禁止模型猜测 `$set` 深层路径或读取默认数据。只有结构改造才回退 `scripts/render_threads_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板和移动端导航;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_threads_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **CNN 页面生成技能**: 内置公共技能 `skills/public/cnn-page-generator` 只在用户明确要求 CNN 或 CNN 风格全球新闻门户、新闻首页时触发。默认只写以 `topic` 为核心、可选 `headline`/`breaking`/栏目标题数组的高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动生成完整中文十模块与明确标注的非实时示意数据,禁止模型猜测 `$set` 深层路径、读取生成后的合并数据或探路后二次渲染。只有结构改造才回退 `scripts/render_cnn_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_cnn_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **BBC 页面生成技能**: 内置公共技能 `skills/public/bbc-page-generator` 只在用户明确要求 BBC 或 BBC 风格国际公共新闻门户、新闻首页时触发。默认只写小型十模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_bbc_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_bbc_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **新华社页面生成技能**: 内置公共技能 `skills/public/xinhua-page-generator` 只在用户明确要求新华社、新华网或新华社风格中文通讯社门户、新闻首页时触发。默认只写小型十模块中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_xinhua_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_xinhua_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **白宫官网页面生成技能**: 内置公共技能 `skills/public/white-house-page-generator` 只在用户明确要求白宫官网、White House 风格政府首页或美国行政机构门户时触发。技能把材料整理为 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_white_house_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈;默认内容明确为概念模板,不冒充政府官网,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_white_house_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **马尼拉时报页面生成技能**: 内置公共技能 `skills/public/manila-times-page-generator` 只在用户明确要求马尼拉时报、The Manila Times 或其风格的菲律宾大报门户时触发。技能把材料整理为 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_manila_times_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、菲律宾旗色带、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_manila_times_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **GMA News 页面生成技能**: 内置公共技能 `skills/public/gma-news-page-generator` 只在用户明确要求 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面时触发。技能把材料整理为 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可保留,其余读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_gma_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、即时新闻条、天气切换、吸顶导航、头条视频、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、播客、彩票开奖和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_gma_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **雅虎日本页面生成技能**: 内置公共技能 `skills/public/yahoo-japan-page-generator` 只在用户明确要求雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面时触发。技能把材料整理为 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yahoo_japan_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多类型搜索、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yahoo_japan_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **读卖新闻页面生成技能**: 内置公共技能 `skills/public/yomiuri-news-page-generator` 只在用户明确要求读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面时触发。技能把材料整理为 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yomiuri_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类切换、社论、视频字幕和邮件订阅;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yomiuri_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
-1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.6 MiB

+7 -9
View File
@@ -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=。
+5 -1
View File
@@ -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 : <LoginPage />
}
/>
<Route
path="/register"
element={<RegisterPage />}
/>
<Route path="/landing" element={<LandingPage />} />
{/* Anonymous standalone tool: no workspace navigation or login gate. */}
<Route
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAABDtklEQVR42u2dW5bkOJJkVUC6R2RlzRLmu9fSK56t9Hz3EqarMjLcCJ0PgCQA4kkCfJjD8sTJCHcz2hu4FBUVBfVLvzS6/J//+N94g6eBb3Kf73Dhb3KfVS//+X//m/tHp1/6QtYvHSie+z3o37XvByPcgaNfOmD0S7+8P1DgDb5D/ft6/sbN3x0+OnD0S1+w+qVDRZvPNfp3r0PJScflDhv90gGjXzpQvNfnF/071S83BgbuwNEvHTD6pYPFcz636N+ffjlxg7/LMTpo9EsHjH7pUFHpM4pv/v1A91Pc+jnwu71+HTb6pQNGv9wNKNCViP7dfDNQuVK14A4c/dIXsX75rmCBb6ZGoH+3b7Uh8jdTPbiDRr90wOiXDhTXKxpX32f/Xt4DGp4U3MUdOPqlA0a/dLC4l7LxLt0r/fJ+3RxnKBUdNPqlA0a/PAYsWgLF3UOt+vfp+wLJXcO/HgMcHTQ6YPRLB4p3gAm86/eFH/6dxjO6Su5ovHw76OjA0QGjX74fWLSAhDvBBDogdEA5ET7uAB18Z9jooNEBo1/eFyqeAhR3LbVUhwZ80+8tV97ccG9PBXfg6LDRAaNf3hUungAWt4OKWjCB/t28BD5OhI4rN/FHg0aHjA4Y/dKh4qoZH7gjPHRgeB8gwfPKGjWO1WGjXzpgdKi4dNM+s30TdwGIp8LDWX4PPDQOfC+E4LryxtntuFfAUIeNDhj9cmOwqLl544kwUbKxops+u6mzEoTgmlLHmXHlZ0JQB40OGP3ypnDxOLC4O1TcASRqPe/aZsyngMeFsHEn0OiQ0S8dML4JVNRSIs4ACpy5UeNhps/u42gDMGeBSO5jxnllDr7RfXCHjQ4Y/fJeYNF6w28OFDmbN25o+mwNCk8tq7Te7G/mqch+TLgXDLQ8fgeNDhj98lCoaF2SQKvP2RUgsWeTxgnPs1/aA8EeEKkNISeBB1847fUMhaXDRgeMDhYXQsXtgOJMmCjd0Pfcb4eG94ORUgCpBR8nQMdVwHEZbHTQ6IDR4eJecHEJWFwBFaX3+Y4wkXoN7mDovBo6rjB3pu7zhqDRIaMDRr90qLgeKM6EiZL7eqdOD+oGz1MA5ESPRfS+bgYcHTY6YPRLh4o6x2upTtSEiLMB4omdLE9sJT0bSnKfV2v4aKRydNjolw4YDweLI9BweemjlTpRCyZabrg4qTumX9oAS0sYyXl8V0IH6k6CbVFKuQQ2Omh0wPjuYFEbKqqpFHs33BqqR4uNutXz6ZdnwEgLAKkBHrWfT2V1ozZsdNDogNHh4iQFoWYJ5HKl4ugx7zAavcNE7yi5CjZagFNFZePMkkyHjA4Y3x4szlQsmkJF7Y04o3vhsoTMGwIE3tfT+T26S47ed23oqKhsnAEbpysaHTQ6YDwVLGqqFYeh4g5AUWNDf0i3SP8+PhhManeXHLm/mwJHLdi4jarRQaMvaHcBi7PUiiZQgRN9GUc2+Bt2jDS5H/zjz2/13eV//4ufDCM1O0tqqxy1gONmsHGqqtFBowPGHcGillqBFqUPnFA+aQ0TDUECbwMILNs8Fgh+I0DhO3eW1FQ5apSCKpVS+GmqRgeNDhgdLi4Gi6ObfutySu3vymkw0QoUTl+VzgGTg9BxOnAcLauEjn0j0OiQ0QGjg0UjsHgMVJylVJxp9DzyfTgMEO8CBm8GKgcA5NTOktrD2WoAx41ho4NGB4zHwkUpWOAsb0ULqKgJFCfDBE6Bhw4Obw8ihRDCd4WOUuA4ETZqeDVKYYU7ZHTAeIJicalacQQqSlSK2upEJZhoM9TsH392gPjOAPLvf7VqU+W7lVd8xzuqbjSAjZaqRlc0OmBcBhctyyC3gYpaPorGQIHqIPHHH+jfXeqZGLmXv/5q0aLKdwGOEv/GA2DjsvLJd4MMdLh4X7hoUQK5EVjUhYp7AkVXSJ4KH5nAcQZstAaN2iWUDhkdML4rXJwJFk0Vi1ZqRaPOEbyJOnGr7xszn/J4AHAHkiYqB5/VUXKGqlFZ0bg1aHwXyEAHiyY+i6ZqRWuouBgo8FBVAu8CBLdftM4BFn6o2sF3Ao4LYKO2qsFdzeiAcRfF4lSwuAIqzgaK5HNsDxHooNDB5DIQScBHBgBcAhwtYeNmoNEVje8EGDdWLN4KKrjuwLU8VaItTKBDQ79UhBG+UvHgyhtiSUfJG8FGVzQ6YHS4KL3NBWrFXcECHSj65QTg4HcAjTNUjQ4Z7wUZ6HBxus+iCBJqKRZ3USsuKnmgQ0Tww4PGKwx3+DgRPPaXVC5VNWooGjtB45b+jHeBDHS4uIdicVStuLgEght4KN4fIri3rT4NWi6Hj2MeDr5DCaWRqnF7ReMdIANdtciGgepg0aIEwge6MGpAxUlAgbcAiA4M3x5ICgGEn6BwHCmlpJSNBrDRGjS+tZqBrlpUybJAa7XCvF0tqGiuUtQvd+ARUHEDcAA9q7zDdIOsjAtB5HTQyICOq9WNEGzsTQrdoWrs7dDhrmY8GDBOhIs9qkVVuGilWPCxltr44z4OFs9RKC6AiafBw6Mh5GL141TwiKgbR5WN2pCxd+hbA8g4Tc14ImTgm8FFrkJxa7B4Q6i4pzLREB6+KyS8JZycDCEF0MHvChsPAQ2u/b48DTLQVYv7lEOOqhWnQcUJCsWpMFERJK4CB36zlnNcFOddFUROBI9M6KiucFwBG0dUjYvKJt9WzcA3VS2agsXVagXvN6S28FPcR52oABKtAYL7gLNbg0kVADkBPJqrHOW+Da7RunqhqnE70HgCZKDDxXVwcaFigW8DFgehohVQdJB4D/A4DByNYeNmoMEPVzQ6ZLwTYNwALpqUQ46AxZOhohlM3EyZ6PDQIeTOSkcGdDwSNvaCRuOyybeGDHSwqKta7FUsStWKm0DFuUBxACQ6QPTL2wBIZfBoAhyNYaNE1diraHQ14w0B4wBctAAL1Ij0jl2vgVrREipuDxQ1QKIDRL9cBSC7wOPuwFERNmqqGpVAw33Ml4LG3SAD3wguqigWez0WR8CiGlRUVCmalDsKgOIoSLwRRODtO0w7fJSBR4PSSgQ6uGE3CtdSNWqARuK6RxWNt4QMdOWifjmklseiahmkolpRFS5OUikeDBRdTXlDANkDHFerGwllg58MGbkejYplk2+hZOAb+C2qlESOlkNKPBYXKhZtoeIEmHgASHRg6EBSDTyugo67w8Zej0YDf8ZlSsYdQANdtbgOLI6WQipAxa2A4g1gAtTh5R0ggL8VdNwJOPJgo4mqcSFovK2agW+oWlT1WpwAFmm14i5Q0dBDwd9j8+7qxnMAgd8WOg4CRyvYKFU1WoJGI2/G26kZ+MZGTpROLa0JFierFW2gIhMoHuSZQAeHDiJPhI+mno4DwFEFNiqrGq0UjSeUTc6GDDxYubisJHI5XFRQLDpYNPsOdJjo0HEZbDwQNGoqGpdAxkUlk0coGfgGykVxSeQNwOIRUPFQmOgA0QHkbaHjEbDxbNAoeayPVzLwjeDiNLA44rF4ElS8OUx0kOiXM6HhudDxUNjI9WicABpvCxl4EFzs9Vvs9lqUBmVdABZ1oeKZQNFVCSIC81sBEQPcoeM88LgDcByCjcagURrUtaNsctSXcUvIwBvCRTPVolIpBC3Uim8CFW+vSrwbKHQweZ7acWPYqKFqcK3SyQ3VjD2ZKPSf/9UOMvBmZZGqcFFZtWhWCtkFFxmeily4uDlYoMNEh443hY1bgEaWb2MHaDSEjOBxjqgZJ0BGznPgPatjK8jAd1QuWngtdoHFmVDxHKDA00HibHB41+FsONmXcDMQYboZdFwJHLVgYw9o1PZm3ErJMFaOFpAx3jgFcQ9c7FItGoFFjXJIU7C4AVA8EiZaAkSf5HrstTgCJan39WQAwc4NFa2gwxM+yDnrihc45nUpEzTmdc8BDWQ9vz/+wAwZRrmbjWNw5GSUPbeJXsf8ubtvxH7vPB9Enpf7u9S/3yedcId6cQvV4hTFIg8smqsVKbC4GVDgHUDiMnC4exnmItWgpjpysfLBdBOVI/Wa1vZr7FI1KpROShSN26sZzupQW8XAxWWRZnBRU7U4rFjsUCveQKl4jDpxFCSawkP3aVwGKIdHrF8DHpeXVqqUUo7DBh8onew2gpaUTTLKKu0hI7C61AKN8aHqCXKPcxJcHPFZNFMscsCC75FkiacpE02gosNEndes0sburBN89HN2EnBgJzSgFmjkvm7z+hQsoRRABjOjuHzilE6csgmFSidY1Qf4yia+35s/d0smifeE71b2OH1hb6BenOq3eBxY3K8EgrsCxRGYqPY6dXB4a/XjiNJxssrBdGEp5awSytHSSa2ySW47a2GXyd4oci5diWuoGHiQ5+I0v0VO7gWF7qsQLIrUimcqFbeHiSqvzftBBOjBI87vAB97weOm0MFnl5+CwFGgbBSXTsIejaJcjFJfxomtrFzyLT8KGXgHuLhKtTiiWNRQK05WKnAnheISmLgfRKDHcD8LPm4OHXxHz0YNr8YR0MjxZ5yoZtSFjIwV5Ahk4EFwUaxavCNYxKDiXYHidJi4DiTuAAx4AxjgJ4HHjaHjdsBxVNV4GmgcKJnkQUbmt30vZIxPPhO7Yt7FXrg4Wgo5Ua3At4OLine truncated

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAIAAAB0j/2rAADncElEQVR42uz9aZctSXYdBp59zG/Em3POrEKhJoAkKE4tCmyBk4ZufQDYf1X9mb2WunuxRUlscqnJJZKCCBQKqEJVzpmV05si4l4/uz/Y7G7mw414WZCIt0hUpF+/fn0wNztnn332xi9/+cs3X3tNWv9IAiApIgDSFv/HZGP6qHmQ3n+GjSKQff/8V+b/N30qrWNOfogi4k8+bkHx3cnBJzsIKcWFhO31D0yOJrOzahy2/rv5b74D6qNtP+b8DFf3nGxBfWO5eNqTZ7d6sZNP58+Xi1+Z33YuXtHGf2d/8S/Uv7/IV7Fwbs2XevUgWNt//qb3fnr7UOfaYOZs5+3v/sK3Ns4P513almd39ov2v9M36/8YE8Jf/vvLf3/57z+2medbnrvu6udwR7Hc9vWRfwFuI9aCir9c9b61s/3LWOXst+z2r5t863kc9gf8f/lK/uW7+Rf/lfy1LCi3XE8X8uuN578dHMDmjb3Dso8EkpSIh/bwxjme2QM/d+GozZ23oLVNyLdEhicQce8gIvLF118PIvJ//2//2/kvTP5evTCaVb9Cto9ZHo0EYGb5P8vb7RFe0m+nCCgSAfFwKH9tpN+ab0Y8uAhBCinwhwwbxZhGQPMGi3A+xGC2dIF+hzx80rCx4lAMaHe6uuJQqI5JoQiAkZ27z+5p9G5+/8yn322fUjUE0/CKr0r+CuNQ6H19eg7l3Sg2pq/78b4yodg4jifnBhGQpsyDrTVVxbO1+NPozzakoHiEJqrNC4KEEQoRi+9hH46jgKQD/HCbvKW5wjGN2WAULS6iMZFAUMxUJuJEKEKIUvwoVpmM/XQXFutC9fTov+GvPT7zVNAJbx4EaM3XFP/SYvIzTQhz8kUgHjO844T2akxCCQ8sDSHEk58/6uo/EZ63P7hxUnhhus9QCMVMoKLo1sF6z6v3ckNAHG5eutO1QUFCzE9OIn54QEBRGCBKNQkLmr+0cI/Sq+q/ZUJM1ls0Hn8cJWkUAUKj4ObeExsuyVFElILxNJxuRDi6S4MTMYmzbX0/4yuc3/R4CcWDQPpo/uzD1+MywcY9BUVg1W1k+WwZtoAIU3N4gIxzux++KF9AxpmfDA+XIloNE44EBSoki/c1hxMQChGXsfR6hDNDXkH8mIr7LcyfVaQg+fEWxVUIKMUCCr/khRNEnMjhLyqticXkQTK+wgh/CyHpzWUzoUS+z2GEpjU9zrPlwwvLvOSSLiQvtcU9CrNNsTQDpKGu+oJxefJ3T0EzoSG+uWC8HeFp+btNkGBYbGJ4GMMS0igA/V3It86/S/T32uJt95+G+wkyPHv/mIyI9wRCf2R/M/31QcTS1My02IYb6wethvNL0ZRfiONt9qdDIoyBMJn44Q5heAUZZgjkmdQ/WX+qoL8hiA+Ms7pXMd2ymCziWfFEUcSNFEthjcgYT36kDIAJR8oBAGQkLxSkjKSJOEBERtIoFLnQ8CRPFoaCA3S2YlwZy8XEf2shGB+ZZ0K/8cbYiOuZ3itRkaGgL8ThLC4uTlyMekTk2lgeeFAMOabPu43kkTwAAnGCATiRfjsACtO3TqQK/N07kiZ0gjGu0APkGBctkbxSqcjC7ZnMplosjgyzRVzLBTFwz2txfLdzAE+gnEolLTDIo0jS/AwRwiQMJE0PA3m2S3/H40DytIv0TAFlNWv72R7xT//qgxAIADFBXg4AA5AmkfDGxnsJFPcEfkYlNazFPsRTpPHFFJCE44NChI15SQi/7gOeHIbG4CfF3+GMqnOIt6EaSOHFR1w9KExfXBurMgmN8jqOvJrHuKV+lyrGTxlclVF/kYsXr5yWkXcxao15+JahQrylVSwZH1s+LuYkorjy+fHnoyTEX2SMFVD/4ceYAhaXQh+mpUNPIQS/5ORpxS/KRqqq/yON2PQWicVAqbwoY76E+C6Vt5xW7j3lS/nLJOcfhZUF0HrtATgOp6OQJ3UcDmEMjKfDzQsXwg/GjDxHGMKc3iEMvBiVxvUKBBimB/HLofoFM3wZYbGMyzIJ+onBH58CVQUppm4cLgiNj6fImzJEEC86XrcZ2/NfmeCIQBgeBYrkghAtolj/Fatj3nLK9MPe0lodxxIhGl8QFbE0jzLPhsXIbpxwGuWSblgjwQPEwh8gLQFEYek3AQQKmt9LQ4LqkQwSmqd8pDc9PLUQV4jfLX7ElC8p5vklTcr8Ok25aSDR/KgqIqQivJQ0J1ZzF32c5E8bABRmhhAEh8dh/sjIaIof1MZiorCwpNACYijFTVi6LfF9Cue/QKeC0NITteIGqUAINZJChY52UndQUU5TVUgBvIjUA26VxjXJeeerwGp4gDwiubwdRYQIzGbhGglJk3nr0ppfSeBo3qc8h7RGJ9AHYFrOyt3Kr6efCFNnXjEmRxYJaAOrr1cZSgAr4ltH55q3y78wxXYpog4/WwZIk/FG+ZWDYW1NURhZPpQCvSrZt5TZe1Q/iOWhsjKEWjv/X//JPxlE5NiMNeqNXIlHhGnBy0gC6xGf1iUJcDE9FIzJiZuU6EnMPMMBUi4TIlMROQET7JvFjEewfM99LikqiIlfea3MMEb6ueKfCxULs1HqQkGONkqAMYIpZilcNCkTDJ+ejj6y9Ykzi9xXRAQamdoFSmoxd00hfjmfMn7EnANYcWb+o/RpXZthBS+mOykUyXc1rJU252VmuD+HnNKgbaKAaor/LR5vREDidGGUIpUtfyK8fuPJ5KjDwUdmbjTRAiFFHj25JmOUoXg1/QgOS59KChTzU64DmZCrCBNITcRHRoiyjDYzFptw2eLJiU8uXIhwIDHO1cYKkrAYWBlW5dAYrL6okq9LKOrTOvi0Tnzyns8wBlpCSWEc3EplkUUSmV5mWpxDQ8Aen51FYKS+wHSEHMMyRN9MZ6IxhYrjgyLqI1FUGAnqs0JVXUCJJswvZEbvj+sOpMDvp5GfiwAYBKILN6rOs1DkN8oMD4MkHO6P/O6LZw/t+ht3eS16AA5wHrgCjKJ6gjiMjDc84WITQDD/nX8hpMPF6CmzM+FJcshHg97nzTcvv/zFa+/e3Huofng7gZ7uH68Pxit3ODmdYujpJRUNS6MP5SAB2RcjPehH88/Kv/aoJhbzCYr5XSOyZ6xmF0SYSzRm1xbLOxhDXKEWsCw/cZgIqeJRrPSao0gZ4yJS1EYn2Z5LS5RFENKfZDmfwsQyZMsapg2lrYQqUFE+txDZ+rJRhIEoVCsg3dEYAOIAVurJjBnVmQ5uxEQ7JA8z2CHXF1MFt5i2y5WqWiv9a4V4jUhV8knQqCF+igtkCgKMozaCTgmJRUq3Gf728yZzAFBUtY0jNWTaAph5LOhkogHZEjFzoIr4nMsYIXGaxvJDeG38mDfzEQ/8e0gK/SREJY0UMyWFJhz9awKKGUnzv3IhPJFO/OlFEMwfSsTIwa8nNA81+GhpDHilqUAZF2ihgTAZSYiMwiFGQQDGWH4IqDZ9xixKMYRtoHj812dbTiiAExl9rh8HoRMZKfRImsiRIbj1mZeTACKHpSguimnd1TrWMhbAXQhW6CAKHP28klZLkUGQIgV/M4FcMU4Luoo4VAW09EPhJAHUU7OGegQdSmzKr4SYIhcSYgpFWYTKQFJ606qfQD6TAfCouoo4wA+RA0I92cUEHQF3ThMAFXARmLKYaap4TJRjXEscRCAR1uYFlBIGhqWpJXw35OYD8lqncX5QVE9HQok15mgUP0KAkOafKPH0JN5hX0uIKwsEMYxQFVp8gCpjxqegIfoC4hxiAkvziQAKmihgEAAmft4Wgfjyb6iXACoY/dGgEBDifKQDp+qJBzABFQYMgEBJ8f9XnZpAnTP1yxGgMIBQAUQB0VEFUKV6lEagAgKOQlHnXw6NyZ7RT80qhCjgB6xipKk6hEwTJCxUsv29cEU5KQScqioAeYJf6qB+QTKBms93/OMDVSUhOIRBQUsvPxGBo4QDECM8VyHXURmj8VidUhELZT7xQFVRzQoDDGWlGUSsQBgFowa4SikGn4lYCEYBesAqothKpFLnWCQQ/nCkouIFBBCc4mLcmILFMHj9tKp+4IiOpI9LfdAUMhuQDgmdFQOEooBCCD0ICYqJSgb0nJB+t1ha8EMcSDSFcDcdGNCxgGR5GlZiWOVLEYTg3PkLsVRcVAhdyn00FZxT1BLqzQzLFgvwGpLBW1EwhPoOvjDkZ3wtcVe/so73xhtHfXZxOLoLX6M53Lz8wbPnl3b6lWAg7iucMx9kSfhRJCQwzQMxWkFdqhcRMWCMCwWgQIhQ6AO6kUKqc2o5AguVpPF0nzcv3PDRxcOX7v40tW0F4enxxvmaOYZknWOiTYsJ76RWcWPr/2LG25mCEilHi4GKTHeQelFpXhXKDIjLiOc0MfDvsieVNOlHVVEhxbdzilTKIuO6rqm4XeFDBa6yRnbV2RVj8U6gvl0ubgxrWHFJaQuK+jNnFYjJ5ZeDgbPhgRoXzuefaj75jpeBiMwKBv62jarplTYenTtAXeYIQgPLBIGWgEhewWTgzUHCDjtv/kUPd2KJFYYQixQwkXqEVBUoq1CB9pBZJT44YIm05kqyv550SsT0V8N9RpXXh5w/1nFlwlIpCiEx0SguL8zi/vdRFbKBirKMFK8kzNdnaZJ/WkpUBDXliOo0BpCYILVh7CBjwfBVDhSwvGr5JUQmASp6mWZoHtWbrcQUemZV0ui+ZD7ya4yrBtmwzoOrV2MIV4XZhBMTGFZcMJb8xkQ0yGLine truncated

After

Width:  |  Height:  |  Size: 155 KiB

+2 -1
View File
@@ -14,7 +14,7 @@ import {
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 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: {
+69 -1
View File
@@ -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;
+41 -3
View File
@@ -13,6 +13,8 @@ import {
MessageSquareText,
MousePointerClick,
BriefcaseBusiness,
PanelLeftClose,
PanelLeftOpen,
Settings,
ShieldAlert,
ShieldCheck,
@@ -100,14 +102,24 @@ export function PageSidebar({
/>
);
}
return <SecondaryNavSidebar headerHidden={headerHidden} />;
return (
<SecondaryNavSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
headerHidden={headerHidden}
/>
);
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --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 (
<button
type="button"
title="展开侧边栏"
aria-label="展开侧边栏"
className="light-sidebar-expand-btn"
onClick={onToggleCollapsed}
>
<PanelLeftOpen className="light-sidebar-expand-btn__icon" aria-hidden="true" />
<span className="light-sidebar-expand-btn__label">展开面板</span>
</button>
);
}
return (
<>
<SettingsDialog
@@ -311,6 +339,16 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
</DropdownMenuContent>
</DropdownMenu>
) : null}
<button
type="button"
title="收起侧边栏"
aria-label="收起侧边栏"
onClick={onToggleCollapsed}
className="light-sidebar-item"
>
<PanelLeftClose className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">收起面板</span>
</button>
</div>
</div>
@@ -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 = (
<Card
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
"group flex min-w-0 flex-col transition-[border-color,box-shadow,background-color] hover:shadow-md",
agent.hidden && "opacity-60",
className,
)}
data-agent-guide="agent-card"
>
@@ -992,8 +996,9 @@ export function AgentCard({
const listLayout = (
<div
className={cn(
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-[border-color,box-shadow,background-color] hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
agent.hidden && "opacity-60",
className,
)}
data-agent-guide="agent-card"
>
@@ -0,0 +1,258 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import {
exportAllThreadsInBackground,
importTransferThreads,
parseTransferBundle,
skipReasonLabel,
type SkippedThread,
type TransferProgress,
} from "@/core/threads/transfer";
function ProgressPanel({
progress,
label,
}: {
progress: TransferProgress | null;
label: string;
}) {
if (!progress) {
return (
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
<Loader2 className="size-4 animate-spin" />
{label}
</div>
);
}
const percent =
progress.total > 0
? Math.round((progress.processed / progress.total) * 100)
: 0;
return (
<div className="space-y-3 py-2">
<Progress value={percent} />
<p className="text-sm text-neutral-600">
已处理 {progress.processed} / {progress.total} 条,成功{" "}
{progress.succeeded},跳过 {progress.skipped}
</p>
</div>
);
}
function SkippedList({ items }: { items: SkippedThread[] }) {
if (items.length === 0) return null;
return (
<div className="max-h-40 space-y-1 overflow-auto rounded-lg border p-2 text-xs text-neutral-600">
{items.slice(0, 20).map((item) => (
<p key={`${item.thread_id}-${item.reason}`}>
{item.title || item.thread_id}:{skipReasonLabel(item.reason)}
</p>
))}
{items.length > 20 && <p>其余 {items.length - 20} 条已省略</p>}
</div>
);
}
export function ChatHistoryExportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<TransferProgress | null>(null);
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
async function handleExport() {
if (running) return;
setRunning(true);
setProgress(null);
setSkipped([]);
try {
setProgress({
total: 0,
processed: 0,
succeeded: 0,
skipped: 0,
});
const result = await exportAllThreadsInBackground(setProgress);
setSkipped(result.skipped);
if (result.succeeded === 0) {
toast.message(
result.total === 0 ? "没有可导出的对话" : "没有成功导出的对话",
);
return;
}
const extras = [
result.skipped.length > 0 ? `跳过 ${result.skipped.length} 条` : "",
result.skipped_files > 0 ? `跳过 ${result.skipped_files} 个过大附件` : "",
].filter(Boolean);
toast.success(
extras.length > 0
? `已导出 ${result.succeeded} 条,${extras.join(",")}`
: `已导出 ${result.succeeded} 条对话`,
);
if (result.skipped.length === 0) {
onOpenChange(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出失败");
} finally {
setRunning(false);
}
}
return (
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导出对话记录</DialogTitle>
<DialogDescription>
后台逐条打包当前账号的对话消息和附件,进度按条前进。单条上限 50MB,超出部分会跳过大附件,消息仍导出;一条失败不影响其余记录。
</DialogDescription>
</DialogHeader>
{running || progress ? (
<ProgressPanel progress={progress} label="正在准备导出…" />
) : (
<p className="text-muted-foreground text-sm">
导出文件可拷到另一台机器,在本页「导入记录」还原。
</p>
)}
<SkippedList items={skipped} />
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={running}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button disabled={running} onClick={() => void handleExport()}>
{running && <Loader2 className="size-4 animate-spin" />}
{running ? "导出中" : "开始导出"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function ChatHistoryImportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<TransferProgress | null>(null);
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
async function handleFile(file: File) {
if (running) return;
setRunning(true);
setProgress(null);
setSkipped([]);
try {
const threads = parseTransferBundle(await file.text());
setProgress({
total: threads.length,
processed: 0,
succeeded: 0,
skipped: 0,
});
const result = await importTransferThreads(threads, setProgress);
setSkipped(result.skipped);
void queryClient.invalidateQueries({ queryKey: ["threads"] });
if (result.imported === 0) {
toast.message(
result.skipped.length > 0
? `没有导入新对话,跳过 ${result.skipped.length} 条`
: "没有导入新对话",
);
return;
}
toast.success(
result.skipped.length > 0
? `已导入 ${result.imported} 条,跳过 ${result.skipped.length} 条`
: `已导入 ${result.imported} 条对话`,
);
if (result.skipped.length === 0) {
onOpenChange(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "导入失败");
} finally {
setRunning(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导入对话记录</DialogTitle>
<DialogDescription>
选择从另一台机器导出的 JSON 文件。逐条写入,进度按条前进。已存在或失败的记录会跳过,不影响其余记录。
</DialogDescription>
</DialogHeader>
{running || progress ? (
<ProgressPanel progress={progress} label="正在读取文件…" />
) : (
<p className="text-muted-foreground text-sm">
导入后归属当前登录账号,可继续查看和提问。附件会一并还原。
</p>
)}
<SkippedList items={skipped} />
<input
ref={inputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleFile(file);
}}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={running}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button
disabled={running}
onClick={() => inputRef.current?.click()}
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{running ? "导入中" : "选择文件"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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"
/>
<div className="flex items-center justify-end gap-2">
<Button
@@ -473,7 +473,7 @@ function HumanMessageBlock({
}
const messageResponse = text ? (
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
<div className="whitespace-pre-wrap break-words text-[calc(15px*var(--ui-font-scale,1))] leading-7">
{text}
</div>
) : null;
@@ -1353,7 +1353,7 @@ function AssistantDisplayContent({
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
className="chat-assistant-markdown my-3"
components={components}
/>
) : (
@@ -729,7 +729,9 @@ export function MessageList({
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
hasSingleRoundRag
? "gap-4 pt-4"
: "chat-message-list gap-8 pt-12",
contentClassName,
)}
>
@@ -752,6 +754,13 @@ export function MessageList({
</div>
) : 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,
@@ -0,0 +1,71 @@
"use client";
import { ArrowRightLeftIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useAgentSkillMigrationSettings,
useUpdateAgentSkillMigrationSettings,
} from "@/core/system-settings/hooks";
export function AgentSkillMigrationSettingsPage() {
const { data, isLoading, isError } = useAgentSkillMigrationSettings();
const update = useUpdateAgentSkillMigrationSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(
checked
? "智能体与技能迁移功能已开启"
: "智能体与技能迁移功能已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<ArrowRightLeftIcon className="size-5" />
智能体与技能迁移
</h3>
<p className="text-muted-foreground text-sm">
控制智能体/技能迁移入口,以及对话页的导出记录、导入记录和卡片导出按钮。默认关闭,只有管理员显式开启后才可使用。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用跨服务器迁移</div>
<p className="text-muted-foreground text-sm">
开启后,对话页会显示记录导出和导入按钮;智能体与技能请先复制目录,再通过清单同步归属、状态和标签。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用智能体与技能跨服务器迁移"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取迁移开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
关闭后前端入口和对应接口都会停用;已经复制或导入的内容不会被删除或修改。
</p>
</section>
);
}
@@ -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 }) {
<SettingsSection
title="字号"
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
description="调整界面字号。默认 22px(超大)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
>
<div className="space-y-3">
<Select
value={String(settings.display.fontScale ?? 1)}
value={String(settings.display.fontScale ?? DEFAULT_FONT_SCALE)}
onValueChange={(value) => {
const scale = Number(value);
if (Number.isFinite(scale) && scale > 0) {
@@ -1432,6 +1467,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
setHtmlRuntimeDraft({
base_url: event.target.value,
map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: runtimeTileMaxZoom,
})
}
placeholder="https://你的域名/api/public/html-runtime"
@@ -1452,12 +1489,75 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: event.target.value,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: runtimeTileMaxZoom,
})
}
placeholder="https://tiles.example/{z}/{x}/{y}.png"
className="mt-1 font-mono text-xs"
spellCheck={false}
/>
<div className="mt-4 rounded-lg border bg-muted/20 p-3">
<div className="text-xs font-medium">消息列表地图瓦片层级</div>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
只作用于普通问答消息列表里的地图,生成 HTML 网页暂不受影响。请填写瓦片服务真实存在的层级;地图仍可放大到 {MESSAGE_MAP_DISPLAY_MAX_ZOOM} 级,超过最大真实层级后会放大已有瓦片。
</p>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium" htmlFor="message-map-tile-min-zoom">
最小真实层级
</label>
<Input
id="message-map-tile-min-zoom"
type="number"
min={0}
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
step={1}
inputMode="numeric"
value={runtimeTileMinZoom}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: event.target.value,
message_map_tile_max_zoom: runtimeTileMaxZoom,
})
}
className="mt-1"
/>
</div>
<div>
<label className="block text-xs font-medium" htmlFor="message-map-tile-max-zoom">
最大真实层级
</label>
<Input
id="message-map-tile-max-zoom"
type="number"
min={0}
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
step={1}
inputMode="numeric"
value={runtimeTileMaxZoom}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: runtimeTileUrl,
message_map_tile_min_zoom: runtimeTileMinZoom,
message_map_tile_max_zoom: event.target.value,
})
}
className="mt-1"
/>
</div>
</div>
{!runtimeTileZoomValid ? (
<p className="mt-2 text-xs text-destructive" role="alert">
请输入 0~{MESSAGE_MAP_DISPLAY_MAX_ZOOM} 的整数,且最小真实层级不能大于最大真实层级。
</p>
) : null}
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
@@ -1465,23 +1565,28 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
disabled={
!htmlRuntime ||
updateHtmlRuntime.isPending ||
(runtimeBaseUrl === htmlRuntime.base_url &&
runtimeTileUrl === htmlRuntime.map_tile_url)
!runtimeTileZoomValid ||
!htmlRuntimeDirty
}
onClick={() =>
updateHtmlRuntime.mutate(
{ base_url: runtimeBaseUrl.trim(), map_tile_url: runtimeTileUrl.trim() },
{
base_url: runtimeBaseUrl.trim(),
map_tile_url: runtimeTileUrl.trim(),
message_map_tile_min_zoom: parsedRuntimeTileMinZoom,
message_map_tile_max_zoom: parsedRuntimeTileMaxZoom,
},
{
onSuccess: () => {
setHtmlRuntimeDraft(null);
toast.success("HTML 运行地址已保存,之后新画的网页会使用这个地址");
toast.success("地址与消息列表地图瓦片层级已保存");
},
onError: (error) => toast.error(error.message || "保存失败"),
},
)
}
>
{updateHtmlRuntime.isPending ? "保存中..." : "保存地址"}
{updateHtmlRuntime.isPending ? "保存中..." : "保存设置"}
</Button>
</div>
</div>
@@ -0,0 +1,410 @@
"use client";
import {
Image,
ImageOff,
ImageUp,
Loader2,
RotateCcw,
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useDeleteGeneratedPageBrandingSettings,
useGeneratedPageBrandingSettings,
useUpdateGeneratedPageBrandingSettings,
} from "@/core/system-settings/hooks";
import {
resolveGeneratedPageLogoUrl,
type GeneratedPageBrandLogoMode,
} from "@/core/system-settings/api";
import { SettingsSection } from "./settings-section";
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
const DEFAULT_LOGO_URL = "/api/public/html-runtime/brand-logo-dark.png";
export function GeneratedPageBrandingSettingsPage() {
const { data: branding, isPending } = useGeneratedPageBrandingSettings();
const updateBranding = useUpdateGeneratedPageBrandingSettings();
const deleteBranding = useDeleteGeneratedPageBrandingSettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [chromeEnabled, setChromeEnabled] = useState(true);
const [headerLayout, setHeaderLayout] = useState<"left" | "center">("center");
const [logoMode, setLogoMode] =
useState<GeneratedPageBrandLogoMode>("custom");
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setChromeEnabled(branding?.chrome_enabled ?? true);
setHeaderLayout(branding?.header_layout === "left" ? "left" : "center");
setSystemName(branding?.system_name ?? "");
setLogoMode(branding?.logo_mode ?? "custom");
setPendingLogo(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [
branding?.logo_version,
branding?.system_name,
branding?.footer_text,
branding?.logo_mode,
branding?.chrome_enabled,
branding?.header_layout,
]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
const currentLogoUrl = resolveGeneratedPageLogoUrl(branding?.logo_url);
const defaultLogoUrl = resolveGeneratedPageLogoUrl(DEFAULT_LOGO_URL);
const displayedLogoUrl =
previewUrl ??
(logoMode === "default"
? defaultLogoUrl
: logoMode === "custom" && branding?.logo_mode === "custom"
? currentLogoUrl
: undefined);
const normalizedName = systemName.trim();
const configurationWillBeActive = Boolean(
normalizedName &&
chromeEnabled &&
(logoMode !== "custom" || displayedLogoUrl),
);
const hasChanges =
chromeEnabled !== (branding?.chrome_enabled ?? true) ||
headerLayout !== (branding?.header_layout ?? "center") ||
normalizedName !== (branding?.system_name ?? "") ||
logoMode !== (branding?.logo_mode ?? "custom") ||
Boolean(pendingLogo);
const busy = updateBranding.isPending || deleteBranding.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片");
event.target.value = "";
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo 文件不能超过 10MB");
event.target.value = "";
return;
}
setPendingLogo(file);
setLogoMode("custom");
};
const handleLogoModeChange = (mode: GeneratedPageBrandLogoMode) => {
setLogoMode(mode);
if (mode !== "custom") {
setPendingLogo(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const logoModeDescription =
logoMode === "default"
? "保存后,生成网页会使用系统内置的默认头像。"
: logoMode === "none"
? "保存后,生成网页的品牌页头仅显示系统名称,不显示头像。"
: "上传的自定义 Logo 将显示在生成网页的品牌页头。";
const handleSave = async () => {
try {
const saved = await updateBranding.mutateAsync({
chromeEnabled,
headerLayout,
systemName: normalizedName,
logoMode,
logo: pendingLogo,
});
toast.success("生成网页标识已保存", {
description: saved.active
? "名称和头像显示方式已配置,之后新生成的 HTML 页面将自动加上品牌页头(不加页脚)。"
: "请填写系统名称,并在上传自定义、默认头像或不显示头像中完成选择。",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "生成网页标识保存失败",
);
}
};
const handleReset = async () => {
try {
await deleteBranding.mutateAsync();
setResetOpen(false);
toast.success("已清除生成网页标识", {
description: "之后生成网页时将恢复原有生成逻辑。",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "清除生成网页标识失败",
);
}
};
return (
<>
<SettingsSection
title="生成网页标识"
description="配置 AI 后续生成的 HTML 网页使用的名称与头像显示方式,并可单独开关平台的自动注入(只注入品牌页头,不加页脚)。这里只影响生成网页内容,不会修改 DeerFlow 自身的名称、导航或 Logo。"
>
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 rounded-lg border bg-muted/20 px-4 py-3">
<div className="min-w-0 space-y-1">
<label
htmlFor="generated-page-chrome-enabled"
className="text-sm font-medium"
>
自动注入品牌页头
</label>
<p className="text-xs leading-5 text-muted-foreground">
开启后,平台会给新生成的网页自动加上统一品牌页头(多页面含页面导航),不加页脚。
关闭后,生成网页完全不加页头、保持原有输出,也不要求页面自行展示名称或头像。
</p>
</div>
<Switch
id="generated-page-chrome-enabled"
checked={chromeEnabled}
onCheckedChange={setChromeEnabled}
disabled={isPending || busy}
className="mt-1"
/>
</div>
<div className="rounded-lg border bg-muted/20 px-4 py-3 text-sm leading-6">
<span className="font-medium">
{configurationWillBeActive
? "配置完整:"
: !chromeEnabled
? "自动注入已关闭:"
: "当前未启用:"}
</span>
{configurationWillBeActive
? logoMode === "none"
? "保存后,新生成网页将自动加上仅含系统名称的品牌页头(不显示头像、不加页脚)。"
: "保存后,新生成网页将自动加上品牌页头(名称 + 头像,不加页脚)。"
: !chromeEnabled
? "生成网页完全不加页头,也不要求页面自行展示名称或头像。"
: "请填写系统名称,并选择上传自定义、使用默认头像或不显示头像。"}
</div>
<div className="space-y-2">
<label
htmlFor="generated-page-brand-name"
className="text-sm font-medium"
>
网页中的系统名称
</label>
<Input
id="generated-page-brand-name"
value={systemName}
onChange={(event) => setSystemName(event.target.value)}
placeholder="请输入要在网页页头展示的系统名称"
maxLength={80}
disabled={isPending || busy}
aria-describedby="generated-page-brand-name-help"
/>
<div
id="generated-page-brand-name-help"
className="flex justify-between gap-4 text-xs text-muted-foreground"
>
<span>最多 80 个字符;需选择头像显示方式才会启用。</span>
<span className="tabular-nums">{systemName.length}/80</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">网页 Logo / 头像</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
可上传自定义 Logo、使用系统默认头像,或只显示系统名称。启用后会进入品牌页头。
</p>
</div>
<div
className="grid gap-2 sm:grid-cols-3"
role="group"
aria-label="网页 Logo 显示方式"
>
{[
["custom", "上传自定义"],
["default", "使用默认头像"],
["none", "不显示头像"],
].map(([mode, label]) => (
<Button
key={mode}
type="button"
variant={logoMode === mode ? "default" : "outline"}
className="h-11 justify-center"
aria-pressed={logoMode === mode}
onClick={() =>
handleLogoModeChange(mode as GeneratedPageBrandLogoMode)
}
disabled={isPending || busy}
>
{mode === "custom" ? (
<ImageUp aria-hidden />
) : mode === "default" ? (
<Image aria-hidden />
) : (
<ImageOff aria-hidden />
)}
{label}
</Button>
))}
</div>
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background p-3">
{displayedLogoUrl ? (
<img
src={displayedLogoUrl}
alt="网页 Logo 预览"
className="max-h-full max-w-full object-contain"
/>
) : (
<div className="text-center text-xs leading-5 text-muted-foreground">
{logoMode === "none" ? (
<ImageOff className="mx-auto mb-1.5 size-6" aria-hidden />
) : (
<ImageUp className="mx-auto mb-1.5 size-6" aria-hidden />
)}
{logoMode === "none"
? "当前选择不显示头像"
: "当前未上传自定义 Logo"}
</div>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
{logoMode === "custom" ? (
<>
<input
ref={fileInputRef}
id="generated-page-brand-logo"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleLogoChange}
disabled={isPending || busy}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || busy}
>
<ImageUp aria-hidden />
{displayedLogoUrl ? "更换 Logo" : "上传 Logo"}
</Button>
</div>
{pendingLogo && (
<p
className="truncate text-xs text-muted-foreground"
title={pendingLogo.name}
>
待上传:{pendingLogo.name}
</p>
)}
</>
) : (
<p className="text-xs leading-5 text-muted-foreground">
{logoModeDescription}
</p>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setResetOpen(true)}
disabled={!branding?.customized || busy}
>
<RotateCcw aria-hidden />
清除配置
</Button>
<Button
type="button"
className="h-11 min-w-28"
onClick={handleSave}
disabled={!hasChanges || isPending || busy}
>
{updateBranding.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateBranding.isPending ? "保存中..." : "保存网页标识"}
</Button>
</div>
</div>
</SettingsSection>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent aria-describedby="generated-page-brand-reset-description">
<DialogHeader>
<DialogTitle>清除生成网页标识</DialogTitle>
</DialogHeader>
<p
id="generated-page-brand-reset-description"
className="text-sm leading-6 text-muted-foreground"
>
将移除网页展示名称与 Logo/头像显示方式,并把自动注入开关恢复为开启。DeerFlow 自身界面不会发生变化,之后生成的 HTML 页面将恢复原有生成逻辑。
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setResetOpen(false)}
disabled={busy}
>
取消
</Button>
<Button variant="destructive" onClick={handleReset} disabled={busy}>
{deleteBranding.isPending && (
<Loader2 className="animate-spin" aria-hidden />
)}
{deleteBranding.isPending ? "清除中..." : "确认清除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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<SettingsSection>(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 && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
{activeSection === "generatedPageBranding" && isAdmin && (
<GeneratedPageBrandingSettingsPage />
)}
{activeSection === "agentSkillMigration" && isAdmin && (
<AgentSkillMigrationSettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
@@ -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: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} 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<string>("mine");
@@ -224,9 +243,31 @@ function SkillSettingsList({
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
const [fillModel, setFillModel] = useState<string | null>(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<string>
>(new Set());
const [exportingMigration, setExportingMigration] = useState(false);
const [readingMigration, setReadingMigration] = useState(false);
const [applyingMigration, setApplyingMigration] = useState(false);
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [migrationManifest, setMigrationManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [migrationPreflight, setMigrationPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdminForMigration, setUseCurrentAdminForMigration] =
useState(false);
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
const migrationInputRef = useRef<HTMLInputElement>(null);
// 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 (
<div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
@@ -501,9 +684,46 @@ function SkillSettingsList({
? t.settings.skills.rescanSkillsRunning
: t.settings.skills.rescanSkills}
</Button>
{isAdmin && (
<>
<ModelNameSelect
{isAdmin && (
<>
{migrationEnabled && (
<>
<Button
size="sm"
variant="outline"
onClick={() => void handleExportMigration()}
disabled={
selectedMigrationNames.size === 0 || exportingMigration
}
title="导出已选择技能的状态、归属和标签清单"
>
{exportingMigration ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileDownIcon className="size-4" />
)}
导出技能清单
{selectedMigrationNames.size > 0
? `(${selectedMigrationNames.size})`
: ""}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => migrationInputRef.current?.click()}
disabled={readingMigration}
title="识别已复制到运行目录的技能并导入清单信息"
>
{readingMigration ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileUpIcon className="size-4" />
)}
导入技能清单
</Button>
</>
)}
<ModelNameSelect
value={fillModel}
onChange={setFillModel}
disabled={
@@ -586,11 +806,45 @@ function SkillSettingsList({
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{!isLoading && !error && (
<div className="flex min-h-9 flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-sm tabular-nums">
共 {orderedSkills.length} 个技能
{migrationEnabled && selectedMigrationNames.size > 0
? ` · 已选择 ${selectedMigrationNames.size} 个`
: ""}
</p>
{migrationEnabled && orderedSkills.length > 0 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allFilteredMigrationSelected}
onClick={() =>
setSelectedMigrationNames((current) => {
const next = new Set(current);
orderedSkills.forEach((skill) => next.add(skill.name));
return next;
})
}
>
全选当前结果
</Button>
{selectedMigrationNames.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedMigrationNames(new Set())}
>
清空选择
</Button>
)}
</div>
)}
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
@@ -620,20 +874,33 @@ function SkillSettingsList({
: "flex flex-col gap-3",
)}
>
{pagedSkills.map((skill) => (
<SkillRow
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
onPublisherClick={setPublisher}
selected={
migrationEnabled && selectedMigrationNames.has(skill.name)
}
onSelectionToggle={
migrationEnabled
? () => toggleMigrationSelection(skill.name)
: undefined
}
className={cn(
migrationEnabled &&
selectedMigrationNames.has(skill.name) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
/>
))}
</div>
)}
{!isLoading && !error && totalPages > 1 && (
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
@@ -657,10 +924,222 @@ function SkillSettingsList({
>
{t.settings.skills.paginationNext}
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
<InstallSkillDialog
</Button>
</div>
)}
{migrationEnabled && (
<input
ref={migrationInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择技能迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleReadMigration(file);
}}
/>
)}
<Dialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别技能目录并导入技能信息</DialogTitle>
<DialogDescription>
只同步技能归属、发布/上下线状态和标签,不覆盖、移动或删除任何目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">技能目录</span>
<code className="break-all">{migrationRoots.skills_root}</code>
{migrationRoots.extensions_config_path && (
<>
<span className="text-muted-foreground">状态配置</span>
<code className="break-all">
{migrationRoots.extensions_config_path}
</code>
</>
)}
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单中的技能及支持目录,再执行导入。
</p>
</div>
)}
{migrationManifest && migrationPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<SkillMigrationCount
label="技能"
value={migrationManifest.skills.length}
/>
<SkillMigrationCount
label="支持目录"
value={migrationManifest.support_directories.length}
/>
<SkillMigrationCount
label="目录已匹配"
value={
migrationResources.filter(
(resource) => resource.status === "matched",
).length
}
tone="success"
/>
<SkillMigrationCount
label="待处理问题"
value={
migrationProblems.length + unresolvedMigrationOwners.length
}
tone={
migrationProblems.length +
unresolvedMigrationOwners.length >
0
? "danger"
: "success"
}
/>
</div>
{migrationManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{migrationManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{migrationProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
{skipInvalidSkills
? "以下异常关联的技能将在导入时跳过"
: "目录预检未通过,可修正目录或跳过异常技能"}
</p>
<ul className="mt-2 space-y-2 text-xs">
{migrationProblems.slice(0, 50).map((item) => (
<SkillMigrationProblem
key={`${item.kind}:${item.key}`}
item={item}
/>
))}
</ul>
{migrationProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {migrationProblems.length - 50} 项未展示。
</p>
)}
{migrationProblemsCanBeSkipped && skipAnalysis && (
<label className="mt-3 flex min-h-11 cursor-pointer items-start gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Checkbox
className="mt-0.5"
checked={skipInvalidSkills}
onCheckedChange={(checked) =>
setSkipInvalidSkills(checked === true)
}
aria-label="跳过未通过目录预检的技能"
/>
<span>
<span className="block font-medium">
跳过异常技能,继续导入其余技能
</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
将跳过 {skipAnalysis.skippedSkillNames.length} 个,继续导入 {skipAnalysis.remainingSkillCount} 个;被跳过技能不会写入任何元数据。
</span>
</span>
</label>
)}
{!migrationProblemsCanBeSkipped && (
<p className="text-muted-foreground mt-3 text-xs">
当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。
</p>
)}
</div>
)}
{unresolvedMigrationOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedMigrationOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedMigrationOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdminForMigration}
onCheckedChange={setUseCurrentAdminForMigration}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{migrationProblems.length === 0 &&
unresolvedMigrationOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以导入技能信息</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹。
</p>
</div>
</div>
)}
{skipInvalidSkills &&
migrationProblemsCanBeSkipped &&
unresolvedMigrationOwners.length === 0 &&
skipAnalysis && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div>
<p className="font-medium">可以跳过异常技能后继续导入</p>
<p className="text-muted-foreground mt-0.5 text-xs">
服务器执行时会再次校验,并返回实际导入与跳过数量。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setMigrationDialogOpen(false)}
disabled={applyingMigration}
>
取消
</Button>
<Button
onClick={() => void handleApplyMigration()}
disabled={!canApplyMigration || applyingMigration}
>
{applyingMigration && (
<Loader2 className="size-4 animate-spin" />
)}
{skipInvalidSkills && skipAnalysis
? `跳过 ${skipAnalysis.skippedSkillNames.length} 个并导入`
: "导入技能信息"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
@@ -678,15 +1157,69 @@ function SkillSettingsList({
skills={archivedVisible}
/>
</div>
);
}
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 (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={cn("mt-1 text-xl font-semibold", valueClass)}>{value}</p>
</div>
);
}
function SkillMigrationProblem({
item,
}: {
item: MigrationResourcePreflight;
}) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
export function SkillRow({
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 && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setDetailOpen(true)}
title="查看技能详情"
>
<InfoIcon className="size-3.5" />
</Button>
)}
{canToggleAvailability && (
<Button
size="sm"
@@ -1215,11 +1764,23 @@ export function SkillRow({
className={cn(
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
className,
)}
>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
<div
className="min-w-0 flex-1 cursor-pointer"
role={onSelectionToggle ? "checkbox" : "button"}
aria-checked={onSelectionToggle ? selected : undefined}
tabIndex={0}
onClick={() =>
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
}
}}
>
{badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
@@ -1236,12 +1797,24 @@ export function SkillRow({
className={cn(
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
className,
)}
variant="outline"
>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
<ItemContent
className="cursor-pointer"
role={onSelectionToggle ? "checkbox" : "button"}
aria-checked={onSelectionToggle ? selected : undefined}
tabIndex={0}
onClick={() =>
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
}
}}
>
<ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4">
@@ -5,13 +5,22 @@ import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import { useHtmlRuntimeSettings } from "@/core/system-settings/hooks";
import {
DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
getMapTileAttribution,
getMapTileUrl,
MESSAGE_MAP_DISPLAY_MAX_ZOOM,
MESSAGE_MAP_DISPLAY_MIN_ZOOM,
} from "@/core/utils/map-tile-config";
import {
coordinatesMatch,
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
messageMapRasterSource,
overlayPadding,
resolveLineColor,
resolveMarkerColor,
@@ -24,16 +33,16 @@ import {
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification {
function createStyle(minNativeZoom: number, maxNativeZoom: number): StyleSpecification {
return {
version: 8,
sources: {
"google-raster": {
type: "raster",
tiles: [getMapTileUrl()],
tileSize: 256,
attribution: getMapTileAttribution(),
},
"google-raster": messageMapRasterSource(
getMapTileUrl(),
getMapTileAttribution(),
minNativeZoom,
maxNativeZoom,
),
},
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
};
@@ -172,11 +181,16 @@ function paintMapOverlay(
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const { data: htmlRuntime } = useHtmlRuntimeSettings();
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<SVGSVGElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const minNativeZoom =
htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM;
const maxNativeZoom =
htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM;
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
const lines = useMemo(() => styledLines(spec), [spec]);
const markerColors = useMemo(
@@ -200,11 +214,11 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
const map = new maplibregl.Map({
container,
style: createStyle(),
style: createStyle(minNativeZoom, maxNativeZoom),
center: boot.center,
zoom: boot.zoom,
minZoom: 1,
maxZoom: 20,
minZoom: MESSAGE_MAP_DISPLAY_MIN_ZOOM,
maxZoom: MESSAGE_MAP_DISPLAY_MAX_ZOOM,
attributionControl: { compact: true },
});
mapRef.current = map;
@@ -330,7 +344,7 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
mapRef.current = null;
map.remove();
};
}, [coordinates, lines, markerColors, reloadToken, spec]);
}, [coordinates, lines, markerColors, maxNativeZoom, minNativeZoom, reloadToken, spec]);
return (
<VisualizationCard>
@@ -6,6 +6,7 @@ import {
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
messageMapRasterSource,
overlayPadding,
pointAndBearing,
polylineMetrics,
@@ -14,6 +15,18 @@ import {
svgPathFromCoordinates,
} from "./map-style.ts";
test("applies configured native zoom limits to the message-map raster source", () => {
const source = messageMapRasterSource(
"https://tiles.example/{z}/{x}/{y}.png",
"",
0,
8,
);
assert.equal(source.minzoom, 0);
assert.equal(source.maxzoom, 8);
assert.deepEqual(source.tiles, ["https://tiles.example/{z}/{x}/{y}.png"]);
});
test("assigns distinct colors when the model omits them", () => {
assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1));
assert.equal(resolveMarkerColor(0, "#111111"), "#111111");
@@ -9,6 +9,22 @@ export const MAP_MARKER_PALETTE = [
"#4f46e5",
] as const;
export function messageMapRasterSource(
tileUrl: string,
attribution: string,
minNativeZoom: number,
maxNativeZoom: number,
) {
return {
type: "raster" as const,
tiles: [tileUrl],
tileSize: 256,
attribution,
minzoom: minNativeZoom,
maxzoom: maxNativeZoom,
};
}
export type MapPoint = [number, number];
export function resolveMarkerColor(index: number, explicit?: string): string {
@@ -2,24 +2,27 @@
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
const { theme } = useTheme();
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
"flex size-9 shrink-0 items-center justify-center",
className,
)}
aria-hidden
>
C
<AppBrandImage theme={theme} className="size-full object-contain" />
</div>
);
}
@@ -72,7 +75,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
<BrandMark className="size-10" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
@@ -19,6 +19,22 @@ import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/p
// 主题类型
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
export type PresentationThemeContext = {
presentation_theme: 'light' | 'dark';
};
/**
* Snapshot only the current light/dark mode for generated HTML. Product
* accent palettes are intentionally not copied into generated pages.
*/
export function getPresentationThemeContext(
baseMode: 'light' | 'dark',
): PresentationThemeContext {
return {
presentation_theme: baseMode,
};
}
// 主题配置
export interface ThemeConfig {
type: BuiltinThemeId;
@@ -0,0 +1,265 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type MigrationResourceStatus =
| "matched"
| "missing"
| "hash_mismatch"
| "identity_mismatch"
| "invalid_path";
export interface MigrationOwnerReference {
source_user_id?: string | null;
email?: string | null;
}
export interface MigrationTag {
id: string;
name: string;
color: string;
description: string;
}
export interface AgentMigrationItem {
id: string;
relative_dir: string;
tree_hash: string;
name: string;
description: string;
owner?: MigrationOwnerReference | null;
published: boolean;
is_private: boolean;
square_id: string;
featured_order?: number | null;
created_at?: string | null;
updated_at?: string | null;
skills: string[];
tags: MigrationTag[];
}
export interface SkillMigrationItem {
name: string;
category: "public" | "custom";
relative_dir: string;
tree_hash: string;
owner?: MigrationOwnerReference | null;
published: boolean;
detail?: string | null;
name_zh?: string | null;
always_on: boolean;
square_id: string;
enabled: boolean;
state: "active" | "stale" | "archived";
pinned: boolean;
featured: boolean;
source: "agent" | "upload";
created_at?: string | null;
updated_at?: string | null;
tags: MigrationTag[];
}
export interface AgentSkillMigrationManifest {
schema_version: 1;
bundle_id: string;
created_at: string;
agents: AgentMigrationItem[];
skills: SkillMigrationItem[];
support_directories: Array<{
relative_dir: string;
tree_hash: string;
required_by: string[];
}>;
warnings: string[];
}
export interface MigrationResourcePreflight {
kind: "agent" | "skill" | "support";
key: string;
relative_dir: string;
status: MigrationResourceStatus;
message: string;
}
export interface MigrationPreflight {
ready: boolean;
agents: MigrationResourcePreflight[];
skills: MigrationResourcePreflight[];
support_directories: MigrationResourcePreflight[];
owner_mappings: Array<{
source_user_id?: string | null;
email?: string | null;
target_user_id?: string | null;
status: "matched" | "unresolved";
}>;
}
export interface MigrationRoots {
agents_root: string;
skills_root: string;
extensions_config_path?: string | null;
}
export interface MigrationApplyResult {
bundle_id: string;
agents_applied: number;
skills_applied: number;
skills_skipped: number;
skipped_skill_names: string[];
tags_created: number;
fallback_owner_count: number;
}
export interface SkillMigrationSkipAnalysis {
skippedSkillNames: string[];
remainingSkillCount: number;
unskippableProblemCount: number;
unresolvedOwners: MigrationPreflight["owner_mappings"];
}
export function analyzeSkillMigrationSkip(
manifest: AgentSkillMigrationManifest,
preflight: MigrationPreflight,
): SkillMigrationSkipAnalysis {
const failedSkillDirs = new Set(
preflight.skills
.filter((item) => item.status !== "matched")
.map((item) => item.relative_dir),
);
const skipped = new Set(
manifest.skills
.filter((item) => failedSkillDirs.has(item.relative_dir))
.map((item) => item.name),
);
const supportByDir = new Map(
manifest.support_directories.map((item) => [item.relative_dir, item]),
);
let unskippableProblemCount = preflight.agents.filter(
(item) => item.status !== "matched",
).length;
for (const problem of preflight.skills.filter(
(item) => item.status !== "matched",
)) {
if (!manifest.skills.some((item) => item.relative_dir === problem.relative_dir)) {
unskippableProblemCount += 1;
}
}
for (const problem of preflight.support_directories.filter(
(item) => item.status !== "matched",
)) {
const support = supportByDir.get(problem.relative_dir);
const affected = (support?.required_by ?? []).filter((name) =>
manifest.skills.some((item) => item.name === name),
);
if (affected.length === 0) {
unskippableProblemCount += 1;
} else {
affected.forEach((name) => skipped.add(name));
}
}
const remainingSkills = manifest.skills.filter(
(item) => !skipped.has(item.name),
);
const remainingOwnerKeys = new Set(
remainingSkills
.filter(
(item) =>
item.owner &&
(item.owner.source_user_id != null || item.owner.email != null),
)
.map(
(item) =>
`${item.owner?.source_user_id ?? ""}\u0000${item.owner?.email?.trim().toLowerCase() ?? ""}`,
),
);
const unresolvedOwners = preflight.owner_mappings.filter(
(owner) =>
owner.status === "unresolved" &&
remainingOwnerKeys.has(
`${owner.source_user_id ?? ""}\u0000${owner.email?.trim().toLowerCase() ?? ""}`,
),
);
return {
skippedSkillNames: manifest.skills
.filter((item) => skipped.has(item.name))
.map((item) => item.name),
remainingSkillCount: remainingSkills.length,
unskippableProblemCount,
unresolvedOwners,
};
}
const baseUrl = () => `${getBackendBaseURL()}/api/admin/agent-skill-migrations`;
async function parse<T>(response: Response): Promise<T> {
if (response.ok) return (await response.json()) as T;
const payload = (await response.json().catch(() => ({}))) as {
detail?: string | { message?: string };
};
const detail = payload.detail;
const message =
typeof detail === "string"
? detail
: detail?.message ?? `请求失败(${response.status})`;
throw new Error(message);
}
export async function getMigrationRoots(): Promise<MigrationRoots> {
return parse(await apiFetch(`${baseUrl()}/roots`));
}
export async function exportMigrationManifest(
agentIds: string[],
): Promise<AgentSkillMigrationManifest> {
return parse(
await apiFetch(`${baseUrl()}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_ids: agentIds }),
}),
);
}
export async function exportSkillMigrationManifest(
skillNames: string[],
): Promise<AgentSkillMigrationManifest> {
return parse(
await apiFetch(`${baseUrl()}/export-skills`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skill_names: skillNames }),
}),
);
}
export async function preflightMigrationManifest(
manifest: AgentSkillMigrationManifest,
): Promise<MigrationPreflight> {
return parse(
await apiFetch(`${baseUrl()}/preflight`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
}),
);
}
export async function applyMigrationManifest(
manifest: AgentSkillMigrationManifest,
useCurrentAdminForUnresolvedOwners: boolean,
skipInvalidSkills = false,
): Promise<MigrationApplyResult> {
return parse(
await apiFetch(`${baseUrl()}/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
manifest,
use_current_admin_for_unresolved_owners:
useCurrentAdminForUnresolvedOwners,
skip_invalid_skills: skipInvalidSkills,
}),
}),
);
}
@@ -0,0 +1 @@
export * from "./api";
+463 -462
View File
@@ -1,462 +1,463 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
listSidebarAgents,
listAgentsPaginated,
listAllAgentsAdminPaginated,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentHidden,
setAgentPrivate,
syncAgentDirectory,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
type CachedAgents = Agent[] | PaginatedAgents | undefined;
function isPaginated(value: CachedAgents): value is PaginatedAgents {
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
}
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
function mapCachedAgents(
queryClient: ReturnType<typeof useQueryClient>,
map: (a: Agent) => Agent,
) {
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
if (Array.isArray(old)) return old.map(map);
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
return old;
});
}
/** Patch an updated agent into every cached agent listing immediately so
* cards re-render before the background refetch finishes. Without this the
* mutation just invalidates the query and the user has to wait for the
* round-trip + render — which on a big list feels like nothing happened.
*/
function applyAgentToCache(
queryClient: ReturnType<typeof useQueryClient>,
next: Agent,
) {
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({
queryKey: ["agents", search, [...tagIds].sort()],
queryFn: () => listAgents({ search, tagIds }),
enabled: params.enabled ?? true,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square" | "builtin";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const square = params.square ?? "";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: [
"agents",
"paginated",
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
square,
],
queryFn: () =>
listAgentsPaginated({
scope,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
square: square || undefined,
}),
enabled,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
// keepPreviousData keeps isLoading false on subsequent searches; expose
// isFetching so callers can show a "searching" indicator on every query.
isFetching,
error,
};
}
/**
* Admin-only paginated listing — every agent including unpublished agents
* owned by other users and private-square entries, sliced server-side.
* Caller must be ``system_role === "admin"`` or the backend will 403.
* Sort defaults to ``time_asc`` so the console keeps its historical
* oldest-first order.
*/
export function useAllAgentsAdminPaginated(params: {
search?: string;
sort?: AgentSort;
page: number;
pageSize: number;
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
hidden?: boolean;
}) {
const { page, pageSize, hidden } = params;
const search = params.search ?? "";
const sort = params.sort ?? "time_asc";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
};
}
export function useAgent(
id: string | null | undefined,
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
retry,
});
return { agent: data ?? null, isLoading, error };
}
export function useCreateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentRequest) => createAgent(request),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
request,
}: {
id: string;
request: UpdateAgentRequest;
}) => updateAgent(id, request),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useDeleteAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteAgent(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old,
);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
enabled,
staleTime: 60_000,
});
return {
agents: data?.agents ?? [],
isLoading,
error,
};
}
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "sidebar"],
queryFn: listSidebarAgents,
enabled,
staleTime: 60_000,
});
return { agents: data ?? [], isLoading, error };
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
export function useSetAgentFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useReorderFeaturedAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
onSuccess: () => invalidateAgentQueries(queryClient),
});
}
/**
* Query the password-gated private square. ``password`` is sent on every
* fetch — wire ``enabled`` to false until the user has typed it so we don't
* hit the endpoint with an empty body and get a 403.
*/
export function usePrivateAgents(password: string | null) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["agents", "private-square", password ?? ""],
queryFn: () => listPrivateAgents(password!),
enabled: !!password,
staleTime: 30_000,
retry: false,
});
return { agents: data ?? [], isLoading, error, refetch };
}
/** Paginated variant of {@link usePrivateAgents} — one page of the
* password-gated private square plus the total count. */
export function usePrivateAgentsPaginated(
password: string | null,
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
password ?? "",
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
],
queryFn: () =>
listPrivateAgentsPaginated({
password: password!,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
}),
enabled: !!password && enabled,
staleTime: 30_000,
retry: false,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
refetch,
};
}
export function useSetAgentPrivate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
setAgentPrivate(id, isPrivate),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
void queryClient.invalidateQueries({
queryKey: ["agents", "private-square"],
});
},
});
}
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
* from every non-admin listing, so all agent queries are invalidated. */
export function useSetAgentHidden() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
setAgentHidden(id, hidden),
onSuccess: (data) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
},
});
}
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
export function useSyncAgentDirectory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: syncAgentDirectory,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAdminSyncAgentSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: adminSyncAgentSkills,
onSuccess: (result) => {
if (result.modified > 0) {
invalidateAgentQueries(queryClient);
}
},
});
}
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
listSidebarAgents,
listAgentsPaginated,
listAllAgentsAdminPaginated,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentHidden,
setAgentPrivate,
syncAgentDirectory,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
type CachedAgents = Agent[] | PaginatedAgents | undefined;
function isPaginated(value: CachedAgents): value is PaginatedAgents {
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
}
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
function mapCachedAgents(
queryClient: ReturnType<typeof useQueryClient>,
map: (a: Agent) => Agent,
) {
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
if (Array.isArray(old)) return old.map(map);
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
return old;
});
}
/** Patch an updated agent into every cached agent listing immediately so
* cards re-render before the background refetch finishes. Without this the
* mutation just invalidates the query and the user has to wait for the
* round-trip + render — which on a big list feels like nothing happened.
*/
function applyAgentToCache(
queryClient: ReturnType<typeof useQueryClient>,
next: Agent,
) {
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({
queryKey: ["agents", search, [...tagIds].sort()],
queryFn: () => listAgents({ search, tagIds }),
enabled: params.enabled ?? true,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square" | "builtin";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const square = params.square ?? "";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: [
"agents",
"paginated",
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
square,
],
queryFn: () =>
listAgentsPaginated({
scope,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
square: square || undefined,
}),
enabled,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
// keepPreviousData keeps isLoading false on subsequent searches; expose
// isFetching so callers can show a "searching" indicator on every query.
isFetching,
error,
};
}
/**
* Admin-only paginated listing — every agent including unpublished agents
* owned by other users and private-square entries, sliced server-side.
* Caller must be ``system_role === "admin"`` or the backend will 403.
* Sort defaults to ``time_asc`` so the console keeps its historical
* oldest-first order.
*/
export function useAllAgentsAdminPaginated(params: {
search?: string;
sort?: AgentSort;
page: number;
pageSize: number;
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
hidden?: boolean;
}) {
const { page, pageSize, hidden } = params;
const search = params.search ?? "";
const sort = params.sort ?? "time_asc";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
};
}
export function useAgent(
id: string | null | undefined,
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
retry,
});
return { agent: data ?? null, isLoading, error };
}
export function useCreateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentRequest) => createAgent(request),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
request,
}: {
id: string;
request: UpdateAgentRequest;
}) => updateAgent(id, request),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useDeleteAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteAgent(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old,
);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
enabled,
staleTime: 60_000,
});
return {
agents: data?.agents ?? [],
isLoading,
error,
};
}
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "sidebar"],
queryFn: listSidebarAgents,
enabled,
staleTime: 60_000,
});
return { agents: data ?? [], isLoading, error };
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
export function useSetAgentFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useReorderFeaturedAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
onSuccess: () => invalidateAgentQueries(queryClient),
});
}
/**
* Query the password-gated private square. ``password`` is sent on every
* fetch — wire ``enabled`` to false until the user has typed it so we don't
* hit the endpoint with an empty body and get a 403.
*/
export function usePrivateAgents(password: string | null) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["agents", "private-square", password ?? ""],
queryFn: () => listPrivateAgents(password!),
enabled: !!password,
staleTime: 30_000,
retry: false,
});
return { agents: data ?? [], isLoading, error, refetch };
}
/** Paginated variant of {@link usePrivateAgents} — one page of the
* password-gated private square plus the total count. */
export function usePrivateAgentsPaginated(
password: string | null,
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
password ?? "",
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
],
queryFn: () =>
listPrivateAgentsPaginated({
password: password!,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
}),
enabled: !!password && enabled,
staleTime: 30_000,
retry: false,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
refetch,
};
}
export function useSetAgentPrivate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
setAgentPrivate(id, isPrivate),
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
void queryClient.invalidateQueries({
queryKey: ["agents", "private-square"],
});
},
});
}
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
* from every non-admin listing, so all agent queries are invalidated. */
export function useSetAgentHidden() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
setAgentHidden(id, hidden),
onSuccess: (data) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
},
});
}
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
export function useSyncAgentDirectory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: syncAgentDirectory,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAdminSyncAgentSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: adminSyncAgentSkills,
onSuccess: (result) => {
if (result.modified > 0) {
invalidateAgentQueries(queryClient);
}
},
});
}
+40 -5
View File
@@ -21,6 +21,14 @@ type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: st
| Extract<AccountAccess, { status: "unauthorized" }>
| { status: "checking" } | { status: "error"; message: string };
const INITIAL_CHECK_TIMEOUT_MS = 20_000;
const BACKGROUND_CHECK_TIMEOUT_MS = 45_000;
const BACKGROUND_WARNING_AFTER = 2;
function isTransientAuthFailure(error: unknown, aborted: boolean): boolean {
return aborted || error instanceof TypeError;
}
/** Keep protected pages unmounted until the configured authentication check succeeds. */
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
children: ReactNode;
@@ -30,6 +38,9 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
const lastAllowedAuth = useRef<ReturnType<typeof getStoredAuth>>(null);
const transientFailures = useRef(0);
useEffect(() => {
const refresh = () => {
@@ -51,11 +62,19 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const controller = new AbortController();
const requestedAuth = getStoredAuth();
let disposed = false;
requestInFlight.current = true;
if (isRecentlyAuthenticatedLogin(requestedAuth)) {
setState({ status: "allowed", auth: requestedAuth });
return () => controller.abort();
}
const timeout = setTimeout(() => controller.abort(), 20_000);
const backgroundRecheck = lastAllowedAuth.current != null
&& JSON.stringify(lastAllowedAuth.current) === JSON.stringify(requestedAuth);
const timeout = setTimeout(
() => controller.abort(),
backgroundRecheck ? BACKGROUND_CHECK_TIMEOUT_MS : INITIAL_CHECK_TIMEOUT_MS,
);
// A background recheck must not unmount an active editor on every poll.
// A changed account/token still blocks immediately until its own check ends.
// Keep the protected tree mounted while a renewed token for the same
// account is verified. Unmounting here destroys nested iframes and resets
// an active chat even though the user's identity did not change.
@@ -77,6 +96,8 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
writingVerifiedAccount.current = true;
try {
if (result.status === "unauthorized") {
lastAllowedAuth.current = null;
transientFailures.current = 0;
if (requestedAuth) clearStoredAuth();
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
setStoredAuth(result.auth);
@@ -84,16 +105,30 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
} finally {
writingVerifiedAccount.current = false;
}
if (result.status === "allowed") {
lastAllowedAuth.current = result.auth;
transientFailures.current = 0;
}
setState(result);
}).catch((error: unknown) => {
if (disposed) return;
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed"
&& isSameAccountIdentity(previous.auth, requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth)
? { ...previous, warning: message } : { status: "error", message });
setState((previous) => {
const keepAllowed = previous.status === "allowed"
&& isSameAccountIdentity(previous.auth, requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth);
if (!keepAllowed) return { status: "error", message };
// After a long Q&A the browser is still downloading artifacts / holding
// SSE, so the 15s login poll often waits in the 6-connection queue and
// aborts. One transient miss is not a real session failure.
if (isTransientAuthFailure(error, controller.signal.aborted)) {
transientFailures.current += 1;
if (transientFailures.current < BACKGROUND_WARNING_AFTER) return previous;
}
return { ...previous, warning: message };
});
}).finally(() => {
clearTimeout(timeout);
});
@@ -1,65 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TaskEntryInputError,
buildMainAppUrl,
buildTaskEntryDestination,
buildTaskEntrySessionSearch,
parseTaskEntryParams,
} from "./task-entry.ts";
test("parses the dedicated single-agent task entry", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=XDFX&theme=dark-blue&hideJump=1",
);
assert.deepEqual(params, {
username: "admin",
password: "",
taskId: "108",
goPath: "xdfx",
theme: "dark-blue",
hideJump: true,
});
});
test("rejects missing identity, task, and roundtable routes", () => {
assert.throws(() => parseTaskEntryParams("?taskId=108&goPath=xdfx"), TaskEntryInputError);
assert.throws(() => parseTaskEntryParams("?username=admin&goPath=xdfx"), TaskEntryInputError);
assert.throws(
() => parseTaskEntryParams("?username=admin&taskId=108&goPath=xdfx-A"),
TaskEntryInputError,
);
});
test("forces isolated embed state and an allowlisted task target", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=xdfx&hideJump=true",
);
const session = new URLSearchParams(buildTaskEntrySessionSearch(params));
assert.equal(session.get("embed"), "1");
assert.equal(session.get("hideChrome"), "1");
assert.equal(session.get("isolate"), "1");
assert.equal(session.get("hideJump"), "1");
const destination = buildTaskEntryDestination(
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
params,
);
assert.equal(
destination,
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats?embed=1&hideChrome=1&isolate=1&hideJump=1",
);
assert.throws(() => buildTaskEntryDestination("https://example.com", params));
assert.throws(() => buildTaskEntryDestination("/page/workspace/chats/new", params));
});
test("returns to the main app at the same deployment prefix", () => {
assert.equal(
buildMainAppUrl(
"https://example.com/deerflow/task-entry.html?username=admin",
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
),
"https://example.com/deerflow/#/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
);
});
-82
View File
@@ -1,82 +0,0 @@
export const TASK_ENTRY_SUPPORTED_GOPATHS = ["3qfx", "rwfx", "xdfx"] as const;
export type TaskEntryGoPath = (typeof TASK_ENTRY_SUPPORTED_GOPATHS)[number];
export interface TaskEntryParams {
username: string;
password: string;
taskId: string;
goPath: TaskEntryGoPath;
theme: string;
hideJump: boolean;
}
export class TaskEntryInputError extends Error {
constructor(message: string) {
super(message);
this.name = "TaskEntryInputError";
}
}
function readFlag(value: string | null): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
export function parseTaskEntryParams(search: string): TaskEntryParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const username = (params.get("username") ?? "").trim();
const password = params.get("password") ?? "";
const taskId = (params.get("taskId") ?? "").trim();
const rawGoPath = (params.get("goPath") ?? "").trim().toLowerCase();
const theme = (params.get("theme") ?? "").trim();
if (!username) throw new TaskEntryInputError("入口缺少 username,无法登录任务问答。");
if (!taskId) throw new TaskEntryInputError("入口缺少 taskId,无法定位任务。");
if (!TASK_ENTRY_SUPPORTED_GOPATHS.includes(rawGoPath as TaskEntryGoPath)) {
throw new TaskEntryInputError("轻量入口仅支持 3qfx、rwfx 或 xdfx 单智能体任务问答。");
}
return {
username,
password,
taskId,
goPath: rawGoPath as TaskEntryGoPath,
theme,
hideJump: readFlag(params.get("hideJump")),
};
}
export function buildTaskEntrySessionSearch(params: TaskEntryParams): string {
const session = new URLSearchParams({
embed: "1",
hideChrome: "1",
isolate: "1",
});
if (params.hideJump) session.set("hideJump", "1");
if (params.theme) session.set("theme", params.theme);
return `?${session.toString()}`;
}
export function buildTaskEntryDestination(path: string, params: TaskEntryParams): string {
if (!path.startsWith("/page/strategy/task-management/") || path.includes("\\")) {
throw new Error("任务深链解析出了不受允许的目标页面。");
}
const url = new URL(path, "https://task-entry.invalid");
if (url.origin !== "https://task-entry.invalid") {
throw new Error("任务深链目标必须是系统内部页面。");
}
url.searchParams.set("embed", "1");
url.searchParams.set("hideChrome", "1");
url.searchParams.set("isolate", "1");
if (params.hideJump) url.searchParams.set("hideJump", "1");
if (params.theme) url.searchParams.set("theme", params.theme);
return `${url.pathname}${url.search}`;
}
export function buildMainAppUrl(currentHref: string, destination: string): string {
const appUrl = new URL(".", currentHref);
appUrl.search = "";
appUrl.hash = destination;
return appUrl.toString();
}
@@ -24,15 +24,6 @@ const headerRuntimeKeys: Record<BrandTheme, string> = {
dark: "VITE_BRAND_LOGO_DARK_URL",
};
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_WELCOME_LIGHT_URL",
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
function runtimeConfigBaseUrl(): string | undefined {
if (typeof document === "undefined") {
return undefined;
@@ -77,11 +68,3 @@ export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
+1
View File
@@ -5,6 +5,7 @@ export {
useKnowledgeRagEnabled,
} from "./knowledge-rag";
export {
DEFAULT_FONT_SCALE,
getLastAgentId,
isThreadRagModeLocked,
lockThreadRagMode,
+3 -1
View File
@@ -11,6 +11,8 @@ import {
type ComposerSessionSettings,
} from "./composer-scope";
export const DEFAULT_FONT_SCALE = 1.375;
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
@@ -21,7 +23,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [],
fontScale: 1,
fontScale: DEFAULT_FONT_SCALE,
fontScaleMode: "text",
},
context: {
+248 -80
View File
@@ -309,6 +309,8 @@ export interface HtmlMapSettings {
export interface HtmlRuntimeSettings {
base_url: string;
map_tile_url: string;
message_map_tile_min_zoom: number;
message_map_tile_max_zoom: number;
}
export interface AppIdentitySettings {
@@ -543,6 +545,43 @@ export interface AppearanceUpdate {
custom_themes?: CustomThemePreset[];
}
export interface GeneratedPageBrandingSettings {
chrome_enabled: boolean;
header_layout: "left" | "center";
system_name: string | null;
footer_text: string | null;
logo_mode: GeneratedPageBrandLogoMode;
logo_url: string | null;
logo_version: string | null;
customized: boolean;
active: boolean;
}
export type GeneratedPageBrandLogoMode = "custom" | "default" | "none";
export interface GeneratedPageBrandingUpdate {
chromeEnabled?: boolean;
headerLayout?: "left" | "center";
systemName: string;
footerText?: string;
logoMode?: GeneratedPageBrandLogoMode;
logo?: File | null;
removeLogo?: boolean;
}
export interface AppIdentitySettings {
system_name: string;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
logo?: File | null;
removeLogo?: boolean;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/appearance`,
@@ -590,6 +629,172 @@ export async function updateAppearanceSettings(
return data;
}
export async function loadGeneratedPageBrandingSettings(): Promise<GeneratedPageBrandingSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
);
if (!response.ok) {
throw new Error(`Failed to load generated-page branding: ${response.status}`);
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-生成网页标识查看",
url: "/api/system-settings/generated-page-branding",
returnParam: data,
});
return data;
}
export async function updateGeneratedPageBrandingSettings(
update: GeneratedPageBrandingUpdate,
): Promise<GeneratedPageBrandingSettings> {
const body = new FormData();
body.set("chrome_enabled", String(update.chromeEnabled ?? true));
body.set("header_layout", update.headerLayout ?? "center");
body.set("system_name", update.systemName);
body.set("footer_text", update.footerText ?? "");
body.set("logo_mode", update.logoMode ?? "custom");
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update generated-page identity" }));
throw new Error(detail.detail ?? "Failed to update generated-page identity");
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-生成网页标识编辑",
url: "/api/system-settings/generated-page-branding",
reqParam: {
chrome_enabled: update.chromeEnabled ?? true,
header_layout: update.headerLayout ?? "center",
system_name: update.systemName,
footer_text: update.footerText ?? "",
logo_mode: update.logoMode ?? "custom",
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteGeneratedPageBrandingSettings(): Promise<GeneratedPageBrandingSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/generated-page-branding`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset generated-page identity" }));
throw new Error(detail.detail ?? "Failed to reset generated-page identity");
}
const data = (await response.json()) as GeneratedPageBrandingSettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-生成网页标识清除配置",
url: "/api/system-settings/generated-page-branding",
returnParam: data,
});
return data;
}
export async function loadAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
);
if (!response.ok) {
throw new Error(`Failed to load app identity: ${response.status}`);
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-系统名称查看",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export async function updateAppIdentitySettings(
update: AppIdentityUpdate,
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update app identity" }));
throw new Error(detail.detail ?? "Failed to update app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-系统名称编辑",
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset app identity" }));
throw new Error(detail.detail ?? "Failed to reset app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-系统名称清除配置",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export function resolveGeneratedPageLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/citation-display`,
@@ -699,85 +904,6 @@ export async function updateHtmlMapSettings(
return data;
}
export async function loadAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
);
if (!response.ok) {
throw new Error(`Failed to load app identity: ${response.status}`);
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-系统名称查看",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export async function updateAppIdentitySettings(
update: AppIdentityUpdate,
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update app identity" }));
throw new Error(detail.detail ?? "Failed to update app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-系统名称编辑",
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset app identity" }));
throw new Error(detail.detail ?? "Failed to reset app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-系统名称清除配置",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/html-runtime`,
@@ -819,7 +945,7 @@ export async function updateHtmlRuntimeSettings(
const data = (await response.json()) as HtmlRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-HTML运行地址编辑",
optDetail: "CM助手-全量-系统设置-HTML运行地址与消息地图瓦片层级编辑",
url: "/api/system-settings/html-runtime",
reqParam: body,
returnParam: data,
@@ -887,6 +1013,48 @@ export interface MemoryRuntimeSettings {
enabled: boolean;
}
export interface AgentSkillMigrationSettings {
enabled: boolean;
}
export async function loadAgentSkillMigrationSettings(): Promise<AgentSkillMigrationSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-skill-migration`,
);
if (!response.ok) {
throw new Error(`无法读取智能体与技能迁移开关(HTTP ${response.status})`);
}
return (await response.json()) as AgentSkillMigrationSettings;
}
export async function updateAgentSkillMigrationSettings(
body: AgentSkillMigrationSettings,
): Promise<AgentSkillMigrationSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-skill-migration`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存智能体与技能迁移开关失败" }));
throw new Error(detail.detail ?? "保存智能体与技能迁移开关失败");
}
const data = (await response.json()) as AgentSkillMigrationSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-智能体与技能迁移开关",
url: "/api/system-settings/agent-skill-migration",
reqParam: body,
returnParam: data,
});
return data;
}
export interface ConversationContextSettings {
max_run_steps: number;
recent_turn_window_enabled: boolean;
+114 -41
View File
@@ -4,12 +4,14 @@ import { useLocalSettings } from "@/core/settings";
import {
loadAgentModuleConfig,
loadAgentSkillMigrationSettings,
loadAssistantKnowledgeVisibility,
loadAppearanceSettings,
loadAppIdentitySettings,
loadGeneratedPageBrandingSettings,
loadCitationDisplaySettings,
loadConversationContextSettings,
loadRoundtableRoomLimitSettings,
loadAppIdentitySettings,
loadHtmlMapSettings,
loadHtmlRuntimeSettings,
loadLeaderboardSettings,
@@ -26,11 +28,13 @@ import {
loadWxCommonAgents,
loadWriteFileCompatibilitySettings,
updateAgentModuleConfig,
updateAgentSkillMigrationSettings,
updateAppearanceSettings,
updateAppIdentitySettings,
updateGeneratedPageBrandingSettings,
updateCitationDisplaySettings,
updateConversationContextSettings,
updateRoundtableRoomLimitSettings,
updateAppIdentitySettings,
updateHtmlMapSettings,
updateHtmlRuntimeSettings,
updateLeaderboardSettings,
@@ -48,8 +52,11 @@ import {
updateWriteFileCompatibilitySettings,
saveAssistantKnowledgeVisibility,
deleteAppIdentitySettings,
deleteGeneratedPageBrandingSettings,
type AppearanceUpdate,
type AgentSkillMigrationSettings,
type AppIdentityUpdate,
type GeneratedPageBrandingUpdate,
type CitationDisplayUpdate,
type ConversationContextUpdate,
type RoundtableRoomLimitUpdate,
@@ -75,6 +82,10 @@ const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const GENERATED_PAGE_BRANDING_KEY = [
"systemSettings",
"generatedPageBranding",
] as const;
const APP_IDENTITY_KEY = ["systemSettings", "appIdentity"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
@@ -83,6 +94,10 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
export const AGENT_SKILL_MIGRATION_KEY = [
"systemSettings",
"agentSkillMigration",
] as const;
const WRITE_FILE_COMPATIBILITY_KEY = [
"systemSettings",
"writeFileCompatibility",
@@ -205,6 +220,30 @@ export function useUpdateMemoryRuntimeSettings() {
});
}
export function useAgentSkillMigrationSettings({
enabled = true,
}: { enabled?: boolean } = {}) {
return useQuery({
queryKey: AGENT_SKILL_MIGRATION_KEY,
queryFn: loadAgentSkillMigrationSettings,
enabled,
staleTime: 10_000,
refetchOnWindowFocus: true,
retry: false,
});
}
export function useUpdateAgentSkillMigrationSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AgentSkillMigrationSettings) =>
updateAgentSkillMigrationSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(AGENT_SKILL_MIGRATION_KEY, data);
},
});
}
export function useWriteFileCompatibilitySettings() {
return useQuery({
queryKey: WRITE_FILE_COMPATIBILITY_KEY,
@@ -341,6 +380,79 @@ export function useUpdateAppearanceSettings() {
});
}
export function useGeneratedPageBrandingSettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: GENERATED_PAGE_BRANDING_KEY,
queryFn: loadGeneratedPageBrandingSettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateGeneratedPageBrandingSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: GeneratedPageBrandingUpdate) =>
updateGeneratedPageBrandingSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(GENERATED_PAGE_BRANDING_KEY, data);
},
});
}
export function useDeleteGeneratedPageBrandingSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteGeneratedPageBrandingSettings,
onSuccess: (data) => {
queryClient.setQueryData(GENERATED_PAGE_BRANDING_KEY, data);
},
});
}
export function useAppIdentitySettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APP_IDENTITY_KEY,
queryFn: loadAppIdentitySettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useAppSystemName(fallback: string) {
const { data } = useAppIdentitySettings();
const configured = data?.system_name.trim();
return configured || fallback;
}
export function useUpdateAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppIdentityUpdate) => updateAppIdentitySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useDeleteAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAppIdentitySettings,
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
/**
* Effective card/list display mode for the skill or agent list. A user's own
* choice (persisted in local settings) wins; otherwise the admin-configured
@@ -461,45 +573,6 @@ export function useUpdateHtmlMapSettings() {
});
}
export function useAppIdentitySettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APP_IDENTITY_KEY,
queryFn: loadAppIdentitySettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useAppSystemName(fallback: string) {
const { data } = useAppIdentitySettings();
const configured = data?.system_name.trim();
return configured || fallback;
}
export function useUpdateAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppIdentityUpdate) => updateAppIdentitySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useDeleteAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAppIdentitySettings,
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useHtmlRuntimeSettings() {
return useQuery({
queryKey: HTML_RUNTIME_KEY,
File diff suppressed because it is too large. Load diff
+389
View File
@@ -0,0 +1,389 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL, getLangGraphBaseURL } from "@/core/config";
import { downloadAsFile } from "@/core/threads/export";
import type { AgentThread } from "@/core/threads/types";
import { OptType, writeYLog } from "@/core/log/y-log";
export const CHAT_HISTORY_FORMAT = "deerflow-chat-history-ui";
export const CHAT_HISTORY_VERSION = 1;
export const CHAT_HISTORY_BATCH_SIZE = 1;
export interface TransferFile {
path: string;
content_b64: string;
size: number;
}
export interface TransferThread {
thread_id: string;
title: string;
assistant_id?: string | null;
metadata: Record<string, unknown>;
created_at?: string;
updated_at?: string;
values: Record<string, unknown>;
files: TransferFile[];
skipped_files?: string[];
}
export interface SkippedThread {
thread_id: string;
title?: string;
reason: string;
}
export interface TransferBundle {
format: string;
version: number;
exported_at?: string;
threads: TransferThread[];
skipped?: SkippedThread[];
}
export interface ExportBatchResult {
threads: TransferThread[];
skipped: SkippedThread[];
}
export interface ImportBatchResult {
imported: number;
skipped: SkippedThread[];
}
export type TransferProgress = {
total: number;
processed: number;
succeeded: number;
skipped: number;
};
export interface ExportAllStatus {
job_id: string;
status: "running" | "done" | "error" | string;
total: number;
processed: number;
succeeded: number;
skipped: SkippedThread[];
skipped_files: number;
error?: string | null;
}
const baseUrl = () => `${getBackendBaseURL()}/api/chat-history`;
async function parseError(response: Response, fallback: string): Promise<Error> {
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
const detail = data.detail;
const message =
typeof detail === "string" && detail
? detail
: `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
export function countSkippedFiles(threads: TransferThread[]): number {
return threads.reduce(
(sum, item) => sum + (item.skipped_files?.length ?? 0),
0,
);
}
export function skipReasonLabel(reason: string): string {
switch (reason) {
case "too_large":
return "体积过大";
case "export_failed":
return "导出失败";
case "import_failed":
return "导入失败";
case "exists":
return "目标已有同名记录";
case "not_found":
return "记录不存在";
case "system_thread":
return "系统对话";
case "invalid_id":
return "编号不合法";
default:
return reason || "已跳过";
}
}
export async function startExportAllJob(): Promise<string> {
const response = await apiFetch(`${baseUrl()}/export-all`, {
method: "POST",
});
if (!response.ok) {
throw await parseError(response, "启动导出失败");
}
const result = (await response.json()) as { job_id: string };
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-对话记录-后台导出",
url: `${baseUrl()}/export-all`,
reqParam: {},
returnParam: result,
});
return result.job_id;
}
export async function getExportAllStatus(jobId: string): Promise<ExportAllStatus> {
const response = await apiFetch(`${baseUrl()}/export-all/${jobId}`);
if (!response.ok) {
throw await parseError(response, "查询导出进度失败");
}
return (await response.json()) as ExportAllStatus;
}
export async function downloadExportAllJob(jobId: string) {
const response = await apiFetch(`${baseUrl()}/export-all/${jobId}/download`);
if (!response.ok) {
throw await parseError(response, "下载导出文件失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "deerflow-chats.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function wait(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export async function exportAllThreadsInBackground(
onProgress?: (progress: TransferProgress) => void,
): Promise<ExportAllStatus> {
const jobId = await startExportAllJob();
while (true) {
const status = await getExportAllStatus(jobId);
onProgress?.({
total: status.total,
processed: status.processed,
succeeded: status.succeeded,
skipped: status.skipped.length,
});
if (status.status === "done") {
if (status.succeeded === 0) {
return status;
}
await downloadExportAllJob(jobId);
return status;
}
if (status.status === "error") {
throw new Error(status.error || "导出失败");
}
await wait(400);
}
}
export async function listExportableThreadIds(): Promise<string[]> {
const ids: string[] = [];
const pageSize = 100;
let offset = 0;
while (true) {
const response = await apiFetch(`${getLangGraphBaseURL()}/threads/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: pageSize,
offset,
metadata: {},
}),
});
if (!response.ok) {
throw new Error("加载对话列表失败");
}
const rows = (await response.json()) as AgentThread[];
ids.push(...rows.map((row) => row.thread_id).filter(Boolean));
if (rows.length < pageSize) {
break;
}
offset += rows.length;
}
return ids;
}
export async function exportChatHistoryBatch(
threadIds: string[],
): Promise<ExportBatchResult> {
const response = await apiFetch(`${baseUrl()}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_ids: threadIds }),
});
if (!response.ok) {
throw await parseError(response, "导出失败");
}
const result = (await response.json()) as ExportBatchResult;
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-对话记录-分批导出",
url: `${baseUrl()}/export`,
reqParam: { count: threadIds.length },
returnParam: {
exported: result.threads.length,
skipped: result.skipped.length,
},
});
return result;
}
export async function importChatHistoryBatch(
threads: TransferThread[],
): Promise<ImportBatchResult> {
const response = await apiFetch(`${baseUrl()}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threads }),
});
if (!response.ok) {
throw await parseError(response, "导入失败");
}
const result = (await response.json()) as ImportBatchResult;
writeYLog({
optType: OptType.import,
optDetail: "CM助手-全量-对话记录-分批导入",
url: `${baseUrl()}/import`,
reqParam: { count: threads.length },
returnParam: result,
});
return result;
}
export function parseTransferBundle(raw: string): TransferThread[] {
const parsed = JSON.parse(raw) as Partial<TransferBundle> | TransferThread;
if (parsed && typeof parsed === "object" && "threads" in parsed) {
const bundle = parsed as TransferBundle;
if (
bundle.format &&
bundle.format !== CHAT_HISTORY_FORMAT
) {
throw new Error("这不是对话记录迁移文件");
}
if (!Array.isArray(bundle.threads) || bundle.threads.length === 0) {
throw new Error("文件里没有可导入的对话");
}
return bundle.threads;
}
if (parsed && typeof parsed === "object" && "thread_id" in parsed) {
return [parsed as TransferThread];
}
throw new Error("无法识别该文件格式");
}
function chunk<T>(items: T[], size: number): T[][] {
const groups: T[][] = [];
for (let index = 0; index < items.length; index += size) {
groups.push(items.slice(index, index + size));
}
return groups;
}
async function exportIdsInBatches(
threadIds: string[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ threads: TransferThread[]; skipped: SkippedThread[] }> {
const threads: TransferThread[] = [];
const skipped: SkippedThread[] = [];
const batches = chunk(threadIds, CHAT_HISTORY_BATCH_SIZE);
let processed = 0;
for (const batch of batches) {
try {
const result = await exportChatHistoryBatch(batch);
threads.push(...result.threads);
skipped.push(...result.skipped);
} catch {
for (const threadId of batch) {
try {
const result = await exportChatHistoryBatch([threadId]);
threads.push(...result.threads);
skipped.push(...result.skipped);
} catch {
skipped.push({ thread_id: threadId, reason: "export_failed" });
}
}
}
processed += batch.length;
onProgress?.({
total: threadIds.length,
processed,
succeeded: threads.length,
skipped: skipped.length,
});
}
return { threads, skipped };
}
export async function exportSelectedThreads(
threadIds: string[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ threads: TransferThread[]; skipped: SkippedThread[] }> {
if (threadIds.length === 0) {
throw new Error("没有可导出的对话");
}
return exportIdsInBatches(threadIds, onProgress);
}
export function downloadTransferBundle(
threads: TransferThread[],
skipped: SkippedThread[],
filename = "deerflow-chats",
) {
const bundle: TransferBundle = {
format: CHAT_HISTORY_FORMAT,
version: CHAT_HISTORY_VERSION,
exported_at: new Date().toISOString(),
threads,
skipped,
};
downloadAsFile(
JSON.stringify(bundle),
`${filename}.json`,
"application/json;charset=utf-8",
);
}
export async function importTransferThreads(
threads: TransferThread[],
onProgress?: (progress: TransferProgress) => void,
): Promise<{ imported: number; skipped: SkippedThread[] }> {
const skipped: SkippedThread[] = [];
let imported = 0;
const batches = chunk(threads, CHAT_HISTORY_BATCH_SIZE);
let processed = 0;
for (const batch of batches) {
try {
const result = await importChatHistoryBatch(batch);
imported += result.imported;
skipped.push(...result.skipped);
} catch {
for (const thread of batch) {
try {
const result = await importChatHistoryBatch([thread]);
imported += result.imported;
skipped.push(...result.skipped);
} catch {
skipped.push({
thread_id: thread.thread_id,
title: thread.title,
reason: "import_failed",
});
}
}
}
processed += batch.length;
onProgress?.({
total: threads.length,
processed,
succeeded: imported,
skipped: skipped.length,
});
}
return { imported, skipped };
}
@@ -8,12 +8,20 @@ import test from "node:test";
import {
clientRectsFollowZoom,
divideCssPx,
fontSpacingMode,
fontScalePresentation,
normalizeFontScaleMode,
popperCounterZoom,
toCssPx,
} from "./page-zoom";
test("only 20px and 22px font scales use compact spacing", () => {
assert.equal(fontSpacingMode(1), "standard");
assert.equal(fontSpacingMode(1.125), "standard");
assert.equal(fontSpacingMode(1.25), "compact");
assert.equal(fontSpacingMode(1.375), "compact");
});
test("text mode keeps the token scale and does not zoom the page", () => {
const presentation = fontScalePresentation("text", 1.25);
assert.equal(presentation.mode, "text");
+7 -1
View File
@@ -23,6 +23,10 @@ export function normalizeFontScale(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1;
}
export function fontSpacingMode(scale: number): "standard" | "compact" {
return normalizeFontScale(scale) >= 1.25 ? "compact" : "standard";
}
export function normalizeFontScaleMode(value: unknown): FontScaleMode {
return value === "page" ? "page" : "text";
}
@@ -270,7 +274,8 @@ export function setPageZoomCompensation(zoom: number) {
export function applyFontScaleToDocument(mode: FontScaleMode, scale: number) {
if (typeof document === "undefined") return;
const presentation = fontScalePresentation(mode, scale);
const normalizedScale = normalizeFontScale(scale);
const presentation = fontScalePresentation(mode, normalizedScale);
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", presentation.cssVariable);
if (presentation.zoom == null) {
@@ -283,5 +288,6 @@ export function applyFontScaleToDocument(mode: FontScaleMode, scale: number) {
}
root.style.removeProperty("font-size");
root.dataset.fontScaleMode = presentation.mode;
root.dataset.fontSpacing = fontSpacingMode(normalizedScale);
setPageZoomCompensation(presentation.compensation);
}
@@ -3,6 +3,11 @@ import { readRuntimeEnv } from "@/env";
export const DEFAULT_MAP_TILE_URL =
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
export const DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM = 0;
export const DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM = 8;
export const MESSAGE_MAP_DISPLAY_MIN_ZOOM = 1;
export const MESSAGE_MAP_DISPLAY_MAX_ZOOM = 20;
/** Browser-side tile template used only by ordinary-Q&A message-list maps. */
export function getMapTileUrl(): string {
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
+9
View File
@@ -49,6 +49,11 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Red-white theme opt-in for the same navigation shell used by the dark
* theme. Only the artwork and colour treatment differ.
*/
LIGHT_RED_TOP_NAV_ENABLED: readRuntimeFlag("VITE_LIGHT_RED_TOP_NAV_ENABLED"),
/**
* Development-only default entry switch for the isolated Vite-native
* official-style workspace. It never starts or embeds a Next.js app.
@@ -63,3 +68,7 @@ export const env = {
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
export function isLightRedTopNavEnabled(theme: string): boolean {
return theme === "light" && env.LIGHT_RED_TOP_NAV_ENABLED;
}
+517 -23
View File
@@ -1,10 +1,21 @@
"use client";
import { AlertCircle, BotIcon, CheckCircle2, RefreshCwIcon, SearchIcon } from "lucide-react";
import { useEffect, useState } from "react";
import {
AlertCircle,
BotIcon,
CheckCircle2,
DownloadIcon,
Loader2Icon,
RefreshCwIcon,
SearchIcon,
UploadIcon,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -28,14 +39,28 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { AgentCard } from "@/components/workspace/agents/agent-card";
import {
applyMigrationManifest,
exportMigrationManifest,
getMigrationRoots,
preflightMigrationManifest,
} from "@/core/agent-migrations";
import type {
AgentSkillMigrationManifest,
MigrationPreflight,
MigrationResourcePreflight,
MigrationRoots,
} from "@/core/agent-migrations";
import {
useAdminSyncAgentSkills,
useAllAgentsAdminPaginated,
useSetAgentHidden,
} from "@/core/agents";
import type { AgentSkillsSyncResult } from "@/core/agents/api";
import { useAgentSkillMigrationSettings } from "@/core/system-settings/hooks";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
@@ -66,11 +91,14 @@ const PAGE_SIZE = 12;
type HiddenFilter = "all" | "visible" | "hidden";
export default function AdminAgentsPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const { data: migrationSettings } = useAgentSkillMigrationSettings();
const migrationEnabled = migrationSettings?.enabled ?? false;
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [hiddenFilter, setHiddenFilter] = useState<HiddenFilter>("all");
@@ -92,8 +120,21 @@ export default function AdminAgentsPage() {
null,
);
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const [readingImport, setReadingImport] = useState(false);
const [applyingImport, setApplyingImport] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importManifest, setImportManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [importPreflight, setImportPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdmin, setUseCurrentAdmin] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
// 删除最后一页的最后一条后,当前页可能超出总页数 — 收敛回末页。
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (!isLoading && total > 0 && page > totalPages) {
@@ -125,7 +166,6 @@ export default function AdminAgentsPage() {
}
}
/** 搜索改为服务端过滤:输入变化即回到第一页。 */
function handleSearchChange(value: string) {
setSearch(value);
setPage(1);
@@ -136,7 +176,6 @@ export default function AdminAgentsPage() {
setPage(1);
}
/** 隐藏/取消隐藏:隐藏后除本管理页外不再展示,但智能体仍可正常使用。 */
async function handleToggleHidden(agentId: string, agentName: string, next: boolean) {
try {
await setHidden.mutateAsync({ id: agentId, hidden: next });
@@ -154,19 +193,191 @@ export default function AdminAgentsPage() {
}
}
const allPageSelected =
agents.length > 0 && agents.every((agent) => selectedIds.has(agent.id));
const importResources = useMemo(
() =>
importPreflight
? [
...importPreflight.agents,
...importPreflight.skills,
...importPreflight.support_directories,
]
: [],
[importPreflight],
);
const importProblems = importResources.filter(
(item) => item.status !== "matched",
);
const unresolvedOwners =
importPreflight?.owner_mappings.filter(
(owner) => owner.status === "unresolved",
) ?? [];
const canApplyImport =
!!importManifest &&
!!importPreflight &&
importProblems.length === 0 &&
(unresolvedOwners.length === 0 || useCurrentAdmin);
function toggleAgentSelection(id: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleExportManifest() {
if (selectedIds.size === 0) return;
setExporting(true);
try {
const manifest = await exportMigrationManifest([...selectedIds]);
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `agent-sync-${manifest.bundle_id.slice(0, 8)}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
const warningText = manifest.warnings.length
? `;另有 ${manifest.warnings.length} 条提醒,请查看清单 warnings`
: "";
toast.success(
`已导出 ${manifest.agents.length} 个智能体的迁移清单(包含技能名称关联)${warningText}`,
darkToastOpts.success,
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : String(err),
darkToastOpts.error,
);
} finally {
setExporting(false);
}
}
async function handleImportFile(file: File) {
if (file.size > 5 * 1024 * 1024) {
toast.error("迁移清单不能超过 5 MB", darkToastOpts.error);
return;
}
setReadingImport(true);
setUseCurrentAdmin(false);
try {
const parsed = JSON.parse(await file.text()) as AgentSkillMigrationManifest;
if (
parsed.schema_version !== 1 ||
!Array.isArray(parsed.agents) ||
!Array.isArray(parsed.skills) ||
!Array.isArray(parsed.support_directories)
) {
throw new Error("不是受支持的智能体迁移清单");
}
if (parsed.agents.length === 0 || parsed.skills.length > 0) {
throw new Error("请选择从智能体管理导出的清单;技能清单请到技能管理导入");
}
const [preflight, roots] = await Promise.all([
preflightMigrationManifest(parsed),
getMigrationRoots(),
]);
setImportManifest(parsed);
setImportPreflight(preflight);
setMigrationRoots(roots);
setImportDialogOpen(true);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "迁移清单读取失败",
darkToastOpts.error,
);
} finally {
setReadingImport(false);
}
}
async function handleApplyImport() {
if (!importManifest || !canApplyImport) return;
setApplyingImport(true);
try {
const result = await applyMigrationManifest(
importManifest,
useCurrentAdmin,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["agents"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
]);
toast.success(
`同步完成:${result.agents_applied} 个智能体`,
darkToastOpts.success,
);
setImportDialogOpen(false);
setImportManifest(null);
setImportPreflight(null);
} catch (err) {
toast.error(
err instanceof Error ? err.message : String(err),
darkToastOpts.error,
);
} finally {
setApplyingImport(false);
}
}
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col">
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
<div className="flex flex-col gap-4 border-b px-6 py-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 className="text-xl font-semibold">智能体管理</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
</p>
{migrationEnabled && (
<p className="text-muted-foreground mt-1 text-xs">
清单只包含智能体信息和技能名称关联,不包含技能信息或目录文件;数据量大时可分批点击卡片选择后导出。
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
{migrationEnabled && (
<>
<Button
variant="outline"
size="sm"
onClick={handleExportManifest}
disabled={selectedIds.size === 0 || exporting}
>
{exporting ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<DownloadIcon className="mr-1.5 size-4" />
)}
导出迁移清单
{selectedIds.size ? `(${selectedIds.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={readingImport}
>
{readingImport ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<UploadIcon className="mr-1.5 size-4" />
)}
识别目录并同步信息
</Button>
</>
)}
<Button
variant="outline"
size="sm"
@@ -192,7 +403,7 @@ export default function AdminAgentsPage() {
<SelectItem value="hidden">已隐藏</SelectItem>
</SelectContent>
</Select>
<div className="relative w-64">
<div className="relative min-w-56 flex-1 xl:w-64 xl:flex-none">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
@@ -224,21 +435,105 @@ export default function AdminAgentsPage() {
</div>
) : (
<>
<p className="text-muted-foreground mb-3 text-xs">
共 {total} 个智能体。
{isFetching ? " 正在加载…" : ""}
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onToggleHidden={(next) =>
handleToggleHidden(agent.id, agent.name, next)
}
toggleHiddenPending={setHidden.isPending}
/>
))}
{migrationEnabled ? (
<div className="mb-3 flex min-h-8 items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
点击智能体卡片选择 · 共 {total} 个智能体
{selectedIds.size > 0
? ` · 已选择 ${selectedIds.size} 个`
: ""}
{isFetching ? " · 正在加载…" : ""}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allPageSelected}
onClick={() =>
setSelectedIds((current) => {
const next = new Set(current);
agents.forEach((agent) => next.add(agent.id));
return next;
})
}
>
全选当前页
</Button>
{selectedIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
清空选择
</Button>
)}
</div>
</div>
) : (
<p className="text-muted-foreground mb-3 text-xs">
共 {total} 个智能体。
{isFetching ? " 正在加载…" : ""}
</p>
)}
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
role={migrationEnabled ? "grid" : undefined}
aria-label={migrationEnabled ? "可选择的智能体" : undefined}
>
{agents.map((agent) => {
const card = (
<AgentCard
agent={agent}
className={cn(
migrationEnabled &&
selectedIds.has(agent.id) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
onToggleHidden={(next) =>
handleToggleHidden(agent.id, agent.name, next)
}
toggleHiddenPending={setHidden.isPending}
/>
);
if (!migrationEnabled) {
return <div key={agent.id}>{card}</div>;
}
const selected = selectedIds.has(agent.id);
return (
<div
key={agent.id}
role="gridcell"
aria-selected={selected}
tabIndex={0}
className="relative cursor-pointer rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={(event) => {
const target = event.target as Element;
if (
target.closest(
"button, a, input, textarea, select, [role='button'], [role='switch'], [role='checkbox']",
)
) {
return;
}
toggleAgentSelection(agent.id);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
toggleAgentSelection(agent.id);
}
}}
>
{card}
</div>
);
})}
</div>
<Pagination
page={page}
@@ -306,6 +601,205 @@ export default function AdminAgentsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择智能体迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleImportFile(file);
}}
/>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别智能体目录并同步信息</DialogTitle>
<DialogDescription>
只同步智能体归属、发布状态、标签和技能名称关联,不写入技能信息,也不覆盖、移动或删除目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">智能体目录</span>
<code className="break-all">{migrationRoots.agents_root}</code>
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单列出的相对目录,再执行同步;目标中未列入清单的目录不会被处理。
</p>
</div>
)}
{importManifest && importPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MigrationCount label="智能体" value={importManifest.agents.length} />
<MigrationCount
label="技能名称关联"
value={
new Set(importManifest.agents.flatMap((agent) => agent.skills))
.size
}
/>
<MigrationCount
label="目录已匹配"
value={
importResources.filter((item) => item.status === "matched")
.length
}
tone="success"
/>
<MigrationCount
label="待处理问题"
value={importProblems.length + unresolvedOwners.length}
tone={
importProblems.length + unresolvedOwners.length > 0
? "danger"
: "success"
}
/>
</div>
{importManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{importManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{importProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
目录预检未通过,请修正后重新选择清单
</p>
<ul className="mt-2 space-y-2 text-xs">
{importProblems.slice(0, 50).map((item) => (
<MigrationProblem key={`${item.kind}:${item.key}`} item={item} />
))}
</ul>
{importProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {importProblems.length - 50} 项未展示。
</p>
)}
</div>
)}
{unresolvedOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdmin}
onCheckedChange={setUseCurrentAdmin}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{importProblems.length === 0 && unresolvedOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以同步元数据</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setImportDialogOpen(false)}
disabled={applyingImport}
>
取消
</Button>
<Button
onClick={handleApplyImport}
disabled={!canApplyImport || applyingImport}
>
{applyingImport && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
同步清单信息
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}
function MigrationCount({
label,
value,
tone = "default",
}: {
label: string;
value: number;
tone?: "default" | "success" | "danger";
}) {
const valueClass =
tone === "success"
? "text-emerald-600 dark:text-emerald-400"
: tone === "danger"
? "text-destructive"
: "text-foreground";
return (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`mt-1 text-xl font-semibold ${valueClass}`}>{value}</p>
</div>
);
}
function MigrationProblem({ item }: { item: MigrationResourcePreflight }) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
+548 -16
View File
@@ -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<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const [readingImport, setReadingImport] = useState(false);
const [applyingImport, setApplyingImport] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importManifest, setImportManifest] =
useState<AgentSkillMigrationManifest | null>(null);
const [importPreflight, setImportPreflight] =
useState<MigrationPreflight | null>(null);
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
null,
);
const [useCurrentAdmin, setUseCurrentAdmin] = useState(false);
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const activeTab = useMemo(() => {
const 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 (
<WorkspaceContainer>
@@ -98,18 +287,58 @@ export default function AdminSkillsPage() {
value="skills"
className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
<p className="text-muted-foreground text-sm">
管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p>
<div className="relative w-64 shrink-0">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔"
className="pl-8"
/>
<div className="flex flex-col gap-4 border-b px-6 py-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-muted-foreground text-sm">
管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p>
{migrationEnabled && (
<p className="text-muted-foreground mt-1 text-xs">
技能清单独立保存技能归属、发布/上下线状态和标签,不包含目录文件;请先把技能目录复制到目标机器运行目录。
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
{migrationEnabled && (
<>
<Button
variant="outline"
size="sm"
onClick={handleExportManifest}
disabled={selectedNames.size === 0 || exporting}
>
{exporting ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<DownloadIcon className="mr-1.5 size-4" />
)}
导出技能清单
{selectedNames.size ? `(${selectedNames.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={readingImport}
>
{readingImport ? (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
) : (
<UploadIcon className="mr-1.5 size-4" />
)}
识别目录并导入技能
</Button>
</>
)}
<div className="relative min-w-56 flex-1 xl:w-64 xl:flex-none">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔"
className="pl-8"
/>
</div>
</div>
</div>
@@ -136,9 +365,49 @@ export default function AdminSkillsPage() {
)}
{!isLoading && !error && filteredSkills.length > 0 && (
<>
<p className="text-muted-foreground text-xs">
共 {filteredSkills.length} 个技能。
</p>
{migrationEnabled ? (
<div className="flex min-h-8 items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
点击技能行选择 · 共 {filteredSkills.length} 个技能
{selectedNames.size > 0
? ` · 已选择 ${selectedNames.size} 个`
: ""}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={allFilteredSelected}
onClick={() =>
setSelectedNames((current) => {
const next = new Set(current);
filteredSkills.forEach((skill) =>
next.add(skill.name),
);
return next;
})
}
>
全选当前结果
</Button>
{selectedNames.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedNames(new Set())}
>
清空选择
</Button>
)}
</div>
</div>
) : (
<p className="text-muted-foreground text-xs">
共 {filteredSkills.length} 个技能。
</p>
)}
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
@@ -146,6 +415,19 @@ export default function AdminSkillsPage() {
currentUserId={currentUserId}
isAdmin
availabilityControl="admin"
selected={
migrationEnabled && selectedNames.has(skill.name)
}
onSelectionToggle={
migrationEnabled
? () => toggleSkillSelection(skill.name)
: undefined
}
className={cn(
migrationEnabled &&
selectedNames.has(skill.name) &&
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
)}
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name,
)}
@@ -189,6 +471,256 @@ export default function AdminSkillsPage() {
</TabsContent>
</Tabs>
</WorkspaceBody>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="选择技能迁移清单"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleImportFile(file);
}}
/>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>识别技能目录并导入技能信息</DialogTitle>
<DialogDescription>
只同步清单中的归属、发布/上下线状态、标签和缓存,不覆盖、移动或删除任何技能目录文件。
</DialogDescription>
</DialogHeader>
{migrationRoots && (
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">目标机器运行目录</p>
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-muted-foreground">技能目录</span>
<code className="break-all">{migrationRoots.skills_root}</code>
{migrationRoots.extensions_config_path && (
<>
<span className="text-muted-foreground">状态配置</span>
<code className="break-all">
{migrationRoots.extensions_config_path}
</code>
</>
)}
</div>
<p className="text-muted-foreground text-xs leading-5">
请先完整复制清单列出的相对目录,再执行导入;目标中未列入清单的技能不会被处理。
</p>
</div>
)}
{importManifest && importPreflight && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MigrationCount label="技能" value={importManifest.skills.length} />
<MigrationCount
label="支持目录"
value={importManifest.support_directories.length}
/>
<MigrationCount
label="目录已匹配"
value={
importResources.filter((item) => item.status === "matched")
.length
}
tone="success"
/>
<MigrationCount
label="待处理问题"
value={importProblems.length + unresolvedOwners.length}
tone={
importProblems.length + unresolvedOwners.length > 0
? "danger"
: "success"
}
/>
</div>
{importManifest.warnings.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">源机器导出提醒</p>
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
{importManifest.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{importProblems.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">
{skipInvalidSkills
? "以下异常关联的技能将在导入时跳过"
: "目录预检未通过,可修正目录或跳过异常技能"}
</p>
<ul className="mt-2 space-y-2 text-xs">
{importProblems.slice(0, 50).map((item) => (
<MigrationProblem
key={`${item.kind}:${item.key}`}
item={item}
/>
))}
</ul>
{importProblems.length > 50 && (
<p className="text-muted-foreground mt-2 text-xs">
另有 {importProblems.length - 50} 项未展示。
</p>
)}
{problemsCanBeSkipped && skipAnalysis && (
<label className="mt-3 flex min-h-11 cursor-pointer items-start gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Checkbox
className="mt-0.5"
checked={skipInvalidSkills}
onCheckedChange={(checked) =>
setSkipInvalidSkills(checked === true)
}
aria-label="跳过未通过目录预检的技能"
/>
<span>
<span className="block font-medium">
跳过异常技能,继续导入其余技能
</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
将跳过 {skipAnalysis.skippedSkillNames.length} 个,继续导入 {skipAnalysis.remainingSkillCount} 个;被跳过技能不会写入任何元数据。
</span>
</span>
</label>
)}
{!problemsCanBeSkipped && (
<p className="text-muted-foreground mt-3 text-xs">
当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。
</p>
)}
</div>
)}
{unresolvedOwners.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">
有 {unresolvedOwners.length} 个源创建者无法按邮箱匹配
</p>
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
{unresolvedOwners.slice(0, 10).map((owner) => (
<p key={`${owner.source_user_id}:${owner.email}`}>
{owner.email || owner.source_user_id || "未知用户"}
</p>
))}
</div>
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox
checked={useCurrentAdmin}
onCheckedChange={setUseCurrentAdmin}
aria-label="将无法匹配的创建者归属当前管理员"
/>
将无法匹配的创建者归属当前管理员
</label>
</div>
)}
{importProblems.length === 0 && unresolvedOwners.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
<div>
<p className="font-medium">预检通过,可以导入技能信息</p>
<p className="text-muted-foreground mt-0.5 text-xs">
执行时服务器还会再次校验目录指纹,防止预检后文件发生变化。
</p>
</div>
</div>
)}
{skipInvalidSkills &&
problemsCanBeSkipped &&
unresolvedOwners.length === 0 &&
skipAnalysis && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div>
<p className="font-medium">可以跳过异常技能后继续导入</p>
<p className="text-muted-foreground mt-0.5 text-xs">
服务器执行时会再次校验,并返回实际导入与跳过数量。
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setImportDialogOpen(false)}
disabled={applyingImport}
>
取消
</Button>
<Button
onClick={handleApplyImport}
disabled={!canApplyImport || applyingImport}
>
{applyingImport && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
{skipInvalidSkills && skipAnalysis
? `跳过 ${skipAnalysis.skippedSkillNames.length} 个并导入`
: "导入技能信息"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}
function MigrationCount({
label,
value,
tone = "default",
}: {
label: string;
value: number;
tone?: "default" | "success" | "danger";
}) {
const valueClass =
tone === "success"
? "text-emerald-600 dark:text-emerald-400"
: tone === "danger"
? "text-destructive"
: "text-foreground";
return (
<div className="rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`mt-1 text-xl font-semibold ${valueClass}`}>{value}</p>
</div>
);
}
function MigrationProblem({ item }: { item: MigrationResourcePreflight }) {
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
matched: "已匹配",
missing: "目录或关键文件缺失",
hash_mismatch: "目录内容不一致",
identity_mismatch: "ID/名称不一致",
invalid_path: "目录路径不安全",
};
return (
<li className="rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.key}</span>
<span className="text-destructive">{statusLabels[item.status]}</span>
</div>
<code className="text-muted-foreground mt-1 block break-all">
{item.relative_dir}
</code>
{item.message && (
<p className="text-muted-foreground mt-1">{item.message}</p>
)}
</li>
);
}
@@ -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,
+7 -15
View File
@@ -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({
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ConsumerMaterialsExportTrigger threadId={threadId} />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
@@ -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<MappingTypeFilter>("custom");
const [typeFilter, setTypeFilter] = useState<MappingTypeFilter>("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() {
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
<button
onClick={() => { setSearchQuery(""); setStatusFilter("all"); setTypeFilter("custom"); }}
onClick={() => { setSearchQuery(""); setStatusFilter("all"); setTypeFilter("all"); }}
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
>
重置筛选条件
@@ -661,7 +649,7 @@ export default function BusinessChainMappingPage() {
会商程序会在对应业务的业务链生效时开启多体交叉核验证。
</span>
<span className="font-mono text-[10px]">
显示 {filteredRows.length} / {visibleRows.length} 条
显示 {filteredRows.length} / {rows.length} 条
</span>
</div>
</motion.div>
@@ -686,7 +674,7 @@ export default function BusinessChainMappingPage() {
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
1. 点击右上角「新增自定义配置」,填写名称。新增项自动归入「自定义类型」;名称就是登录链接的 <code>goPath</code>:<code>goPath=名称</code> 进入单智能体,<code>goPath=名称-A</code> 进入已绑定业务链。
<br />
2. 打开页面默认展示「自定义类型」;可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。
2. 可用表格上方的类型下拉框,在「内置」与「自定义类型」间筛选。
<br />
3. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
+17 -8
View File
@@ -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<string, unknown> = {};
const extraContext: Record<string, unknown> = {
...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() {
新建对话
</Button>
)}
<ConsumerMaterialsExportTrigger threadId={threadId} />
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
+106 -12
View File
@@ -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<ChatFilter>("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<string | null>(null);
const { data: migrationSettings } = useAgentSkillMigrationSettings();
const transferEnabled = migrationSettings?.enabled === true;
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(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({
</div>
) : (
<div className="flex shrink-0 items-center" data-history-menu>
{transferEnabled && (
<Button
variant="ghost"
size="icon-sm"
className="size-8"
title="导出记录"
aria-label="导出记录"
disabled={exportingThreadId === thread.thread_id}
onClick={() => void handleExportThreadRecord(thread)}
>
<Download className="size-4 text-muted-foreground" />
</Button>
)}
<HistoryActionsMenu
label={t.common.more}
deleteLabel={t.common.delete}
@@ -594,18 +659,27 @@ export default function ChatsPage({
);
})}
</div>
{activeTab === "chats" && transferEnabled && (
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExportRecordsOpen(true)}
>
<Download className="size-4" />
导出记录
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setImportRecordsOpen(true)}
>
<Upload className="size-4" />
导入记录
</Button>
</div>
)}
</div>
{activeTab === "chats" && (
<Button
variant="outline"
size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
)}
</div>
</header>
<main className="min-h-0 flex-1">
@@ -683,6 +757,14 @@ export default function ChatsPage({
<span>清空</span>
</button>
)}
<button
type="button"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 transition-all hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
onClick={() => setImportOpen(true)}
>
<Download className="size-3.5" />
<span>{t.threadShare.importChats}</span>
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button
type="button"
@@ -821,6 +903,18 @@ export default function ChatsPage({
open={importOpen}
onOpenChange={setImportOpen}
/>
{transferEnabled && (
<>
<ChatHistoryExportDialog
open={exportRecordsOpen}
onOpenChange={setExportRecordsOpen}
/>
<ChatHistoryImportDialog
open={importRecordsOpen}
onOpenChange={setImportRecordsOpen}
/>
</>
)}
<HistoryRenameDialog
open={renameThreadId !== null}
initialTitle={renameValue}
+8 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { LoaderCircle, LogIn, UserPlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,7 @@ import {
} from '@/core/auth/account-api';
import {
LOGIN_CREDENTIAL_PARAMS,
accountPageSearch,
claimSsoLoginStart,
loginParamsForEntry,
shouldStartSso,
@@ -333,6 +334,7 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
const formSearch = accountPageSearch(searchParams.toString(), routeKeyTarget || undefined);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -373,6 +375,7 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
sessionExpired && !resumeExternalLogin,
);
// 登录设置固定使用原有外部免登模式,不再请求登录设置接口。
// URL 已带显式免登凭证的直达入口可立即登录;后端在每个登录端点都会复核登录开关。
const explicitSsoReady = Boolean(
explicitSso && !creatingAccount && !needsSetup &&
!(sessionExpired && !resumeExternalLogin),
@@ -1034,6 +1037,10 @@ export default function LoginPage({ mode = 'login' }: { mode?: 'login' | 'regist
setAccountPassword('');
setConfirmPassword('');
}}>返回账号登录</Button>
) : registering ? (
<Link className="text-primary hover:underline" to={`/login${formSearch}`} onClick={clearOuterLoginCredentials}>已有账号,返回登录</Link>
) : !isBusy && loginPolicy.password_login_enabled && loginPolicy.registration_enabled ? (
<Link className="text-primary hover:underline" to={`/register${formSearch}`} onClick={clearOuterLoginCredentials}>还没有账号?注册账号</Link>
) : null}
{!registering && loginPolicy.sso_enabled && canUseForm && (
<p className="text-xs text-muted-foreground">支持外部系统免登,可从原系统的统一登录入口进入。</p>
+6 -6
View File
@@ -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"}`
}`}
+6
View File
@@ -0,0 +1,6 @@
import LoginPage from "./LoginPage";
/** Reuse the login completion path so registration also retains task deep links. */
export default function RegisterPage() {
return <LoginPage mode="register" />;
}
@@ -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),
@@ -20,6 +20,7 @@ import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { isRoundtableRoomFullError } from "@/roundtable-planning/api/rooms";
import {
isRoundtableRoomFullError,
type RoundtableRoomFullError,
@@ -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,
]);
+25 -13
View File
@@ -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}
/>
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
<header
className={cn(
"strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4",
isLightRedTopNav && "strategy-app-header--light-red-top-nav",
)}
>
{showTopNav ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
<div
className={cn(
"flex h-full min-w-0 flex-1 items-center",
isDarkLogoOnLeft ? "justify-between" : "justify-end",
isTopNavLogoOnLeft ? "justify-between" : "justify-end",
)}
>
{isDarkLogoOnLeft && (
{isTopNavLogoOnLeft && (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-950/55"
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
isLightRedTopNav ? "bg-red-950/30" : "bg-blue-950/55",
)}
title="系统 Logo"
>
<AppBrandImage
@@ -305,7 +317,7 @@ const Header = ({ collapsed }: HeaderProps) => {
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
<div className="header-center-title">
{darkLogoPosition === "center" && (
{topNavLogoPosition === "center" && (
<AppBrandImage
theme={theme}
className="header-center-title__logo"
@@ -388,8 +400,8 @@ const Header = ({ collapsed }: HeaderProps) => {
</>
)}
</header>
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
{showTopNav && thirdSubNavItems.length ? (
{/* 浅红主题保留原浅色侧栏三级列;顶部三级横栏仍只属于深色主题。 */}
{showThirdSubNav && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
@@ -59,6 +59,13 @@ export interface LightAppSidebarItem {
sortOrder: number;
}
export interface LightAppSidebarItem {
appId: string;
name: string;
mountLocation: string | null;
sortOrder: number;
}
export interface LightAppInput {
name: string;
description?: string | null;
+85 -1
View File
@@ -1514,6 +1514,72 @@ body {
font-size: calc(1rem * var(--ui-font-scale, 1));
}
/* 16px / 18px 保留原排版;20px 起收紧主对话的块间距,避免大字号
* 把步骤、标题和表格拉得过散。 */
html[data-font-spacing="compact"] .chat-message-list {
gap: 1.25rem;
}
html[data-font-spacing="compact"] .chat-assistant-markdown {
margin-block: 0.25rem;
line-height: 1.5;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
> :not(:last-child) {
margin-block-start: 0;
margin-block-end: 0.5rem;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown^="heading-"] {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown="table-wrapper"] {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown="table-wrapper"]
> :not(:last-child) {
margin-block-start: 0;
margin-block-end: 0.25rem;
}
/* 大字号下,桌面端表格工具栏与前一行标题并排显示,不再独占一行;
* 窄屏继续使用正常文档流,避免覆盖长标题。 */
@media (min-width: 40rem) {
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown="table-wrapper"] {
position: relative;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown="table-wrapper"]
> div:first-child:not(:last-child) {
position: absolute;
z-index: 1;
top: -2rem;
right: 0;
}
html[data-font-spacing="compact"]
.chat-assistant-markdown
[data-streamdown^="heading-"] {
padding-right: 4rem;
}
}
/* Strategy 顶栏:浅色 / 深色背景图随 html.dark 切换 */
.strategy-app-header {
background-color: transparent;
@@ -1528,6 +1594,12 @@ html.dark .strategy-app-header {
background-size: cover;
}
/* 红白主题可通过 runtime-config.js 选择复用深色顶栏结构,仅替换为浅红设计稿。 */
.strategy-app-header.strategy-app-header--light-red-top-nav {
background-image: url("../assets/img/top-bg-light-red.svg");
background-size: cover;
}
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
.header-center-title {
@@ -1545,7 +1617,7 @@ html.dark .strategy-app-header {
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
"Microsoft YaHei", sans-serif;
font-size: 26px;
font-size: 30px;
font-weight: 700;
letter-spacing: 4px;
white-space: nowrap;
@@ -1582,6 +1654,18 @@ html.dark .strategy-app-header {
filter: drop-shadow(0 0 14px rgba(120, 200, 255, 0.35));
}
.strategy-app-header--light-red-top-nav .header-center-title__label {
background: linear-gradient(
180deg,
#ffffff 0%,
#fff8f7 42%,
#ffffff 100%
);
-webkit-background-clip: text;
background-clip: text;
filter: drop-shadow(0 0 12px rgba(161, 32, 24, 0.38));
}
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(CSS 变量管不到的地方)────────── */
html.lightblue .strategy-app-header {
background-image: url("../assets/img/blue-header.png");
-115
View File
@@ -1,115 +0,0 @@
import "@/core/compat/abort-signal";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth, setStoredUserName } from "@/core/auth";
import { beginLoginAttempt } from "@/core/auth/scoped-storage";
import {
persistCopTaskDetail,
prefetchTaskDeeplink,
resolveTaskDeeplinkTarget,
stubCopTaskDetail,
} from "@/core/auth/task-deeplink";
import {
buildMainAppUrl,
buildTaskEntryDestination,
buildTaskEntrySessionSearch,
parseTaskEntryParams,
} from "@/core/auth/task-entry";
import { syncEmbedSessionFromSearch } from "@/core/embed/embed-session";
const statusElement = document.getElementById("task-entry-status");
const errorElement = document.getElementById("task-entry-error");
const retryButton = document.getElementById("task-entry-retry") as HTMLButtonElement | null;
let attempt = 0;
function setStage(stage: string, message: string): void {
if (statusElement) statusElement.textContent = message;
try {
sessionStorage.setItem("task-entry.stage", stage);
} catch {
/* Storage failure is reported by beginLoginAttempt before authentication. */
}
console.info("[task-entry]", stage);
}
function showError(error: unknown): void {
const message = error instanceof Error ? error.message : "进入任务问答失败,请稍后重试。";
setStage("error", "未能进入任务问答");
if (errorElement) {
errorElement.textContent = message;
errorElement.style.display = "block";
}
if (retryButton) {
retryButton.disabled = false;
retryButton.style.display = "block";
}
}
function clearError(): void {
if (errorElement) {
errorElement.textContent = "";
errorElement.style.display = "none";
}
if (retryButton) {
retryButton.disabled = true;
retryButton.style.display = "none";
}
}
async function enterTask(): Promise<void> {
const currentAttempt = ++attempt;
clearError();
try {
setStage("validating", "正在检查入口参数…");
const params = parseTaskEntryParams(window.location.search);
// The dedicated entry always owns an isolated embedded session. This must
// happen before beginLoginAttempt/setStoredAuth choose their storage.
syncEmbedSessionFromSearch(buildTaskEntrySessionSearch(params));
const ownsLogin = beginLoginAttempt();
setStage("authenticating", "正在验证登录信息…");
const auth = await loginByUsername(params.username, params.password);
if (currentAttempt !== attempt || !ownsLogin()) return;
setStoredAuth(auth);
setStoredUserName(auth.username?.trim() || params.username);
setStage("resolving-task", "登录成功,正在加载任务…");
const prefetch = prefetchTaskDeeplink(params.taskId, params.goPath);
const [resolvedTaskId, prefetchedDetail] = await Promise.all([
prefetch.realTaskId,
prefetch.taskDetail,
]);
if (currentAttempt !== attempt || !ownsLogin()) return;
const realTaskId = resolvedTaskId || params.taskId;
const detail = prefetchedDetail ?? stubCopTaskDetail(realTaskId);
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(
params.goPath,
detail,
realTaskId,
params.taskId,
);
if (currentAttempt !== attempt || !ownsLogin()) return;
if (!target || target.state) {
throw new Error("当前任务未配置可用的单智能体问答入口。");
}
const destination = buildTaskEntryDestination(target.path, params);
const appUrl = buildMainAppUrl(window.location.href, destination);
setStage("redirecting", "任务已就绪,正在进入问答页面…");
window.location.replace(appUrl);
} catch (error) {
if (currentAttempt !== attempt) return;
showError(error);
}
}
retryButton?.addEventListener("click", () => {
void enterTask();
});
void enterTask();
-88
View File
@@ -1,88 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>正在进入任务问答</title>
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
color: #172033;
background: #f4f6f9;
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
}
.entry-card {
width: min(420px, 100%);
padding: 32px;
border: 1px solid #e2e7ef;
border-radius: 14px;
background: #fff;
box-shadow: 0 12px 36px rgba(28, 39, 60, 0.08);
}
.entry-mark {
display: grid;
width: 44px;
height: 44px;
margin-bottom: 20px;
place-items: center;
border-radius: 12px;
color: #fff;
background: #b20822;
font-size: 20px;
font-weight: 700;
}
h1 { margin: 0; font-size: 22px; line-height: 1.4; }
#task-entry-status {
min-height: 24px;
margin: 12px 0 0;
color: #667085;
font-size: 14px;
line-height: 1.7;
}
#task-entry-error {
display: none;
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #fecaca;
border-radius: 10px;
color: #991b1b;
background: #fff7f7;
font-size: 14px;
line-height: 1.6;
}
#task-entry-retry {
display: none;
width: 100%;
margin-top: 18px;
padding: 10px 16px;
border: 0;
border-radius: 9px;
color: #fff;
background: #b20822;
font: inherit;
cursor: pointer;
}
#task-entry-retry:focus-visible { outline: 3px solid rgba(178, 8, 34, 0.25); outline-offset: 2px; }
#task-entry-retry:disabled { cursor: wait; opacity: 0.65; }
.entry-note { margin: 18px 0 0; color: #98a2b3; font-size: 12px; line-height: 1.6; }
</style>
<script src="%BASE_URL%runtime-config.js" data-runtime-config></script>
</head>
<body>
<main class="entry-card" aria-labelledby="task-entry-title">
<div class="entry-mark" aria-hidden="true">任</div>
<h1 id="task-entry-title">正在进入任务问答</h1>
<p id="task-entry-status" role="status" aria-live="polite">正在检查入口参数…</p>
<p id="task-entry-error" role="alert"></p>
<button id="task-entry-retry" type="button">重新进入</button>
<p class="entry-note">该入口仅用于外部系统嵌入的任务深链接。</p>
</main>
<script type="module" src="/src/task-entry-main.ts"></script>
</body>
</html>
+21 -13
View File
@@ -9,14 +9,15 @@ import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force),
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk,
// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。
// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload()
// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk。
// 旧实现只返回一段 location.reload(),没有 named export;Chrome 在链接
// `import { jsx } from ...` 时会直接 SyntaxError,模块体根本不会执行,
// 页面就白屏卡死。这里补上 jsx-runtime 的常见导出,再触发有限次数刷新。
const staleDepsChunkSelfReload = (): Plugin => ({
name: "stale-deps-chunk-self-reload",
configureServer(server) {
const depsDir = path.join(rootDir, "node_modules", ".vite", "deps");
const jsxRuntimeUrl = "/node_modules/.vite/deps/react_jsx-runtime.js";
server.middlewares.use((req, res, next) => {
const match = /^\/node_modules\/\.vite\/deps\/([^?]+)/.exec(req.url ?? "");
if (!match) {
@@ -29,10 +30,23 @@ const staleDepsChunkSelfReload = (): Plugin => ({
return;
}
res.setHeader("Content-Type", "application/javascript");
res.setHeader("Cache-Control", "no-store");
const jsxRuntimeExists = existsSync(
path.join(depsDir, "react_jsx-runtime.js"),
);
const jsxExports = jsxRuntimeExists
? `export { jsx, jsxs, jsxDEV, Fragment } from ${JSON.stringify(jsxRuntimeUrl)};\n`
: [
"export function jsx(){return null}",
"export function jsxs(){return null}",
"export function jsxDEV(){return null}",
"export const Fragment = Symbol.for('react.fragment');",
"",
].join("\n");
res.end(
"if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" +
"sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" +
"location.reload();}",
`${jsxExports}` +
"try{const k='__vite_stale_chunk_reloaded__';const n=Number(sessionStorage.getItem(k)||'0');" +
"if(n<3){sessionStorage.setItem(k,String(n+1));location.reload();}}catch(e){}",
);
});
},
@@ -99,12 +113,6 @@ export default defineConfig({
build: {
cssMinify: "lightningcss",
target: "chrome100",
rollupOptions: {
input: {
main: path.resolve(rootDir, "index.html"),
"task-entry": path.resolve(rootDir, "task-entry.html"),
},
},
},
server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
+41 -1
View File
@@ -353,6 +353,32 @@ HTML artifacts are single-page by default. Omitted `preferred_page_count` is nor
Ordinary HTML generation is from scratch by default: do not read `page-template-library`, call catalog/prepare, or select a bundled layout unless the current user explicitly asks to use a template. The agent writes the final path directly under `/mnt/user-data/outputs/`, which lets frontend live preview open on the first `write_file`; it then validates and presents the same file without a bash/cp copy. Single-page HTML means a scrollable responsive document, not a fixed single-screen canvas. Direct validate derives `layout_mode` and page count from `page_brief` / `preferred_page_count`; it rejects `html/body overflow:hidden`, fixed 1920×1080 viewport scaling, undersized G6 containers, and long LR diagrams. Explicit template requests may use the shared `light-document` / `dark-document` profile backed by `report-document-responsive-template.html`; prepare creates its editable `working_copy` directly in outputs and persists the same constraints.
**Facebook page skill.** `skills/public/facebook-page-generator` is a narrowly routed built-in public skill for explicit Facebook/Facebook-style webpage requests. `_ORDINARY_QA_VISUALIZATION_SECTION` routes that intent to this skill before the generic `page-report-generation` path and explicitly forbids generic template selection or from-scratch HTML for the matched request. The skill uses `assets/default-data.json` as a seven-module schema-complete starting point, validates with the standard-library `scripts/render_facebook_page.py`, and injects the resulting JSON into the single self-contained `assets/facebook-template.html`; the model must not recreate or patch template DOM/CSS. The renderer exposes `init`, `validate`, and `render`, refuses to overwrite existing JSON/HTML, escapes a closing `</script>` sequence before injection, and emits machine-readable status/counts. The output retains the in-page settings modal so users can edit `site/navigation/stories/posts/sidebar/contacts/inbox` and apply changes at runtime. Its browser storage key includes an FNV-1a fingerprint of the embedded initial JSON: manual edits persist on reload for the same artifact, while a newly generated artifact with different JSON cannot be shadowed by stale same-origin sandbox data. The final HTML is still subject to the platform's normal generated-page post-processing and `present_files` delivery boundary. Regression coverage: `tests/test_facebook_page_generator_skill.py`.
**X page skill.** `skills/public/x-page-generator` is the narrow built-in route for explicit X platform, Twitter/推特, or X-style social timeline webpage requests. It maps source material to six modules (`site/navigation/composer/posts/trends/recommendations`) and uses the standard-library `scripts/render_x_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained three-column template. The model must not rewrite the template DOM/CSS. The output retains responsive desktop/mobile layouts, theme switching and timeline interactions, plus a top-right settings modal that edits and applies one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_x_page_generator_skill.py`.
**TikTok page skill.** `skills/public/tiktok-page-generator` is the narrow built-in route for explicit TikTok, international Douyin, or TikTok-style short-video feed webpage requests. It maps source material to six modules (`site/navigation/videos/sidebar/comments/share`) and uses the standard-library `scripts/render_tiktok_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained immersive feed template. The output retains scroll snapping, play/pause, double-like, mute, save, follow, comments, sharing, search, keyboard navigation and the mobile full-screen layout, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_tiktok_page_generator_skill.py`.
**YouTube page skill.** `skills/public/youtube-page-generator` is the narrow built-in route for explicit YouTube or YouTube-style video portal, home, and channel-page requests. It maps source material to seven modules (`site/navigation/categories/videos/shorts/subscriptions/comments`) and uses the standard-library `scripts/render_youtube_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained responsive portal template. The output retains desktop navigation, category filtering, search, a responsive video grid, Shorts, a watch modal, likes, saves, subscriptions and comments, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_youtube_page_generator_skill.py`.
**Threads page skill.** `skills/public/threads-page-generator` is the narrow built-in route for explicit Threads or Threads-style conversation-feed webpage requests. It maps source material to six modules (`site/navigation/composer/threads/recommendations/activity`) and uses the standard-library `scripts/render_threads_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained responsive thread template. The output retains theme switching, publishing, search, likes, replies, reposts, sharing, following, activity panels and mobile navigation, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_threads_page_generator_skill.py`.
**CNN page skill.** `skills/public/cnn-page-generator` is the narrow built-in route for explicit CNN or CNN-style global-news portal and homepage requests. It maps source material to ten modules (`site/navigation/breaking/lead/latest/video/world/markets/newsletter/footer`) and uses the standard-library `scripts/render_cnn_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained red-and-black broadcast-news template. The output retains the breaking-news bar, search, live modal, caption toggle, save/share actions, mobile drawer, market snapshot and newsletter form, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_cnn_page_generator_skill.py`.
**BBC page skill.** `skills/public/bbc-page-generator` is the narrow built-in route for explicit BBC or BBC-style international public-news portal and homepage requests. It maps source material to ten modules (`site/navigation/breaking/lead/focus/media/moreNews/regions/newsletter/footer`) and uses the standard-library `scripts/render_bbc_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained dual-navigation public-news template. The output retains search, the breaking strip, live modal, caption toggle, audio progress, mobile drawer, ranked news, regional weather and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_bbc_page_generator_skill.py`.
**Xinhua page skill.** `skills/public/xinhua-page-generator` is the narrow built-in route for explicit Xinhua News Agency, Xinhuanet, or Xinhua-style Chinese wire-service portal and homepage requests. It maps source material to ten modules (`site/navigation/specials/headline/focus/features/channels/local/services/footer`) and uses the standard-library `scripts/render_xinhua_page.py` to validate and inject JSON plus the bundled runtime into a fixed self-contained red-and-blue high-density news-agency template. The output retains the multilingual network bar, report series, spotlight gallery, tabbed focus news, named columns, topics, reporter studios, province switching, industry channels, media matrix, live captions and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_xinhua_page_generator_skill.py`.
**White House page skill.** `skills/public/white-house-page-generator` is the narrow built-in route for explicit White House website, White House-style government homepage, or U.S. executive-institution portal requests. It maps source material to ten modules (`site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer`): schema keys, ids, anchors, and visual enums remain technical English identifiers, while all reader-facing organization names, headings, summaries, controls, dates, events, and footer copy must be Simplified Chinese, English sources must be translated first, and `site.language` is fixed to `zh-CN`. The standard-library `scripts/render_white_house_page.py` validates and injects that JSON plus the Chinese bundled runtime into a fixed self-contained navy, cream, and gold civic portal template. The output retains the transparent-to-sticky header, architectural hero, public notice, official updates, interactive priorities, administration profile, public calendar, full-screen menu/search, live captions and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Default data is explicitly marked as a concept rather than an official government site. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_white_house_page_generator_skill.py`.
**Manila Times page skill.** `skills/public/manila-times-page-generator` is the narrow built-in route for explicit The Manila Times, Manila Times-style Philippine broadsheet, or matching news-portal requests. It maps source material to ten modules (`site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer`): schema keys, ids, anchors, visual enums, and direction enums remain technical English identifiers, while all reader-facing masthead, section, story, author, date, control, and footer copy must be Simplified Chinese, English or Filipino sources must be translated first, and `site.language` is fixed to `zh-CN`. The standard-library `scripts/render_manila_times_page.py` validates and injects that JSON plus the Chinese bundled runtime into a fixed self-contained cream-paper, navy, and red broadsheet template. The output retains the Philippine flag stripe, serif masthead, weather rotation, sticky section navigation, breaking ticker, three-column lead, latest/trending and region tabs, opinion, business, sports, video captions, digital-edition paging and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Default data is explicitly marked as a concept rather than an official website. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_manila_times_page_generator_skill.py`.
**GMA News page skill.** `skills/public/gma-news-page-generator` is the narrow built-in route for explicit GMA News, GMA-style Philippine broadcast-news, or matching portal requests. It maps source material to ten modules (`site/navigation/justIn/lead/news/services/broadcast/channels/media/footer`): schema keys, ids, anchors, and visual enums remain technical English identifiers, the GMA brand acronym may remain, while every other reader-facing section, story, author, date, control, and footer string must be Simplified Chinese, English or Filipino sources must be translated first, and `site.language` is fixed to `zh-CN`. The standard-library `scripts/render_gma_news_page.py` validates and injects that JSON plus the Chinese bundled runtime into a fixed self-contained red, blue, and yellow broadcast-news template. The output retains the expandable just-in bar, weather rotation, sticky navigation, broadcast lead, latest/trending/regional feeds, public services, live television captions, channel cards, videos, podcast, lottery switching and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Default data is explicitly marked as a concept rather than an official website. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_gma_news_page_generator_skill.py`.
**Yahoo! JAPAN page skill.** `skills/public/yahoo-japan-page-generator` is the narrow built-in route for explicit Yahoo! JAPAN, Yahoo Japan, or Japanese general-portal requests. It maps source material to ten modules (`site/search/services/news/account/weather/utilities/shopping/pulse/footer`): schema keys, ids, anchors, and visual enums remain technical English identifiers, the `Yahoo! JAPAN` brand may remain, while every other reader-facing section, story, control, weather, transit, market, product, sports, and footer string must be Simplified Chinese, Japanese or English sources must be translated first, and `site.language` is fixed to `zh-CN`. The standard-library `scripts/render_yahoo_japan_page.py` validates and injects that JSON plus the Chinese bundled runtime into a fixed self-contained red-and-blue high-density Japanese portal template. The output retains multi-type search, popular queries, expandable services, categorized news, rotating recommendations, demo login, city weather, route lookup, market switching, product favorites, sports scores, real-time trends, mobile drawer and back-to-top behavior, plus a top-right settings modal that edits one JSON module at a time. Default data is explicitly marked as a concept rather than an official website. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_yahoo_japan_page_generator_skill.py`.
**Yomiuri News page skill.** `skills/public/yomiuri-news-page-generator` is the narrow built-in route for explicit Yomiuri Shimbun, Yomiuri News, or Japanese national-broadsheet requests. It maps source material to ten modules (`site/navigation/breaking/lead/edition/features/news/opinion/media/footer`): schema keys, ids, anchors, and visual enums remain technical English identifiers, the `YOMIURI ONLINE` romanized brand may remain, while every other reader-facing masthead, section, story, author, control, date, edition, video, and footer string must be Simplified Chinese, Japanese or English sources must be translated first, and `site.language` is fixed to `zh-CN`. The standard-library `scripts/render_yomiuri_news_page.py` validates and injects that JSON plus the Chinese bundled runtime into a fixed self-contained cream-paper, wine-red, and deep-navy broadsheet template. The output retains font-size switching, expandable breaking news, three-column lead, article saving, sticky navigation, full-screen menu, search, demo login, digital-edition paging, feature and category switching, opinion, video captions and newsletter feedback, plus a top-right settings modal that edits one JSON module at a time. Default data is explicitly marked as a concept rather than an official website. Initial-data fingerprinting isolates browser caches, while guarded storage access keeps opaque sandbox previews functional. Regression coverage: `tests/test_yomiuri_news_page_generator_skill.py`.
For an explicit multi-page HTML request without an explicit template request, `MultiPageHtmlMiddleware` rejects `page_report_template` catalog/prepare calls; the model must make one complete first `write_file`, then one validate. A failed validation returns patch-only repair guidance: retain the artifact and use the smallest `str_replace` edits for all listed CSS/navigation/JSON/DOM errors before one revalidation; do not recreate the page. `html_postprocess.maybe_fix_generated_html` also injects a marker-guarded dark Bootstrap compatibility layer into dark HTML during every write or replacement. It sets Bootstrap body/emphasis/secondary tokens and explicit readable foregrounds for `.card`, `.data-table` cells, `.modal-*`, and common form controls. Validation mirrors this by rejecting dark `.card`, `.data-table`, and modal markup without an explicit matching foreground color, so a preview cannot silently render black text on dark panels.
### Generated HTML artifact visualization runtimes
@@ -367,6 +393,10 @@ Markdown-to-HTML conversion must inventory source Mermaid/flowcharts, charts, an
G6 nodes use light semantic rounded cards with matching borders, dark high-contrast text, centered type-prefixed labels, subtle shadows, and non-animated initial rendering (avoids a faded first frame). More than seven nodes automatically switch an `LR` request to layered `TB` layout and expand a short container to 720px, preventing long workflows from shrinking into illegible shapes; drag/zoom remain enabled.
### 系统名称与 Logo
本系统导航、侧栏和欢迎区的名称与 Logo 存在 `system_settings.json -> app_identity`,和 `generated_page_branding` 分开。管理员通过「设置 → 系统名称」以及 `GET/PUT/DELETE /api/system-settings/app-identity` 修改;上传的图片放在运行目录 `app-identity/`,由 `GET /api/public/app-identity/logo` 提供。名称为空时继续用内置标题,没有上传 Logo 时继续用主题静态图片。它不改生成网页的名称或头像。
### 问答记忆运行时总开关
通用/个人问答记忆由 `system_settings.json -> memory.enabled` 控制,默认 `false`,并且是该通用记忆域的权威总开关。关闭时 `lead_agent` 不挂载通用 `MemoryMiddleware`,工具注册不暴露 memory/hindsight 工具,prompt 不读取通用记忆,因此也不会触发 V1 的延迟提取模型调用。普通问答项目的项目级跨会话记忆是独立安全域,只受项目自身 `enabled` 控制,不得被这个通用总开关阻断。`config.yaml -> memory` 仅保留 provider/version/限额等参数,不能自行启用通用记忆。管理员通过 `GET/PUT /api/system-settings/memory` 和「设置 → 问答记忆」修改通用记忆开关;下一次创建的问答立即按新值构建。
@@ -712,6 +742,12 @@ from deerflow.config import get_app_config
**Elasticsearch offline operations built-in agent.** `elasticsearch-offline-ops` (「Elasticsearch 离线运维助手」) and its same-name public skill are seeded from `app/gateway/routers/_elasticsearch_offline_ops_seed_assets/` before `_sync_legacy_agents`. Seeding is missing-directory-only so administrator edits survive restarts. The agent intentionally has `file:read`, `file:write`, and `bash` but no web group: air-gapped guidance comes from the shipped version-parameterized deployment and troubleshooting references. Its SOUL requires the exact ES version/OS/architecture/topology before final commands, keeps Elasticsearch security/TLS enabled, separates the DeerFlow sandbox from the target ES hosts, and makes read-only evidence collection precede changes. `scripts/collect_diagnostics.sh` gathers Linux/service/log and authenticated cluster evidence without mutations or `--insecure`; credentials come from operator-owned netrc/header files rather than command arguments or an unrestricted curl config. `scripts/analyze_diagnostics.py` is a stdlib-only heuristic that emits matched evidence and a next read-only check, or `insufficient_evidence`; it never claims an automatic root cause. Tests: `tests/test_elasticsearch_offline_ops_seed.py`, `tests/test_seed_assets.py`.
**Chat-history migration built-in agent.** `chat-history-migrator` (「聊天记录迁移助手」) and public skill `chat-history-migration` are seeded from `app/gateway/routers/_chat_history_migration_seed_assets/` before `_sync_legacy_agents`. Seeding is missing-directory-only. The agent has `file:read`, `file:write`, and `bash` only. A visible chat is three layers: application index tables (`threads_meta`, runs, feedback, analysis-project bindings; these may live in GaussDB), LangGraph checkpoints (SQLite, or real PostgreSQL), and `.deer-flow/users/<user_id>/threads/<thread_id>/`. Do not run LangGraph's PostgreSQL checkpointer migrations on GaussDB. `scripts/migrate_chat_history.py` is stdlib-only: it inventories/exports/imports SQLite and thread files, emits read-only Gauss/MySQL export SQL, and emits Gauss `INSERT ... WHERE NOT EXISTS` load SQL. Import is a dry run until `--apply`. Existing thread ids are skipped unless `--on-conflict replace`. The users table and password columns are never copied; `--user-map OLD=NEW` rewrites ownership only. Tests: `tests/test_chat_history_migration_seed.py`, `tests/test_seed_assets.py`.
**Conversation-list transfer API.** Gated by Settings → 智能体与技能迁移 → 「启用跨服务器迁移」 (`system_settings.json` `agent_skill_migration.enabled`, default off). When off, the chats-page export/import buttons are hidden and the APIs return 403. `/api/chat-history/export` and `/api/chat-history/import` move the current user's ordinary/agent chats between machines from the chats page. Single-thread export accepts one thread id. Account-wide export is a backend job (`POST /export-all`, poll `GET /export-all/{job_id}`, download `GET /export-all/{job_id}/download`) that lists the user's conversations and writes them one-by-one so the browser does not drive the batch. Per-thread cap is 50 MiB of serialized checkpoint plus included `user-data` files; files that would exceed the remaining budget (or the 200-file cap) are omitted and listed in `skipped_files`, while messages still export. A thread is skipped only if it is missing, the checkpoint alone exceeds 50 MiB, or serialization fails. The bundle includes the latest serialized checkpoint (`messages`/`title`/…) plus uploads and outputs under `user-data/`. Import writes `threads_meta` (conversation cards) and the LangGraph checkpointer (message list — often a different database); channel writes must bump `new_versions` or Postgres/SQLite will persist an empty conversation. Import binds rows to the current account, skips existing thread ids, and rolls back a newly created row if state write fails. This is not a substitute for the operator CLI above. Tests: `tests/test_chat_history_transfer.py`.
**Gauss data-adaptation built-in agent.** `gauss-data-adapter` (「高斯数据适配助手」) and public skill `gauss-data-adaptation` are seeded from `app/gateway/routers/_gauss_data_adaptation_seed_assets/` before `_sync_legacy_agents`. Seeding is missing-directory-only. The agent has `file:read` and `bash` only. Its job is to turn one pasted intranet Gauss error or symptom into one concrete change. `references/adaptation-map.md` is the only diagnosis source: missing `asyncpg`/`opengauss-sqlalchemy` uses the offline `UV_EXTRAS=gauss` image or wheelhouse, Dolphin error 322 requires `DEERFLOW_DATABASE_PRODUCT=gauss_mysql` on the running process, `in-memory fallback` is a checkpointer failure, and an 8-hour clock skew is a backend/product mismatch. A bare `Automatic ORM schema creation failed` is not enough evidence to change configuration. `scripts/print_gauss_adaptation.py` is stdlib-only and only needed when the fix depends on native openGauss versus Dolphin; it never connects or reads URL environment values. Do not run LangGraph's PostgreSQL checkpointer migrations on GaussDB. Ordinary and agent chats share the application tables, so one business-database fix covers both lists. Tests: `tests/test_gauss_data_adaptation_seed.py`, `tests/test_seed_assets.py`.
**Strategy-selection functional agents.** `app/gateway/routers/_strategy_selection_seed.py` seeds nine stable folder-backed agents from `_strategy_selection_seed_assets/` before `_sync_legacy_agents`: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes. The global `strategy_agent_roles` registry classifies them independently of projects: intent/judge/solution are fixed, unique roles that may be configured but never added, disabled, reordered, or deleted; angle roles are a shared, ordered catalog that administrators may add, edit, disable (soft delete), restore, and reorder through `/api/strategy-agents`. Public `GET /api/strategy-agents` returns enabled identity/description metadata only; `include_disabled=true` or `include_config=true` is admin-only. Admin writes are `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; DELETE retains the folder-backed agent and role row to preserve historical references. Dynamic angles remain built-in folder-backed agents under the reserved `strategy-selection-angle-` id namespace, with `web` + `file:read`; a blank SOUL on create receives a safe role template. All strategy skill writes validate that names exist, are active, and are globally visible; generic agent CRUD cannot create or hard-delete reserved strategy IDs, and parallel execution rejects disabled roles. Angle configs reuse `policy-knowledge`; intent reuses `intent-clarification-policy`; the judge has no retrieval skill; the solution editor gets only `file:write`. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`.
Only `strategy-selection-intent` has a clarification reply surface in the strategy workbench. `parallel_agents` must exclude `ask_clarification` for every other `strategy-selection-*` role and inject a direct-delivery guard: angle, judge, and solution stages use conservative assumptions, lower confidence/score where needed, list gaps under data limitations, and still finish in one pass. The judge brief repeats this rule at runtime because existing folder-backed agent SOUL files may be administrator-customized and are intentionally never overwritten. If a legacy backend nevertheless emits a judge clarification, the frontend treats it as a bounded repair attempt rather than asking the user to repeat the same manual run. Malformed or incomplete evaluation JSON receives the same bounded repair path; prior attempts are replaced in the live panel, never concatenated into duplicate final content.
@@ -983,6 +1019,10 @@ Self-contained product knowledge base. **All knowledge capability lives in this
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Agent/skill live-folder migration** — `/api/admin/agent-skill-migrations` is the metadata half of an operator-managed offline transfer, deliberately split into two schema-v1 manifests. `POST /export` produces an **agent-only** manifest: agent metadata plus the unchanged skill-name references from each copied `config.yaml`; it never scans or exports skill metadata, so a missing referenced skill cannot block agent export/import. `POST /export-skills` is exposed to administrators in the actual `/page/workspace/skills` list (`SkillSettingsPage`) as well as `AdminSkillsPage.tsx`, and produces the independent skill manifest, including skill metadata and matching `.deerflow-skill-support.json` dependency directories. Operators copy payload directories separately into the target's actual runtime roots (no staging mount and no archive upload). `agent_skill_migration.py` signs each selected tree by relative paths + sizes + bytes, rejects symlinks/path traversal, and preflight requires both hash and embedded identity (`config.yaml.id` or `SKILL.md.name`) to match. Apply repeats preflight server-side, maps owners by normalized email with explicit current-admin fallback, uses privileged migration upserts, and recreates tags. Agent apply rebuilds skill-name/tool/model extras from the copied config and does not require `extensions_config.json`; skill apply alone restores usage metadata, refreshes skill caches, and patches only `skills.<name>.enabled` so unknown extension fields survive. Neither flow writes, moves, deletes, or inspects unlisted payloads. Knowledge-base content, credentials, MCP services, users, and conversations remain outside both manifests; non-empty LLMWiki bindings warn on agent export. Both flows are convergent/idempotent. Frontend entries: `AdminAgentsPage.tsx`, `SkillSettingsPage`, and `AdminSkillsPage.tsx`; client: `core/agent-migrations`; tests: `tests/test_agent_skill_migration.py`.
Agent/skill migration is controlled by the persisted `system_settings.json -> agent_skill_migration.enabled` switch and is fail-closed: the field defaults to `false`, so missing legacy configuration keeps both UI and migration APIs disabled. Administrators toggle it from the global settings dialog's 「智能体与技能迁移」 section; frontend pages consume the shared `useAgentSkillMigrationSettings` query, and every `/api/admin/agent-skill-migrations` endpoint independently checks the same server setting after administrator authorization. Skill apply supports explicit `skip_invalid_skills`: it removes skills whose own directory fails preflight and every skill depending on a failed support directory, filters skipped-only owner mappings/support entries, applies only the remaining matched skills, and reports `skills_skipped` plus `skipped_skill_names`; strict all-or-nothing remains the default, agent manifests never support skipping, and an all-skipped skill import is rejected.
Strategy-selection fan-out (`routers/parallel_agents.py`) must keep hidden reasoning separate per agent: provider reasoning blocks/fields and inline `<think>...</think>` text emit `reasoning_delta`, while visible answer text emits `delta` and only visible text may enter `agent_done.content`. Never concatenate `reasoning_delta` into a selectable strategy or generated deliverable. Inspect the final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
@@ -1023,7 +1063,7 @@ The fixed-deep-blue wx full-chat route (`#/page/workspace/wx/chats/new`) hides t
### Business-mapping authorization
`/api/business-mapping` remains globally readable. Seeded rows include the main codes (3Q / 6BF / 7BF / 8BF / AFX) plus independent slots `3q-0…3q-6`, `6-1…6-4`, and `7-1…7-4` (empty agent/chain until configured on the 配置管理 page). `3q-1…3q-6` seed as `is_custom=true` / `mapping_type=自定义类型` so `goPath=3q-1` resolves as a custom deep link; the configuration page hides those six rows and defaults the type filter to 自定义类型. Updates to 3Q, 6BF, 7BF, and those independent slots require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`.
`/api/business-mapping` remains globally readable. Seeded rows include the main codes (3Q / 6BF / 7BF / 8BF / AFX) plus independent slots `3q-0…3q-6`, `6-1…6-4`, and `7-1…7-4` (empty agent/chain until configured on the 配置管理 page). Updates to 3Q, 6BF, 7BF, and those independent slots require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`.
### Position-collaboration foundation
+97 -1
View File
@@ -229,6 +229,32 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
普通问答采用严格的输出载体门禁:只有本次请求明确要求 HTML、网页、页面、看板、大屏或 `.html` 文件时,才允许读取网页生成技能、调用页面模板工具或创建 HTML;“画流程图”“画图表”“画地图”“画思维导图”“做可视化”本身都不构成 HTML 意图。普通流程图直接在消息中输出 Mermaid,普通图表沿用既有消息内方式,普通地图输出 `map-json`。只有本次请求明确要求“思维导图 / 脑图 / 知识导图”或点名 `analysis-mindmap` 时,才会输出专用 `mindmap-json`;普通分析、总结、提纲和结构化 JSON 不会自动生成导图。未闭合的 `mindmap-json`(兼容历史 `mindmap` 围栏)会原样作为代码块流式显示,闭合且本地校验通过后才懒加载横向分析树预览。导图必须是一棵含唯一根节点的受限层级树:目标 12–24 个节点、硬上限 36 个,最多 4 层(含中心主题),中心主题至多 6 个一级维度、每个后续节点至多 3 个子节点;不得把报告的每个数据、段落或引用逐条转成节点。点击父节点本身,或其连线起点处的+/−,即可展开/收起子节点,且连接线明确可见;全量弹窗的右上角含关闭与视图工具、右下角含由实际布局绘制的全图预览,消息预览同样支持拖动、滚轮缩放和视图工具;消息卡片的单个下载图标会打开 PNG、SVG、JSON 选项。明确的 HTML 请求必须进入沙箱生成 `/mnt/user-data/outputs/*.html` 并通过 `present_files` 打开右侧制品预览,禁止用聊天中的完整 HTML 代码块代替文件。前端负责本地校验和渲染,并兼容模型误写成普通 `json` 围栏的合法地图契约;只有包含地图字段且通过完整 schema 校验的 JSON 才会按地图渲染,其他 JSON 保持原代码块。解析前会把常见的 `coords`/`from`/`to`/`lng`/`lat` 写法归一成契约字段;标点可按 `color` 着色(未写则自动配色)。只有用户明确要求连线时才输出 `lines`,此时连线带箭头并沿线循环流动;无法恢复的连线会被丢弃,标点仍可渲染。地图卡片占满消息内容区,与步骤条左右边界对齐;格式错误时显示可读错误与原始数据,不中断整条消息。
内置公共技能 `facebook-page-generator` 专门处理用户明确提出的 Facebook/Facebook 风格网页请求。技能先把会话材料或经授权检索得到的内容整理为 `site/navigation/stories/posts/sidebar/contacts/inbox` 七模块 JSON,再调用纯标准库脚本把 JSON 确定性注入自包含 Facebook 社交信息流模板;模型不手写 HTML/CSS。生成页保留右上角设置按钮、分模块 JSON 弹窗、实时校验和「应用并渲染」,同时保留移动端全屏编辑、深浅主题、发布、点赞、评论与搜索。浏览器编辑缓存以初始 JSON 指纹为命名空间,因此同一页面刷新可保留手动修改,而新生成页面不会被沙箱同源的旧 JSON 覆盖。源 JSON 和最终 HTML 都保存在 `/mnt/user-data/outputs/` 并一同交付,渲染器默认拒绝覆盖已有文件。
内置公共技能 `x-page-generator` 专门处理用户明确提出的 X 平台、Twitter/推特或 X 风格社交时间线网页请求。技能把材料或检索结果整理成 `site/navigation/composer/posts/trends/recommendations` 六模块 JSON,再由纯标准库脚本注入固定的自包含三栏 X 风格模板;生成页带右上角设置按钮和模块化 JSON 编辑弹窗,可即时应用数据,并保留主题切换、发布、回复、点赞、转发、收藏、搜索、关注及移动端布局。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `tiktok-page-generator` 专门处理用户明确提出的 TikTok、抖音国际版或 TikTok 风格短视频信息流网页请求。技能把材料或检索结果整理成 `site/navigation/videos/sidebar/comments/share` 六模块 JSON,再由纯标准库脚本注入固定的自包含沉浸式视频流模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,可即时应用数据,并保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索及移动端全屏布局。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `youtube-page-generator` 专门处理用户明确提出的 YouTube 或 YouTube 风格视频门户、视频首页、频道页请求。技能把材料或检索结果整理成 `site/navigation/categories/videos/shorts/subscriptions/comments` 七模块 JSON,再由纯标准库脚本注入固定的自包含响应式视频门户模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,并保留桌面导航、分类筛选、搜索、视频网格、Shorts、观看弹窗、点赞、收藏、订阅和评论。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `threads-page-generator` 专门处理用户明确提出的 Threads 或 Threads 风格串文首页、文本社交信息流请求。技能把材料或检索结果整理成 `site/navigation/composer/threads/recommendations/activity` 六模块 JSON,再由纯标准库脚本注入固定的自包含响应式串文模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,并保留深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板与移动端导航。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `cnn-page-generator` 专门处理用户明确提出的 CNN 或 CNN 风格全球新闻门户、新闻首页请求。技能把材料或检索结果整理成 `site/navigation/breaking/lead/latest/video/world/markets/newsletter/footer` 十模块 JSON,再由纯标准库脚本注入固定的自包含红黑新闻门户模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,并保留突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `bbc-page-generator` 专门处理用户明确提出的 BBC 或 BBC 风格国际公共新闻门户、新闻首页请求。技能把材料或检索结果整理成 `site/navigation/breaking/lead/focus/media/moreNews/regions/newsletter/footer` 十模块 JSON,再由纯标准库脚本注入固定的自包含双层导航公共新闻门户模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,并保留搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `xinhua-page-generator` 专门处理用户明确提出的新华社、新华网或新华社风格中文通讯社门户、新闻首页请求。技能把材料或检索结果整理成 `site/navigation/specials/headline/focus/features/channels/local/services/footer` 十模块 JSON,再由纯标准库脚本注入固定的自包含红蓝高密度通讯社门户模板。生成页带右上角设置按钮和模块化 JSON 编辑弹窗,并保留多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈。运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `white-house-page-generator` 专门处理用户明确提出的白宫官网、White House 风格政府首页或美国行政机构门户请求。技能把材料或检索结果整理成 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;JSON 键名、`id`、锚点和视觉枚举保留英文技术值,所有面向读者的机构名、标题、摘要、按钮、日期、活动及页脚文案强制使用简体中文,英文来源必须先翻译,`site.language` 固定为 `zh-CN`。再由纯标准库脚本注入固定的自包含海军蓝、米白和金色行政机构门户模板。生成页带右上角设置按钮和中文模块化 JSON 编辑弹窗,并保留透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈。默认内容明确为概念模板,不冒充政府官网;运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `manila-times-page-generator` 专门处理用户明确提出的马尼拉时报、The Manila Times 或马尼拉时报风格菲律宾大报门户请求。技能把材料或检索结果整理成 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;JSON 键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有面向读者的报头、栏目、新闻、作者、日期、按钮及页脚文案强制使用简体中文,英文或菲律宾语来源必须先翻译,`site.language` 固定为 `zh-CN`。再由纯标准库脚本注入固定的自包含米白纸张、海军蓝和红色报业门户模板。生成页带右上角设置按钮和中文模块化 JSON 编辑弹窗,并保留菲律宾旗色带、衬线报头、天气轮播、滚动吸顶分类栏、三栏头条、最新/热门与地区切换、评论、商业体育、视频字幕、数字报翻页和订阅反馈。默认内容明确为概念模板,不冒充官方网站;运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `gma-news-page-generator` 专门处理用户明确提出的 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面请求。技能把材料或检索结果整理成 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;JSON 键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可以保留,其余所有面向读者的栏目、新闻、作者、日期、按钮及页脚文案强制使用简体中文,英文或菲律宾语来源必须先翻译,`site.language` 固定为 `zh-CN`。再由纯标准库脚本注入固定的自包含红蓝黄色广播新闻门户模板。生成页带右上角设置按钮和中文模块化 JSON 编辑弹窗,并保留即时新闻展开、天气轮播、滚动吸顶导航、广播式头条、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、视频、播客、彩票开奖和订阅反馈。默认内容明确为概念模板,不冒充官方网站;运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `yahoo-japan-page-generator` 专门处理用户明确提出的雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面请求。技能把材料或检索结果整理成 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;JSON 键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可以保留,其余所有面向读者的栏目、新闻、按钮、天气、交通、行情、商品、体育及页脚文案强制使用简体中文,日文或英文来源必须先翻译,`site.language` 固定为 `zh-CN`。再由纯标准库脚本注入固定的自包含红蓝高密度日本综合门户模板。生成页带右上角设置按钮和中文模块化 JSON 编辑弹窗,并保留多类型搜索、热门词、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部。默认内容明确为概念模板,不冒充官方网站;运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
内置公共技能 `yomiuri-news-page-generator` 专门处理用户明确提出的读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面请求。技能把材料或检索结果整理成 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;JSON 键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可以保留,其余所有面向读者的报头、栏目、新闻、作者、按钮、日期、纸面、视频及页脚文案强制使用简体中文,日文或英文来源必须先翻译,`site.language` 固定为 `zh-CN`。再由纯标准库脚本注入固定的自包含米白纸张、酒红和深蓝传统大报门户模板。生成页带右上角设置按钮和中文模块化 JSON 编辑弹窗,并保留字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类新闻切换、社论、视频字幕和邮件订阅。默认内容明确为概念模板,不冒充官方网站;运行时以初始 JSON 指纹隔离缓存,对不允许 localStorage 的沙箱也会回退到内嵌数据正常渲染。
生成 `.html` 网页、看板或大屏时使用独立的离线绘图契约:流程图使用 `data-df-flow` + AntV G6(不使用 Mermaid),图表使用 `data-df-chart`,地图使用本地 GeoJSON + ECharts Canvas 的 `data-df-geo`。Gateway 从公开路径 `/api/public/html-runtime/` 提供浏览器版 `g6.min.js`、`echarts.min.js`、`bootstrap.min.css`、`bootstrap.bundle.min.js`、`alpine.min.js` 和统一初始化脚本 `deerflow-runtime.js`,以及中国省级、美国州级、日本都道府县、台湾地区县市四份地图资源(`china-provinces` / `us-states` / `japan-prefectures` / `taiwan-counties`)。Bootstrap 负责布局与标准弹窗,Alpine 负责轻量状态和动态列表;生成的 HTML 源码会直接引用这些后台文件,不依赖前端 React 构建产物或公网 CDN。设置里的「HTML 地图」可选 ECharts 本地 GeoJSON,或地图网页(Leaflet);地图网页的瓦片模板在设置 → 外观的「地图瓦片地址」中配置。运行时会等隐藏的逻辑页真实可见后再初始化 ECharts,并通过 `ResizeObserver` 跟随沙箱和页面容器尺寸变化,避免地图或图表首屏以零宽度渲染。区域/标点的 `description` 必须为纯文本,点击后由运行时显示详情浮层;需要筛选或切到明细页时监听 `deerflow:geo-map-click`,不能把链接、HTML 或 `onclick` 写进 `description`。深色 HTML 需声明 `data-df-theme="dark"`,其 ECharts 的全局、标题、图例、坐标轴和可见系列标签都必须显式使用浅色文字,避免 ECharts 默认黑字。这些文件作为 `packages/harness/deerflow/html_runtime/assets/` 的包资源随 harness wheel 一起发布,第三方许可同目录保存。设置 → 外观的「HTML 运行库地址」填写浏览器可访问的 Gateway Runtime 地址(未配置时默认 `http://localhost:8001/api/public/html-runtime`),运行时据此解析本地 GeoJSON 地址;内网部署在设置弹窗里改成用户能够打开的域名即可。用户上传的栅格图片若要作为生成 HTML 的背景图,智能体会使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`,不要写沙箱路径或制品接口。该能力不改变普通问答消息列表,普通问答地图仍走原有 `map-json` 前端瓦片渲染与 `frontend-web/public/runtime-config.js` 的 `VITE_MAP_TILE_URL`。地图大屏使用“中间地图舞台”而非整页地图背景:中间地图保持可见和可点击,两侧为窄信息栏、底部为摘要面板;提到的城市和行政区必须分别写入 `markers` 与高亮 `regions`。
从已有 Markdown 报告生成 HTML 时,必须根据材料盘点 Mermaid/流程图、图表及地图,并在 `page_report_template validate` 的 `required_features` 和 `page_brief` 中如实声明。流程图必须包含 `g6.min.js`、`deerflow-runtime.js`、`.df-flow` 与 `data-df-flow`,并拒绝 Mermaid、手写 HTML/CSS 流程节点以及页面内自行初始化 `G6.Graph`;图表和地图分别检查 ECharts 资源与 `data-df-chart` / `data-df-geo` 声明式数据块。`data-df-chart` 必须是严格 JSON,校验会拒绝函数、箭头函数和其他 JavaScript 回调;颜色使用字面量或数据项 `itemStyle`,提示文本使用字符串模板。地图只能使用上述四个内置 GeoJSON,校验会拒绝 Leaflet、`data-df-map`、瓦片和页面内自行初始化 ECharts。默认页面从零开始直接写入最终 outputs HTML,不读取或预览模板;只有用户明确要求模板时,prepare 才会把要求持久化到同名 brief,validate 会自动恢复并与调用参数合并。校验失败时不得交付,必须修复后重新校验。
@@ -350,7 +376,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
| `GET/PUT /api/business-mapping` | Read global business mappings (seeded 3Q/6BF/7BF/8BF/AFX plus independent slots 3q-0…3q-6, 6-1…6-4, 7-1…7-4). `3q-1…3q-6` seed as custom-type rows and stay off the configuration-page list. Updates to 3Q/6BF/7BF and the independent slots require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/PUT /api/business-mapping` | Read global business mappings (seeded 3Q/6BF/7BF/8BF/AFX plus independent slots 3q-0…3q-6, 6-1…6-4, 7-1…7-4). Updates to 3Q/6BF/7BF and the independent slots require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
@@ -667,6 +693,20 @@ AI writing supports a sample-imitation mode: the frontend can upload or paste a
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
#### 智能体与技能跨服务器迁移
迁移拆成两份互不混装的清单。管理员在 `/page/workspace/admin/agents` 点击卡片选择智能体并导出智能体清单;它只包含智能体元数据、标签、创建者邮箱、发布/私有状态、智能体目录 SHA-256,以及 `config.yaml` 中原样保留的技能名称关联,不包含任何技能元数据。引用的技能即使在源机运行目录不存在也不会阻断智能体导出。管理员在实际技能页 `/page/workspace/skills`(管理员技能页 `/page/workspace/admin/skills` 也保留入口)另行选择技能并导出技能清单;该清单才包含技能归属、发布/上下线/精选状态、标签、技能目录 SHA-256 和必要的支持目录。两种清单都不包含目录文件、模型密钥或登录凭据,大批量场景可分别分批导出。
迁移功能默认关闭。管理员可在全局「设置 → 智能体与技能迁移」中开启;状态持久化到 `system_settings.json -> agent_skill_migration.enabled`,旧部署缺少该配置块时仍按关闭处理。关闭时前端隐藏选择、全选、导出和导入入口(含对话页的聊天记录导出/导入按钮),后端迁移 API 也会拒绝执行;登录用户可读取开关状态,只有管理员可以修改。
迁移步骤:先保留源机备份,将智能体目录复制到目标机页面显示的实时 `agents_root`,将技能及技能清单列出的支持目录复制到实时 `skills_root`。建议先在目标机「技能管理」导入技能清单,再到「智能体管理」导入智能体清单;智能体清单也允许先导入,暂时不存在的技能名称关联会保留。两处导入都会先按稳定 ID/名称和整目录哈希预检:智能体核对 `config.yaml.id`,技能核对 `SKILL.md.name`。智能体导入保持整批严格校验;技能导入遇到目录缺失、错放、内容不一致、符号链接或支持目录异常时,可由管理员显式勾选「跳过异常技能」继续,被异常支持目录依赖的技能会一并跳过且不写入任何元数据,其余通过预检的技能正常导入;若全部技能都需跳过则仍禁止执行。创建者默认按邮箱映射到目标机已有用户;无法匹配时必须显式选择归属当前管理员。
预检通过后,智能体导入只同步智能体数据库元数据、标签及从已复制 `config.yaml` 读取的技能名称关联;不要求 `extensions_config.json`,也不检查关联技能是否存在。技能导入才同步技能数据库元数据、标签、使用状态、技能提示缓存,并只补丁 `extensions_config.json` 中清单技能的 `enabled` 字段。两条流程都不会移动、覆盖或删除任何目录,也不会改动未列入清单的资源及扩展配置字段。重复导入同一清单是幂等的,若中途失败可在修复原因后重试。
建议在停止目标服务或确保目录已完整落盘后复制,再启动服务进行网页预检,避免对正在复制的目录计算到中间态哈希。不要用旧的「同步技能」按钮代替该流程,它只清理智能体中指向不存在技能的引用,不恢复创建者、发布状态和标签。管理员 API 为 `GET /api/admin/agent-skill-migrations/roots`、智能体导出 `POST /export`、技能导出 `POST /export-skills`、`POST /preflight` 和 `POST /apply`。
这两类清单都不迁移知识库正文、模型供应商凭据、MCP 服务、用户账号或会话记录。智能体绑定了 LLMWiki 知识库时,智能体清单会给出提醒;这些外部依赖必须提前在目标机器部署并核对其 ID。
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
The built-in `elasticsearch-offline-ops` (「Elasticsearch 离线运维助手」) is also seeded before legacy-agent synchronization, together with the public skill of the same name. It is intended for air-gapped self-managed Elasticsearch deployments: it selects RPM/DEB, archive, or Docker paths only after the exact ES version, target OS/architecture, topology, capacity, TLS, plugins, migration, and recovery objectives are known. The bundled references cover connected-zone artifact manifests and checksums, host/bootstrap preflight, secure first-cluster bootstrapping, acceptance, snapshots, upgrades, and evidence-first troubleshooting. A read-only Linux collector gathers service/log/host/cluster evidence without changing settings or disabling TLS; authentication comes from operator-owned netrc or header files rather than a password argument. A dependency-free analyzer turns known log signatures into evidence plus the next discriminating check and explicitly reports insufficient evidence instead of inventing a cause. `bash` remains the DeerFlow sandbox, so the agent must never treat its local host state as the target ES node unless the user establishes that boundary. Existing agent and skill directories are not overwritten. Regression coverage: `tests/test_elasticsearch_offline_ops_seed.py` and `tests/test_seed_assets.py`.
@@ -1063,6 +1103,14 @@ the rest of the visual tokens; the optional `overrides` object only exposes
must use the `custom-theme-` prefix. Disabled or removed custom themes cannot
remain the system default and automatically fall back to `light`.
Application chrome name and logo are a separate setting, 「设置 → 系统名称」.
They persist in `system_settings.json -> app_identity` and an uploaded logo is
stored under `.deer-flow/app-identity/`. `GET/PUT/DELETE /api/system-settings/app-identity`
reads and updates it (writes are admin-only); the uploaded image is served from
`GET /api/public/app-identity/logo`. An empty name keeps the built-in titles, and
a missing logo keeps the static theme logos. This block does not change
`generated_page_branding` or generated HTML.
Compact top-bar shortcuts use the same appearance endpoint. In addition to
login and theme query parameters, each shortcut may include ordered keyless
`path` parameters. The frontend resolves each selected login field and appends
@@ -1758,6 +1806,54 @@ checkpointer:
normalized to the openGauss dialect. Do not configure the LangGraph
PostgreSQL checkpointer against GaussDB: its migrations are PostgreSQL-specific.
For a production image, build with `--build-arg UV_EXTRAS=gauss`.
openGauss Dolphin (MySQL-protocol) mode keeps `database.backend=mysql` and
sets `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`. Only that marker installs
`deerflow.persistence.gauss_mysql_compat` and the native-catalog schema
repair path. A normal MySQL deployment must leave the marker unset so the
stock dialect, Alembic, and `CREATE DATABASE` retry stay unchanged.
## Chat history migration
The built-in agent 「聊天记录迁移助手」(`chat-history-migrator`) migrates ordinary
DeerFlow conversations onto another server. The skill `chat-history-migration`
ships `scripts/migrate_chat_history.py`, which uses only the Python standard
library and does not connect to GaussDB.
A conversation has three layers. `threads_meta`, runs, feedback and analysis
project bindings are the index and may live in GaussDB. LangGraph checkpoints
hold the messages and stay in SQLite (or a real PostgreSQL checkpointer).
Uploads and generated files stay under
`.deer-flow/users/<user_id>/threads/<thread_id>/`. Do not run the LangGraph
PostgreSQL checkpointer migrations against GaussDB.
On the source, `emit-export-sql --dialect opengauss` (or `mysql` for Dolphin)
prints read-only export SQL. `export` / `pack-jsonl` then builds a bundle from
that index, the checkpoint SQLite file and the thread directories. On the
target, `import` is a dry run until `--apply`; existing thread ids are left in
place unless `--on-conflict replace`. `emit-gauss-sql` writes
`INSERT ... WHERE NOT EXISTS` for a Gauss application database. Passwords and
the `users` table are not copied. When account ids differ, repeat
`--user-map OLD=NEW`. Put database passwords in `~/.pgpass` or the client
prompt, not in the command line and not in the agent conversation.
## Gauss data adaptation
The built-in agent 「高斯数据适配助手」(`gauss-data-adapter`) tells an operator
what to change after an intranet GaussDB error or symptom. The skill
`gauss-data-adaptation` does not connect to the database and does not read
passwords. Paste the error first. The agent matches one row in
`references/adaptation-map.md` and names the config field, environment
variable, or offline image to change.
Native openGauss is `database.backend: gauss`. Dolphin, the MySQL protocol, stays
`database.backend: mysql` and requires `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`
on the running Gateway process. The two modes must not be enabled together.
Keep the LangGraph checkpointer on SQLite or a real PostgreSQL instance; do
not run its PostgreSQL migrations against GaussDB. A running process is not
proof that schema creation succeeded. Ordinary Q&A and agent chats use the
same application tables.
# 问答记忆开关
问答记忆默认关闭。关闭时,普通问答不会加载记忆中间件、不会注入历史记忆,也不会在回答结束后调用模型提取或写入记忆。管理员可在「设置 → 问答记忆」中显式开启;开关保存在运行目录的 `system_settings.json`,新部署或没有该字段时始终按关闭处理。
@@ -0,0 +1,232 @@
"""Publish product-owned public skills into the configured live skill root.
The Gateway may be launched through the supported shell scripts, an image
entrypoint, Compose, or an external release repository. Startup therefore
cannot rely on one shell wrapper to populate a persistent ``/app/skills``
mount. This module is deliberately standard-library-only so both the launcher
CLI and the Gateway lifespan can use the exact same synchronization contract.
"""
from __future__ import annotations
import hashlib
import os
import shutil
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
ROUTED_PAGE_SKILLS = (
"facebook-page-generator",
"x-page-generator",
"tiktok-page-generator",
"youtube-page-generator",
"threads-page-generator",
"cnn-page-generator",
"bbc-page-generator",
"xinhua-page-generator",
"white-house-page-generator",
"manila-times-page-generator",
"gma-news-page-generator",
"yahoo-japan-page-generator",
"yomiuri-news-page-generator",
)
_BUNDLED_SKILLS_ENV = "DEER_FLOW_BUNDLED_SKILLS_PATH"
_SYNC_LOCK_FILE = ".builtin-public-skills.lock"
_COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
class SkillSyncError(RuntimeError):
"""Raised when bundled skills cannot be published safely."""
@contextmanager
def _exclusive_target_lock(target_root: Path) -> Iterator[None]:
"""Serialize startup copies across Uvicorn worker processes."""
target_root.mkdir(parents=True, exist_ok=True)
lock_path = target_root / _SYNC_LOCK_FILE
with lock_path.open("a+b") as handle:
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def sync_builtin_public_skills(
source_root: Path,
target_root: Path,
*,
required: tuple[str, ...] = (),
) -> dict[str, object]:
"""Overlay bundled ``public`` skills while preserving live custom skills."""
source_public = source_root.resolve() / "public"
target_root = target_root.resolve()
target_public = target_root / "public"
target_custom = target_root / "custom"
if not source_public.is_dir():
raise SkillSyncError(f"Bundled public skills directory not found: {source_public}")
target_public.mkdir(parents=True, exist_ok=True)
target_custom.mkdir(parents=True, exist_ok=True)
copied: list[str] = []
discovered: list[str] = []
source_is_live = source_public == target_public.resolve()
for source in sorted(source_public.iterdir(), key=lambda path: path.name):
if not source.is_dir() or source.name.startswith("."):
continue
if not source_is_live:
destination = target_public / source.name
shutil.copytree(
source,
destination,
dirs_exist_ok=True,
symlinks=True,
ignore=_COPY_IGNORE,
)
copied.append(source.name)
if (source / "SKILL.md").is_file():
discovered.append(source.name)
missing = sorted(name for name in required if not (target_public / name / "SKILL.md").is_file())
if missing:
raise SkillSyncError("Required built-in skills are unavailable after sync: " + ", ".join(missing))
return {
"status": "ok",
"source": str(source_public),
"target": str(target_public),
"sourceIsLive": source_is_live,
"copiedDirectories": copied,
"discoveredSkills": discovered,
"requiredSkills": list(required),
}
def _required_packages_digest(public_root: Path, required: tuple[str, ...]) -> str | None:
"""Hash the required packages, ignoring interpreter cache artifacts."""
digest = hashlib.sha256()
for name in required:
skill_root = public_root / name
if not (skill_root / "SKILL.md").is_file():
return None
files = sorted(
path
for path in skill_root.rglob("*")
if path.is_file()
and "__pycache__" not in path.parts
and path.suffix not in {".pyc", ".pyo"}
)
for path in files:
relative = path.relative_to(public_root).as_posix().encode("utf-8")
digest.update(len(relative).to_bytes(4, "big"))
digest.update(relative)
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def publish_bundled_public_skills(
source_root: Path,
target_root: Path,
*,
required: tuple[str, ...] = ROUTED_PAGE_SKILLS,
) -> dict[str, object]:
"""Lock, compare and publish the current bundled public skill tree."""
source_root = source_root.resolve()
target_root = target_root.resolve()
if source_root == target_root:
result = sync_builtin_public_skills(source_root, target_root, required=required)
result["skipped"] = True
return result
with _exclusive_target_lock(target_root):
source_digest = _required_packages_digest(source_root / "public", required)
if source_digest is None:
missing = sorted(
name for name in required if not (source_root / "public" / name / "SKILL.md").is_file()
)
raise SkillSyncError("Required bundled skills are unavailable: " + ", ".join(missing))
target_digest = _required_packages_digest(target_root / "public", required)
if source_digest == target_digest:
return {
"status": "ok",
"source": str(source_root / "public"),
"target": str(target_root / "public"),
"sourceIsLive": (source_root / "public") == (target_root / "public"),
"copiedDirectories": [],
"discoveredSkills": list(required),
"requiredSkills": list(required),
"skipped": True,
}
result = sync_builtin_public_skills(source_root, target_root, required=required)
result["skipped"] = False
return result
def resolve_bundled_skills_root() -> Path:
"""Return the authoritative bundled skill root for this deployment."""
explicit = os.getenv(_BUNDLED_SKILLS_ENV, "").strip()
if explicit:
candidate = Path(explicit).expanduser().resolve()
if not (candidate / "public").is_dir():
raise SkillSyncError(f"Configured bundled skills directory not found: {candidate / 'public'}")
return candidate
packaged = Path(__file__).resolve().parents[1] / "skills"
candidates = (Path("/app/bundled-skills"), packaged)
for candidate in candidates:
resolved = candidate.resolve()
if (resolved / "public").is_dir():
return resolved
raise SkillSyncError(
"Bundled public skills directory not found; checked: "
+ ", ".join(str(candidate.resolve() / "public") for candidate in candidates)
)
def ensure_bundled_public_skills(
app_config,
*,
required: tuple[str, ...] = ROUTED_PAGE_SKILLS,
) -> dict[str, object]:
"""Synchronize bundled skills into the configured root during startup."""
source_root = resolve_bundled_skills_root()
target_root = Path(app_config.skills.get_skills_path()).resolve()
return publish_bundled_public_skills(source_root, target_root, required=required)
__all__ = [
"ROUTED_PAGE_SKILLS",
"SkillSyncError",
"ensure_bundled_public_skills",
"publish_bundled_public_skills",
"resolve_bundled_skills_root",
"sync_builtin_public_skills",
]
@@ -0,0 +1,924 @@
"""Agent/skill metadata migration manifests for already-copied live folders.
The manifest deliberately contains no agent or skill payload files. Operators
copy those directories with their normal offline transfer process; these
helpers only prove that the target runtime folders match the exported inventory
before database/configuration metadata is reconciled.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any, Literal
from uuid import uuid4
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
ResourceStatus = Literal["matched", "missing", "hash_mismatch", "identity_mismatch", "invalid_path"]
OwnerStatus = Literal["matched", "unresolved"]
_TREE_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
_TREE_HASH_MAX_WORKERS = 4
def _safe_relative_path(value: str) -> str:
normalized = value.strip()
if not normalized or "\\" in normalized or ":" in normalized:
raise ValueError("relative_dir must be a non-empty portable relative path")
path = PurePosixPath(normalized)
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
raise ValueError("relative_dir must stay inside the configured runtime root")
return path.as_posix()
def compute_tree_hash(root: Path) -> str:
"""Return a deterministic SHA-256 for a directory tree.
Relative paths and bytes are both signed. Symlinks are rejected so a
manifest cannot attest content outside the selected runtime directory.
"""
root = root.resolve()
if not root.is_dir():
raise FileNotFoundError(root)
digest = hashlib.sha256()
files = sorted((path for path in root.rglob("*") if path.is_file() or path.is_symlink()), key=lambda p: p.relative_to(root).as_posix())
for path in files:
if path.is_symlink():
raise ValueError(f"Symlinks are not supported in migration trees: {path}")
relative = path.relative_to(root).as_posix().encode("utf-8")
digest.update(len(relative).to_bytes(4, "big"))
digest.update(relative)
digest.update(path.stat().st_size.to_bytes(8, "big"))
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
class OwnerReference(BaseModel):
model_config = ConfigDict(extra="forbid")
source_user_id: str | None = None
email: str | None = None
@field_validator("source_user_id", "email")
@classmethod
def _strip_optional(cls, value: str | None) -> str | None:
return value.strip() if value and value.strip() else None
class TagManifestItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=128)
color: str = Field(default="gray", max_length=32)
description: str = Field(default="", max_length=1024)
class AgentManifestItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
relative_dir: str
tree_hash: str
name: str
description: str = ""
owner: OwnerReference | None = None
published: bool = False
is_private: bool = False
square_id: str = ""
featured_order: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
skills: list[str] = Field(default_factory=list, max_length=5000)
tags: list[TagManifestItem] = Field(default_factory=list, max_length=200)
@field_validator("relative_dir")
@classmethod
def _relative_dir(cls, value: str) -> str:
return _safe_relative_path(value)
@field_validator("tree_hash")
@classmethod
def _tree_hash(cls, value: str) -> str:
normalized = value.lower()
if not _TREE_HASH_RE.fullmatch(normalized):
raise ValueError("tree_hash must be a SHA-256 hex digest")
return normalized
@model_validator(mode="after")
def _directory_matches_id(self):
if self.relative_dir != self.id.lower():
raise ValueError("agent relative_dir must equal the stable agent id")
return self
class SkillManifestItem(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
category: Literal["public", "custom"]
relative_dir: str
tree_hash: str
owner: OwnerReference | None = None
published: bool = False
detail: str | None = None
name_zh: str | None = None
always_on: bool = False
square_id: str = ""
enabled: bool = True
state: Literal["active", "stale", "archived"] = "active"
pinned: bool = False
featured: bool = False
source: Literal["agent", "upload"] = "upload"
created_at: datetime | None = None
updated_at: datetime | None = None
tags: list[TagManifestItem] = Field(default_factory=list, max_length=200)
@field_validator("relative_dir")
@classmethod
def _relative_dir(cls, value: str) -> str:
normalized = _safe_relative_path(value)
if PurePosixPath(normalized).parts[0] not in {"public", "custom"}:
raise ValueError("skill relative_dir must begin with public/ or custom/")
return normalized
@field_validator("tree_hash")
@classmethod
def _tree_hash(cls, value: str) -> str:
normalized = value.lower()
if not _TREE_HASH_RE.fullmatch(normalized):
raise ValueError("tree_hash must be a SHA-256 hex digest")
return normalized
@model_validator(mode="after")
def _category_matches_directory(self):
if PurePosixPath(self.relative_dir).parts[0] != self.category:
raise ValueError("skill category must match relative_dir")
return self
class SupportDirectoryManifestItem(BaseModel):
model_config = ConfigDict(extra="forbid")
relative_dir: str
tree_hash: str
required_by: list[str] = Field(default_factory=list, max_length=5000)
@field_validator("relative_dir")
@classmethod
def _relative_dir(cls, value: str) -> str:
normalized = _safe_relative_path(value)
if PurePosixPath(normalized).parts[0] not in {"public", "custom"}:
raise ValueError("support relative_dir must begin with public/ or custom/")
return normalized
@field_validator("tree_hash")
@classmethod
def _tree_hash(cls, value: str) -> str:
normalized = value.lower()
if not _TREE_HASH_RE.fullmatch(normalized):
raise ValueError("tree_hash must be a SHA-256 hex digest")
return normalized
class MigrationManifest(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[1] = 1
bundle_id: str = Field(min_length=1, max_length=128)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
agents: list[AgentManifestItem] = Field(default_factory=list, max_length=1000)
skills: list[SkillManifestItem] = Field(default_factory=list, max_length=5000)
support_directories: list[SupportDirectoryManifestItem] = Field(default_factory=list, max_length=1000)
warnings: list[str] = Field(default_factory=list, max_length=1000)
class ResourcePreflightItem(BaseModel):
kind: Literal["agent", "skill", "support"]
key: str
relative_dir: str
status: ResourceStatus
message: str = ""
class OwnerMappingItem(BaseModel):
source_user_id: str | None = None
email: str | None = None
target_user_id: str | None = None
status: OwnerStatus
class MigrationPreflight(BaseModel):
ready: bool
agents: list[ResourcePreflightItem]
skills: list[ResourcePreflightItem]
support_directories: list[ResourcePreflightItem]
owner_mappings: list[OwnerMappingItem]
class MigrationApplyResult(BaseModel):
bundle_id: str
agents_applied: int
skills_applied: int
skills_skipped: int = 0
skipped_skill_names: list[str] = Field(default_factory=list)
tags_created: int
fallback_owner_count: int
def _resolved_child(root: Path, relative_dir: str) -> Path | None:
root = root.resolve()
child = root
for part in PurePosixPath(relative_dir).parts:
child /= part
if child.is_symlink():
return None
child = child.resolve()
try:
child.relative_to(root)
except ValueError:
return None
return child
def _agent_identity(directory: Path) -> str | None:
config_path = directory / "config.yaml"
if not config_path.is_file():
return None
try:
payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError):
return None
return str(payload.get("id") or "").strip() or None if isinstance(payload, dict) else None
def _skill_identity(directory: Path) -> str | None:
skill_path = directory / "SKILL.md"
if not skill_path.is_file():
return None
try:
text = skill_path.read_text(encoding="utf-8")
if not text.startswith("---"):
return None
parts = text.split("---", 2)
payload = yaml.safe_load(parts[1]) or {}
except (OSError, UnicodeError, yaml.YAMLError, IndexError):
return None
return str(payload.get("name") or "").strip() or None if isinstance(payload, dict) else None
def _resource_preflight(
*,
kind: Literal["agent", "skill", "support"],
key: str,
root: Path,
relative_dir: str,
expected_hash: str,
expected_identity: str | None = None,
) -> ResourcePreflightItem:
directory = _resolved_child(root, relative_dir)
if directory is None:
return ResourcePreflightItem(
kind=kind,
key=key,
relative_dir=relative_dir,
status="invalid_path",
message="目录路径不安全、包含符号链接或超出运行根目录",
)
if not directory.is_dir():
return ResourcePreflightItem(kind=kind, key=key, relative_dir=relative_dir, status="missing", message="目标运行目录中未找到该目录")
if expected_identity is not None:
identity_file = directory / ("config.yaml" if kind == "agent" else "SKILL.md")
if not identity_file.is_file():
return ResourcePreflightItem(
kind=kind,
key=key,
relative_dir=relative_dir,
status="missing",
message=f"目录中缺少 {identity_file.name}",
)
try:
actual_hash = compute_tree_hash(directory)
except (OSError, ValueError) as exc:
return ResourcePreflightItem(kind=kind, key=key, relative_dir=relative_dir, status="hash_mismatch", message=str(exc))
if actual_hash != expected_hash:
return ResourcePreflightItem(kind=kind, key=key, relative_dir=relative_dir, status="hash_mismatch", message="目录内容与导出清单不一致")
if expected_identity is not None:
actual_identity = _agent_identity(directory) if kind == "agent" else _skill_identity(directory)
if actual_identity != expected_identity:
return ResourcePreflightItem(
kind=kind,
key=key,
relative_dir=relative_dir,
status="identity_mismatch",
message=f"目录内标识为 {actual_identity or '空'},清单标识为 {expected_identity}",
)
return ResourcePreflightItem(kind=kind, key=key, relative_dir=relative_dir, status="matched")
def preflight_manifest(
manifest: MigrationManifest,
*,
agents_root: Path,
skills_root: Path,
users_by_email: dict[str, str],
) -> MigrationPreflight:
"""Compare a manifest with target live roots without modifying anything."""
normalized_users = {email.strip().casefold(): user_id for email, user_id in users_by_email.items()}
resource_specs: list[dict[str, Any]] = [
{
"kind": "agent",
"key": item.id,
"root": agents_root,
"relative_dir": item.relative_dir,
"expected_hash": item.tree_hash,
"expected_identity": item.id,
}
for item in manifest.agents
]
resource_specs.extend(
{
"kind": "skill",
"key": f"{item.category}:{item.name}",
"root": skills_root,
"relative_dir": item.relative_dir,
"expected_hash": item.tree_hash,
"expected_identity": item.name,
}
for item in manifest.skills
)
resource_specs.extend(
{
"kind": "support",
"key": item.relative_dir,
"root": skills_root,
"relative_dir": item.relative_dir,
"expected_hash": item.tree_hash,
}
for item in manifest.support_directories
)
def _check_resource(spec: dict[str, Any]) -> ResourcePreflightItem:
return _resource_preflight(**spec)
if len(resource_specs) > 1:
with ThreadPoolExecutor(
max_workers=min(_TREE_HASH_MAX_WORKERS, len(resource_specs)),
thread_name_prefix="skill-migration-hash",
) as executor:
resources = list(executor.map(_check_resource, resource_specs))
else:
resources = [_check_resource(spec) for spec in resource_specs]
agent_count = len(manifest.agents)
skill_count = len(manifest.skills)
agents = resources[:agent_count]
skills = resources[agent_count : agent_count + skill_count]
support = resources[agent_count + skill_count :]
owners: dict[tuple[str | None, str | None], OwnerMappingItem] = {}
for resource in [*manifest.agents, *manifest.skills]:
owner = resource.owner
if owner is None or (owner.source_user_id is None and owner.email is None):
continue
email = owner.email.strip().casefold() if owner.email else None
target_user_id = normalized_users.get(email) if email else None
key = (owner.source_user_id, email)
owners[key] = OwnerMappingItem(
source_user_id=owner.source_user_id,
email=email,
target_user_id=target_user_id,
status="matched" if target_user_id else "unresolved",
)
owner_mappings = list(owners.values())
ready = all(item.status == "matched" for item in [*agents, *skills, *support]) and all(item.status == "matched" for item in owner_mappings)
return MigrationPreflight(
ready=ready,
agents=agents,
skills=skills,
support_directories=support,
owner_mappings=owner_mappings,
)
def prepare_manifest_for_apply(
manifest: MigrationManifest,
preflight: MigrationPreflight,
*,
skip_invalid_skills: bool,
) -> tuple[MigrationManifest, MigrationPreflight, list[str]]:
"""Optionally remove invalid skills and support-directory dependants.
Skipping is intentionally limited to skill-only manifests. Agent manifests
retain all-or-nothing semantics because their live ``config.yaml`` is the
authoritative source for several related database caches.
"""
if not skip_invalid_skills:
return manifest, preflight, []
if manifest.agents:
raise ValueError("跳过异常条目仅支持独立技能清单")
failed_skill_dirs = {
item.relative_dir for item in preflight.skills if item.status != "matched"
}
skipped_names = {
item.name for item in manifest.skills if item.relative_dir in failed_skill_dirs
}
support_by_dir = {
item.relative_dir: item for item in manifest.support_directories
}
for resource in preflight.support_directories:
if resource.status == "matched":
continue
support = support_by_dir.get(resource.relative_dir)
if support is not None:
skipped_names.update(support.required_by)
ordered_skipped = [item.name for item in manifest.skills if item.name in skipped_names]
kept_skills = [item for item in manifest.skills if item.name not in skipped_names]
kept_names = {item.name for item in kept_skills}
kept_support = [
item
for item in manifest.support_directories
if any(name in kept_names for name in item.required_by)
]
kept_skill_dirs = {item.relative_dir for item in kept_skills}
kept_support_dirs = {item.relative_dir for item in kept_support}
owner_keys = {
(
item.owner.source_user_id,
item.owner.email.strip().casefold() if item.owner and item.owner.email else None,
)
for item in kept_skills
if item.owner is not None
and (item.owner.source_user_id is not None or item.owner.email is not None)
}
owner_mappings = [
item
for item in preflight.owner_mappings
if (
item.source_user_id,
item.email.strip().casefold() if item.email else None,
)
in owner_keys
]
skill_resources = [
item for item in preflight.skills if item.relative_dir in kept_skill_dirs
]
support_resources = [
item
for item in preflight.support_directories
if item.relative_dir in kept_support_dirs
]
ready = all(
item.status == "matched"
for item in [*preflight.agents, *skill_resources, *support_resources]
) and all(item.status == "matched" for item in owner_mappings)
effective_manifest = manifest.model_copy(
update={
"skills": kept_skills,
"support_directories": kept_support,
}
)
effective_preflight = preflight.model_copy(
update={
"ready": ready,
"skills": skill_resources,
"support_directories": support_resources,
"owner_mappings": owner_mappings,
}
)
return effective_manifest, effective_preflight, ordered_skipped
def _owner_reference(user_id: str | None, users_by_id: dict[str, str]) -> OwnerReference | None:
if user_id is None:
return None
return OwnerReference(source_user_id=user_id, email=users_by_id.get(user_id))
def _load_agent_config(directory: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load((directory / "config.yaml").read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise ValueError(f"Cannot read agent config: {directory}") from exc
if not isinstance(payload, dict):
raise ValueError(f"Agent config must be an object: {directory}")
return payload
def _manifest_tags(rows: list[dict[str, Any]]) -> list[TagManifestItem]:
return [
TagManifestItem(
id=str(row.get("id") or ""),
name=str(row.get("name") or ""),
color=str(row.get("color") or "gray"),
description=str(row.get("description") or ""),
)
for row in rows
if row.get("id") and row.get("name")
]
async def build_agent_migration_manifest(
agent_ids: list[str],
*,
agent_store: Any,
tag_store: Any,
agents_root: Path,
users_by_id: dict[str, str],
) -> MigrationManifest:
"""Build an agent manifest without inspecting or exporting skill payloads."""
selected_ids = list(dict.fromkeys(agent_id.strip() for agent_id in agent_ids if agent_id.strip()))
if not selected_ids:
raise ValueError("Select at least one agent")
rows_by_id = {str(row["id"]): row for row in await agent_store.list_all()}
missing_agents = [agent_id for agent_id in selected_ids if agent_id not in rows_by_id]
if missing_agents:
raise ValueError(f"Unknown agents: {', '.join(missing_agents)}")
agent_tag_map = await tag_store.list_assignments("agent", selected_ids)
agents: list[AgentManifestItem] = []
warnings: list[str] = []
for agent_id in selected_ids:
row = rows_by_id[agent_id]
directory = _resolved_child(agents_root, agent_id.lower())
if directory is None or not directory.is_dir():
raise ValueError(f"Agent directory is missing: {agent_id}")
config = _load_agent_config(directory)
if str(config.get("id") or "") != agent_id:
raise ValueError(f"Agent config id does not match directory: {agent_id}")
raw_skills = config.get("skills")
selected_skills = list(dict.fromkeys(item.strip() for item in raw_skills if isinstance(item, str) and item.strip())) if isinstance(raw_skills, list) else []
knowledge_ids = config.get("llmwiki_knowledge_base_ids")
if isinstance(knowledge_ids, list) and any(str(item).strip() for item in knowledge_ids):
warnings.append(f"智能体 {agent_id} 绑定了知识库;知识库数据不在本迁移清单范围内,请在目标机器单独确认")
agents.append(
AgentManifestItem(
id=agent_id,
relative_dir=directory.relative_to(agents_root.resolve()).as_posix(),
tree_hash=await asyncio.to_thread(compute_tree_hash, directory),
name=str(row.get("name") or config.get("name") or agent_id),
description=str(row.get("description") or ""),
owner=_owner_reference(row.get("user_id"), users_by_id),
published=bool(row.get("published")),
is_private=bool(row.get("is_private")),
square_id=str(row.get("square_id") or ""),
featured_order=row.get("featured_order"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
skills=selected_skills,
tags=_manifest_tags(agent_tag_map.get(agent_id, [])),
)
)
return MigrationManifest(
bundle_id=uuid4().hex,
agents=agents,
warnings=warnings,
)
async def build_skill_migration_manifest(
skill_names: list[str],
*,
skill_store: Any,
tag_store: Any,
skills_root: Path,
discovered_skills: list[Any],
users_by_id: dict[str, str],
usage_entries: dict[str, dict[str, Any]],
) -> MigrationManifest:
"""Build a metadata-only manifest for explicitly selected live skills."""
selected_names = list(dict.fromkeys(name.strip() for name in skill_names if name.strip()))
if not selected_names:
raise ValueError("Select at least one skill")
skill_rows = {str(row["name"]): row for row in await skill_store.list_all()}
discovered = {str(skill.name): skill for skill in discovered_skills}
missing_skills = [name for name in selected_names if name not in discovered]
if missing_skills:
raise ValueError(f"Skills are missing from the live runtime directory: {', '.join(missing_skills)}")
skill_tag_map = await tag_store.list_assignments("skill", selected_names)
skill_directories = [Path(discovered[name].skill_dir).resolve() for name in selected_names]
tree_hashes = await _compute_tree_hashes(skill_directories)
skills: list[SkillManifestItem] = []
for name, tree_hash in zip(selected_names, tree_hashes, strict=True):
skill = discovered[name]
category = str(skill.category)
relative = PurePosixPath(category) / PurePosixPath(Path(skill.relative_path).as_posix())
directory = Path(skill.skill_dir).resolve()
row = skill_rows.get(name, {})
usage = usage_entries.get(name, {})
state = str(usage.get("state") or "active")
if state not in {"active", "stale", "archived"}:
state = "active"
source = str(usage.get("source") or "upload")
if source not in {"agent", "upload"}:
source = "upload"
skills.append(
SkillManifestItem(
name=name,
category=category,
relative_dir=relative.as_posix(),
tree_hash=tree_hash,
owner=_owner_reference(row.get("owner_user_id"), users_by_id),
published=bool(row.get("published", category == "public")),
detail=row.get("detail"),
name_zh=row.get("name_zh"),
always_on=bool(row.get("always_on", getattr(skill, "always_on", False))),
square_id=str(row.get("square_id") or ""),
enabled=bool(skill.enabled),
state=state,
pinned=bool(usage.get("pinned")),
featured=bool(usage.get("featured")),
source=source,
created_at=row.get("created_at") or usage.get("created_at"),
updated_at=row.get("updated_at"),
tags=_manifest_tags(skill_tag_map.get(name, [])),
)
)
selected_set = set(selected_names)
warnings: list[str] = []
support_directories: list[SupportDirectoryManifestItem] = []
support_candidates, support_warnings = await asyncio.to_thread(
_find_support_directories,
skills_root,
selected_set,
)
warnings.extend(support_warnings)
support_hashes = await _compute_tree_hashes(
[directory for directory, _required_by in support_candidates]
)
for (directory, selected_required_by), tree_hash in zip(
support_candidates,
support_hashes,
strict=True,
):
support_directories.append(
SupportDirectoryManifestItem(
relative_dir=directory.relative_to(skills_root.resolve()).as_posix(),
tree_hash=tree_hash,
required_by=selected_required_by,
)
)
return MigrationManifest(
bundle_id=uuid4().hex,
skills=skills,
support_directories=support_directories,
warnings=warnings,
)
async def _compute_tree_hashes(directories: list[Path]) -> list[str]:
if not directories:
return []
semaphore = asyncio.Semaphore(min(_TREE_HASH_MAX_WORKERS, len(directories)))
async def _compute(directory: Path) -> str:
async with semaphore:
return await asyncio.to_thread(compute_tree_hash, directory)
return list(await asyncio.gather(*(_compute(directory) for directory in directories)))
def _find_support_directories(
skills_root: Path,
selected_names: set[str],
) -> tuple[list[tuple[Path, list[str]]], list[str]]:
candidates: list[tuple[Path, list[str]]] = []
warnings: list[str] = []
for support_manifest in sorted(skills_root.rglob(".deerflow-skill-support.json")):
try:
payload = json.loads(support_manifest.read_text(encoding="utf-8"))
required_by = [str(item).strip() for item in payload.get("required_by", []) if str(item).strip()]
except (OSError, UnicodeError, json.JSONDecodeError, AttributeError):
warnings.append(f"无法读取技能支持目录清单:{support_manifest}")
continue
selected_required_by = [name for name in required_by if name in selected_names]
if not selected_required_by:
continue
candidates.append((support_manifest.parent.resolve(), selected_required_by))
return candidates, warnings
def _apply_usage_metadata(name: str, metadata: dict[str, Any]) -> None:
_apply_usage_metadata_batch({name: metadata})
def _apply_usage_metadata_batch(entries: dict[str, dict[str, Any]]) -> None:
from deerflow.skills.usage import set_migration_metadata_batch
set_migration_metadata_batch(entries)
def _target_owner_id(
owner: OwnerReference | None,
*,
users_by_email: dict[str, str],
fallback_owner_user_id: str | None,
) -> tuple[str | None, bool]:
if owner is None or (owner.source_user_id is None and owner.email is None):
return None, False
target = users_by_email.get(owner.email.casefold()) if owner.email else None
if target:
return target, False
if fallback_owner_user_id:
return fallback_owner_user_id, True
raise ValueError(f"Unresolved owner: {owner.email or owner.source_user_id}")
async def _apply_tags(tag_store: Any, target_type: str, target_id: str, tags: list[TagManifestItem]) -> int:
return await _apply_tags_bulk(tag_store, target_type, [(target_id, tags)])
async def _apply_tags_bulk(
tag_store: Any,
target_type: str,
targets: list[tuple[str, list[TagManifestItem]]],
) -> int:
if not targets:
return 0
existing = await tag_store.list_tags()
by_name = {
(str(row.get("tag_type")), str(row.get("name"))): row
for row in existing
}
used_ids = {str(row.get("id")) for row in existing}
assignments: dict[str, list[str]] = {}
created = 0
for target_id, tags in targets:
tag_ids: list[str] = []
for tag in tags:
same_name = by_name.get((target_type, tag.name))
if same_name is not None:
tag_ids.append(str(same_name["id"]))
continue
tag_id = tag.id if tag.id not in used_ids else uuid4().hex
created_tag = await tag_store.create_tag(
{
"id": tag_id,
"name": tag.name,
"tag_type": target_type,
"color": tag.color,
"description": tag.description,
}
)
by_name[(target_type, tag.name)] = created_tag
used_ids.add(tag_id)
tag_ids.append(tag_id)
created += 1
assignments[target_id] = tag_ids
await tag_store.set_target_tags_bulk(target_type, assignments)
return created
async def apply_migration_manifest(
manifest: MigrationManifest,
*,
preflight: MigrationPreflight,
agent_store: Any,
skill_store: Any,
tag_store: Any,
agents_root: Path,
users_by_email: dict[str, str],
fallback_owner_user_id: str | None,
extensions_config_path: Path | None,
skip_invalid_skills: bool = False,
) -> MigrationApplyResult:
"""Idempotently reconcile metadata after all live folders passed preflight."""
manifest, preflight, skipped_skill_names = prepare_manifest_for_apply(
manifest,
preflight,
skip_invalid_skills=skip_invalid_skills,
)
bad_resources = [item for item in [*preflight.agents, *preflight.skills, *preflight.support_directories] if item.status != "matched"]
if bad_resources:
raise ValueError("Migration preflight contains missing or mismatched directories")
if any(item.status == "unresolved" for item in preflight.owner_mappings) and not fallback_owner_user_id:
raise ValueError("Migration preflight contains unresolved owners")
if manifest.skills:
if extensions_config_path is None:
raise ValueError("目标运行环境未找到 extensions_config.json")
from deerflow.config.extensions_config import patch_extensions_config
enabled_by_name = {item.name: item.enabled for item in manifest.skills}
def _patch(document: dict[str, Any]) -> None:
skills_document = document.setdefault("skills", {})
if not isinstance(skills_document, dict):
raise ValueError("extensions_config.json skills must be an object")
for name, enabled in enabled_by_name.items():
current = skills_document.get(name)
entry = dict(current) if isinstance(current, dict) else {}
entry["enabled"] = enabled
skills_document[name] = entry
patch_extensions_config(extensions_config_path, _patch)
normalized_users = {email.strip().casefold(): user_id for email, user_id in users_by_email.items()}
fallback_count = 0
tags_created = 0
skill_payloads: list[dict[str, Any]] = []
skill_usage_entries: dict[str, dict[str, Any]] = {}
for item in manifest.skills:
owner_user_id, used_fallback = _target_owner_id(
item.owner,
users_by_email=normalized_users,
fallback_owner_user_id=fallback_owner_user_id,
)
fallback_count += int(used_fallback)
skill_payloads.append(
{
"name": item.name,
"owner_user_id": owner_user_id,
"published": item.published,
"detail": item.detail,
"name_zh": item.name_zh,
"always_on": item.always_on,
"square_id": item.square_id,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
)
skill_usage_entries[item.name] = {
"state": item.state,
"pinned": item.pinned,
"featured": item.featured,
"source": item.source,
}
if skill_payloads:
await skill_store.migration_upsert_many(skill_payloads)
tags_created += await _apply_tags_bulk(
tag_store,
"skill",
[(item.name, item.tags) for item in manifest.skills],
)
_apply_usage_metadata_batch(skill_usage_entries)
agent_tag_targets: list[tuple[str, list[TagManifestItem]]] = []
for item in manifest.agents:
owner_user_id, used_fallback = _target_owner_id(
item.owner,
users_by_email=normalized_users,
fallback_owner_user_id=fallback_owner_user_id,
)
fallback_count += int(used_fallback)
await agent_store.migration_upsert(
{
"id": item.id,
"name": item.name,
"description": item.description,
"user_id": owner_user_id,
"published": item.published,
"is_private": item.is_private,
"square_id": item.square_id,
"featured_order": item.featured_order,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
)
config = _load_agent_config(agents_root / item.relative_dir)
await agent_store.replace_skills(item.id, config.get("skills") if isinstance(config.get("skills"), list) else [])
await agent_store.set_extras(
item.id,
tool_groups=config.get("tool_groups") if isinstance(config.get("tool_groups"), list) else None,
model=str(config.get("model")) if config.get("model") else None,
)
agent_tag_targets.append((item.id, item.tags))
tags_created += await _apply_tags_bulk(tag_store, "agent", agent_tag_targets)
return MigrationApplyResult(
bundle_id=manifest.bundle_id,
agents_applied=len(manifest.agents),
skills_applied=len(manifest.skills),
skills_skipped=len(skipped_skill_names),
skipped_skill_names=skipped_skill_names,
tags_created=tags_created,
fallback_owner_count=fallback_count,
)
@@ -37,6 +37,7 @@ from app.gateway.routers import (
admin_active_runs,
admin_audit_retention as admin_audit_retention_routes,
admin_users,
agent_skill_migrations,
agents,
ai_writing,
app_identity,
@@ -48,6 +49,7 @@ from app.gateway.routers import (
browser_context,
business_mapping,
channels,
chat_history_transfer,
chat_archive,
checkpoint_migration,
compact_menu_overrides,
@@ -1204,6 +1206,30 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.exception(error_msg)
raise RuntimeError(error_msg) from e
# Populate product-owned public skills before any ownership migration,
# MySQL catalogue projection, prompt cache, or sandbox view observes the
# configured live root. This application-level guard is intentionally in
# addition to the supported shell launchers: image entrypoints, Compose,
# and external release wrappers must receive the same built-ins.
try:
from app.builtin_skill_sync import ensure_bundled_public_skills
sync_result = await asyncio.to_thread(
ensure_bundled_public_skills,
app.state.config,
)
logger.info(
"Bundled public skills synchronized: source=%s target=%s copied=%d required=%d",
sync_result["source"],
sync_result["target"],
len(sync_result["copiedDirectories"]),
len(sync_result["requiredSkills"]),
)
except Exception as e:
error_msg = f"Failed to synchronize required built-in skills during gateway startup: {e}"
logger.exception(error_msg)
raise RuntimeError(error_msg) from e
config = get_gateway_config()
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
@@ -1276,6 +1302,20 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Skill MySQL catalog projection sync failed (non-fatal)")
# Project the compact per-skill catalog metadata into MySQL before the
# Gateway accepts requests. List APIs read indexed scalar columns and
# never load skill markdown, display samples, or usage sidecars.
try:
from app.gateway.skill_catalog_cache import refresh_skill_catalog_projection
catalog_size = await refresh_skill_catalog_projection(
app.state.config,
skills=catalog_skills,
)
logger.info("Skill MySQL catalog projection synchronized: skills=%d", catalog_size)
except Exception:
logger.exception("Skill MySQL catalog projection sync failed (non-fatal)")
# ????? agent(intent / recommender / coordinator)???????
# ??? _sync_legacy_agents **??**??????????????????
# ??? upsert ? agents ?,?????? /api/agents ?????,
@@ -1462,6 +1502,32 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.exception(
"Elasticsearch offline operations agent seeding failed at startup (non-fatal)"
)
# 内置「聊天记录迁移助手」:高斯/SQLite 会话索引、检查点和线程文件分开迁移。
# 目录已存在时不覆盖管理员修改。
try:
from app.gateway.routers._chat_history_migration_seed import (
ensure_chat_history_migration_agent,
)
ensure_chat_history_migration_agent()
except Exception:
logger.exception(
"Chat-history migration agent seeding failed at startup (non-fatal)"
)
# 内置「高斯数据适配助手」:根据报错说明要改的配置。目录已存在时不覆盖。
try:
from app.gateway.routers._gauss_data_adaptation_seed import (
ensure_gauss_data_adaptation_agent,
)
ensure_gauss_data_adaptation_agent()
except Exception:
logger.exception(
"Gauss data-adaptation agent seeding failed at startup (non-fatal)"
)
# 内置「任务研判报告助手」(agentfx 任务深链固定使用):同样必须在
# _sync_legacy_agents 之前补建目录 + 补建配套 task-report-import 技能
# (skills/ 被 .gitignore 排除),由后者 upsert 进 agents 表。
@@ -1587,6 +1653,13 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Admin audit retention scheduler failed to start (non-fatal)")
try:
app.state.admin_audit_retention_task = asyncio.create_task(
_admin_audit_retention_loop(app)
)
except Exception:
logger.exception("Admin audit retention scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
@@ -1765,6 +1838,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await close_api_read_cache()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
from app.gateway.worker_pool.lifecycle import managed_controller
from app.gateway.perf_logging import event_loop_watchdog, register_stack_dump_signal
register_stack_dump_signal()
watchdog = asyncio.create_task(event_loop_watchdog(app), name="gateway-event-loop-watchdog")
try:
async with managed_controller(app):
async with _gateway_lifespan(app):
yield
finally:
watchdog.cancel()
await asyncio.gather(watchdog, return_exceptions=True)
from app.gateway.api_read_cache import close_api_read_cache
await close_api_read_cache()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
@@ -2097,11 +2189,14 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(analysis_projects.router)
app.include_router(threads.router)
app.include_router(chat_history_transfer.router)
# Dedicated taskId + goPath history lookup for single-agent task deep-link chats.
app.include_router(task_deeplink_threads.router)
app.include_router(task_deeplink_details.router)
# Agents API is mounted at /api/agents
app.include_router(agents.router)
# Metadata-only migration for agent/skill folders copied into live roots.
app.include_router(agent_skill_migrations.router)
app.include_router(strategy_agents.router)
# Recommended Questions API is mounted at /api/recommended-questions
@@ -1,401 +1,410 @@
"""Authorization decorators and context for DeerFlow.
Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blob/main/libs/sdk-py/langgraph_sdk/auth/__init__.py
**Usage:**
1. Use ``@require_auth`` on routes that need authentication
2. Use ``@require_permission("resource", "action", filter_key=...)`` for permission checks
3. The decorator chain processes from bottom to top
**Example:**
@router.get("/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: str, request: Request):
# User is authenticated and has threads:read permission
...
**Permission Model:**
- threads:read - View thread
- threads:write - Create/update thread
- threads:delete - Delete thread
- runs:create - Run agent
- runs:read - View run
- runs:cancel - Cancel run
"""
from __future__ import annotations
import asyncio
import functools
import inspect
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
if TYPE_CHECKING:
from app.gateway.auth.models import User
logger = logging.getLogger(__name__)
P = ParamSpec("P")
T = TypeVar("T")
async def _project_for_thread(project_store: Any, thread_id: str) -> dict | None:
"""Best-effort project binding lookup; a store error must not fail the read."""
try:
return await project_store.project_for_thread(thread_id)
except Exception:
logger.debug("project_for_thread failed for %s", thread_id, exc_info=True)
return None
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
A checkpoint with no matching ``threads_meta`` row is the signature of a
DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state
survives in the checkpointer while the business row is gone. Normal thread
deletion removes the checkpoint too, so this can never resurrect a
deliberately-deleted thread.
"""
try:
from app.gateway.deps import get_checkpointer
checkpointer = get_checkpointer(request)
tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
return tup is not None
except Exception:
return False
# Permission constants
class Permissions:
"""Permission constants for resource:action format."""
# Threads
THREADS_READ = "threads:read"
THREADS_WRITE = "threads:write"
THREADS_DELETE = "threads:delete"
# Runs
RUNS_CREATE = "runs:create"
RUNS_READ = "runs:read"
RUNS_CANCEL = "runs:cancel"
class AuthContext:
"""Authentication context for the current request.
Stored in request.state.auth after require_auth decoration.
Attributes:
user: The authenticated user, or None if anonymous
permissions: List of permission strings (e.g., "threads:read")
"""
__slots__ = ("user", "permissions")
def __init__(self, user: User | None = None, permissions: list[str] | None = None):
self.user = user
self.permissions = permissions or []
@property
def is_authenticated(self) -> bool:
"""Check if user is authenticated."""
return self.user is not None
def has_permission(self, resource: str, action: str) -> bool:
"""Check if context has permission for resource:action.
Args:
resource: Resource name (e.g., "threads")
action: Action name (e.g., "read")
Returns:
True if user has permission
"""
permission = f"{resource}:{action}"
return permission in self.permissions
def require_user(self) -> User:
"""Get user or raise 401.
Raises:
HTTPException 401 if not authenticated
"""
if not self.user:
raise HTTPException(status_code=401, detail="Authentication required")
return self.user
def get_auth_context(request: Request) -> AuthContext | None:
"""Get AuthContext from request state."""
return getattr(request.state, "auth", None)
_ALL_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
]
def _make_test_request_stub() -> Any:
"""Create a minimal request-like object for direct unit calls.
Used when decorated route handlers are invoked without FastAPI's
request injection. Includes fields accessed by auth helpers.
"""
return SimpleNamespace(state=SimpleNamespace(), cookies={}, _deerflow_test_bypass_auth=True)
async def _authenticate(request: Request) -> AuthContext:
"""Authenticate request and return AuthContext.
Delegates to deps.get_optional_user_from_request() for the JWT→User pipeline.
Returns AuthContext with user=None for anonymous requests.
"""
if is_auth_disabled():
user = await get_assumed_user_for_disabled_auth()
return AuthContext(user=user, permissions=_ALL_PERMISSIONS)
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return AuthContext(user=None, permissions=[])
# In future, permissions could be stored in user record
return AuthContext(user=user, permissions=_ALL_PERMISSIONS)
def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]:
"""Decorator that authenticates the request and enforces authentication.
Independently raises HTTP 401 for unauthenticated requests, regardless of
whether ``AuthMiddleware`` is present in the ASGI stack. Sets the resolved
``AuthContext`` on ``request.state.auth`` for downstream handlers.
Must be placed ABOVE other decorators (executes after them).
Usage:
@router.get("/{thread_id}")
@require_auth # Bottom decorator (executes first after permission check)
@require_permission("threads", "read")
async def get_thread(thread_id: str, request: Request):
auth: AuthContext = request.state.auth
...
Raises:
HTTPException: 401 if the request is unauthenticated.
ValueError: If 'request' parameter is missing.
"""
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
request = kwargs.get("request")
if request is None:
# Unit tests may call decorated handlers directly without a
# FastAPI Request object. Inject a minimal request stub when
# the wrapped function declares `request`.
if "request" in inspect.signature(func).parameters:
kwargs["request"] = _make_test_request_stub()
else:
raise ValueError("require_auth decorator requires 'request' parameter")
request = kwargs["request"]
if getattr(request, "_deerflow_test_bypass_auth", False):
return await func(*args, **kwargs)
# Authenticate and set context
auth_context = await _authenticate(request)
request.state.auth = auth_context
if not auth_context.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
return await func(*args, **kwargs)
return wrapper
def require_permission(
resource: str,
action: str,
owner_check: bool = False,
require_existing: bool = False,
claim_orphan: bool = False,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator that checks permission for resource:action.
Must be used AFTER @require_auth.
Args:
resource: Resource name (e.g., "threads", "runs")
action: Action name (e.g., "read", "write", "delete")
owner_check: If True, validates that the current user owns the resource.
Requires 'thread_id' path parameter and performs ownership check.
require_existing: Only meaningful with ``owner_check=True``. If True, a
missing ``threads_meta`` row counts as a denial (404)
instead of "untracked legacy thread, allow". Use on
**destructive / mutating** routes (DELETE, PATCH,
state-update) so a deleted thread can't be re-targeted
by another user via the missing-row code path.
claim_orphan: Only meaningful with ``owner_check=True`` and
``require_existing=True``. When the ``threads_meta``
row is missing **and** a LangGraph checkpoint exists
for the thread (a DB-switch orphan), adopt it for the
current user instead of 404ing. Use on **run-create**
routes so a conversation whose business row was lost
(sqlite↔mysql swap) stays answerable. Safe: normal
deletion removes the checkpoint, so this never revives
a deliberately-deleted thread.
Usage:
# Read-style: legacy untracked threads are allowed
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: str, request: Request):
...
# Destructive: thread row MUST exist and be owned by caller
@require_permission("threads", "delete", owner_check=True, require_existing=True)
async def delete_thread(thread_id: str, request: Request):
...
Raises:
HTTPException 401: If authentication required but user is anonymous
HTTPException 403: If user lacks permission
HTTPException 404: If owner_check=True but user doesn't own the thread
ValueError: If owner_check=True but no ``thread_id`` is supplied,
either directly or on the Pydantic request body
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
request = kwargs.get("request")
if request is None:
# Unit tests may call decorated route handlers directly without
# constructing a FastAPI Request object. Inject a minimal stub
# when the wrapped function declares `request`.
if "request" in inspect.signature(func).parameters:
kwargs["request"] = _make_test_request_stub()
else:
return await func(*args, **kwargs)
request = kwargs["request"]
if getattr(request, "_deerflow_test_bypass_auth", False):
return await func(*args, **kwargs)
auth: AuthContext = getattr(request.state, "auth", None)
if auth is None:
auth = await _authenticate(request)
request.state.auth = auth
if not auth.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
# Check permission
if not auth.has_permission(resource, action):
raise HTTPException(
status_code=403,
detail=f"Permission denied: {resource}:{action}",
)
# Owner check for thread-specific resources.
#
# 2.0-rc moved thread metadata into the SQL persistence layer
# (``threads_meta`` table). We verify ownership via
# ``ThreadMetaStore.check_access``: it returns True for
# missing rows (untracked legacy thread) and for rows whose
# ``user_id`` is NULL (shared / pre-auth data), so this is
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
if owner_check:
thread_id = kwargs.get("thread_id")
# Most thread-scoped routes expose ``thread_id`` in their URL.
# A small number of mutation endpoints (for example complete
# document rewrite) intentionally keep the target inside a
# Pydantic request body so their public URL stays stable. The
# FastAPI wrapper passes that body as ``body=...``; accept its
# Python field name here while preserving the exact same
# ownership verification below.
if thread_id is None:
body = kwargs.get("body")
thread_id = getattr(body, "thread_id", None)
if thread_id is None:
raise ValueError(
"require_permission with owner_check=True requires "
"a 'thread_id' parameter or body.thread_id"
)
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None)
allowed_coro = thread_store.check_access(
thread_id,
user_id,
require_existing=require_existing,
)
if project_store is not None:
project, allowed = await asyncio.gather(
_project_for_thread(project_store, thread_id),
allowed_coro,
)
if project and project["user_id"] != user_id:
raise HTTPException(status_code=404, detail="Project thread not accessible")
else:
allowed = await allowed_coro
if not allowed:
# Tell apart three denial causes so the client gets an
# accurate message and so DB-switch orphans can be adopted
# rather than hard-404'd:
# 1. row exists but owned by another user -> not accessible
# 2. no row, no checkpoint -> truly missing
# 3. no row + checkpoint + claim_orphan -> orphan, claim
existing = await thread_store.get(thread_id, user_id=None)
if existing is not None:
# Distinct detail string lets the frontend show "无权访问"
# instead of "不存在" for a cross-user / stale-session hit.
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not accessible",
)
claimed = False
if claim_orphan and await _thread_checkpoint_exists(request, thread_id):
try:
await thread_store.create(thread_id, user_id=user_id)
logger.info(
"Claimed orphan thread %s for user %s (checkpoint present, no meta row)",
thread_id,
user_id,
)
claimed = True
except Exception:
logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True)
if not claimed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
return await func(*args, **kwargs)
return wrapper
return decorator
"""Authorization decorators and context for DeerFlow.
Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blob/main/libs/sdk-py/langgraph_sdk/auth/__init__.py
**Usage:**
1. Use ``@require_auth`` on routes that need authentication
2. Use ``@require_permission("resource", "action", filter_key=...)`` for permission checks
3. The decorator chain processes from bottom to top
**Example:**
@router.get("/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: str, request: Request):
# User is authenticated and has threads:read permission
...
**Permission Model:**
- threads:read - View thread
- threads:write - Create/update thread
- threads:delete - Delete thread
- runs:create - Run agent
- runs:read - View run
- runs:cancel - Cancel run
"""
from __future__ import annotations
import asyncio
import functools
import inspect
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
if TYPE_CHECKING:
from app.gateway.auth.models import User
logger = logging.getLogger(__name__)
P = ParamSpec("P")
T = TypeVar("T")
async def _project_for_thread(project_store: Any, thread_id: str) -> dict | None:
"""Best-effort project binding lookup; a store error must not fail the read."""
try:
return await project_store.project_for_thread(thread_id)
except Exception:
logger.debug("project_for_thread failed for %s", thread_id, exc_info=True)
return None
async def _project_for_thread(project_store: Any, thread_id: str) -> dict | None:
"""Best-effort project binding lookup; a store error must not fail the read."""
try:
return await project_store.project_for_thread(thread_id)
except Exception:
logger.debug("project_for_thread failed for %s", thread_id, exc_info=True)
return None
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
A checkpoint with no matching ``threads_meta`` row is the signature of a
DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state
survives in the checkpointer while the business row is gone. Normal thread
deletion removes the checkpoint too, so this can never resurrect a
deliberately-deleted thread.
"""
try:
from app.gateway.deps import get_checkpointer
checkpointer = get_checkpointer(request)
tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
return tup is not None
except Exception:
return False
# Permission constants
class Permissions:
"""Permission constants for resource:action format."""
# Threads
THREADS_READ = "threads:read"
THREADS_WRITE = "threads:write"
THREADS_DELETE = "threads:delete"
# Runs
RUNS_CREATE = "runs:create"
RUNS_READ = "runs:read"
RUNS_CANCEL = "runs:cancel"
class AuthContext:
"""Authentication context for the current request.
Stored in request.state.auth after require_auth decoration.
Attributes:
user: The authenticated user, or None if anonymous
permissions: List of permission strings (e.g., "threads:read")
"""
__slots__ = ("user", "permissions")
def __init__(self, user: User | None = None, permissions: list[str] | None = None):
self.user = user
self.permissions = permissions or []
@property
def is_authenticated(self) -> bool:
"""Check if user is authenticated."""
return self.user is not None
def has_permission(self, resource: str, action: str) -> bool:
"""Check if context has permission for resource:action.
Args:
resource: Resource name (e.g., "threads")
action: Action name (e.g., "read")
Returns:
True if user has permission
"""
permission = f"{resource}:{action}"
return permission in self.permissions
def require_user(self) -> User:
"""Get user or raise 401.
Raises:
HTTPException 401 if not authenticated
"""
if not self.user:
raise HTTPException(status_code=401, detail="Authentication required")
return self.user
def get_auth_context(request: Request) -> AuthContext | None:
"""Get AuthContext from request state."""
return getattr(request.state, "auth", None)
_ALL_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
]
def _make_test_request_stub() -> Any:
"""Create a minimal request-like object for direct unit calls.
Used when decorated route handlers are invoked without FastAPI's
request injection. Includes fields accessed by auth helpers.
"""
return SimpleNamespace(state=SimpleNamespace(), cookies={}, _deerflow_test_bypass_auth=True)
async def _authenticate(request: Request) -> AuthContext:
"""Authenticate request and return AuthContext.
Delegates to deps.get_optional_user_from_request() for the JWT→User pipeline.
Returns AuthContext with user=None for anonymous requests.
"""
if is_auth_disabled():
user = await get_assumed_user_for_disabled_auth()
return AuthContext(user=user, permissions=_ALL_PERMISSIONS)
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return AuthContext(user=None, permissions=[])
# In future, permissions could be stored in user record
return AuthContext(user=user, permissions=_ALL_PERMISSIONS)
def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]:
"""Decorator that authenticates the request and enforces authentication.
Independently raises HTTP 401 for unauthenticated requests, regardless of
whether ``AuthMiddleware`` is present in the ASGI stack. Sets the resolved
``AuthContext`` on ``request.state.auth`` for downstream handlers.
Must be placed ABOVE other decorators (executes after them).
Usage:
@router.get("/{thread_id}")
@require_auth # Bottom decorator (executes first after permission check)
@require_permission("threads", "read")
async def get_thread(thread_id: str, request: Request):
auth: AuthContext = request.state.auth
...
Raises:
HTTPException: 401 if the request is unauthenticated.
ValueError: If 'request' parameter is missing.
"""
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
request = kwargs.get("request")
if request is None:
# Unit tests may call decorated handlers directly without a
# FastAPI Request object. Inject a minimal request stub when
# the wrapped function declares `request`.
if "request" in inspect.signature(func).parameters:
kwargs["request"] = _make_test_request_stub()
else:
raise ValueError("require_auth decorator requires 'request' parameter")
request = kwargs["request"]
if getattr(request, "_deerflow_test_bypass_auth", False):
return await func(*args, **kwargs)
# Authenticate and set context
auth_context = await _authenticate(request)
request.state.auth = auth_context
if not auth_context.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
return await func(*args, **kwargs)
return wrapper
def require_permission(
resource: str,
action: str,
owner_check: bool = False,
require_existing: bool = False,
claim_orphan: bool = False,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator that checks permission for resource:action.
Must be used AFTER @require_auth.
Args:
resource: Resource name (e.g., "threads", "runs")
action: Action name (e.g., "read", "write", "delete")
owner_check: If True, validates that the current user owns the resource.
Requires 'thread_id' path parameter and performs ownership check.
require_existing: Only meaningful with ``owner_check=True``. If True, a
missing ``threads_meta`` row counts as a denial (404)
instead of "untracked legacy thread, allow". Use on
**destructive / mutating** routes (DELETE, PATCH,
state-update) so a deleted thread can't be re-targeted
by another user via the missing-row code path.
claim_orphan: Only meaningful with ``owner_check=True`` and
``require_existing=True``. When the ``threads_meta``
row is missing **and** a LangGraph checkpoint exists
for the thread (a DB-switch orphan), adopt it for the
current user instead of 404ing. Use on **run-create**
routes so a conversation whose business row was lost
(sqlite↔mysql swap) stays answerable. Safe: normal
deletion removes the checkpoint, so this never revives
a deliberately-deleted thread.
Usage:
# Read-style: legacy untracked threads are allowed
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: str, request: Request):
...
# Destructive: thread row MUST exist and be owned by caller
@require_permission("threads", "delete", owner_check=True, require_existing=True)
async def delete_thread(thread_id: str, request: Request):
...
Raises:
HTTPException 401: If authentication required but user is anonymous
HTTPException 403: If user lacks permission
HTTPException 404: If owner_check=True but user doesn't own the thread
ValueError: If owner_check=True but no ``thread_id`` is supplied,
either directly or on the Pydantic request body
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
request = kwargs.get("request")
if request is None:
# Unit tests may call decorated route handlers directly without
# constructing a FastAPI Request object. Inject a minimal stub
# when the wrapped function declares `request`.
if "request" in inspect.signature(func).parameters:
kwargs["request"] = _make_test_request_stub()
else:
return await func(*args, **kwargs)
request = kwargs["request"]
if getattr(request, "_deerflow_test_bypass_auth", False):
return await func(*args, **kwargs)
auth: AuthContext = getattr(request.state, "auth", None)
if auth is None:
auth = await _authenticate(request)
request.state.auth = auth
if not auth.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
# Check permission
if not auth.has_permission(resource, action):
raise HTTPException(
status_code=403,
detail=f"Permission denied: {resource}:{action}",
)
# Owner check for thread-specific resources.
#
# 2.0-rc moved thread metadata into the SQL persistence layer
# (``threads_meta`` table). We verify ownership via
# ``ThreadMetaStore.check_access``: it returns True for
# missing rows (untracked legacy thread) and for rows whose
# ``user_id`` is NULL (shared / pre-auth data), so this is
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
if owner_check:
thread_id = kwargs.get("thread_id")
# Most thread-scoped routes expose ``thread_id`` in their URL.
# A small number of mutation endpoints (for example complete
# document rewrite) intentionally keep the target inside a
# Pydantic request body so their public URL stays stable. The
# FastAPI wrapper passes that body as ``body=...``; accept its
# Python field name here while preserving the exact same
# ownership verification below.
if thread_id is None:
body = kwargs.get("body")
thread_id = getattr(body, "thread_id", None)
if thread_id is None:
raise ValueError(
"require_permission with owner_check=True requires "
"a 'thread_id' parameter or body.thread_id"
)
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None)
allowed_coro = thread_store.check_access(
thread_id,
user_id,
require_existing=require_existing,
)
if project_store is not None:
project, allowed = await asyncio.gather(
_project_for_thread(project_store, thread_id),
allowed_coro,
)
if project and project["user_id"] != user_id:
raise HTTPException(status_code=404, detail="Project thread not accessible")
else:
allowed = await allowed_coro
if not allowed:
# Tell apart three denial causes so the client gets an
# accurate message and so DB-switch orphans can be adopted
# rather than hard-404'd:
# 1. row exists but owned by another user -> not accessible
# 2. no row, no checkpoint -> truly missing
# 3. no row + checkpoint + claim_orphan -> orphan, claim
existing = await thread_store.get(thread_id, user_id=None)
if existing is not None:
# Distinct detail string lets the frontend show "无权访问"
# instead of "不存在" for a cross-user / stale-session hit.
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not accessible",
)
claimed = False
if claim_orphan and await _thread_checkpoint_exists(request, thread_id):
try:
await thread_store.create(thread_id, user_id=user_id)
logger.info(
"Claimed orphan thread %s for user %s (checkpoint present, no meta row)",
thread_id,
user_id,
)
claimed = True
except Exception:
logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True)
if not claimed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
return await func(*args, **kwargs)
return wrapper
return decorator
@@ -204,6 +204,35 @@ class RoundtableJobDispatcher:
)
return expired
async def _expire_overdue_step2_rooms(self, now: datetime) -> int:
"""Persist hard-timeout cancellation even when no browser is connected."""
max_minutes = getattr(
get_roundtable_room_limit_settings(),
"max_hold_minutes",
30,
)
request_cancel_expired = getattr(
self._store,
"request_cancel_expired_step2",
None,
)
if request_cancel_expired is None:
return 0
try:
expired = await request_cancel_expired(
created_before=now - timedelta(minutes=max_minutes),
non_occupying_phases=tuple(NON_OCCUPYING_JOB_PHASES),
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] hard-timeout scan failed", exc_info=True)
return 0
if expired:
logger.info(
"[roundtable-jobs] requested cancellation for %d overdue Step-2 room(s)",
expired,
)
return expired
async def _reap_cancel_requested(self, now: datetime) -> int:
"""收口持租 worker 崩溃后遗留的 ``cancel_requested`` 作业。
@@ -69,6 +69,26 @@ _DRAFT_WRITE_RETRIES = 3
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True
def _background_step2_room_expired(row: dict[str, Any] | None) -> bool:
"""Whether a background job exceeded the configurable Step-2 hard limit."""
if not row or str(row.get("phase") or "") in NON_OCCUPYING_JOB_PHASES:
return False
raw_created = row.get("created_at")
if isinstance(raw_created, datetime):
created_at = raw_created
elif isinstance(raw_created, str) and raw_created:
try:
created_at = datetime.fromisoformat(raw_created)
except ValueError:
return False
else:
return False
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
max_minutes = getattr(get_roundtable_room_limit_settings(), "max_hold_minutes", 30)
return datetime.now(UTC) >= created_at + timedelta(minutes=max_minutes)
def _background_step2_room_expired(row: dict[str, Any] | None) -> bool:
"""Whether a background job exceeded the configurable Step-2 hard limit."""
if not row or str(row.get("phase") or "") in NON_OCCUPYING_JOB_PHASES:
@@ -54,6 +54,8 @@ _AGENT_PURPOSE: dict[str, str] = {
# Workflow Studio(候选流程总控,和圆桌总控隔离)
"workflow-planner": "工作流编排",
"task-analysis-coordinator": "任务分析定时",
"chat-history-migrator": "其他内置功能",
"gauss-data-adapter": "其他内置功能",
}
_TARGET_TYPE = "agent"
@@ -0,0 +1,81 @@
"""Seed the built-in chat-history migration assistant.
The agent and its public skill are copied only when their target directories are
missing. Existing deployments are never overwritten, so administrator edits made
through agent/skill management survive restarts. This seeder must run before
``_sync_legacy_agents``.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
CHAT_HISTORY_MIGRATION_AGENT_ID = "chat-history-migrator"
CHAT_HISTORY_MIGRATION_SKILL_NAME = "chat-history-migration"
_ASSETS_DIR = Path(__file__).parent / "_chat_history_migration_seed_assets"
_AGENT_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _skills_public_dir() -> Path:
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
def _ensure_skill() -> bool:
try:
source = _ASSETS_DIR / "skills" / CHAT_HISTORY_MIGRATION_SKILL_NAME
target = _skills_public_dir() / CHAT_HISTORY_MIGRATION_SKILL_NAME
if target.exists():
return False
if not (source / "SKILL.md").is_file():
logger.error("Missing chat-history migration skill seed at %s", source)
return False
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, target)
logger.info("Seeded public skill '%s'", CHAT_HISTORY_MIGRATION_SKILL_NAME)
return True
except Exception:
logger.exception("Failed to seed chat-history migration skill")
return False
def _ensure_agent() -> bool:
try:
target = get_paths().agent_dir(CHAT_HISTORY_MIGRATION_AGENT_ID)
if target.exists():
return False
source = _ASSETS_DIR / CHAT_HISTORY_MIGRATION_AGENT_ID
if not source.is_dir():
logger.error("Missing chat-history migration agent seed at %s", source)
return False
target.mkdir(parents=True, exist_ok=True)
for filename in _AGENT_FILES:
asset = source / filename
if asset.is_file():
(target / filename).write_bytes(asset.read_bytes())
logger.info("Seeded built-in agent '%s'", CHAT_HISTORY_MIGRATION_AGENT_ID)
return True
except Exception:
logger.exception("Failed to seed chat-history migration agent")
return False
def ensure_chat_history_migration_agent() -> bool:
"""Install the chat-history migration skill and agent when missing."""
_ensure_skill()
return _ensure_agent()
__all__ = [
"CHAT_HISTORY_MIGRATION_AGENT_ID",
"CHAT_HISTORY_MIGRATION_SKILL_NAME",
"ensure_chat_history_migration_agent",
]
@@ -0,0 +1,51 @@
# 角色
你是 **聊天记录迁移助手**。你帮助管理员把一台 DeerFlow 服务器上的普通聊天记录迁到另一台服务器。源站的业务库可以是 openGauss/高斯,目标站可以是高斯、SQLite 或 MySQL。
你面对的操作者可能不熟悉数据库。先用一句通俗的话解释术语,再给可复制的命令。命令里的主机、端口、库名、路径一律用对方提供的值;没有提供时用占位符,禁止猜。
# 聊天记录实际放在哪
一次可见的聊天不是高斯库里的一张表。它有三层,缺任何一层都会表现为「列表空」或「有标题但点进去没有内容」:
1. **会话索引**:`threads_meta` 决定聊天列表里的标题、所属用户和时间。`runs`、`run_events`、`feedback` 是运行记录和评价。项目里的会话还依赖 `analysis_project_threads`。这一层可以在高斯库里。
2. **消息正文**:LangGraph 检查点。推荐放在 SQLite 文件,也可以是真正的 PostgreSQL。这里才是问答内容。
3. **线程文件**:`.deer-flow/users/<user_id>/threads/<thread_id>/user-data/` 下面的上传文件和生成结果。
不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上。高斯只承接业务索引。检查点文件原样进入目标的 SQLite 检查点库。
# 必须遵守的边界
1. **智能体沙箱不是源服务器,也不是目标服务器。** 除非用户明确说明当前环境就是那台机器,否则不要根据沙箱里的主机名、localhost 或某个 `.db` 文件推断现场。需要现场执行的命令要写明在哪一台机器、哪个目录运行。
2. **不要接收密码。** 不要让用户把高斯密码、token、私钥贴进对话。`gsql` / `mysql` 使用 `~/.pgpass` 或客户端自己的密码提示。脚本不会连接高斯,也没有密码参数。
3. **先盘点,再导出,再演练,最后才写入。** 导入命令默认是演练。用户明确说过目标库已有可恢复备份之前,不要在命令里加 `--apply`。
4. **不导出账号密码。** `users` 表和密码列不进入迁移包。目标站必须已经有对应账号。`user_id` 不同时只用 `--user-map 旧ID=新ID` 改归属,不改 `thread_id`。
5. **默认跳过已有会话。** 只有用户明确要求覆盖时才使用 `--on-conflict replace`。替换只影响迁移包里的那些 `thread_id`,不会清空目标库其他会话。已有项目的名称和配置保持目标站原样。
6. **系统线程默认不迁。** 调度器和内部工作线程会让列表变脏。用户点名要包含时才加 `--include-system`。
7. **源库只读。** 不在源库执行 INSERT、UPDATE、DELETE、DROP。导出 SQL 只有查询和 `\copy TO`。
# 工作方式
收到请求后先归入一种模式:
- **还不清楚数据在哪**:读取技能里的 `references/storage-map.md`,根据用户贴出的 `database.backend` 和 `checkpointer` 判断三层位置。一次只追问会改变命令的几项:业务库类型、检查点文件路径、`.deer-flow` 目录、要迁的用户或时间范围、目标库类型。
- **准备搬迁**:读取 `references/runbook.md`。按「停写或接受截止点 → 只读导出 → 打包 → 传到目标 → 演练 → 备份确认 → `--apply` → 验收」推进。每一步都写成功信号和失败时停在哪里。
- **迁完对不上**:先区分「列表没有这条会话」「有标题但正文是空的」「附件不见了」「进了别的账号」。分别对应索引、检查点、线程文件和 `user_id`。不要一次改三层。
脚本在技能目录 `scripts/migrate_chat_history.py`,只用 Python 标准库。用户要在源服务器上跑时,把脚本复制到输出目录并交付:
```bash
cp /mnt/skills/public/chat-history-migration/scripts/migrate_chat_history.py /mnt/user-data/outputs/migrate_chat_history.py
```
沙箱里没有 `bash` 时,用 `read_file` 读取脚本,再用 `write_file` 原样写到该路径。用户已经把 SQLite 或 JSONL 传到当前会话时,可以对 `/mnt/user-data/uploads/` 里的文件运行脚本,把迁移包写到 `/mnt/user-data/outputs/`。
# 交付格式
每次给操作步骤时包含这几项;没有的信息写「还不知道」:
1. **在哪台机器做**:源服务器、目标服务器,或当前会话里的上传文件。
2. **这一步会不会改数据**:只读、演练,或写入。写入前重复备份要求。
3. **命令**:可复制,占位符用 `<GAUSS_HOST>`、`<GAUSS_DB>`、`<CHECKPOINT_SQLITE>`、`<DEER_FLOW_ROOT>` 这种形式。
4. **成功信号**:应该看到的线程数、某个已知会话标题、检查点行数。
5. **停机点**:命令失败或数量对不上时不要继续下一步。
@@ -0,0 +1,35 @@
id: chat-history-migrator
name: 聊天记录迁移助手
description: 把一台 DeerFlow 服务器上的聊天记录迁到另一台。源站业务库可以是高斯/openGauss。会话索引、LangGraph 检查点和线程文件分开处理,不把 PostgreSQL 检查点迁移直接套到高斯上。
tool_groups:
- file:read
- file:write
- bash
skills:
- chat-history-migration
common_questions:
- title: 高斯库里的聊天记录怎么迁
prompt: >-
源服务器的业务库是高斯/openGauss,我要把聊天记录迁到另一台 DeerFlow。
请先用通俗的话说明聊天记录分哪几层存放,再给我一份只读盘点步骤。
不要让我把数据库密码贴进对话,也不要给出把 LangGraph PostgreSQL 检查点迁移直接执行到高斯库上的命令。
- title: 先做只读盘点
prompt: >-
请根据我提供的 database.backend、checkpointer 类型和目录,判断会话索引、消息正文和线程文件各在哪里。
缺哪一项就告诉我该在哪台机器上看哪个文件,不要猜路径,也不要开始拷贝。
- title: 导出迁移包
prompt: >-
请带我从源服务器导出聊天迁移包。业务库如果是高斯或 MySQL 协议的高斯,先生成只读导出 SQL;
检查点仍从 SQLite 文件提取。导出前说明会排除系统线程、不会导出用户密码。
- title: 导入前先演练
prompt: >-
迁移包已经放到目标服务器。请先做导入演练,告诉我会新增哪些会话、会跳过哪些已有会话、哪些文件会拷贝。
我明确确认已经有可恢复备份之前,不要加上 --apply。
- title: 导入后怎么验收
prompt: >-
导入已经执行。请给我验收步骤:会话列表能看到原标题,点进会话能看到原来的问答,附件还在原线程目录。
如果只有标题、打开是空的,请判断是检查点没迁过去,还是 user_id 对不上。
- title: 两边的用户 ID 不一样
prompt: >-
源服务器和目标服务器都有这些账号,但 user_id 不同,聊天记录迁过去后列表是空的。
请说明怎样用 --user-map 只改归属、不改 thread_id,以及哪些字段不会自动改写。
@@ -0,0 +1,45 @@
---
name: chat-history-migration
description: 把 DeerFlow 聊天记录从一台服务器迁到另一台。源业务库可以是 openGauss/高斯或 MySQL 协议高斯。分别导出会话索引、SQLite 检查点和线程文件,导入前默认只演练,不把 PostgreSQL 检查点迁移执行到高斯上。
---
# 聊天记录迁移
本技能的脚本和说明都在本地。先确认用户处于「还不知道数据在哪」「准备导出」「准备导入」还是「迁完对不上」,再只读需要的材料:
- 判断高斯库、检查点和线程目录各存什么:读取 `references/storage-map.md`。
- 已经知道路径,需要逐步导出、传输、演练和验收:读取 `references/runbook.md`。
- 两件事都还没确定时先读存储说明,不要把两份参考一次读完。
脚本路径:
```text
/mnt/skills/public/chat-history-migration/scripts/migrate_chat_history.py
```
它只用 Python 标准库,不连接高斯,也不接收密码。`import` 不加 `--apply` 时只演练,不创建、不修改目标文件。
## 现场和沙箱
`bash` 跑在 DeerFlow 沙箱里,通常不是要迁移的那台服务器。沙箱里的数据库文件和目录不能当成源站或目标站,除非用户明确说当前环境就是那台机器。
两种做法:
1. 把脚本复制到输出目录,让用户在源服务器和目标服务器上执行,再把 JSON 结果贴回来。
2. 用户已经把 SQLite、JSONL 或迁移包上传到当前会话时,直接对 `/mnt/user-data/uploads/` 运行脚本,产物写到 `/mnt/user-data/outputs/`。
交付脚本:
```bash
cp /mnt/skills/public/chat-history-migration/scripts/migrate_chat_history.py /mnt/user-data/outputs/migrate_chat_history.py
```
没有 `bash` 时,读取脚本原文,再用 `write_file` 原样写到上面的路径。
## 不允许的做法
- 不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上。
- 不要导出或覆盖 `users` 表,不要读写 `password_hash`、`login_password`。
- 不要在命令行参数或对话里出现数据库密码。
- 不要在用户确认目标库已有可恢复备份之前执行带 `--apply` 的导入。
- 不要因为列表能看到标题,就认为消息正文已经迁完。正文在检查点里。
@@ -0,0 +1,132 @@
# 迁移步骤
在源服务器导出,在目标服务器导入。迁移包里有对话正文,只通过可信通道拷贝。下面的命令在对应服务器的 shell 里执行,不在智能体沙箱里执行,除非用户确认当前环境就是那台机器。
开始前向用户要这些值,缺的用占位符,不要猜:
- 源、目标的 `database.backend`
- 高斯是 `opengauss` 还是 Dolphin(`gauss_mysql`)
- 检查点 SQLite 的实际路径
- `.deer-flow` 根目录
- 要迁的 `user_id` 或时间范围
- 目标账号 id 是否和源相同
建议先停止源 Gateway,或接受导出时刻之后的新消息不在包里。脚本不会锁高斯库,热导出不是一致快照。
## 1. 源站只读盘点
SQLite 索引或统一的 `deerflow.db`:
```bash
python3 migrate_chat_history.py inventory \
--app-sqlite <APP_SQLITE> \
--checkpoint-sqlite <CHECKPOINT_SQLITE> \
--threads-root <DEER_FLOW_ROOT>
```
只传存在的路径。业务库是高斯、检查点是 SQLite 时,不要传 `--app-sqlite`;索引改用下一步的 SQL 盘点。成功信号是 JSON 里的 `threads_meta`、检查点线程数和文件数。`system_threads` 是默认会排除的内部线程。
## 2. 从高斯导出索引
在源服务器上执行。密码放在 `~/.pgpass`(权限 600)或让客户端提示,不要写进命令,也不要贴进对话。
```bash
python3 migrate_chat_history.py emit-export-sql --dialect opengauss --out export_chat_index.sql
gsql -h <GAUSS_HOST> -p 26000 -d <GAUSS_DB> -U <GAUSS_USER> -f export_chat_index.sql
```
Dolphin / MySQL 协议把 `--dialect` 换成 `mysql`,然后用 `mysql -h <HOST> -P 3306 -u <USER> -p -D <DB> -N` 逐条执行,每条查询的输出存成对应的 `.jsonl`。`-p` 会提示输入密码。
这份 SQL 只有查询。它不包含检查点表。不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上。
## 3. 打迁移包
索引也在 SQLite 时:
```bash
python3 migrate_chat_history.py export \
--out <BUNDLE_DIR> \
--app-sqlite <APP_SQLITE> \
--checkpoint-sqlite <CHECKPOINT_SQLITE> \
--threads-root <DEER_FLOW_ROOT> \
--user-id <USER_ID>
```
索引来自刚才的 JSONL 时:
```bash
python3 migrate_chat_history.py pack-jsonl \
--out <BUNDLE_DIR> \
--threads-meta threads_meta.jsonl \
--runs runs.jsonl \
--run-events run_events.jsonl \
--feedback feedback.jsonl \
--project-threads analysis_project_threads.jsonl \
--projects analysis_projects.jsonl \
--checkpoint-sqlite <CHECKPOINT_SQLITE> \
--threads-root <DEER_FLOW_ROOT>
```
`<BUNDLE_DIR>` 必须是空目录,并且不能放在源数据库或 `.deer-flow` 目录里面。成功信号是目录里有 `manifest.json`。`excluded_system_thread_ids` 里的会话是故意不迁的。需要它们时两边都加 `--include-system`。
只迁某个时间之后的会话时加 `--since 2026-10-01T00:00:00`。
## 4. 目标站先演练
先复制备份:目标检查点 SQLite、目标业务库(高斯做库级备份)。没有备份就不加 `--apply`。
SQLite 业务库:
```bash
python3 migrate_chat_history.py import \
--bundle <BUNDLE_DIR> \
--app-sqlite <TARGET_APP_SQLITE> \
--checkpoint-sqlite <TARGET_CHECKPOINT_SQLITE> \
--threads-root <TARGET_DEER_FLOW_ROOT>
```
目标业务库是高斯时,索引和检查点分开:
```bash
python3 migrate_chat_history.py emit-gauss-sql --bundle <BUNDLE_DIR> --out load_chat_index.sql
python3 migrate_chat_history.py import \
--bundle <BUNDLE_DIR> \
--checkpoint-sqlite <TARGET_CHECKPOINT_SQLITE> \
--threads-root <TARGET_DEER_FLOW_ROOT>
```
演练的成功信号是 `"dry_run": true`,并且目标文件的修改时间没有变。看 `inserted`、`skipped_threads` 和 `files_copied`。已有的 `thread_id` 默认跳过;如果索引已在而检查点缺失,演练会显示还要补检查点。
账号 id 不同:
```bash
python3 migrate_chat_history.py import --bundle <BUNDLE_DIR> --user-map <OLD_USER_ID>=<NEW_USER_ID>
```
可以重复写多个 `--user-map`。
## 5. 确认备份后再写入
SQLite 目标把同一条 `import` 加上 `--apply`。高斯索引另执行:
```bash
gsql -h <GAUSS_HOST> -p 26000 -d <GAUSS_DB> -U <GAUSS_USER> -f load_chat_index.sql
```
`load_chat_index.sql` 默认 `INSERT ... WHERE NOT EXISTS`,不覆盖已有主键。用户明确要求覆盖这些会话时,生成 SQL 才加 `--on-conflict replace`。替换会删除目标上这些 `thread_id` 的索引行再写入,不会删除同 id 的已有项目定义,也不会动迁移包以外的会话。
目标 SQLite 文件必须已经存在(先启动一次目标 Gateway,让它建好表)。检查点文件不存在时,`--apply` 可以按迁移包里的表结构创建。
## 6. 验收
```bash
python3 migrate_chat_history.py verify \
--bundle <BUNDLE_DIR> \
--app-sqlite <TARGET_APP_SQLITE> \
--checkpoint-sqlite <TARGET_CHECKPOINT_SQLITE> \
--threads-root <TARGET_DEER_FLOW_ROOT>
```
退出码 0 表示会话、检查点行数和文件都在。高斯索引不经过这个 SQLite 校验;在目标高斯里对一个已知 `thread_id` 查询 `threads_meta`,再在网页上打开该会话,确认能看到迁前的问答和附件。
数量不对就停。跳过模式下重新执行 `--apply` 只会补缺失的检查点或文件,不会覆盖已有会话。要用备份恢复时,恢复第 4 步复制的目标库和检查点文件,并删掉本次新建的 `users/<user_id>/threads/<thread_id>` 目录。源服务器始终没有被修改。
@@ -0,0 +1,60 @@
# 聊天记录存在哪
DeerFlow 的一条聊天要三层都在,打开后才能看到原来的问答。
## 1. 会话索引(可以在高斯里)
`config.yaml` 的 `database.backend` 决定这一层:
| backend | 索引在哪 | 导出方式 |
| --- | --- | --- |
| `gauss` | `database.gauss_url` 指向的 openGauss/GaussDB | `emit-export-sql --dialect opengauss`,再用 `gsql` 执行 |
| `mysql`,且部署设置了 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql` | 高斯的 MySQL 协议(Dolphin) | `emit-export-sql --dialect mysql`,再用 `mysql` 客户端执行 |
| `sqlite` | `{sqlite_dir}/deerflow.db`,默认 `.deer-flow/data/deerflow.db` | 脚本直接读这个文件 |
| `postgres` | `database.postgres_url` | 先把索引导出成 JSONL,再 `pack-jsonl`。不要改检查点迁移方式 |
和聊天直接相关的表:
- `threads_meta`:聊天列表。`thread_id`、`user_id`、`display_name`、`metadata_json`、时间。
- `runs` / `run_events`:一轮运行的状态、首句预览和事件。
- `feedback`:回答评价。
- `analysis_projects` / `analysis_project_threads`:会话属于哪个分析项目。目标上已有的同 id 项目不会被覆盖;项目下的知识库文件不在这个迁移里。
`users` 表不迁。密码列不迁。目标服务器要先有账号,并且 `threads_meta.user_id` 等于目标账号的 id。两边 id 不同时,导入用 `--user-map 旧ID=新ID`。这个映射只改 `user_id` 以及 `users/<旧ID>/threads/...` 目录,不改 `thread_id`,也不改消息正文里的文字。
`metadata_json.system = true` 的是调度器或内部工作线程,默认排除。
## 2. 消息正文(不要放进高斯迁移)
问答正文在 LangGraph 检查点,不在 `threads_meta`。
`config.yaml` 的 `checkpointer`:
- `type: sqlite`:`connection_string` 指向的文件。业务库是 SQLite 且没有单独的 checkpointer 文件时,检查点表和 `threads_meta` 可能在同一个 `deerflow.db` 里。脚本遇到这种文件会把两层拆开。
- `type: postgres`:只适用于真正的 PostgreSQL。它的建表迁移不能对高斯执行。
- 业务库是 `gauss` 时,检查点应继续用 SQLite。如果现场误把检查点建在了高斯里,先停下来说明这个部署和当前脚本的支持范围不一致,不要自动改跑 PostgreSQL 迁移。
检查点表通常叫 `checkpoints`、`writes`、`checkpoint_blobs`、`checkpoint_writes`。迁移包里的 `checkpoints.sqlite` 只含选中会话的这些表。
只迁了 `threads_meta` 时,列表有标题,点进去没有历史消息。
## 3. 线程文件
默认在后端运行目录:
```text
<DEER_FLOW_ROOT>/users/<user_id>/threads/<thread_id>/user-data/
```
旧数据可能在 `<DEER_FLOW_ROOT>/threads/<thread_id>/`。`--threads-root` 传 `<DEER_FLOW_ROOT>`,也就是同时能看到 `users/` 或旧 `threads/` 的那一层,不要只传某一个会话目录。
这里有上传文件和生成的 HTML、Markdown。知识库原文、记忆库和分享链接不在这份迁移里。
## 迁完后的对照
| 现象 | 先看 |
| --- | --- |
| 目标列表完全没有这些会话 | 索引没进目标库,或 `user_id` 对不上 |
| 有标题,打开是空的 | 检查点没进目标 SQLite |
| 文字在,附件不见 | `--threads-root` 没指到同一个 `.deer-flow` 根 |
| 普通聊天在,项目里看不到 | 目标没有同 id 的 `analysis_projects`,或绑定表没导入 |
@@ -0,0 +1,81 @@
"""Seed the built-in GaussDB data-adaptation assistant.
The agent and its public skill are copied only when their target directories are
missing. Existing deployments are never overwritten, so administrator edits made
through agent/skill management survive restarts. This seeder must run before
``_sync_legacy_agents``.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
GAUSS_DATA_ADAPTATION_AGENT_ID = "gauss-data-adapter"
GAUSS_DATA_ADAPTATION_SKILL_NAME = "gauss-data-adaptation"
_ASSETS_DIR = Path(__file__).parent / "_gauss_data_adaptation_seed_assets"
_AGENT_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _skills_public_dir() -> Path:
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
def _ensure_skill() -> bool:
try:
source = _ASSETS_DIR / "skills" / GAUSS_DATA_ADAPTATION_SKILL_NAME
target = _skills_public_dir() / GAUSS_DATA_ADAPTATION_SKILL_NAME
if target.exists():
return False
if not (source / "SKILL.md").is_file():
logger.error("Missing Gauss data-adaptation skill seed at %s", source)
return False
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, target)
logger.info("Seeded public skill '%s'", GAUSS_DATA_ADAPTATION_SKILL_NAME)
return True
except Exception:
logger.exception("Failed to seed Gauss data-adaptation skill")
return False
def _ensure_agent() -> bool:
try:
target = get_paths().agent_dir(GAUSS_DATA_ADAPTATION_AGENT_ID)
if target.exists():
return False
source = _ASSETS_DIR / GAUSS_DATA_ADAPTATION_AGENT_ID
if not source.is_dir():
logger.error("Missing Gauss data-adaptation agent seed at %s", source)
return False
target.mkdir(parents=True, exist_ok=True)
for filename in _AGENT_FILES:
asset = source / filename
if asset.is_file():
(target / filename).write_bytes(asset.read_bytes())
logger.info("Seeded built-in agent '%s'", GAUSS_DATA_ADAPTATION_AGENT_ID)
return True
except Exception:
logger.exception("Failed to seed Gauss data-adaptation agent")
return False
def ensure_gauss_data_adaptation_agent() -> bool:
"""Install the Gauss data-adaptation skill and agent when missing."""
_ensure_skill()
return _ensure_agent()
__all__ = [
"GAUSS_DATA_ADAPTATION_AGENT_ID",
"GAUSS_DATA_ADAPTATION_SKILL_NAME",
"ensure_gauss_data_adaptation_agent",
]
@@ -0,0 +1,21 @@
你是高斯数据适配助手。用户会把内网高斯适配时的报错或现象发给你,你告诉他改哪里。
不要先给安装教程。不要连接高斯,不要让用户把密码、完整连接串或令牌发到对话里。沙箱里的配置只代表跑脚本的那台机器;用户说的是另一台内网服务器时,不要用沙箱里的 `config.yaml` 给那台机器下改法。
先读技能里的 `references/adaptation-map.md`,只命中一条。
1. 报错原文已经能对应地图里唯一一条时,直接写改法。例如缺少 `asyncpg` / `opengauss_sqlalchemy`、错误 322、`in-memory fallback`、`current_schema()` 不一致、`VARCHAR(n)` 被拒绝、`standard_conforming_strings` 冲突。
2. 改法取决于「原生 openGauss」还是「Dolphin(MySQL 协议)」时,先确认模式,再给改法。能在出问题的那台 Gateway 上运行技能脚本时就运行;不能运行时,只追问 `database.backend` 和进程环境里有没有 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`。
3. 对不上地图时,要改的地方写「先不要改」。指出下一段必须贴来的日志,不要编造 `ALTER`、不要把检查点迁到高斯、不要让内网机器访问公网安装依赖。
4. 用户只贴了 `Automatic ORM schema creation failed` 这一句时,改法还不能确定。向他要这句下面的异常类型和第一条 Gauss/MySQL/openGauss 报错,不要根据这句摘要改配置。
5. 通用问答和智能体问答共用同一套业务表。列表不见,改业务库;列表还在、正文没有,改检查点。不要为这两种聊天各做一套适配。
每次回答用这五段,没有内容就写「无」:
现象
判定模式
要改的地方
不要改的地方
改完如何确认
要改的地方必须是用户能执行的一项:改哪一个配置字段、哪一个环境变量、换哪一个已在现场的镜像或离线包、或明确「先不要改」。改完如何确认要写出重启后日志里应出现或应消失的那一句。
@@ -0,0 +1,19 @@
id: gauss-data-adapter
name: 高斯数据适配助手
description: 内网把业务库适配到高斯后出现报错或异常现象时,根据报错说明要改的配置、环境变量或部署方式。不连接数据库,不收集密码。
tool_groups:
- file:read
- bash
skills:
- gauss-data-adaptation
common_questions:
- title: 启动报缺少高斯驱动
prompt: Gateway 启动报错,说 database.backend 是 gauss,但没有安装 asyncpg 或 opengauss 的 SQLAlchemy 方言。这是内网,不能访问公网。请告诉我要改哪里。
- title: 进程起来了但建表失败
prompt: Gateway 进程已经起来,日志里有 Automatic ORM schema creation failed。请根据这个现象告诉我要改什么,不要先给一整套安装步骤。
- title: Dolphin 报错 322
prompt: 高斯走 MySQL 协议(Dolphin)。启动或建表时报错 322。请告诉我要改哪一项配置或环境变量。
- title: 列表还在,重启后正文没了
prompt: 通用问答和智能体的会话列表还在,但重启 Gateway 后对话正文没了。日志里有 in-memory fallback。请告诉我该改检查点还是高斯业务库。
- title: 时间差了 8 小时
prompt: 业务库改到高斯之后,页面上的时间比北京时间差了 8 小时。请告诉我要改配置的哪一处,不要让我在 SQL 里手工加减 8 小时。
@@ -0,0 +1,22 @@
---
name: gauss-data-adaptation
description: 根据内网高斯适配报错判断要改的配置或环境变量。区分原生 openGauss 与 Dolphin,不连接数据库,不读取密码。
---
# 高斯数据适配
用户贴来报错或现象后,先读 `references/adaptation-map.md`,只命中一条,按其中的改法回答。
报错已经能唯一对应时,直接给改法。只有改法取决于原生 openGauss 还是 Dolphin 时,才在**出问题的那台 Gateway** 上运行:
```bash
python /mnt/skills/public/gauss-data-adaptation/scripts/print_gauss_adaptation.py
```
沙箱里找不到该路径时:
```bash
python scripts/print_gauss_adaptation.py
```
脚本只读本机 `config.yaml` 和当前进程是否设置了 `DEERFLOW_DATABASE_PRODUCT`,并用 `find_spec` 查看驱动是否可导入。它不连接高斯,不读取 `$GAUSS_DATABASE_URL` 或 `$MYSQL_DATABASE_URL` 的值,输出里不保留账号和密码。沙箱不是出问题的那台机器时,把命令交给那台机器,不要把沙箱的判定当成那台机器的改法。
@@ -0,0 +1,153 @@
# 高斯适配报错对照
只命中一条。改完都要重启出问题的那个 Gateway 进程。环境变量写在启动该进程的服务配置里;在另一个 shell 里临时 export 不会影响已经启动的进程。
两种模式不能同时打开:
- 原生 openGauss:`database.backend: gauss`,`database.gauss_url: $GAUSS_DATABASE_URL`。不要设置 `DEERFLOW_DATABASE_PRODUCT`。
- Dolphin(高斯的 MySQL 协议):`database.backend: mysql`,`database.mysql_url: $MYSQL_DATABASE_URL`,并且进程环境为 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`。不要把 `backend` 改成 `gauss`。
口令只放在服务器的环境变量或客户端口令文件里。下面的改法都不包含密码。
固定约束:不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上。
## 缺驱动
现象:启动日志含 `asyncpg is not installed` 或 `openGauss SQLAlchemy dialect is not installed`。
模式:原生 openGauss。不需要再确认。
要改的地方:不要在内网执行会访问公网的 `uv sync --extra gauss`。换成构建时带 `--build-arg UV_EXTRAS=gauss` 的镜像,或使用现场离线 wheelhouse 里的 `asyncpg` 和 `opengauss-sqlalchemy`。装进当前环境后重启。
不要改的地方:不要为了绕过缺包把 `backend` 改成 `postgres` 或 `mysql`。
改完如何确认:这两句 ImportError 消失,日志出现 `Persistence engine initialized` 且 `dialect=opengauss`。
Dolphin 缺的是 MySQL 驱动。日志含 `MySQL async driver is not installed` 时,用离线 wheelhouse 把 `asyncmy` 装进 `.deer-flow/python-vendor`,不要从公网安装,也不要安装 `opengauss-sqlalchemy`。
## 误用通用 PostgreSQL 方言
现象:TCP 能连上高斯,但方言初始化或建表失败;或者脚本显示 `backend` 不是 `gauss`,连接串却指向高斯端口。
模式:应为原生 openGauss。
要改的地方:`database.backend` 改为 `gauss`,`gauss_url` 使用 `opengauss://`、`opengauss+asyncpg://`、`gaussdb://` 或 PostgreSQL 风格 URL。启动时会归一化成 `opengauss+asyncpg`。取消 `DEERFLOW_DATABASE_PRODUCT`。
不要改的地方:不要手改 SQLAlchemy 的 PostgreSQL 方言,不要把这条 URL 同时填进 `checkpointer.connection_string`。
改完如何确认:`dialect=opengauss`。
## VARCHAR(n) 被拒绝
现象:建表报错指向 `VARCHAR(数字)`。
模式:原生 openGauss。不需要再确认。
要改的地方:确认进程确实以 `backend: gauss` 启动,从而装上把 `VARCHAR(n)` 编译成不带长度的 `VARCHAR` 的兼容层。驱动按上面的「缺驱动」处理。不要在库里把列改成某种固定长度来迁就这条 SQL。
不要改的地方:不要改成 Dolphin,也不要删业务列。
改完如何确认:同一条建表错误消失,`dialect=opengauss`。
## 错误 322,或 SHOW CREATE TABLE 解析失败
现象:日志含 `322`,或 MySQL 反射、`SHOW CREATE TABLE`、重复 `ALTER` 导致事务中止。
模式:Dolphin。不需要再确认。
要改的地方:保证**正在运行的进程**有 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`,`database.backend` 保持 `mysql`。设置后重启该进程。不要再执行 MySQL 的 `SHOW CREATE TABLE` 反射、MySQL JSON 回填、MySQL 会话超时变量,也不要让应用执行 `CREATE DATABASE`。
不要改的地方:不要把 `backend` 改成 `gauss` 来消掉 322。库、用户和 schema 仍由离线部署层创建。
改完如何确认:日志出现 `GaussDB MySQL compatibility enabled` 和 `Skipping MySQL reflection and JSON backfill for openGauss Dolphin`,不再出现 322。
## init_command 冲突
现象:日志含 `gauss_mysql requires init_command` 或 `standard_conforming_strings`。
模式:Dolphin。
要改的地方:从 `mysql_url` 里去掉冲突的 `init_command`。兼容层会在连接时设置 `SET standard_conforming_strings TO off`。
不要改的地方:不要在 URL 里写另一条 `init_command`,不要把口令贴进对话。
改完如何确认:这句 ValueError 消失,并出现 `GaussDB MySQL compatibility enabled`。
## current_schema() 对不上
现象:日志含 `URL schema` 与 `current_schema()` 不一致,或 `current_schema() is empty`。
模式:Dolphin 的启动补列。
要改的地方:把 `mysql_url` 里的库名改成 `SELECT current_schema();` 的结果,或让部署层把该登录用户的当前 schema 改成 URL 里的库名。两边必须是同一个名字。
不要改的地方:不要用 `SHOW CREATE TABLE` 补列,不要由应用 `CREATE DATABASE`。
改完如何确认:这句 schema integrity check 消失。若随后出现 `openGauss schema is incomplete`,按下一条处理,不要提前改别的配置。
## 缺列但启动拒绝补列
现象:日志含 `openGauss schema is incomplete`,以及 `NOT NULL column has no server_default`、主键、identity、computed 或约束需要 explicit migration。
模式:Dolphin。
要改的地方:先不要改。把日志里点名的 `schema.表.列` 原样留下。这种列不能靠启动自动补。需要一份针对该列的显式迁移,而不是在对话里手写 `ALTER TABLE`。
不要改的地方:不要删表、不要把列改成可空来让启动继续。
改完如何确认:同一句 `schema is incomplete` 消失。只为日志点名的那一列做迁移。
## 只看到建表失败摘要
现象:只有 `Automatic ORM schema creation failed; continuing Gateway startup`。
模式:还不能判定。
要改的地方:先不要改。把这句下面的异常类型和第一条数据库报错贴出来,再按本表重新命中。
不要改的地方:不要因为进程还活着就认为适配已经成功。缺表的接口仍会失败。
改完如何确认:重启后这句消失;若仍在,新贴出的第一条数据库报错必须变了,才能进入下一条。
## 列表还在,重启后正文没了
现象:通用问答或智能体的会话列表还在,重启后正文没有;日志含 `LangGraph checkpointer initialization failed` 和 `in-memory fallback`。
模式:与原生或 Dolphin 无关。列表来自业务库,正文来自检查点。
要改的地方:修复日志里检查点初始化的那条异常。检查点使用现有 SQLite 文件,或一台真正的 PostgreSQL。不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上。若 `checkpointer.type` 已是 `postgres` 且 `use_postgres: false`,实际文件是 `sqlite_connection_string`,改这个路径的权限或位置,不要改高斯 URL。
不要改的地方:不要重导 `threads_meta`,不要改 `assistant_id`。两种聊天的正文都在同一套检查点里。
改完如何确认:`in-memory fallback` 消失。重启后再打开刚才那条通用问答和那条智能体问答,正文仍在。
## 时间差 8 小时
现象:库里或页面上的时间比北京时间差 8 小时,业务库刚刚改到高斯。
先确认模式。
- 原生 openGauss 却把 `backend` 配成 `mysql` 或 `postgres`:改回上一节的原生配置。`opengauss` 与 `postgresql` 走带时区的时间类型,不在写入时改成北京墙钟。
- Dolphin 却没设 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`,或误设成 `backend: gauss`:改回 Dolphin 配置。Dolphin 按 MySQL 方言把 UTC 存成北京墙钟。
- 模式已经配对,只是客户端用另一套时区显示:先不要改 DeerFlow 配置,也不要在 SQL 里加减 8 小时。
改完如何确认:新写入的一行与页面显示一致。不要批量改历史时间列。
## JSON 读失败
现象:日志含 `GaussDB MySQL JSON value is neither valid JSON`,或普通 JSON 解析失败。
- 这句完整报错只出现在 Dolphin 兼容层已经装上、但库里的值不是严格 JSON、也不是已支持的旧字面量时:先不要改配置。把报错里的预览留给库侧,不要改用另一种解码把坏值读成对象。
- 同样的数据在未设置 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql` 时解析失败:按 Dolphin 配置补上该环境变量并重启。普通 MySQL 必须继续拒绝坏 JSON,不要为了读旧数据去设置这个变量。
改完如何确认:Dolphin 应出现 `GaussDB MySQL compatibility enabled`。坏值那一行仍失败时,失败原因应仍是这句 JSON 报错,而不是驱动或方言报错。
## 两种模式同时打开
现象:`backend: gauss`,同时进程环境是 `DEERFLOW_DATABASE_PRODUCT=gauss_mysql`。
要改的地方:只留一种。原生就删掉该环境变量;Dolphin 就把 `backend` 改回 `mysql` 并保留该环境变量。
不要改的地方:不要两套 URL 都填上指望自动选择。
改完如何确认:脚本的「判定模式」只剩一种,启动日志里的 `dialect` 与之相符:原生是 `opengauss`,Dolphin 是 `mysql`。
@@ -0,0 +1,226 @@
"""Classify the local Gauss adaptation mode without connecting or reading secrets."""
from __future__ import annotations
import os
from importlib.util import find_spec
from pathlib import Path
PROHIBITION = "不要把 LangGraph 的 PostgreSQL checkpointer 迁移执行到高斯库上"
DOLPHIN_PRODUCT = "gauss_mysql"
GAUSS_URL_PREFIXES = (
"opengauss+asyncpg://",
"opengauss://",
"gaussdb://",
"postgres://",
"postgresql://",
"postgresql+asyncpg://",
)
def config_candidates() -> list[Path]:
found: list[Path] = []
for key in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_CONFIG", "DEERFLOW_CONFIG"):
value = os.environ.get(key, "").strip()
if value:
found.append(Path(value))
found.extend(
[
Path("/app/backend/config.yaml"),
Path.cwd() / "config.yaml",
Path.cwd().parent / "config.yaml",
]
)
return found
def parse_sections(text: str, names: set[str]) -> dict[str, dict[str, str]]:
"""Read one level of scalar keys from the named top-level YAML blocks."""
found = {name: {} for name in names}
current = ""
for raw in text.splitlines():
line = raw.split("#", 1)[0].rstrip()
if not line.strip():
continue
if not line.startswith((" ", "\t")):
header = line.strip()
current = header[:-1] if header.endswith(":") else ""
if current not in names:
current = ""
continue
if not current or ":" not in line:
continue
key, _, value = line.strip().partition(":")
value = value.strip().strip("\"'")
if value:
found[current][key.strip()] = value
return found
def describe_value(value: str) -> str:
"""Describe a config value without usernames, passwords, or env-var contents."""
text = value.strip().strip("\"'")
if not text:
return "未设置"
if text.startswith("$"):
return f"环境变量 {text[1:]}(不读取变量值)"
if "://" not in text:
return text
scheme, _, rest = text.partition("://")
target = rest.split("@")[-1].split("?")[0]
return f"{scheme}://{target}(已去掉账号和密码)"
def driver_status() -> dict[str, bool]:
names = ("asyncpg", "opengauss_sqlalchemy", "asyncmy", "aiomysql")
return {name: find_spec(name) is not None for name in names}
def _enabled(value: str) -> bool:
return value.strip().lower() not in {"", "false", "0", "no", "off"}
def classify(
database: dict[str, str],
checkpointer: dict[str, str],
product: str,
drivers: dict[str, bool],
) -> list[str]:
"""Return the mode and the concrete config change, if one is already known."""
backend = database.get("backend", "").strip().lower()
product_name = product.strip().lower()
checkpoint_type = checkpointer.get("type", "").strip().lower()
use_postgres = checkpointer.get("use_postgres", "")
lines = [
f"database.backend={backend or '未解析'}",
f"DEERFLOW_DATABASE_PRODUCT={product_name or '未设置'}",
f"checkpointer.type={checkpoint_type or '未解析'}",
]
if "gauss_url" in database:
lines.append(f"database.gauss_url={describe_value(database['gauss_url'])}")
if "mysql_url" in database:
lines.append(f"database.mysql_url={describe_value(database['mysql_url'])}")
if "connection_string" in checkpointer:
lines.append(
"checkpointer.connection_string="
+ describe_value(checkpointer["connection_string"])
)
if use_postgres:
lines.append(f"checkpointer.use_postgres={use_postgres}")
for name in ("asyncpg", "opengauss_sqlalchemy", "asyncmy", "aiomysql"):
lines.append(f"{name}={'可导入' if drivers.get(name) else '不可导入'}")
changes: list[str] = []
constraints: list[str] = []
if backend == "gauss" and product_name == DOLPHIN_PRODUCT:
mode = "冲突:原生 openGauss 与 Dolphin 同时打开"
changes.append(
"只保留一种。原生就取消 DEERFLOW_DATABASE_PRODUCT;"
"Dolphin 就把 database.backend 改回 mysql,并保留该环境变量。"
)
elif backend == "gauss":
mode = "原生 openGauss"
url = database.get("gauss_url", "")
if not url:
changes.append("补上 database.gauss_url: $GAUSS_DATABASE_URL。口令只放在该环境变量里。")
elif not url.startswith("$") and not url.startswith(GAUSS_URL_PREFIXES):
changes.append(
"把 gauss_url 改成 opengauss://、opengauss+asyncpg://、gaussdb:// 或 PostgreSQL 风格 URL。"
)
elif not url.startswith("$") and not url.startswith("opengauss+asyncpg://"):
constraints.append("这份 gauss_url 启动时会归一化为 opengauss+asyncpg,不会走通用 PostgreSQL 方言。")
if not drivers.get("asyncpg") or not drivers.get("opengauss_sqlalchemy"):
changes.append(
"当前进程缺少 asyncpg 或 opengauss_sqlalchemy。"
"不要在内网访问公网安装。改用带 UV_EXTRAS=gauss 的镜像,或现场离线 wheelhouse。"
)
constraints.append("不要设置 DEERFLOW_DATABASE_PRODUCT。")
elif backend == "mysql" and product_name == DOLPHIN_PRODUCT:
mode = "Dolphin(高斯 MySQL 协议)"
if not database.get("mysql_url"):
changes.append("补上 database.mysql_url: $MYSQL_DATABASE_URL。口令只放在该环境变量里。")
if not drivers.get("asyncmy") and not drivers.get("aiomysql"):
changes.append(
"当前进程缺少 asyncmy 和 aiomysql。用离线 wheelhouse 把 asyncmy 装进 "
".deer-flow/python-vendor,不要从公网安装。"
)
constraints.append(
"不要执行 SHOW CREATE TABLE、MySQL JSON 回填或应用侧 CREATE DATABASE。"
"URL 里的库名必须等于 SELECT current_schema()。"
)
elif backend == "mysql":
mode = "普通 MySQL"
constraints.append(
"现在不会安装 Dolphin 补丁。只有报错已经表现为 322、current_schema 或 "
"SHOW CREATE TABLE 解析失败时,才给运行中的进程加上 "
"DEERFLOW_DATABASE_PRODUCT=gauss_mysql 并重启。普通 MySQL 不要加。"
)
elif backend == "postgres":
mode = "PostgreSQL"
changes.append("这不是高斯适配路径。连高斯业务库时不要使用 backend: postgres。")
elif backend in {"sqlite", "memory"}:
mode = backend
constraints.append("当前业务库配置不是高斯。这只代表运行脚本的机器。")
else:
mode = "未识别"
changes.append("先不要改。确认 database.backend 是 gauss、mysql、postgres、sqlite 还是 memory。")
postgres_checkpoint = checkpoint_type == "postgres" and _enabled(use_postgres or "true")
if backend in {"gauss", "mysql"} and postgres_checkpoint and product_name == DOLPHIN_PRODUCT:
changes.append(
"先确认 checkpointer.connection_string 是不是高斯。"
+ PROHIBITION
+ " 是高斯就把 checkpointer.type 改为 sqlite,并沿用现有 SQLite 文件;是独立的 PostgreSQL 就不要改。"
)
elif backend == "gauss" and postgres_checkpoint:
changes.append(
"先确认 checkpointer.connection_string 是不是高斯。"
+ PROHIBITION
+ " 是高斯就把 checkpointer.type 改为 sqlite,并沿用现有 SQLite 文件;是独立的 PostgreSQL 就不要改。"
)
elif checkpoint_type == "memory" and backend in {"gauss", "mysql"}:
changes.append("把 checkpointer.type 从 memory 改为 sqlite,否则重启后问答正文不会保留。")
lines.append(f"判定模式:{mode}")
if constraints:
lines.append("先不要改:")
lines.extend(f"- {item}" for item in constraints)
lines.append("要改的地方:")
if changes:
lines.extend(f"- {item}" for item in changes)
else:
lines.append("- 配置层面没有必须修改的项。把报错原文发来后再决定。")
lines.append(f"固定约束:{PROHIBITION}")
return lines
def main() -> None:
print("这个结果只代表运行脚本的机器,不连接高斯,也不读取连接串环境变量的值。")
chosen = next((item for item in config_candidates() if item.is_file()), None)
if chosen is None:
database: dict[str, str] = {}
checkpointer: dict[str, str] = {}
print("配置:未找到 config.yaml。")
else:
print(f"配置文件:{chosen}")
try:
parsed = parse_sections(
chosen.read_text(encoding="utf-8"),
{"database", "checkpointer"},
)
except OSError as exc:
print(f"配置:读取失败:{exc}")
parsed = {"database": {}, "checkpointer": {}}
database = parsed["database"]
checkpointer = parsed["checkpointer"]
for line in classify(
database,
checkpointer,
os.environ.get("DEERFLOW_DATABASE_PRODUCT", ""),
driver_status(),
):
print(line)
if __name__ == "__main__":
main()
@@ -21,7 +21,7 @@
## 页面生成流程
1. 先读取 `page-report-generation` 技能,并将材料归为:事实数据、可推导结论、待补充项、图表数据、长文本、表格行、行动/风险项。优先保留用户原文的关键判断和数据口径。
2. 默认从零直接创建 `/mnt/user-data/outputs/<主题>.html`,第一项文件操作就是写最终路径。只有用户明确要求使用模板或点名模板时,才读取 `page-template-library` 并调用 `page_report_template(action="prepare")`;主题优先使用界面传入的 `presentation_theme`,其次遵从用户明示。
2. 默认从零直接创建 `/mnt/user-data/outputs/<主题>.html`,第一项文件操作就是写最终路径。只有用户明确要求使用模板或点名模板时,才读取 `page-template-library` 并调用 `page_report_template(action="prepare")`;用户明示的页面主题优先,否则只跟随界面传入的 `presentation_theme` 明暗模式。
3. 页面文字通常要多于图表:至少包含结论摘要、关键发现、事项/风险说明、数据口径或来源、行动建议中的多个展示样式。默认生成一个可纵向滚动的响应式页面;只有用户明确要求多页或给出页数时才生成多页。
4. 使用模板时,工具返回的 outputs 工作副本是唯一成品路径。保留已有图表渲染和本地运行时契约,用真实数据替换占位内容,并删除无数据页面和面板;绝不修改模板源文件。
5. 所有可见正文、表头、标签、说明文字最小字号均为 16px;长文本面板要增高、提高行高,不能把段落挤成一团。
@@ -29,7 +29,7 @@
## 设计要求
- 跟随 `presentation_theme`:`dark` 选深蓝骨架,`light` 选浅色骨架;不要把产品现有聊天页面风格照搬进汇报页面。
- 用户未指定页面主题时跟随 `presentation_theme`:`dark` 选深色骨架,`light` 选浅色骨架;不要复制系统的红、蓝、绿等主题主色,页面自己的强调色根据内容、信息层级和可读性独立设计,也不要把产品现有聊天页面风格照搬进汇报页面。
- 深色使用深蓝科技感,选中状态可有细腻的扫描光、描边或流光;浅色使用清晰留白与高对比文字。
- 动效服务于阅读:数字滚动、图表入场、进度条、轻量背景粒子即可;支持 `prefers-reduced-motion`,不使用刺眼或持续大幅运动。
- 图表必须与材料一致。数据不够时宁可删掉图表或明确标为示意,不伪造事实;避免为了“丰富”而堆满图。
@@ -15,7 +15,7 @@ allowed-tools:
## 使用流程
1. 先盘点材料和输出约束,确定:
- `theme`:优先使用界面传入的 `presentation_theme`,否则遵从用户明示;仍未指定时使用 `auto`。
- `theme`:用户明示的页面主题优先;否则使用界面传入的 `presentation_theme`。`auto` 会由工具按当前界面明暗模式解析,界面信息缺失时才回退浅色。
- `content_density`:`text-heavy`、`balanced` 或 `data-heavy`。
- `required_features`:如 `flowchart`、`chart`、`map`、`table`、`text-panels`;源 Markdown 中的 Mermaid 必须记为 `flowchart`。
- `preferred_page_count`:默认 `1`;只有用户明确要求多页或指定页数时才大于 `1`。
@@ -318,6 +318,20 @@ class AdminLeaderboardResponse(BaseModel):
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminUserAnalyticsResponse(BaseModel):
range_days: int
since: str
until: str
user: AdminLeaderboardUser
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminLeaderboardReportResponse(BaseModel):
report: str
generated_at: str
@@ -2224,6 +2238,67 @@ async def stream_user_leaderboard_report(
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,
request: Request,
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
}
tool_common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
since_dt, until_dt, range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id, **common)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
user=users[0],
overview=overview,
skills=skills,
tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
trends=trends,
)
# ── Q&A audit list ─────────────────────────────────────────────────────────
@@ -0,0 +1,245 @@
"""Admin-only metadata migration for agent/skill folders copied in place."""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.agent_skill_migration import (
MigrationApplyResult,
MigrationManifest,
MigrationPreflight,
apply_migration_manifest,
build_agent_migration_manifest,
build_skill_migration_manifest,
preflight_manifest,
prepare_manifest_for_apply,
)
from app.gateway.deps import (
get_agent_store,
get_local_provider,
get_optional_user_from_request,
get_skill_store,
get_tag_store,
)
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.skill_view import refresh_skill_sandbox_view
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.usage import all_entries as get_skill_usage_entries
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/agent-skill-migrations", tags=["agent-skill-migrations"])
class MigrationExportRequest(BaseModel):
agent_ids: list[str] = Field(min_length=1, max_length=1000)
class SkillMigrationExportRequest(BaseModel):
skill_names: list[str] = Field(min_length=1, max_length=5000)
class MigrationApplyRequest(BaseModel):
manifest: MigrationManifest
use_current_admin_for_unresolved_owners: bool = False
skip_invalid_skills: bool = False
class MigrationRootsResponse(BaseModel):
agents_root: str
skills_root: str
extensions_config_path: str | None
async def _require_admin(request: Request):
user = await get_optional_user_from_request(request)
if user is not None and getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="智能体与技能迁移仅限管理员")
return user
def _require_migration_enabled() -> None:
if not load_system_settings().agent_skill_migration.enabled:
raise HTTPException(status_code=403, detail="智能体与技能迁移功能未开启")
async def _users() -> tuple[dict[str, str], dict[str, str]]:
"""Return ``(id -> email, normalized email -> id)`` without auth secrets."""
try:
users = await get_local_provider().list_users()
except Exception: # noqa: BLE001 - metadata migration degrades to manual owner fallback
logger.warning("Could not load users for migration owner mapping", exc_info=True)
return {}, {}
by_id = {str(user.id): str(user.email) for user in users}
by_email = {str(user.email).strip().casefold(): str(user.id) for user in users}
return by_id, by_email
def _roots() -> tuple[Path, Path]:
storage = get_or_new_skill_storage()
return get_paths().agents_dir.resolve(), Path(storage.get_skills_root_path()).resolve()
@router.get("/roots", response_model=MigrationRootsResponse)
async def migration_roots(request: Request) -> MigrationRootsResponse:
await _require_admin(request)
_require_migration_enabled()
agents_root, skills_root = _roots()
extensions_path = ExtensionsConfig.resolve_config_path()
return MigrationRootsResponse(
agents_root=str(agents_root),
skills_root=str(skills_root),
extensions_config_path=str(extensions_path.resolve()) if extensions_path else None,
)
@router.post("/export", response_model=MigrationManifest)
async def export_migration_manifest(
body: MigrationExportRequest,
request: Request,
agent_store: AgentStore = Depends(get_agent_store),
tag_store: TagStore = Depends(get_tag_store),
) -> MigrationManifest:
await _require_admin(request)
_require_migration_enabled()
agents_root, _ = _roots()
users_by_id, _ = await _users()
try:
return await build_agent_migration_manifest(
body.agent_ids,
agent_store=agent_store,
tag_store=tag_store,
agents_root=agents_root,
users_by_id=users_by_id,
)
except (FileNotFoundError, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/export-skills", response_model=MigrationManifest)
async def export_skill_migration_manifest(
body: SkillMigrationExportRequest,
request: Request,
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
) -> MigrationManifest:
await _require_admin(request)
_require_migration_enabled()
_, skills_root = _roots()
storage = get_or_new_skill_storage()
users_by_id, _ = await _users()
discovered_skills = await asyncio.to_thread(storage.load_skills, enabled_only=False)
try:
return await build_skill_migration_manifest(
body.skill_names,
skill_store=skill_store,
tag_store=tag_store,
skills_root=skills_root,
discovered_skills=discovered_skills,
users_by_id=users_by_id,
usage_entries=get_skill_usage_entries(),
)
except (FileNotFoundError, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/preflight", response_model=MigrationPreflight)
async def preflight_migration_manifest(
manifest: MigrationManifest,
request: Request,
) -> MigrationPreflight:
await _require_admin(request)
_require_migration_enabled()
agents_root, skills_root = _roots()
_, users_by_email = await _users()
return await asyncio.to_thread(
preflight_manifest,
manifest,
agents_root=agents_root,
skills_root=skills_root,
users_by_email=users_by_email,
)
@router.post("/apply", response_model=MigrationApplyResult)
async def apply_migration(
body: MigrationApplyRequest,
request: Request,
agent_store: AgentStore = Depends(get_agent_store),
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
) -> MigrationApplyResult:
user = await _require_admin(request)
_require_migration_enabled()
agents_root, skills_root = _roots()
_, users_by_email = await _users()
# Re-scan at apply time. The browser's earlier preflight is informative,
# never an authorization or integrity token.
preflight = await asyncio.to_thread(
preflight_manifest,
body.manifest,
agents_root=agents_root,
skills_root=skills_root,
users_by_email=users_by_email,
)
try:
effective_manifest, effective_preflight, _ = prepare_manifest_for_apply(
body.manifest,
preflight,
skip_invalid_skills=body.skip_invalid_skills,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if body.manifest.skills and not effective_manifest.skills:
raise HTTPException(status_code=409, detail="所有技能均未通过预检,没有可导入的技能")
resource_failures = [item.model_dump(mode="json") for item in [*effective_preflight.agents, *effective_preflight.skills, *effective_preflight.support_directories] if item.status != "matched"]
unresolved = [item for item in effective_preflight.owner_mappings if item.status == "unresolved"]
if resource_failures or (unresolved and not body.use_current_admin_for_unresolved_owners):
raise HTTPException(
status_code=409,
detail={
"message": "迁移预检未通过,未写入任何元数据",
"preflight": effective_preflight.model_dump(mode="json"),
},
)
extensions_path = ExtensionsConfig.resolve_config_path() if effective_manifest.skills else None
if effective_manifest.skills and extensions_path is None:
raise HTTPException(status_code=503, detail="目标运行环境未找到 extensions_config.json")
fallback_owner_user_id = None
if unresolved and body.use_current_admin_for_unresolved_owners:
fallback_owner_user_id = str(user.id) if user is not None else get_effective_user_id()
try:
result = await apply_migration_manifest(
body.manifest,
preflight=preflight,
agent_store=agent_store,
skill_store=skill_store,
tag_store=tag_store,
agents_root=agents_root,
users_by_email=users_by_email,
fallback_owner_user_id=fallback_owner_user_id,
extensions_config_path=extensions_path,
skip_invalid_skills=body.skip_invalid_skills,
)
if result.skills_applied:
await refresh_skills_system_prompt_cache_async()
storage = get_or_new_skill_storage()
await asyncio.to_thread(refresh_skill_sandbox_view, storage=storage, force=True)
return result
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,700 @@
"""Account-scoped chat history transfer for the conversation list page.
Exports and imports ordinary / agent chats as a JSON bundle that includes
the latest checkpoint (messages) and the thread ``user-data`` files
(uploads + outputs). One oversized or broken thread is skipped so the
rest of the batch can still succeed.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse
from langgraph.checkpoint.base import empty_checkpoint
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer, get_thread_store
from app.gateway.routers.threads import _strip_reserved_metadata
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.thread_meta.base import is_excluded_system_thread
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso, now_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chat-history", tags=["chat-history-transfer"])
FORMAT = "deerflow-chat-history-ui"
FORMAT_VERSION = 1
BATCH_LIMIT = 1
MAX_THREAD_BYTES = 50 * 1024 * 1024
MAX_FILES_PER_THREAD = 200
SAFE_REL_PATH = re.compile(r"^(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+$")
_FILE_JSON_OVERHEAD = 64
_DROP_META_KEYS = frozenset(
{
"owner_id",
"user_id",
"analysis_project_id",
"analysis_project_mode",
"analysis_project_name",
"shared_from_code",
"shared_origin",
"shared_by",
"taskId",
"actionId",
}
)
class SkippedThread(BaseModel):
thread_id: str
title: str = ""
reason: str
class TransferFile(BaseModel):
path: str
content_b64: str
size: int = 0
class TransferThread(BaseModel):
thread_id: str
title: str = ""
assistant_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
values: dict[str, Any] = Field(default_factory=dict)
files: list[TransferFile] = Field(default_factory=list)
skipped_files: list[str] = Field(default_factory=list)
class ExportRequest(BaseModel):
thread_ids: list[str] = Field(..., min_length=1, max_length=BATCH_LIMIT)
class ExportResponse(BaseModel):
format: str = FORMAT
version: int = FORMAT_VERSION
threads: list[TransferThread] = Field(default_factory=list)
skipped: list[SkippedThread] = Field(default_factory=list)
class ImportRequest(BaseModel):
threads: list[TransferThread] = Field(..., min_length=1, max_length=BATCH_LIMIT)
class ImportResponse(BaseModel):
imported: int = 0
skipped: list[SkippedThread] = Field(default_factory=list)
class ExportAllStartResponse(BaseModel):
job_id: str
class ExportAllStatusResponse(BaseModel):
job_id: str
status: str
total: int = 0
processed: int = 0
succeeded: int = 0
skipped: list[SkippedThread] = Field(default_factory=list)
skipped_files: int = 0
error: str | None = None
@dataclass
class ExportAllJob:
job_id: str
user_id: str
status: str = "running"
total: int = 0
processed: int = 0
succeeded: int = 0
skipped: list[SkippedThread] = field(default_factory=list)
skipped_files: int = 0
error: str | None = None
path: Path | None = None
task: asyncio.Task[None] | None = None
_EXPORT_JOBS: dict[str, ExportAllJob] = {}
_SAFE_USER_DIR = re.compile(r"[^A-Za-z0-9._-]+")
def require_transfer_enabled() -> None:
if not load_system_settings().agent_skill_migration.enabled:
raise HTTPException(status_code=403, detail="跨服务器迁移功能未开启")
def current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None and getattr(user, "id", None):
return str(user.id)
auth = getattr(request.state, "auth", None)
auth_user = getattr(auth, "user", None) if auth is not None else None
if auth_user is not None and getattr(auth_user, "id", None):
return str(auth_user.id)
return get_effective_user_id()
def payload_size(value: Any) -> int:
return len(json.dumps(value, ensure_ascii=False, default=str).encode("utf-8"))
def clean_export_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
return {
key: item
for key, item in _strip_reserved_metadata(metadata).items()
if key not in _DROP_META_KEYS
}
def safe_relpath(root: Path, path: Path) -> str | None:
try:
resolved = path.resolve()
relative = resolved.relative_to(root.resolve())
except ValueError:
return None
text = relative.as_posix()
if not SAFE_REL_PATH.fullmatch(text):
return None
return text
def encoded_file_cost(size: int, path: str) -> int:
return (size * 4 + 3) // 3 + len(path) + _FILE_JSON_OVERHEAD
def list_thread_file_candidates(root: Path) -> list[tuple[int, str, Path]]:
if not root.is_dir():
return []
candidates: list[tuple[int, str, Path]] = []
for path in sorted(root.rglob("*")):
if not path.is_file() or path.is_symlink():
continue
rel = safe_relpath(root, path)
if rel is None:
continue
try:
size = path.stat().st_size
except OSError:
continue
if size < 0:
continue
candidates.append((size, rel, path))
candidates.sort(key=lambda item: (item[0], item[1]))
return candidates
def collect_thread_files(root: Path, *, budget: int) -> tuple[list[dict[str, Any]], list[str]]:
candidates = list_thread_file_candidates(root)
if not candidates:
return [], []
files: list[dict[str, Any]] = []
skipped: list[str] = []
used = 0
for size, rel, path in candidates:
cost = encoded_file_cost(size, rel)
if budget <= 0 or len(files) >= MAX_FILES_PER_THREAD or used + cost > budget:
skipped.append(rel)
continue
try:
data = path.read_bytes()
except OSError:
skipped.append(rel)
continue
files.append(
{
"path": rel,
"content_b64": base64.b64encode(data).decode("ascii"),
"size": size,
}
)
used += cost
return files, skipped
def write_thread_files(
root: Path,
files: list[TransferFile],
*,
budget: int = MAX_THREAD_BYTES,
) -> list[str]:
failed: list[str] = []
used = 0
root.mkdir(parents=True, exist_ok=True)
root_resolved = root.resolve()
for item in files[:MAX_FILES_PER_THREAD]:
rel = safe_relpath(root, root / item.path) if SAFE_REL_PATH.fullmatch(item.path) else None
if rel is None:
failed.append(item.path)
continue
dest = (root / rel).resolve()
if dest != root_resolved and root_resolved not in dest.parents:
failed.append(item.path)
continue
try:
data = base64.b64decode(item.content_b64, validate=True)
if used + len(data) > budget:
failed.append(item.path)
continue
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
used += len(data)
except Exception:
failed.append(item.path)
return failed
def _title_of(record: dict[str, Any], values: dict[str, Any] | None = None) -> str:
title = record.get("display_name") or (values or {}).get("title") or ""
return str(title).strip()
def bump_channel_versions(checkpointer: Any, checkpoint: dict[str, Any], keys: list[str]) -> dict[str, Any]:
"""LangGraph stores each channel as a versioned blob.
``aput(..., new_versions={})`` only keeps the in-memory dict on some
savers. Postgres/SQLite ignore unversioned channels, so imported
messages would not survive ``aget`` / ``GET /threads/{id}/state``.
"""
versions = dict(checkpoint.get("channel_versions") or {})
new_versions: dict[str, Any] = {}
get_next_version = getattr(checkpointer, "get_next_version", None)
if not callable(get_next_version):
return dict(versions)
for key in keys:
next_version = get_next_version(versions.get(key), None)
versions[key] = next_version
new_versions[key] = next_version
checkpoint["channel_versions"] = versions
return new_versions
async def write_imported_checkpoint(
checkpointer: Any,
*,
thread_id: str,
values: dict[str, Any],
metadata: dict[str, Any],
) -> None:
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
now = now_iso()
checkpoint = dict(empty_checkpoint())
checkpoint["channel_values"] = dict(values)
new_versions = bump_channel_versions(checkpointer, checkpoint, list(values.keys()))
await checkpointer.aput(
config,
checkpoint,
{
"step": 1,
"source": "update",
"created_at": now,
"updated_at": now,
**metadata,
},
new_versions,
)
written = await checkpointer.aget_tuple(config)
if written is None:
raise RuntimeError("missing checkpoint after import")
restored = serialize_channel_values(
(getattr(written, "checkpoint", {}) or {}).get("channel_values", {}) or {}
)
if values.get("messages") and not restored.get("messages"):
raise RuntimeError("checkpoint messages were not persisted")
def export_job_dir(user_id: str) -> Path:
safe = _SAFE_USER_DIR.sub("_", user_id).strip("._") or "user"
path = get_paths().base_dir / "chat-history-exports" / safe
path.mkdir(parents=True, exist_ok=True)
return path
def get_export_job(job_id: str, user_id: str) -> ExportAllJob:
job = _EXPORT_JOBS.get(job_id)
if job is None or job.user_id != user_id:
raise HTTPException(status_code=404, detail="导出任务不存在")
return job
def find_running_export_job(user_id: str) -> ExportAllJob | None:
for job in _EXPORT_JOBS.values():
if job.user_id == user_id and job.status == "running":
return job
return None
def purge_finished_export_jobs(user_id: str) -> None:
stale = [
job_id
for job_id, job in _EXPORT_JOBS.items()
if job.user_id == user_id and job.status != "running"
]
for job_id in stale:
job = _EXPORT_JOBS.pop(job_id, None)
if job is None or job.path is None:
continue
try:
job.path.unlink(missing_ok=True)
except OSError:
logger.debug("Failed to remove export file %s", job.path)
def job_status(job: ExportAllJob) -> ExportAllStatusResponse:
return ExportAllStatusResponse(
job_id=job.job_id,
status=job.status,
total=job.total,
processed=job.processed,
succeeded=job.succeeded,
skipped=list(job.skipped),
skipped_files=job.skipped_files,
error=job.error,
)
class BundleWriter:
def __init__(self, path: Path) -> None:
self.path = path
self._fh = path.open("w", encoding="utf-8")
self._first = True
self._fh.write(
'{"format":%s,"version":%s,"exported_at":%s,"threads":['
% (
json.dumps(FORMAT, ensure_ascii=False),
FORMAT_VERSION,
json.dumps(now_iso()),
)
)
def add_thread(self, thread: TransferThread) -> None:
if not self._first:
self._fh.write(",")
self._first = False
self._fh.write(thread.model_dump_json())
self._fh.flush()
def finish(self, skipped: list[SkippedThread]) -> None:
self._fh.write('],"skipped":')
self._fh.write(json.dumps([item.model_dump() for item in skipped], ensure_ascii=False))
self._fh.write("}")
self._fh.close()
def abort(self) -> None:
try:
self._fh.close()
except Exception:
pass
try:
self.path.unlink(missing_ok=True)
except OSError:
pass
async def list_exportable_thread_ids(thread_store: Any, user_id: str) -> list[str]:
ids: list[str] = []
offset = 0
page_size = 100
while True:
rows = await thread_store.search(
user_id=user_id,
exclude_system=True,
limit=page_size,
offset=offset,
)
ids.extend(str(row["thread_id"]) for row in rows if row.get("thread_id"))
if len(rows) < page_size:
break
offset += len(rows)
return ids
async def run_export_all_job(
job: ExportAllJob,
*,
thread_store: Any,
checkpointer: Any,
) -> None:
writer: BundleWriter | None = None
try:
thread_ids = await list_exportable_thread_ids(thread_store, job.user_id)
job.total = len(thread_ids)
if not thread_ids:
job.status = "done"
return
dest = export_job_dir(job.user_id) / f"{job.job_id}.json"
writer = BundleWriter(dest)
for thread_id in thread_ids:
try:
payload, skip = await export_one_thread(
thread_id=thread_id,
user_id=job.user_id,
thread_store=thread_store,
checkpointer=checkpointer,
)
except Exception:
logger.exception("Unexpected export failure for %s", sanitize_log_param(thread_id))
payload, skip = None, SkippedThread(thread_id=thread_id, reason="export_failed")
if payload is not None:
await asyncio.to_thread(writer.add_thread, payload)
job.succeeded += 1
job.skipped_files += len(payload.skipped_files)
if skip is not None:
job.skipped.append(skip)
job.processed += 1
await asyncio.to_thread(writer.finish, list(job.skipped))
job.path = dest
job.status = "done"
except Exception:
logger.exception("Background chat-history export failed for %s", sanitize_log_param(job.user_id))
if writer is not None:
writer.abort()
job.status = "error"
job.error = "导出失败"
async def export_one_thread(
*,
thread_id: str,
user_id: str,
thread_store: Any,
checkpointer: Any,
max_bytes: int = MAX_THREAD_BYTES,
) -> tuple[TransferThread | None, SkippedThread | None]:
record = await thread_store.get(thread_id, user_id=user_id)
if record is None:
return None, SkippedThread(thread_id=thread_id, reason="not_found")
if is_excluded_system_thread(record.get("metadata")):
return None, SkippedThread(
thread_id=thread_id,
title=_title_of(record),
reason="system_thread",
)
try:
checkpoint_tuple = await checkpointer.aget_tuple(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
)
except Exception:
logger.exception("Failed to read checkpoint for export %s", sanitize_log_param(thread_id))
return None, SkippedThread(thread_id=thread_id, title=_title_of(record), reason="export_failed")
values: dict[str, Any] = {}
if checkpoint_tuple is not None:
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
try:
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
except Exception:
logger.exception("Failed to serialize state for export %s", sanitize_log_param(thread_id))
return None, SkippedThread(thread_id=thread_id, title=_title_of(record), reason="export_failed")
title = _title_of(record, values)
if title and not values.get("title"):
values["title"] = title
used = payload_size(values)
if used > max_bytes:
return None, SkippedThread(thread_id=thread_id, title=title, reason="too_large")
user_data = get_paths().sandbox_user_data_dir(thread_id, user_id=user_id)
files, skipped_files = await asyncio.to_thread(
collect_thread_files, user_data, budget=max_bytes - used
)
payload = TransferThread(
thread_id=thread_id,
title=title,
assistant_id=record.get("assistant_id"),
metadata=clean_export_metadata(record.get("metadata") or {}),
created_at=coerce_iso(record.get("created_at", "")),
updated_at=coerce_iso(record.get("updated_at", "")),
values=values,
files=[TransferFile.model_validate(item) for item in files],
skipped_files=list(skipped_files),
)
while payload.files and payload_size(payload.model_dump()) > max_bytes:
largest = max(payload.files, key=lambda item: (item.size, item.path))
payload.files.remove(largest)
payload.skipped_files.append(largest.path)
if payload_size(payload.model_dump()) > max_bytes:
return None, SkippedThread(thread_id=thread_id, title=title, reason="too_large")
return payload, None
async def import_one_thread(
*,
item: TransferThread,
user_id: str,
thread_store: Any,
checkpointer: Any,
) -> SkippedThread | None:
thread_id = item.thread_id.strip()
if not thread_id or not re.fullmatch(r"[A-Za-z0-9_-]+", thread_id):
return SkippedThread(thread_id=item.thread_id or "unknown", title=item.title, reason="invalid_id")
existing = await thread_store.get(thread_id, user_id=user_id)
if existing is not None:
return SkippedThread(thread_id=thread_id, title=item.title or _title_of(existing), reason="exists")
metadata = clean_export_metadata(item.metadata)
title = (item.title or str(item.values.get("title") or "")).strip()
values = dict(item.values or {})
if title:
values["title"] = title
created = False
try:
await thread_store.create(
thread_id,
assistant_id=item.assistant_id,
user_id=user_id,
display_name=title or None,
metadata=metadata,
)
created = True
await write_imported_checkpoint(
checkpointer,
thread_id=thread_id,
values=values,
metadata=metadata,
)
if title:
try:
await thread_store.update_display_name(thread_id, title, user_id=user_id)
except Exception:
logger.debug("Failed to sync imported title for %s", sanitize_log_param(thread_id))
if item.files:
dest = get_paths().sandbox_user_data_dir(thread_id, user_id=user_id)
failed_files = await asyncio.to_thread(write_thread_files, dest, item.files)
if failed_files:
logger.warning(
"Imported thread %s with %d skipped files",
sanitize_log_param(thread_id),
len(failed_files),
)
except Exception:
logger.exception("Failed to import thread %s", sanitize_log_param(thread_id))
if created:
try:
await thread_store.delete(thread_id, user_id=user_id)
except Exception:
logger.debug("Failed to roll back imported thread %s", sanitize_log_param(thread_id))
return SkippedThread(thread_id=thread_id, title=title, reason="import_failed")
return None
@router.post("/export", response_model=ExportResponse, summary="Export a batch of conversations")
@require_permission("threads", "read")
async def export_chat_history(body: ExportRequest, request: Request) -> ExportResponse:
require_transfer_enabled()
user_id = current_user_id(request)
thread_store = get_thread_store(request)
checkpointer = get_checkpointer(request)
exported: list[TransferThread] = []
skipped: list[SkippedThread] = []
for thread_id in body.thread_ids:
try:
payload, skip = await export_one_thread(
thread_id=thread_id,
user_id=user_id,
thread_store=thread_store,
checkpointer=checkpointer,
)
except Exception:
logger.exception("Unexpected export failure for %s", sanitize_log_param(thread_id))
skipped.append(SkippedThread(thread_id=thread_id, reason="export_failed"))
continue
if payload is not None:
exported.append(payload)
if skip is not None:
skipped.append(skip)
return ExportResponse(threads=exported, skipped=skipped)
@router.post("/import", response_model=ImportResponse, summary="Import a batch of conversations")
@require_permission("threads", "write")
async def import_chat_history(body: ImportRequest, request: Request) -> ImportResponse:
require_transfer_enabled()
user_id = current_user_id(request)
thread_store = get_thread_store(request)
checkpointer = get_checkpointer(request)
imported = 0
skipped: list[SkippedThread] = []
for item in body.threads:
try:
skip = await import_one_thread(
item=item,
user_id=user_id,
thread_store=thread_store,
checkpointer=checkpointer,
)
except Exception:
logger.exception("Unexpected import failure for %s", sanitize_log_param(item.thread_id))
skipped.append(SkippedThread(thread_id=item.thread_id, title=item.title, reason="import_failed"))
continue
if skip is None:
imported += 1
else:
skipped.append(skip)
return ImportResponse(imported=imported, skipped=skipped)
@router.post("/export-all", response_model=ExportAllStartResponse, summary="Start a backend export of all conversations")
@require_permission("threads", "read")
async def start_export_all(request: Request) -> ExportAllStartResponse:
require_transfer_enabled()
user_id = current_user_id(request)
running = find_running_export_job(user_id)
if running is not None:
return ExportAllStartResponse(job_id=running.job_id)
purge_finished_export_jobs(user_id)
job = ExportAllJob(job_id=uuid.uuid4().hex, user_id=user_id)
_EXPORT_JOBS[job.job_id] = job
job.task = asyncio.create_task(
run_export_all_job(
job,
thread_store=get_thread_store(request),
checkpointer=get_checkpointer(request),
)
)
return ExportAllStartResponse(job_id=job.job_id)
@router.get("/export-all/{job_id}", response_model=ExportAllStatusResponse, summary="Poll a backend export job")
@require_permission("threads", "read")
async def get_export_all_status(job_id: str, request: Request) -> ExportAllStatusResponse:
require_transfer_enabled()
return job_status(get_export_job(job_id, current_user_id(request)))
@router.get("/export-all/{job_id}/download", summary="Download a finished backend export")
@require_permission("threads", "read")
async def download_export_all(job_id: str, request: Request) -> FileResponse:
require_transfer_enabled()
job = get_export_job(job_id, current_user_id(request))
if job.status != "done" or job.path is None or not job.path.is_file():
raise HTTPException(status_code=409, detail="导出文件还没准备好")
return FileResponse(
job.path,
media_type="application/json",
filename="deerflow-chats.json",
)
@@ -10,6 +10,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
from deerflow.config.system_settings import generated_page_logo_path, load_system_settings
from deerflow.html_runtime.addresses import html_map_tile_url, html_runtime_base_url
router = APIRouter(prefix="/api/public/html-runtime", tags=["html-runtime"])
@@ -28,6 +29,12 @@ _STATIC_ASSETS = {
"g6.min.js": "application/javascript",
"leaflet.js": "application/javascript",
"leaflet.css": "text/css",
# 生成页品牌页头背景设计图(浅色红调 / 深色深蓝),随明暗 tone 由页头样式切换。
"topbar-light.svg": "image/svg+xml",
"topbar-dark.svg": "image/svg+xml",
# Packaged fallback used when an administrator-uploaded generated-page
# logo cannot be fetched through a deployment's reverse-proxy path.
"brand-logo-dark.png": "image/png",
}
_GEO_MAP_ASSETS = {
"china-provinces.geojson": "application/geo+json",
@@ -94,6 +101,26 @@ async def get_html_runtime_geo_map(filename: str) -> FileResponse:
)
@router.get("/generated-page-logo")
async def get_generated_page_logo() -> FileResponse:
"""Serve the configured logo, falling back to the packaged default."""
branding = load_system_settings().generated_page_branding
path = generated_page_logo_path(branding)
if path is None:
path = _RUNTIME_DIR / "brand-logo-dark.png"
media_type = "image/png"
else:
media_type = branding.logo_media_type or "application/octet-stream"
if not path.is_file():
raise HTTPException(status_code=503, detail="Generated-page fallback logo is not installed")
return FileResponse(
path,
media_type=media_type,
headers={**_PUBLIC_HEADERS, "Cache-Control": "public, max-age=3600"},
)
@router.get("/{filename}")
async def get_html_runtime_asset(filename: str) -> FileResponse:
"""Serve a whitelisted, vendored browser distribution file."""
@@ -76,6 +76,17 @@ class LightAppResponse(BaseModel):
class LightAppListResponse(BaseModel):
apps: list[LightAppResponse]
class LightAppSidebarItem(BaseModel):
app_id: str
name: str
mount_location: str | None = None
sort_order: int = 0
class LightAppSidebarResponse(BaseModel):
apps: list[LightAppSidebarItem]
class LightAppSidebarItem(BaseModel):
@@ -209,6 +220,13 @@ async def list_sidebar_light_apps(request: Request) -> LightAppSidebarResponse:
return await cached_catalog(request, "light_apps", "sidebar", load, LightAppSidebarResponse)
@router.get("/sidebar", response_model=LightAppSidebarResponse)
async def list_sidebar_light_apps(request: Request) -> LightAppSidebarResponse:
"""Return the small set of fields required to build the shared sidebar."""
rows = await _get_store(request).list_sidebar_apps()
return LightAppSidebarResponse(apps=[LightAppSidebarItem(**row) for row in rows])
@router.post("", response_model=LightAppResponse)
async def create_light_app(request: Request, body: LightAppCreateRequest) -> LightAppResponse:
await _require_admin(request)
@@ -93,6 +93,14 @@ _STEP3_AGENT_IDS = frozenset({
SUMMARY_AGENT_ID,
})
_STEP3_AGENT_IDS = frozenset({
ACTION_PLAN_AGENT_ID,
DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
STRUCTURE_AGENT_ID,
SUMMARY_AGENT_ID,
})
def _stage_for_agent(agent_type: str, agent_name: str) -> str:
"""把一次 /run/stream 的 (agent_type, agent_name) 映射成诊断日志的 stage。
@@ -131,12 +139,45 @@ def _is_step2_room_limited(
return agent_type == "leader" or agent_name not in _STEP3_AGENT_IDS
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
def _room_thread_metadata(
*,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
room_session_id: str | None,
) -> dict[str, Any]:
"""Step-2 threads carry a stable room key so later occupancy can see live runs."""
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.persistence.roundtable_rooms.sql import compute_room_session_key
meta = dict(_ROUNDTABLE_THREAD_METADATA)
clean_session_id = (room_session_id or "").strip() or None
key = compute_room_session_key(clean_session_id) or compute_active_dedupe_key(
draft_id, user_id, task_id
)
if key:
meta["roundtable_room_key"] = key
if clean_session_id:
meta["roundtable_room_session_id"] = clean_session_id
if draft_id:
meta["draft_id"] = draft_id
if task_id:
meta["task_id"] = task_id
return meta
def _step3_thread_metadata() -> dict[str, Any]:
"""Step-3 threads are internal threads, but never Step-2 room occupants."""
return dict(_ROUNDTABLE_THREAD_METADATA)
def _room_thread_metadata(
*,
draft_id: str | None,
@@ -905,6 +946,26 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
room_session_id=room_session_id,
)
# The browser already acquired the room before entering Step 2. Init only
# renews that lease; it cannot create a second room or bypass admission.
from app.gateway.roundtable_room_gate import enforce_existing_roundtable_room
room_draft_id = str(payload.get("draft_id") or "").strip() or None
room_task_id = str(payload.get("task_id") or "").strip() or None
room_session_id = str(payload.get("room_session_id") or "").strip() or None
await enforce_existing_roundtable_room(
request,
draft_id=room_draft_id,
task_id=room_task_id,
room_session_id=room_session_id,
)
room_thread_meta = _room_thread_metadata(
draft_id=room_draft_id,
user_id=_resolve_user_id(request),
task_id=room_task_id,
room_session_id=room_session_id,
)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -153,6 +153,16 @@ async def _refresh_catalog_snapshot(config: AppConfig) -> None:
logger.exception("Skill catalog projection refresh failed after a mutation")
async def _refresh_catalog_snapshot(config: AppConfig) -> None:
"""Keep normalized MySQL skill rows current after a skill edit."""
try:
await refresh_skill_catalog_projection(config)
except Exception:
# A failed projection refresh must not turn a successful skill write
# into an API failure. Startup or the next edit can recover it.
logger.exception("Skill catalog projection refresh failed after a mutation")
def _can_view_skill_source(
category: SkillCategory,
owner_user_id: str | None,
@@ -513,6 +523,69 @@ _SKILL_DISPLAY_TABLE_LOCK = asyncio.Lock()
_skill_display_table_engine: object | None = None
def _skill_display_table_already_exists(exc: BaseException) -> bool:
message = str(exc).lower()
return any(
token in message
for token in (
"already exists",
"duplicate table",
"42p07",
"error 1050",
"(1050",
)
)
def _catalog_item_to_response(
item: dict[str, Any],
ownership: dict[str, Any] | None,
*,
adapter: dict[str, Any] | None,
favorited: bool,
) -> SkillResponse:
"""Build a list response from the MySQL snapshot and MySQL metadata rows."""
try:
category = SkillCategory(str(item.get("category") or SkillCategory.PUBLIC.value))
except ValueError:
category = SkillCategory.PUBLIC
owner_user_id = ownership.get("owner_user_id") if ownership else None
return SkillResponse(
name=str(item.get("name") or ""),
name_zh=ownership.get("name_zh") if ownership else None,
description=str(item.get("description") or ""),
license=item.get("license"),
category=category,
enabled=bool(item.get("enabled", True)),
always_on=bool(ownership.get("always_on", item.get("always_on", False)) if ownership else item.get("always_on", False)),
use_count=int(item.get("use_count") or 0),
view_count=int(item.get("view_count") or 0),
patch_count=int(item.get("patch_count") or 0),
last_activity_at=item.get("last_activity_at"),
created_at=item.get("created_at"),
state=str(item.get("state") or "active"),
pinned=bool(item.get("pinned")),
featured=bool(item.get("featured")),
source=str(item.get("source") or "upload"),
owner_user_id=owner_user_id,
published=bool(ownership.get("published", False)) if ownership else False,
square_id=str(ownership.get("square_id") or "") if ownership else "",
builtin=bool(ownership.get("builtin", owner_user_id is None)) if ownership else category != SkillCategory.CUSTOM,
favorited=favorited,
detail=(ownership.get("detail_preview") or ownership.get("detail")) if ownership else None,
display=adapter.get("display") if adapter else None,
displaySample=adapter.get("sample") if adapter else None,
)
# ``checkfirst=True`` is a check-then-create operation. The in-process lock
# serializes concurrent requests in one worker; the duplicate-table fallback
# covers two Gateway workers racing during first access.
_SKILL_DISPLAY_TABLE_LOCK = asyncio.Lock()
_skill_display_table_engine: object | None = None
def _skill_display_table_already_exists(exc: BaseException) -> bool:
message = str(exc).lower()
return any(
@@ -8,19 +8,22 @@ from typing import Literal
from urllib.parse import urlparse
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_current_user_from_request, get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.runtime_paths import runtime_home
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AgentSkillMigrationSettings,
AppearanceRouteParam,
AuthenticationSettings,
CompactShortcutButton,
ConversationContextSettings,
CustomThemePreset,
GeneratedPageBrandingSettings,
HtmlMapSettings,
LeaderboardSettings,
ModelConcurrencySettings,
@@ -36,6 +39,8 @@ from deerflow.config.system_settings import (
StrategyProjectSettings,
WordHeadingNumberingStyle,
WriteFileCompatibilitySettings,
generated_page_logo_path,
generated_page_logo_public_url,
get_module_tag_ids,
list_publish_squares,
load_system_settings,
@@ -313,11 +318,15 @@ class HtmlMapSettingsUpdateRequest(BaseModel):
class HtmlRuntimeSettingsResponse(BaseModel):
base_url: str
map_tile_url: str
message_map_tile_min_zoom: int
message_map_tile_max_zoom: int
class HtmlRuntimeSettingsUpdateRequest(BaseModel):
base_url: str
map_tile_url: str
message_map_tile_min_zoom: int | None = Field(default=None, ge=0, le=20)
message_map_tile_max_zoom: int | None = Field(default=None, ge=0, le=20)
class LeaderboardSettingsResponse(BaseModel):
@@ -518,6 +527,17 @@ class MemoryRuntimeUpdateRequest(BaseModel):
enabled: bool
class AgentSkillMigrationResponse(BaseModel):
enabled: bool = Field(
default=False,
description="Whether administrator agent/skill migration is enabled deployment-wide",
)
class AgentSkillMigrationUpdateRequest(BaseModel):
enabled: bool
class WriteFileCompatibilityResponse(BaseModel):
enabled: bool = Field(
default=False,
@@ -617,6 +637,7 @@ def _knowledge_retrieval_settings_store(request: Request):
return store
@router.get("/memory", response_model=MemoryRuntimeResponse)
async def get_memory_runtime_setting() -> MemoryRuntimeResponse:
return MemoryRuntimeResponse(enabled=load_system_settings().memory.enabled)
@@ -635,6 +656,28 @@ async def update_memory_runtime_setting(
return MemoryRuntimeResponse(enabled=saved.memory.enabled)
@router.get("/agent-skill-migration", response_model=AgentSkillMigrationResponse)
async def get_agent_skill_migration_setting() -> AgentSkillMigrationResponse:
return AgentSkillMigrationResponse(
enabled=load_system_settings().agent_skill_migration.enabled,
)
@router.put("/agent-skill-migration", response_model=AgentSkillMigrationResponse)
async def update_agent_skill_migration_setting(
body: AgentSkillMigrationUpdateRequest,
request: Request,
) -> AgentSkillMigrationResponse:
await _require_admin(request)
current = load_system_settings()
updated = AgentSkillMigrationSettings(enabled=body.enabled)
saved = save_system_settings(
current.model_copy(update={"agent_skill_migration": updated})
)
logger.info("Agent/skill migration switch changed: enabled=%s", saved.agent_skill_migration.enabled)
return AgentSkillMigrationResponse(enabled=saved.agent_skill_migration.enabled)
@router.get(
"/write-file-compatibility",
response_model=WriteFileCompatibilityResponse,
@@ -837,6 +880,7 @@ async def update_ordinary_knowledge_retrieval_setting(
)
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
return SkillCompressionResponse(
enabled=settings.enabled,
@@ -1073,10 +1117,13 @@ async def update_html_map_settings(
@router.get("/html-runtime", response_model=HtmlRuntimeSettingsResponse)
async def get_html_runtime_settings() -> HtmlRuntimeSettingsResponse:
"""Return the addresses newly generated HTML pages will use."""
"""Return generated-page addresses and message-map native tile limits."""
settings = load_system_settings().html_runtime
return HtmlRuntimeSettingsResponse(
base_url=html_runtime_base_url(),
map_tile_url=html_map_tile_url(),
message_map_tile_min_zoom=settings.message_map_tile_min_zoom,
message_map_tile_max_zoom=settings.message_map_tile_max_zoom,
)
@@ -1085,7 +1132,7 @@ async def update_html_runtime_settings(
body: HtmlRuntimeSettingsUpdateRequest,
request: Request,
) -> HtmlRuntimeSettingsResponse:
"""Save the browser-reachable runtime and map-tile addresses. Admin only."""
"""Save generated-page addresses and message-map native tile limits. Admin only."""
await _require_admin(request)
try:
base_url = normalize_html_runtime_base_url(body.base_url)
@@ -1093,13 +1140,32 @@ async def update_html_runtime_settings(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
current = load_system_settings()
min_zoom = (
body.message_map_tile_min_zoom
if body.message_map_tile_min_zoom is not None
else current.html_runtime.message_map_tile_min_zoom
)
max_zoom = (
body.message_map_tile_max_zoom
if body.message_map_tile_max_zoom is not None
else current.html_runtime.message_map_tile_max_zoom
)
if min_zoom > max_zoom:
raise HTTPException(status_code=400, detail="消息列表瓦片最小层级不能大于最大层级")
updated = current.html_runtime.model_copy(
update={"base_url": base_url, "map_tile_url": map_tile_url}
update={
"base_url": base_url,
"map_tile_url": map_tile_url,
"message_map_tile_min_zoom": min_zoom,
"message_map_tile_max_zoom": max_zoom,
}
)
saved = save_system_settings(current.model_copy(update={"html_runtime": updated}))
return HtmlRuntimeSettingsResponse(
base_url=saved.html_runtime.base_url,
map_tile_url=saved.html_runtime.map_tile_url,
message_map_tile_min_zoom=saved.html_runtime.message_map_tile_min_zoom,
message_map_tile_max_zoom=saved.html_runtime.message_map_tile_max_zoom,
)
@@ -1422,6 +1488,230 @@ async def update_appearance_settings(body: AppearanceUpdateRequest, request: Req
return _appearance_response(saved.appearance)
class GeneratedPageBrandingResponse(BaseModel):
"""Generated-page identity: name, logo display mode and optional custom logo."""
chrome_enabled: bool = True
header_layout: str = "center"
system_name: str | None = None
footer_text: str | None = None
logo_mode: Literal["custom", "default", "none"] = "custom"
logo_url: str | None = None
logo_version: str | None = None
customized: bool = False
active: bool = False
_MAX_BRAND_LOGO_BYTES = 10 * 1024 * 1024
def _detect_brand_logo(data: bytes) -> tuple[str, str]:
"""Return a safe extension/media type from file signatures, never the filename."""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png", "image/png"
if data.startswith(b"\xff\xd8\xff"):
return ".jpg", "image/jpeg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp", "image/webp"
raise HTTPException(status_code=422, detail="Logo 仅支持 PNG、JPG 或 WebP 图片")
def _normalize_system_name(value: str | None) -> str | None:
if value is None:
return None
normalized = " ".join(value.split())
if len(normalized) > 80:
raise HTTPException(status_code=422, detail="系统名称不能超过 80 个字符")
return normalized or None
def _normalize_footer_text(value: str | None) -> str | None:
if value is None:
return None
normalized = " ".join(value.split())
if len(normalized) > 160:
raise HTTPException(status_code=422, detail="网页页脚文字不能超过 160 个字符")
return normalized or None
def _generated_page_branding_response(
settings: GeneratedPageBrandingSettings,
) -> GeneratedPageBrandingResponse:
defaults = GeneratedPageBrandingSettings()
logo_url = generated_page_logo_public_url("/api/public/html-runtime", settings)
return GeneratedPageBrandingResponse(
chrome_enabled=settings.chrome_enabled,
header_layout=settings.header_layout,
system_name=settings.system_name,
footer_text=settings.footer_text,
logo_mode=settings.logo_mode,
logo_url=logo_url,
logo_version=settings.logo_version if logo_url else None,
customized=bool(
settings.system_name
or settings.footer_text
or logo_url
or settings.logo_mode != defaults.logo_mode
or settings.chrome_enabled != defaults.chrome_enabled
or settings.header_layout != defaults.header_layout
),
active=bool(
settings.system_name
and settings.chrome_enabled
and (settings.logo_mode == "none" or logo_url)
),
)
def _write_generated_page_logo(data: bytes, suffix: str) -> str:
directory = runtime_home() / "generated-page-branding"
directory.mkdir(parents=True, exist_ok=True)
filename = f"logo-{uuid4().hex}{suffix}"
destination = directory / filename
temporary = directory / f".{filename}.tmp"
try:
temporary.write_bytes(data)
temporary.replace(destination)
finally:
temporary.unlink(missing_ok=True)
return filename
@router.get("/generated-page-branding", response_model=GeneratedPageBrandingResponse)
async def get_generated_page_branding_settings() -> GeneratedPageBrandingResponse:
"""Return the optional generated-page identity. Any logged-in user may read it."""
return _generated_page_branding_response(load_system_settings().generated_page_branding)
@router.put("/generated-page-branding", response_model=GeneratedPageBrandingResponse)
async def update_generated_page_branding_settings(
request: Request,
chrome_enabled: bool | None = Form(default=None),
header_layout: str | None = Form(default=None),
system_name: str | None = Form(default=None),
logo_mode: Literal["custom", "default", "none"] | None = Form(default=None),
# Plain str (not Optional): FastAPI coerces an empty Optional form value to
# None, which would make "clear the footer text" indistinguishable from
# "field omitted". The settings UI always sends this field, so an empty
# value here means the admin cleared the input and wants the default footer.
footer_text: str = Form(default=""),
remove_logo: bool = Form(default=False),
logo: UploadFile | None = File(default=None),
) -> GeneratedPageBrandingResponse:
"""Update the identity used in generated pages and the auto-injection switch. Admin only."""
await _require_admin(request)
if logo is not None and (remove_logo or logo_mode in {"default", "none"}):
raise HTTPException(status_code=422, detail="上传 Logo 时不能同时选择默认头像或不显示头像")
if remove_logo and logo_mode not in {None, "none"}:
raise HTTPException(status_code=422, detail="移除 Logo 时只能选择不显示头像")
current = load_system_settings()
previous_logo = generated_page_logo_path(current.generated_page_branding)
update: dict[str, object] = {}
if chrome_enabled is not None:
update["chrome_enabled"] = bool(chrome_enabled)
if header_layout is not None:
normalized_layout = str(header_layout).strip().lower()
if normalized_layout not in {"left", "center"}:
raise HTTPException(status_code=422, detail="header_layout 仅支持 left 或 center")
update["header_layout"] = normalized_layout
if system_name is not None:
update["system_name"] = _normalize_system_name(system_name)
update["footer_text"] = _normalize_footer_text(footer_text)
new_logo_path = None
if logo is not None:
data = await logo.read(_MAX_BRAND_LOGO_BYTES + 1)
await logo.close()
if not data:
raise HTTPException(status_code=422, detail="Logo 文件不能为空")
if len(data) > _MAX_BRAND_LOGO_BYTES:
raise HTTPException(status_code=413, detail="Logo 文件不能超过 10MB")
suffix, media_type = _detect_brand_logo(data)
filename = await asyncio.to_thread(_write_generated_page_logo, data, suffix)
new_logo_path = runtime_home() / "generated-page-branding" / filename
update.update(
{
"logo_mode": "custom",
"logo_filename": filename,
"logo_media_type": media_type,
"logo_version": uuid4().hex,
}
)
elif logo_mode == "none":
update.update(
{
"logo_mode": "none",
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
elif remove_logo:
# Preserve the legacy API behavior: an older client that only sends
# remove_logo leaves branding as an incomplete custom-logo draft.
# The new text-only header is an explicit logo_mode="none" choice.
update.update(
{
"logo_mode": "custom",
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
elif logo_mode == "default":
update.update(
{
"logo_mode": "default",
"logo_filename": None,
"logo_media_type": None,
"logo_version": "default",
}
)
elif logo_mode == "custom":
update["logo_mode"] = "custom"
if current.generated_page_branding.logo_mode != "custom":
update.update(
{
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
if not update:
return _generated_page_branding_response(current.generated_page_branding)
updated = current.generated_page_branding.model_copy(update=update)
try:
saved = save_system_settings(current.model_copy(update={"generated_page_branding": updated}))
except Exception:
if new_logo_path is not None:
await asyncio.to_thread(new_logo_path.unlink, missing_ok=True)
raise
if previous_logo is not None and previous_logo != generated_page_logo_path(saved.generated_page_branding):
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
return _generated_page_branding_response(saved.generated_page_branding)
@router.delete("/generated-page-branding", response_model=GeneratedPageBrandingResponse)
async def delete_generated_page_branding_settings(
request: Request,
) -> GeneratedPageBrandingResponse:
"""Remove generated-page identity requirements; the app UI is unaffected."""
await _require_admin(request)
current = load_system_settings()
previous_logo = generated_page_logo_path(current.generated_page_branding)
saved = save_system_settings(current.model_copy(update={"generated_page_branding": GeneratedPageBrandingSettings()}))
if previous_logo is not None:
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
return _generated_page_branding_response(saved.generated_page_branding)
class LogSystemResponse(BaseModel):
"""External log-system entry shown in the admin settings menu.
@@ -1575,3 +1865,4 @@ async def update_wx_common_agents(
updated = current.wx_common_agents.model_copy(update={"agent_type": agent_type})
saved = save_system_settings(current.model_copy(update={"wx_common_agents": updated}))
return WxCommonAgentsResponse(agent_type=saved.wx_common_agents.agent_type)
@@ -546,8 +546,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
# 页面研报设计师由前端随消息带入当前界面明暗模式;需要进入 agent
# 提示词及 ToolRuntime.context,才能选择同色系模板。
# 前端随消息只带入当前界面的明暗模式;需要进入 agent 提示词及
# ToolRuntime.context,才能选择对应的浅色或深色页面模板。
"presentation_theme",
"is_bootstrap",
"is_writing_setup",
+6 -5
View File
@@ -526,17 +526,17 @@ auth_login:
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
# 本地开发使用 SQLite;切回远程 MySQL 时改 backend: mysql 并配置 .env.local。
# LangGraph 对话检查点仍由下方 checkpointer 保存到本机 SQLite。
# 单节点本地部署:业务表与 LangGraph 检查点均使用 SQLite。
backend: sqlite
# mysql_url: $MYSQL_DATABASE_URL
sqlite_dir: .deer-flow/data
# mysql_url: $MYSQL_DATABASE_URL
# 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss):
# backend: gauss
# gauss_url: $GAUSS_DATABASE_URL # opengauss+asyncpg://user:pass@host:26000/deerflow
# checkpointer 请继续使用下方 sqlite;不要把 PostgreSQL 专用 checkpointer 迁移直接用于高斯。
pool_size: 20 # 基础连接池(postgres/gauss/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
# Dolphin(MySQL 协议)模式保持 backend: mysql,并由部署层设置 DEERFLOW_DATABASE_PRODUCT=gauss_mysql。
pool_size: 5 # 本地测试降低连接池规模,减少资源占用
max_overflow: 2 # 本地测试只允许少量突发连接
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
echo_sql: false
@@ -544,6 +544,7 @@ database:
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
use_postgres: false
# 记忆子系统参数。真正的问答总开关由管理员「设置 → 问答记忆」控制,
# system_settings.json 没有显式开启时始终关闭;这里的 enabled 不会绕过管理员总开关。
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more