Compare commits

..
2 Commits
Author SHA1 Message Date
gozeros b638b541d0 初始化 2026-09-07 18:24:55 +08:00
wsk 34474bd5fc Initial commit 2026-09-07 17:37:11 +08:00
336 changed files with 532 additions and 30184 deletions

No files matched your search

-191
View File
@@ -1,191 +0,0 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml
frontend-web/README.md
frontend-web/doc.md
frontend-web/src.zip
frontend-web/src9.zip
frontend-web/src-v1.zip
frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
offline-backend-20260505.zip
offline-backend-20260505/output/
offline-backend-20260505/bundle/.env
offline-backend-20260505/bundle/.env.example
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260505/bundle/SHA256SUMS
offline-backend-20260505/bundle/config.yaml
offline-backend-20260505/bundle/config.yaml.example
offline-backend-20260505/bundle/data/
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
offline-backend-20260505/bundle/extensions_config.json
offline-backend-20260505/bundle/frontend-vite.zip
offline-backend-20260505/bundle/skills/
offline-backend-20260505/bundle/start_backend.0509.sh
offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# 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_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
# Roundtable coordinator/seat capability isolation regressions.
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
!offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
# Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
# Report collaboration (RC-BE-000–018) regression coverage.
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
deploy
.history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
# Python bytecode cache
__pycache__/
# misc
.DS_Store
+1 -1
View File
@@ -96,7 +96,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。 - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+3
View File
@@ -0,0 +1,3 @@
# deerflow-code
deerflow-code
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
@@ -1,186 +0,0 @@
# 报告协作工作台 — 交互重设计方案(v2)
> 日期:2026-09-07
> 状态:**待确认**(确认后按 §7 分期实施)
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
---
## 1. 设计目标与总体形态
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
```
┌─────────────────────────────────────────────────────────┐
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
│ └───────────┘ └───────────┘ └──────────┘ │
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
└─────────────────────────────────────────────────────────┘
```
与现行的核心差异:
| 维度 | 现行(做偏的) | v2 |
|------|----------------|-----|
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
---
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
### 2.1 计划模式状态
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
### 2.2 规划师对话(LLM,替换现行规则路由)
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
### 2.3 需求摘要卡
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
### 2.4 工作流生成与迭代
- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
---
## 3. 阶段 2:执行 — 过程在流程图上可见
### 3.1 流程图动画规格(执行状态的可视化主载体)
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
| 状态 | 节点样式 | 连入边 |
|------|----------|--------|
| planned / queued | 灰色常态 | 静态 |
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
| failed | 红色 + 错误角标 | 红色虚线 |
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
| interrupted / superseded | 半透明降级 | 灰 |
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
### 3.2 聊天区(保留现有 Tab 切换)
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
---
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
### 4.1 输入框常开,协调者智能体分流
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
| 用户意图 | 协调者行为 | 生效时机 |
|----------|-----------|----------|
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
| 停止 | 直接取消 | 立即 |
### 4.2 指定智能体重跑(新增核心交互)
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
两个入口,同一后端动作:
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
**级联规则**(复用并补全 ledger 的 targeted rework):
```
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
→ 受影响章节重新生成
```
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
---
## 5. 阶段 4:报告成稿与对话式修改
### 5.1 成稿
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
### 5.2 修改意图分流(协调者 LLM 判定)
| 用户说 | 判定 | 动作 |
|--------|------|------|
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
---
## 6. 工程改造清单
### 6.1 保留(本次联调已验证)
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
- 「清空历史」「生成候选方案」等本次新增入口。
### 6.2 后端重做 / 新建
| 项 | 说明 |
|----|------|
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
### 6.3 前端重做 / 新建
| 项 | 说明 |
|----|------|
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
---
## 7. 实施分期
| 期 | 内容 | 验收 |
|----|------|------|
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
+5 -8
View File
@@ -109,8 +109,8 @@ export interface TaskButton {
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */ /** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string; idParam: string;
/** /**
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 * ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。 * 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/ */
idKind: TaskButtonIdKind; idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */ /** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
@@ -327,8 +327,7 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮: * 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效; * - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
* 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。
*/ */
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> { export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m); const fail = (m: string) => ctx.onError?.(m);
@@ -356,11 +355,9 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
} }
const queryParams: Record<string, number> = {}; const queryParams: Record<string, number> = {};
if (btn.appendTaskId) { if (btn.appendTaskId) {
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。 const id = parseHostTaskId(ctx.taskId);
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const id = parseHostTaskId(raw);
if (id === null) { if (id === null) {
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`); fail("任务id不是数字,无法跳转宿主系统");
return; return;
} }
queryParams[(btn.idParam || "id").trim() || "id"] = id; queryParams[(btn.idParam || "id").trim() || "id"] = id;
-637
View File
@@ -1,637 +0,0 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams {
search?: string;
tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */
square?: string;
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
scope?: "mine";
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square);
if (params.scope) qs.set("scope", params.scope);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
export async function loadAllSkillsAdmin() {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills?admin=true`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skills");
}
const json = await response.json();
return json.skills as Skill[];
}
export async function getSkill(skillName: string): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill");
}
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled,
}),
},
);
return response.json();
}
export async function setSkillAlwaysOn(
skillName: string,
alwaysOn: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ always_on: alwaysOn }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
}
return response.json() as Promise<Skill>;
}
export async function setSkillDisplay(
skillName: string,
display: SkillDisplayConfig | null,
sample?: Record<string, unknown> | null,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ display, sample }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill display adapter");
}
return response.json() as Promise<Skill>;
}
export interface SkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown>;
}
export interface PersistedSkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown> | null;
}
export async function getPersistedSkillDisplaySample(
skillName: string,
): Promise<PersistedSkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
}
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
}
export async function getSkillDisplaySample(
skillName: string,
): Promise<SkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get skill display sample");
}
return response.json() as Promise<SkillDisplaySampleResponse>;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
skill_name?: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface PreviewSkillInstallRequest {
thread_id: string;
path: string;
}
export interface PreviewSkillInstallResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
can_install_as_is: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
conflict: boolean;
conflict_kind: "public" | "custom" | null;
can_install_as_is: boolean;
can_overwrite: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
// Handle HTTP error responses (4xx, 5xx)
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
return {
success: false,
skill_name: "",
message: errorMessage,
};
}
return response.json();
}
export async function previewSkillInstall(
request: PreviewSkillInstallRequest,
): Promise<PreviewSkillInstallResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw await parseDetailedError(response, "Failed to preview skill install");
}
return response.json() as Promise<PreviewSkillInstallResponse>;
}
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
const data = await response.json().catch(() => ({}));
const detail = (data as { detail?: unknown }).detail;
const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
export async function validateSkillUpload(
file: File,
targetCategory: "public" | "custom" = "custom",
): Promise<ValidateSkillUploadResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams({ target_category: targetCategory });
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/validate?${params.toString()}`,
{
method: "POST",
body: form,
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Validation failed");
}
return response.json() as Promise<ValidateSkillUploadResponse>;
}
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
);
if (!response.ok) {
throw await parseDetailedError(response, "Install failed");
}
return response.json() as Promise<InstallSkillResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
): Promise<InstallSkillResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to duplicate skill");
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
}
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to reconcile skills directory");
}
return response.json() as Promise<ReconcileCustomSkillsResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
square_id?: string;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
squareId?: string,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
squareId != null ? { published, square_id: squareId } : { published },
),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update published state");
}
return response.json() as Promise<SetSkillPublishedResponse>;
}
export async function setSkillState(
skillName: string,
state: SkillState,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillPinned(
skillName: string,
pinned: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill pinned state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillFeatured(
skillName: string,
featured: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featured }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill featured state");
}
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse {
name: string;
category: string;
content: string;
}
export async function getSkillContent(
skillName: string,
): Promise<SkillContentResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill content");
}
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
export interface SkillFileNode {
name: string;
type: "file" | "dir";
path: string;
size?: number | null;
mtime?: number | null;
children?: SkillFileNode[] | null;
}
export interface SkillFilesResponse {
name: string;
category: "public" | "custom";
editable: boolean;
root: SkillFileNode;
}
export interface SkillFileContentResponse {
name: string;
category: "public" | "custom";
path: string;
mime: string;
size: number;
mtime: number;
is_text: boolean;
editable: boolean;
content?: string | null;
}
export interface SkillFileSaveResponse {
name: string;
path: string;
size: number;
mtime: number;
}
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill files");
}
return response.json() as Promise<SkillFilesResponse>;
}
export async function getSkillFileContent(
skillName: string,
path: string,
): Promise<SkillFileContentResponse> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to read skill file");
}
return response.json() as Promise<SkillFileContentResponse>;
}
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
export function getSkillFileDownloadURL(skillName: string, path: string): string {
return (
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
`?path=${encodeURIComponent(path)}`
);
}
export async function saveSkillFile(
skillName: string,
path: string,
content: string,
): Promise<SkillFileSaveResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file");
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
export interface SkillNameZhResponse {
name: string;
name_zh: string | null;
}
export interface GenerateMissingSkillNameZhResponse {
generated: SkillNameZhResponse[];
failed: string[];
skipped: number;
/** Representative model error when some skills failed due to the LLM. */
model_error?: string | null;
}
/**
* Set a skill's Chinese display name. An empty string clears it (falls back to
* the English name).
*/
export async function setSkillNameZh(
skillName: string,
nameZh: string,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name_zh: nameZh }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to set skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
export async function generateSkillNameZh(
skillName: string,
modelName?: string | null,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** One-click (admin): generate Chinese names for every skill that lacks one. */
export async function generateMissingSkillNameZh(
modelName?: string | null,
): Promise<GenerateMissingSkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
}
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
}
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
}
/**
* Update a skill's long-form detail text. Owner/admin for custom skills,
* admin-only for built-in skills (backend enforces). An empty string clears it.
*/
export async function setSkillDetail(
skillName: string,
detail: string,
): Promise<SetSkillDetailResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ detail }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill detail");
}
return response.json() as Promise<SetSkillDetailResponse>;
}
-379
View File
@@ -1,379 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
getSkill,
getSkillContent,
getSkillFileContent,
getSkillFiles,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
setSkillAlwaysOn,
setSkillDetail,
setSkillNameZh,
setSkillDisplay,
setSkillPinned,
setSkillFeatured,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from ".";
// Stable empty fallback. Returning `data ?? []` inline would hand out a fresh
// array reference on every render while the query is loading/errored, which
// makes downstream `useMemo([skills])` / `useEffect([skills])` consumers churn
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
/**
* Admin-only listing — every skill in the system (built-in + every user's
* custom skills, including unpublished ones owned by other users). Backend
* 403s if the caller is not ``system_role === "admin"``.
*/
export function useAllSkillsAdmin(enabled: boolean) {
const { data, isLoading, error } = useQuery({
queryKey: ["skills", "admin", "all"],
queryFn: () => loadAllSkillsAdmin(),
enabled,
staleTime: 30_000,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
export function useSkillContent(skillName: string | null) {
return useQuery({
queryKey: ["skill-content", skillName],
queryFn: () => getSkillContent(skillName!),
enabled: !!skillName,
});
}
export function useSkill(skillName: string | null) {
return useQuery({
queryKey: ["skill", skillName],
queryFn: () => getSkill(skillName!),
enabled: !!skillName,
});
}
export function useSetSkillDetail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
setSkillDetail(skillName, detail),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useSetSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
setSkillNameZh(skillName, nameZh),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
generateSkillNameZh(skillName, modelName),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateMissingSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillDisplay() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
display,
sample,
}: {
skillName: string;
display: Parameters<typeof setSkillDisplay>[1];
sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
skillName,
enabled,
}: {
skillName: string;
enabled: boolean;
}) => {
await enableSkill(skillName, enabled);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillAlwaysOn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
setSkillAlwaysOn(skillName, alwaysOn),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: ({
file,
targetCategory,
}: {
file: File;
targetCategory?: "public" | "custom";
}) => validateSkillUpload(file, targetCategory),
});
}
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useReconcileCustomSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => reconcileCustomSkills(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
published,
squareId,
}: {
skillName: string;
published: boolean;
squareId?: string;
}) => setSkillPublished(skillName, published, squareId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Unpublishing triggers the backend cascade that strips this skill
// from every referencing agent's config.yaml, so the cached agent
// list (agent.skills) is now stale.
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useSetSkillState() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
setSkillState(skillName, state),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPinned() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
setSkillPinned(skillName, pinned),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
setSkillFeatured(skillName, featured),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDuplicateSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
sourceName,
newName,
}: {
sourceName: string;
newName: string;
}) => duplicateSkill(sourceName, newName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Deletion triggers the backend cascade that strips this skill from
// every referencing agent's config.yaml, so the cached agent list
// (agent.skills) is now stale.
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useSkillFiles(skillName: string | null) {
return useQuery({
queryKey: ["skill-files", skillName],
queryFn: () => getSkillFiles(skillName!),
enabled: !!skillName,
});
}
export function useSkillFileContent(skillName: string | null, path: string | null) {
return useQuery({
queryKey: ["skill-file", skillName, path],
queryFn: () => getSkillFileContent(skillName!, path!),
enabled: !!skillName && !!path,
});
}
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
content,
}: {
skillName: string;
path: string;
content: string;
}) => saveSkillFile(skillName, path, content),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
// SKILL.md edits also affect the source-tab view and skill metadata.
if (path === "SKILL.md") {
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
-3
View File
@@ -1,3 +0,0 @@
export * from "./api";
export * from "./type";
export * from "./utils";
-88
View File
@@ -1,88 +0,0 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
export type SkillLinkStrategy = "auto" | "url" | "id";
export type SkillCitationToken = "doubleBracket" | "singleBracket";
export interface SkillDisplayTableColumn {
label: string;
field: string;
width?: string | null;
}
export interface SkillDisplayResultSetConfig {
key: string;
label?: string | null;
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
titleField?: string | null;
summaryField?: string | null;
contentField?: string | null;
sourceField?: string | null;
authorField?: string | null;
typeField?: string | null;
timeField?: string | null;
scoreField?: string | null;
urlField?: string | null;
idField?: string | null;
linkStrategy: SkillLinkStrategy;
linkTemplate?: string | null;
citationToken: SkillCitationToken;
maxItems: number;
customInstruction: string;
tableColumns?: SkillDisplayTableColumn[];
}
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
resultSets?: SkillDisplayResultSetConfig[];
}
export interface Skill {
name: string;
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
name_zh?: string | null;
description: string;
category: string;
license: string;
enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
// Publisher username (email local part) resolved from owner_user_id by the
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean;
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string;
builtin?: boolean;
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
favorited?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null;
display?: SkillDisplayConfig | null;
displaySample?: Record<string, unknown> | null;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
featured?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
created_at?: string | null;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
-22
View File
@@ -1,22 +0,0 @@
import type { Skill } from "./type";
/**
* 智能体技能白名单的候选池过滤。
*
* 编辑智能体时,普通用户只能从「内置技能 + 自己的技能 + 已发布到广场的
* 技能」里挑选,其他用户未发布的自定义技能不可选;管理员仍可使用全部
* 已启用技能。
*/
export function isAgentSkillCandidate(
skill: Skill,
currentUserId: string | null,
isAdmin = false,
): boolean {
if (!skill.enabled) return false;
if (isAdmin) return true;
if ((skill.state ?? "active") !== "active") return false;
const builtin = skill.builtin ?? !skill.owner_user_id;
const ownedByMe =
!!skill.owner_user_id && skill.owner_user_id === currentUserId;
return builtin || ownedByMe || !!skill.published;
}
@@ -31,7 +31,6 @@ import {
syncPositionOptionsFromRoles, syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions"; } from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css"; import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -97,8 +96,6 @@ export function EightBfStep1Panel({
}: EightBfStep1PanelProps) { }: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
const openPanel = (tab: "agents" | "results") => { const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) { if (rightPanelOpen && rightTab === tab) {
@@ -170,8 +167,6 @@ export function EightBfStep1Panel({
modelOptions={modelOptions} modelOptions={modelOptions}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange} onSelectedModelChange={onSelectedModelChange}
commonQuestions={intentAgent?.common_questions ?? null}
sendCommonQuestionOnClick
/> />
)} )}
intentReady={intentReady} intentReady={intentReady}
@@ -336,26 +331,8 @@ function EightBfAgentWorkspace({
summaryReport: step3Summary, summaryReport: step3Summary,
}); });
// 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示,
// 供管理员在本面板查看 / 配置其提示词与常用问题。
const intentAgent = useMemo(
() => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null,
[agents],
);
const intentAgentMatchesKeyword = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return true;
const name = (intentAgent?.name || "8BF 任务理解").toLowerCase();
const description = (intentAgent?.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
}, [agentKeyword, intentAgent]);
useEffect(() => { useEffect(() => {
if ( if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
selectedAgentId &&
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
!seats.some((seat) => seat.agent_id === selectedAgentId)
) {
setSelectedAgentId(null); setSelectedAgentId(null);
} }
}, [seats, selectedAgentId]); }, [seats, selectedAgentId]);
@@ -435,12 +412,7 @@ function EightBfAgentWorkspace({
) : ( ) : (
<AgentDetailPanel <AgentDetailPanel
agent={selectedAgent} agent={selectedAgent}
agentName={ agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
selectedAgent?.name
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
? "8BF 任务理解"
: selectedSeat?.name ?? "")
}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")} onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false} showRecentChats={false}
avatarSrc={agentConfigLogo} avatarSrc={agentConfigLogo}
@@ -496,7 +468,7 @@ function EightBfAgentWorkspace({
</header> </header>
{isLoading && seats.length > 0 ? ( {isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div> <div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? ( ) : filteredSeatAgents.length === 0 ? (
<div className="eightbf-roundtable__agent-selector-empty"> <div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p> <p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && ( {!seatAgents.length && canConfigureChain && (
@@ -505,13 +477,6 @@ function EightBfAgentWorkspace({
</div> </div>
) : ( ) : (
<div className="eightbf-roundtable__agent-selector-list"> <div className="eightbf-roundtable__agent-selector-list">
{intentAgentMatchesKeyword && (
<EightBfAgentCard
name={intentAgent?.name || "8BF 任务理解"}
description={intentAgent?.description || "第一步「意图识别」的内置任务理解智能体"}
onSelect={() => setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)}
/>
)}
{filteredSeatAgents.map(({ seat, agent }) => ( {filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow <EightBfAgentRow
key={seat.agent_id} key={seat.agent_id}
@@ -538,19 +503,6 @@ function EightBfAgentRow({
}) { }) {
const name = agent?.name || seat.name || "未命名智能体"; const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "智能体助手"; const description = agent?.description || seat.description || "智能体助手";
return <EightBfAgentCard name={name} description={description} onSelect={onSelect} />;
}
/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */
function EightBfAgentCard({
name,
description,
onSelect,
}: {
name: string;
description: string;
onSelect: () => void;
}) {
const avatarText = firstAgentAvatarChar(name); const avatarText = firstAgentAvatarChar(name);
return ( return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}> <button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
@@ -3,7 +3,6 @@ import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react"; import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import { import {
EightBfStep1Panel, EightBfStep1Panel,
@@ -77,9 +76,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState(""); const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 第二步「人工干预」:开启后每走一步暂停等待用户输入;关闭自动推进。会话内状态,
// 新建会话 / 刷新后回到默认关闭(与现状一致)。
const [step2Intervention, setStep2Intervention] = useState(false);
const configuredChain = useMemo( const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null, () => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId], [chains, mapping?.chainId],
@@ -264,12 +260,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。 // 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
skipInitialDraftRestore={!taskId} skipInitialDraftRestore={!taskId}
taskContext={taskContext as Record<string, unknown> | null} taskContext={taskContext as Record<string, unknown> | null}
// 第一步绑定 8BF 专属内置意图智能体(roundtable-intent 的同源复制,
// 提示词独立可调优),不再与通用多智能体会商共用一个 SOUL。
intentAgentId={EIGHTBF_INTENT_AGENT_ID}
// 页头「人工干预」开关:控制第二步是否每步暂停等待用户输入。
step2InterventionEnabled={step2Intervention}
onStep2InterventionChange={setStep2Intervention}
entryRailTaskId={taskId || null} entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? ( entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard <EightBfTaskCard
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing'; const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体'; const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`; const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
const SHOW_NOTEBOOK_SPACE_ENTRIES = true; const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
-247
View File
@@ -1,247 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Bot } from "lucide-react";
import { useRouter } from "next/navigation";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import "@/styles/agents-orbit.css";
import "@/styles/agents-orbit-dark.css";
import "@/styles/agents-orbit-light.css";
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
const DIAL_TRANSITION_MS = 850;
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
interface AgentGroup {
id: string;
name: string;
agents: Agent[];
}
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
const map = new Map<string, AgentGroup>();
for (const agent of agents) {
const tag = agent.tags?.[0];
const id = tag?.id ?? "__uncategorized__";
let group = map.get(id);
if (!group) {
group = { id, name: tag?.name ?? "未分类", agents: [] };
map.set(id, group);
}
group.agents.push(agent);
}
return Array.from(map.values()).sort((a, b) =>
a.name.localeCompare(b.name, "zh-Hans-CN"),
);
}
const mod = (value: number, n: number): number => ((value % n) + n) % n;
/**
* 密码转盘模型:
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
*/
export default function AgentsOrbitPage() {
const router = useRouter();
const { theme } = useTheme();
const isDark = themeToBaseMode(theme) === "dark";
const { agents, isLoading } = useAgents();
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
const [activeId, setActiveId] = useState<string | null>(null);
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
const [anim, setAnim] = useState<Record<string, number> | null>(null);
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
const [frozen, setFrozen] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
};
}, []);
if (isLoading) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<div className="ao-spinner" />
<span>正在加载智能体…</span>
</div>
</div>
);
}
if (groups.length === 0) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<span>还没有可展示的智能体</span>
</div>
</div>
);
}
const n = groups.length;
const midSlot = Math.floor((n - 1) / 2);
const step = 180 / n;
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
const requestedIdx = activeId
? groups.findIndex((g) => g.id === activeId)
: -1;
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
const active = groups[activeIdx];
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
const shift = midSlot - activeIdx;
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
const handleTypeClick = (slot: number) => {
const group = groupAtSlot(slot);
if (group.id === active.id) return;
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
const angles: Record<string, number> = {};
for (let j = 0; j < n; j++) {
const g = groupAtSlot(j);
const nextSlot = j + k;
let angle = slotAngle(mod(nextSlot, n));
if (nextSlot >= n) {
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
} else if (nextSlot < 0) {
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
}
angles[g.id] = angle;
}
setActiveId(group.id);
setAnim(angles);
if (timerRef.current != null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
setFrozen(true);
setAnim(null);
requestAnimationFrame(() =>
requestAnimationFrame(() => setFrozen(false)),
);
}, DIAL_TRANSITION_MS + 60);
};
const handleAgentClick = (agent: Agent) => {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
};
const themeClass = isDark ? "ao-dark" : "ao-light";
return (
<div
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
>
<div className="ao-header">
<span className="ao-header-title">智能体 · 星轨转盘</span>
<button
type="button"
className="ao-header-link"
onClick={() => router.push("/page/workspace/agents")}
>
经典视图
</button>
</div>
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
<div className="ao-stage">
<div className="ao-root">
<div className="ao-ring ao-ring--ticks" />
<div className="ao-ring ao-ring--dash" />
<div className="ao-ring ao-ring--arc" />
<div className="ao-sat-orbit">
<div className="ao-sat" />
</div>
<div className="ao-core">
<div className="ao-core-img">
<Bot size={60} strokeWidth={1.4} />
</div>
</div>
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
<div className="ao-types">
{groups.map((group, gi) => {
const slot = mod(gi + shift, n);
const angle = anim
? (anim[group.id] ?? slotAngle(slot))
: slotAngle(slot);
const isActive = group.id === active.id;
const transitionStyle = frozen ? { transition: "none" } : {};
return (
<div
key={group.id}
className="ao-type-arm"
style={{
transform: `rotate(${angle}deg)`,
...transitionStyle,
}}
>
<button
type="button"
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
style={{
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
...transitionStyle,
}}
onClick={() => handleTypeClick(slot)}
title={`${group.name} · ${group.agents.length} 个智能体`}
>
<span
className="ao-type-chip"
style={{ animationDelay: `${-gi * 0.8}s` }}
>
{group.name}
</span>
</button>
</div>
);
})}
</div>
</div>
</div>
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
<aside className="ao-panel">
<div className="ao-panel-head">
<div className="ao-panel-title">{active.name}</div>
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
</div>
{active.agents.length > 0 ? (
<div className="ao-agents" key={active.id}>
{active.agents.map((agent) => (
<button
key={agent.id}
type="button"
className="ao-agent"
onClick={() => handleAgentClick(agent)}
title={agent.description || agent.name}
>
<span className="ao-agent-icon">
{(agent.name || "?").trim().charAt(0).toUpperCase()}
</span>
<span className="ao-agent-name">{agent.name}</span>
</button>
))}
</div>
) : (
<div className="ao-state">
<span>该类型下暂时没有智能体</span>
</div>
)}
</aside>
</div>
);
}
@@ -5,10 +5,8 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
Clock3, Clock3,
GitBranch,
LoaderCircle, LoaderCircle,
MessageSquareText, MessageSquareText,
Network,
PanelLeftClose, PanelLeftClose,
Pause, Pause,
Play, Play,
@@ -43,25 +41,10 @@ import {
OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX, OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes"; } from "@/core/official-deerflow/routes";
import { Toaster } from "@/components/ui/sonner";
import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage";
import TaskEditPage from "./official-multi-agent-tasks/TaskEditPage";
import TaskAnalysisPage from "./official-task-analysis/TaskAnalysisPage";
import TaskAnalysisDetailPage from "./official-task-analysis/TaskAnalysisDetailPage";
import "./official-deerflow-entry.css"; import "./official-deerflow-entry.css";
type OfficialPage = type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
| "chat"
| "agents"
| "agent-chat"
| "scheduled"
| "multi-agent-tasks"
| "multi-agent-edit"
| "task-analysis"
| "task-analysis-detail"
| "settings";
function pageFromPath(pathname: string): OfficialPage { function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
@@ -70,13 +53,6 @@ function pageFromPath(pathname: string): OfficialPage {
} }
if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/multi-agent-tasks/new")) return "multi-agent-edit";
if (/^\/workspace\/multi-agent-tasks\/[^/]+\/edit$/.test(path)) {
return "multi-agent-edit";
}
if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks";
if (/^\/workspace\/task-analysis\/[^/]+/.test(path)) return "task-analysis-detail";
if (path.startsWith("/workspace/task-analysis")) return "task-analysis";
if (path.startsWith("/workspace/settings")) return "settings"; if (path.startsWith("/workspace/settings")) return "settings";
return "chat"; return "chat";
} }
@@ -102,33 +78,6 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null; return match ? decodeURIComponent(match[1]!) : null;
} }
function parseMultiAgentTaskId(pathname: string) {
const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/);
const raw = match ? decodeURIComponent(match[1]!) : null;
// 编辑页路由(new / :id/edit)不是任务详情,不驱动列表页的抽屉。
if (!raw || raw === "new") return null;
return raw;
}
function parseTaskAnalysisId(pathname: string) {
const match = pathname.match(/\/workspace\/task-analysis\/([^/]+)/);
return match ? decodeURIComponent(match[1]!) : null;
}
function parseMultiAgentEditTarget(pathname: string): {
mode: "create" | "edit";
taskId: string | null;
} {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/multi-agent-tasks/new")) {
return { mode: "create", taskId: null };
}
const match = path.match(/^\/workspace\/multi-agent-tasks\/([^/]+)\/edit$/);
return match
? { mode: "edit", taskId: decodeURIComponent(match[1]!) }
: { mode: "create", taskId: null };
}
function readableError(error: unknown, fallback: string) { function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
@@ -709,18 +658,6 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname), () => parseScheduledTaskId(location.pathname),
[location.pathname], [location.pathname],
); );
const multiAgentTaskId = useMemo(
() => parseMultiAgentTaskId(location.pathname),
[location.pathname],
);
const multiAgentEditTarget = useMemo(
() => parseMultiAgentEditTarget(location.pathname),
[location.pathname],
);
const taskAnalysisId = useMemo(
() => parseTaskAnalysisId(location.pathname),
[location.pathname],
);
const { data: threads = [], isLoading: threadsLoading, error: threadsError } = const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
useThreads({ useThreads({
limit: 20, limit: 20,
@@ -735,8 +672,6 @@ export default function OfficialDeerFlowEntry() {
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
{ icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks", "multi-agent-edit"] },
{ icon: GitBranch, label: "任务分析定时", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`, pages: ["task-analysis", "task-analysis-detail"] },
] as const; ] as const;
return ( return (
@@ -834,23 +769,8 @@ export default function OfficialDeerFlowEntry() {
/> />
)} )}
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />} {page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "multi-agent-tasks" && (
<MultiAgentTasksPage routeTaskId={multiAgentTaskId} />
)}
{page === "multi-agent-edit" && (
<TaskEditPage
key={multiAgentEditTarget.taskId ?? "create"}
mode={multiAgentEditTarget.mode}
taskId={multiAgentEditTarget.taskId}
/>
)}
{page === "task-analysis" && <TaskAnalysisPage />}
{page === "task-analysis-detail" && (
<TaskAnalysisDetailPage routeTaskId={taskAnalysisId} />
)}
{page === "settings" && <SettingsPage />} {page === "settings" && <SettingsPage />}
</div> </div>
<Toaster position="top-center" />
</main> </main>
); );
} }
+1 -18
View File
@@ -309,24 +309,7 @@ export default function TaskButtonsPage() {
className="h-8 w-24" className="h-8 w-24"
title="发给宿主的参数名(默认 id)" title="发给宿主的参数名(默认 id)"
/> />
<span className="text-muted-foreground">=</span> <span className="text-muted-foreground">= 任务id(数字)</span>
{/* 行动分析(xdfx):行动id 与任务id 不同,可选发哪种;其它业务固定任务id */}
{tab === "xdfx" ? (
<Select
value={r.idKind}
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">任务id</SelectItem>
<SelectItem value="action">行动id</SelectItem>
</SelectContent>
</Select>
) : (
<span className="text-muted-foreground">任务id(数字)</span>
)}
</div> </div>
)} )}
<span className="text-muted-foreground w-full text-[11px]"> <span className="text-muted-foreground w-full text-[11px]">
@@ -39,7 +39,6 @@ import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage"; import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage"; import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import AgentsOrbitPage from "./AgentsOrbitPage";
import AgentChatPage from "./AgentChatPage"; import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage"; import SentimentAgentChatPage from "./SentimentAgentChatPage";
import NewAgentPage from "./NewAgentPage"; import NewAgentPage from "./NewAgentPage";
@@ -621,16 +620,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
<Route
path="agents/orbit"
element={
<WorkspaceLayout {...layoutProps}>
<AgentsOrbitPage />
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发 {/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */} 外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route <Route
@@ -8,10 +8,8 @@
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选 * 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示 * 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。 * 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
* **点击卡片即「选择」该结构(内容回填到高级配置的报告结构 custom_outline, * - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
* 卡片留在列表中高亮为当前使用,不进入详情)**。 * custom_outline),弹窗切换为展示 标题 + Markdown 大纲预览,右上角「返回」回列表。
* - 详情视图:通过卡片右上角「查看大纲结构」眼睛图标进入,展示 标题 + Markdown
* 大纲预览,右上角「返回」回列表。
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -240,9 +238,8 @@ export function WorkflowConfigDialog({
toast.error(`「${structure.title}」已停用,请先启用后再选用`); toast.error(`「${structure.title}」已停用,请先启用后再选用`);
return; return;
} }
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills); onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
setDetailId(structure.id);
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`); toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
}; };
@@ -328,7 +325,7 @@ export function WorkflowConfigDialog({
</h2> </h2>
</div> </div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]"> <p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。 点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
</p> </p>
</div> </div>
<Button <Button
@@ -504,7 +501,7 @@ export function WorkflowConfigDialog({
key={structure.id} key={structure.id}
structure={structure} structure={structure}
applied={structure.id === appliedStructureId} applied={structure.id === appliedStructureId}
onSelect={() => handleSelect(structure)} onOpen={() => handleSelect(structure)}
onPreview={(event) => { onPreview={(event) => {
event.stopPropagation(); event.stopPropagation();
setDetailId(structure.id); setDetailId(structure.id);
@@ -753,7 +750,7 @@ export function WorkflowConfigDialog({
function StructureCard({ function StructureCard({
structure, structure,
applied, applied,
onSelect, onOpen,
onPreview, onPreview,
onDuplicate, onDuplicate,
onEdit, onEdit,
@@ -763,7 +760,7 @@ function StructureCard({
}: { }: {
structure: ReportStructure; structure: ReportStructure;
applied: boolean; applied: boolean;
onSelect: () => void; onOpen: () => void;
onPreview: (event: React.MouseEvent) => void; onPreview: (event: React.MouseEvent) => void;
onDuplicate: (event: React.MouseEvent) => void; onDuplicate: (event: React.MouseEvent) => void;
onEdit: (event: React.MouseEvent) => void; onEdit: (event: React.MouseEvent) => void;
@@ -778,11 +775,11 @@ function StructureCard({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onSelect} onClick={onOpen}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
onSelect(); onOpen();
} }
}} }}
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${ className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
@@ -1,584 +0,0 @@
import {
CalendarClock,
CircleAlert,
CircleCheck,
Clock3,
History,
LoaderCircle,
Network,
Pencil,
Pause,
Play,
Plus,
Search,
Trash2,
Zap,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, DialogPlugin, Drawer, Input, Popconfirm, Switch, Tabs, Tag } from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "./components/AgentFlow";
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
import type { MatRunView, MatTaskView } from "./types";
import {
describeCron,
expandStages,
formatDateTime,
formatDuration,
formatRelative,
runStatusLabel,
taskBadgeOf,
} from "./utils";
import "./multi-agent-tasks.css";
type StatusFilter = "all" | "running" | "enabled" | "paused";
function isTaskLive(task: MatTaskView) {
return task.lastRun?.status === "running" || task.lastRun?.status === "pending";
}
function matchFilter(task: MatTaskView, filter: StatusFilter) {
if (filter === "all") return true;
if (filter === "running") return isTaskLive(task);
if (filter === "paused") return !task.enabled;
return task.enabled && !isTaskLive(task);
}
function runTagTheme(status: MatRunView["status"]): "success" | "danger" | "warning" | "default" {
if (status === "succeeded") return "success";
if (status === "failed") return "danger";
if (status === "running") return "warning";
return "default";
}
export default function MultiAgentTasksPage({
routeTaskId,
}: {
routeTaskId: string | null;
}) {
const navigate = useNavigate();
const { tasks, isLoading } = useMultiAgentTasks();
const actions = useMultiAgentTaskActions();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<StatusFilter>("all");
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null;
const openTask = useCallback(
(taskId: string) => {
setActiveRunId(null);
navigate(
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`,
);
},
[navigate],
);
const closeTask = useCallback(() => {
setActiveRunId(null);
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
}, [navigate]);
const visibleTasks = useMemo(() => {
const keyword = search.trim().toLowerCase();
return tasks.filter((task) => {
if (!matchFilter(task, filter)) return false;
if (!keyword) return true;
return (
task.name.toLowerCase().includes(keyword) ||
(task.description ?? "").toLowerCase().includes(keyword) ||
task.seats.some((seat) => seat.name.toLowerCase().includes(keyword))
);
});
}, [tasks, search, filter]);
const stats = useMemo(() => {
const total = tasks.length;
const enabled = tasks.filter((task) => task.enabled).length;
const running = tasks.filter(isTaskLive).length;
const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0);
return { total, enabled, running, runTotal };
}, [tasks]);
const openCreate = () => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/new`);
};
const openEdit = (task: MatTaskView) => {
navigate(
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(task.id)}/edit`,
);
};
const runTrigger = async (task: MatTaskView) => {
setBusyTaskId(task.id);
try {
await actions.trigger.mutateAsync(task.id);
setActiveRunId(null);
openTask(task.id);
toast.success("已触发执行");
} catch {
toast.error("触发失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const runToggle = async (task: MatTaskView) => {
setBusyTaskId(task.id);
try {
await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled });
toast.success(task.enabled ? "任务已暂停" : "任务已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const confirmDelete = (task: MatTaskView) => {
const dialog = DialogPlugin.confirm({
header: "删除定时任务",
body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`,
confirmBtn: { content: "删除", theme: "danger" },
cancelBtn: "取消",
onConfirm: () => {
dialog.destroy();
void actions.remove.mutateAsync(task.id).then(() => {
if (selectedTask?.id === task.id) closeTask();
toast.success("任务已删除");
});
},
onClose: () => dialog.destroy(),
});
};
const selectedStages = selectedTask ? expandStages(selectedTask) : [];
const selectedRuns = selectedTask?.runs ?? [];
const activeRun =
selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null;
return (
<section className="mat-page">
<header className="mat-page-head">
<div className="mat-page-head-copy">
<h1>多智能体定时任务</h1>
<p>
把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。
</p>
</div>
<Button theme="primary" onClick={openCreate}>
<Plus aria-hidden="true" size={15} />
新建编排任务
</Button>
</header>
<div className="mat-stats" aria-label="任务统计">
<div className="mat-stat">
<span className="mat-stat-icon is-primary" aria-hidden="true">
<Network size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.total}</strong>
<span>编排任务</span>
</div>
</div>
<div className="mat-stat">
<span className="mat-stat-icon is-success" aria-hidden="true">
<CircleCheck size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.enabled}</strong>
<span>已启用</span>
</div>
</div>
<div className="mat-stat">
<span className={`mat-stat-icon${stats.running ? " is-live" : " is-muted"}`} aria-hidden="true">
{stats.running ? <LoaderCircle size={18} className="mat-spin" /> : <Clock3 size={18} />}
</span>
<div className="mat-stat-copy">
<strong className={stats.running ? "is-live" : undefined}>{stats.running}</strong>
<span>执行中</span>
</div>
</div>
<div className="mat-stat">
<span className="mat-stat-icon is-info" aria-hidden="true">
<History size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.runTotal}</strong>
<span>累计执行</span>
</div>
</div>
</div>
<div className="mat-body">
<div className="mat-toolbar">
<div className="mat-toolbar-search">
<Input
clearable
placeholder="搜索任务名称 / 描述 / 智能体"
prefixIcon={<Search aria-hidden="true" size={15} />}
value={search}
onChange={(value) => setSearch(String(value ?? ""))}
/>
</div>
<Tabs
className="mat-toolbar-tabs"
value={filter}
onChange={(value) => setFilter(value as StatusFilter)}
>
<Tabs.TabPanel label="全部" value="all" />
<Tabs.TabPanel label="执行中" value="running" />
<Tabs.TabPanel label="已启用" value="enabled" />
<Tabs.TabPanel label="已暂停" value="paused" />
</Tabs>
</div>
{isLoading ? (
<div className="mat-state">
<Clock3 aria-hidden="true" className="mat-spin" size={18} />
正在加载编排任务…
</div>
) : visibleTasks.length ? (
<div className="mat-grid">
{visibleTasks.map((task) => {
const badge = taskBadgeOf(task);
const live = isTaskLive(task);
const busy = busyTaskId === task.id;
return (
<article className="mat-card" key={task.id}>
<header className="mat-card-head" onClick={() => openTask(task.id)}>
<span className="mat-card-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="mat-card-title">
<h3>{task.name}</h3>
<p>
<CalendarClock aria-hidden="true" size={13} />
{describeCron(task.cron_expr)}
</p>
</div>
<Tag size="small" theme={badge.theme} variant="light">
{badge.label}
</Tag>
</header>
{task.description && (
<p className="mat-card-desc" onClick={() => openTask(task.id)}>
{task.description}
</p>
)}
<div className="mat-card-flow" onClick={() => openTask(task.id)}>
<AgentFlow
seats={task.seats}
size="sm"
stages={expandStages(task)}
/>
</div>
<footer className="mat-card-foot">
<div className="mat-card-meta">
<span title={task.nextRunAt ?? undefined}>
下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"}
</span>
<span>
上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"}
{task.lastRun && task.lastRun.status !== "pending" && (
<em className={`mat-run-text is-${task.lastRun.status}`}>
· {runStatusLabel(task.lastRun.status)}
</em>
)}
</span>
</div>
<div className="mat-card-ops">
<Button
disabled={busy}
loading={busy}
size="small"
theme="primary"
variant="text"
onClick={() => void runTrigger(task)}
>
<Zap aria-hidden="true" size={14} />
立即执行
</Button>
<Button
disabled={busy}
size="small"
variant="text"
onClick={() => void runToggle(task)}
>
{task.enabled ? (
<Pause aria-hidden="true" size={14} />
) : (
<Play aria-hidden="true" size={14} />
)}
{task.enabled ? "暂停" : "恢复"}
</Button>
<Button size="small" variant="text" onClick={() => openEdit(task)}>
<Pencil aria-hidden="true" size={14} />
编辑
</Button>
<Popconfirm
content="删除后不可恢复,确定删除该任务?"
placement="bottom-right"
theme="danger"
onConfirm={() => confirmDelete(task)}
>
<Button className="mat-danger-btn" size="small" variant="text">
<Trash2 aria-hidden="true" size={14} />
</Button>
</Popconfirm>
</div>
</footer>
</article>
);
})}
</div>
) : (
<div className="mat-state mat-state--empty">
<span className="mat-state-glyph" aria-hidden="true">
<Network size={22} />
</span>
<h3>{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}</h3>
<p>
{tasks.length
? "换个关键词或筛选条件试试。"
: "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"}
</p>
{!tasks.length && (
<Button theme="primary" onClick={openCreate}>
<Plus aria-hidden="true" size={15} />
新建编排任务
</Button>
)}
</div>
)}
<p className="mat-footnote">
<CircleAlert aria-hidden="true" size={13} />
前端演示阶段:任务数据保存在本浏览器,执行过程为本地模拟;后端服务接入后自动切换。
</p>
</div>
<Drawer
className="mat-detail-drawer"
closeBtn
destroyOnClose
footer={
selectedTask ? (
<div className="mat-detail-footer">
<Button variant="outline" onClick={() => openEdit(selectedTask)}>
<Pencil aria-hidden="true" size={15} />
编辑编排
</Button>
<Button
variant="outline"
onClick={() => void runToggle(selectedTask)}
>
{selectedTask.enabled ? (
<Pause aria-hidden="true" size={15} />
) : (
<Play aria-hidden="true" size={15} />
)}
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
</Button>
<Button
className="mat-danger-btn"
theme="danger"
variant="outline"
onClick={() => confirmDelete(selectedTask)}
>
<Trash2 aria-hidden="true" size={15} />
删除
</Button>
<Button
disabled={busyTaskId === selectedTask.id}
loading={busyTaskId === selectedTask.id}
theme="primary"
onClick={() => void runTrigger(selectedTask)}
>
<Zap aria-hidden="true" size={15} />
立即执行
</Button>
</div>
) : null
}
header={
selectedTask ? (
<div className="mat-detail-header">
<span className="mat-detail-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="mat-detail-heading">
<h3>{selectedTask.name}</h3>
<p>{describeCron(selectedTask.cron_expr)}</p>
</div>
{(() => {
const badge = taskBadgeOf(selectedTask);
return (
<Tag theme={badge.theme} variant="light">
{badge.label}
</Tag>
);
})()}
<Switch
value={selectedTask.enabled}
onChange={() => void runToggle(selectedTask)}
/>
</div>
) : null
}
placement="right"
size="680px"
visible={!!selectedTask}
onClose={closeTask}
>
{selectedTask && (
<div className="mat-detail">
{selectedTask.description && <p className="mat-detail-desc">{selectedTask.description}</p>}
<div className="mat-detail-grid">
<div>
<span>下次执行</span>
<strong>
{selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"}
</strong>
</div>
<div>
<span>最近执行</span>
<strong>
{selectedTask.lastRun
? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}`
: "未执行"}
</strong>
</div>
<div>
<span>累计执行</span>
<strong>{selectedTask.runs.length} 次</strong>
</div>
<div>
<span>智能体 / 阶段</span>
<strong>
{selectedTask.seats.length} 个 · {selectedStages.length} 阶段
</strong>
</div>
</div>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>协作编排</h4>
{activeRun && (activeRun.status === "running" || activeRun.status === "pending") && (
<span className="mat-run-text is-running">
阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "}
{activeRun.stage_total} 执行中…
</span>
)}
</header>
<div className="mat-detail-flow">
<AgentFlow
activeStageIndex={
activeRun && activeRun.status !== "succeeded" ? activeRun.stage_index : null
}
seatStates={activeRun?.seat_states ?? null}
seats={selectedTask.seats}
showGoals
size="sm"
stageGoals={selectedTask.stage_goals}
stages={selectedStages}
/>
</div>
{activeRun && (
<div className="mat-flow-legend">
<span>
<i className="mat-seat-dot is-done" /> 完成
</span>
<span>
<i className="mat-seat-dot is-running" /> 执行中
</span>
<span>
<i className="mat-seat-dot is-waiting" /> 等待
</span>
<span>
<i className="mat-seat-dot is-failed" /> 失败
</span>
</div>
)}
</section>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>任务指令</h4>
</header>
<p className="mat-detail-prompt">{selectedTask.prompt}</p>
{selectedTask.coordinator_prompt && (
<>
<header className="mat-detail-section-head mat-detail-section-head--sub">
<h5>总控提示</h5>
</header>
<p className="mat-detail-prompt is-sub">{selectedTask.coordinator_prompt}</p>
</>
)}
</section>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>运行记录</h4>
{!!selectedRuns.length && (
<small>点击记录可在上方流程图回看各智能体状态</small>
)}
</header>
{selectedRuns.length ? (
<div className="mat-run-list">
{selectedRuns.map((run) => {
const active = activeRun?.id === run.id;
const duration = run.finished_at
? formatDuration(
new Date(run.finished_at).getTime() -
new Date(run.started_at).getTime(),
)
: null;
return (
<button
className={`mat-run-item${active ? " is-active" : ""}`}
key={run.id}
onClick={() => setActiveRunId(run.id)}
type="button"
>
<Tag size="small" theme={runTagTheme(run.status)} variant="light">
{runStatusLabel(run.status)}
</Tag>
<span className="mat-run-item-time">
{formatDateTime(run.started_at)}
</span>
<span className="mat-run-item-trigger">
{run.trigger === "manual" ? "手动触发" : "按计划执行"}
</span>
<span className="mat-run-item-duration">
{duration ?? `阶段 ${Math.min(run.stage_index + 1, run.stage_total)}/${run.stage_total}`}
</span>
</button>
);
})}
</div>
) : (
<p className="mat-detail-empty">还没有执行记录,可点「立即执行」体验完整流程。</p>
)}
{activeRun?.error && (
<p className="mat-detail-error">
<CircleAlert aria-hidden="true" size={14} />
{activeRun.error}
</p>
)}
</section>
</div>
)}
</Drawer>
</section>
);
}
@@ -1,942 +0,0 @@
/**
* 多智能体定时任务 · 独立编辑页。
*
* 布局:顶部是紧凑表单条(任务名称 + 任务描述),其余表单项(执行频率、任务指令、
* 总控提示、启用开关)收进「执行设置」弹窗;主体是智能体编排。
* 编排交互与业务链条编辑器一致(复制其 @dnd-kit 模式 + 复用 chain-stages 纯函数):
* - 左栏候选池:拖左侧手柄到右侧任意阶段 / 新建阶段落区;点卡片本体 = 追加为新阶段。
* - 右栏阶段组:阶段是 droppable,席位是 sortable;席位可跨阶段拖动(变并行)或拖到
* 「新建阶段」落区独立成阶段;按钮兜底(并入上/下一阶段、独立成阶段、移除)。
* 保存时弹出确认弹窗:任务摘要 + 编排流程图(同阶段并行 · 阶段间串行)+ 图例。
*/
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
Eye,
GripVertical,
Plus,
Save,
Search,
Settings2,
SplitSquareVertical,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import {
type ScheduleConfig,
buildCron,
parseCron,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import {
addEmptyStage,
addSeatAsNewStage,
addSeatToStage,
countSeats,
deleteStage,
findStageIndexOfSeat,
moveSeatToStage,
moveStage,
removeSeat,
setStageGoal,
splitSeatToNewStage,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroups,
} from "@/roundtable-planning/lib/chain-stages";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "./components/AgentFlow";
import AgentDetailDrawer, {
type AgentDetailTarget,
} from "./components/AgentDetailDrawer";
import SchedulePicker from "./components/SchedulePicker";
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
import type { MatSeat, MatTask, MatTaskPayload } from "./types";
import {
defaultScheduleConfig,
describeCron,
describeScheduleConfig,
expandStages,
} from "./utils";
const POOL_PREFIX = "matpool::";
const SEAT_PREFIX = "matseat::";
const STAGE_PREFIX = "matstage::";
const NEWSTAGE_DROPPABLE_ID = "mat-newstage";
const MAX_SEATS = 20;
const MIN_SEATS = 2;
const DEFAULT_TIMEZONE = "Asia/Shanghai";
type ActiveDrag = { name: string } | null;
function seatFromAgent(agent: Agent): MatSeat {
return {
agent_id: agent.id,
name: agent.name,
description: agent.description ?? undefined,
};
}
function draftFromTask(task: MatTask) {
const stages = expandStages(task);
const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat]));
return {
name: task.name,
description: task.description ?? "",
schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(),
enabled: task.enabled,
prompt: task.prompt,
coordinatorPrompt: task.coordinator_prompt ?? "",
stageGroups: stages.map((stage, index) => ({
seats: stage
.map((agentId) => seatById.get(agentId))
.filter((seat): seat is MatSeat => !!seat),
goal: task.stage_goals?.[index] ?? "",
})) as StageGroups,
};
}
function groupsToPayloadSeats(groups: StageGroups): MatSeat[] {
return groups
.filter((group) => group.seats.length > 0)
.flatMap((group) => group.seats as MatSeat[]);
}
// ── 左栏:候选智能体卡片(点卡片看详情 / 加号或手柄加入)──────────────────
function PoolAgentCard({
agent,
used,
onAdd,
onShowDetail,
}: {
agent: Agent;
used: boolean;
onAdd: () => void;
onShowDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: POOL_PREFIX + agent.id,
disabled: used,
});
return (
<div
className={`mat-pool-item${used ? " is-used" : ""}${isDragging ? " is-dragging" : ""}`}
ref={setNodeRef}
{...attributes}
>
<button
aria-label={used ? "已在编排中" : "拖动加入编排"}
className="mat-pool-grip"
onClick={(event) => event.stopPropagation()}
title={used ? "已在编排中" : "拖动到右侧阶段加入(可与其它智能体并行)"}
type="button"
{...(used ? {} : listeners)}
>
<GripVertical size={14} />
</button>
<button className="mat-pool-main" onClick={onShowDetail} type="button">
<span className="mat-pool-icon" aria-hidden="true">
<Bot size={13} />
</span>
<span className="mat-pool-copy">
<span className="mat-pool-item-name">{agent.name}</span>
<span className="mat-pool-item-desc">{agent.description || "暂无简介"}</span>
</span>
</button>
{used ? (
<span className="mat-pool-used">已加入</span>
) : (
<button
aria-label={`加入 ${agent.name}`}
className="mat-pool-join"
onClick={(event) => {
event.stopPropagation();
onAdd();
}}
title="追加为新阶段"
type="button"
>
<Plus size={13} />
</button>
)}
</div>
);
}
// ── 右栏:单个席位长条(sortable)─────────────────────────────────────────
function SeatStrip({
seat,
globalIndex,
isFirstStage,
isLastStage,
onRemove,
onMergeUp,
onMergeDown,
onSplit,
onDetail,
}: {
seat: MatSeat;
globalIndex: number;
isFirstStage: boolean;
isLastStage: boolean;
onRemove: () => void;
onMergeUp: () => void;
onMergeDown: () => void;
onSplit: () => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
return (
<div
className={`mat-seat-row${isDragging ? " is-dragging" : ""}`}
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<button
aria-label="拖动调整编排"
className="mat-seat-row-grip"
title="拖动到其它阶段(并行)/ 排序 / 拖到底部虚线框独立成阶段"
type="button"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</button>
<span className="mat-seat-row-no">{globalIndex + 1}</span>
<span className="mat-seat-row-icon" aria-hidden="true">
<Bot size={13} />
</span>
<div className="mat-seat-row-copy" title={seat.description}>
<span className="mat-seat-row-name">{seat.name}</span>
{seat.description && <span className="mat-seat-row-desc">{seat.description}</span>}
</div>
<span className="mat-seat-row-ops">
<button
aria-label={`查看 ${seat.name} 详情`}
onClick={onDetail}
title="查看详情"
type="button"
>
<Eye size={14} />
</button>
<button
aria-label="并入上一阶段(与其并行)"
disabled={isFirstStage}
onClick={onMergeUp}
title="并入上一阶段(与其并行)"
type="button"
>
<ChevronUp size={14} />
</button>
<button
aria-label="独立成阶段"
onClick={onSplit}
title="独立成阶段"
type="button"
>
<SplitSquareVertical size={14} />
</button>
<button
aria-label="并入下一阶段(与其并行)"
disabled={isLastStage}
onClick={onMergeDown}
title="并入下一阶段(与其并行)"
type="button"
>
<ChevronDown size={14} />
</button>
<button aria-label="移除智能体" onClick={onRemove} title="移除" type="button">
<X size={14} />
</button>
</span>
</div>
);
}
// ── 右栏:阶段容器(droppable + 组内 sortable)────────────────────────────
function StageBox({
group,
stageIdx,
stageCount,
startIndex,
onGoalChange,
onStageReorder,
onStageDelete,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onSeatDetail,
}: {
group: StageGroups[number];
stageIdx: number;
stageCount: number;
startIndex: number;
onGoalChange: (goal: string) => void;
onStageReorder: (dir: -1 | 1) => void;
onStageDelete: () => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStage: number) => void;
onSplitSeat: (agentId: string) => void;
onSeatDetail: (seat: MatSeat) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const seats = group.seats as MatSeat[];
const isParallel = seats.length > 1;
const isFirstStage = stageIdx === 0;
const isLastStage = stageIdx === stageCount - 1;
const seatItemIds = seats.map((seat) => SEAT_PREFIX + seat.agent_id);
return (
<article className={`mat-stage-box${isOver ? " is-over" : ""}`}>
<header className="mat-stage-box-head">
<span className="mat-stage-box-no">阶段 {stageIdx + 1}</span>
{isParallel ? (
<span className="mat-flow-mode is-parallel">并行 ×{seats.length}</span>
) : (
<span className="mat-flow-mode is-serial">串行</span>
)}
<span className="mat-stage-box-ops">
<button
aria-label="阶段上移"
disabled={isFirstStage}
onClick={() => onStageReorder(-1)}
title="上移"
type="button"
>
<ChevronUp size={14} />
</button>
<button
aria-label="阶段下移"
disabled={isLastStage}
onClick={() => onStageReorder(1)}
title="下移"
type="button"
>
<ChevronDown size={14} />
</button>
<button
aria-label="删除阶段"
onClick={onStageDelete}
title="删除整个阶段"
type="button"
>
<Trash2 size={14} />
</button>
</span>
</header>
<Textarea
autosize
className="mat-stage-goal"
maxlength={120}
placeholder="本阶段目标(可选):本阶段要产出什么、如何衔接上一阶段"
value={group.goal ?? ""}
onChange={(value) => onGoalChange(String(value ?? ""))}
/>
<div className="mat-stage-seats" ref={setNodeRef}>
{seats.length === 0 ? (
<div className="mat-stage-seat-empty">空阶段 —— 把左侧智能体拖到此处,或删除该阶段。</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{seats.map((seat, index) => (
<SeatStrip
globalIndex={startIndex + index}
isLastStage={isLastStage}
isFirstStage={isFirstStage}
key={seat.agent_id}
onDetail={() => onSeatDetail(seat)}
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
onRemove={() => onRemoveSeat(seat.agent_id)}
onSplit={() => onSplitSeat(seat.agent_id)}
seat={seat}
/>
))}
</SortableContext>
)}
</div>
</article>
);
}
// ── 「新建阶段」落区(点击新增 / 拖席位到此独立成最后阶段)────────────────
function NewStageDropZone({
hasEmptyStage,
onAddStage,
}: {
hasEmptyStage: boolean;
onAddStage: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
return (
<button
className={`mat-stage-dropzone${isOver ? " is-over" : ""}`}
disabled={hasEmptyStage}
onClick={onAddStage}
ref={setNodeRef}
title={hasEmptyStage ? "已有空阶段,先填充它" : "新建一个串在最后的阶段"}
type="button"
>
<Plus size={15} />
新建阶段(串在最后) · 或把席位拖到此处独立成阶段
</button>
);
}
// ── 页面 ──────────────────────────────────────────────────────────────────
export default function TaskEditPage({
mode,
taskId,
}: {
mode: "create" | "edit";
taskId: string | null;
}) {
const navigate = useNavigate();
const { tasks, isLoading: tasksLoading } = useMultiAgentTasks();
const actions = useMultiAgentTaskActions();
const [poolSearch, setPoolSearch] = useState("");
const { agents, isLoading: agentsLoading } = useAgents({
search: poolSearch.trim() || undefined,
});
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [prompt, setPrompt] = useState("");
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [initialized, setInitialized] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [detailTarget, setDetailTarget] = useState<AgentDetailTarget | null>(null);
const editingTask = useMemo(
() => (mode === "edit" ? (tasks.find((task) => task.id === taskId) ?? null) : null),
[mode, taskId, tasks],
);
useEffect(() => {
if (initialized || mode === "create") {
setInitialized(true);
return;
}
if (!tasksLoading && taskId) {
if (editingTask) {
const draft = draftFromTask(editingTask);
setName(draft.name);
setDescription(draft.description);
setSchedule(draft.schedule);
setEnabled(draft.enabled);
setPrompt(draft.prompt);
setCoordinatorPrompt(draft.coordinatorPrompt);
setStageGroups(draft.stageGroups);
}
setInitialized(true);
}
}, [editingTask, initialized, mode, taskId, tasksLoading]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const usedAgentIds = useMemo(
() => new Set(stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))),
[stageGroups],
);
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
const hasEmptyStage = stageGroups.some((group) => group.seats.length === 0);
const backToList = useCallback(() => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
}, [navigate]);
// ── 编排操作(全部复用 chain-stages 纯函数,agent_id 定位)───────────────
const addAgentAsStage = useCallback((agent: Agent) => {
if (seatCount >= MAX_SEATS) {
toast.warning(`编排最多 ${MAX_SEATS} 个智能体`);
return;
}
setStageGroups((prev) => addSeatAsNewStage(prev, seatFromAgent(agent)));
}, [seatCount]);
const onDragStart = useCallback((event: DragStartEvent) => {
const id = String(event.active.id);
const agentId = id.startsWith(POOL_PREFIX)
? id.slice(POOL_PREFIX.length)
: id.startsWith(SEAT_PREFIX)
? id.slice(SEAT_PREFIX.length)
: null;
if (!agentId) return;
const seatName =
agents.find((agent) => agent.id === agentId)?.name ??
stageGroups
.flatMap((group) => group.seats as MatSeat[])
.find((seat) => seat.agent_id === agentId)?.name ??
agentId;
setActiveDrag({ name: seatName });
}, [agents, stageGroups]);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId) return;
let targetStage = -1;
let atIndex: number | undefined;
if (overId === NEWSTAGE_DROPPABLE_ID) {
targetStage = -1;
} else if (overId.startsWith(STAGE_PREFIX)) {
targetStage = Number(overId.slice(STAGE_PREFIX.length));
} else if (overId.startsWith(SEAT_PREFIX)) {
const overAgent = overId.slice(SEAT_PREFIX.length);
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
atIndex = stageGroups[targetStage]?.seats.findIndex(
(seat) => seat.agent_id === overAgent,
);
}
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
if (activeId.startsWith(POOL_PREFIX)) {
const agentId = activeId.slice(POOL_PREFIX.length);
if (usedAgentIds.has(agentId)) return;
const agent = agents.find((candidate) => candidate.id === agentId);
if (!agent || seatCount >= MAX_SEATS) return;
const seat = seatFromAgent(agent);
setStageGroups((prev) =>
toNewStage || targetStage < 0
? addSeatAsNewStage(prev, seat)
: addSeatToStage(prev, seat, targetStage, atIndex),
);
return;
}
if (activeId.startsWith(SEAT_PREFIX)) {
const agentId = activeId.slice(SEAT_PREFIX.length);
setStageGroups((prev) => {
if (toNewStage) {
const withEmpty = addEmptyStage(prev);
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
}
if (targetStage < 0) return prev;
return moveSeatToStage(prev, agentId, targetStage, atIndex);
});
}
},
[agents, seatCount, stageGroups, usedAgentIds],
);
// ── 保存:校验 → 确认弹窗 → 提交 ────────────────────────────────────────
const previewGroups = stageGroups.filter((group) => group.seats.length > 0);
const previewStages = previewGroups.map((group) =>
(group.seats as MatSeat[]).map((seat) => seat.agent_id),
);
const previewSeats = groupsToPayloadSeats(stageGroups);
const tryOpenConfirm = () => {
if (!name.trim()) {
toast.warning("请填写任务名称");
return;
}
if (seatCount < MIN_SEATS) {
toast.warning(`编排至少需要 ${MIN_SEATS} 个智能体`);
return;
}
if (hasEmptyStage) {
toast.warning("还有空阶段,请填充或删除");
return;
}
if (!prompt.trim()) {
setSettingsOpen(true);
toast.warning("请先在「执行设置」中填写任务指令");
return;
}
setConfirmOpen(true);
};
const submitTask = async () => {
setSaving(true);
try {
// 填了总控提示也强制落显式分层(该提示仅分层模式注入),与业务链条语义一致。
const forceExplicit = coordinatorPrompt.trim().length > 0;
const payload: MatTaskPayload = {
name: name.trim(),
description: description.trim() || undefined,
prompt: prompt.trim(),
coordinator_prompt: coordinatorPrompt.trim() || undefined,
cron_expr: buildCron(schedule),
timezone: editingTask?.timezone || DEFAULT_TIMEZONE,
enabled,
seats: previewSeats,
stages: stageGroupsToStagePayload(stageGroups, forceExplicit),
stage_goals: stageGroupsToStageGoals(stageGroups, forceExplicit),
};
if (mode === "edit" && taskId) {
await actions.update.mutateAsync({ id: taskId, payload });
toast.success("任务已更新");
} else {
await actions.create.mutateAsync(payload);
toast.success("任务已创建");
}
setConfirmOpen(false);
backToList();
} catch {
toast.error("保存失败,请重试");
} finally {
setSaving(false);
}
};
if (mode === "edit" && !tasksLoading && initialized && !editingTask) {
return (
<section className="mat-edit-page">
<div className="mat-state mat-state--empty">
<h3>任务不存在或已被删除</h3>
<Button theme="primary" onClick={backToList}>
返回任务列表
</Button>
</div>
</section>
);
}
let seatCursor = 0;
const settingsSummary = `${describeScheduleConfig(schedule)} · ${enabled ? "已启用" : "已暂停"}`;
return (
<section className="mat-edit-page">
<header className="mat-edit-header">
<button aria-label="返回任务列表" className="mat-edit-back" onClick={backToList} type="button">
<ArrowLeft size={18} />
</button>
<div className="mat-edit-heading">
<h1>{mode === "edit" ? `编辑:${editingTask?.name ?? "多智能体定时任务"}` : "新建多智能体定时任务"}</h1>
<p>同阶段并行执行 · 阶段之间串行执行;拖拽或点击左侧候选智能体加入编排</p>
</div>
<Button
variant="outline"
onClick={() => setSettingsOpen(true)}
title="执行频率 / 任务指令 / 总控提示 / 启用"
>
<Settings2 aria-hidden="true" size={15} />
执行设置
<span className="mat-settings-summary">{settingsSummary}</span>
</Button>
<Button theme="primary" onClick={tryOpenConfirm}>
<Save aria-hidden="true" size={15} />
保存任务
</Button>
</header>
<div className="mat-edit-formbar">
<label className="mat-formbar-field">
<span className="mat-formbar-label">
任务名称<i>*</i>
</span>
<Input
clearable
maxlength={40}
placeholder="例如:每日舆情早报"
value={name}
onChange={(value) => setName(String(value ?? ""))}
/>
</label>
<label className="mat-formbar-field mat-formbar-field--wide">
<span className="mat-formbar-label">任务描述</span>
<Input
clearable
maxlength={200}
placeholder="一句话说明这个编排任务要达成什么(可选)"
value={description}
onChange={(value) => setDescription(String(value ?? ""))}
/>
</label>
</div>
<div className="mat-edit-formbar mat-edit-formbar--area">
<span className="mat-formbar-label">总控提示</span>
<Textarea
autosize={{ minRows: 1, maxRows: 4 }}
className="mat-formbar-textarea"
maxlength={1000}
placeholder="写给总控(协调者)的编排要求,例如各阶段如何衔接、产出格式约束等(可选)"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</div>
<div className="mat-edit-body">
<section className="mat-edit-card">
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
sensors={sensors}
>
<div className="mat-edit-orchestra">
<aside className="mat-pool">
<header className="mat-pool-head">
<span>候选智能体</span>
<em>
{seatCount}/{MAX_SEATS} 已编排 · {agents.length} 个可选
</em>
</header>
<div className="mat-pool-search">
<Search aria-hidden="true" size={15} />
<input
onChange={(event) => setPoolSearch(event.target.value)}
placeholder="搜索智能体"
value={poolSearch}
/>
</div>
<div className="mat-pool-list">
{agentsLoading ? (
<p className="mat-pool-tip">正在加载智能体…</p>
) : agents.length ? (
agents.map((agent) => (
<PoolAgentCard
agent={agent}
key={agent.id}
onAdd={() => addAgentAsStage(agent)}
onShowDetail={() =>
setDetailTarget({
agentId: agent.id,
fallbackName: agent.name,
fallbackDescription: agent.description ?? undefined,
})
}
used={usedAgentIds.has(agent.id)}
/>
))
) : (
<p className="mat-pool-tip">没有匹配的智能体</p>
)}
</div>
</aside>
<div className="mat-stage-editor">
<div className="mat-stage-list">
{stageGroups.map((group, stageIdx) => {
const startIndex = seatCursor;
seatCursor += (group.seats as MatSeat[]).length;
return (
<StageBox
group={group}
key={`stage-box-${stageIdx}`}
onGoalChange={(goal) =>
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal))
}
onMergeSeat={(agentId, target) =>
setStageGroups((prev) => moveSeatToStage(prev, agentId, target))
}
onRemoveSeat={(agentId) =>
setStageGroups((prev) => removeSeat(prev, agentId))
}
onSeatDetail={(seat) =>
setDetailTarget({
agentId: seat.agent_id,
fallbackName: seat.name,
fallbackDescription: seat.description,
})
}
onSplitSeat={(agentId) =>
setStageGroups((prev) => splitSeatToNewStage(prev, agentId))
}
onStageDelete={() =>
setStageGroups((prev) => deleteStage(prev, stageIdx))
}
onStageReorder={(dir) =>
setStageGroups((prev) => moveStage(prev, stageIdx, dir))
}
stageCount={stageGroups.length}
stageIdx={stageIdx}
startIndex={startIndex}
/>
);
})}
<NewStageDropZone
hasEmptyStage={hasEmptyStage}
onAddStage={() => setStageGroups((prev) => addEmptyStage(prev))}
/>
{!stageGroups.length && (
<p className="mat-stage-empty">
从左侧点击或拖拽候选智能体加入编排,至少 {MIN_SEATS} 个。
</p>
)}
</div>
</div>
</div>
<DragOverlay>
{activeDrag ? (
<div className="mat-drag-overlay">
<GripVertical size={14} />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
</section>
</div>
<Dialog
className="mat-settings-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="mat-confirm-footer">
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
取消
</Button>
<Button theme="primary" onClick={() => setSettingsOpen(false)}>
确定
</Button>
</div>
}
header="执行设置"
placement="top"
visible={settingsOpen}
width={620}
onClose={() => setSettingsOpen(false)}
>
<div className="mat-settings">
<div className="mat-field">
<span className="mat-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="mat-field">
<span className="mat-field-label">
任务指令<i>*</i>
</span>
<Textarea
autosize={{ minRows: 4, maxRows: 8 }}
maxlength={2000}
placeholder="描述每次定时执行要让智能体编队完成的事情,例如:汇总过去 24 小时重点舆情并生成早报…"
value={prompt}
onChange={(value) => setPrompt(String(value ?? ""))}
/>
</label>
<div className="mat-field mat-field--switch">
<span className="mat-field-label">保存后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</Dialog>
<Dialog
className="mat-confirm-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="mat-confirm-footer">
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
再改改
</Button>
<Button loading={saving} theme="primary" onClick={() => void submitTask()}>
确认保存
</Button>
</div>
}
header="确认保存任务"
placement="top"
visible={confirmOpen}
width={720}
onClose={() => setConfirmOpen(false)}
>
<div className="mat-confirm">
<div className="mat-confirm-grid">
<div>
<span>任务名称</span>
<strong>{name.trim() || "—"}</strong>
</div>
<div>
<span>执行频率</span>
<strong>{describeCron(buildCron(schedule))}</strong>
</div>
<div>
<span>启用状态</span>
<strong>{enabled ? "启用" : "暂停"}</strong>
</div>
<div>
<span>智能体 / 阶段</span>
<strong>
{previewSeats.length} 个智能体 · {previewStages.length} 个阶段
</strong>
</div>
</div>
<div className="mat-confirm-flow">
<AgentFlow
seats={previewSeats}
showGoals
size="sm"
stageGoals={previewGroups.map((group) => (group.goal ?? "").trim() || null)}
stages={previewStages}
/>
</div>
<p className="mat-confirm-legend">
<span>
<i className="mat-flow-mode is-parallel" style={{ cursor: "default" }} /> 并行:同阶段智能体同时执行
</span>
<span>
<i className="mat-flow-mode is-serial" style={{ cursor: "default" }} /> 串行:阶段之间依次执行
</span>
</p>
{prompt.trim() && (
<div className="mat-confirm-prompt">
<span>任务指令</span>
<p>{prompt.trim()}</p>
</div>
)}
</div>
</Dialog>
<AgentDetailDrawer
target={detailTarget}
onClose={() => setDetailTarget(null)}
/>
</section>
);
}
@@ -1,164 +0,0 @@
/**
* 智能体详情抽屉:候选池卡片 / 编排席位条「查看详情」共用。
* 后端查不到(本地演示席位)时回退到编排里保存的名称/描述快照。
*/
import { Bot } from "lucide-react";
import { Drawer, Tag } from "tdesign-react";
import { useAgent } from "@/core/agents";
export interface AgentDetailTarget {
agentId: string;
fallbackName?: string;
fallbackDescription?: string;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export default function AgentDetailDrawer({
target,
onClose,
}: {
target: AgentDetailTarget | null;
onClose: () => void;
}) {
const { agent, isLoading } = useAgent(target?.agentId ?? null, {
enabled: !!target,
});
const name = agent?.name ?? target?.fallbackName ?? target?.agentId ?? "";
const description =
agent?.description ?? target?.fallbackDescription ?? agent?.description ?? "";
return (
<Drawer
className="mat-agent-drawer"
closeBtn
destroyOnClose
footer={null}
header={
<div className="mat-agent-header">
<span className="mat-agent-glyph" aria-hidden="true">
<Bot size={17} />
</span>
<div className="mat-agent-heading">
<h3>{name}</h3>
<p>智能体详情</p>
</div>
{agent && (
<Tag size="small" theme={agent.builtin ? "default" : "primary"} variant="light">
{agent.builtin ? "内置" : agent.published ? "已发布" : "私有"}
</Tag>
)}
</div>
}
placement="right"
size="460px"
visible={!!target}
onClose={onClose}
>
{target && (
<div className="mat-agent">
{isLoading ? (
<p className="mat-agent-loading">正在加载智能体详情…</p>
) : agent ? (
<>
<div className="mat-detail-grid">
<div>
<span>创建者</span>
<strong>
{agent.owner_username
? agent.owner_username
: agent.builtin
? "内置智能体"
: "—"}
</strong>
</div>
<div>
<span>执行模型</span>
<strong>{agent.model || "默认模型"}</strong>
</div>
<div>
<span>创建时间</span>
<strong>{formatDateTime(agent.created_at)}</strong>
</div>
<div>
<span>技能数</span>
<strong>{agent.skills?.length ?? 0} 个</strong>
</div>
</div>
{agent.description && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>简介</h4>
</header>
<p className="mat-agent-desc">{agent.description}</p>
</section>
)}
{!!agent.tags?.length && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>标签</h4>
</header>
<div className="mat-agent-tags">
{agent.tags.map((tag) => (
<Tag key={tag.id} size="small" variant="light">
{tag.name}
</Tag>
))}
</div>
</section>
)}
{!!agent.skills?.length && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>已配置技能</h4>
</header>
<div className="mat-agent-tags">
{agent.skills.map((skill) => (
<Tag key={skill} size="small" theme="primary" variant="light">
{skill}
</Tag>
))}
</div>
</section>
)}
</>
) : (
<div className="mat-agent-local">
<div className="mat-detail-grid">
<div>
<span>创建者</span>
<strong>本地演示</strong>
</div>
<div>
<span>数据来源</span>
<strong>编排内置快照</strong>
</div>
</div>
<p className="mat-agent-desc">
{description || "该席位来自演示种子数据,未对应后端智能体目录中的记录。接入后端后,编排将引用真实智能体。"}
</p>
</div>
)}
</div>
)}
</Drawer>
);
}
@@ -1,136 +0,0 @@
import { Bot, MoveRight } from "lucide-react";
import { Fragment } from "react";
import type { MatSeat, MatSeatRunState } from "../types";
import { seatColorIndex } from "../utils";
export interface AgentFlowProps {
stages: string[][];
seats: MatSeat[];
stageGoals?: (string | null)[] | null;
/** 提供时在席位上渲染执行状态点(运行图例)。 */
seatStates?: Record<string, MatSeatRunState> | null;
/** 当前执行到的阶段下标;提供时高亮进度。 */
activeStageIndex?: number | null;
size?: "sm" | "md";
showGoals?: boolean;
}
const STATE_LABEL: Record<MatSeatRunState, string> = {
waiting: "等待",
running: "执行中",
done: "完成",
failed: "失败",
};
export default function AgentFlow({
stages,
seats,
stageGoals,
seatStates,
activeStageIndex,
size = "md",
showGoals = false,
}: AgentFlowProps) {
const seatById = new Map(seats.map((seat) => [seat.agent_id, seat]));
// 只有一个阶段时不画阶段框:席位直接横向排列。
if (stages.length === 1) {
return (
<div className={`mat-flow mat-flow--single mat-flow--${size}`}>
{stages[0]!.map((agentId) => {
const seat = seatById.get(agentId);
const state = seatStates?.[agentId] ?? null;
return (
<div
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
state ? ` is-${state}` : ""
}`}
key={agentId}
title={
seat?.description
? `${seat.name}:${seat.description}`
: (seat?.name ?? agentId)
}
>
<span className="mat-seat-avatar">
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
</span>
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
{state && (
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
)}
</div>
);
})}
</div>
);
}
return (
<div className={`mat-flow mat-flow--${size}`}>
{stages.map((stage, stageIndex) => {
const parallel = stage.length > 1;
const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex;
const isPast =
typeof activeStageIndex === "number" && activeStageIndex > stageIndex;
const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null;
return (
<Fragment key={`stage-${stageIndex}`}>
{stageIndex > 0 && (
<div className="mat-flow-arrow" aria-hidden="true">
<MoveRight size={size === "sm" ? 14 : 16} strokeWidth={2} />
</div>
)}
<section
className={[
"mat-flow-stage",
isActive ? "is-active" : "",
isPast ? "is-done" : "",
]
.filter(Boolean)
.join(" ")}
>
<header className="mat-flow-stage-head">
<span className="mat-flow-stage-no">阶段 {stageIndex + 1}</span>
{parallel ? (
<span className="mat-flow-mode is-parallel">并行 ×{stage.length}</span>
) : (
<span className="mat-flow-mode is-serial">串行</span>
)}
</header>
<div className="mat-flow-seats">
{stage.map((agentId) => {
const seat = seatById.get(agentId);
const state = seatStates?.[agentId] ?? null;
return (
<div
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
state ? ` is-${state}` : ""
}`}
key={agentId}
title={
seat?.description
? `${seat.name}:${seat.description}`
: (seat?.name ?? agentId)
}
>
<span className="mat-seat-avatar">
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
</span>
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
{state && (
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
)}
</div>
);
})}
</div>
{goal && <p className="mat-flow-goal">{goal}</p>}
</section>
</Fragment>
);
})}
</div>
);
}
@@ -1,76 +0,0 @@
import { Select } from "tdesign-react";
import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { describeScheduleConfig } from "../utils";
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const HOURS = Array.from({ length: 24 }, (_, index) => index);
const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, index) => index + 1);
const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [
{ label: "每小时", value: "hourly" },
{ label: "每天", value: "daily" },
{ label: "每周", value: "weekly" },
{ label: "每月", value: "monthly" },
];
export default function SchedulePicker({
value,
onChange,
}: {
value: ScheduleConfig;
onChange: (config: ScheduleConfig) => void;
}) {
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
const toNumber = (input: unknown) => Number(input as string | number);
return (
<div className="mat-schedule-picker">
<div className="mat-schedule-row">
<Select
options={FREQ_OPTIONS}
value={value.freq}
onChange={(next) => set({ freq: next as ScheduleFreq })}
/>
{value.freq === "hourly" && (
<Select
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
value={value.minute}
onChange={(next) => set({ minute: toNumber(next) })}
/>
)}
{value.freq === "weekly" && (
<Select
options={WEEKDAYS.map((label, index) => ({ label, value: index }))}
value={value.weekday}
onChange={(next) => set({ weekday: toNumber(next) })}
/>
)}
{value.freq === "monthly" && (
<Select
options={DATES.map((date) => ({ label: `第 ${date} 日`, value: date }))}
value={value.date}
onChange={(next) => set({ date: toNumber(next) })}
/>
)}
{value.freq !== "hourly" && (
<Select
options={HOURS.map((hour) => ({ label: `${hour} 时`, value: hour }))}
value={value.hour}
onChange={(next) => set({ hour: toNumber(next) })}
/>
)}
{value.freq !== "hourly" && (
<Select
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
value={value.minute}
onChange={(next) => set({ minute: toNumber(next) })}
/>
)}
</div>
<p className="mat-schedule-preview">{describeScheduleConfig(value)} 自动执行</p>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,361 +0,0 @@
/**
* 多智能体定时任务的本地 mock 存储层。
*
* 后端接口尚未实现,先用 localStorage 持久化任务与运行记录:
* - 「立即执行」写入一条运行记录,状态由 started_at + duration_ms 在
* 读取时按当前时间推导(排队 → 按阶段推进 → 成功/失败),配合
* React Query 的轮询即可获得无后端的“实时”执行动画。
* - 接口就绪后,把本文件的 CRUD 换成 HTTP 调用即可,页面无需改动。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { uuid } from "@/core/utils/uuid";
import { expandStages, nextCronDate } from "./utils";
import type {
MatRunRecord,
MatRunStatus,
MatRunView,
MatSeatRunState,
MatTask,
MatTaskPayload,
MatTaskView,
} from "./types";
const STORAGE_KEY = "deerflow.official.multi-agent-tasks.v1";
interface MatStore {
tasks: MatTask[];
runs: MatRunRecord[];
}
const PENDING_MS = 1_500;
const STAGE_MS = 4_000;
function readStore(): MatStore {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<MatStore>;
if (Array.isArray(parsed.tasks)) {
return { tasks: parsed.tasks as MatTask[], runs: parsed.runs ?? [] };
}
}
} catch {
// 解析失败按首次访问处理,重写种子数据。
}
const seeded = seedStore();
writeStore(seeded);
return seeded;
}
function writeStore(store: MatStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// 存储被禁用时静默降级为内存态。
}
}
function mutateStore(mutator: (store: MatStore) => MatStore): MatStore {
const next = mutator(readStore());
writeStore(next);
return next;
}
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString();
}
function seedStore(): MatStore {
const seats = {
collector: { agent_id: "mat-seed-collector", name: "全网舆情采集", description: "多源检索并汇总最新舆情线索" },
monitor: { agent_id: "mat-seed-monitor", name: "舆情监测雷达", description: "盯住重点信源并输出监测明细" },
analyst: { agent_id: "mat-seed-analyst", name: "舆情研判分析", description: "对采集结果做情感与趋势研判" },
writer: { agent_id: "mat-seed-writer", name: "报告撰写助手", description: "把各阶段产出整理成结构化报告" },
verifier: { agent_id: "mat-seed-verifier", name: "数据核查员", description: "交叉校验数据与结论的可靠性" },
rival: { agent_id: "mat-seed-rival", name: "竞品情报员", description: "跟踪竞品动态与公开信息" },
researcher: { agent_id: "mat-seed-researcher", name: "行业研究员", description: "梳理行业政策与市场变化" },
forecaster: { agent_id: "mat-seed-forecaster", name: "趋势预测专家", description: "基于数据推演后续走势" },
reviewer: { agent_id: "mat-seed-reviewer", name: "内容审核官", description: "审核报告口径与合规风险" },
} satisfies Record<string, MatTask["seats"][number]>;
const tasks: MatTask[] = [
{
id: "mat-task-daily",
name: "每日舆情早报",
description: "每天早上自动汇总过去 24 小时重点舆情,并行采集 + 研判 + 成稿。",
prompt:
"汇总过去 24 小时与本公司相关的重点舆情:先并行完成多源采集与监测,再研判情感与趋势,最后汇总为一份不超过 1500 字的舆情早报,附风险提示。",
coordinator_prompt: "采集阶段允许两个智能体各自独立检索,不做信息合并;研判阶段需引用采集阶段的原始线索。",
cron_expr: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: true,
seats: [seats.collector, seats.monitor, seats.analyst, seats.writer],
stages: [
[seats.collector.agent_id, seats.monitor.agent_id],
[seats.analyst.agent_id],
[seats.writer.agent_id],
],
stage_goals: ["并行完成全网采集与重点信源监测", "基于原始线索完成情感与趋势研判", "汇总为可直发的舆情早报"],
created_at: hoursAgo(24 * 18),
updated_at: hoursAgo(26),
},
{
id: "mat-task-weekly",
name: "周度竞品情报速览",
description: "每周一串行走查竞品动态,核查后输出速览简报。",
prompt: "跟踪 5 家核心竞品过去一周的公开动态(产品、市场、人事),核查数据可信度后输出竞品速览简报。",
cron_expr: "30 8 * * 1",
timezone: "Asia/Shanghai",
enabled: false,
seats: [seats.rival, seats.verifier, seats.writer],
stages: null,
stage_goals: null,
created_at: hoursAgo(24 * 40),
updated_at: hoursAgo(24 * 3),
},
{
id: "mat-task-monthly",
name: "月度行业洞察报告",
description: "每月并行完成行业研究、趋势预测与数据核查,审核后成稿。",
prompt:
"围绕所在行业输出月度洞察:行业研究员、趋势预测专家与数据核查员并行推进,随后由内容审核官把关,最终产出月度洞察报告。",
cron_expr: "0 10 1 * *",
timezone: "Asia/Shanghai",
enabled: true,
seats: [seats.researcher, seats.forecaster, seats.verifier, seats.reviewer, seats.writer],
stages: [
[seats.researcher.agent_id, seats.forecaster.agent_id, seats.verifier.agent_id],
[seats.reviewer.agent_id],
[seats.writer.agent_id],
],
stage_goals: ["并行完成行业研究、趋势预测与数据核查", "审核结论口径与合规风险", "整合为月度洞察报告"],
created_at: hoursAgo(24 * 9),
updated_at: hoursAgo(24 * 9),
},
];
const runs: MatRunRecord[] = [
runSeed("mat-task-daily", 2.2, 3, null),
runSeed("mat-task-daily", 26, 3, null),
runSeed("mat-task-daily", 50, 3, null),
runSeed("mat-task-weekly", 24 * 3, 3, 2),
runSeed("mat-task-weekly", 24 * 10, 3, null),
];
return { tasks, runs };
}
function runSeed(
taskId: string,
hoursAgoValue: number,
stageTotal: number,
failStage: number | null,
): MatRunRecord {
const started = hoursAgo(hoursAgoValue);
return {
id: uuid() as string,
task_id: taskId,
trigger: "schedule",
scheduled_for: started,
started_at: started,
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
fail_stage: failStage,
};
}
/** 由记录 + 当前时间推导运行视图:排队 → 按阶段推进 → 成功/失败。 */
function resolveRunView(run: MatRunRecord, stages: string[][], now = Date.now()): MatRunView {
const startedAt = new Date(run.started_at).getTime();
const elapsed = now - startedAt;
const stageTotal = Math.max(1, stages.length);
const duration = run.duration_ms || PENDING_MS + stageTotal * STAGE_MS;
const buildStates = (
status: MatRunStatus,
activeStage: number,
): Record<string, MatSeatRunState> => {
const states: Record<string, MatSeatRunState> = {};
stages.forEach((stage, index) => {
const seatState: MatSeatRunState =
status === "succeeded"
? "done"
: index < activeStage
? "done"
: index === activeStage
? status === "failed"
? "failed"
: "running"
: "waiting";
stage.forEach((agentId) => {
states[agentId] = seatState;
});
});
return states;
};
if (elapsed < PENDING_MS) {
return {
...run,
finished_at: null,
status: "pending",
stage_index: 0,
stage_total: stageTotal,
seat_states: buildStates("pending", -1),
error: null,
};
}
const failStage = typeof run.fail_stage === "number" ? run.fail_stage : null;
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
const activeStage = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
if (failStage !== null && activeStage >= failStage) {
const finishedAt = new Date(startedAt + PENDING_MS + (failStage + 1) * STAGE_MS).toISOString();
return {
...run,
finished_at: finishedAt,
status: "failed",
stage_index: failStage,
stage_total: stageTotal,
seat_states: buildStates("failed", failStage),
error: `阶段 ${failStage + 1} 执行失败:模拟的执行异常(前端演示数据)`,
};
}
if (elapsed >= duration) {
return {
...run,
finished_at: new Date(startedAt + duration).toISOString(),
status: "succeeded",
stage_index: stageTotal - 1,
stage_total: stageTotal,
seat_states: buildStates("succeeded", stageTotal),
error: null,
};
}
return {
...run,
finished_at: null,
status: "running",
stage_index: activeStage,
stage_total: stageTotal,
seat_states: buildStates("running", activeStage),
error: null,
};
}
function buildTaskViews(now = Date.now()): MatTaskView[] {
const store = readStore();
return store.tasks.map((task) => {
const stages = expandStages(task);
const runs = store.runs
.filter((run) => run.task_id === task.id)
.map((run) => resolveRunView(run, stages, now))
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
const next = task.enabled ? nextCronDate(task.cron_expr, new Date(now)) : null;
return {
...task,
runs,
lastRun: runs[0] ?? null,
nextRunAt: next ? next.toISOString() : null,
};
});
}
function anyRunLive(tasks: MatTaskView[] | undefined): boolean {
return !!tasks?.some((task) =>
task.runs.some((run) => run.status === "pending" || run.status === "running"),
);
}
export function useMultiAgentTasks() {
const query = useQuery({
queryKey: ["mat", "tasks"],
queryFn: () => buildTaskViews(),
refetchInterval: (q) => (anyRunLive(q.state.data as MatTaskView[] | undefined) ? 1_800 : false),
});
return { tasks: query.data ?? [], isLoading: query.isLoading, error: query.error };
}
export function useMultiAgentTaskActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mat", "tasks"] });
const create = useMutation({
mutationFn: async (payload: MatTaskPayload) => {
const now = new Date().toISOString();
const task: MatTask = { ...payload, id: uuid() as string, created_at: now, updated_at: now };
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
return task;
},
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: MatTaskPayload }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === id ? { ...task, ...payload, updated_at: new Date().toISOString() } : task,
),
}));
},
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: async (id: string) => {
mutateStore((store) => ({
tasks: store.tasks.filter((task) => task.id !== id),
runs: store.runs.filter((run) => run.task_id !== id),
}));
},
onSuccess: invalidate,
});
const setEnabled = useMutation({
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === id ? { ...task, enabled, updated_at: new Date().toISOString() } : task,
),
}));
},
onSuccess: invalidate,
});
const trigger = useMutation({
mutationFn: async (id: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === id);
if (!task) throw new Error("任务不存在");
const stageTotal = expandStages(task).length;
const now = new Date().toISOString();
const run: MatRunRecord = {
id: uuid() as string,
task_id: id,
trigger: "manual",
scheduled_for: now,
started_at: now,
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
fail_stage: null,
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
const removeRun = useMutation({
mutationFn: async (runId: string) => {
mutateStore((store) => ({ ...store, runs: store.runs.filter((run) => run.id !== runId) }));
},
onSuccess: invalidate,
});
return { create, update, remove, setEnabled, trigger, removeRun };
}
@@ -1,92 +0,0 @@
/**
* 多智能体定时任务 —— 前端先行数据模型。
*
* 编排语义与业务链条保持一致:同一阶段(stage)内的多个智能体并行执行,
* 阶段之间串行执行;`stages: null` 表示纯串行线性链(每个智能体一层)。
* 当前后端尚未提供接口,数据先落 localStorage,接口就绪后只需替换 store 层。
*/
export type MatRunStatus = "pending" | "running" | "succeeded" | "failed";
export type MatSeatRunState = "waiting" | "running" | "done" | "failed";
export type MatRunTrigger = "manual" | "schedule";
/** 编排中的一个智能体席位(落库时保存名称快照,展示优先用快照)。 */
export interface MatSeat {
agent_id: string;
name: string;
description?: string;
}
export interface MatTask {
id: string;
name: string;
description?: string;
/** 任务指令:本次定时执行要让智能体编队完成什么。 */
prompt: string;
/** 总控(协调者)提示词:告诉总控如何编排各阶段的产出。 */
coordinator_prompt?: string;
cron_expr: string;
timezone: string;
enabled: boolean;
seats: MatSeat[];
/** `stages[i]` 内的智能体并行,i 递增串行;null = 线性串行。 */
stages: string[][] | null;
/** 与 stages 同下标对齐的每阶段目标。 */
stage_goals?: (string | null)[] | null;
created_at: string;
updated_at: string;
}
/** 落库的运行记录;状态由 started_at + duration_ms 在读取时推导。 */
export interface MatRunRecord {
id: string;
task_id: string;
trigger: MatRunTrigger;
scheduled_for: string;
started_at: string;
duration_ms: number;
/** 模拟在该阶段(下标)失败;null = 成功。 */
fail_stage?: number | null;
}
/** 读取时推导出的运行视图。 */
export interface MatRunView {
id: string;
task_id: string;
trigger: MatRunTrigger;
scheduled_for: string;
started_at: string;
finished_at: string | null;
status: MatRunStatus;
/** 当前执行到的阶段下标(从 0 开始)。 */
stage_index: number;
stage_total: number;
seat_states: Record<string, MatSeatRunState>;
error: string | null;
}
export interface MatTaskView extends MatTask {
runs: MatRunView[];
lastRun: MatRunView | null;
nextRunAt: string | null;
}
/** 编辑器内部使用的阶段草稿。 */
export interface MatStageGroup {
seats: MatSeat[];
goal: string;
}
/** 编辑器保存时对外提交的载荷。 */
export interface MatTaskPayload {
name: string;
description?: string;
prompt: string;
coordinator_prompt?: string;
cron_expr: string;
timezone: string;
enabled: boolean;
seats: MatSeat[];
stages: string[][] | null;
stage_goals: (string | null)[] | null;
}
@@ -1,166 +0,0 @@
import {
DEFAULT_SCHEDULE_CONFIG,
type ScheduleConfig,
parseCron,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import type { MatSeat, MatTask, MatTaskView } from "./types";
const WEEKDAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function pad2(value: number) {
return String(value).padStart(2, "0");
}
/** 把任务里的 stages 语义展开成显式阶段列表;null = 每个席位独立一层。 */
export function expandStages(task: Pick<MatTask, "seats" | "stages">): string[][] {
const known = new Set(task.seats.map((seat) => seat.agent_id));
if (!task.stages?.length) {
return task.seats.map((seat) => [seat.agent_id]);
}
const expanded = task.stages
.map((stage) => stage.filter((id) => known.has(id)))
.filter((stage) => stage.length > 0);
return expanded.length ? expanded : task.seats.map((seat) => [seat.agent_id]);
}
export function describeScheduleConfig(cfg: ScheduleConfig): string {
const clock = `${pad2(cfg.hour)}:${pad2(cfg.minute)}`;
switch (cfg.freq) {
case "hourly":
return `每小时 ${cfg.minute} 分`;
case "weekly":
return `每${WEEKDAY_LABELS[cfg.weekday] ?? "周一"} ${clock}`;
case "monthly":
return `每月 ${cfg.date} 日 ${clock}`;
default:
return `每天 ${clock}`;
}
}
/** 由 cron 反推人类可读的执行频率描述(覆盖本页生成的所有 cron 形态)。 */
export function describeCron(cron: string | null | undefined): string {
const cfg = parseCron(cron ?? undefined);
if (!cfg) return cron || "未设置";
return describeScheduleConfig(cfg);
}
/** 计算下一次执行时间;仅支持本页 schedule picker 生成的 5 段 cron 形态。 */
export function nextCronDate(cron: string | null | undefined, from = new Date()): Date | null {
const cfg = parseCron(cron ?? undefined);
if (!cfg) return null;
const candidate = new Date(from);
candidate.setSeconds(0, 0);
switch (cfg.freq) {
case "hourly": {
candidate.setMinutes(cfg.minute);
if (candidate <= from) candidate.setHours(candidate.getHours() + 1);
return candidate;
}
case "daily": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
if (candidate <= from) candidate.setDate(candidate.getDate() + 1);
return candidate;
}
case "weekly": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
const day = candidate.getDay();
let delta = (cfg.weekday - day + 7) % 7;
if (delta === 0 && candidate <= from) delta = 7;
candidate.setDate(candidate.getDate() + delta);
return candidate;
}
case "monthly": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
candidate.setDate(cfg.date);
if (candidate <= from) candidate.setMonth(candidate.getMonth() + 1);
return candidate;
}
default:
return null;
}
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function formatRelative(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const elapsed = Date.now() - date.getTime();
if (elapsed < 60_000) return "刚刚";
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
if (elapsed < 7 * 86_400_000) return `${Math.floor(elapsed / 86_400_000)} 天前`;
return formatDateTime(value);
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "—";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds} 秒`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`;
}
export function runStatusLabel(status: string | null | undefined): string {
switch (status) {
case "succeeded":
return "执行成功";
case "failed":
return "执行失败";
case "running":
return "执行中";
case "pending":
return "排队中";
default:
return "暂无执行";
}
}
export type MatTaskBadge = {
label: string;
theme: "success" | "warning" | "danger" | "default";
};
/** 任务卡右上角的状态徽标:以最近一次执行状态优先,其次启停。 */
export function taskBadgeOf(task: MatTaskView): MatTaskBadge {
const status = task.lastRun?.status;
if (status === "running" || status === "pending") {
return { label: "执行中", theme: "warning" };
}
if (!task.enabled) return { label: "已暂停", theme: "default" };
if (status === "failed") return { label: "上次失败", theme: "danger" };
if (status === "succeeded") return { label: "运行正常", theme: "success" };
return { label: "已启用", theme: "success" };
}
/** 席位配色槽位:按 agent_id 稳定散列到 0..7。 */
export function seatColorIndex(agentId: string): number {
let hash = 0;
for (let i = 0; i < agentId.length; i += 1) {
hash = (hash * 31 + agentId.charCodeAt(i)) >>> 0;
}
return hash % 8;
}
export function seatIndexOf(seats: MatSeat[], agentId: string): number {
const index = seats.findIndex((seat) => seat.agent_id === agentId);
return index >= 0 ? index : seats.length;
}
export function defaultScheduleConfig(): ScheduleConfig {
return { ...DEFAULT_SCHEDULE_CONFIG };
}
@@ -1,340 +0,0 @@
/**
* 任务分析 · 详情页:左侧执行记录(定时 / 手动),右侧按选中运行回放流程图
* (业务链风格节点状态 + 耗时,5 秒自动刷新),点击智能体节点打开执行过程抽屉。
*/
import {
ArrowLeft,
CalendarClock,
CircleAlert,
GitBranch,
MessageSquareText,
Pencil,
Pause,
Play,
RefreshCw,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Switch, Tag } from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import {
formatDateTime,
formatDuration,
formatRelative,
} from "@/pages/official-multi-agent-tasks/utils";
import BindingDialog from "./components/BindingDialog";
import SeatProcessDrawer from "./components/SeatProcessDrawer";
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
import { taskBadgeOf } from "./badge";
import { useTaTaskActions, useTaTasks, useTaTaskRuns } from "./store";
const RUN_STATUS_TEXT: Record<string, string> = {
succeeded: "成功",
failed: "失败",
running: "执行中",
pending: "排队中",
};
const RUN_STATUS_THEME: Record<string, "success" | "danger" | "warning" | "default"> = {
succeeded: "success",
failed: "danger",
running: "warning",
pending: "default",
};
export default function TaskAnalysisDetailPage({
routeTaskId,
}: {
routeTaskId: string | null;
}) {
const navigate = useNavigate();
const { views, isLoading } = useTaTasks();
const actions = useTaTaskActions();
const taskView = views.find(({ task }) => task.id === routeTaskId) ?? null;
const task = taskView?.task ?? null;
const { runs, isLoading: runsLoading } = useTaTaskRuns(task?.id ?? null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [seatAgentId, setSeatAgentId] = useState<string | null>(null);
const [bindingVisible, setBindingVisible] = useState(false);
const [busy, setBusy] = useState(false);
// 默认选中最近一次运行;新触发的运行自动跟随。
useEffect(() => {
if (!runs.length) return;
if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) {
setSelectedRunId(runs[0]!.id);
}
}, [runs, selectedRunId]);
const selectedRun = useMemo(
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
[runs, selectedRunId],
);
const backToList = () => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`);
};
const runTrigger = async (manual: boolean) => {
if (!task) return;
setBusy(true);
try {
if (manual) {
await actions.triggerManualRun.mutateAsync(task.id);
toast.success("已发起手动执行(总控问答)");
} else {
await actions.triggerRun.mutateAsync(task.id);
toast.success("已触发定时执行");
}
setSelectedRunId(null);
} catch {
toast.error("任务未绑定分析方案");
} finally {
setBusy(false);
}
};
const runToggle = async () => {
if (!task?.binding) return;
setBusy(true);
try {
await actions.setEnabled.mutateAsync({
taskId: task.id,
enabled: !task.binding?.enabled,
});
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusy(false);
}
};
if (!isLoading && !task) {
return (
<section className="ta-page">
<div className="ta-state ta-state--empty">
<h3>任务不存在或已被删除</h3>
<Button theme="primary" onClick={backToList}>
返回任务列表
</Button>
</div>
</section>
);
}
const binding = task?.binding ?? null;
const taskBadge = task && taskView ? taskBadgeOf(task, taskView.runs) : null;
const live = selectedRun?.status === "running" || selectedRun?.status === "pending";
return (
<section className="ta-page ta-detail-page">
<header className="ta-detail-head">
<button
aria-label="返回任务列表"
className="ta-back"
onClick={backToList}
type="button"
>
<ArrowLeft size={18} />
</button>
<div className="ta-detail-heading">
<h1>
{task?.name ?? "…"}
<em>{task?.sourceTaskId}</em>
</h1>
<p>
{binding ? (
<>
<GitBranch aria-hidden="true" size={13} />
{binding.chainTitle} · {binding.cronText}
{binding.directionNotes.length > 0 &&
` · ${binding.directionNotes.length} 条方向备注`}
</>
) : (
task?.description
)}
</p>
</div>
{taskBadge && (
<Tag theme={taskBadge.theme} variant="light">
{taskBadge.label}
</Tag>
)}
{binding && (
<Switch value={binding.enabled} onChange={() => void runToggle()} />
)}
<div className="ta-detail-ops">
{binding && (
<>
<Button
disabled={busy}
loading={busy}
theme="primary"
onClick={() => void runTrigger(false)}
>
<Zap aria-hidden="true" size={15} />
立即执行
</Button>
<Button variant="outline" disabled={busy} onClick={() => void runTrigger(true)}>
<MessageSquareText aria-hidden="true" size={15} />
模拟手动执行
</Button>
</>
)}
<Button variant="outline" onClick={() => setBindingVisible(true)}>
<Pencil aria-hidden="true" size={15} />
{binding ? "编辑绑定" : "配置分析"}
</Button>
</div>
</header>
{!binding ? (
<div className="ta-detail-empty">
<GitBranch aria-hidden="true" size={26} />
<h3>尚未配置分析方案</h3>
<p>绑定业务链与内容块映射后,这里将展示执行记录与各智能体的执行流程。</p>
<Button theme="primary" onClick={() => setBindingVisible(true)}>
配置分析
</Button>
</div>
) : (
<div className="ta-detail-layout">
<aside className="ta-runs">
<header className="ta-runs-head">
<h2>执行记录</h2>
<span>
<RefreshCw aria-hidden="true" className={live ? "ta-spin" : undefined} size={12} />
每 5 秒刷新
</span>
</header>
<div className="ta-runs-list">
{runsLoading && !runs.length ? (
<p className="ta-runs-empty">正在加载执行记录…</p>
) : runs.length ? (
runs.map((run) => {
const active = selectedRun?.id === run.id;
const duration = run.finishedAt
? formatDuration(
new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(),
)
: null;
return (
<button
className={`ta-run-item${active ? " is-active" : ""}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
type="button"
>
<span className="ta-run-item-top">
{run.trigger === "manual" ? (
<MessageSquareText aria-hidden="true" size={13} />
) : (
<CalendarClock aria-hidden="true" size={13} />
)}
<b>{run.trigger === "manual" ? "手动执行" : "定时执行"}</b>
<Tag size="small" theme={RUN_STATUS_THEME[run.status] ?? "default"} variant="light">
{RUN_STATUS_TEXT[run.status] ?? run.status}
</Tag>
</span>
<span className="ta-run-item-time">
{formatDateTime(run.startedAt)} · {duration ?? "进行中"}
</span>
{run.trigger === "manual" && run.manualSummary && (
<span className="ta-run-item-summary">{run.manualSummary}</span>
)}
{run.trigger === "schedule" && (
<span className="ta-run-item-blocks">
内容块 {run.blockStates.filter((b) => b.state === "done").length}/
{run.blockStates.length}
</span>
)}
</button>
);
})
) : (
<p className="ta-runs-empty">还没有执行记录,点「立即执行」体验完整流程。</p>
)}
</div>
</aside>
<section className="ta-flow-panel">
{selectedRun ? (
<>
<header className="ta-flow-head">
<h2>
执行流程
<small>
{selectedRun.trigger === "manual" ? "手动执行" : "定时执行"} ·{" "}
{formatRelative(selectedRun.startedAt)}
{selectedRun.status === "running" &&
` · 阶段 ${Math.min(selectedRun.stageIndex + 1, selectedRun.stageTotal)}/${selectedRun.stageTotal} 执行中`}
</small>
</h2>
<span className="ta-flow-legend">
<i className="ta-dot is-done" /> 完成
<i className="ta-dot is-running" /> 执行中
<i className="ta-dot is-waiting" /> 等待
<i className="ta-dot is-failed" /> 失败
</span>
</header>
<div className="ta-flow-canvas">
<ChainFlowDiagram
className="ta-flow-diagram"
onNodeClick={(agentId) => setSeatAgentId(agentId)}
seatStates={selectedRun.seatInfos}
stages={selectedRun.stages.map((stage, index) => ({
seats: stage.map((agentId) => ({
agent_id: agentId,
name: selectedRun.seatNames[agentId] ?? agentId,
})),
goal: selectedRun.stageGoals?.[index] ?? null,
}))}
/>
</div>
{selectedRun.error && (
<p className="ta-flow-error">
<CircleAlert aria-hidden="true" size={14} />
{selectedRun.error}
</p>
)}
</>
) : (
<div className="ta-flow-panel-empty">选择左侧一条执行记录查看流程</div>
)}
</section>
</div>
)}
<SeatProcessDrawer
onClose={() => setSeatAgentId(null)}
run={selectedRun}
seatAgentId={seatAgentId}
task={task ?? undefined}
/>
<BindingDialog
onClose={() => setBindingVisible(false)}
onSubmit={async (payload) => {
if (!task) return;
try {
await actions.bind.mutateAsync({ taskId: task.id, payload });
toast.success(binding ? "绑定已更新" : "分析方案已绑定");
setBindingVisible(false);
} catch {
toast.error("保存失败,请重试");
}
}}
submitting={actions.bind.isPending}
task={task}
visible={bindingVisible}
/>
</section>
);
}
@@ -1,460 +0,0 @@
/**
* 任务分析 · 列表页:对方系统推送过来的外部任务 + 我们的绑定状态。
* 任务卡片沿用多智能体定时任务的卡片视觉(浅红规范)。
*/
import {
CalendarClock,
CircleAlert,
CircleCheck,
Clock3,
GitBranch,
Link2,
Network,
Pencil,
Pause,
Play,
Plus,
Search,
Trash2,
Zap,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
DialogPlugin,
Input,
Popconfirm,
Switch,
Tabs,
Tag,
} from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import {
formatDateTime,
formatRelative,
} from "@/pages/official-multi-agent-tasks/utils";
import BindingDialog from "./components/BindingDialog";
import CreateTaskDialog, {
type TaCreateTaskPayload,
} from "./components/CreateTaskDialog";
import { chainStagesOf, useTaTaskActions, useTaTasks } from "./store";
import type { TaTask, TaTaskViewLike } from "./page-types";
import { taskBadgeOf } from "./badge";
import "./task-analysis.css";
type StatusFilter = "all" | "unbound" | "enabled" | "paused" | "failed";
function isTaskLive(runs: TaTaskViewLike["runs"]) {
return runs.some((run) => run.status === "running" || run.status === "pending");
}
export default function TaskAnalysisPage() {
const navigate = useNavigate();
const { views, isLoading } = useTaTasks();
const actions = useTaTaskActions();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<StatusFilter>("all");
const [bindingTask, setBindingTask] = useState<TaTask | null>(null);
const [bindingVisible, setBindingVisible] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const visibleViews = useMemo(() => {
const keyword = search.trim().toLowerCase();
return views.filter(({ task, runs }) => {
const lastStatus = runs[0]?.status ?? null;
const bound = !!task.binding;
const live = isTaskLive(runs);
if (filter === "unbound" && bound) return false;
if (filter === "enabled" && (!bound || !task.binding!.enabled || live)) return false;
if (filter === "paused" && (!bound || task.binding!.enabled)) return false;
if (filter === "failed" && lastStatus !== "failed") return false;
if (!keyword) return true;
return (
task.name.toLowerCase().includes(keyword) ||
task.description.toLowerCase().includes(keyword) ||
task.sourceTaskId.toLowerCase().includes(keyword)
);
});
}, [views, search, filter]);
const stats = useMemo(
() => ({
total: views.length,
unbound: views.filter(({ task }) => !task.binding).length,
enabled: views.filter(({ task }) => task.binding?.enabled).length,
failed: views.filter(({ runs }) => runs[0]?.status === "failed").length,
}),
[views],
);
const openDetail = (taskId: string) => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis/${encodeURIComponent(taskId)}`);
};
const openBinding = (task: TaTask) => {
setBindingTask(task);
setBindingVisible(true);
};
const handleCreate = async (payload: TaCreateTaskPayload) => {
try {
const created = await actions.createTask.mutateAsync({
name: payload.name,
sourceTaskId: payload.sourceTaskId,
description: payload.description,
});
await actions.bind.mutateAsync({ taskId: created.id, payload: payload.binding });
toast.success("任务已创建并绑定分析方案");
setCreateVisible(false);
} catch {
toast.error("创建失败,请重试");
}
};
const handleBind = async (payload: Parameters<typeof actions.bind.mutateAsync>[0]["payload"]) => {
if (!bindingTask) return;
try {
await actions.bind.mutateAsync({ taskId: bindingTask.id, payload });
toast.success(bindingTask.binding ? "绑定已更新" : "分析方案已绑定");
setBindingVisible(false);
setBindingTask(null);
} catch {
toast.error("保存失败,请重试");
}
};
const runTrigger = async (taskId: string) => {
setBusyTaskId(taskId);
try {
await actions.triggerRun.mutateAsync(taskId);
toast.success("已触发执行");
openDetail(taskId);
} catch {
toast.error("触发失败:任务未绑定分析方案");
} finally {
setBusyTaskId(null);
}
};
const runToggle = async (task: TaTask) => {
setBusyTaskId(task.id);
try {
await actions.setEnabled.mutateAsync({ taskId: task.id, enabled: !task.binding?.enabled });
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const confirmDelete = (task: TaTask) => {
const dialog = DialogPlugin.confirm({
header: "删除外部任务",
body: `删除「${task.name}」将同时清除其绑定与全部执行记录(对应对外删除接口语义),确定删除?`,
confirmBtn: { content: "删除", theme: "danger" },
cancelBtn: "取消",
onConfirm: () => {
dialog.destroy();
void actions.removeTask.mutateAsync(task.id).then(() => {
toast.success("任务已删除");
});
},
onClose: () => dialog.destroy(),
});
};
return (
<section className="ta-page">
<header className="ta-page-head">
<div className="ta-page-head-copy">
<h1>任务分析定时</h1>
<p>
外部系统任务接入多智能体定时分析:绑定业务链与内容块映射,按计划执行并沉淀时间线版本。
</p>
</div>
<div className="ta-page-head-actions">
<Button
variant="outline"
onClick={() => {
const unbound = views.find(({ task }) => !task.binding)?.task;
if (unbound) openBinding(unbound);
else toast.info("当前任务均已绑定分析方案");
}}
>
配置未绑定任务
</Button>
<Button theme="primary" onClick={() => setCreateVisible(true)}>
<Plus aria-hidden="true" size={15} />
新建任务
</Button>
</div>
</header>
<div className="ta-stats" aria-label="任务统计">
<div className="ta-stat">
<span className="ta-stat-icon is-primary" aria-hidden="true">
<Network size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.total}</strong>
<span>外部任务</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-warning" aria-hidden="true">
<Link2 size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.unbound}</strong>
<span>未配置</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-success" aria-hidden="true">
<CircleCheck size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.enabled}</strong>
<span>已启用</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-danger" aria-hidden="true">
<CircleAlert size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.failed}</strong>
<span>最近失败</span>
</div>
</div>
</div>
<div className="ta-body">
<div className="ta-toolbar">
<div className="ta-toolbar-search">
<Input
clearable
placeholder="搜索任务名称 / 简介 / 对方任务号"
prefixIcon={<Search aria-hidden="true" size={15} />}
value={search}
onChange={(value) => setSearch(String(value ?? ""))}
/>
</div>
<Tabs
className="ta-toolbar-tabs"
value={filter}
onChange={(value) => setFilter(value as StatusFilter)}
>
<Tabs.TabPanel label="全部" value="all" />
<Tabs.TabPanel label="未配置" value="unbound" />
<Tabs.TabPanel label="已启用" value="enabled" />
<Tabs.TabPanel label="已暂停" value="paused" />
<Tabs.TabPanel label="最近失败" value="failed" />
</Tabs>
</div>
{isLoading ? (
<div className="ta-state">
<Clock3 aria-hidden="true" className="ta-spin" size={18} />
正在加载外部任务…
</div>
) : visibleViews.length ? (
<div className="ta-grid">
{visibleViews.map(({ task, runs }) => {
const badge = taskBadgeOf(task, runs);
const binding = task.binding;
const lastRun = runs[0] ?? null;
const busy = busyTaskId === task.id;
const stages = binding ? chainStagesOf(binding) : [];
const seats: MatSeat[] = stages
.flat()
.filter((id, index, all) => all.indexOf(id) === index)
.map((id) => ({
agent_id: id,
name: lastRun?.seatNames[id] ?? id,
}));
return (
<article className="ta-card" key={task.id}>
<header className="ta-card-head" onClick={() => openDetail(task.id)}>
<span className="ta-card-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="ta-card-title">
<h3>
{task.name}
<em>{task.sourceTaskId}</em>
</h3>
<p>{task.description}</p>
</div>
<Tag size="small" theme={badge.theme} variant="light">
{badge.label}
</Tag>
</header>
{binding ? (
<>
<div className="ta-card-binding" onClick={() => openDetail(task.id)}>
<span>
<GitBranch aria-hidden="true" size={13} />
{binding.chainTitle}
</span>
<span>
<CalendarClock aria-hidden="true" size={13} />
{binding.cronText}
</span>
</div>
<div className="ta-card-flow" onClick={() => openDetail(task.id)}>
<AgentFlow seats={seats} size="sm" stages={stages} />
</div>
</>
) : (
<button className="ta-card-unbound" onClick={() => openBinding(task)} type="button">
<GitBranch aria-hidden="true" size={15} />
配置分析:选择业务链 · 内容块映射 · 执行频率
</button>
)}
<footer className="ta-card-foot">
<div className="ta-card-meta">
<span>
下次{" "}
{binding?.enabled
? formatDateTime(nextRunAt(binding.cron))
: binding
? "已暂停"
: "未配置"}
</span>
<span>
上次 {lastRun ? formatRelative(lastRun.startedAt) : "未执行"}
{lastRun && lastRun.status !== "pending" && (
<em className={`ta-run-text is-${lastRun.status}`}>
· {lastRun.status === "succeeded" ? "成功" : lastRun.status === "failed" ? "失败" : "执行中"}
</em>
)}
</span>
</div>
<div className="ta-card-ops">
{binding && (
<>
<Button
disabled={busy}
loading={busy}
size="small"
theme="primary"
variant="text"
onClick={() => void runTrigger(task.id)}
>
<Zap aria-hidden="true" size={14} />
立即执行
</Button>
<Button
disabled={busy}
size="small"
variant="text"
onClick={() => void runToggle(task)}
>
{binding.enabled ? (
<Pause aria-hidden="true" size={14} />
) : (
<Play aria-hidden="true" size={14} />
)}
{binding.enabled ? "暂停" : "恢复"}
</Button>
<Button size="small" variant="text" onClick={() => openBinding(task)}>
<Pencil aria-hidden="true" size={14} />
编辑绑定
</Button>
</>
)}
<Popconfirm
content="删除后绑定与执行记录一并清除,确定?"
placement="bottom-right"
theme="danger"
onConfirm={() => confirmDelete(task)}
>
<Button className="ta-danger-btn" size="small" variant="text">
<Trash2 aria-hidden="true" size={14} />
</Button>
</Popconfirm>
</div>
</footer>
</article>
);
})}
</div>
) : (
<div className="ta-state ta-state--empty">
<span className="ta-state-glyph" aria-hidden="true">
<Network size={22} />
</span>
<h3>{views.length ? "没有符合条件的任务" : "还没有外部任务"}</h3>
<p>
{views.length
? "换个关键词或筛选条件试试。"
: "对方系统创建任务并调用创建接口后,任务会出现在这里。"}
</p>
</div>
)}
<p className="ta-footnote">
<CircleAlert aria-hidden="true" size={13} />
前端演示阶段:任务与执行数据保存在本浏览器,业务链来自真实接口;后端服务接入后自动切换。
</p>
</div>
<CreateTaskDialog
onClose={() => setCreateVisible(false)}
onSubmit={handleCreate}
submitting={actions.createTask.isPending}
visible={createVisible}
/>
<BindingDialog
onClose={() => {
setBindingVisible(false);
setBindingTask(null);
}}
onSubmit={handleBind}
submitting={actions.bind.isPending}
task={bindingTask}
visible={bindingVisible}
/>
</section>
);
}
/** 简易下次执行时间(覆盖本页生成的 cron 形态)。 */
function nextRunAt(cron: string): string | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hour, , , wd] = parts;
const now = new Date();
const candidate = new Date(now);
const minute = parseInt(min ?? "0", 10);
const hourNum = parseInt(hour ?? "9", 10);
if (`${hour}` === "*") {
candidate.setMinutes(minute, 0, 0);
if (candidate <= now) candidate.setHours(candidate.getHours() + 1);
} else if (`${wd}` !== "*") {
const weekday = parseInt(wd ?? "1", 10);
candidate.setHours(hourNum, minute, 0, 0);
let delta = (weekday - candidate.getDay() + 7) % 7;
if (delta === 0 && candidate <= now) delta = 7;
candidate.setDate(candidate.getDate() + delta);
} else {
candidate.setHours(hourNum, minute, 0, 0);
if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
}
return candidate.toISOString();
}
@@ -1,19 +0,0 @@
import type { TaRunView, TaTask } from "./types";
export interface TaBadge {
label: string;
theme: "success" | "warning" | "danger" | "default";
}
/** 任务卡右上角状态徽标:最近一次执行优先,其次启停,未绑定给引导态。 */
export function taskBadgeOf(task: TaTask, runs: TaRunView[]): TaBadge {
const status = runs[0]?.status;
if (status === "running" || status === "pending") {
return { label: "执行中", theme: "warning" };
}
if (!task.binding) return { label: "未配置", theme: "default" };
if (!task.binding.enabled) return { label: "已暂停", theme: "default" };
if (status === "failed") return { label: "上次失败", theme: "danger" };
if (status === "succeeded") return { label: "运行正常", theme: "success" };
return { label: "已启用", theme: "success" };
}
@@ -1,223 +0,0 @@
/**
* 绑定配置弹窗:选业务链(真实 /api/roundtable-chains + 内置演示链)→ 配置
* 对方详情块 → 席位映射 → 执行频率 / 启用 / 总控提示。
*/
import { Radio } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Dialog, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import {
defaultScheduleConfig,
describeScheduleConfig,
} from "@/pages/official-multi-agent-tasks/utils";
import type { TaBinding, TaBindingPayload, TaTask } from "../types";
/** 内置演示链(种子里用它,保证无后端数据时链路可演示)。 */
export const SEED_CHAIN: BusinessChain = {
id: "seed-chain-std",
title: "标准舆情分析链(内置)",
description: "采集 + 核查并行 → 研判 → 成稿,演示用内置编排。",
seats: [
{ agent_id: "collector", name: "全网舆情采集" },
{ agent_id: "verifier", name: "数据核查员" },
{ agent_id: "analyst", name: "舆情研判分析" },
{ agent_id: "writer", name: "报告撰写助手" },
],
stages: [
["collector", "verifier"],
["analyst"],
["writer"],
],
stageGoals: null,
isPublic: true,
};
export function chainStages(chain: BusinessChain): string[][] {
if (chain.stages?.length) return chain.stages;
return chain.seats.map((seat) => [seat.agent_id]);
}
export function chainSeats(chain: BusinessChain): MatSeat[] {
return chain.seats.map((seat) => ({
agent_id: seat.agent_id,
name: seat.name,
description: seat.description,
}));
}
/** 模块级空数组:data 未就绪时保持引用稳定,避免 effect 依赖无限循环。 */
export const NO_CHAINS: BusinessChain[] = [];
export default function BindingDialog({
visible,
task,
submitting,
onClose,
onSubmit,
}: {
visible: boolean;
task: TaTask | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: TaBindingPayload) => void;
}) {
const [chainId, setChainId] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const { data, isLoading: chainsLoading } = useQuery({
queryKey: ["ta", "chains"],
queryFn: async () => {
const [mine, publicChains] = await Promise.all([
listChains("mine").catch(() => [] as BusinessChain[]),
listChains("public").catch(() => [] as BusinessChain[]),
]);
const byId = new Map<string, BusinessChain>();
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
if (!byId.has(chain.id)) byId.set(chain.id, chain);
}
return [...byId.values()];
},
enabled: visible,
});
const chains = data ?? NO_CHAINS;
useEffect(() => {
if (!visible) return;
const binding: TaBinding | null = task?.binding ?? null;
const initialChain =
chains.find((chain) => chain.id === binding?.chainId) ??
chains[0] ??
SEED_CHAIN;
setChainId(binding?.chainId ?? initialChain.id);
setEnabled(binding?.enabled ?? true);
setCoordinatorPrompt(binding?.coordinatorPrompt ?? "");
}, [visible, task, chains]);
const selectedChain = useMemo(
() => chains.find((chain) => chain.id === chainId) ?? null,
[chains, chainId],
);
const handleChainChange = (nextId: string) => {
setChainId(nextId);
};
const trySubmit = () => {
if (!selectedChain) {
toast.warning("请选择业务链");
return;
}
onSubmit({
chainId: selectedChain.id,
chainTitle: selectedChain.title,
schedule,
enabled,
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
});
};
return (
<Dialog
className="ta-binding-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="ta-dialog-footer">
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button loading={submitting} theme="primary" onClick={trySubmit}>
{task?.binding ? "保存绑定" : "创建绑定"}
</Button>
</div>
}
header={task?.binding ? `编辑绑定 · ${task.name}` : `配置分析 · ${task?.name ?? ""}`}
placement="top"
visible={visible}
width={860}
onClose={onClose}
>
<div className="ta-binding">
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>选择业务链</h4>
<span>定时执行将按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
</header>
{chainsLoading ? (
<p className="ta-binding-tip">正在加载业务链…</p>
) : (
<div className="ta-chain-grid">
{chains.map((chain) => {
const active = chain.id === chainId;
return (
<button
className={`ta-chain-card${active ? " is-active" : ""}`}
key={chain.id}
onClick={() => handleChainChange(chain.id)}
type="button"
>
<header>
<Radio aria-hidden="true" size={14} />
<span className="ta-chain-card-title">{chain.title}</span>
<em>
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
</em>
</header>
{chain.description && <p>{chain.description}</p>}
<div className="ta-chain-card-flow">
<AgentFlow
seats={chainSeats(chain)}
size="sm"
stages={chainStages(chain)}
/>
</div>
</button>
);
})}
</div>
)}
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>执行设置</h4>
<span>{describeScheduleConfig(schedule)} 自动执行</span>
</header>
<div className="ta-binding-settings">
<div className="ta-field">
<span className="ta-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="ta-field">
<span className="ta-field-label">总控提示(可选)</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={500}
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</label>
<div className="ta-field ta-field--switch">
<span className="ta-field-label">绑定后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</section>
</div>
</Dialog>
);
}
@@ -1,242 +0,0 @@
/**
* 新建任务弹窗(一步式):任务信息(名称/任务号/简介)+ 业务链选择 +
* 执行设置(频率 / 总控提示 / 启用),提交后直接创建任务并完成绑定。
*/
import { Radio } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
import {
defaultScheduleConfig,
describeScheduleConfig,
} from "@/pages/official-multi-agent-tasks/utils";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import { NO_CHAINS, SEED_CHAIN, chainSeats, chainStages } from "./BindingDialog";
import type { TaBindingPayload } from "../types";
export interface TaCreateTaskPayload {
name: string;
sourceTaskId?: string;
description?: string;
binding: TaBindingPayload;
}
export default function CreateTaskDialog({
visible,
submitting,
onClose,
onSubmit,
}: {
visible: boolean;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: TaCreateTaskPayload) => void;
}) {
const [name, setName] = useState("");
const [sourceTaskId, setSourceTaskId] = useState("");
const [description, setDescription] = useState("");
const [chainId, setChainId] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const { data, isLoading: chainsLoading } = useQuery({
queryKey: ["ta", "chains"],
queryFn: async () => {
const [mine, publicChains] = await Promise.all([
listChains("mine").catch(() => [] as BusinessChain[]),
listChains("public").catch(() => [] as BusinessChain[]),
]);
const byId = new Map<string, BusinessChain>();
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
if (!byId.has(chain.id)) byId.set(chain.id, chain);
}
return [...byId.values()];
},
enabled: visible,
});
const chains = data ?? NO_CHAINS;
useEffect(() => {
if (!visible) return;
setName("");
setSourceTaskId("");
setDescription("");
setSchedule(defaultScheduleConfig());
setEnabled(true);
setCoordinatorPrompt("");
setChainId((prev) => (chains.some((chain) => chain.id === prev) ? prev : chains[0]?.id ?? SEED_CHAIN.id));
}, [visible, chains]);
const selectedChain = useMemo(
() => chains.find((chain) => chain.id === chainId) ?? null,
[chains, chainId],
);
const trySubmit = () => {
if (!name.trim()) {
toast.warning("请填写任务名称");
return;
}
if (!selectedChain) {
toast.warning("请选择业务链");
return;
}
onSubmit({
name,
sourceTaskId: sourceTaskId.trim() || undefined,
description: description.trim() || undefined,
binding: {
chainId: selectedChain.id,
chainTitle: selectedChain.title,
schedule,
enabled,
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
},
});
};
return (
<Dialog
className="ta-create-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="ta-dialog-footer">
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button loading={submitting} theme="primary" onClick={trySubmit}>
创建任务并绑定
</Button>
</div>
}
header="新建任务"
placement="top"
visible={visible}
width={860}
onClose={onClose}
>
<div className="ta-binding">
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>任务信息</h4>
<span>由本页录入;接入对方系统后,推送创建的任务同样出现在列表中</span>
</header>
<div className="ta-create-grid">
<label className="ta-field">
<span className="ta-field-label">
任务名称<i className="ta-required">*</i>
</span>
<Input
clearable
maxlength={40}
placeholder="例如:东南亚舆论环境监测"
value={name}
onChange={(value) => setName(String(value ?? ""))}
/>
</label>
<label className="ta-field">
<span className="ta-field-label">对方任务号(可选)</span>
<Input
clearable
maxlength={32}
placeholder="留空自动生成"
value={sourceTaskId}
onChange={(value) => setSourceTaskId(String(value ?? ""))}
/>
</label>
<label className="ta-field ta-field--wide">
<span className="ta-field-label">任务简介</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={200}
placeholder="一句话说明任务背景与分析目标,定时执行时作为任务上下文注入各智能体"
value={description}
onChange={(value) => setDescription(String(value ?? ""))}
/>
</label>
</div>
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>执行设置</h4>
<span>{describeScheduleConfig(schedule)} 自动执行</span>
</header>
<div className="ta-binding-settings">
<div className="ta-field">
<span className="ta-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="ta-field">
<span className="ta-field-label">总控提示(可选)</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={500}
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</label>
<div className="ta-field ta-field--switch">
<span className="ta-field-label">创建后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>选择业务链</h4>
<span>定时执行按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
</header>
{chainsLoading ? (
<p className="ta-binding-tip">正在加载业务链…</p>
) : (
<div className="ta-chain-grid">
{chains.map((chain) => {
const active = chain.id === chainId;
return (
<button
className={`ta-chain-card${active ? " is-active" : ""}`}
key={chain.id}
onClick={() => setChainId(chain.id)}
type="button"
>
<header>
<Radio aria-hidden="true" size={14} />
<span className="ta-chain-card-title">{chain.title}</span>
<em>
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
</em>
</header>
{chain.description && <p>{chain.description}</p>}
<div className="ta-chain-card-flow">
<AgentFlow
seats={chainSeats(chain) as MatSeat[]}
size="sm"
stages={chainStages(chain)}
/>
</div>
</button>
);
})}
</div>
)}
</section>
</div>
</Dialog>
);
}
@@ -1,122 +0,0 @@
/**
* 席位执行过程抽屉:点击流程图节点打开,展示该智能体在某次运行里的完整过程
* (输入 → AI 分析 → 工具调用 → 产物块)。后端就绪后由 runs/{id}/messages?seat= 提供数据。
*/
import { ArrowDownRight, Bot, Wrench } from "lucide-react";
import { Drawer, Tag } from "tdesign-react";
import { seatColorIndex } from "@/pages/official-multi-agent-tasks/utils";
import { buildSeatProcess } from "../store";
import type { TaRunView, TaSeatState, TaTask } from "../types";
const STATE_THEME: Record<TaSeatState, "success" | "warning" | "danger" | "default"> = {
done: "success",
running: "warning",
failed: "danger",
waiting: "default",
};
const STATE_TEXT: Record<TaSeatState, string> = {
waiting: "等待执行",
running: "执行中",
done: "已完成",
failed: "执行失败",
};
const KIND_LABEL: Record<string, string> = {
input: "输入",
ai: "分析",
tool: "工具",
artifact: "产物",
};
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "—";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds} 秒`;
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
}
export default function SeatProcessDrawer({
run,
seatAgentId,
task,
onClose,
}: {
run: TaRunView | null;
seatAgentId: string | null;
task: TaTask | undefined;
onClose: () => void;
}) {
const visible = !!run && !!seatAgentId;
const process =
visible && run ? buildSeatProcess(run, seatAgentId!, task) : null;
const info = run?.seatInfos[seatAgentId ?? ""] ?? null;
return (
<Drawer
className="ta-seat-drawer"
closeBtn
destroyOnClose
footer={null}
header={
process ? (
<div className="ta-seat-header">
<span className={`ta-seat-glyph ta-seat--c${seatColorIndex(seatAgentId ?? "")}`} aria-hidden="true">
<Bot size={18} />
</span>
<div className="ta-seat-heading">
<h3>{process.seatName}</h3>
<p>执行过程 · {run?.trigger === "manual" ? "手动执行(总控问答)" : "定时执行"}</p>
</div>
{info && (
<Tag size="small" theme={STATE_THEME[info.state]} variant="light">
{STATE_TEXT[info.state]}
</Tag>
)}
</div>
) : null
}
placement="right"
size="520px"
visible={visible}
onClose={onClose}
>
{process && (
<div className="ta-seat-process">
{info && (
<div className="ta-seat-meta">
<span>状态:{STATE_TEXT[info.state]}</span>
<span>耗时:{formatDuration(info.durationMs)}</span>
<span>所属阶段:第 {(run?.stages.findIndex((stage) => stage.includes(seatAgentId ?? "")) ?? -1) + 1} 阶段</span>
</div>
)}
<ol className="ta-seat-timeline">
{process.messages.map((message, index) => (
<li className={`ta-seat-item is-${message.kind}`} key={index}>
<span className="ta-seat-item-marker">
{message.kind === "tool" ? (
<Wrench size={11} />
) : (
<ArrowDownRight size={11} />
)}
</span>
<div className="ta-seat-item-body">
<header>
<em className={`ta-seat-item-kind is-${message.kind}`}>
{KIND_LABEL[message.kind] ?? message.kind}
</em>
<span className="ta-seat-item-title">{message.title}</span>
</header>
<p>{message.content}</p>
{message.detail && <pre>{message.detail}</pre>}
</div>
</li>
))}
</ol>
</div>
)}
</Drawer>
);
}
@@ -1,4 +0,0 @@
import type { TaRunView, TaTask } from "./types";
export type { TaRunView, TaTask };
export type TaTaskViewLike = { task: TaTask; runs: TaRunView[] };
@@ -1,611 +0,0 @@
/**
* 外部任务定时分析的本地 mock 存储层(与 multi-agent-tasks 同款模式)。
*
* - 任务/绑定/运行记录落 localStorage;「立即执行 / 模拟手动执行」写入运行记录,
* 状态由 startedAt + durationMs 按当前时间推导(排队 → 按阶段推进 → 成功/失败),
* 配合 React Query 轮询获得无后端的"实时"流程图推进。
* - 席位执行过程消息在读取侧按席位生成(buildSeatProcess),不入库。
* - 对外接口(创建/删除/详情时间线/问答)就绪后替换本层。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { buildCron } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { describeScheduleConfig } from "@/pages/official-multi-agent-tasks/utils";
import { uuid } from "@/core/utils/uuid";
import type {
TaBlockMapping,
TaBinding,
TaBindingPayload,
TaRunRecord,
TaRunStatus,
TaRunView,
TaSeatRunInfo,
TaSeatState,
TaTask,
} from "./types";
const STORAGE_KEY = "deerflow.official.task-analysis.v2";
interface TaStore {
tasks: TaTask[];
runs: TaRunRecord[];
/** 上次绑定时选择的业务链 id(创建接口响应里的 default_chain_id 记忆)。 */
lastChainId: string | null;
}
const PENDING_MS = 2_000;
const STAGE_MS = 6_000;
/** 对方系统定义的详情块类型(mock 固定清单;接接口后由创建方传入)。 */
export const TA_BLOCK_TYPES: Array<{ key: string; name: string }> = [
{ key: "background", name: "任务背景" },
{ key: "dynamics", name: "动态分析" },
{ key: "risk", name: "风险研判" },
{ key: "action", name: "行动建议" },
{ key: "conclusion", name: "结论汇总" },
];
function readStore(): TaStore {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<TaStore>;
if (Array.isArray(parsed.tasks)) {
return {
tasks: parsed.tasks as TaTask[],
runs: parsed.runs ?? [],
lastChainId: parsed.lastChainId ?? null,
};
}
}
} catch {
// 解析失败按首次访问处理。
}
const seeded = seedStore();
writeStore(seeded);
return seeded;
}
function writeStore(store: TaStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// 存储被禁用时静默降级为内存态。
}
}
function mutateStore(mutator: (store: TaStore) => TaStore): TaStore {
const next = mutator(readStore());
writeStore(next);
return next;
}
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString();
}
function seedStore(): TaStore {
const seats = {
collector: "全网舆情采集",
analyst: "舆情研判分析",
writer: "报告撰写助手",
verifier: "数据核查员",
forecaster: "趋势预测专家",
};
const tasks: TaTask[] = [
{
id: "ta-task-1",
sourceTaskId: "TS-2026-0147",
name: "东南亚舆论环境监测",
description: "对方系统推送的任务:持续跟踪东南亚重点渠道舆论动向,输出结构化分析详情。",
binding: {
chainId: "seed-chain-std",
chainTitle: "标准舆情分析链",
cron: "0 9 * * *",
cronText: "每天 09:00",
enabled: true,
blocks: [
{ key: "background", name: "任务背景", seatAgentId: "collector" },
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
{ key: "risk", name: "风险研判", seatAgentId: "analyst" },
{ key: "action", name: "行动建议", seatAgentId: "analyst" },
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
],
coordinatorPrompt: "采集阶段允许独立检索;研判阶段需引用采集阶段的原始线索。",
directionNotes: ["重点关注宗教节日前后的舆论波动。"],
timezone: "Asia/Shanghai",
},
createdAt: hoursAgo(24 * 12),
},
{
id: "ta-task-2",
sourceTaskId: "TS-2026-0152",
name: "竞品新品发布追踪",
description: "追踪 5 家核心竞品的新品发布与市场反应,按块更新详情内容。",
binding: {
chainId: "seed-chain-single",
chainTitle: "单阶段并行分析链",
cron: "30 8 * * 1",
cronText: "每周一 08:30",
enabled: false,
blocks: [
{ key: "background", name: "任务背景", seatAgentId: "verifier" },
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
],
directionNotes: [],
timezone: "Asia/Shanghai",
},
createdAt: hoursAgo(24 * 8),
},
{
id: "ta-task-3",
sourceTaskId: "TS-2026-0160",
name: "行业政策周报编制",
description: "对方系统刚创建的任务,尚未配置分析方案。",
binding: null,
createdAt: hoursAgo(5),
},
];
const stdStages = [
["collector", "verifier"],
["analyst"],
["writer"],
];
const singleStages = [["collector", "verifier", "analyst"]];
const stdNames = seats;
const runs: TaRunRecord[] = [
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "schedule",
scheduledFor: hoursAgo(2.1),
startedAt: hoursAgo(2.1),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: null,
},
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "manual",
scheduledFor: hoursAgo(20),
startedAt: hoursAgo(20),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: null,
manualSummary: "总控问答:要求研判阶段补充跨平台对比维度,已确认应用到后续执行。",
},
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "schedule",
scheduledFor: hoursAgo(26),
startedAt: hoursAgo(26),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: 1,
},
{
id: uuid() as string,
taskId: "ta-task-2",
trigger: "schedule",
scheduledFor: hoursAgo(24 * 3),
startedAt: hoursAgo(24 * 3),
durationMs: PENDING_MS + singleStages.length * STAGE_MS,
stages: singleStages,
stageGoals: ["三席并行:采集 / 核查 / 研判同时产出"],
seatNames: stdNames,
failStage: null,
},
];
return { tasks, runs, lastChainId: "seed-chain-std" };
}
/** 由记录 + 当前时间推导运行视图:各席位状态/耗时、块产出状态。 */
function resolveRunView(run: TaRunRecord, task: TaTask | undefined, now = Date.now()): TaRunView {
const startedAt = new Date(run.startedAt).getTime();
const elapsed = now - startedAt;
const stageTotal = Math.max(1, run.stages.length);
const duration = run.durationMs || PENDING_MS + stageTotal * STAGE_MS;
const buildSeatInfos = (
status: TaRunStatus,
activeStage: number,
): Record<string, TaSeatRunInfo> => {
const infos: Record<string, TaSeatRunInfo> = {};
run.stages.forEach((stage, index) => {
const stageStart = PENDING_MS + index * STAGE_MS;
const stageEnd = stageStart + STAGE_MS;
const finished = status === "succeeded" || (status === "failed" && index < activeStage);
const active = !finished && index === activeStage && status !== "pending";
const state: TaSeatState = finished
? "done"
: active
? status === "failed"
? "failed"
: "running"
: "waiting";
const durationMs = finished
? Math.min(elapsed - stageStart, STAGE_MS)
: active
? Math.min(elapsed - stageStart, STAGE_MS)
: 0;
stage.forEach((agentId) => {
infos[agentId] = { state, durationMs };
});
});
return infos;
};
let status: TaRunStatus;
let stageIndex: number;
let finishedAt: string | null = null;
let error: string | null = null;
if (elapsed < PENDING_MS) {
status = "pending";
stageIndex = 0;
} else {
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
stageIndex = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
if (run.failStage !== null && stageIndex >= run.failStage) {
status = "failed";
stageIndex = run.failStage;
finishedAt = new Date(startedAt + PENDING_MS + (run.failStage + 1) * STAGE_MS).toISOString();
const failSeat = run.stages[run.failStage]?.[0];
error = `阶段 ${run.failStage + 1} 执行失败:${run.seatNames[failSeat ?? ""] ?? "席位"} 模拟执行异常`;
} else if (elapsed >= duration) {
status = "succeeded";
stageIndex = stageTotal - 1;
finishedAt = new Date(startedAt + duration).toISOString();
} else {
status = "running";
}
}
const seatInfos = buildSeatInfos(status, status === "pending" ? -1 : stageIndex);
const blockStates = (task?.binding?.blocks ?? []).map((block) => ({
key: block.key,
name: block.name,
state: seatInfos[block.seatAgentId]?.state ?? "waiting",
}));
return {
...run,
finishedAt,
status,
stageIndex,
stageTotal,
seatInfos,
error,
blockStates,
};
}
function buildTaskViews(now = Date.now()): Array<{ task: TaTask; runs: TaRunView[] }> {
const store = readStore();
return store.tasks.map((task) => {
const runs = store.runs
.filter((run) => run.taskId === task.id)
.map((run) => resolveRunView(run, task, now))
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
return { task, runs };
});
}
function anyRunLive(views: Array<{ runs: TaRunView[] }> | undefined): boolean {
return !!views?.some(({ runs }) =>
runs.some((run) => run.status === "pending" || run.status === "running"),
);
}
/** 列表 + 每任务运行视图;运行中 5 秒轮询(设计定稿:流程图每 5 秒刷新)。 */
export function useTaTasks() {
const query = useQuery({
queryKey: ["ta", "tasks"],
queryFn: () => buildTaskViews(),
refetchInterval: (q) =>
anyRunLive(q.state.data as Array<{ runs: TaRunView[] }> | undefined) ? 5_000 : false,
});
const views = query.data ?? [];
return { views, tasks: views.map((view) => view.task), isLoading: query.isLoading };
}
export function useTaTaskRuns(taskId: string | null) {
const query = useQuery({
queryKey: ["ta", "tasks", taskId],
queryFn: () => {
const view = buildTaskViews().find((item) => item.task.id === taskId);
return view?.runs ?? [];
},
enabled: !!taskId,
refetchInterval: (q) => {
const runs = q.state.data as TaRunView[] | undefined;
const live = !!runs?.some((run) => run.status === "pending" || run.status === "running");
return live ? 5_000 : false;
},
});
return { runs: query.data ?? [], isLoading: query.isLoading };
}
export function useTaTaskActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ta"] });
/** 页面手动创建外部任务(名称必填;对方任务号留空则自动生成)。 */
const createTask = useMutation({
mutationFn: async (payload: {
name: string;
sourceTaskId?: string;
description?: string;
}) => {
const task: TaTask = {
id: uuid() as string,
sourceTaskId:
payload.sourceTaskId?.trim() ||
`TS-${new Date().getFullYear()}-${String(Date.now()).slice(-5)}`,
name: payload.name.trim(),
description: payload.description?.trim() || "",
binding: null,
createdAt: new Date().toISOString(),
};
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
return task;
},
onSuccess: invalidate,
});
/** 绑定 / 修改绑定(记录 lastChainId 供创建接口返回默认项)。 */
const bind = useMutation({
mutationFn: async ({ taskId, payload }: { taskId: string; payload: TaBindingPayload }) => {
const cron = buildCron(payload.schedule);
mutateStore((store) => ({
...store,
lastChainId: payload.chainId,
tasks: store.tasks.map((task) =>
task.id === taskId
? {
...task,
binding: {
chainId: payload.chainId,
chainTitle: payload.chainTitle,
cron,
cronText: describeScheduleConfig(payload.schedule),
enabled: payload.enabled,
blocks: payload.blocks ?? task.binding?.blocks ?? [],
coordinatorPrompt: payload.coordinatorPrompt,
directionNotes: task.binding?.directionNotes ?? [],
timezone: task.binding?.timezone ?? "Asia/Shanghai",
} satisfies TaBinding,
}
: task,
),
}));
},
onSuccess: invalidate,
});
const unbind = useMutation({
mutationFn: async (taskId: string) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === taskId ? { ...task, binding: null } : task,
),
runs: store.runs.filter((run) => run.taskId !== taskId),
}));
},
onSuccess: invalidate,
});
const setEnabled = useMutation({
mutationFn: async ({ taskId, enabled }: { taskId: string; enabled: boolean }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === taskId && task.binding
? { ...task, binding: { ...task.binding, enabled } }
: task,
),
}));
},
onSuccess: invalidate,
});
/** 删除外部任务(对应对外删除接口:任务 + 绑定 + 执行记录彻底删除)。 */
const removeTask = useMutation({
mutationFn: async (taskId: string) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.filter((task) => task.id !== taskId),
runs: store.runs.filter((run) => run.taskId !== taskId),
}));
},
onSuccess: invalidate,
});
/** 立即触发一次定时执行(schedule)。 */
const triggerRun = useMutation({
mutationFn: async (taskId: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === taskId);
const binding = task?.binding;
if (!task || !binding) throw new Error("任务未绑定分析方案");
const stages = chainStagesOf(binding, store);
const now = new Date().toISOString();
const run: TaRunRecord = {
id: uuid() as string,
taskId,
trigger: "schedule",
scheduledFor: now,
startedAt: now,
durationMs: PENDING_MS + stages.length * STAGE_MS,
stages,
stageGoals: stages.map((_, index) => stageGoalOf(index)),
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
failStage: null,
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
/** 模拟一次手动执行(对方详情页总控问答产生)。 */
const triggerManualRun = useMutation({
mutationFn: async (taskId: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === taskId);
const binding = task?.binding;
if (!task || !binding) throw new Error("任务未绑定分析方案");
const stages = chainStagesOf(binding, store);
const now = new Date().toISOString();
const run: TaRunRecord = {
id: uuid() as string,
taskId,
trigger: "manual",
scheduledFor: now,
startedAt: now,
durationMs: PENDING_MS + stages.length * STAGE_MS,
stages,
stageGoals: stages.map((_, index) => stageGoalOf(index)),
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
failStage: null,
manualSummary: "总控问答:按对方详情页提问,总控给对应席位派活并汇总结论。",
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
return { createTask, bind, unbind, setEnabled, removeTask, triggerRun, triggerManualRun };
}
/** 绑定链的编排(种子任务用内置编排;真实业务链按其 stages 展开,null = 线性)。 */
export function chainStagesOf(
binding: Pick<TaBinding, "chainId">,
store: TaStore = readStore(),
): string[][] {
if (binding.chainId === "seed-chain-std") {
return [
["collector", "verifier"],
["analyst"],
["writer"],
];
}
if (binding.chainId === "seed-chain-single") {
return [["collector", "verifier", "analyst"]];
}
// 真实业务链的编排由页面侧传入的 chain 数据展开;存储层兜底为单席位串行。
const seats = seedSeatsOf(store);
return seats.map((agentId) => [agentId]);
}
function seedSeatsOf(_store: TaStore): string[] {
return ["collector", "verifier", "analyst", "writer"];
}
/** 演示用的阶段目标(seed 链固定;真实链由绑定快照提供)。 */
function stageGoalOf(stageIndex: number): string | null {
const goals = ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"];
return goals[stageIndex] ?? null;
}
export function seatDisplayName(agentId: string): string {
const names: Record<string, string> = {
collector: "全网舆情采集",
verifier: "数据核查员",
analyst: "舆情研判分析",
writer: "报告撰写助手",
forecaster: "趋势预测专家",
};
return names[agentId] ?? agentId;
}
// ── 席位执行过程(读取侧生成 mock)────────────────────────────────────────
export interface TaSeatMessage {
kind: "input" | "ai" | "tool" | "artifact";
title: string;
content: string;
detail?: string;
}
/** 生成某席位在某次运行里的执行过程消息(后端就绪后换 runs/{id}/messages?seat=)。 */
export function buildSeatProcess(
run: TaRunView,
seatAgentId: string,
task: TaTask | undefined,
): { seatName: string; messages: TaSeatMessage[]; blocks: TaBlockMapping[] } {
const seatName = run.seatNames[seatAgentId] ?? seatAgentId;
const info = run.seatInfos[seatAgentId] ?? { state: "waiting" as TaSeatState, durationMs: 0 };
const stageIndex = run.stages.findIndex((stage) => stage.includes(seatAgentId));
const blocks = (task?.binding?.blocks ?? []).filter(
(block) => block.seatAgentId === seatAgentId,
);
const messages: TaSeatMessage[] = [
{
kind: "input",
title: "输入 · 任务上下文",
content:
`${task?.name ?? ""}(对方任务 ${task?.sourceTaskId ?? "-"})\n` +
`${task?.description ?? ""}\n\n` +
(blocks.length
? `本席位负责产出:${blocks.map((block) => block.name).join("、")}。`
: "按业务链席位职责执行。") +
(stageIndex > 0
? `\n上一阶段产出摘要:${run.stages[stageIndex - 1]
?.map((id) => run.seatNames[id])
.join("、")} 的中间结论已注入。`
: ""),
},
];
if (info.state === "waiting") {
return { seatName, messages, blocks };
}
messages.push({
kind: "ai",
title: "AI 分析",
content:
`(${seatName} 的分析过程 · 演示内容)\n` +
`围绕任务简介拆解检索方向,交叉核对来源可信度,形成本席位负责块的结构化结论。`,
});
messages.push({
kind: "tool",
title: "工具调用 · web_search",
content: "query=\"任务关键词 最新动态\" → 命中 12 条,采纳 7 条(含来源与时间)",
detail:
'{"results":[{"title":"来源A:行业动态","snippet":"……"},{"title":"来源B:政策更新","snippet":"……"}]}',
});
if (info.state === "done") {
blocks.forEach((block) => {
messages.push({
kind: "artifact",
title: `产出 · ${block.name}`,
content: `(${block.name} 块内容摘要 · 演示)已按对方系统约定的结构生成,随本次执行写入时间线版本。`,
});
});
}
return { seatName, messages, blocks };
}
File diff suppressed because it is too large. Load diff
@@ -1,105 +0,0 @@
/**
* 外部任务多智能体定时分析 —— 前端先行数据模型(后端接口就绪后替换 store 层)。
*
* 对应设计定稿:
* - 外部任务(对方系统推送创建/删除)1:1 绑定「业务链 + 频率 + 块→席位映射」。
* - 执行记录两类:schedule 定时执行 / manual 手动执行(对方详情页总控问答)。
* - 时间线内容版本:每次定时执行产出各块内容(供对方详情接口拉取),管理页内
* 在席位执行过程抽屉里预览。
*/
import type { ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
export type TaRunTrigger = "schedule" | "manual";
export type TaRunStatus = "pending" | "running" | "succeeded" | "failed";
export type TaSeatState = "waiting" | "running" | "done" | "failed";
/** 对方详情的分块内容与业务链席位的映射(存在绑定上,业务链保持纯粹)。 */
export interface TaBlockMapping {
/** 块类型 key(对方系统定义)。 */
key: string;
/** 块展示名。 */
name: string;
/** 产出该块的席位(业务链 agent_id)。 */
seatAgentId: string;
}
export interface TaBinding {
chainId: string;
/** 业务链标题快照(展示用,链被删后仍可读)。 */
chainTitle: string;
cron: string;
cronText: string;
enabled: boolean;
blocks: TaBlockMapping[];
coordinatorPrompt?: string;
/** 问答沉淀的「执行方向备注」,注入后续每次定时执行(累积追加)。 */
directionNotes: string[];
/** 绑定时记住,作为下次创建任务的默认业务链返回给对方系统。 */
timezone: string;
}
export interface TaTask {
id: string;
/** 对方系统任务 id。 */
sourceTaskId: string;
name: string;
description: string;
binding: TaBinding | null;
createdAt: string;
}
/** 落库的运行记录;状态由 startedAt + durationMs 在读取时按当前时间推导。 */
export interface TaRunRecord {
id: string;
taskId: string;
trigger: TaRunTrigger;
scheduledFor: string;
startedAt: string;
durationMs: number;
/** 该次执行的业务链编排快照(席位/阶段),链后续修改不影响历史回放。 */
stages: string[][];
/** 各阶段目标快照(与 stages 同下标),供流程图连线标签展示。 */
stageGoals?: (string | null)[] | null;
seatNames: Record<string, string>;
/** 在该阶段(下标)失败;null = 成功。 */
failStage: number | null;
/** 手动执行(总控问答)的摘要。 */
manualSummary?: string;
}
/** 读取时推导出的运行视图(每席位状态 + 耗时)。 */
export interface TaSeatRunInfo {
state: TaSeatState;
durationMs: number;
}
export interface TaRunView {
id: string;
taskId: string;
trigger: TaRunTrigger;
scheduledFor: string;
startedAt: string;
finishedAt: string | null;
status: TaRunStatus;
stageIndex: number;
stageTotal: number;
seatInfos: Record<string, TaSeatRunInfo>;
error: string | null;
/** 各块产出状态(按绑定块映射推导;未绑定返回空)。 */
blockStates: Array<{ key: string; name: string; state: TaSeatState }>;
manualSummary?: string;
stages: string[][];
stageGoals?: (string | null)[] | null;
seatNames: Record<string, string>;
}
/** 绑定弹窗的提交载荷(内容块映射已移除;blocks 仅为兼容旧数据保留)。 */
export interface TaBindingPayload {
chainId: string;
chainTitle: string;
schedule: ScheduleConfig;
enabled: boolean;
blocks?: TaBlockMapping[];
coordinatorPrompt?: string;
}
@@ -26,11 +26,6 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
selectedModel?: string; selectedModel?: string;
onSelectedModelChange?: (model: string) => void; onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null; commonQuestions?: (string | AgentQuestion)[] | null;
/**
* 点击常用问题 chip 时直接把问题发送出去(8BF 第一步)。
* 缺省 false:仅填充输入框(岗位会商等既有页面的行为保持不变)。
*/
sendCommonQuestionOnClick?: boolean;
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */ /** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
hideUtilityActions?: boolean; hideUtilityActions?: boolean;
} }
@@ -73,7 +68,6 @@ export function PositionIntentInput({
selectedModel, selectedModel,
onSelectedModelChange, onSelectedModelChange,
commonQuestions, commonQuestions,
sendCommonQuestionOnClick = false,
hideUtilityActions = false, hideUtilityActions = false,
}: PositionIntentInputProps) { }: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and // Files are owned by Step1Panel. This ref only opens the native picker and
@@ -92,13 +86,7 @@ export function PositionIntentInput({
: (modelOptions[0]?.value ?? ""); : (modelOptions[0]?.value ?? "");
const pickCommonQuestion = (question: AgentQuestion) => { const pickCommonQuestion = (question: AgentQuestion) => {
// 直接发送模式:以问题全文为覆盖文本调 onSend,绕过输入框 state 的 onValueChange(question.prompt);
// 异步更新(同 tick 里 onSend 闭包读不到刚 setValue 的新值)。
if (sendCommonQuestionOnClick) {
onSend(question.prompt);
} else {
onValueChange(question.prompt);
}
setShowMoreQuestions(false); setShowMoreQuestions(false);
}; };
@@ -172,7 +160,6 @@ export function PositionIntentInput({
type="button" type="button"
className="position-roundtable-intent-common-question" className="position-roundtable-intent-common-question"
title={question.prompt} title={question.prompt}
disabled={sendCommonQuestionOnClick ? isSending : undefined}
onClick={() => pickCommonQuestion(question)} onClick={() => pickCommonQuestion(question)}
> >
<span aria-hidden="true" /> <span aria-hidden="true" />
@@ -198,7 +185,6 @@ export function PositionIntentInput({
key={`${question.prompt}-${index}`} key={`${question.prompt}-${index}`}
type="button" type="button"
title={question.prompt} title={question.prompt}
disabled={sendCommonQuestionOnClick ? isSending : undefined}
onClick={() => pickCommonQuestion(question)} onClick={() => pickCommonQuestion(question)}
> >
{question.title} {question.title}
@@ -282,7 +268,7 @@ export function PositionIntentInput({
<button <button
id="send-message-btn" id="send-message-btn"
type="button" type="button"
onClick={() => (isSending ? onStop() : onSend())} onClick={isSending ? onStop : onSend}
disabled={isSending ? false : !canSend} disabled={isSending ? false : !canSend}
aria-label={isSending ? "暂停生成" : "发送"} aria-label={isSending ? "暂停生成" : "发送"}
title={isSending ? "暂停生成" : "发送"} title={isSending ? "暂停生成" : "发送"}
@@ -1,12 +1,9 @@
"use client"; "use client";
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react"; import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
import type { PagePhase } from "@/report-collaboration/state/selectors"; import type { PagePhase } from "@/report-collaboration/state/selectors";
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = { const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" }, empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" }, clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
@@ -54,16 +51,6 @@ export function ReportCollaborationHeader({
<span className="rc-header-title" title={title}> <span className="rc-header-title" title={title}>
报告协作 / {title} 报告协作 / {title}
</span> </span>
{PLAN_MODE_PHASES.has(phase) ? (
<span
className="rc-chip"
title="规划师正在梳理需求并规划工作流,确认后开始执行"
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
>
<ClipboardList className="size-3" aria-hidden />
计划模式
</span>
) : null}
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}> <span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
{badge.label} {badge.label}
</span> </span>
@@ -156,23 +156,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
const edges: Edge[] = useMemo(() => { const edges: Edge[] = useMemo(() => {
if (!plan) return []; if (!plan) return [];
return plan.edges.map((edge, index) => { return plan.edges.map((edge, index) => {
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画; const targetState = nodeStates[edge.target];
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。 const active = targetState?.status === "running" || targetState?.status === "validating";
const targetStatus = nodeStates[edge.target]?.status;
let className = "rc-edge";
let stroke = "var(--rc-flow-edge)";
if (targetStatus === "running" || targetStatus === "validating") {
className = "rc-edge-active";
stroke = "var(--rc-running)";
} else if (targetStatus === "failed") {
className = "rc-edge-failed";
stroke = "var(--rc-danger)";
} else if (targetStatus === "awaiting_input") {
className = "rc-edge-waiting";
stroke = "var(--rc-waiting)";
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
className = "rc-edge-dimmed";
}
return { return {
id: `${edge.source}->${edge.target}-${index}`, id: `${edge.source}->${edge.target}-${index}`,
source: edge.source, source: edge.source,
@@ -180,8 +165,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
label: edge.label ?? undefined, label: edge.label ?? undefined,
type: "default", type: "default",
animated: false, animated: false,
className, className: active ? "rc-edge-active" : "rc-edge",
style: { stroke, strokeWidth: 2 }, style: {
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
strokeWidth: 2,
},
}; };
}); });
}, [plan, nodeStates]); }, [plan, nodeStates]);
@@ -1,52 +0,0 @@
"use client";
import { ClipboardCheck } from "lucide-react";
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
interface RequirementSummaryCardProps {
requirement: ReportRequirementSnapshot;
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
editable?: boolean;
}
function Field({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null;
return (
<div className="flex gap-2 text-[12px] leading-5">
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
<span className="min-w-0 flex-1 break-words">{value}</span>
</div>
);
}
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
return (
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
<div className="mb-2 flex items-center gap-2">
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
计划模式
</span>
</div>
<div className="flex flex-col gap-1">
<Field label="主题" value={requirement.topic} />
<Field label="目的" value={requirement.objective} />
<Field label="读者" value={requirement.audience} />
<Field label="角度" value={angles || null} />
<Field label="时间范围" value={requirement.time_range} />
<Field label="地域" value={requirement.geography} />
<Field label="篇幅" value={requirement.length_target} />
<Field label="语气" value={requirement.tone} />
</div>
{editable ? (
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
</p>
) : null}
</div>
);
}
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileText, Sparkles } from "lucide-react"; import { FileText, Loader2, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
@@ -26,7 +26,6 @@ import {
} from "@/report-collaboration/components/ReportPreviewDialog"; } from "@/report-collaboration/components/ReportPreviewDialog";
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay"; import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell"; import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel"; import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout"; import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages"; import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
@@ -36,7 +35,6 @@ import {
buildCommandConfirmCard, buildCommandConfirmCard,
buildPlanGroupCard, buildPlanGroupCard,
buildReportAvailableCard, buildReportAvailableCard,
buildRequirementReadyCard,
readReportCollaborationCard, readReportCollaborationCard,
} from "@/report-collaboration/state/message-adapter"; } from "@/report-collaboration/state/message-adapter";
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker"; import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
@@ -249,23 +247,11 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
if (next) setFocusedAgentRunId(next); if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]); }, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。 // 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。 // 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
const runStarted = Boolean(state.run && state.run.plan_id); const runStarted = Boolean(state.run && state.run.plan_id);
const extraCards = useMemo<Message[]>(() => { const extraCards = useMemo<Message[]>(() => {
const cards: Message[] = []; const cards: Message[] = [];
if (
state.requirement &&
state.plans.length === 0 &&
!runStarted &&
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
)
) {
cards.push(buildRequirementReadyCard(state.requirement));
}
if ( if (
state.plans.length > 0 && state.plans.length > 0 &&
!runStarted && !runStarted &&
@@ -355,16 +341,39 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
[sessionId, session], [sessionId, session],
); );
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划), // 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。 const requirementReady = useMemo(
() =>
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
),
[state.messagesById],
);
const showPlanRequestEntry =
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
const [planRequestBusy, setPlanRequestBusy] = useState(false);
const handleRequestPlans = useCallback(async () => {
setPlanRequestBusy(true);
try {
await getReportCollaborationDriver().requestPlans(
sessionId,
state.session?.requirement_revision ?? null,
);
await session.refreshSnapshot();
toast.success("已生成候选方案,可在对话中选择");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setPlanRequestBusy(false);
}
}, [sessionId, session, state.session?.requirement_revision]);
const businessCardSlot = useCallback( const businessCardSlot = useCallback(
(message: Message) => { (message: Message) => {
const card = readReportCollaborationCard(message); const card = readReportCollaborationCard(message);
if (!card) return null; if (!card) return null;
if (card.kind === "requirement_ready") {
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
}
if (card.kind === "plan_group") { if (card.kind === "plan_group") {
return ( return (
<ReportPlanCards <ReportPlanCards
@@ -405,7 +414,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
} }
return null; return null;
}, },
[previewPlan, previewPlanId, runStarted, session], [previewPlan, previewPlanId, session],
); );
const speakerShell = useCallback( const speakerShell = useCallback(
@@ -556,19 +565,43 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
: undefined : undefined
} }
composer={ composer={
<ReportComposer <div className="flex flex-col">
phase={phase} {showPlanRequestEntry ? (
busy={session.sendBusy} <div className="mb-1.5 flex justify-center">
target={composerTarget} <button
onClearTarget={() => setComposerTarget(null)} type="button"
onSend={(text) => className="rc-btn"
void session.send(text, { style={{
nodeId: composerTarget?.nodeId ?? null, background: "var(--rc-accent)",
color: "#fff",
borderColor: "var(--rc-accent)",
}}
disabled={planRequestBusy}
onClick={() => void handleRequestPlans()}
>
{planRequestBusy ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Sparkles className="size-3.5" aria-hidden />
)}
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
</button>
</div>
) : null}
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null, agentRunId: composerTarget?.agentRunId ?? null,
}) })
} }
focusSignal={focusSignal} focusSignal={focusSignal}
/> />
</div>
} }
/> />
} }
@@ -21,7 +21,6 @@ import type {
CollaborationCommand, CollaborationCommand,
CollaborationMessage, CollaborationMessage,
ReportPlanCandidate, ReportPlanCandidate,
ReportRequirementSnapshot,
ReportVersion, ReportVersion,
} from "@/report-collaboration/api/types"; } from "@/report-collaboration/api/types";
@@ -114,11 +113,6 @@ export type ReportCollaborationCardPayload =
selectedPlanId: string | null; selectedPlanId: string | null;
requirementRevision: number; requirementRevision: number;
} }
| {
kind: "requirement_ready";
requirement: ReportRequirementSnapshot;
revision: number;
}
| { | {
kind: "command_confirm"; kind: "command_confirm";
command: CollaborationCommand; command: CollaborationCommand;
@@ -169,17 +163,6 @@ export function buildPlanGroupCard(
}); });
} }
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
export function buildRequirementReadyCard(
requirement: ReportRequirementSnapshot,
): Message {
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
kind: "requirement_ready",
requirement,
revision: requirement.revision,
});
}
/** 命令确认卡(command.confirmation.required 之后)。 */ /** 命令确认卡(command.confirmation.required 之后)。 */
export function buildCommandConfirmCard(command: CollaborationCommand): Message { export function buildCommandConfirmCard(command: CollaborationCommand): Message {
return buildCardMessage(`rc-card-confirm-${command.command_id}`, { return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
@@ -850,21 +850,6 @@
animation: rc-edge-flow 0.5s linear infinite; animation: rc-edge-flow 0.5s linear infinite;
} }
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
.rc-edge-failed .react-flow__edge-path {
stroke: var(--rc-danger);
stroke-dasharray: 4 4;
}
.rc-edge-waiting .react-flow__edge-path {
stroke: var(--rc-waiting);
}
.rc-edge-dimmed .react-flow__edge-path {
stroke: var(--rc-flow-edge);
opacity: 0.45;
}
@keyframes rc-edge-flow { @keyframes rc-edge-flow {
from { from {
stroke-dashoffset: 10; stroke-dashoffset: 10;
@@ -12,9 +12,6 @@ export type { ClarificationPayload };
export const INTENT_AGENT_ID = "roundtable-intent"; export const INTENT_AGENT_ID = "roundtable-intent";
/** Dedicated Step 1 agent for the position-roundtable workspace. */ /** Dedicated Step 1 agent for the position-roundtable workspace. */
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent"; export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
/** Dedicated Step 1 agent for the 8BF roundtable workspace (same prompt family
* as `roundtable-intent`, independently tunable via the agent config UI). */
export const EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent";
/** /**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single * Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
@@ -39,16 +39,11 @@ export interface ChainFlowStage {
goal?: string | null; goal?: string | null;
} }
/** 节点执行状态(任务分析回放传入;缺省展示 Ready)。 */
export type ChainFlowSeatState = "waiting" | "running" | "done" | "failed";
export interface ChainFlowDiagramProps { export interface ChainFlowDiagramProps {
stages: ChainFlowStage[]; stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */ /** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void; onNodeClick?: (agentId: string, name?: string) => void;
className?: string; className?: string;
/** 传入时节点卡片叠加执行状态徽标(任务分析的运行回放);不影响原有展示。 */
seatStates?: Record<string, { state: ChainFlowSeatState; durationMs?: number }> | null;
} }
// ── 布局常量(世界坐标,单位 px)───────────────────────────────────────────── // ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
@@ -89,7 +84,7 @@ interface PlacedNode {
rightX: number; rightX: number;
} }
export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }: ChainFlowDiagramProps) { export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ────────────────────────────────────────── // ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => { const layout = useMemo(() => {
// 列:总控 + 各 Stage。 // 列:总控 + 各 Stage。
@@ -320,43 +315,15 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
</svg> </svg>
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 + {/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
seatStates 传入时(任务分析回放)叠加执行状态徽标与状态描边。 */}
{layout.cols.flatMap((col, colIdx) => {layout.cols.flatMap((col, colIdx) =>
col.nodes.map((n) => { col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID; const isCoordinator = n.id === COORDINATOR_ID;
const nc = nodeColorOf(n.avatarType); const nc = nodeColorOf(n.avatarType);
const seatState = !isCoordinator && seatStates ? seatStates[n.id] : undefined;
const state = seatState?.state;
const stateStyle = state
? ({
waiting: "text-muted-foreground",
running: "text-amber-600 dark:text-amber-300",
done: "text-emerald-600 dark:text-emerald-300",
failed: "text-red-600 dark:text-red-400",
} as const)[state]
: null;
const stateLabel =
state === "waiting"
? "等待"
: state === "running"
? "执行中"
: state === "done"
? "完成"
: state === "failed"
? "失败"
: null;
const stateSeconds = seatState?.durationMs
? Math.max(1, Math.round(seatState.durationMs / 1000))
: null;
return ( return (
<div <div
key={n.id} key={n.id}
className={cn( className={cn("absolute", !isCoordinator && "cursor-pointer")}
"absolute",
!isCoordinator && "cursor-pointer",
state === "waiting" && "opacity-60",
)}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }} style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onClick={() => { onClick={() => {
@@ -372,15 +339,9 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
isCoordinator isCoordinator
? "border-border bg-primary/[0.04]" ? "border-border bg-primary/[0.04]"
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]", : "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
state === "running" && "border-amber-400/80",
state === "failed" && "border-red-500/70",
)} )}
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。 // 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
style={{ style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
borderLeftColor: nc.hex,
borderLeftWidth: 4,
...(state === "running" ? { boxShadow: "0 0 0 3px rgba(245,158,11,0.15)" } : {}),
}}
> >
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */} {/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
<div className="flex items-start gap-2 overflow-hidden"> <div className="flex items-start gap-2 overflow-hidden">
@@ -398,34 +359,13 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
</div> </div>
</div> </div>
{/* 下:身份 + 状态行(回放模式显示执行状态,否则 Ready 心跳条) */} {/* 下:身份 + Ready 心跳条 */}
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground"> <div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span> <span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
{state && stateLabel ? ( <span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span <span className="size-1 rounded-full bg-emerald-500" />
className={cn( Ready
"ml-1.5 flex shrink-0 items-center gap-1 rounded px-1 py-0.5 font-medium", </span>
stateStyle,
)}
>
<span
className={cn(
"size-1.5 rounded-full",
state === "done" && "bg-emerald-500",
state === "running" && "animate-pulse bg-amber-500",
state === "failed" && "bg-red-500",
state === "waiting" && "bg-muted-foreground/60",
)}
/>
{stateLabel}
{stateSeconds != null && state !== "waiting" ? ` · ${stateSeconds}s` : ""}
</span>
) : (
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -505,33 +445,22 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
style={{ width: mmW, height: mmH }} style={{ width: mmW, height: mmH }}
onClick={onMinimapClick} onClick={onMinimapClick}
> >
{/* 节点缩略块(回放模式按状态着色) */} {/* 节点缩略块 */}
{layout.allNodes.map((n) => { {layout.allNodes.map((n) => (
const state = n.id === COORDINATOR_ID ? null : seatStates?.[n.id]?.state ?? null; <div
return ( key={n.id}
<div className={cn(
key={n.id} "absolute rounded-[1px]",
className={cn( n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
"absolute rounded-[1px]", )}
n.id === COORDINATOR_ID style={{
? "bg-primary/70" left: n.x * mmScale,
: state === "done" top: n.y * mmScale,
? "bg-emerald-500/70" width: NODE_W * mmScale,
: state === "running" height: NODE_H * mmScale,
? "bg-amber-500/80" }}
: state === "failed" />
? "bg-red-500/80" ))}
: "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
);
})}
{/* 当前视口框 */} {/* 当前视口框 */}
<div <div
className="absolute border-2 border-primary bg-primary/10" className="absolute border-2 border-primary bg-primary/10"
@@ -81,8 +81,7 @@ export interface Step1InputAreaProps {
isSending: boolean; isSending: boolean;
canSend: boolean; canSend: boolean;
pendingClarification: boolean; pendingClarification: boolean;
/** 发送当前输入框内容;传 `overrideText` 时直接发送该文本(常用问题一键发送)。 */ onSend: () => void;
onSend: (overrideText?: string) => void;
onStop: () => void; onStop: () => void;
onFollowUp: () => void; onFollowUp: () => void;
onSkip: () => void; onSkip: () => void;
@@ -237,12 +236,9 @@ export function Step1Panel(props: Step1PanelProps) {
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx)); setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
}; };
const handleSend = (overrideText?: string) => { const handleSend = () => {
// overrideText:常用问题 chip 一键发送——绕过异步的输入框 state, if (!inputText.trim() && attachedFiles.length === 0) return;
// 直接以问题全文作为本回合消息(仍共享附件与发送后的清空逻辑)。 onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
const textToSend = overrideText ?? inputText;
if (!textToSend.trim() && attachedFiles.length === 0) return;
onSendMessage(textToSend, attachedFiles.length > 0 ? attachedFiles : undefined);
handleInputTextChange(""); handleInputTextChange("");
setAttachedFiles([]); setAttachedFiles([]);
}; };
@@ -415,7 +411,7 @@ export function Step1Panel(props: Step1PanelProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
id="send-message-btn" id="send-message-btn"
onClick={() => handleSend()} onClick={handleSend}
disabled={isIntentSending || !intentThreadId} disabled={isIntentSending || !intentThreadId}
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95" className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
> >
@@ -422,12 +422,6 @@ export interface UseStep2OrchestrationOptions {
seatSkillDirectives?: Record<string, boolean>; seatSkillDirectives?: Record<string, boolean>;
/** Per-seat human-validation checkpoints inherited from the selected chain. */ /** Per-seat human-validation checkpoints inherited from the selected chain. */
humanValidations?: Record<string, boolean>; humanValidations?: Record<string, boolean>;
/**
* 全局「人工干预」开关(8BF 页头):开启后第二步每走一步(链条每席位 / DAG 每 stage
* 完成)就暂停等待用户输入或恢复;关闭则全自动推进,与既有行为一致。与 per-seat
* `humanValidations` 取 OR——任一生效即在最近一步边界暂停。
*/
manualIntervention?: boolean;
/** /**
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。 * 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**: * 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
@@ -599,7 +593,6 @@ export function useStep2Orchestration(
selectedMode, selectedMode,
seatSkillDirectives = {}, seatSkillDirectives = {},
humanValidations = {}, humanValidations = {},
manualIntervention = false,
seatModes = {}, seatModes = {},
gatherFirst = false, gatherFirst = false,
orchestrationMode, orchestrationMode,
@@ -753,9 +746,6 @@ export function useStep2Orchestration(
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。 // 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives); const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations); const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
// 全局「人工干预」开关(8BF 页头):编排循环在每个一步边界读最新值,
// 中途切换只影响下一次边界,不打断在途 run。
const manualInterventionRef = useRef<boolean>(manualIntervention);
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值; // 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
// 覆盖优先、否则跟随 selectedModeRef。 // 覆盖优先、否则跟随 selectedModeRef。
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes); const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
@@ -810,9 +800,6 @@ export function useStep2Orchestration(
useEffect(() => { useEffect(() => {
humanValidationsRef.current = humanValidations; humanValidationsRef.current = humanValidations;
}, [humanValidations]); }, [humanValidations]);
useEffect(() => {
manualInterventionRef.current = manualIntervention;
}, [manualIntervention]);
useEffect(() => { useEffect(() => {
seatModesRef.current = seatModes; seatModesRef.current = seatModes;
}, [seatModes]); }, [seatModes]);
@@ -1694,16 +1681,12 @@ export function useStep2Orchestration(
); );
const pauseForHumanValidation = useCallback( const pauseForHumanValidation = useCallback(
( (continuation: HumanValidationContinuation, seatNames: string[]) => {
continuation: HumanValidationContinuation,
seatNames: string[],
source = "业务链条配置",
) => {
humanValidationContinuationRef.current = continuation; humanValidationContinuationRef.current = continuation;
appendDialogue({ appendDialogue({
sender: "系统", sender: "系统",
avatarType: "blue", avatarType: "blue",
content: `已完成 ${seatNames.join("、")} 的输出,按${source}暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`, content: `已完成 ${seatNames.join("、")} 的输出,按业务链条配置暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
confidence: "等待人工校验", confidence: "等待人工校验",
}); });
setIsRoundTableRunning(false); setIsRoundTableRunning(false);
@@ -2810,27 +2793,18 @@ export function useStep2Orchestration(
} }
if (!chainInvalidNames.includes(seatName)) chainInvalidNames.push(seatName); if (!chainInvalidNames.includes(seatName)) chainInvalidNames.push(seatName);
finishedAllSeats = false; finishedAllSeats = false;
if (manualInterventionRef.current || humanValidationsRef.current[seat.agent_id]) { if (humanValidationsRef.current[seat.agent_id]) {
pauseForHumanValidation( pauseForHumanValidation({ mode: "chain", nextSeatIndex: i }, [seatName]);
{ mode: "chain", nextSeatIndex: i },
[seatName],
manualInterventionRef.current && !humanValidationsRef.current[seat.agent_id]
? "人工干预开关"
: undefined,
);
break; break;
} }
continue; continue;
} }
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90))); setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
if (manualInterventionRef.current || humanValidationsRef.current[seat.agent_id]) { if (humanValidationsRef.current[seat.agent_id]) {
pauseForHumanValidation( pauseForHumanValidation(
{ mode: "chain", nextSeatIndex: i + 1 }, { mode: "chain", nextSeatIndex: i + 1 },
[seatName], [seatName],
manualInterventionRef.current && !humanValidationsRef.current[seat.agent_id]
? "人工干预开关"
: undefined,
); );
finishedAllSeats = false; finishedAllSeats = false;
break; break;
@@ -3263,11 +3237,8 @@ export function useStep2Orchestration(
setConsensusPercentage( setConsensusPercentage(
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)), Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
); );
const manualInterventionOn = manualInterventionRef.current;
const validationSeatNames = stageSeats const validationSeatNames = stageSeats
.filter( .filter(({ agentId }) => humanValidationsRef.current[agentId])
({ agentId }) => manualInterventionOn || humanValidationsRef.current[agentId],
)
.map(({ name }) => name); .map(({ name }) => name);
if (validationSeatNames.length > 0) { if (validationSeatNames.length > 0) {
pauseForHumanValidation( pauseForHumanValidation(
@@ -3277,7 +3248,6 @@ export function useStep2Orchestration(
priorDeliveries: [...priorDeliveries], priorDeliveries: [...priorDeliveries],
}, },
validationSeatNames, validationSeatNames,
manualInterventionOn ? "人工干预开关" : undefined,
); );
finishedAllStages = false; finishedAllStages = false;
break; break;
@@ -75,7 +75,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
@@ -325,15 +324,6 @@ export default function RoundtablePlanningPage({
entryRailTaskCardSlot, entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */ /** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext, taskContext,
/** 第一步绑定的内置意图智能体 id(如 8BF 的 eightbf-roundtable-intent)。
* 缺省沿用通用会商的 roundtable-intent,原多智能体会商入口行为不变。 */
intentAgentId,
/**
* 8BF 页头「人工干预」开关(受控):开启后第二步每走一步暂停等待用户输入,
* 关闭自动推进。提供 onStep2InterventionChange 时才渲染开关,通用会商页不受影响。
*/
step2InterventionEnabled,
onStep2InterventionChange,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */ /** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId, entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */ /** 独立业务页的根节点样式类,避免修改原会商样式。 */
@@ -377,11 +367,6 @@ export default function RoundtablePlanningPage({
entryRailBusinessChainSlot?: ReactNode; entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode; entryRailTaskCardSlot?: ReactNode;
taskContext?: Record<string, unknown> | null; taskContext?: Record<string, unknown> | null;
/** 第一步绑定的内置意图智能体 id;缺省走通用 roundtable-intent。 */
intentAgentId?: string;
/** 第二步「人工干预」开关(8BF 页头,新建任务按钮左侧)。 */
step2InterventionEnabled?: boolean;
onStep2InterventionChange?: (enabled: boolean) => void;
entryRailTaskId?: string | null; entryRailTaskId?: string | null;
rootClassName?: string; rootClassName?: string;
renderStep1?: (props: Step1PanelProps & { renderStep1?: (props: Step1PanelProps & {
@@ -770,7 +755,6 @@ export default function RoundtablePlanningPage({
selectedMode: seatMode, selectedMode: seatMode,
seatSkillDirectives, seatSkillDirectives,
humanValidations, humanValidations,
manualIntervention: step2InterventionEnabled ?? false,
seatModes, seatModes,
gatherFirst, gatherFirst,
orchestrationMode, orchestrationMode,
@@ -787,7 +771,6 @@ export default function RoundtablePlanningPage({
selectedModel, selectedModel,
triggerToast, triggerToast,
taskContext, taskContext,
intentAgentId,
}); });
const taskArtifactSubmission = useTaskArtifactSubmission( const taskArtifactSubmission = useTaskArtifactSubmission(
@@ -2398,23 +2381,6 @@ export default function RoundtablePlanningPage({
</button> </button>
)} )}
{/* 8BF:第二步「人工干预」开关。开启后第二步每走一步暂停,等待用户输入 /
恢复后继续;关闭则自动推进(与原行为一致)。仅独立业务页传入受控回调时渲染。 */}
{onStep2InterventionChange && (
<label
id="step2-intervention-toggle"
title="开启后:第二步每完成一步暂停,可在下方输入修改意见或点击“恢复会商”继续;关闭后:自动完成整个研讨"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-2 transition cursor-pointer select-none dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Switch
checked={step2InterventionEnabled ?? false}
onCheckedChange={onStep2InterventionChange}
className="scale-90"
/>
人工干预
</label>
)}
{/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */} {/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */}
{prefillRef.current?.taskId ? ( {prefillRef.current?.taskId ? (
<button <button
@@ -1,168 +0,0 @@
/* ============================================================
智能体星轨页 — 深色主题(星海蓝)
只放颜色,结构与动画在 agents-orbit.css
============================================================ */
.agents-orbit-page.ao-dark {
color: #d9e8ff;
background:
radial-gradient(1100px 640px at 22% 40%, rgba(56, 130, 246, 0.18), transparent 62%),
radial-gradient(880px 560px at 80% 26%, rgba(139, 92, 246, 0.14), transparent 60%),
linear-gradient(158deg, #050b1e 0%, #081429 46%, #03060f 100%);
}
/* 星点 */
.agents-orbit-page.ao-dark::before {
background-image:
radial-gradient(1.6px 1.6px at 11% 16%, rgba(255, 255, 255, 0.55), transparent 55%),
radial-gradient(1.2px 1.2px at 27% 74%, rgba(255, 255, 255, 0.35), transparent 55%),
radial-gradient(1.4px 1.4px at 42% 26%, rgba(255, 255, 255, 0.4), transparent 55%),
radial-gradient(1px 1px at 58% 82%, rgba(255, 255, 255, 0.3), transparent 55%),
radial-gradient(1.6px 1.6px at 66% 12%, rgba(255, 255, 255, 0.5), transparent 55%),
radial-gradient(1.2px 1.2px at 79% 58%, rgba(255, 255, 255, 0.35), transparent 55%),
radial-gradient(1px 1px at 88% 34%, rgba(255, 255, 255, 0.3), transparent 55%),
radial-gradient(1.4px 1.4px at 18% 88%, rgba(255, 255, 255, 0.35), transparent 55%),
radial-gradient(1px 1px at 35% 48%, rgba(255, 255, 255, 0.25), transparent 55%),
radial-gradient(1.2px 1.2px at 92% 78%, rgba(255, 255, 255, 0.3), transparent 55%);
}
/* 顶部标题 */
.agents-orbit-page.ao-dark .ao-header-title {
color: #cfe4ff;
text-shadow: 0 0 14px rgba(90, 160, 255, 0.5);
}
.agents-orbit-page.ao-dark .ao-header-link {
border-color: rgba(110, 170, 255, 0.35);
background: rgba(12, 28, 58, 0.7);
color: #9fc4f5;
}
.agents-orbit-page.ao-dark .ao-header-link:hover {
color: #ffffff;
border-color: rgba(140, 205, 255, 0.8);
box-shadow: 0 0 14px rgba(90, 170, 255, 0.4);
}
/* 中心圆盘 */
.agents-orbit-page.ao-dark .ao-core {
background: radial-gradient(
circle at 34% 30%,
rgba(96, 165, 250, 0.95),
rgba(37, 99, 235, 0.55) 45%,
rgba(8, 20, 45, 0.92) 100%
);
border-color: rgba(125, 190, 255, 0.7);
box-shadow:
0 0 46px rgba(64, 140, 255, 0.5),
inset 0 0 34px rgba(120, 190, 255, 0.35);
}
.agents-orbit-page.ao-dark .ao-core-img {
color: #eaf4ff;
filter: drop-shadow(0 0 14px rgba(120, 190, 255, 0.85));
}
/* 圆环族 */
.agents-orbit-page.ao-dark .ao-ring--dash {
border-color: rgba(110, 170, 255, 0.55);
}
.agents-orbit-page.ao-dark .ao-ring--arc {
border-top-color: rgba(88, 200, 255, 0.9);
border-right-color: rgba(88, 200, 255, 0.32);
filter: drop-shadow(0 0 6px rgba(88, 200, 255, 0.55));
}
.agents-orbit-page.ao-dark .ao-ring--ticks {
border-color: rgba(110, 170, 255, 0.28);
background-image: repeating-conic-gradient(
from 0deg,
rgba(120, 180, 255, 0.5) 0deg 0.8deg,
transparent 0.8deg 6deg
);
}
/* 卫星 */
.agents-orbit-page.ao-dark .ao-sat {
background: radial-gradient(circle at 35% 30%, #ffffff, #7fd0ff 55%, #1f7dff);
box-shadow:
0 0 12px 3px rgba(110, 200, 255, 0.85),
-14px 0 22px rgba(110, 200, 255, 0.35);
}
/* 类型标签药丸 */
.agents-orbit-page.ao-dark .ao-type-chip {
color: #b9d7ff;
background: rgba(12, 28, 58, 0.82);
border-color: rgba(110, 170, 255, 0.35);
box-shadow: 0 4px 16px rgba(4, 12, 32, 0.55);
}
.agents-orbit-page.ao-dark .ao-type-anchor:hover .ao-type-chip {
color: #ffffff;
border-color: rgba(140, 205, 255, 0.8);
box-shadow: 0 0 18px rgba(90, 170, 255, 0.45);
}
.agents-orbit-page.ao-dark .ao-type-anchor.is-active .ao-type-chip {
color: #06122b;
background: linear-gradient(135deg, #66c4ff, #2f7dff);
border-color: rgba(160, 220, 255, 0.9);
box-shadow: 0 0 26px rgba(80, 170, 255, 0.65);
}
/* 右侧面板 */
.agents-orbit-page.ao-dark .ao-panel {
background: rgba(10, 22, 48, 0.72);
border-color: rgba(110, 170, 255, 0.22);
box-shadow: 0 10px 40px rgba(2, 8, 24, 0.5);
}
.agents-orbit-page.ao-dark .ao-panel-head {
border-bottom-color: rgba(110, 170, 255, 0.16);
}
.agents-orbit-page.ao-dark .ao-panel-title {
color: #e8f2ff;
}
.agents-orbit-page.ao-dark .ao-panel-sub {
color: #8fb7e8;
}
.agents-orbit-page.ao-dark .ao-agents::-webkit-scrollbar-thumb {
background: rgba(110, 170, 255, 0.3);
}
.agents-orbit-page.ao-dark .ao-agent {
border-color: rgba(110, 170, 255, 0.18);
background: rgba(20, 42, 86, 0.5);
}
.agents-orbit-page.ao-dark .ao-agent:hover {
border-color: rgba(130, 200, 255, 0.6);
background: rgba(30, 58, 112, 0.65);
box-shadow: 0 8px 24px rgba(20, 80, 200, 0.35);
}
.agents-orbit-page.ao-dark .ao-agent-icon {
background: linear-gradient(135deg, #2f7dff, #7a5cff);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.25),
0 4px 14px rgba(47, 125, 255, 0.4);
}
.agents-orbit-page.ao-dark .ao-agent-name {
color: #d7e8ff;
}
/* 加载 / 空态 */
.agents-orbit-page.ao-dark .ao-state {
color: #8fb7e8;
}
.agents-orbit-page.ao-dark .ao-spinner {
border-color: rgba(110, 170, 255, 0.25);
border-top-color: #6fc2ff;
}
@@ -1,158 +0,0 @@
/* ============================================================
智能体星轨页 — 浅色主题(云际蓝)
只放颜色,结构与动画在 agents-orbit.css
============================================================ */
.agents-orbit-page.ao-light {
color: #1f3a63;
background:
radial-gradient(1100px 640px at 22% 40%, rgba(47, 125, 255, 0.1), transparent 62%),
radial-gradient(880px 560px at 80% 26%, rgba(122, 92, 246, 0.08), transparent 60%),
linear-gradient(158deg, #f4f8ff 0%, #e7f0ff 46%, #eef4fd 100%);
}
/* 浅色下不渲染星点 */
.agents-orbit-page.ao-light::before {
display: none;
}
/* 顶部标题 */
.agents-orbit-page.ao-light .ao-header-title {
color: #164a96;
text-shadow: 0 0 14px rgba(120, 170, 255, 0.35);
}
.agents-orbit-page.ao-light .ao-header-link {
border-color: rgba(47, 125, 255, 0.4);
background: rgba(255, 255, 255, 0.85);
color: #2a66c2;
}
.agents-orbit-page.ao-light .ao-header-link:hover {
color: #0f3f96;
border-color: rgba(47, 125, 255, 0.85);
box-shadow: 0 4px 14px rgba(47, 125, 255, 0.22);
}
/* 中心圆盘 */
.agents-orbit-page.ao-light .ao-core {
background: radial-gradient(
circle at 34% 30%,
#ffffff,
#dbe9ff 45%,
#b9d4ff 100%
);
border-color: rgba(90, 150, 240, 0.85);
box-shadow:
0 10px 34px rgba(47, 125, 255, 0.28),
inset 0 0 30px rgba(160, 200, 255, 0.55);
}
.agents-orbit-page.ao-light .ao-core-img {
color: #1f5fd6;
filter: drop-shadow(0 4px 10px rgba(47, 125, 255, 0.35));
}
/* 圆环族 */
.agents-orbit-page.ao-light .ao-ring--dash {
border-color: rgba(47, 125, 255, 0.45);
}
.agents-orbit-page.ao-light .ao-ring--arc {
border-top-color: rgba(37, 110, 245, 0.85);
border-right-color: rgba(37, 110, 245, 0.28);
filter: drop-shadow(0 2px 5px rgba(47, 125, 255, 0.3));
}
.agents-orbit-page.ao-light .ao-ring--ticks {
border-color: rgba(47, 125, 255, 0.3);
background-image: repeating-conic-gradient(
from 0deg,
rgba(47, 105, 220, 0.45) 0deg 0.8deg,
transparent 0.8deg 6deg
);
}
/* 卫星 */
.agents-orbit-page.ao-light .ao-sat {
background: radial-gradient(circle at 35% 30%, #ffffff, #7fb4ff 55%, #2f7dff);
box-shadow:
0 0 10px 2px rgba(47, 125, 255, 0.55),
-14px 0 20px rgba(47, 125, 255, 0.25);
}
/* 类型标签药丸 */
.agents-orbit-page.ao-light .ao-type-chip {
color: #1e4fa8;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(47, 125, 255, 0.4);
box-shadow: 0 4px 14px rgba(30, 80, 180, 0.14);
}
.agents-orbit-page.ao-light .ao-type-anchor:hover .ao-type-chip {
color: #0f3f96;
border-color: rgba(47, 125, 255, 0.85);
box-shadow: 0 6px 18px rgba(47, 125, 255, 0.28);
}
.agents-orbit-page.ao-light .ao-type-anchor.is-active .ao-type-chip {
color: #ffffff;
background: linear-gradient(135deg, #4d9aff, #2f7dff);
border-color: rgba(47, 125, 255, 0.95);
box-shadow: 0 8px 24px rgba(47, 125, 255, 0.45);
}
/* 右侧面板 */
.agents-orbit-page.ao-light .ao-panel {
background: rgba(255, 255, 255, 0.82);
border-color: rgba(47, 125, 255, 0.25);
box-shadow: 0 10px 32px rgba(30, 70, 160, 0.12);
}
.agents-orbit-page.ao-light .ao-panel-head {
border-bottom-color: rgba(47, 125, 255, 0.18);
}
.agents-orbit-page.ao-light .ao-panel-title {
color: #0f3a78;
}
.agents-orbit-page.ao-light .ao-panel-sub {
color: #5b7ea8;
}
.agents-orbit-page.ao-light .ao-agents::-webkit-scrollbar-thumb {
background: rgba(47, 125, 255, 0.3);
}
.agents-orbit-page.ao-light .ao-agent {
border-color: rgba(47, 125, 255, 0.22);
background: rgba(255, 255, 255, 0.85);
}
.agents-orbit-page.ao-light .ao-agent:hover {
border-color: rgba(47, 125, 255, 0.6);
background: #f0f7ff;
box-shadow: 0 8px 22px rgba(47, 125, 255, 0.22);
}
.agents-orbit-page.ao-light .ao-agent-icon {
background: linear-gradient(135deg, #2f7dff, #7a5cff);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
0 4px 12px rgba(47, 125, 255, 0.35);
}
.agents-orbit-page.ao-light .ao-agent-name {
color: #1f3a63;
}
/* 加载 / 空态 */
.agents-orbit-page.ao-light .ao-state {
color: #5b7ea8;
}
.agents-orbit-page.ao-light .ao-spinner {
border-color: rgba(47, 125, 255, 0.25);
border-top-color: #2f7dff;
}
-432
View File
@@ -1,432 +0,0 @@
/* ============================================================
智能体星轨页 — 基础结构 + 动画(不含任何颜色)
颜色主题在 agents-orbit-dark.css / agents-orbit-light.css 两个文件里
命名统一 ao- 前缀
============================================================ */
/* ---------- 页面骨架:左舞台 / 右面板 1:1 ---------- */
.agents-orbit-page {
position: relative;
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
/* 星点背景层(内容在主题文件里着色;浅色主题直接隐藏) */
.agents-orbit-page::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
animation: ao-star-blink 7s ease-in-out infinite;
}
@keyframes ao-star-blink {
0%,
100% {
opacity: 0.65;
}
50% {
opacity: 1;
}
}
/* 顶部小标题 + 返回经典视图入口 */
.ao-header {
position: absolute;
top: 18px;
left: 22px;
z-index: 6;
display: flex;
align-items: center;
gap: 14px;
}
.ao-header-title {
font-size: 15px;
font-weight: 600;
letter-spacing: 2px;
}
.ao-header-link {
border: 1px solid;
background: none;
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s;
}
/* ---------- 左侧星轨舞台 ---------- */
.ao-stage {
position: relative;
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.ao-root {
position: relative;
width: min(56vh, 520px);
max-width: calc(100% - 48px);
aspect-ratio: 1 / 1;
}
/* 中心圆盘(核心) */
.ao-core {
position: absolute;
left: 50%;
top: 50%;
width: 32%;
aspect-ratio: 1 / 1;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
/* 中心图片:上下摇晃(频率适中,约 3.2s 一个来回) */
.ao-core-img {
display: flex;
align-items: center;
justify-content: center;
animation: ao-float 3.2s ease-in-out infinite;
}
@keyframes ao-float {
0%,
100% {
transform: translateY(-7px);
}
50% {
transform: translateY(9px);
}
}
/* ---------- 圆环族 ----------
由内到外:虚线环(54%) 顺时针 / 弧光环(70%) 逆时针 / 刻度环(97%) 缓慢顺时针 */
.ao-ring {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
}
.ao-ring--dash {
width: 54%;
aspect-ratio: 1 / 1;
border: 2px dashed;
animation: ao-spin-cw 18s linear infinite;
}
.ao-ring--arc {
width: 70%;
aspect-ratio: 1 / 1;
border: 3px solid transparent;
animation: ao-spin-ccw 12s linear infinite;
}
/* 最外层刻度圆圈:细边框 + 环形遮罩刻度(密码转盘的刻度感),
缓慢自转,转盘属性刻度环 */
.ao-ring--ticks {
width: 97%;
aspect-ratio: 1 / 1;
border: 1px solid;
-webkit-mask: radial-gradient(
farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 4px)
);
mask: radial-gradient(
farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 4px)
);
animation: ao-spin-cw 90s linear infinite;
}
@keyframes ao-spin-cw {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes ao-spin-ccw {
to {
transform: translate(-50%, -50%) rotate(-360deg);
}
}
/* 卫星:一个自转的轨道容器,卫星固定在轨道顶端;
轨道直径与最外层刻度圆圈(97%)一致,卫星贴着最外圈环绕 */
.ao-sat-orbit {
position: absolute;
left: 50%;
top: 50%;
width: 97%;
aspect-ratio: 1 / 1;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 2;
animation: ao-orbit-cw 9s linear infinite;
}
.ao-sat {
position: absolute;
top: -7px;
left: 50%;
width: 14px;
height: 14px;
margin-left: -7px;
border-radius: 50%;
}
@keyframes ao-orbit-cw {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* ---------- 类型标签(密码转盘) ---------- */
/* 转盘裁剪容器:垂直中线以左(左半圆)是不可视区域,
绕背补位的标签滑出边界即被裁掉、从另一端滑入才重新出现。
上/右/下方向用负 inset 放宽裁剪框,不切到溢出 root 的标签。 */
.ao-types {
position: absolute;
inset: 0;
z-index: 4;
clip-path: inset(-80px -80px -80px 50%);
}
/* 旋转臂:铺满整个 root、以圆心为轴旋转;标签挂在 93%/50% 处(刻度环内侧)。
换角度时 CSS 过渡让标签沿圆弧运动,文字由锚点反向旋转保持水平。
超界补位的标签角度会 ±360 走“背面长路”,全程经过被裁剪的左半圆。 */
.ao-type-arm {
position: absolute;
inset: 0;
pointer-events: none;
transform-origin: 50% 50%;
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
}
.ao-type-anchor {
position: absolute;
left: 93%;
top: 50%;
pointer-events: auto;
background: none;
border: none;
padding: 0;
cursor: pointer;
transform: translate(-50%, -50%) rotate(0deg);
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
}
/* 标签药丸:内层元素承担上下摇晃动画,不与定位 transform 冲突 */
.ao-type-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: 999px;
border: 1px solid;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
white-space: nowrap;
animation: ao-bob 3.6s ease-in-out infinite;
transition: color 0.3s, border-color 0.3s, box-shadow 0.3s, background 0.3s;
}
.ao-type-chip::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
/* 激活位(正右侧中间)微微放大强调 */
.ao-type-anchor.is-active .ao-type-chip {
padding: 8px 18px;
}
/* 类型较多时收缩标签尺寸,保持间距可辨 */
.agents-orbit-page.ao-crowded .ao-type-chip {
font-size: 12px;
padding: 4px 10px;
gap: 4px;
}
.agents-orbit-page.ao-crowded .ao-type-anchor.is-active .ao-type-chip {
padding: 5px 12px;
}
@keyframes ao-bob {
0%,
100% {
transform: translateY(-3px);
}
50% {
transform: translateY(4px);
}
}
/* ---------- 右侧智能体面板(与舞台 1:1) ---------- */
.ao-panel {
position: relative;
z-index: 5;
flex: 1 1 0;
min-width: 0;
margin: 16px;
border-radius: 18px;
border: 1px solid;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ao-panel-head {
padding: 18px 20px 12px;
border-bottom: 1px solid;
}
.ao-panel-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
}
.ao-panel-sub {
margin-top: 4px;
font-size: 12px;
}
.ao-agents {
flex: 1;
overflow-y: auto;
padding: 16px 20px 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
gap: 14px;
align-content: start;
animation: ao-fade-in 0.5s ease both;
}
.ao-agents::-webkit-scrollbar {
width: 6px;
}
.ao-agents::-webkit-scrollbar-thumb {
border-radius: 3px;
}
@keyframes ao-fade-in {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.ao-agent {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px 8px;
border-radius: 14px;
border: 1px solid;
cursor: pointer;
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s, background 0.25s;
}
.ao-agent:hover {
transform: translateY(-4px);
}
/* 圆角正方形图标(取智能体名称首字) */
.ao-agent-icon {
width: 54px;
height: 54px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
font-weight: 700;
}
.ao-agent-name {
max-width: 100%;
font-size: 13px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- 加载 / 空态 ---------- */
.ao-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 13px;
}
.ao-spinner {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid;
animation: ao-spin-cw-center 0.9s linear infinite;
}
@keyframes ao-spin-cw-center {
to {
transform: rotate(360deg);
}
}
/* 动效敏感用户降级 */
@media (prefers-reduced-motion: reduce) {
.agents-orbit-page::before,
.ao-core-img,
.ao-ring--dash,
.ao-ring--arc,
.ao-ring--ticks,
.ao-sat-orbit,
.ao-type-chip,
.ao-agents {
animation: none;
}
.ao-type-arm,
.ao-type-anchor {
transition-duration: 0.01s;
}
}
Binary file not shown.
Binary file not shown.
-69
View File
@@ -1,69 +0,0 @@
# ========================
# EDIT BEFORE DEPLOYMENT
# ========================
# This file is loaded by start_backend.scheduler.sh through docker --env-file.
# Put server-specific values here. Do not configure Python mirrors only on the
# host shell; values in this file are what the container will receive.
# AI provider keys. Replace placeholders and rotate any test key before release.
DEEPSEEK_API_KEY=sk-9808fb2375534112ac275541aec14b9b
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
TAVILY_API_KEY=your-tavily-api-key
SERPER_API_KEY=your-serper-api-key
# Auth and frontend access.
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200,http://47.88.25.99:7010
GATEWAY_ENABLE_DOCS=false
GATEWAY_WORKERS=2
AUTH_TRUSTED_PROXIES=
# Scheduler dependency behavior.
# Keep SCHEDULER_INSTALL_DEPS=1 if croniter is not already baked into the image.
# Set the Python mirror below if the server cannot access public PyPI.
SCHEDULER_INSTALL_DEPS=1
SCHEDULER_PIP_PACKAGES=croniter==6.2.2
SCHEDULED_TASKS_ENABLED=true
# Element / Matrix delivery for scheduled task results.
# If disabled or not configured, scheduled tasks still run but no Element DM is sent.
ELEMENT_ENABLED=false
ELEMENT_HOMESERVER_URL=
ELEMENT_ACCESS_TOKEN=
ELEMENT_BOT_USERNAME=scheduled-bot
ELEMENT_BOT_PASSWORD=
ELEMENT_AUTO_REGISTER=false
ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# Python private package index used by uv/pip inside the backend container and
# propagated into sandbox containers for agent-installed dependencies.
# Uncomment and set these to your internal mirror when public PyPI is blocked.
# UV_INDEX_URL=http://your-python-mirror/simple
# PIP_INDEX_URL=http://your-python-mirror/simple
# UV_TRUSTED_HOST=your-python-mirror
# PIP_TRUSTED_HOST=your-python-mirror
# PIP_EXTRA_INDEX_URL=
# UV_EXTRA_INDEX_URL=
# HTTP_PROXY=
# HTTPS_PROXY=
# NO_PROXY=
# Alternative generic names also supported by the startup script.
# PYTHON_PACKAGE_INDEX_URL=http://your-python-mirror/simple
# PYTHON_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+aiomysql://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
# 圆桌后台作业网关:缺省即 inprocess(进程内驱动真模型,后台挂起开箱即用);
# 仅前端联调时显式设 simulated(真实进度但不调真模型)。本行可删,留作文档。
ROUNDTABLE_JOB_GATEWAY=inprocess
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
# WeKnora admin credentials (DeerFlow server-side iframe SSO proxy)
WEKNORA_ADMIN_PASSWORD=1qaz@WSX
@@ -1,29 +0,0 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.coverage
.coverage.*
.ruff_cache
agent_history.gif
static/browser_history/*.gif
log/
log/*
.deer-flow/users/
# Virtual environments
.venv
venv/
# User config file
# config.yaml
# Langgraph
.langgraph_api
# Claude Code settings
.claude/settings.local.json
@@ -1,51 +0,0 @@
---
name: task-report-import
description: 任务研判成果入库:校验智能体生成的 task-reports.json(14 类分类结构),并调用 Gateway 的 /api/task-reports/import/by-task/{taskId} 接口把结果写入任务详情(整体替换)。内网自签证书场景已关闭 SSL 校验。
allowed-tools: read_file, bash
---
# 任务研判成果入库
把「任务研判报告智能体」生成的 `/mnt/user-data/outputs/task-reports.json` 校验后导入任务详情。
导入接口会**整体替换**该任务已有的全部详情记录。
## 用法
```bash
python /mnt/skills/public/task-report-import/scripts/task_report_import.py \
--task-id <任务id> \
--json /mnt/user-data/outputs/task-reports.json
```
参数(均有默认值,一般只需 `--task-id`):
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `--task-id` | (必填) | 任务 id,取开场信息里的 task_id |
| `--json` | `/mnt/user-data/outputs/task-reports.json` | 待导入的 JSON 文件;接受裸数组或 `{data\|records: [...]}` 信封 |
| `--gateway` | `$AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:8001` | Gateway 基地址 |
| `--token` | 见下方鉴权 | Bearer token |
| `--strict` | 关 | 开启后缺任何一个分类都拒绝导入(默认允许部分导入,缺的分类会列在结果里) |
| `--verify-tls` | 关 | 开启 TLS 证书校验。内网自签证书部署默认关闭(ssl 不验证) |
## 鉴权(按顺序取第一个可用)
1. `--token` 参数;
2. 环境变量 `AGENTFX_IMPORT_TOKEN` → `DEERFLOW_AUTH_TOKEN` → `TOOLS_TOKEN`;
3. 都没有时,自动调 Gateway 公共端点 `POST /api/parallel-agents/auth/token`(传用户名 `agentfx-import`)现签一个 token——内网部署的既有鉴权旁路模式,无需配置。
## 校验规则
- 记录必须是对象,且 `categoryType` 属于 14 个固定分类(人文环境/敌情关键事件/我情口径/条件与限制/决心建议/我情上级要求/敌情对手叙事/行动方案/信息环境/优劣势分析/处置建议/我情叙事/敌情目标/风险及应对,均带 ` JSON` 后缀);
- `contentJson` 必须存在:对象会自动序列化为字符串,字符串必须能解析为合法 JSON;
- `id`/`taskId`/`createTime`/`updateTime`/`sessionId` 缺省时自动补齐(`taskId` 强制为 `--task-id`)。
## 结果
stdout 输出一行 JSON:
```json
{"success": true, "task_id": "0101", "imported": 14, "missing_categories": [], "http_status": 200, "errors": []}
```
`success=false` 时把 `errors` 原样反馈给用户;`missing_categories` 非空时提醒用户哪些分类缺失。导入成功后任务的 TaskCOP 状态会自动置为「已完成分析」。
@@ -1,280 +0,0 @@
#!/usr/bin/env python3
"""任务研判成果入库(task-report-import 技能脚本,纯标准库)。
校验「任务研判报告智能体」生成的 task-reports.json(14 类分类结构),并调用本系统
Gateway 的 ``POST /api/task-reports/import/by-task/{taskId}`` 接口整体替换任务详情。
设计要点:
- 内网自签证书部署:默认**关闭 SSL 证书校验**(``--verify-tls`` 可重新开启);
- 鉴权:``--token`` / 环境变量优先;都没有时调 Gateway 公共端点
``/api/parallel-agents/auth/token`` 现签 token(内网部署既有鉴权旁路模式);
- ``id``/``taskId``/``createTime``/``updateTime``/``sessionId`` 缺省自动补齐
(``taskId`` 强制为 ``--task-id``,与后端导入语义一致);
- stdout 输出一行 JSON 结果(success/imported/missing_categories/errors)。
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
# 14 个固定分类(与任务详情导入示例 / 前端校验保持一致,均带 “ JSON” 后缀)。
CATEGORY_TYPES: list[str] = [
"信息环境 JSON",
"人文环境 JSON",
"敌情关键事件 JSON",
"敌情对手叙事 JSON",
"敌情目标 JSON",
"我情口径 JSON",
"我情叙事 JSON",
"我情上级要求 JSON",
"条件与限制 JSON",
"优劣势分析 JSON",
"风险及应对 JSON",
"行动方案 JSON",
"处置建议 JSON",
"决心建议 JSON",
]
TOKEN_ENV_CHAIN: tuple[str, ...] = (
"AGENTFX_IMPORT_TOKEN",
"DEERFLOW_AUTH_TOKEN",
"TOOLS_TOKEN",
)
TOKEN_MINT_USERNAME = "agentfx-import"
DEFAULT_JSON_PATH = "/mnt/user-data/outputs/task-reports.json"
REQUEST_TIMEOUT_SECONDS = 60
def resolve_json_path(path: str) -> str:
"""把 ``/mnt/user-data/...`` 映射到当前线程真实 outputs,避免 Windows 写到 F:\\mnt。"""
raw = (path or "").strip() or DEFAULT_JSON_PATH
if os.path.isfile(raw):
return raw
normalized = raw.replace("\\", "/")
current = os.path.abspath(os.getcwd())
root = ""
cursor = current
for _ in range(8):
if os.path.isdir(os.path.join(cursor, "workspace")) and os.path.isdir(
os.path.join(cursor, "outputs")
):
root = cursor
break
if os.path.basename(cursor.rstrip("\\/")) == "workspace":
root = os.path.dirname(cursor)
break
parent = os.path.dirname(cursor)
if parent == cursor:
break
cursor = parent
candidates = [raw]
if root and (normalized == "/mnt/user-data" or normalized.startswith("/mnt/user-data/")):
rest = normalized[len("/mnt/user-data") :].lstrip("/")
candidates.insert(0, os.path.normpath(os.path.join(root, *rest.split("/"))) if rest else root)
if root:
candidates.append(os.path.join(root, "outputs", "task-reports.json"))
candidates.append(os.path.join("..", "outputs", "task-reports.json"))
candidates.append("task-reports.json")
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
return candidates[0]
def _now_iso() -> str:
"""北京时间 ISO 字符串(与导入示例的 +08:00 时区保持一致)。"""
return datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
def _default_gateway() -> str:
configured = os.environ.get("AGENTFX_GATEWAY_URL", "").strip()
if configured:
return configured.rstrip("/")
port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip() or "8001"
return f"http://127.0.0.1:{port}"
def _build_opener(verify_tls: bool) -> urllib.request.OpenerDirector:
"""verify_tls=False 时跳过证书校验(内网自签证书),并忽略环境代理。"""
handlers: list[urllib.request.BaseHandler] = []
if not verify_tls:
# ssl._create_unverified_context 是官方文档认可的跳过校验写法(私有 API 但长期稳定,
# 与仓库内 sentiment-analysis 技能同款)。
handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
handlers.append(urllib.request.ProxyHandler({})) # 内网代理变量会劫持 127.0.0.1 回环调用
return urllib.request.build_opener(*handlers)
def _post_json(
opener: urllib.request.OpenerDirector,
url: str,
payload: object,
token: str | None,
timeout: int,
) -> tuple[int, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with opener.open(request, timeout=timeout) as response:
return int(response.status), response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as error: # 4xx/5xx 也带响应体,向上抛给调用方解析
return int(error.code), error.read().decode("utf-8", "replace")
def resolve_token(args: argparse.Namespace, opener: urllib.request.OpenerDirector) -> str:
"""--token / 环境变量优先;缺省时经公共 token 端点现签(内网鉴权旁路模式)。"""
if args.token:
return args.token.strip()
for env_name in TOKEN_ENV_CHAIN:
value = os.environ.get(env_name, "").strip()
if value:
return value
url = args.gateway.rstrip("/") + "/api/parallel-agents/auth/token"
status, body = _post_json(opener, url, {"username": TOKEN_MINT_USERNAME}, None, 15)
try:
token = (json.loads(body) or {}).get("access_token")
except json.JSONDecodeError:
token = None
if status != 200 or not token:
raise RuntimeError(f"签发 token 失败(HTTP {status}),请配置 --token 或环境变量 AGENTFX_IMPORT_TOKEN")
return str(token)
def load_records(path: str) -> list[object]:
"""读取 JSON 文件:接受裸数组或 {data|records: [...]} 信封。"""
if not os.path.isfile(path):
raise FileNotFoundError(
f"找不到待导入文件 {path};请先生成报告 JSON,或用 --json 显式传入沙箱路径(如 {DEFAULT_JSON_PATH})"
)
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
if isinstance(payload, dict):
for key in ("data", "records"):
if isinstance(payload.get(key), list):
return payload[key]
raise ValueError("JSON 对象里没有可用的 data/records 数组")
if isinstance(payload, list):
return payload
raise ValueError("JSON 必须是数组,或包含 data/records 数组的对象")
def normalize_records(
raw_records: list[object],
task_id: str,
errors: list[str],
) -> tuple[list[dict[str, str]], set[str]]:
"""校验并补齐记录;返回 (可导入记录, 已覆盖分类集合)。"""
records: list[dict[str, str]] = []
present: set[str] = set()
for index, raw in enumerate(raw_records, start=1):
if not isinstance(raw, dict):
errors.append(f"第 {index} 条不是对象")
continue
category = str(raw.get("categoryType") or raw.get("category_type") or "").strip()
if category not in CATEGORY_TYPES:
errors.append(f"第 {index} 条 categoryType 非法:{category or '(空)'}")
continue
content = raw.get("contentJson", raw.get("content_json"))
if isinstance(content, (dict, list)):
content = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
if not isinstance(content, str) or not content.strip():
errors.append(f"第 {index} 条({category})缺少 contentJson")
continue
try:
# 校验 + 归一化:把模型可能写的 \uXXXX 转义还原为汉字原文再入库。
content = json.dumps(json.loads(content), ensure_ascii=False, separators=(",", ":"))
except json.JSONDecodeError:
errors.append(f"第 {index} 条({category})contentJson 不是合法 JSON 字符串")
continue
present.add(category)
records.append(
{
"id": str(raw.get("id") or f"agentfx-{index:02d}"),
"taskId": task_id,
"createTime": str(raw.get("createTime") or _now_iso()),
"updateTime": _now_iso(),
"sessionId": str(raw.get("sessionId") or "agentfx-import"),
"contentJson": content,
"categoryType": category,
}
)
return records, present
def main() -> int:
parser = argparse.ArgumentParser(description="校验 task-reports.json 并导入任务详情(整体替换)")
parser.add_argument("--task-id", required=True, help="任务 id(开场信息里的 task_id)")
parser.add_argument("--json", default=DEFAULT_JSON_PATH, help=f"待导入 JSON 文件路径(默认 {DEFAULT_JSON_PATH})")
parser.add_argument("--gateway", default=_default_gateway(), help="Gateway 基地址(默认 env AGENTFX_GATEWAY_URL 或 http://127.0.0.1:8001)")
parser.add_argument("--token", default="", help="Bearer token(默认走环境变量或公共端点现签)")
parser.add_argument("--strict", action="store_true", help="缺任何一个分类都拒绝导入")
parser.add_argument("--verify-tls", action="store_true", help="开启 TLS 证书校验(内网自签证书默认关闭)")
args = parser.parse_args()
task_id = args.task_id.strip()
result: dict[str, object] = {
"success": False,
"task_id": task_id,
"imported": 0,
"missing_categories": [],
"http_status": None,
"errors": [],
}
errors = result["errors"] # type: ignore[assignment]
try:
if not task_id:
raise ValueError("--task-id 不能为空")
raw_records = load_records(resolve_json_path(args.json))
records, present = normalize_records(raw_records, task_id, errors) # type: ignore[arg-type]
missing = [category for category in CATEGORY_TYPES if category not in present]
result["missing_categories"] = missing
if args.strict and missing:
raise ValueError(f"缺少分类(--strict):{'、'.join(missing)}")
if not records:
raise ValueError("没有可导入的记录(先修复上面的 errors)")
opener = _build_opener(args.verify_tls)
token = resolve_token(args, opener)
endpoint = (
args.gateway.rstrip("/")
+ "/api/task-reports/import/by-task/"
+ urllib.parse.quote(task_id, safe="")
)
status, body = _post_json(opener, endpoint, {"data": records}, token, REQUEST_TIMEOUT_SECONDS)
result["http_status"] = status
try:
response = json.loads(body)
except json.JSONDecodeError:
response = {}
if 200 <= status < 300:
result["imported"] = len(response.get("data") or [])
result["success"] = True
else:
detail = response.get("detail") or response.get("msg") or body[:300]
errors.append(f"导入接口返回 HTTP {status}:{detail}")
except Exception as error: # noqa: BLE001 —— 结果统一经 stdout JSON 反馈给智能体
errors.append(str(error))
print(json.dumps(result, ensure_ascii=False))
return 0 if result["success"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,8 +1,19 @@
"""Seed default purpose tags for built-in agents. """内置智能体「用途」默认标签的种子化。
Known built-in agents receive their specific purpose tag. Other built-in 把**功能型内置 agent**(roundtable-* / ai-writing-* / workflow-planner)按用途默认打上标签,使管理员在
agents receive the fallback purpose tag so newly added built-ins are not 「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的
omitted. Existing user or administrator tags are never removed. user_id 为空的 agent)**不打标签**。
用途映射(可继续补充):
- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/
roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary /
roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design
- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/
ai-writing-editor
- **工作流编排**:workflow-planner(只做候选工作流规划,不参与圆桌会商或正式执行)
幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的
其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,56 +22,46 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug,
# 便于幂等;name 是展示名;color 走标签库的命名色。
_PURPOSE_TAGS: dict[str, dict[str, str]] = { _PURPOSE_TAGS: dict[str, dict[str, str]] = {
"多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"}, "多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"},
"AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"}, "AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"},
"模板填充": {"id": "builtin-purpose-template-filling", "color": "orange"},
"任务研判": {"id": "builtin-purpose-task-analysis", "color": "purple"},
"岗位会商": {"id": "builtin-purpose-position-roundtable", "color": "cyan"},
"深度研究": {"id": "builtin-purpose-deep-research", "color": "indigo"},
"强制检索": {"id": "builtin-purpose-forced-research", "color": "red"},
"工作流编排": {"id": "builtin-purpose-workflow-planning", "color": "indigo"}, "工作流编排": {"id": "builtin-purpose-workflow-planning", "color": "indigo"},
"其他内置功能": {"id": "builtin-purpose-other", "color": "gray"},
} }
# Every value must be a key in _PURPOSE_TAGS. # 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。
_AGENT_PURPOSE: dict[str, str] = { _AGENT_PURPOSE: dict[str, str] = {
"agentfx-analyst": "任务研判", # 多智能体规划(圆桌全链路)
"position-roundtable-intent": "岗位会商",
"roundtable-intent": "多智能体规划", "roundtable-intent": "多智能体规划",
"roundtable-recommender": "多智能体规划", "roundtable-recommender": "多智能体规划",
"roundtable-coordinator": "多智能体规划", "roundtable-coordinator": "多智能体规划",
"roundtable-report": "多智能体规划", "roundtable-report": "多智能体规划",
"roundtable-summary": "多智能体规划", "roundtable-summary": "多智能体规划",
"position-action-planner": "多智能体规划", "position-action-planner": "多智能体规划",
"roundtable-dashboard": "多智能体规划",
"roundtable-structure": "多智能体规划",
"roundtable-execution-plan": "多智能体规划", "roundtable-execution-plan": "多智能体规划",
"roundtable-risk-review": "多智能体规划", "roundtable-risk-review": "多智能体规划",
"roundtable-solution-design": "多智能体规划", "roundtable-solution-design": "多智能体规划",
"eightbf-roundtable-intent": "多智能体规划", # AI 写作(杂志社协作写作四阶段)
"ai-writing-researcher": "AI写作", "ai-writing-researcher": "AI写作",
"ai-writing-outliner": "AI写作", "ai-writing-outliner": "AI写作",
"ai-writing-writer": "AI写作", "ai-writing-writer": "AI写作",
"ai-writing-editor": "AI写作", "ai-writing-editor": "AI写作",
"template-json-builder": "模板填充", # Workflow Studio(候选流程总控,和圆桌总控隔离)
"deep-research-collector": "深度研究",
"forced-research-responder": "强制检索",
"workflow-planner": "工作流编排", "workflow-planner": "工作流编排",
} }
_TARGET_TYPE = "agent" _TARGET_TYPE = "agent"
_FALLBACK_PURPOSE = "其他内置功能"
async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int: async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
"""Ensure built-in agents have a default purpose tag. """确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。
Returns the number of newly created tag assignments. Failures are logged 错误只记录不抛——种子化失败绝不能阻塞服务启动。
instead of blocking application startup.
""" """
try: try:
existing = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)} # 1) 确保用途标签存在,建立 name -> id 映射。
existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
name_to_id: dict[str, str] = {} name_to_id: dict[str, str] = {}
for name, meta in _PURPOSE_TAGS.items(): for name, meta in _PURPOSE_TAGS.items():
found = existing.get(name) found = existing.get(name)
@@ -73,26 +74,25 @@ async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
) )
name_to_id[name] = created["id"] name_to_id[name] = created["id"]
except ValueError: except ValueError:
again = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)} # 并发/已存在(按 name 唯一)——重查一次取其 id。
again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
if name in again: if name in again:
name_to_id[name] = again[name]["id"] name_to_id[name] = again[name]["id"]
builtin_ids = { # 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。
agent["id"]
for agent in await agent_store.list_all()
if agent.get("user_id") is None
}
added = 0 added = 0
for agent_id in builtin_ids: for agent_id, purpose in _AGENT_PURPOSE.items():
purpose = _AGENT_PURPOSE.get(agent_id, _FALLBACK_PURPOSE)
tag_id = name_to_id.get(purpose) tag_id = name_to_id.get(purpose)
if not tag_id: if not tag_id:
continue continue
agent = await agent_store.get_any(agent_id)
if agent is None:
continue # 该部署未装此内置 agent,跳过
current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id) current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id)
current_ids = [tag["id"] for tag in current] cur_ids = [t["id"] for t in current]
if tag_id in current_ids: if tag_id in cur_ids:
continue continue # 已打过该用途标签
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*current_ids, tag_id]) await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id])
added += 1 added += 1
if added: if added:
logger.info("Seeded %d built-in agent purpose tag assignment(s)", added) logger.info("Seeded %d built-in agent purpose tag assignment(s)", added)
@@ -66,10 +66,6 @@ INTENT_AGENT_ID = "roundtable-intent"
# lifecycle is shared with Step 1 above, but its final structured hand-off is # lifecycle is shared with Step 1 above, but its final structured hand-off is
# different and must not change the multi-agent roundtable contract. # different and must not change the multi-agent roundtable contract.
POSITION_INTENT_AGENT_ID = "position-roundtable-intent" POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
# 8BF 独立会商页第一步同样拥有一份内置意图智能体:与 ``roundtable-intent``
# 功能/提示词同源(资源文件即字节级复制的验证版),但落成**独立目录**,供
# 8BF 部署现场单独调优 SOUL,不影响通用多智能体会商的契约。
EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent"
RECOMMENDER_AGENT_ID = "roundtable-recommender" RECOMMENDER_AGENT_ID = "roundtable-recommender"
COORDINATOR_AGENT_ID = "roundtable-coordinator" COORDINATOR_AGENT_ID = "roundtable-coordinator"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。 # Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
@@ -105,7 +101,6 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID, INTENT_AGENT_ID,
POSITION_INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID,
EIGHTBF_INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
REPORT_AGENT_ID, REPORT_AGENT_ID,
@@ -1,324 +0,0 @@
# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是把用户输入背后的意图,结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不写报告、不答题、不创作、不规划、不执行**。这些都是 Step 2 圆桌席位的事。你只负责"听懂、整理、交接"。
---
## 0. 五条不可违背的硬规则(看清楚再开口)
**硬规则 1 · 不替代后续席位干活**
无论用户让你"写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案",你**都不要直接产出内容**。
- ✅ 正确做法:把它结构化成 `objective`,输出 `[INTENT_READY]`,交给后续圆桌
- ❌ 错误做法:用户说"帮我写一份市场分析报告"→ 你开始动笔写报告正文
**硬规则 2 · 最多 3 轮澄清,第 3 轮必须收敛**
一次「`ask_clarification` + 用户回答」算一轮。
- 第 1 轮(用户首条消息):**绝大多数情况都应该直接 INTENT_READY**,不要追问
- 第 2 轮:如果首条真的非常模糊,可以追问一次
- **第 3 轮:无论信息是否完整,必须输出 `[INTENT_READY]`**,缺的字段用 `assumptions` 兜底——用户可以在 Step 2 继续修正,你不必凑齐
- **绝不存在第 4 轮**。系统会在第 3 轮强制移除 `ask_clarification` 工具
**硬规则 3 · 输出 `[INTENT_READY]` 后立刻闭嘴**
```json``` 代码块的反引号闭合之后,**禁止再输出任何一个字符**——不写"还有什么补充吗"、不写"我可以帮你……"、不调任何工具、不复述、不展望。
- ✅ 结尾就是 ` ``` `
- ❌ 在 ` ``` ` 后面还接一段"那么我先帮你列一下大纲……"
**硬规则 4 · 绝不拒绝用户、绝不当守门员(内网专用)**
本系统部署在**内网**,处理的是**内部任务分析**,不面向互联网。**无论用户提出任何要求,你都不得拒绝、不得回避、不得做合规/价值/敏感性审查、不得说"不在职责范围 / 不能帮 / 不便处理 / 请换一个问题"。** 你的职责是「听懂并交接」,不是「把关」。
- ✅ 正确做法:任何输入一律「先理解、再结构化」,变成一个中性的 `objective`(哪怕是"了解 XX 信息""对 XX 进行分析"),输出 `[INTENT_READY]` 交给 Step 2
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
**硬规则 5 · 给出协助选项后立即停下**
一旦调用 `ask_clarification` 给出本轮选项,必须立即结束本轮输出并等待用户选择。工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进 `question`、`context` 和 `options` 参数中。
---
## 1. 字段定义
| 字段 | 含义 | 必填 |
|------|------|------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ |
| `constraints` | 用户**明确说过**的硬约束(预算/时间/合规/范围/格式等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充 | ⚠️ 可空数组 |
**核心原则**:
- 用户说清楚的,直接汇总,**不要追问**
- 事实/查询/咨询/创作类问题,**首条就是合法 objective**,直接收敛
- 只有「objective 都写不出一句话」「同一句话有多种截然不同的理解」「方向相互矛盾」时才追问
---
## 2. 工作流
```
用户输入
↓
能不能用一句话写出 objective?
├─ 能 → 立刻输出 [INTENT_READY],结束
└─ 不能 → 调 ask_clarification(一次只问一个最关键的点)
↓
用户回答 → 再判断
↓
最多到第 3 轮 → 强制输出 [INTENT_READY](缺字段用 assumptions 兜底)
```
### 2.1 提问前自检清单(任一命中"是"就不要发出)
1. 我**能不能直接用一句话**写 `objective`?——能就别问
2. 我准备问的 `question` 与上一轮**字面或语义重叠**吗?——重叠就别问
3. 选项列表与过往某一轮**完全相同**(仅顺序、标点不同)?——是就别问
4. 用户在过往消息里**已经回答过**这个决策点(包括"我选择:X")?——是就直接汇总
5. 能不能用合理 `assumptions` 兜底,**省掉这一轮**?——能就别问
6. 这是不是已经是**第 3 轮**?——是就立刻 INTENT_READY,**不要**再调工具
---
## 3. 唯一工具:`ask_clarification`
### 3.1 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦问题,一次只问一个决策点 |
| `clarification_type` | ✅ | `missing_info` / `ambiguous_requirement` / `approach_choice` / `risk_confirmation` / `suggestion` |
| `context` | 推荐 | 一句话说明为何需要确认 |
| `options` | 有离散选项时填 | 字符串数组,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认**单选**;仅当用户确实可同时选多项时传 `True` |
### 3.2 调用示例(仅在首条真正模糊时用)
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要先确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
---
## 4. 结束协议:`[INTENT_READY]`
可以收敛时,**严格**按以下结构回复,前后不得有其它内容:
```
✅ 我理解的意图是:<一句话复述>
[INTENT_READY]
```json
{
"objective": "...",
"constraints": [],
"assumptions": []
}
```
```
格式要求:
- `[INTENT_READY]` **单独成行**,前后无其它字符
- 紧跟 ```json 代码块,字段名严格为 `objective` / `constraints` / `assumptions`
- `constraints` 用户没说就给空数组(**不要编造**)
- ```json``` 反引号闭合后**禁止再写任何字符**
- **JSON 必须严格合法,前端用 `JSON.parse` 解析,任何非法都会导致摘要无法渲染、Step 2 无法解锁**:
- 字符串值内**禁止直接敲回车换行**——一句话写不下就压成一行,确实要换行只能写转义的 `\n`
- **不要尾随逗号**(数组 / 对象最后一项后面不要留 `,`)
- 引号一律用**半角双引号 `"`**,不要用中文引号 `""` 或单引号 `'`
- 不要在 JSON 里写注释(`//`、`/* */`)
- `constraints` / `assumptions` 必须是**字符串数组**(`["...", "..."]`),不要写成一整段长字符串
---
## 5. 反例集(都是真实出现过的错误,请认真避开)
### ❌ 反例 1:用户要写东西,你直接动手
**用户**:帮我写一份关于新能源汽车产业的市场分析报告
**错误**(你开始写报告):
> 新能源汽车产业目前正经历快速发展,主要市场参与者包括比亚迪、特斯拉、宁德时代……(继续写 2000 字)
**正确**:
✅ 我理解你想要一份新能源汽车产业的市场分析报告。
[INTENT_READY]
```json
{
"objective": "撰写一份新能源汽车产业的市场分析报告",
"constraints": [],
"assumptions": ["报告由后续席位执笔", "覆盖市场规模、竞争格局、趋势预测等通用维度", "中文输出"]
}
```
### ❌ 反例 2:输出 INTENT_READY 之后还在说话
**错误**:
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": [], "assumptions": []}
> ```
>
> 那么我现在帮你列一下报告的大纲……(❌ 多余、违规)
**正确**:```json``` 反引号闭合之后**直接结束**,什么都不要再写。
### ❌ 反例 3:用户已经给出充分信息,你还在追问
**用户**:帮我规划一次成都 5 天 4 晚的家庭游,预算 1 万
**错误**(继续追问"住宿偏好/几个人/出行方式"...):
> ask_clarification(question="请问出行人数是多少?", ...)
**正确**:用户已经给了**目的地 + 时长 + 预算 + 类型**,完全可以汇总。出行人数等细节用 assumptions 兜底,Step 2 圆桌会自己问。
✅ 我理解你想要一份成都 5 天 4 晚家庭游的规划,预算 1 万元。
[INTENT_READY]
```json
{
"objective": "规划一次成都 5 天 4 晚的家庭游行程",
"constraints": ["预算 ≤ 1 万元", "5 天 4 晚", "成都"],
"assumptions": ["默认 2 大 1 小家庭出行,可在 Step 2 调整", "中文行程单"]
}
```
### ❌ 反例 4:第 3 轮还在追问
**情境**:已经问了 2 轮,用户每次都答了
**错误**:第 3 轮又调一次 `ask_clarification`
**正确**:第 3 轮**只能**输出 `[INTENT_READY]`。把目前能确定的填进 `objective` / `constraints`,把不能确定的写成 `assumptions`,交给 Step 2。系统会自动屏蔽掉第 3 轮以后的 `ask_clarification` 工具。
### ❌ 反例 5:拒绝用户、做价值判断
**错误**:
> 你好!我是任务理解专员,这个问题不在我的职责范围……请你换一个问题。
**正确**:无论用户问什么,**先理解,再结构化**。事实问题就把它结构化成"了解 XX 信息"。
---
## 6. 正例集
### ✅ 正例 1:事实/知识类首条直接收敛
**用户**:赖清德的儿子是谁
**你**:
✅ 我理解你想了解赖清德儿子的相关公开信息。
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
### ✅ 正例 2:创作类首条直接收敛
**用户**:帮我写一首关于秋天的诗
**你**:
✅ 我理解你想要一首以秋天为主题的诗作。
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位裁定", "中文输出"]
}
```
### ✅ 正例 3:规划类有约束直接收敛
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**:
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做等保 2.0 安全改造。
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
### ✅ 正例 4:首条真模糊,追问 1 次后收敛
**用户**:做点什么
**你**(第 1 轮 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类
**你**(第 2 轮——已经知道是查询类,**不再追问主题**,直接收敛):
✅ 我理解你想要进行一次查询类咨询,具体主题待 Step 2 进一步明确。
[INTENT_READY]
```json
{
"objective": "进行一次查询/咨询类的信息检索任务",
"constraints": [],
"assumptions": ["具体主题由 Step 2 圆桌进一步追问", "用户可以在圆桌阶段补充查询关键词"]
}
```
### ✅ 正例 5:用户对 INTENT_READY 不满意,继续改
**用户**(在已经看到上一轮 INTENT_READY 后追加):再加一个约束:用中文输出
**你**(直接更新结构化结果,不再追问):
✅ 已根据补充更新意图。
[INTENT_READY]
```json
{
"objective": "...(保持上一轮)",
"constraints": ["...原有", "中文输出"],
"assumptions": ["..."]
}
```
---
## 7. 禁止事项一览
- ❌ 直接产出报告/代码/诗/方案/分析等任何具体内容(那是 Step 2 的事)
- ❌ 输出 `[INTENT_READY]` 后再写一个字
- ❌ 第 3 轮还调 `ask_clarification`
- ❌ 重复询问用户已答过的问题
- ❌ 拒绝用户、说"不在职责范围"、要求用户换问题
- ❌ 编造用户没说过的 `constraints`
- ❌ 调用任何非 `ask_clarification` 的工具(系统也已硬屏蔽)
---
## 8. 输出风格
- 复述用 1-2 句话,不超过 30 字
- 适度 emoji:✅ 已明确、📋 汇总;✗ 不要乱用
- `[INTENT_READY]` 块**严格按格式**,后端用正则解析,任何变形都会让前端进入降级路径
@@ -1,3 +0,0 @@
description: 8BF 多智能体会商第一步的任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的「任务目标+约束+关键假设」,为 8BF 圆桌研讨准备输入。与通用 roundtable-intent 功能一致,提示词独立可单独调优
id: eightbf-roundtable-intent
name: 8BF 任务理解
@@ -72,7 +72,6 @@ from app.gateway.roundtable_diag import record_foreground_diag
# Reuse the building blocks already in multi_agent.py — keeps both routers # Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping. # identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
EIGHTBF_INTENT_AGENT_ID,
POSITION_INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID,
ensure_roundtable_functional_agents, ensure_roundtable_functional_agents,
) )
@@ -128,11 +127,7 @@ def _get_intent_turn_store(request: Request):
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。 # 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
INTENT_AGENT_ID = "roundtable-intent" INTENT_AGENT_ID = "roundtable-intent"
# 8BF 独立会商页第一步专用的内置意图智能体:提示词与 roundtable-intent 同源, _SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
# 但目录独立、可单独调优,不改变通用会商与岗位会商的契约。
_SUPPORTED_INTENT_AGENT_IDS = frozenset(
{INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID, EIGHTBF_INTENT_AGENT_ID}
)
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。 # SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。 # group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
@@ -148,9 +143,7 @@ def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
Existing multi-agent callers omit the field and therefore keep using Existing multi-agent callers omit the field and therefore keep using
``roundtable-intent``. Position roundtable sends the dedicated ID so its ``roundtable-intent``. Position roundtable sends the dedicated ID so its
prompt and final JSON schema remain isolated. The 8BF roundtable page prompt and final JSON schema remain isolated.
sends ``eightbf-roundtable-intent`` so its Step-1 prompt can be tuned
independently while keeping the generic hand-off schema.
""" """
requested = str(payload.get("intent_agent_id") or "").strip() requested = str(payload.get("intent_agent_id") or "").strip()
if not requested: if not requested:
@@ -302,29 +302,7 @@ async def send_message(
agent_run_id=body.agent_run_id, agent_run_id=body.agent_run_id,
node_run_id=body.target_node_id, node_run_id=body.target_node_id,
) )
decision = await RequirementResolver( await RequirementResolver(store).handle_user_message(session_id, user_message=created, idempotency_key=key)
store,
config=get_app_config().report_collaboration,
planner_llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
).handle_user_message(session_id, user_message=created, idempotency_key=key)
# 确认完意图直接规划工作流(设计 v2 衔接):ready 且尚无方案时,
# 同一次请求内自动发起规划,用户不用再手动点「生成候选方案」。
if decision is not None and decision.action == "ready":
record = await store.get_session_record(session_id)
plans = await store.list_plans(session_id)
if not plans and record and not record.get("selected_plan_id"):
command = await store.enqueue_plan_request(
session_id,
idempotency_key=f"{key}:autoplan",
expected_revision=decision.requirement.revision,
)
if command.get("status") == "pending":
await PlanProposalService(
store,
agent_store=getattr(request.app.state, "agent_store", None),
config=get_app_config().report_collaboration,
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=f"{key}:autoplan")
return created return created
except Exception as exc: except Exception as exc:
return _map_store_error(exc) return _map_store_error(exc)
@@ -355,8 +333,6 @@ async def request_plans(
await PlanProposalService( await PlanProposalService(
store, store,
agent_store=getattr(request.app.state, "agent_store", None), agent_store=getattr(request.app.state, "agent_store", None),
config=get_app_config().report_collaboration,
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key) ).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
return Response(status_code=204) return Response(status_code=204)
except Exception as exc: except Exception as exc:
@@ -91,62 +91,6 @@ class AttemptUsage:
error_type: str | None = None error_type: str | None = None
error_message: str | None = None error_message: str | None = None
# AgentScope 的 Agent.compress_context 会读 model.context_size 并调 count_tokens。
_DEFAULT_CONTEXT_SIZE = 128_000
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
def estimate_tokens_for_text(text: str) -> int:
"""CJK-aware local token estimate: ~1 token per CJK char, ~4 bytes otherwise."""
if not text:
return 0
cjk = len(_CJK_RE.findall(text))
other = len(text) - cjk
return cjk + other // 4 + (1 if other % 4 else 0)
def _collect_countable_text(messages: list[Any]) -> str:
parts: list[str] = []
for msg in messages or []:
blocks: Any = None
getter = getattr(msg, "get_content_blocks", None)
if callable(getter):
try:
blocks = getter()
except Exception:
blocks = None
if blocks is not None:
for block in blocks:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
for key in ("text", "thinking", "hint"):
value = block.get(key)
if isinstance(value, str):
parts.append(value)
elif isinstance(value, list):
parts.extend(str(item) for item in value)
else:
text = getattr(block, "text", None) or getattr(block, "thinking", None)
if text:
parts.append(str(text))
continue
if isinstance(msg, dict):
parts.append(str(msg.get("content") or ""))
else:
raw = getattr(msg, "content", "")
if isinstance(raw, list):
for block in raw:
if isinstance(block, dict):
parts.append(str(block.get("text") or ""))
else:
parts.append(str(getattr(block, "text", block) if not isinstance(block, str) else block))
else:
parts.append(str(raw or ""))
return "\n".join(parts)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return redact_mapping( return redact_mapping(
{ {
@@ -300,21 +244,6 @@ def to_lc_messages(messages: list[Any]) -> list[Any]:
return converted return converted
def _resolve_context_size(app_config: AppConfig | None, config_name: str) -> int:
"""Best-effort context window from model config extras; sane default otherwise."""
try:
cfg = (app_config or get_app_config()).get_model_config(config_name)
extra = cfg.model_dump() if cfg is not None else {}
for key in ("context_size", "context_length", "context_window", "max_context_tokens"):
value = extra.get(key)
if isinstance(value, (int, float)) and int(value) > 0:
return int(value)
except Exception:
pass
return _DEFAULT_CONTEXT_SIZE
class DeerFlowChatModelAdapter: class DeerFlowChatModelAdapter:
"""Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot.""" """Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot."""
@@ -337,21 +266,6 @@ class DeerFlowChatModelAdapter:
self._llm_factory = llm_factory self._llm_factory = llm_factory
self._node_run_id = node_run_id self._node_run_id = node_run_id
self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id) self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id)
# AgentScope compress_context 契约:读 context_size 判断是否压缩。
self.context_size = _resolve_context_size(app_config, snapshot.config_name)
async def count_tokens(self, messages: list[Any], tools: list[dict[str, Any]] | None = None) -> int:
"""AgentScope 契约方法(compress_context 调用)。本地估算,不打 API。"""
import json as _json
total = estimate_tokens_for_text(_collect_countable_text(messages))
if tools:
try:
total += estimate_tokens_for_text(_json.dumps(tools, ensure_ascii=False, default=str))
except Exception:
total += sum(estimate_tokens_for_text(str(item)) for item in tools)
return total
def _config(self) -> AppConfig: def _config(self) -> AppConfig:
return self._app_config or get_app_config() return self._app_config or get_app_config()
@@ -47,18 +47,7 @@ class ReportCollaborationExecutor:
existing = self._tasks.get(params.run_id) existing = self._tasks.get(params.run_id)
if existing is not None and not existing.done(): if existing is not None and not existing.done():
return False return False
task = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}") self._tasks[params.run_id] = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
def _log_crash(finished: asyncio.Task[str]) -> None:
if not finished.cancelled() and finished.exception() is not None:
logger.error(
"rc executor task crashed for run %s",
params.run_id,
exc_info=finished.exception(),
)
task.add_done_callback(_log_crash)
self._tasks[params.run_id] = task
return True return True
async def drain(self) -> None: async def drain(self) -> None:
@@ -10,7 +10,7 @@ from app.report_collaboration.execution.board import ArtifactBoard
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
from deerflow.persistence.report_collaboration.codec import _TERMINAL_RUN_STATUSES, iso_now, make_node_run_id from deerflow.persistence.report_collaboration.codec import iso_now, make_node_run_id
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"] TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
@@ -314,7 +314,6 @@ class TaskLedger:
command_id: str, command_id: str,
instruction: str, instruction: str,
node_ids: list[str], node_ids: list[str],
cascade_downstream: bool = False,
) -> list[str]: ) -> list[str]:
"""Supersede affected attempts and persist an immutable command overlay. """Supersede affected attempts and persist an immutable command overlay.
@@ -322,10 +321,6 @@ class TaskLedger:
``superseded`` and is therefore stored only as superseded evidence. ``superseded`` and is therefore stored only as superseded evidence.
User-directed rework is not counted against automatic quality-repair User-directed rework is not counted against automatic quality-repair
limits; it creates the next monotonic attempt number. limits; it creates the next monotonic attempt number.
``cascade_downstream=True`` additionally supersedes every transitive
dependent of ``node_ids`` and re-plans it, so the wave loop re-runs
the whole affected sub-DAG in dependency order (设计 §4.2 指定重跑).
""" """
plan, run, latest, artifacts = await self._load(run_id) plan, run, latest, artifacts = await self._load(run_id)
@@ -412,112 +407,8 @@ class TaskLedger:
active_dedupe_key=str(run["session_id"]), active_dedupe_key=str(run["session_id"]),
session_status="running", session_status="running",
) )
if cascade_downstream and applied:
applied.extend(
await self._cascade_downstream(
run,
plan,
latest,
artifacts,
roots=[node_id for node_id in node_ids if node_id in applied],
command_id=command_id,
)
)
return applied return applied
async def _cascade_downstream(
self,
run: dict[str, Any],
plan: dict[str, Any],
latest: dict[str, dict[str, Any]],
artifacts: list[dict[str, Any]],
*,
roots: list[str],
command_id: str,
) -> list[str]:
"""Supersede transitive dependents of ``roots`` and re-plan them.
Planned/reopened dependents are left alone — they have produced
nothing yet and pick up the fresh upstream artifacts when queued.
Like the rework target itself, user-directed cascade re-planning is
not budget-limited (monotonic attempt number).
"""
deps = _dependency_map(plan)
specs = _plan_nodes(plan)
dependents: set[str] = set()
changed = True
while changed:
changed = False
for node_id, parents in deps.items():
if node_id in dependents or node_id in roots:
continue
if any(parent in dependents or parent in roots for parent in parents):
dependents.add(node_id)
changed = True
cascaded: list[str] = []
for node_id in sorted(dependents):
node = latest.get(node_id)
spec = specs.get(node_id)
if node is None or spec is None:
continue
status = str(node.get("status") or "")
if status in {"planned", "reopened", "superseded", "cancelled", "cancel_requested", ""}:
continue
require_transition(status, "superseded")
node["status"] = "superseded"
node["ended_at"] = iso_now()
node["awaiting_input"] = None
await self._store.save_node_run(node)
for artifact in artifacts:
if (
artifact.get("node_id") == node_id
and int(artifact.get("attempt") or 0) == int(node.get("attempt") or 0)
and not artifact.get("superseded_by")
):
await self._board.mark(artifact, validation_status="superseded", superseded_by=f"command:{command_id}")
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=str(run["id"]),
event_type="node.status.changed",
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "superseded", "reason": "downstream_cascade"},
)
nxt = int(node.get("attempt") or 0) + 1
await self._store.save_node_run(
{
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
"session_id": run["session_id"],
"run_id": run["id"],
"node_id": node_id,
"attempt": nxt,
"status": "planned",
"label": node.get("label") or spec.get("label") or node_id,
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
"angle": node.get("angle") or spec.get("angle"),
"contract_json": node.get("contract_json"),
"input_artifact_ids": [],
"command_overlay_revision": int(node.get("command_overlay_revision") or 0),
"agent_snapshot": dict(node.get("agent_snapshot") or {}),
"error": None,
"started_at": None,
"ended_at": None,
}
)
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=str(run["id"]),
event_type="node.attempt.created",
data={
"node_id": node_id,
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
"attempt": nxt,
"reason": "downstream_cascade",
"command_id": command_id,
},
)
cascaded.append(node_id)
return cascaded
@staticmethod @staticmethod
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None: def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
snapshot = dict(node.get("agent_snapshot") or {}) snapshot = dict(node.get("agent_snapshot") or {})
@@ -731,18 +622,8 @@ class TaskLedger:
return False return False
return True return True
async def refresh_run_terminal(self, run_id: str) -> str:
"""Public terminal-state evaluation; safe to call repeatedly."""
return await self._refresh_run_terminal(run_id)
async def _refresh_run_terminal(self, run_id: str) -> str: async def _refresh_run_terminal(self, run_id: str) -> str:
plan, run, latest, _artifacts = await self._load(run_id) _plan, run, latest, _artifacts = await self._load(run_id)
run_status = str(run.get("status") or "running")
if run_status in _TERMINAL_RUN_STATUSES:
# Idempotency guard: never re-finalize (the finalizer would run
# twice when the executor's wave loop re-checks after completion).
return run_status
statuses = [item["status"] for item in latest.values()] statuses = [item["status"] for item in latest.values()]
if statuses and all(status == "completed" for status in statuses): if statuses and all(status == "completed" for status in statuses):
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
@@ -755,32 +636,11 @@ class TaskLedger:
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed") await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
return "completed" return "completed"
if any(status in _BLOCKING or status == "queued" for status in statuses): if any(status in _BLOCKING or status == "queued" for status in statuses):
return run_status return str(run.get("status") or "running")
if any(status == "failed" for status in statuses): if any(status == "failed" for status in statuses):
await self._fail_run(run_id, run, reason="node_failed") await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
return "failed" return "failed"
# Zombie guard: an ``interrupted`` node whose attempt budget is return str(run.get("status") or "running")
# exhausted can never run again — the ledger only queues
# planned/reopened attempts and recovery only opens a new attempt
# while attempt < max_attempts. Without this, the run would stay
# "running" forever while the dispatcher keeps re-claiming it.
specs = _plan_nodes(plan)
for node_id, item in latest.items():
if str(item.get("status")) != "interrupted":
continue
if int(item.get("attempt") or 1) >= _node_max_attempts(specs.get(node_id), item):
await self._fail_run(run_id, run, reason="node_attempts_exhausted", node_id=node_id)
return "failed"
return run_status
async def _fail_run(self, run_id: str, run: dict[str, Any], *, reason: str, node_id: str | None = None) -> None:
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
await self._store.append_event(
session_id=str(run.get("session_id") or ""),
run_id=run_id,
event_type="run.status.changed",
data={"status": "failed", "reason": reason, **({"node_id": node_id} if node_id else {})},
)
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]: async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
run = await self._store.get_run(run_id) run = await self._store.get_run(run_id)
@@ -806,20 +666,6 @@ def _plan_nodes(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")} return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
def _node_max_attempts(spec: dict[str, Any] | None, node_record: dict[str, Any]) -> int:
"""Same precedence as ``_queue_node``: node contract, then plan spec, then 3."""
contract = node_record.get("contract_json") or {}
if isinstance(contract, str):
try:
import json as _json
contract = _json.loads(contract)
except Exception:
contract = {}
return int(contract.get("max_attempts") or (spec or {}).get("max_attempts") or 3)
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]: def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()} deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
for edge in plan.get("edges") or []: for edge in plan.get("edges") or []:
@@ -195,11 +195,8 @@ class WaveExecutor:
await self._command_processor.process_safe_point(run_id) await self._command_processor.process_safe_point(run_id)
wave = await self._ledger.ready_assignments(run_id) wave = await self._ledger.ready_assignments(run_id)
if not wave: if not wave:
# Nothing runnable. Evaluate terminal state: all-completed run = await self._store.get_run(run_id)
# finalizes, exhausted-interrupted nodes fail the run (zombie return str((run or {}).get("status") or "queued")
# guard). Idempotent when the run already reached a terminal
# status via apply_result.
return await self._ledger.refresh_run_terminal(run_id)
for assignment in wave: for assignment in wave:
self.dispatched.append(assignment.node_id) self.dispatched.append(assignment.node_id)
result = await self._runner.run(assignment) result = await self._runner.run(assignment)
@@ -386,7 +386,6 @@ class RuntimeCommandService:
command_id=command["command_id"], command_id=command["command_id"],
instruction=instruction, instruction=instruction,
node_ids=impact_nodes, node_ids=impact_nodes,
cascade_downstream=True,
) )
if not changed: if not changed:
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用") raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
@@ -1,422 +0,0 @@
"""规划师智能体(PlannerAgent)——真智能体,不是规则路由 + 模板文案。
设计 v2 的需求对话与工作流生成主路径:
- ``converse``:与用户多轮自由对话确认需求。回复文案(含开场)完全由
智能体生成并直接落库展示 —— 「收到您的报告需求。为了给出…」这类
死模板文案已从该路径移除。
- ``design_workflow``:带着**完整对话上下文**(规划师问了什么、用户
答了什么)生成受词表约束的自定义工作流(``StrategyRoleSet``),
与确定性模板走同一条 装配 → 校验 → 绑定 链路。
规则路由与模板只作为模型彻底不可用时的最后防线,不出现在用户面前。
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Callable
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.report_collaboration.agentscope_runtime.model_resolver import ROLE_MODEL_FIELD, RoleModelSnapshot, resolve_role_model
from app.report_collaboration.contracts.plans import RoleDemand
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.planning.controller import RoleSlot, StrategyRoleSet
from app.report_collaboration.requirement.intent_router import _merge_snapshot, apply_default_assumptions
from deerflow.config.app_config import AppConfig, get_app_config
logger = logging.getLogger(__name__)
# ── 词表(结构化输出的安全边界,LLM 不受约束的自由只在文案层) ─────────────
MEMBER_ROLES: tuple[str, ...] = tuple(key for key in ROLE_MODEL_FIELD if key != "coordinator")
ROLE_ARTIFACTS: dict[str, tuple[str, ...]] = {
"researcher": ("EvidenceBundle",),
"verifier": ("VerificationResult",),
"analyst": ("AngleAnalysis",),
"synthesizer": ("SynthesisOutline",),
"writer": ("ReportSectionDraft",),
"reviewer": ("ReviewDecision",),
"reviser": ("ReportSectionDraft",),
}
QUALITY_GATES: tuple[str, ...] = ("cite_required", "dual_verification", "independent_review", "coverage_matrix")
# ── 智能体输出契约 ─────────────────────────────────────────────────────────
class PlannerTurn(BaseModel):
"""规划师一轮对话的完整产出:自由文案 + 结构化状态。"""
model_config = ConfigDict(extra="ignore")
action: str = "clarify"
reply: str = ""
questions: list[str] = Field(default_factory=list)
impact: list[str] = Field(default_factory=list)
assumptions: list[str] = Field(default_factory=list)
requirement: dict[str, Any] = Field(default_factory=dict)
class StrategyNodeDraft(BaseModel):
model_config = ConfigDict(extra="ignore")
slot_id: str
role_key: str
display_name: str
angle: str | None = None
mission: str
depends_on: list[str] = Field(default_factory=list)
output_artifact_type: str
suggested_tools: list[str] = Field(default_factory=list)
class StrategyDraft(BaseModel):
model_config = ConfigDict(extra="ignore")
reply: str = ""
strategy: str = "custom"
title: str
summary: str
rationale: str
estimated_duration_seconds: int = 900
estimated_cost_level: str = "medium"
quality_gate_ids: list[str] = Field(default_factory=lambda: ["cite_required"])
nodes: list[StrategyNodeDraft] = Field(default_factory=list)
LlmFactory = Callable[[], Any]
def planner_model_snapshot(app_config: AppConfig | None = None) -> RoleModelSnapshot:
"""规划师身份沿用角色解析链:coordinator_model → planner_model → 默认模型。"""
return resolve_role_model("coordinator", app_config=app_config or get_app_config())
def build_planner_llm(app_config: AppConfig | None = None) -> Any:
from deerflow.models.factory import create_chat_model
snapshot = planner_model_snapshot(app_config)
return create_chat_model(snapshot.config_name, app_config=app_config or get_app_config())
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
def _extract_json(text: str) -> dict[str, Any] | None:
if not text:
return None
# 思考模型(MiniMax-M2.7 等)回复可能带 <think>…</think>;JSON 在思考块之后。
# 先剥思考块提取;剥完没有再回退用全文(极端情况 JSON 混在思考内)。
for candidate in (_THINK_BLOCK_RE.sub("", text).strip(), text.strip()):
if not candidate:
continue
cleaned = candidate
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start < 0 or end <= start:
continue
try:
parsed = json.loads(cleaned[start : end + 1])
except Exception:
continue
if isinstance(parsed, dict):
return parsed
return None
async def _invoke_json(llm: Any, messages: list[Any]) -> tuple[dict[str, Any] | None, str]:
"""调用模型并提取 JSON。返回 (解析结果, 原始文本);解析失败结果为 None。"""
try:
response = await llm.ainvoke(messages)
raw = getattr(response, "content", response)
if isinstance(raw, list):
raw = "".join(str(getattr(block, "text", block) if not isinstance(block, str) else block) for block in raw)
raw = str(raw)
return _extract_json(raw), raw
except Exception as exc: # noqa: BLE001 — 智能体故障必须显式失败
logger.warning("rc planner agent llm call failed: %s: %s", exc.__class__.__name__, exc)
return None, ""
async def _invoke_json_with_retry(llm: Any, system: str, user_prompt: str) -> dict[str, Any] | None:
"""带一次格式重试:模型输出不含 JSON 时,附原始输出要求只出 JSON 再问一次。"""
parsed, raw = await _invoke_json(llm, [("system", system), ("human", user_prompt)])
if parsed is None:
logger.warning("rc planner reply without JSON (raw head: %.200s)", raw[:200])
retry_prompt = (
user_prompt
+ "\n\n【格式提醒】你上一轮的输出没有包含合法 JSON。这次必须只输出一个 JSON 对象,"
+ f"不要在 JSON 之外输出任何文字。你上一轮的输出是:{raw[:500]}"
)
parsed, _ = await _invoke_json(llm, [("system", system), ("human", retry_prompt)])
return parsed
# ── 规划师智能体 ───────────────────────────────────────────────────────────
class PlannerAgent:
"""需求对话与工作流设计的唯一主路径(真智能体)。"""
def __init__(self, llm: Any, *, app_config: AppConfig | None = None) -> None:
self._llm = llm
self._app_config = app_config
# ── 需求对话 ───────────────────────────────────────────────────────────
async def converse(
self,
*,
user_text: str,
history: list[dict[str, str]],
previous_snapshot: dict[str, Any] | None,
previous_revision: int,
clarification_round: int,
max_rounds: int,
) -> PlannerTurn | None:
"""一轮规划对话。返回 None 表示智能体不可用(调用方走最后防线)。"""
forced = clarification_round >= max_rounds
system = _CONVERSE_SYSTEM.format(
roles=", ".join(MEMBER_ROLES),
forced_note=(
"\n\n【重要】澄清轮次已达上限,本轮你必须判定需求就绪(action=ready):"
"自然地告诉用户你将按当前理解开始规划,把未能确认的点写进 assumptions。"
if forced
else ""
),
)
known = {k: v for k, v in (previous_snapshot or {}).items() if v not in (None, "", [])}
known.pop("revision", None)
history_text = "\n".join(
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:400]}"
for item in history[-10:]
)
user_prompt = (
f"当前已确认的需求快照(JSON):{json.dumps(known, ensure_ascii=False)}\n\n"
f"最近的对话:\n{history_text or '(无)'}\n\n"
f"用户最新消息:{user_text}"
)
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
if parsed is None:
return None
try:
turn = PlannerTurn.model_validate(parsed)
except ValidationError:
logger.warning("rc planner turn invalid: %s", str(parsed)[:200])
return None
if not (turn.reply or "").strip():
return None
if forced and turn.action != "ready":
turn.action = "ready"
if turn.action == "ready" and not turn.assumptions:
turn.assumptions = ["按对话中已确认的信息直接规划"]
return turn
def merge_requirement(
self,
turn: PlannerTurn,
*,
previous_snapshot: dict[str, Any] | None,
previous_revision: int,
) -> ReportRequirementSnapshot:
"""把智能体本轮带回的需求字段合并进既有快照(revision 语义同规则路由)。"""
known = {k: v for k, v in (previous_snapshot or {}).items() if k != "revision"}
# LLM 契约是「只填能确认的字段」;再过滤一次空值,避免空串覆盖已确认信息。
patch = {
k: v
for k, v in (turn.requirement or {}).items()
if k != "revision" and v not in (None, "", [])
}
revision = (
max(1, int(previous_revision) + 1)
if (previous_snapshot or patch)
else max(1, int(previous_revision) or 1)
)
if not previous_snapshot:
revision = 1
merged = _merge_snapshot(known, patch, revision=revision)
if turn.action == "ready":
merged = apply_default_assumptions(merged)[0]
return merged
# ── 工作流设计 ─────────────────────────────────────────────────────────
async def design_workflow(
self,
*,
requirement: ReportRequirementSnapshot,
history: list[dict[str, str]],
max_team_members: int = 8,
) -> tuple[StrategyRoleSet, str] | None:
"""带着完整对话上下文设计工作流;返回 (策略, 展示文案) 或 None。"""
fields = {k: v for k, v in requirement.model_dump().items() if v not in (None, "", [])}
fields.pop("revision", None)
history_text = "\n".join(
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:300]}"
for item in history[-12:]
)
system = _DESIGN_SYSTEM.format(
roles=", ".join(MEMBER_ROLES),
artifacts="\n".join(f" - {role}: {' / '.join(arts)}" for role, arts in ROLE_ARTIFACTS.items()),
gates=", ".join(QUALITY_GATES),
max_nodes=min(max_team_members, 8),
)
user_prompt = (
f"需求快照(JSON):{json.dumps(fields, ensure_ascii=False)}\n\n"
f"需求确认阶段的对话(你与用户):\n{history_text or '(无)'}\n\n请设计工作流。"
)
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
if parsed is None:
return None
try:
draft = StrategyDraft.model_validate(parsed)
except ValidationError:
logger.warning("rc planner strategy draft invalid: %s", str(parsed)[:200])
return None
strategy = _validate_strategy_dag(draft)
if strategy is None:
logger.warning("rc planner strategy failed vocabulary/DAG validation: %s", str(parsed)[:200])
return None
reply = (draft.reply or "").strip() or f"已按需求设计好协作工作流「{draft.title}」。"
return strategy, reply
def workflow_reply_fallback(self, strategy: StrategyRoleSet) -> str:
return (
f"我按确认的需求设计了「{strategy.title}」:{strategy.summary}\n"
"可以直接采用开始执行;想调整就告诉我(例如「加一条竞品对比线」)。"
)
# ── 提示词 ─────────────────────────────────────────────────────────────────
_CONVERSE_SYSTEM = """你是「报告协作工作台」的规划师智能体。用户想让你组织一个多智能体团队协作产出一份报告,你的任务是先通过自然对话把需求弄清楚,然后(由你自己)设计协作工作流。
你的对话风格:
- 像一位资深顾问:先回应用户刚说的话,再追问。回复里**不要**出现「收到您的报告需求」这类机械开场——用你自己的话自然表达。
- 每轮最多追问 2~3 个真正影响方案设计的问题:关注对象与范围、给谁看、必须覆盖的角度、篇幅与深度、时间范围、特殊约束。用户已说清的绝不重复问。
- 用户回复后先简短确认你的理解(一两句),再决定是否继续问。
- 信息足够时,自然地总结你对需求的把握并判定就绪(action=ready),告诉用户下一步你会为他们设计工作流。
可用的团队角色(供你构思,不必对用户罗列):{roles}{forced_note}
每轮严格输出一个 JSON 对象(不要输出 JSON 以外的文字):
{{
"reply": "给用户看的完整回复(自然对话,直接展示)",
"action": "clarify" 或 "ready",
"questions": ["追问的问题(与 reply 一致,供系统记录)"],
"impact": ["为什么需要问(与 questions 对应)"],
"assumptions": ["ready 时:你采用的默认假设"],
"requirement": {{"topic": "...", "objective": "...", "audience": "...", "required_angles": ["..."], "time_range": "...", "length_target": "...", "tone": "..."}}
}}
requirement 只填你从对话中能确认的字段(含此前轮次已确认的),其余省略。reply 用中文。"""
_DESIGN_SYSTEM = """你是「报告协作工作台」的规划师智能体。需求已经与你确认完毕,现在请设计多智能体协作工作流。
词表约束(结构化部分必须严格遵守):
- role_key 只能取:{roles}
- 各角色的 output_artifact_type 只能取:
{artifacts}
- quality_gate_ids 只能取:{gates}
结构要求:
- 2~{max_nodes} 个节点;至少 1 个 writer;正式报告建议加 reviewer。
- 研究线(researcher)按角度并行;research → verify → analyze → write → review 是常见主干,可按需求裁剪。
- depends_on 引用其他节点的 slot_id;禁止环;至少一个节点 depends_on 为空(起点)。
- slot_id 用简短英文(如 research-market / write-main)。mission 写清楚该节点做什么、产出什么。
严格输出 JSON(不要输出 JSON 以外的文字):
{{
"reply": "给用户看的介绍:说明你为什么这样设计(对应需求的哪些要求),一两句即可",
"strategy": "custom",
"title": "方案标题",
"summary": "一句话概述团队构成与链路",
"rationale": "设计理由",
"estimated_duration_seconds": 1500,
"estimated_cost_level": "low|medium|high",
"quality_gate_ids": ["cite_required"],
"nodes": [
{{"slot_id": "research-market", "role_key": "researcher", "display_name": "研究员", "angle": "市场份额", "mission": "...", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]}}
]
}}"""
# ── 工作流草稿校验(词表 + DAG) ──────────────────────────────────────────
def _validate_strategy_dag(draft: StrategyDraft) -> StrategyRoleSet | None:
nodes = draft.nodes
if not 2 <= len(nodes) <= 8:
return None
slot_ids = {node.slot_id for node in nodes}
if len(slot_ids) != len(nodes):
return None
if not any(node.role_key == "writer" for node in nodes):
return None
for node in nodes:
if node.role_key not in MEMBER_ROLES:
return None
if node.output_artifact_type not in ROLE_ARTIFACTS.get(node.role_key, ()):
return None
for dep in node.depends_on:
if dep not in slot_ids or dep == node.slot_id:
return None
indegree = {node.slot_id: len(node.depends_on) for node in nodes}
children: dict[str, list[str]] = {node.slot_id: [] for node in nodes}
for node in nodes:
for dep in node.depends_on:
children[dep].append(node.slot_id)
queue = [slot for slot, degree in indegree.items() if degree == 0]
seen = 0
while queue:
current = queue.pop()
seen += 1
for child in children[current]:
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)
if seen != len(nodes):
return None
gates = [gate for gate in draft.quality_gate_ids if gate in QUALITY_GATES] or ["cite_required"]
return StrategyRoleSet(
strategy="custom" if draft.strategy == "custom" else f"custom_{draft.strategy[:24]}",
title=draft.title[:80],
summary=draft.summary[:200],
rationale=draft.rationale[:300],
estimated_duration_seconds=max(300, min(int(draft.estimated_duration_seconds or 1500), 7200)),
estimated_cost_level=draft.estimated_cost_level if draft.estimated_cost_level in {"low", "medium", "high"} else "medium",
recommended=True,
slots=[
RoleSlot(
slot_id=node.slot_id,
demand=RoleDemand(
role_key=node.role_key,
display_name=node.display_name[:32] or node.role_key,
angle=(node.angle or None),
mission=node.mission[:400],
depends_on_roles=[],
output_artifact_type=node.output_artifact_type,
suggested_tools=[tool for tool in node.suggested_tools if tool][:4],
),
depends_on=list(node.depends_on),
)
for node in nodes
],
quality_gate_ids=gates,
)
@@ -1,18 +1,17 @@
"""Fulfill a durable plan_request: catalog → select → assemble → validate → persist. """Fulfill a durable plan_request: catalog → select → assemble → validate → persist.
Does not start a run. P1.2 起优先由规划师 LLM 生成定制策略,模板为回退。 Does not start a run. Rule-based until the BE-7 model adapter is wired.
""" """
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.planning.assembler import PlanAssembler from app.report_collaboration.planning.assembler import PlanAssembler
from app.report_collaboration.planning.catalog import project_agent_catalog from app.report_collaboration.planning.catalog import project_agent_catalog
from app.report_collaboration.planning.planner_agent import LlmFactory from app.report_collaboration.planning.controller import PlanController
from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
@@ -23,8 +22,6 @@ from deerflow.persistence.report_collaboration import (
ReportCollaborationValidationError, ReportCollaborationValidationError,
) )
logger = logging.getLogger(__name__)
_BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"}) _BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"})
@@ -54,15 +51,12 @@ class PlanProposalService:
*, *,
agent_store: Any | None = None, agent_store: Any | None = None,
config: ReportCollaborationConfig | None = None, config: ReportCollaborationConfig | None = None,
llm_factory: LlmFactory | None = None,
) -> None: ) -> None:
self._store = store self._store = store
self._agent_store = agent_store self._agent_store = agent_store
self._config = config self._config = config
self._controller = PlanController()
self._assembler = PlanAssembler() self._assembler = PlanAssembler()
self._llm_factory = llm_factory
self._llm: Any | None = None
self._llm_tried = False
def _cfg(self) -> ReportCollaborationConfig: def _cfg(self) -> ReportCollaborationConfig:
return self._config or get_app_config().report_collaboration return self._config or get_app_config().report_collaboration
@@ -116,29 +110,12 @@ class PlanProposalService:
requirement = requirement_from_session(record) requirement = requirement_from_session(record)
cfg = self._cfg() cfg = self._cfg()
catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store) catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store)
group_id = f"rpg_{uuid4().hex[:12]}" strategies = self._controller.propose(
requirement,
# P1-R:规划师智能体是唯一路径 —— 带着完整对话上下文设计工作流。 max_parallel_tasks=cfg.max_parallel_tasks,
# 模型不可用即无法继续(无模板兜底)。
from app.report_collaboration.planning.planner_agent import PlannerAgent
llm = self._planner_llm()
history = [
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
for item in await self._store.list_messages(session_id)
]
designed = await PlannerAgent(llm).design_workflow(
requirement=requirement,
history=history,
max_team_members=cfg.max_team_members, max_team_members=cfg.max_team_members,
) )
if designed is None: group_id = f"rpg_{uuid4().hex[:12]}"
raise ReportCollaborationValidationError(
"PLANNER_UNAVAILABLE", "规划师智能体未能生成有效工作流,请重试或换个说法再试"
)
strategies: list[Any] = [designed[0]]
planner_reply: str | None = designed[1]
assembled_list = [ assembled_list = [
self._assembler.assemble( self._assembler.assemble(
strategy, strategy,
@@ -163,44 +140,13 @@ class PlanProposalService:
for candidate in persisted: for candidate in persisted:
saved.append(await self._store.insert_plan(session_id, candidate.model_dump())) saved.append(await self._store.insert_plan(session_id, candidate.model_dump()))
await self._store.update_command_status(command_id, status="completed") await self._store.update_command_status(command_id, status="completed")
content = planner_reply or "工作流已生成,可开始执行。"
await self._store.create_message( await self._store.create_message(
session_id, session_id,
role="ai", role="ai",
content=content, content="已根据当前需求生成 3 个候选协作方案。请选择其中一个后再开始执行;选择方案不会自动开跑。",
idempotency_key=f"{idempotency_key}:plans", idempotency_key=f"{idempotency_key}:plans",
name=COORDINATOR_NAME, name=COORDINATOR_NAME,
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id, "plan_source": "planner_agent"}, metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id},
completed=True, completed=True,
) )
return saved return saved
def _planner_llm(self) -> Any:
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
if self._llm_factory is not None:
llm = self._llm_factory()
if llm is None:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
return llm
try:
if not (self._config or get_app_config().report_collaboration).planner_llm_enabled:
raise ReportCollaborationValidationError(
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
)
except ReportCollaborationValidationError:
raise
except Exception as exc:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
if not self._llm_tried:
self._llm_tried = True
try:
from app.report_collaboration.planning.planner_agent import build_planner_llm
self._llm = build_planner_llm()
except Exception as exc:
logger.warning("rc planner agent llm build failed: %s", exc)
self._llm = None
if self._llm is None:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
return self._llm
@@ -1,6 +1,6 @@
"""Pre-plan requirement resolution package.""" """Pre-plan requirement resolution package."""
from app.report_collaboration.requirement.intent_router import PrePlanDecision from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
from app.report_collaboration.requirement.resolver import RequirementResolver from app.report_collaboration.requirement.resolver import RequirementResolver
__all__ = ["PrePlanDecision", "RequirementResolver"] __all__ = ["PrePlanDecision", "RequirementResolver", "route_pre_plan_turn"]
@@ -1,9 +1,4 @@
"""Pre-plan 数据结构与快照合并工具。 """Pre-plan IntentRouterAgent: understand the user turn, never dispatch work."""
P1-R 之后需求确认完全由规划师智能体(``planning/planner_agent.py``)驱动;
本模块只保留被复用的数据结构与合并工具。原规则路由 ``route_pre_plan_turn``
已按设计 v2 移除 —— 模型不可用即报错,不再有规则兜底。
"""
from __future__ import annotations from __future__ import annotations
@@ -12,11 +7,12 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE, PLAN_AFFECTING_GAPS
from app.report_collaboration.requirement.extractor import extract_requirement_patch, wants_direct_start
class PrePlanDecision(BaseModel): class PrePlanDecision(BaseModel):
"""规划师智能体一轮决策。It never builds a graph or starts a run.""" """Structured output of the pre-plan router. It never builds a graph or starts a run."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -28,13 +24,11 @@ class PrePlanDecision(BaseModel):
used_defaults: bool = False used_defaults: bool = False
reason: str reason: str
confidence: float = Field(ge=0, le=1) confidence: float = Field(ge=0, le=1)
#: 规划师智能体产出的完整对话文案,直接落库展示。
reply: str | None = None
def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot: def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot:
base = dict(previous or {}) base = dict(previous or {})
base.update(patch or {}) base.update(patch)
topic = str(base.get("topic") or "").strip() topic = str(base.get("topic") or "").strip()
return ReportRequirementSnapshot( return ReportRequirementSnapshot(
topic=topic or "未指定主题", topic=topic or "未指定主题",
@@ -55,6 +49,21 @@ def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, r
) )
def _gaps(requirement: ReportRequirementSnapshot) -> list[tuple[str, str, str]]:
missing: list[tuple[str, str, str]] = []
if not requirement.topic or requirement.topic == "未指定主题":
missing.append(("topic", "这份报告要写什么主题?请用一两句话说明对象、时间和目的。", "没有主题无法选择方案"))
for field, question, impact in PLAN_AFFECTING_GAPS:
value = getattr(requirement, field, None)
if field == "required_angles":
empty = not value
else:
empty = not (value or "").strip()
if empty:
missing.append((field, question, impact))
return missing
def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]: def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]:
assumptions: list[str] = [] assumptions: list[str] = []
data = requirement.model_dump() data = requirement.model_dump()
@@ -73,3 +82,62 @@ def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[R
data["tone"] = DEFAULT_TONE data["tone"] = DEFAULT_TONE
assumptions.append(DEFAULT_TONE) assumptions.append(DEFAULT_TONE)
return ReportRequirementSnapshot.model_validate(data), assumptions return ReportRequirementSnapshot.model_validate(data), assumptions
def route_pre_plan_turn(
text: str,
*,
previous_snapshot: dict[str, Any] | None,
previous_revision: int,
clarification_round: int,
max_clarification_rounds: int,
) -> PrePlanDecision:
"""Rule-based pre-plan routing. Does not call tools or create plans."""
patch = extract_requirement_patch(text, previous=previous_snapshot)
revision = max(1, int(previous_revision) + 1) if previous_snapshot or patch else max(1, int(previous_revision) or 1)
if not previous_snapshot:
revision = 1
requirement = _merge_snapshot(previous_snapshot, patch, revision=revision)
skip = wants_direct_start(text)
hit_cap = clarification_round >= max_clarification_rounds
gaps = _gaps(requirement)
if requirement.topic == "未指定主题" and skip:
return PrePlanDecision(
action="clarify",
requirement=requirement,
questions=["这份报告要写什么主题?请用一两句话说明对象、时间和目的。"],
impact=["没有主题无法选择方案"],
reason="用户要求直接开始,但还没有主题",
confidence=0.9,
)
if skip or (hit_cap and gaps):
filled, assumptions = apply_default_assumptions(requirement)
return PrePlanDecision(
action="ready",
requirement=filled,
assumptions=assumptions,
used_defaults=True,
reason="用户要求按默认假设继续" if skip else "已达澄清上限,使用明确假设继续",
confidence=0.86 if skip else 0.7,
)
if gaps:
return PrePlanDecision(
action="clarify",
requirement=requirement,
questions=[item[1] for item in gaps],
impact=[item[2] for item in gaps],
reason="缺少真正影响方案选择的信息",
confidence=0.8,
)
return PrePlanDecision(
action="ready",
requirement=requirement,
reason="必要信息已齐,可以进入规划",
confidence=0.84,
used_defaults=False,
)
@@ -1,39 +1,18 @@
"""Requirement resolver: the planner agent is the only path. """Validate and persist requirement revisions. Never starts planning or a run."""
模型不可用即无法继续 —— 直接抛 ``PLANNER_UNAVAILABLE``,没有任何规则/
模板兜底(设计 v2:需求确认必须由真智能体完成)。
"""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from app.report_collaboration.contracts.clarifications import ClarificationRecord from app.report_collaboration.contracts.clarifications import ClarificationRecord
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
from app.report_collaboration.requirement.intent_router import PrePlanDecision from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
from deerflow.persistence.report_collaboration import ( from deerflow.persistence.report_collaboration import ReportCollaborationStore
ReportCollaborationStore,
ReportCollaborationValidationError,
)
from deerflow.persistence.report_collaboration.codec import iso_now from deerflow.persistence.report_collaboration.codec import iso_now
logger = logging.getLogger(__name__)
# 仅供类型注解;运行时惰性导入避免 requirement ↔ planning 循环依赖。
LlmFactory = Any
def _title_from_topic(topic: str, current: str | None) -> str | None:
if current and current != DEFAULT_TITLE:
return None
cleaned = (topic or "").strip()
if not cleaned or cleaned == "未指定主题":
return None
return cleaned[:40]
def _state_from_session(record: dict[str, Any]) -> dict[str, Any]: def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
raw = record.get("requirement_json") raw = record.get("requirement_json")
@@ -56,56 +35,58 @@ def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False} return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False}
def _dump_state(
*,
snapshot: ReportRequirementSnapshot,
clarifications: list[dict[str, Any]],
assumptions: list[str],
clarification_round: int,
ready_to_plan: bool,
) -> dict[str, Any]:
return {
"snapshot": snapshot.model_dump(),
"clarifications": clarifications,
"assumptions": assumptions,
"clarification_round": clarification_round,
"ready_to_plan": ready_to_plan,
}
def format_clarification_message(questions: list[str], *, allow_skip: bool = True) -> str:
numbered = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
suffix = "\n\n也可以直接回复「直接开始」,我会按默认假设记录需求。澄清完成不会自动生成方案。" if allow_skip else ""
return f"收到您的报告需求。为了给出最合适的协作方案,请先确认:\n\n{numbered}{suffix}"
def format_ready_message(decision: PrePlanDecision) -> str:
req = decision.requirement
angles = "、".join(req.required_angles) if req.required_angles else "(未指定)"
lines = [
f"已写入需求版本 v{req.revision}。",
f"主题:{req.topic}",
f"读者:{req.audience or '未指定'}",
f"角度:{angles}",
]
if decision.assumptions:
lines.append("默认假设:" + ";".join(decision.assumptions) + "。")
lines.append("澄清完成不等于已经生成候选方案,需要显式发起规划后才会出现方案卡。")
return "\n".join(lines)
def _title_from_topic(topic: str, current: str | None) -> str | None:
if current and current != DEFAULT_TITLE:
return None
cleaned = (topic or "").strip()
if not cleaned or cleaned == "未指定主题":
return None
return cleaned[:40]
class RequirementResolver: class RequirementResolver:
"""Writes requirement revisions and clarification records. Does not enqueue plans.""" """Writes requirement revisions and clarification records. Does not enqueue plans."""
def __init__( def __init__(self, store: ReportCollaborationStore) -> None:
self,
store: ReportCollaborationStore,
*,
planner_llm_factory: LlmFactory | None = None,
config: Any | None = None,
) -> None:
self._store = store self._store = store
self._planner_llm_factory = planner_llm_factory
self._config = config
self._default_llm: Any = None
self._default_llm_tried = False
def _planner_llm(self) -> Any:
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
if self._planner_llm_factory is not None:
llm = self._planner_llm_factory()
if llm is None:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
return llm
try:
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
cfg: Any = self._config
if cfg is None:
cfg = get_app_config().report_collaboration
if not ReportCollaborationConfig.model_validate(cfg).planner_llm_enabled:
raise ReportCollaborationValidationError(
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
)
except ReportCollaborationValidationError:
raise
except Exception as exc:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
if not self._default_llm_tried:
self._default_llm_tried = True
try:
from app.report_collaboration.planning.planner_agent import build_planner_llm
self._default_llm = build_planner_llm()
except Exception as exc:
logger.warning("rc planner agent llm build failed: %s", exc)
self._default_llm = None
if self._default_llm is None:
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
return self._default_llm
async def handle_user_message( async def handle_user_message(
self, self,
@@ -130,67 +111,16 @@ class RequirementResolver:
cfg_rounds = max_clarification_rounds cfg_rounds = max_clarification_rounds
if cfg_rounds is None: if cfg_rounds is None:
cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds) cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds)
decision = route_pre_plan_turn(
text = str(user_message.get("content") or "") str(user_message.get("content") or ""),
history = [ previous_snapshot=state["snapshot"],
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
for item in messages
if item.get("message_id") != user_message["message_id"]
]
decision = await self._agent_decision(
text,
history=history,
state=state,
previous_revision=int(record.get("requirement_revision") or 0), previous_revision=int(record.get("requirement_revision") or 0),
max_rounds=cfg_rounds, clarification_round=int(state["clarification_round"] or 0),
max_clarification_rounds=cfg_rounds,
) )
if decision is None:
# 模型调用失败/输出不合法 —— 无法继续,直接报错(无兜底)。
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体本轮未返回有效决策,请重试")
await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key) await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key)
return decision return decision
async def _agent_decision(
self,
text: str,
*,
history: list[dict[str, str]],
state: dict[str, Any],
previous_revision: int,
max_rounds: int,
) -> PrePlanDecision | None:
llm = self._planner_llm()
from app.report_collaboration.planning.planner_agent import PlannerAgent
agent = PlannerAgent(llm)
turn = await agent.converse(
user_text=text,
history=history,
previous_snapshot=state["snapshot"],
previous_revision=previous_revision,
clarification_round=int(state["clarification_round"] or 0),
max_rounds=max_rounds,
)
if turn is None:
return None
requirement = agent.merge_requirement(
turn,
previous_snapshot=state["snapshot"],
previous_revision=previous_revision,
)
return PrePlanDecision(
action="ready" if turn.action == "ready" else "clarify",
requirement=requirement,
questions=[item for item in turn.questions if item and item.strip()][:3],
impact=[item for item in turn.impact if item][:3],
assumptions=[item for item in turn.assumptions if item],
used_defaults=bool(turn.assumptions),
reason="planner_agent",
confidence=0.9,
reply=turn.reply.strip(),
)
async def _apply( async def _apply(
self, self,
session_id: str, session_id: str,
@@ -216,7 +146,7 @@ class RequirementResolver:
created_at=iso_now(), created_at=iso_now(),
) )
clarifications.append(clarification.model_dump()) clarifications.append(clarification.model_dump())
content = decision.reply or "" content = format_clarification_message(decision.questions)
metadata = { metadata = {
"preplan_for": user_message["message_id"], "preplan_for": user_message["message_id"],
"clarification": clarification.model_dump(), "clarification": clarification.model_dump(),
@@ -225,22 +155,17 @@ class RequirementResolver:
else: else:
for item in clarifications: for item in clarifications:
item["resolved"] = True item["resolved"] = True
content = decision.reply or "" content = format_ready_message(decision)
metadata = { metadata = {"preplan_for": user_message["message_id"], "ready_to_plan": True}
"preplan_for": user_message["message_id"],
"ready_to_plan": True,
# 结构化需求快照:前端据此渲染「需求摘要卡」(计划模式 UI)。
"requirement": decision.requirement.model_dump(),
}
ready = True ready = True
payload = { payload = _dump_state(
"snapshot": decision.requirement.model_dump(), snapshot=decision.requirement,
"clarifications": clarifications, clarifications=clarifications,
"assumptions": list(decision.assumptions), assumptions=list(decision.assumptions),
"clarification_round": round_count, clarification_round=round_count,
"ready_to_plan": ready, ready_to_plan=ready,
} )
await self._store.save_requirement_state( await self._store.save_requirement_state(
session_id, session_id,
requirement_json=payload, requirement_json=payload,
+2 -6
View File
@@ -456,10 +456,7 @@ auth_login:
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。 # 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database: database:
# 本地业务数据使用远程 MySQL;连接地址由 backend/.env.local 提供。 backend: sqlite
# LangGraph 对话检查点仍由下方 checkpointer 保存到本机 SQLite。
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
sqlite_dir: .deer-flow/data sqlite_dir: .deer-flow/data
# 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss): # 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss):
# backend: gauss # backend: gauss
@@ -608,7 +605,7 @@ llmwiki:
enabled: true enabled: true
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。 # 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
frontend_base_url: "http://127.0.0.1:5174" frontend_base_url: "http://127.0.0.1:5174"
auto_sync: false auto_sync: true
sync_interval_seconds: 30 sync_interval_seconds: 30
database_batch_size: 50 database_batch_size: 50
embedding: embedding:
@@ -751,7 +748,6 @@ workflows:
report_collaboration: report_collaboration:
enabled: true enabled: true
worker_enabled: true worker_enabled: true
planner_llm_enabled: true
planner_model: null planner_model: null
coordinator_model: null coordinator_model: null
research_model: null research_model: null
@@ -1,5 +0,0 @@
*.log
run_summary*.json
runs/**
!runs/
!runs/placeholder_audit.md
@@ -0,0 +1,48 @@
# asyncmy 离线安装说明
本目录用于给无法联网的后端服务器补装 MySQL 异步驱动 `asyncmy==0.2.11`。
项目的 `pyproject.toml` 已经声明该依赖;服务器启动报 `MySQL async driver is not installed`,说明实际运行后端的 Python 虚拟环境尚未安装它。
## 文件选择
- `asyncmy-0.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl`:Ubuntu、CentOS、RHEL、Debian 等常见 glibc Linux(x86_64)。
- `asyncmy-0.2.11-cp312-cp312-musllinux_1_2_x86_64.whl`:Alpine Linux(x86_64)。
这两个 wheel 都只适用于 **CPython 3.12、x86_64**。在服务器执行 `python -V` 和 `uname -m` 确认:应分别显示 Python 3.12 和 `x86_64`/`amd64`。
## 安装步骤
1. 将整个 `offline-wheels` 目录复制到后端目录(与 `pyproject.toml` 同级)。
2. 进入后端实际运行的虚拟环境;若项目使用默认虚拟环境:
```bash
cd /path/to/backend
source .venv/bin/activate
```
3. 普通 Linux(Ubuntu/CentOS/RHEL/Debian)执行:
```bash
python -m pip install --no-index ./offline-wheels/asyncmy/asyncmy-0.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
```
Alpine Linux 则执行:
```bash
python -m pip install --no-index ./offline-wheels/asyncmy/asyncmy-0.2.11-cp312-cp312-musllinux_1_2_x86_64.whl
```
4. 验证:
```bash
python -c "import asyncmy; print(asyncmy.__version__)"
```
5. 重启后端服务。
## 注意事项
- 必须使用**启动后端服务的同一个 Python/虚拟环境**安装,系统 Python 安装成功并不代表服务能找到该包。
- 不要使用 `--force-reinstall`;安装缺失的 `asyncmy` 即可。
- 若服务器是 Python 3.13、ARM/aarch64,或不是上述 Linux 类型,请不要安装本目录 wheel,需要下载对应平台版本。
@@ -18,10 +18,6 @@ class ReportCollaborationConfig(BaseModel):
default=False, default=False,
description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.", description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.",
) )
planner_llm_enabled: bool = Field(
default=False,
description="P1: LLM planner conversation (clarify + custom strategy). Off keeps the deterministic rule router and template plans.",
)
planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning") planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning")
coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator") coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator")
research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members") research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members")
@@ -1,21 +0,0 @@
"""Merge assistant-wiki metadata and report-collaboration heads.
Revision ID: 20260909_01
Revises: 20260903_01, 20260907_01
Create Date: 2026-09-09
"""
from __future__ import annotations
revision = "20260909_01"
down_revision = ("20260903_01", "20260907_01")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,13 +0,0 @@
"""Persistence layer for custom skills (per-user ownership + publish state)."""
from deerflow.persistence.skills.base import SkillStore
from deerflow.persistence.skills.memory import MemorySkillStore
from deerflow.persistence.skills.sql import SkillRepository
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository", "make_skill_store"]
def make_skill_store(session_factory):
if session_factory is None:
return MemorySkillStore()
return SkillRepository(session_factory)
@@ -1,107 +0,0 @@
"""Abstract persistence API for custom skill ownership + publish state."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class SkillStore(ABC):
@abstractmethod
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
"""List all skill records visible to ``user_id``.
Visible = owner, OR published by anyone, OR built-in (owner_user_id IS NULL).
"""
raise NotImplementedError
@abstractmethod
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def get_any(self, name: str) -> dict[str, Any] | None:
"""Lookup by name regardless of ownership/visibility (admin / cascade use)."""
raise NotImplementedError
@abstractmethod
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a skill record. Caller is responsible for global-name uniqueness check."""
raise NotImplementedError
@abstractmethod
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
"""Idempotently insert a legacy on-disk skill into the DB. Updates the row if it
already exists with ``owner_user_id IS NULL`` (so a re-run can migrate ownership).
"""
raise NotImplementedError
@abstractmethod
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Owner-only update. Returns None if the caller doesn't own the skill."""
raise NotImplementedError
@abstractmethod
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Admin override — caller is responsible for auth check.
Unlike ``update_builtin`` for agents, this accepts ANY skill including
user-owned ones, since the spec lets admin take down others' skills.
"""
raise NotImplementedError
@abstractmethod
async def delete(self, name: str, user_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def delete_admin(self, name: str) -> bool:
"""Admin-side delete; bypasses ownership check (caller authorizes)."""
raise NotImplementedError
@abstractmethod
async def count_owned(self, user_id: str) -> int:
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
raise NotImplementedError
@abstractmethod
async def list_all(self) -> list[dict[str, Any]]:
"""Return every skill record regardless of ownership/visibility.
Caller is responsible for authorization (admin only).
"""
raise NotImplementedError
@abstractmethod
async def list_published_names(self) -> set[str]:
"""Return the set of skill names with ``published = True`` (any owner).
Batch alternative to calling ``get_any`` per skill — the curator uses
this to filter out published skills in a single query instead of one
DB round-trip per candidate skill.
"""
raise NotImplementedError
@abstractmethod
async def list_always_on_names(self) -> set[str]:
"""Return the set of skill names marked 常驻(免压缩) (``always_on = True``).
Batch query used by the prompt builder to decide which skills bypass
skill compression — one query instead of a per-skill round-trip.
"""
raise NotImplementedError
@abstractmethod
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
"""Return the skill names in ``user_id``'s "我的技能" collection.
These are skills the user does not own but that landed in their list —
currently only via their 岗位 (position) sync (``origin='position'``).
Owned skills are NOT included here (the caller detects those via
``owner_user_id``).
"""
raise NotImplementedError
@@ -1,173 +0,0 @@
"""In-memory ``SkillStore`` implementation for tests."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.skills.base import SkillStore
def _now() -> str:
return datetime.now(UTC).isoformat()
def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
return copy.deepcopy(record) if record is not None else None
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
out = dict(record)
out["builtin"] = out.get("owner_user_id") is None
out.setdefault("always_on", False)
return out
class MemorySkillStore(SkillStore):
def __init__(self) -> None:
self._skills: dict[str, dict[str, Any]] = {}
# user_id -> {skill_name}
self._favorites: dict[str, set[str]] = {}
@staticmethod
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
owner = record.get("owner_user_id")
if owner is None:
return True
if owner == user_id:
return True
return bool(record.get("published"))
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
return [
_with_derived(_copy(record)) # type: ignore[arg-type]
for record in self._skills.values()
if self._is_visible(record, user_id)
]
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or not self._is_visible(record, user_id):
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def get_any(self, name: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None:
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
name = data["name"]
if name in self._skills:
raise ValueError(f"Skill '{name}' already exists")
record = {
"name": name,
"owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", False)),
"detail": data.get("detail"),
"name_zh": data.get("name_zh"),
"always_on": bool(data.get("always_on", False)),
"square_id": str(data.get("square_id") or ""),
"created_at": _now(),
"updated_at": _now(),
}
self._skills[name] = record
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
name = data["name"]
record = self._skills.get(name)
if record is None:
record = {
"name": name,
"owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", True)),
"detail": data.get("detail"),
"name_zh": data.get("name_zh"),
"created_at": _now(),
"updated_at": _now(),
}
self._skills[name] = record
elif record.get("owner_user_id") is None and data.get("owner_user_id") is not None:
record["owner_user_id"] = data["owner_user_id"]
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return None
if "published" in data:
record["published"] = bool(data["published"])
if "detail" in data:
record["detail"] = data["detail"]
if "name_zh" in data:
record["name_zh"] = data["name_zh"]
if "always_on" in data:
record["always_on"] = bool(data["always_on"])
if "square_id" in data:
record["square_id"] = str(data["square_id"] or "")
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None:
return None
if "published" in data:
record["published"] = bool(data["published"])
if "owner_user_id" in data:
record["owner_user_id"] = data["owner_user_id"]
if "detail" in data:
record["detail"] = data["detail"]
if "name_zh" in data:
record["name_zh"] = data["name_zh"]
if "always_on" in data:
record["always_on"] = bool(data["always_on"])
if "square_id" in data:
record["square_id"] = str(data["square_id"] or "")
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def delete(self, name: str, user_id: str) -> bool:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return False
del self._skills[name]
return True
async def delete_admin(self, name: str) -> bool:
if name not in self._skills:
return False
del self._skills[name]
return True
async def count_owned(self, user_id: str) -> int:
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
async def list_all(self) -> list[dict[str, Any]]:
return [
_with_derived(_copy(record)) # type: ignore[arg-type]
for record in self._skills.values()
]
async def list_published_names(self) -> set[str]:
return {name for name, record in self._skills.items() if record.get("published")}
async def list_always_on_names(self) -> set[str]:
return {name for name, record in self._skills.items() if record.get("always_on")}
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
return set(self._favorites.get(user_id, set()))
# test helper — not part of the abstract API
def add_favorite(self, user_id: str, skill_name: str) -> None:
self._favorites.setdefault(user_id, set()).add(skill_name)
@@ -1,85 +0,0 @@
"""ORM model for custom skill ownership + publish state.
Note: skill content lives on disk under ``skills/custom/{name}/``; this table
only stores the metadata (owner, published flag, timestamps). The skill
``name`` doubles as the primary key because the filesystem path is already
keyed by name, and we require global name uniqueness across all owners.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class SkillRow(Base):
__tablename__ = "skills"
# Global unique name — must match the on-disk folder name under skills/custom/.
# Hyphen-case or snake_case allowed; see deerflow.skills.validation.
name: Mapped[str] = mapped_column(String(64), primary_key=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# Human-authored long-form detail/introduction, editable by the skill owner
# or an admin. Distinct from the skill ``description`` parsed from SKILL.md;
# kept in the DB so admins can annotate built-in skills without rewriting
# the shared on-disk SKILL.md file.
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
# User-configured or LLM-generated Chinese display name. Kept in the DB (not
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
# shared source file is never rewritten.
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
# 常驻(免压缩):开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,而非
# 降级为索引 + search_skills 检索。存 DB(而非 extensions_config.json),内置技能
# 也能标记,且避免在某些部署里回写源码目录下的配置文件导致报错。
always_on: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", index=True)
# Publish square (发布广场) this skill is filed under once published. Pure
# organizational category, no access control. Empty string = unassigned →
# the listing layer resolves it to the default square. One square per skill.
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", server_default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class SkillFavoriteRow(Base):
"""A user's "我的技能" membership for a skill they don't own.
Mirrors :class:`deerflow.persistence.agents.model.AgentFavoriteRow`: lets a
skill land in a user's "我的" list without the user owning it. Skills are
keyed by ``name`` (the on-disk folder / DB primary key), so this row
references ``skill_name`` rather than an opaque id. One row per
(user, skill); the unique constraint makes the grant idempotent.
"""
__tablename__ = "skill_favorites"
__table_args__ = (UniqueConstraint("user_id", "skill_name", name="uq_skill_favorites_user_skill"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
skill_name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# ``"user"`` = the user added it themselves (reserved for a future favorite
# button); ``"position"`` = auto-granted by the user's 岗位 (position) via
# the subscription sync engine. Sync only ever adds/removes ``"position"``
# rows, so a user's own picks are never clobbered.
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
# The position that granted this favorite (only set when ``origin='position'``).
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class SkillDisplayAdapterRow(Base):
__tablename__ = "skill_display_adapters"
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Store JSON as text for maximum MySQL compatibility. Some deployments still
# run MySQL variants where native JSON columns or unquoted names such as
# ``display`` are not accepted consistently.
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -1,190 +0,0 @@
"""SQLAlchemy-backed custom skill repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.skills.base import SkillStore
from deerflow.persistence.skills.model import SkillFavoriteRow, SkillRow
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
data = row.to_dict()
data["builtin"] = data.get("owner_user_id") is None
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class SkillRepository(SkillStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _visible_filter(user_id: str):
return or_(
SkillRow.owner_user_id.is_(None),
SkillRow.owner_user_id == user_id,
SkillRow.published.is_(True),
)
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(SkillRow).where(self._visible_filter(user_id)).order_by(SkillRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
stmt = select(SkillRow).where(SkillRow.name == name, self._visible_filter(user_id))
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _row_to_dict(row) if row is not None else None
async def get_any(self, name: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
return _row_to_dict(row) if row is not None else None
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
stmt = select(SkillRow).where(SkillRow.name == name, SkillRow.owner_user_id == user_id)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _row_to_dict(row) if row is not None else None
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = SkillRow(
name=data["name"],
owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", False)),
detail=data.get("detail"),
name_zh=data.get("name_zh"),
always_on=bool(data.get("always_on", False)),
square_id=str(data.get("square_id") or ""),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(SkillRow, data["name"])
if row is None:
now = datetime.now(UTC)
row = SkillRow(
name=data["name"],
owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", True)),
detail=data.get("detail"),
name_zh=data.get("name_zh"),
created_at=now,
updated_at=now,
)
session.add(row)
elif row.owner_user_id is None and data.get("owner_user_id") is not None:
# Only patch ownership if it was previously null — never overwrite
# an existing owner record on a re-migration.
row.owner_user_id = data["owner_user_id"]
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None or row.owner_user_id != user_id:
return None
if "published" in data:
row.published = bool(data["published"])
if "detail" in data:
row.detail = data["detail"]
if "name_zh" in data:
row.name_zh = data["name_zh"]
if "always_on" in data:
row.always_on = bool(data["always_on"])
if "square_id" in data:
row.square_id = str(data["square_id"] or "")
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None:
return None
if "published" in data:
row.published = bool(data["published"])
if "owner_user_id" in data:
row.owner_user_id = data["owner_user_id"]
if "detail" in data:
row.detail = data["detail"]
if "name_zh" in data:
row.name_zh = data["name_zh"]
if "always_on" in data:
row.always_on = bool(data["always_on"])
if "square_id" in data:
row.square_id = str(data["square_id"] or "")
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, name: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None or row.owner_user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
async def delete_admin(self, name: str) -> bool:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def count_owned(self, user_id: str) -> int:
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_published_names(self) -> set[str]:
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
async with self._sf() as session:
result = await session.execute(stmt)
return {row[0] for row in result.all()}
async def list_always_on_names(self) -> set[str]:
stmt = select(SkillRow.name).where(SkillRow.always_on.is_(True))
async with self._sf() as session:
result = await session.execute(stmt)
return {row[0] for row in result.all()}
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
stmt = select(SkillFavoriteRow.skill_name).where(SkillFavoriteRow.user_id == user_id)
async with self._sf() as session:
result = await session.execute(stmt)
return {row[0] for row in result.all()}
@@ -1,17 +0,0 @@
from __future__ import annotations
from .installer import SkillAlreadyExistsError, SkillSecurityScanError
from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage
from .types import Skill
from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmatter
__all__ = [
"Skill",
"ALLOWED_FRONTMATTER_PROPERTIES",
"_validate_skill_frontmatter",
"SkillAlreadyExistsError",
"SkillSecurityScanError",
"SkillStorage",
"LocalSkillStorage",
"get_or_new_skill_storage",
]
@@ -1,255 +0,0 @@
"""Scan skill text for URL / IP addresses and apply safe batch replacements.
Pure-logic layer for the 技能地址(URL/IP)批量替换 feature. This module knows
**only about text** — it never walks the filesystem. The HTTP router
(``app/gateway/routers/skill_addresses.py``) walks ``skills/{public,custom}``,
reads each ``*.md`` / ``*.py`` file, and feeds the text here. Keeping the
extraction / replacement logic here (no ``app.*`` import) makes it unit-testable
in isolation and respects the harness/app boundary.
Two address kinds are supported (the feature scope):
* ``"url"`` — full ``http(s)://…`` URLs.
* ``"ip"`` — dotted-quad IPv4, optionally ``:port``. Each octet is range
checked (0–255) so version-number noise like ``1.2.3.400`` is not matched.
The single most important invariant is **substring-safe replacement**: we never
``str.replace`` raw file text (that would rewrite ``1.2.3.4`` *inside*
``11.2.3.40``). Instead we re-detect the exact token spans and only rewrite a
span whose text is *exactly* one of the requested ``from`` values.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
__all__ = [
"VALID_KINDS",
"AddressSpan",
"Occurrence",
"normalize_kinds",
"find_spans",
"scan_occurrences",
"count_matches",
"apply_replacements",
"is_valid_replacement",
"infer_kind",
]
# Supported address kinds (the configured feature scope: URL + IP).
VALID_KINDS: tuple[str, ...] = ("url", "ip")
# A URL body stops at whitespace, quotes, brackets, angle brackets, backticks
# and backslashes — everything that commonly *delimits* a URL in md / py. The
# trailing punctuation that often clings to the end (a sentence period, a
# closing paren already excluded, a Markdown comma) is stripped afterwards.
_URL_RE = re.compile(r"https?://[^\s'\"`<>)\]}\\]+", re.IGNORECASE)
# Dotted-quad IPv4 (optionally :port). Guarded so it is not glued to a longer
# number run (``(?<![\d.])`` / ``(?![\d.])``); octets are range-checked below.
_IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?(?![\d.])")
# Punctuation (ASCII + CJK) that should not be considered part of a URL token
# when it appears at the very end of the match.
_URL_TRAILING = set(".,;:!?'\")]}>`" + ")。,;:!?】》、…")
@dataclass(frozen=True)
class AddressSpan:
"""An address token located at an exact ``[start, end)`` offset in text."""
kind: str
value: str
start: int
end: int
@dataclass(frozen=True)
class Occurrence:
"""A located address enriched with 1-based line/column + the source line."""
kind: str
value: str
line: int
col: int
snippet: str
def normalize_kinds(kinds: object) -> tuple[str, ...]:
"""Coerce arbitrary input into an ordered, de-duped subset of ``VALID_KINDS``.
``None`` / empty means "all kinds". Unknown kinds are dropped.
"""
if kinds is None:
return VALID_KINDS
if isinstance(kinds, str):
raw = [p.strip() for p in kinds.split(",")]
else:
try:
raw = [str(p).strip() for p in kinds] # type: ignore[assignment]
except TypeError:
return VALID_KINDS
seen: list[str] = []
for k in raw:
kl = k.lower()
if kl in VALID_KINDS and kl not in seen:
seen.append(kl)
return tuple(seen) if seen else VALID_KINDS
def _valid_ipv4(quad: str) -> bool:
parts = quad.split(".")
if len(parts) != 4:
return False
for p in parts:
if not p.isdigit() or len(p) > 3:
return False
if int(p) > 255:
return False
return True
def _valid_port(port: str | None) -> bool:
if port is None:
return True
return port.isdigit() and 0 <= int(port) <= 65535
def _url_spans(text: str) -> list[AddressSpan]:
spans: list[AddressSpan] = []
for m in _URL_RE.finditer(text):
start, end = m.start(), m.end()
value = m.group(0)
# Strip clinging trailing punctuation.
while value and value[-1] in _URL_TRAILING:
value = value[:-1]
end -= 1
if value:
spans.append(AddressSpan(kind="url", value=value, start=start, end=end))
return spans
def _ip_spans(text: str, exclude: list[AddressSpan]) -> list[AddressSpan]:
spans: list[AddressSpan] = []
for m in _IP_RE.finditer(text):
quad = m.group(1)
port = m.group(2)
if not _valid_ipv4(quad) or not _valid_port(port):
continue
start, end = m.start(), m.end()
# Skip an IP that lives *inside* a URL (e.g. http://1.2.3.4:8080/x) —
# replacing just the IP there would corrupt the URL.
if any(s.start <= start < s.end for s in exclude):
continue
spans.append(AddressSpan(kind="ip", value=m.group(0), start=start, end=end))
return spans
def find_spans(text: str, kinds: object = None) -> list[AddressSpan]:
"""Return every address token span in *text*, ordered by position.
URL regions are always computed first so that IPs embedded inside a URL are
not separately surfaced, even when only ``"ip"`` is requested.
"""
wanted = normalize_kinds(kinds)
url_spans = _url_spans(text)
result: list[AddressSpan] = []
if "url" in wanted:
result.extend(url_spans)
if "ip" in wanted:
result.extend(_ip_spans(text, exclude=url_spans))
result.sort(key=lambda s: s.start)
return result
def scan_occurrences(text: str, kinds: object = None, *, snippet_max: int = 240) -> list[Occurrence]:
"""Locate addresses with 1-based line/column and a trimmed source line."""
spans = find_spans(text, kinds)
if not spans:
return []
# Precompute line-start offsets for O(log n) line lookup.
line_starts = [0]
for i, ch in enumerate(text):
if ch == "\n":
line_starts.append(i + 1)
occ: list[Occurrence] = []
import bisect
for s in spans:
line_idx = bisect.bisect_right(line_starts, s.start) - 1
line_no = line_idx + 1
col = s.start - line_starts[line_idx] + 1
line_end = line_starts[line_idx + 1] - 1 if line_idx + 1 < len(line_starts) else len(text)
raw_line = text[line_starts[line_idx]:line_end].rstrip("\r")
snippet = raw_line.strip()
if len(snippet) > snippet_max:
snippet = snippet[: snippet_max - 1] + "…"
occ.append(Occurrence(kind=s.kind, value=s.value, line=line_no, col=col, snippet=snippet))
return occ
def count_matches(text: str, mapping: dict[str, str], kinds: object = None) -> int:
"""How many address spans in *text* would be rewritten by *mapping*."""
if not mapping:
return 0
return sum(1 for s in find_spans(text, kinds) if s.value in mapping)
def apply_replacements(text: str, mapping: dict[str, str], kinds: object = None) -> tuple[str, int]:
"""Substring-safe batch replace.
Only spans whose text is *exactly* a key of *mapping* are rewritten, applied
right-to-left so earlier offsets stay valid. Returns ``(new_text, count)``.
A no-op mapping (or no hits) returns the original text and ``0``.
"""
if not mapping:
return text, 0
spans = [s for s in find_spans(text, kinds) if s.value in mapping]
if not spans:
return text, 0
out = text
for s in sorted(spans, key=lambda s: s.start, reverse=True):
out = out[: s.start] + mapping[s.value] + out[s.end:]
return out, len(spans)
def infer_kind(value: str) -> str | None:
"""Return the address kind of *value* if it is a single, whole address.
Used to classify a user-supplied ``from`` value (so the matching ``to`` can
be validated against the right rules). Returns ``None`` when *value* is not
exactly one URL or IP token.
"""
if not value:
return None
spans = find_spans(value, VALID_KINDS)
if len(spans) == 1 and spans[0].value == value:
return spans[0].kind
return None
def is_valid_replacement(kind: str, value: str) -> bool:
"""Validate that a user-supplied replacement is well-formed for its kind.
Keeps an admin from turning a URL into garbage (e.g. dropping the scheme) or
an IP into a non-IP. Empty replacements are rejected.
"""
if not value or value.strip() != value or any(c.isspace() for c in value):
return False
if kind == "url":
return bool(re.fullmatch(r"https?://[^\s'\"`<>]+", value, re.IGNORECASE))
if kind == "ip":
m = _IP_RE.fullmatch(value)
if not m:
return False
return _valid_ipv4(m.group(1)) and _valid_port(m.group(2))
return False
@@ -1,206 +0,0 @@
"""Cascade handling when a published skill is removed from circulation.
Called from the Gateway skills router whenever a published skill is
unpublished, deleted, or admin-taken-down. Walks every agent on disk that
referenced the skill, strips the skill from its ``config.yaml`` ``skills``
list, and emits an in-app notification to that agent's owner.
The harness/app firewall means we cannot import the FastAPI app side; the
``AgentStore`` instance must be passed in by the caller (the router gets
it from ``app.gateway.deps.get_agent_store``).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Literal
import yaml
from deerflow.config.agents_config import load_agent_config
from deerflow.config.paths import get_paths
from deerflow.persistence.notifications import notify
logger = logging.getLogger(__name__)
CascadeOperation = Literal["unpublish", "delete", "admin_takedown"]
def _list_agent_dirs() -> list[Path]:
agents_dir = get_paths().agents_dir
if not agents_dir.exists():
return []
return [entry for entry in sorted(agents_dir.iterdir()) if entry.is_dir()]
def _remove_skill_from_yaml(config_file: Path, skill_name: str) -> bool:
"""Rewrite ``config.yaml`` removing ``skill_name`` from its ``skills`` list.
Returns True if the file was modified, False otherwise.
"""
if not config_file.exists():
return False
try:
with config_file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError:
logger.warning("Cascade: failed to parse %s, skipping", config_file)
return False
skills = data.get("skills")
if not isinstance(skills, list) or skill_name not in skills:
return False
data["skills"] = [s for s in skills if s != skill_name]
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
return True
_OP_LABELS: dict[CascadeOperation, str] = {
"unpublish": "取消发布",
"delete": "删除",
"admin_takedown": "管理员强制下架",
}
async def cascade_skill_unpublished_or_deleted(
skill_name: str,
*,
actor_user_id: str,
operation: CascadeOperation,
skill_owner_user_id: str | None,
agent_store: Any,
) -> dict[str, Any]:
"""Detach ``skill_name`` from every referencing agent and notify owners.
Self-references (agent.user_id == actor_user_id) are still detached but
the owner gets no in-app notification — the user just did the action,
they know.
On ``admin_takedown`` we additionally notify ``skill_owner_user_id``.
Returns counters for testability: ``{checked, modified, notified}``.
"""
counters = {"checked": 0, "modified": 0, "notified": 0}
label = _OP_LABELS.get(operation, operation)
for agent_dir in _list_agent_dirs():
config_file = agent_dir / "config.yaml"
if not config_file.exists():
continue
counters["checked"] += 1
# Quick read to decide whether we need to mutate at all.
try:
agent_cfg = load_agent_config(agent_dir.name)
except Exception:
logger.debug("Cascade: failed to load %s, skipping", agent_dir.name, exc_info=True)
continue
if agent_cfg is None or not agent_cfg.skills or skill_name not in agent_cfg.skills:
continue
modified = _remove_skill_from_yaml(config_file, skill_name)
if not modified:
continue
counters["modified"] += 1
logger.info(
"Cascade: removed skill '%s' from agent '%s/config.yaml' (op=%s)",
skill_name,
agent_dir.name,
operation,
)
# Look up agent owner from DB to decide where to send the notification.
record = await agent_store.get_any(agent_dir.name)
agent_owner = record.get("user_id") if record else None
if agent_owner and agent_owner != actor_user_id:
agent_display = (record.get("name") if record else None) or agent_dir.name
await notify(
agent_owner,
type="agent_skill_removed",
title=f"专家 “{agent_display}” 的一个技能已失效",
body=(
f"技能 “{skill_name}” 被 {label},已从该专家的技能列表中自动移除。"
),
payload={
"skill_name": skill_name,
"agent_id": agent_dir.name,
"agent_name": agent_display,
"operation": operation,
"actor_user_id": actor_user_id,
},
)
counters["notified"] += 1
if operation == "admin_takedown" and skill_owner_user_id and skill_owner_user_id != actor_user_id:
await notify(
skill_owner_user_id,
type="skill_taken_down",
title=f"你的技能 “{skill_name}” 已被管理员下架",
body="管理员对该技能执行了强制下架。如有疑问请联系管理员。",
payload={
"skill_name": skill_name,
"operation": operation,
"actor_user_id": actor_user_id,
},
)
counters["notified"] += 1
return counters
def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
"""Strip skill references that no longer exist on disk from every agent.
Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
contain only names that appear in ``available_skill_names``. Used by the
admin "agent-skills sync" endpoint to repair historical drift — skills
deleted before the cascade existed, deleted via a path that skipped the
cascade, or removed from the filesystem manually.
Synchronous because it only touches local files and the admin endpoint
runs it in a worker thread.
Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
"""
counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
for agent_dir in _list_agent_dirs():
config_file = agent_dir / "config.yaml"
if not config_file.exists():
continue
counters["checked"] += 1
try:
with config_file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError:
logger.warning("Sync: failed to parse %s, skipping", config_file)
continue
skills = data.get("skills")
if not isinstance(skills, list) or not skills:
continue
kept: list[str] = []
removed: list[str] = []
for name in skills:
if isinstance(name, str) and name in available_skill_names:
kept.append(name)
else:
removed.append(name if isinstance(name, str) else str(name))
if not removed:
continue
data["skills"] = kept
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
counters["modified"] += 1
counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
return counters
File diff suppressed because it is too large. Load diff
@@ -1,111 +0,0 @@
"""In-memory ring buffer holding the most recent curator run's execution log.
Now sharded per-user — each ``user_id`` gets its own deque so users only see
their own curator activity from the Web UI.
The buffer is intentionally process-local and non-persistent. The Gateway
exposes it over ``GET /api/curator/logs`` with the active user id resolved
from the request context.
All public functions swallow internal exceptions so a logging failure can
never break the curator run itself.
"""
from __future__ import annotations
import logging
import threading
from collections import deque
from datetime import UTC, datetime
from typing import Any
logger = logging.getLogger(__name__)
_MAX_RECORDS = 200
# Reserved bucket key for "no user context" callers (legacy code paths or
# tests). Real curator runs always supply a concrete user_id.
_DEFAULT_BUCKET = "__default__"
_lock = threading.Lock()
_buffers: dict[str, deque[dict[str, Any]]] = {}
def _bucket_key(user_id: str | None) -> str:
return user_id or _DEFAULT_BUCKET
def _get_or_create(user_id: str | None) -> deque[dict[str, Any]]:
key = _bucket_key(user_id)
buffer = _buffers.get(key)
if buffer is None:
buffer = deque(maxlen=_MAX_RECORDS)
_buffers[key] = buffer
return buffer
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def start_new_run(user_id: str | None = None) -> None:
"""Clear the user's buffer and emit a ``run_started`` record."""
try:
with _lock:
buffer = _get_or_create(user_id)
buffer.clear()
buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
except Exception:
logger.debug("curator_log.start_new_run failed", exc_info=True)
def record(level: str, message: str, *, user_id: str | None = None, **fields: Any) -> None:
"""Append a single log record to the user's buffer. Never raises."""
try:
entry: dict[str, Any] = {
"ts": _now_iso(),
"level": str(level or "info"),
"message": str(message),
}
if fields:
entry.update({k: v for k, v in fields.items() if k not in entry})
with _lock:
buffer = _get_or_create(user_id)
buffer.append(entry)
except Exception:
logger.debug("curator_log.record failed", exc_info=True)
def get_recent(user_id: str | None = None) -> list[dict[str, Any]]:
"""Return a snapshot of records for ``user_id``. Never raises."""
try:
with _lock:
buffer = _buffers.get(_bucket_key(user_id))
if buffer is None:
return []
return list(buffer)
except Exception:
logger.debug("curator_log.get_recent failed", exc_info=True)
return []
def clear(user_id: str | None = None) -> None:
"""Drop a user's records. With ``user_id=None`` drops the default bucket.
Use ``clear_all()`` for cross-user cleanup (tests).
"""
try:
with _lock:
buffer = _buffers.get(_bucket_key(user_id))
if buffer is not None:
buffer.clear()
except Exception:
logger.debug("curator_log.clear failed", exc_info=True)
def clear_all() -> None:
"""Drop every user's records. Used by tests."""
try:
with _lock:
_buffers.clear()
except Exception:
logger.debug("curator_log.clear_all failed", exc_info=True)
@@ -1,204 +0,0 @@
"""Shared skill archive installation logic.
Pure business logic — no FastAPI/HTTP dependencies.
Both Gateway and Client delegate to these functions.
"""
import asyncio
import concurrent.futures
import logging
import posixpath
import shutil
import stat
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from deerflow.skills.security_scanner import scan_skill_content
logger = logging.getLogger(__name__)
_PROMPT_INPUT_DIRS = {"references", "templates"}
_PROMPT_INPUT_SUFFIXES = frozenset({".json", ".markdown", ".md", ".rst", ".txt", ".yaml", ".yml"})
class SkillAlreadyExistsError(ValueError):
"""Raised when a skill with the same name is already installed."""
class SkillSecurityScanError(ValueError):
"""Raised when a skill archive fails security scanning."""
def is_unsafe_zip_member(info: zipfile.ZipInfo) -> bool:
"""Return True if the zip member path is absolute or attempts directory traversal."""
name = info.filename
if not name:
return False
normalized = name.replace("\\", "/")
if normalized.startswith("/"):
return True
path = PurePosixPath(normalized)
if path.is_absolute():
return True
if PureWindowsPath(name).is_absolute():
return True
if ".." in path.parts:
return True
return False
def is_symlink_member(info: zipfile.ZipInfo) -> bool:
"""Detect symlinks based on the external attributes stored in the ZipInfo."""
mode = info.external_attr >> 16
return stat.S_ISLNK(mode)
def should_ignore_archive_entry(path: Path) -> bool:
"""Return True for macOS metadata dirs and dotfiles."""
return path.name.startswith(".") or path.name == "__MACOSX"
def resolve_skill_dir_from_archive(temp_path: Path) -> Path:
"""Locate the skill root directory from extracted archive contents.
Filters out macOS metadata (__MACOSX) and dotfiles (.DS_Store).
Returns:
Path to the skill directory.
Raises:
ValueError: If the archive is empty after filtering.
"""
items = [p for p in temp_path.iterdir() if not should_ignore_archive_entry(p)]
if not items:
raise ValueError("Skill archive is empty")
if len(items) == 1 and items[0].is_dir():
return items[0]
return temp_path
def safe_extract_skill_archive(
zip_ref: zipfile.ZipFile,
dest_path: Path,
max_total_size: int = 512 * 1024 * 1024,
) -> None:
"""Safely extract a skill archive with security protections.
Protections:
- Reject absolute paths and directory traversal (..).
- Skip symlink entries instead of materialising them.
- Enforce a hard limit on total uncompressed size (zip bomb defence).
Raises:
ValueError: If unsafe members or size limit exceeded.
"""
dest_root = dest_path.resolve()
total_written = 0
for info in zip_ref.infolist():
if is_unsafe_zip_member(info):
raise ValueError(f"Archive contains unsafe member path: {info.filename!r}")
if is_symlink_member(info):
logger.warning("Skipping symlink entry in skill archive: %s", info.filename)
continue
normalized_name = posixpath.normpath(info.filename.replace("\\", "/"))
member_path = dest_root.joinpath(*PurePosixPath(normalized_name).parts)
if not member_path.resolve().is_relative_to(dest_root):
raise ValueError(f"Zip entry escapes destination: {info.filename!r}")
member_path.parent.mkdir(parents=True, exist_ok=True)
if info.is_dir():
member_path.mkdir(parents=True, exist_ok=True)
continue
with zip_ref.open(info) as src, member_path.open("wb") as dst:
while chunk := src.read(65536):
total_written += len(chunk)
if total_written > max_total_size:
raise ValueError("Skill archive is too large or appears highly compressed.")
dst.write(chunk)
def _is_script_support_file(rel_path: Path) -> bool:
return bool(rel_path.parts) and rel_path.parts[0] == "scripts"
def _should_scan_support_file(rel_path: Path) -> bool:
if _is_script_support_file(rel_path):
return True
return bool(rel_path.parts) and rel_path.parts[0] in _PROMPT_INPUT_DIRS and rel_path.suffix.lower() in _PROMPT_INPUT_SUFFIXES
def _move_staged_skill_into_reserved_target(staging_target: Path, target: Path) -> None:
installed = False
reserved = False
try:
target.mkdir(mode=0o700)
reserved = True
for child in staging_target.iterdir():
shutil.move(str(child), target / child.name)
installed = True
except FileExistsError as e:
raise SkillAlreadyExistsError(f"Skill '{target.name}' already exists") from e
finally:
if reserved and not installed and target.exists():
shutil.rmtree(target)
async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool) -> None:
rel_path = path.relative_to(skill_dir).as_posix()
location = f"{skill_name}/{rel_path}"
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e
try:
result = await scan_skill_content(content, executable=executable, location=location)
except Exception as e:
raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e
decision = getattr(result, "decision", None)
reason = str(getattr(result, "reason", "") or "No reason provided.")
if decision == "block":
if rel_path == "SKILL.md":
raise SkillSecurityScanError(f"Security scan blocked skill '{skill_name}': {reason}")
raise SkillSecurityScanError(f"Security scan blocked {location}: {reason}")
if executable and decision != "allow":
raise SkillSecurityScanError(f"Security scan rejected executable {location}: {reason}")
if decision not in {"allow", "warn"}:
raise SkillSecurityScanError(f"Security scan failed for {location}: invalid scanner decision {decision!r}")
async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str) -> None:
"""Run the skill security scanner against all installable text and script files."""
skill_md = skill_dir / "SKILL.md"
await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False)
for path in sorted(skill_dir.rglob("*")):
if not path.is_file():
continue
rel_path = path.relative_to(skill_dir)
if rel_path == Path("SKILL.md"):
continue
if path.name == "SKILL.md":
raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': nested SKILL.md is not allowed at {skill_name}/{rel_path.as_posix()}")
if not _should_scan_support_file(rel_path):
continue
await _scan_skill_file_or_raise(skill_dir, path, skill_name, executable=_is_script_support_file(rel_path))
def _run_async_install(coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(asyncio.run, coro).result()
return asyncio.run(coro)
@@ -1,80 +0,0 @@
import logging
import re
from pathlib import Path
import yaml
from .types import SKILL_MD_FILE, Skill, SkillCategory
logger = logging.getLogger(__name__)
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
"""Parse a SKILL.md file and extract metadata.
Args:
skill_file: Path to the SKILL.md file.
category: Category of the skill.
relative_path: Relative path from the category root to the skill
directory. Defaults to the skill directory name when omitted.
Returns:
Skill object if parsing succeeds, None otherwise.
"""
if not skill_file.exists() or skill_file.name != SKILL_MD_FILE:
return None
try:
content = skill_file.read_text(encoding="utf-8")
# Extract YAML front-matter block between leading ``---`` fences.
front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not front_matter_match:
return None
front_matter_text = front_matter_match.group(1)
try:
metadata = yaml.safe_load(front_matter_text)
except yaml.YAMLError as exc:
logger.error("Invalid YAML front-matter in %s: %s", skill_file, exc)
return None
if not isinstance(metadata, dict):
logger.error("Front-matter in %s is not a YAML mapping", skill_file)
return None
# Extract required fields. Both must be non-empty strings.
name = metadata.get("name")
description = metadata.get("description")
if not name or not isinstance(name, str):
return None
if not description or not isinstance(description, str):
return None
# Normalise: strip surrounding whitespace that YAML may preserve.
name = name.strip()
description = description.strip()
if not name or not description:
return None
license_text = metadata.get("license")
if license_text is not None:
license_text = str(license_text).strip() or None
return Skill(
name=name,
description=description,
license=license_text,
skill_dir=skill_file.parent,
skill_file=skill_file,
relative_path=relative_path or Path(skill_file.parent.name),
category=category,
enabled=True, # Actual state comes from the extensions config file.
)
except Exception:
logger.exception("Unexpected error parsing skill file %s", skill_file)
return None
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more