Compare commits

..
32 Commits
Author SHA1 Message Date
董宏凯 ab6068a7c1 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-09-09 22:29:54 +08:00
董宏凯 5ab3d7f254 代码修改 2026-09-09 22:29:47 +08:00
董宏凯 30e1c2dd90 代码合并 2026-09-09 19:49:38 +08:00
董宏凯 673adef7df 代码提交 2026-09-09 12:48:06 +08:00
董宏凯 6f7919ded2 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-09-08 14:41:55 +08:00
董宏凯 014d754707 代码提交 2026-09-08 14:33:03 +08:00
董宏凯 8859c13cfb 代码合并 2026-09-08 09:40:31 +08:00
董宏凯 b0970de677 代码提交 2026-09-07 18:38:51 +08:00
董宏凯andCursor 53cac5f81a Merge branch 'master' into dhk-1
Resolve conflicts by accepting master version for:
- workspace-nav-chat-list.tsx
- ResearchSidebar.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:55:32 +08:00
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

See merge request knowledge-base-bj/deerflow-code!39
2026-09-07 09:53:09 +00:00
董宏凯 1244fb1f2f 代码提交 2026-09-07 17:30:53 +08:00
吉鑫升 3ca5528640 feat(knowledge): support standalone file ingestion and configurable wiki entry
Use neutral knowledge-service labels and sanitize user-facing task errors.
2026-09-07 17:07:27 +08:00
董宏凯 250db4de04 fix(buttons): 行动分析宿主路由支持切换任务id/行动id
- TaskButtonsPage: 宿主路由追加任务id时,行动分析(xdfx) tab 下增加 idKind 下拉框可选任务id/行动id,与外部链接类型一致
- runTaskButton: host 分支按 idKind 取 ctx.actionId 或 ctx.taskId(行动id缺失回退任务id),与 url 分支同规则
- AGENTS.md: 同步更新 idKind 适用范围说明(url/host 均适用)
2026-09-07 15:56:50 +08:00
董宏凯 f9b6d7e322 Merge branch 'master-nj' into 'master'
feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底

See merge request knowledge-base-bj/deerflow-code!38
2026-09-07 06:58:42 +00:00
董宏凯 6edb6fa7aa 本地模式线上区分-v1 2026-09-07 14:57:17 +08:00
董宏凯 cf9fe58905 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/pyproject.toml
2026-09-07 14:46:36 +08:00
董宏凯 56bb4f141d 代码提交 2026-09-06 22:17:53 +08:00
董宏凯 75f288b241 代码提交 2026-09-06 20:49:35 +08:00
董宏凯 bf42a48fcc 代码提交 2026-09-06 20:25:14 +08:00
董宏凯andCursor 5e953c9fcb feat(report-collaboration): complete RC-BE-015–018 backend, eval suite, and MySQL sync
Ship report rewrite/version APIs, security budget and audit persistence, AgentScope worker wiring, and the 20-task quality eval harness so the frontend can integrate against a stable contract once enabled in config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 17:32:15 +08:00
董宏凯 814a44f0b9 代码修改 2026-09-05 11:28:25 +08:00
董宏凯 b80433f091 chore: 同步仓库执行说明与约束文档
功能说明:\n- 更新仓库根目录 AGENTS.md 为当前约定和功能说明口径\n- 主要是开发约束与架构说明的同步,供后续提交与协作流程对齐
2026-09-04 23:18:59 +08:00
董宏凯 5c75e1af16 feat(deep-research): 强化任务链路可靠性并接入 agentfx 报告助手种子
功能说明:\n- 重构 Deep Research 调度与执行链,补齐 dispatcher、executor、live hub 与依赖注入入口,提升任务下发、执行、回调的稳定性\n- 增强运行时持久化与恢复能力,接入业务映射、任务来源与任务事件日志模型,覆盖持久化与执行可靠性关键路径测试\n- 新增 agentfx 报告助手 seed 与任务报告导入脚本配置,支持离线环境快速初始化与任务流模板回填\n- 完善 deep-research 与 thread_runs 路由参数编排,兼容不同业务映射与运行入口的前后端联动\n- 补充前端 agentfx deep-link 解析,保证 deep-research 场景下的登录跳转与任务上下文一致
2026-09-04 23:18:52 +08:00
董宏凯 438772f0b7 feat(frontend): 上线报告协作与多轮 RAG 消息聚合体验
功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
2026-09-04 23:18:27 +08:00
吉鑫升 dea4697ea0 feat: improve knowledge sync and entity alignment 2026-09-04 18:43:12 +08:00
g zy 7dbac9cc56 Merge branch 'gzy_browser' into 'master'
fix

See merge request knowledge-base-bj/deerflow-code!37
2026-09-04 03:21:48 +00:00
董宏凯 e58da3fbdf 代码提交 2026-09-03 20:06:54 +08:00
吉鑫升 60e2c72514 feat: preserve Wiki vectors across knowledge transfers and add local BGE-M3
Unify Wiki reading, preserve metadata and graph links, add asynchronous file-backed transfers, and reuse compatible vectors. Includes regression coverage and live acceptance notes.
2026-09-03 19:21:10 +08:00
董宏凯 1eae02f782 代码提交 2026-09-03 17:45:06 +08:00
董宏凯andCursor db333a4c33 feat: 重构多轮RAG展示并完善报告协作、业务链审核与 agentfx 种子
抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 17:34:16 +08:00
董宏凯andCursor f109a95535 feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底
支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 11:16:50 +08:00
董宏凯andCursor 33dea724a1 Hide workspace 岗位会商 nav entry and enable AI writing notebook space entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 19:01:24 +08:00
349 changed files with 62578 additions and 6282 deletions

No files matched your search

+17 -11
View File
@@ -91,6 +91,7 @@ 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
@@ -122,25 +123,28 @@ offline-backend-20260512/backend/tests/*
# 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
!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_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
@@ -172,8 +176,10 @@ 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
# 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/
+2 -2
View File
@@ -96,13 +96,13 @@ 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>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (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/`。
- **按钮管理 (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`.
- **按钮管理 (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`.
- **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
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
### Configuration
+117
View File
@@ -0,0 +1,117 @@
# Wiki 数据流转与本地编码验收(2026-09-03)
## 结论
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
## 本地编码配置
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
`local_model_path` 指定离线权重目录,详见后端 README。
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
不同模型或维度不能混用;导入不静默重新编码。
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
## 实际数据验收
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
| --- | ---: | ---: | ---: | ---: |
| 普通知识库导出 | 19 | 101 | 19 | 108 |
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
逐项断言通过:
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
### 检索
实际查询:“军方用什么工具评估极端天气带来的风险?”
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
首个命中分段余弦相似度约 0.6343。
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
### 页面
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
### 验收数据位置
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
## 这次实际修复的问题
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
## 阻塞与未通过的范围
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
分页与助手问答的最终生成回答不属于这轮通过结论。
## 自动化检查
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
WeKnora 接口、向量检索与知识库管理)。
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
- `git diff --check` 通过(只有仓库换行提示)。
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
@@ -0,0 +1,186 @@
# 报告协作工作台 — 交互重设计方案(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 单测,不引入对其他模块的影响。
@@ -1,13 +1,25 @@
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
> 文档状态:待评审、尚未开始编码
> 编写日期:2026-09-02
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
> 编写日期:2026-09-02;进度更新日期:2026-09-06
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
## 0. 当前进度
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
## 1. 前端目标
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
@@ -662,6 +674,8 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
## 9. 前端开发顺序
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
1. `RC-FE-001`:视觉截图与行为基线。
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
@@ -678,15 +692,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
依赖规则:
- `RC-FE-004` 必须等待前后端契约冻结。
- 页面骨架和视觉可与后端 BE-0~BE-3 并行。
- 真实 SSE 联调必须等待后端 durable event store。
- 报告版本操作必须等待后端候选应用语义完成。
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
---
## 10. 前端完成定义
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
前端只有同时满足以下条件才算完成:
1. 页面结构与 Coze 参考页面逐状态验收通过。
@@ -0,0 +1,481 @@
# 报告协作工作台 — 前端对接指南
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
> **配套文档:**
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
---
## 1. 结论速览
| 问题 | 答案 |
|------|------|
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
---
## 2. 架构:前端已有什么
```
ReportCollaborationPage
└─ useReportCollaborationSession(唯一数据入口)
├─ driver.getSnapshot() ← REST 冷启动
├─ driver.openRunStream() ← SSE 实时事件
├─ driver.sendMessage / createCommand / startRun …
└─ event-reducer ← 事件 → UI 状态
ReportConversationPanel
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
```
### 2.1 目录结构
```text
frontend-web/src/report-collaboration/
api/
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
sessions.ts # 会话 CRUD、消息
plans.ts # 方案请求 / 列表 / 选定
runs.ts # Run、SSE、命令、节点合同
reports.ts # 报告版本、改写、恢复
driver.ts # 真实后端统一门面(Mock 驱动已移除)
state/
event-normalizer.ts
event-reducer.ts
selectors.ts
message-adapter.ts
hooks/
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
useReportCollaborationStream.ts # 断线重连、after_seq 续接
useReportCollaborationMessages.ts
components/ …
pages/ReportCollaborationPage.tsx
```
### 2.2 事实源原则
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
---
## 3. 后端启用与部署
### 3.1 必改配置
文件:`offline-backend-20260512/backend/config.yaml`
```yaml
report_collaboration:
enabled: false # 联调必须改为 true
worker_enabled: false # 要看成员执行必须 true
planner_model: null # 建议填可用模型名
coordinator_model: null
research_model: null
writer_model: null
reviewer_model: null
# … token_budget、max_concurrent_runs_per_user 等见同文件
```
| 配置 | 现象 |
|------|------|
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
| 两者 `true` + 模型配置完整 | 完整联调 |
### 3.2 MySQL / 表结构
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
### 3.3 前端环境变量
| 变量 | 说明 |
|------|------|
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
---
## 4. API 基址与请求约定
### 4.1 Base URL
```text
{VITE_BACKEND_BASE_URL}/api/report-collaboration
```
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
### 4.2 请求头
| Header | 何时携带 | 说明 |
|--------|----------|------|
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
| `Accept: text/event-stream` | SSE | `openRunStream` |
### 4.3 错误响应格式
```json
{
"code": "REPORT_COLLABORATION_DISABLED",
"message": "报告协作工作台未启用",
"detail": { }
}
```
前端映射(`api/client.ts`):
| HTTP | 异常类 | 典型场景 |
|------|--------|----------|
| 409 | `ConflictError` | revision 过期、active run 冲突 |
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
| 5xx | `ServerError` | 可带同一幂等键重试 |
| 0 | `NetworkError` | fetch 失败 |
### 4.4 完整路由清单(与前端 client 对齐)
| 方法 | 路径 | 前端模块 |
|------|------|----------|
| POST | `/sessions` | `sessions.createSession` |
| GET | `/sessions` | `sessions.listSessions` |
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
| POST | `/sessions/{id}/runs` | `runs.startRun` |
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
| GET | `/runs/{id}` | `runs.getRun` |
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
| POST | `/runs/{id}/commands` | `runs.createCommand` |
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
---
## 5. 用户流程与 API 时序
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端 RC
participant API as Gateway
participant W as Worker
U->>FE: 输入需求
FE->>API: POST /sessions
FE->>API: POST /sessions/{id}/messages
API-->>FE: 澄清(快照 messages)
U->>FE: 回答澄清
FE->>API: POST /messages
FE->>API: POST /plan-requests
API-->>FE: 候选方案(快照 plans)
U->>FE: 采用方案
FE->>API: POST /plans/{id}/select
U->>FE: 开始协作
FE->>API: POST /runs
FE->>API: GET /runs/{id}/stream
W-->>API: 持久化事件
API-->>FE: SSE
U->>FE: 运行中干预
FE->>API: POST /commands → confirm
```
### 5.1 阶段 0:进入页面
| 路由 | 行为 |
|------|------|
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
### 5.2 阶段 1:需求澄清(无 run)
```http
POST /api/report-collaboration/sessions
POST /api/report-collaboration/sessions/{sessionId}/messages
Content-Type: application/json
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
```
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
### 5.3 阶段 2:候选方案
```http
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
```
- **预览方案**(`previewPlanId`):纯前端,不调 API。
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
### 5.4 阶段 3:开始执行与 SSE
```http
POST /api/report-collaboration/sessions/{sessionId}/runs
{ "plan_id": "plan-xxx" }
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
Accept: text/event-stream
```
SSE 每条 `data:` 为 JSON:
```typescript
interface CollaborationEvent {
eventId: string;
sessionId: string;
runId?: string | null;
seq: number;
type: CollaborationEventType;
timestamp: string;
data: unknown;
}
```
常用 `type`(完整列表见 `api/types.ts`):
| 类型 | 含义 |
|------|------|
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
| `team_message.*` | 成员 / 协调者广播 |
| `plan.proposed` / `plan.selected` | 方案生命周期 |
| `run.status.changed` | Run 状态 |
| `node.status.changed` / `node.progress` | 节点执行 |
| `command.confirmation.required` | 需用户确认的干预 |
| `report.version.created` | 报告版本落盘 |
| `heartbeat` | 保活 |
### 5.5 阶段 4:运行中干预
```http
POST /api/report-collaboration/runs/{runId}/commands
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
POST /api/report-collaboration/runs/{runId}/cancel
```
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
### 5.6 阶段 5:报告版本与改写
```http
GET /api/report-collaboration/sessions/{sessionId}/reports
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
```
- `createRewrite` 只生成候选,不直接覆盖正式版本。
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
---
## 6. 前端状态机(接入方须知)
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
```text
SessionSnapshot → applySessionSnapshot()
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
→ selectors(derivePagePhase、selectPlanForCanvas、…)
```
### 6.1 核心 Selector
| Selector | 用途 |
|----------|------|
| `derivePagePhase` | Composer 占位、方案卡、空态 |
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
| `selectAgentTabs` | 并行成员 Tab |
| `selectNodeStatusMap` | 节点颜色 / 状态 |
| `selectPendingConfirmationCommands` | 干预确认卡 |
| `selectHeadReportVersion` | 报告预览入口 |
| `selectStreamingAgentRunIds` | 流式成员指示 |
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
### 6.3 乐观消息
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
---
## 7. 消息与 MessageList 复用
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
| 来源 | 渲染 |
|------|------|
| `human` / `ai` 文本 | 普通气泡 |
| `tool_calls` + `tool` | 现有工具卡 |
| `team_message` 事件 | 带 `name` 的成员发言 |
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
### 7.1 在其他页面嵌入 RC(最小集)
1. `useReportCollaborationSession(sessionId)`
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
3. 引入 `report-collaboration/styles/report-collaboration.css`
---
## 8. 单测
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
单测:
```bash
cd frontend-web
pnpm test:report-collaboration
```
---
## 9. 联调验收清单
### 9.1 准备
- [ ] `report_collaboration.enabled: true`
- [ ] `report_collaboration.worker_enabled: true`
- [ ] 五个 `*_model` 已配置且可用
- [ ] Gateway 已重启(MySQL 表/列就绪)
- [ ] 前端已登录(JWT 有效)
### 9.2 探活
```bash
curl -s -H "Authorization: Bearer <token>" \
http://127.0.0.1:8001/api/report-collaboration/sessions
```
期望:**200** + `{ "sessions": [...] }`,而非 503。
### 9.3 页面流程(浏览器 Network)
1. 打开 `/#/page/workspace/report-collaboration`
2. 输入需求 → `POST .../sessions` **200**
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
4. 首条消息 → `POST .../messages` **200**
5. 请求方案 → `POST .../plan-requests` **200**
6. 采用方案 → `POST .../plans/{id}/select` **200**
7. 开始协作 → `POST .../runs` **200**
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
9. 刷新页面 → 消息与 `last_seq` 续接正确
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
### 9.4 常见失败
| 现象 | 原因 | 处理 |
|------|------|------|
| 全部 503 | `enabled: false` | 改配置并重启 |
| run 一直 queued | `worker_enabled: false` | 改为 true |
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
---
## 10. 契约与类型维护
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
`SessionSnapshot` 形状(摘要):
```typescript
interface SessionSnapshot {
session: ReportSession;
messages: CollaborationMessage[];
plans: ReportPlanCandidate[];
run: ReportRun | null;
nodes: CollaborationNodeState[];
agent_runs: AgentRun[];
commands: CollaborationCommand[];
report_versions: ReportVersion[];
last_seq: number;
}
```
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
---
## 11. 后续工作(非对接阻塞)
| 项 | 说明 |
|----|------|
| RC-BE-019 | 灰度发布与离线部署文档 |
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
---
## 12. 相关代码索引
| 用途 | 路径 |
|------|------|
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
@@ -1,11 +1,44 @@
# AgentScope 2.0 多智能体报告协作工作台——后端实施计划
> 文档状态:待评审、尚未开始编码
> 编写日期:2026-09-02
> 文档状态:实施中(`RC-BE-000`~`018` 已完成;下一票 `RC-BE-019`)
> 编写日期:2026-09-05;进度更新日期:2026-09-06
> 目标项目:`F:\react01\deeflow-code\deerflow-server\offline-backend-20260512\backend`
> 协作框架:[AgentScope 2.0](https://github.com/agentscope-ai/agentscope)
> 配套前端计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
> 前端契约来源:`frontend-web/src/report-collaboration/api/types.ts`、`api/mock.ts`、`state/event-reducer.ts`(前端已完成,契约冻结)
> 尖峰基线:`offline-backend-20260512/backend/docs/AGENTSCOPE_BASELINE.md`
> 本文只规划新增的报告协作后端,不修改现有多智能体会商,不使用旧 Workflow Studio DAG 调度器。
> 下文 §8 的 `BE-n` 与 §10 的 `RC-BE-00n` 是同一批工单的两种编号;进度以 `RC-BE-*` 为准。
## 0. 当前进度
已打通 **建会话 → 澄清需求 → 生成 2~3 套候选方案 → 选定 → 创建 `queued` run(冻结模型 + 模板 + 预算)**,以及 **TaskLedger 波次调度 + 受控成员 / 真实 AgentScope 内核 + 主张级 QualityGate + 正式报告版本/改写 + 产品 durable SSE + 租约 dispatcher/恢复 + 运行期自然语言干预 + owner/预算/审计**。主开关默认关闭:`config.yaml → report_collaboration.enabled` / `worker_enabled` 均为 `false`。
运行期 IntentRouter 的歧义兜底已可调用冻结的 coordinator 模型,但明确命令走高精度规则;该智能体无工具且不能调度。`POST /runs` 仍只入队。`worker_enabled` 为 false 时 lifespan **不**挂载后台循环;测试用 `dispatch_once()` / `WaveExecutor` 驱动。`GET /runs/{id}/stream` 可重放/尾随已持久化事件。生产 worker 在双开时注入 `AgentScopeMemberKernel`。20 项质量评测已在 `RC-BE-018` 落地(确定性打分,不跑真实模型)。
| 工单 | 状态 | 代码落点 / 缺口 |
|---|---|---|
| `RC-BE-000` | 条件完成 | `agentscope_runtime/event_adapter.py`。`agentscope==2.0.5` 仅为 optional extra,**未写入 `uv.lock`**(等纵向 POC)。 |
| `RC-BE-001` | 完成 | `app/report_collaboration/contracts/` + `execution/completion.py`。 |
| `RC-BE-002` | 完成 | `deerflow.config.report_collaboration_config`;默认双关。 |
| `RC-BE-003` | 完成 | `deerflow.persistence.report_collaboration/`,迁移 `20260905_01`。 |
| `RC-BE-004` | 完成 | `/api/report-collaboration`。改写 / apply / restore 见 `015`。SSE 见 `011`。 |
| `RC-BE-005` | 完成 | `requirement/`:规划前澄清;「直接开始」记默认假设;澄清完成 ≠ 出方案。 |
| `RC-BE-006` | 完成 | `planning/`:目录投影(无 SOUL/密钥)+ 三策略方案;select 与 start-run 独立。 |
| `RC-BE-007` | 完成 | `DeerFlowChatModelAdapter` + `freeze_plan_models`;缺模型/能力不匹配在 `POST /runs` 前 422。 |
| `RC-BE-008` | 完成 | `permission_adapter.py` + `tool_adapter.py`:角色 × 工具矩阵、注入扫描。 |
| `RC-BE-009` | 完成 | `execution/ledger.py` + `member_runner.py`:TaskLedger 按依赖波次分派;AgentScope/协调者建议不能选下一节点;空输出/非法 JSON 进入新 attempt;超限失败不完成整个 run。内核目前为 `ScriptedMemberKernel`(单测),真实模型/web_search 仍待接。 |
| `RC-BE-010` | 完成 | `execution/quality_gate.py` + `ArtifactBoard.supersede_previous`:冲突数字/缺口径拒绝;未核验或争议主张不能进分析/章节;审稿必须映射到节点或章节;覆盖矩阵缺口拒绝。Ledger 先 schema 再质检,通过才 `validated`。 |
| `RC-BE-011` | 完成 | `execution/live_hub.py`:run-scoped Live Hub、`PublishingReportCollaborationStore`(先入库再发布)、`after_seq`/`Last-Event-ID` 重放、心跳、token delta 合帧、SSE 断开不取消 run。`GET /runs/{id}/stream` 已挂载。报告正式版本见 `015`。 |
| `RC-BE-012` | 完成 | store `list_claimable` / `renew_lease` / `finalize_cancel`;`execution/{dispatcher,executor,recovery}.py`:原子 claim、心跳续租、两阶段取消、过期租约接管。恢复保留已完成节点,遗留 in-flight 标 `interrupted`/`superseded` 并开新 `planned` attempt。晚到成果 `validation_status=superseded`,不改终态。`start()` 仅在 `worker_enabled` 时挂循环;默认双关。 |
| `RC-BE-013` | 完成 | `execution/quality_roles.py`:Verifier/Analyst/Writer/Reviewer/Reviser 作为受控成员(确定性内核,非 AgentScope Team)。核验标记过期/跨包冲突;缺失角度只重开检索链;审稿 `revise` 只重开目标写作节点 + 审稿自身。`TaskResult.additional_drafts` 允许多条 VerificationResult。脚本内核仍供单测;生产 worker 见 `017`。 |
| `RC-BE-014` | 完成 | `interventions/`:运行期 IntentRouterAgent(规则优先、模型兜底且无工具)+ 确定性 ImpactAnalyzer + command CAS/safe-point。支持节点/agent 归属校验、角度定向、确认闸门、等待边界、overlay、新 attempt、成果失效及晚到结果隔离。 |
| `RC-BE-015` | 完成 | `reporting/`:run 开始冻结模板;`ReportFinalizer` 在审稿通过且 lint 后落正式版本并发 `report.version.created`;改写候选 / apply / restore 已挂路由,不再 503。 |
| `RC-BE-016` | 完成 | owner 隔离、跨会话幂等拒绝、`sanitize_for_user` + 审计落库、每用户 active run 上限、每 run 预算 fail-closed。 |
| `RC-BE-017` | 完成 | `AgentScopeMemberKernel` 真实 `reply_stream` + 工具 + 结构化成果;生产 worker 注入该内核;冒烟可跳过未安装的 `agentscope`。并发交错/租约恢复仍由 `012` 测试覆盖。 |
| `RC-BE-018` | 完成 | `app/report_collaboration/eval/`:20 项标注任务(政策/市场/企业/事件/对比各 4)、确定性指标、金标/失败实例、会商盲评对照、20 次固定任务可靠性计数。`cite_required` 与当前默认 knobs 为通过评测的推荐画像。真实模型盲评仍属上线决策,不阻塞本票。 |
| `RC-BE-019` | 未开始 | 灰度与离线发版。 |
当前可联调路径:打开 `report_collaboration.enabled` 后,规划前轮询 `GET /sessions/{id}` 即可看到澄清消息和方案卡;`POST /runs` 只入队;`GET /runs/{id}/stream` 重放已入库事件。默认 **不**自动领取 queued run(`worker_enabled=false`)。测试可调 `dispatch_once()`。
## 1. 后端目标
@@ -14,7 +47,7 @@
1. 理解任意报告需求并在必要时澄清。
2. 从当前用户可见的智能体、技能、模型和资料源中筛选能力。
3. 生成 2~3 个真实有差异的候选协作计划。
4. 将选定计划实例化为 AgentScope leader-worker 团队。
4. 将选定计划实例化为受 `TaskLedger` 约束的 AgentScope 任务成员,而非让自由会商自行决定流程。
5. 通过结构化成果物完成检索、核验、分析、综合、写作、审稿和返工。
6. 接受全过程自然语言干预,并只重开受影响的任务和下游成果。
7. 使用数据库租约、durable events、幂等命令和版本化报告保证恢复与一致性。
@@ -23,11 +56,27 @@
框架能提供协作机制,不能单独保证报告质量。质量由证据合同、来源核验、独立审稿、定向返工、模板约束和真实评测集共同保证。
### 1.1 前端冻结契约:后端必须适配,不要求前端回退
已完成的双栏前端以 `SessionSnapshot` 和 run-scoped durable SSE 为唯一事实来源;后端实现不得另造一套摘要卡或节点结果接口来补足过程。
| 前端已实现行为 | 后端适配要求 |
|---|---|
| run 创建前轮询 `GET /sessions/{id}` | 澄清、需求修订、候选方案与选择结果必须同步写入完整快照;不能只存在于内存任务。 |
| run 后以 `seq` 重放/订阅 SSE | 每个 run 的事件先持久化、再发布,`seq` 严格单调且可用 `after_seq` 补拉。 |
| 并行成员横向阶段卡 | 同一并行波次使用相同 `phase_id`;`node_run_id` 固定为 `${node_id}-attempt{attempt}`,不可使用前端无法反查节点的随机值。 |
| 复用 `MessageList` 展示过程 | assistant、TeamSay、工具调用、工具结果与流式增量均写入 `report_collaboration_messages` 并按原消息协议推送。 |
| 成员状态来自局部快照合并 | 前端没有 `agent_run.*` 事件;成员开始、等待输入、完成、失败、取消时必须发 `session.snapshot: {agent_runs:[...], run?:...}`。 |
| 报告在对话流中渐显 | 写作阶段发 `report.delta`;质量门和版本落库完成后才发 `report.version.created`。 |
| 节点抽屉只读 | `GET /runs/{id}/nodes/{node_id}/contract` 只返回职责合同,永不返回执行结果正文。 |
每个写操作读取前端发送的 `X-Idempotency-Key` 与 `X-Expected-Revision`;用户消息的服务端镜像必须将同一幂等键放入 `metadata.idempotency_key`,以替换前端乐观消息而不重复显示。
---
## 2. 关键架构决策
### 2.1 使用 AgentScope Core,不采用演示服务作为事实源
### 2.1 AgentScope 用作任务执行内核,不把 Agent Team 自主会商当作调度器
AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Service 示例,但团队可靠性与多进程/分布式服务仍在持续完善:
@@ -36,13 +85,16 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic
- [Agent Team 可靠性跟踪](https://github.com/agentscope-ai/agentscope/issues/1802)
- [多进程与分布式能力跟踪](https://github.com/agentscope-ai/agentscope/issues/1722)
因此采用:
因此采用下列职责分界:
- AgentScope Core:Agent、Team、Tool、Event、Permission。
- AgentScope Core:单个受控任务成员的模型调用、流式事件、工具调用、权限封装与结构化输出修复。
- DeerFlow `TaskLedger`:计划实例化、依赖拓扑、并发波次、输入成果选择、状态迁移、重试、租约、取消、命令安全点与业务终态。
- DeerFlow `ArtifactBoard` / `QualityGate`:成果物事实源、验收、失效传播、引用约束和报告版本准入。
- `Coordinator`:受限的规划/协调智能体,只能提出 `PlanProposal`、`TaskAssignment`、`RepairRequest` 和 `ClarificationRequest`;它不能自行创建任意图、改变数据库或宣告节点完成。
- AgentScope TeamSay:允许作为可见的协作交接文本,但不是任务分派、完成判定或数据依赖的事实来源。
- DeerFlow:身份、权限、配置、数据库、消息、命令、作业、租约、SSE、成果、文件、报告版本和审计。
- AgentScope Event 既驱动运行状态,也通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;其用户可见内容不能在 adapter 中被压缩为进度摘要。
- 不运行 AgentScope `examples/web_ui`。
- 不把 AgentScope Session、MessageBus 或内存对象作为恢复依据。
- AgentScope Event 通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;用户可见内容不能在 adapter 中压缩为进度摘要。
- 不运行 AgentScope `examples/web_ui`,不使用 AgentScope Session、MessageBus 或内存对象作为恢复/重放依据,也不使用其 session SSE 作为产品 SSE。
- 节点公开接口只返回职责合同、图关系和状态,不返回节点实际输出;实际阶段消息、工具过程和成果仍从会话消息流以原生消息、工具步骤、引用和报告文件卡的形式呈现。
### 2.2 新功能独立,不进入旧运行内核
@@ -63,7 +115,7 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic
- 展示当前状态和返工范围。
- 供用户理解和选择方案。
真正调度由 `Coordinator + TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能从服务端允许的动作中选择,不能返回任意 Python、任意图或数据库操作。
真正调度由 `TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能提出服务端可验证的建议;`TaskLedger` 才能执行分派、依赖解锁、重试和终态迁移,不能返回任意 Python、任意图或数据库操作。
### 2.4 数据库是唯一事实源
@@ -89,12 +141,12 @@ flowchart LR
API --> DISP[Lease Dispatcher]
DISP --> EXEC[Report Collaboration Executor]
EXEC --> AS[AgentScope Core]
AS --> LEAD[Coordinator]
AS --> TEAM[Research/Verify/Analyze/Write/Review Team]
EXEC --> LEDGER[Task Ledger]
EXEC --> BOARD[Typed Artifact Board]
LEDGER --> COORD[Constrained Coordinator]
LEDGER --> AS[AgentScope Core Task Workers]
COORD --> LEDGER
AS --> TEAM[Research / Verify / Analyze / Write / Review]
EXEC --> MODEL[DeerFlow Model Adapter]
EXEC --> TOOL[Allowlisted Tool Adapter]
@@ -290,6 +342,59 @@ completed -> reopened -> queued
- 期望成果物落库、Schema 通过、验收器通过后才能完成。
- 返工创建新 attempt,保留旧 attempt 和旧成果。
### 5.6 `TaskLedger`:智能体协作的确定性边界
每个计划节点被实例化为一条可审计的 ledger task;并行只是多个 ready task 位于同一波次,不是让多个智能体在群聊中自行决定谁做什么。
```text
TaskAssignment
node_id / node_run_id / phase_id / attempt
requirement_revision
allowed_input_artifact_ids[]
expected_artifact_type + acceptance_criteria
agent_profile_snapshot + allowed_tools + budgets
command_overlay_revision
TaskResult
artifact_draft | clarification_request | repair_request | failure
cited_artifact_ids[]
source_ids[]
```
调度规则:
1. `PlanAssembler` 将角色建议转为固定节点、边、成果合同和质量门;只允许无环主图。
2. `TaskLedger` 仅在依赖的成果物均为 `validated`、未 supersede 且预算可用时使节点 ready。
3. 每个成员只读 requirement revision、自己的 `TaskAssignment` 与允许的已验证上游成果;不能把其他成员的原始上下文、工具返回或内部 prompt 当作输入。
4. 并行节点共享 `phase_id`,但各自有独立 `agent_run_id`;串行节点用独立 `phase_id`。成员的 `node_run_id` 一律为 `${node_id}-attempt{attempt}`。
5. 只有 `TaskResult` 转为成果物并通过 `QualityGate` 后,ledger 才解锁下游;TeamSay 与普通文本没有解锁能力。
6. 修复/返工创建新 attempt;旧 attempt 及其消息留存但被标为 `superseded`,不得重新进入下游。
### 5.7 用户命令的约束化协议
每句话先持久化为用户消息和 `CollaborationCommand`,再由独立的**意图理解智能体(`IntentRouterAgent`)**进行两段式判定:规则/上下文高精度路由优先,必要时调用模型并输出受 Pydantic 约束的 `IntentDecision`。它负责理解,不负责调度或执行。
同一能力有两种受控入口:
| 时机 | `IntentRouterAgent` 的工作 | 后续处理 |
|---|---|---|
| 尚未开始 run | 提取主题、目标、受众、范围、时间、角度、模板和来源约束;判断真正缺失的信息 | `RequirementResolver` 写 requirement revision;`PlanController` 生成候选计划。 |
| run 正在执行或已有报告 | 结合当前选中的 node/agent、等待澄清、成果血缘和报告章节,识别用户是在回答、补角度、重检索、重分析、重跑、改章节、润色、查询还是改规划 | `ImpactAnalyzer` 计算实际影响;`TaskLedger` 在确认和安全点后执行。 |
输入包含用户文本、session/run 状态、最新 requirement、当前 `target_node_id` / `agent_run_id`、awaiting clarification、当前报告版本和可见成果摘要;不得输入其他成员的内部 prompt、隐藏推理或无权访问的资料。
```text
IntentDecision
intent / confidence / normalized_instruction
scope: conversation | node | artifact_lineage | section | plan
requested_target_ids[] / requirement_patch?
requires_confirmation / cost_level / reason
```
服务端必须验证 `target_node_id`、`agent_run_id` 属于当前 session/run,再解析到 node 和成果物血缘;不可相信客户端传入的任意 ID。`ImpactAnalyzer` 产出实际 `impact_nodes`、失效成果和新 attempt 计划,用户所见的命令状态由它和 `TaskLedger` 写入,而非由协调者文本决定。
`IntentRouterAgent` 的置信度不足、目标不唯一、涉及 requirement/plan revision、预计成本为 high 或影响多个并行分支时,必须返回 `requires_confirmation=true`;只有用户确认后 `TaskLedger` 才可执行。对于处于 `awaiting_input` 的节点,明确回答优先分类为 `answer_question`,只解锁该节点的依赖链。
---
## 6. 数据库设计
@@ -300,9 +405,9 @@ completed -> reopened -> queued
| `report_collaboration_messages` | 对话原文镜像 | message_id、role、content、tool_calls_json、tool_call_id、agent_run_id、node_run_id、phase_id、visibility、metadata、command_id、created_at、completed_at |
| `report_collaboration_plans` | 候选及修订 | proposal_group_id、revision、status、graph_json、validation_json |
| `report_collaboration_runs` | 正式执行 | plan_id、status、lease_owner、lease_until、cancel_requested、next_event_seq |
| `report_collaboration_node_runs` | 节点尝试 | node_id、attempt、status、contract_json、agent_snapshot、error、started_at、ended_at |
| `report_collaboration_node_runs` | 节点尝试/ledger task | node_id、attempt、`node_run_id=${node_id}-attempt{attempt}`、status、contract_json、input_artifact_ids、command_overlay_revision、agent_snapshot、error、started_at、ended_at |
| `report_collaboration_agent_runs` | 实际团队成员实例 | agent_run_id、node_run_id、phase_id、role、display_name、avatar_key、status、agent_snapshot、started_at、ended_at |
| `report_collaboration_artifacts` | 成果物 | artifact_type、producer_node_run_id、schema_version、content_json、validation_status |
| `report_collaboration_artifacts` | 成果物及血缘 | artifact_type、producer_node_run_id、requirement_revision、schema_version、content_json、validation_status、superseded_by |
| `report_collaboration_events` | durable SSE | run_id、seq、event_type、payload、created_at |
| `report_collaboration_commands` | 用户命令 | command_id、idempotency_key、intent、scope、status、expected_revision |
| `report_collaboration_report_versions` | 报告版本 | version、parent_version_id、markdown、source_index、change_summary |
@@ -316,6 +421,7 @@ completed -> reopened -> queued
- 事件 seq 在 run 行内原子分配。
- 用户消息、协调者消息、成员 TeamSay、流式回复、工具调用和工具返回使用稳定 `message_id` 与 timeline/event seq;同一 `agentRunId` 内严格保持因果顺序,保证 `MessageList` 能以原生消息重建全过程。
- 并行成员的消息分别归属 `agent_run_id`,同一事件流仍全量持久化;前端选择标签只能过滤/聚焦,不能影响事件写入或消息顺序。
- 成员状态的 event 投影使用 `session.snapshot` 中的增量 `agent_runs[]` 合并;不可自定义未被前端处理的 `agent_run.*` 事件。
- 删除 session 显式删除子资源,不依赖 SQLite cascade pragma。
- 写事务在 commit 前完成必要序列化。
- 不使用跨连接 post-commit refresh。
@@ -346,6 +452,7 @@ GET /api/report-collaboration/runs/{run_id}/events?after_seq={seq}
GET /api/report-collaboration/runs/{run_id}/stream?after_seq={seq}
POST /api/report-collaboration/runs/{run_id}/commands
POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/confirm
POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/cancel
POST /api/report-collaboration/runs/{run_id}/cancel
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
@@ -357,10 +464,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
POST /api/report-collaboration/sessions/{session_id}/report-versions/{id}/restore
```
写请求必须支持:
`GET /sessions/{session_id}` 必须返回完整 `SessionSnapshot`:
- `idempotencyKey`
- `expectedRevision`
```text
session / messages / plans / run / nodes / agent_runs / commands /
report_versions / last_seq
```
写请求必须读取并回显:
- `X-Idempotency-Key`(服务端唯一去重键;用户消息镜像写入 `metadata.idempotency_key`)
- `X-Expected-Revision`(陈旧修改返回 409,不静默覆盖)
计划请求和 run 请求只创建 durable command/job,不在请求生命周期中等待完整模型输出。
@@ -437,10 +551,11 @@ heartbeat
- 历史与实时使用相同 envelope。
- 支持 `Last-Event-ID` 和 `after_seq`。
- token delta 可 live-only 合帧,但需周期性保存可恢复 checkpoint。
- 不能每 token 写数据库。
- 高频 token/tool 参数增量按 50~250ms 或大小阈值合帧为**已持久化** `message.delta` / `tool_call.delta` / `tool_result.delta`;不能每 token 写数据库,也不能仅 live-only 后丢失重放。
- 每一批增量与消息最新正文同事务写入,再向 Live Hub 发布;断线时能通过 `after_seq` 重放,快照也能读到最新 checkpoint。
- 状态、成果、命令和报告版本必须 durable。
- 读取事件暂时失败时保持连接并发送可恢复 warning。
- `session.snapshot` 可以携带完整快照,也可以携带 `run`、`nodes`、`agent_runs` 的增量集合;成员状态变化必须用此事件投影。
`message.*` / `team_message.*` 的 `data` 必须携带 LangChain 兼容的消息字段和归属,而非仅有摘要:
@@ -466,26 +581,33 @@ heartbeat
工具调用先在同一条 `ai` 消息的 `tool_calls` 上创建/追加参数 delta;返回值随后以 `type: "tool"`、相同 `tool_call_id` 的独立消息传输。允许用户查看的正文、参数和返回必须保持原文以及原始增量边界;只有统一脱敏器标记的字段可替换,替换理由写入 metadata。每个成员在开始生成时先发送 `message.created`,因此即使首 token 尚未到达,前端也能立即显示该成员正在回复,而非静默等待。
`reasoning_delta` 仅可承载经策略批准的、面向用户的工作说明(如“正在比对统计口径”),不允许桥接模型隐藏推理链、系统 prompt 或内部审稿思路;如模型未提供安全的公开工作说明,省略字段,过程可见性由 TeamSay、工具步骤、来源与普通 assistant 文本承担。
---
## 8. 分步实施计划
工单状态见 §0。本节正文仍是验收标准,不因部分完成而删减。
### BE-0:AgentScope 兼容性尖峰
**状态:条件完成(`RC-BE-000`)。** 事件适配与 dict/真实事件单测已有;真实双模型流式与 `uv.lock` 仍属后续 POC。20 项标注评测与 20 次固定任务统计已在 `RC-BE-018` 落地。
先写实验和测试,不接前端,不建正式业务表。
验证:
1. 当前 Python 3.12 与稳定 AgentScope 2.x 兼容。
2. 固定精确版本和 commit,不使用浮动 `main`。
1. 当前 Python 3.12 与候选 `agentscope==2.0.5` 兼容;POC 通过后才将精确版本写入 lock,不能使用浮动 `main`。
2. 验证单任务 worker 所需的 Agent、Tool、Event、Permission API;Agent Team 的自主调度与 Session SSE 不属于本功能的正确性依赖。
3. `uv lock` 后检查 Pydantic、SQLAlchemy、FastAPI、OpenAI SDK 冲突。
4. 使用当前至少两个模型完成流式回复。
5. leader + 2 workers 完成任务分配、TeamSay、并发和结构化输出。
5. `TaskLedger` 分派 2 个并行 worker,完成可见 TeamSay、并发和结构化输出。
6. 主动取消、超时、工具异常和成员异常行为可控。
7. AgentScope Event 可稳定关联 agent、task、message、tool call,并能一对一写出 LangChain 兼容的 assistant/tool 消息。
8. 内存状态丢失后可由 DeerFlow task ledger/artifact 重建。
9. 连续运行 20 次固定任务,统计早停、空输出、非法 JSON、事件缺失。
10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID。
10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID,并能按前端既定 `phase_id` / `node_run_id` 聚焦。
11. 人为注入流式中断、未闭合 tool call、等待用户输入与恢复场景;adapter 必须补发终态 tool result,不能留下永久 loading 的工具步骤。
交付:
@@ -502,6 +624,8 @@ Go 条件:
### BE-1:配置、依赖和总开关
**状态:完成(`RC-BE-002`)。** `enabled` / `worker_enabled` 默认 `false`。
新增 `config.yaml -> report_collaboration`:
```text
@@ -538,6 +662,8 @@ cost_budget
### BE-2:领域合同和状态机
**状态:完成(`RC-BE-001`)。**
完成 Pydantic 合同:
- requirement。
@@ -560,6 +686,8 @@ cost_budget
### BE-3:迁移和持久化 Store
**状态:完成(`RC-BE-003`)。** 表前缀 `report_collaboration_*`,迁移 `20260905_01`。
任务:
1. 创建数据库迁移。
@@ -579,15 +707,19 @@ cost_budget
### BE-4:会话、消息和计划 API 骨架
**状态:骨架完成(`RC-BE-004`)+ SSE 已挂载(`RC-BE-011`)+ 改写/恢复已挂载(`RC-BE-015`)。** REST 会话/消息/方案/run 已挂载;`GET /runs/{id}/stream` 为 durable SSE。
任务:
- session CRUD 和用户隔离。
- `GET /sessions/{id}` 一次返回前端 `SessionSnapshot` 的全部字段;run 前的澄清/规划阶段由该快照在 2.5 秒轮询内可见。
- message 持久化:完整保存用户可见的 assistant、TeamSay、`tool_calls` 与 `tool` 返回,并保留 `agent_run_id`/`node_run_id`/`phase_id`;不只保存阶段摘要。
- plan request 入队。
- plan list/select。
- run create/query。
- OpenAPI 响应模型。
- 路由注册顺序和统一错误模型。
- 按 `frontend-web/src/report-collaboration/api/types.ts` 建立契约测试:字段名保持 snake_case,枚举、事件类型、命令确认/取消端点和 revision 头不可漂移。
安全:
@@ -597,14 +729,17 @@ cost_budget
### BE-5:需求解析和澄清
`RequirementResolver`:
**状态:完成(`RC-BE-005`)。** 规则路径;尚未接规划用 LLM。澄清完成不会自动 `plan-requests`。
`IntentRouterAgent`(规划前模式)与 `RequirementResolver`:
1. 读取会话消息和最新 requirement snapshot。
2. 提取主题、目标、受众、范围、角度、模板和来源限制。
3. 判断是否缺少影响方案选择的必要信息。
4. 必要时产生一个或多个独立 clarification。
5. 用户回答后创建新 requirement revision。
6. 达到澄清上限后使用明确假设继续,不能无限追问。
2. `IntentRouterAgent` 输出结构化需求提取、澄清判定或“可直接规划”判定;不得自行建图或启动任务。
3. `RequirementResolver` 校验并写入主题、目标、受众、范围、角度、模板和来源限制。
4. 判断是否缺少真正影响方案选择的必要信息。
5. 必要时产生一个或多个独立 clarification。
6. 用户回答后创建新 requirement revision。
7. 达到澄清上限后使用明确假设继续,不能无限追问。
验收:
@@ -614,6 +749,8 @@ cost_budget
### BE-6:智能体目录、筛选和候选计划
**状态:完成(`RC-BE-006`)。** 选择器为规则重排,未接规划模型。`POST /plan-requests` 在 `worker_enabled=false` 时于请求内同步生成方案(非 LLM)。
五阶段:
1. `AgentCatalogProjection`:读取用户可见且启用的 agent、技能和工具元数据。
@@ -638,6 +775,8 @@ cost_budget
### BE-7:AgentScope 模型适配
**状态:完成(`RC-BE-007`)。** 适配器鸭子类型对齐 `ChatModelBase.__call__`;运行前冻结快照。真实 Provider 冒烟仍属 `RC-BE-009` / `017`。
目标:让 AgentScope 使用 DeerFlow 配置的模型,不再建立第二套 Provider 配置。
任务:
@@ -657,13 +796,15 @@ cost_budget
### BE-8:工具与权限适配
**状态:完成(`RC-BE-008`)。**
第一版允许:
- Web 检索和正文获取。
- 用户有权限的知识库/Wiki 检索。
- 明确批准的只读技能/MCP。
- 成果物板读写工具。
- AgentScope 团队通信与任务工具。
- 用户可见交接(TeamSay)与受控成果物/澄清工具。
第一版默认禁止:
@@ -678,7 +819,7 @@ cost_budget
- Researcher 不能发布报告。
- Writer 不能把未核验证据改成已核验。
- Reviewer 只能输出 ReviewDecision,不能覆盖报告。
- Coordinator 只能通过受控工具创建任务、分派、等待、返工和建议完成。
- Coordinator 只能提出受控的计划、澄清与修复建议;实际创建任务、分派、等待、返工和完成由 `TaskLedger` 服务执行。
- 所有成员获得本次 run 的最小权限快照。
验收:
@@ -688,7 +829,9 @@ cost_budget
- 网页 prompt injection 不能提升权限。
- AgentScope 工具异常被转换为 node/tool event,不泄露内部堆栈给用户。
### BE-9:AgentScope Team 运行器
### BE-9:受控任务成员运行器(不采用自治 Team 调度)
**状态:完成(`RC-BE-009` 脚本内核 + `RC-BE-013` 质量角色内核)。** `TaskLedger` + `WaveExecutor` + `QualityRoleKernel`。真实 AgentScope `reply_stream` / web_search 仍待接。
基础角色:
@@ -706,18 +849,21 @@ cost_budget
- 计划决定 Researcher/Analyst 数量和角度。
- 服务器限制团队人数、并发、attempt、返工和预算。
- 每个成员上下文只包含当前任务、需求快照和必要上游成果。
- Coordinator 不直接操作数据库,只调用 TaskLedger/ArtifactBoard 工具。
- `Coordinator` 只接收结构化成果摘要、质量失败与用户命令影响,输出候选计划/修复建议;它不拥有自由消息总线或数据库权限。
- `TaskLedger` 依据已验证成果、预算与并发限额逐波创建 AgentScope worker;AgentScope 不选择下一节点,也不决定任务完成。
- 每个 worker 只能调用 `submit_artifact`、`request_clarification`、`request_repair`、允许的资料工具和可见 TeamSay;所有改变运行状态的调用都由服务端重新校验。
- 任务完成由 QualityGate 判定,不接受文本声明。
事件映射:
- AgentScope `REPLY_*` → 以原始文本 chunk 创建/更新协调者或成员的稳定 assistant 消息,保留 `agentRunId`;不得等待 reply 完成后再合成为进度摘要。
- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序。
- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序;它不会改变 ledger 状态。
- `MODEL_CALL_*` → 内部遥测,默认不下发原始 prompt。
- `TOOL_CALL_*` → 原生 `ai.tool_calls` 参数事件;对应工具返回 → 原生 `tool` 消息和相同 `tool_call_id`。两者在 SSE 和持久化中一一对应,供现有 `MessageGroup` 直接渲染。
- team task/update → node/task ledger 状态。
- `TaskLedger` 的分派/校验/重试/取消结果 → node/task ledger 状态;AgentScope 仅上报可验证的 TaskResult。
- 成果工具 → artifact created/validated/rejected。
- 每个用户可见阶段开始、进展、完成、等待、返工和失败都必须同步产生稳定的消息或状态事件,供左侧 DeerFlow `MessageList` 按原始消息顺序展示;不能要求前端读取节点结果后自行拼装过程,也不能用聚合卡替换 AgentScope 的实时可见消息。
- agent run 的开始、等待输入、完成、失败和取消,紧邻节点状态事件发 `session.snapshot` 增量,至少包括相应的 `agent_runs[]` 和当前 `run.current_phase`。
验收:
@@ -727,6 +873,8 @@ cost_budget
### BE-10:成果物板与质量门
**状态:完成(`RC-BE-010`)。** `QualityGate` 为确定性规则(无二次模型调用)。模板冻结快照与正式 `ReportVersion` 落库已在 `RC-BE-015` 落地。
`ArtifactBoard`:
- 按 run/node/attempt/type 保存成果。
@@ -736,21 +884,26 @@ cost_budget
`QualityGate`:
- EvidenceBundle:来源数量、日期、URL/文档 ID、摘录和去重。
- VerificationResult:支持与反对证据、冲突、时效。
- AngleAnalysis:结论必须引用验证过的 evidence ID。
- ReportSectionDraft:关键事实和数字必须有引用。
- ReviewDecision:问题必须有目标、严重度和修复要求。
- ReportVersion:模板覆盖、引用完整、章节一致、正式版本已持久化。
- `CoverageMatrix`:把需求中的每个必选角度/关键问题映射到证据目标、分析节点和模板章节;计划和最终版本均不可漏项。
- `EvidenceBundle`:按**主张级**保存 `claim_id`、来源 URL/文档 ID、发布/获取时间、原文摘录、事实或推断标签、适用范围、数值单位/口径、可信度与去重结果。
- `VerificationResult`:对关键主张给出支持与反对证据、冲突、时效、来源独立性和可用结论;数字及重要事实必须经此门。
- `AngleAnalysis`:结论只能引用验证过的 evidence ID,必须分别标示事实、推断、假设、局限和资料缺口。
- `ReportSectionDraft`:关键事实和数字必须有 evidence ID;章节的主张与引用一一可追溯。
- `CitationTemplateLinter`:校验模板章节、必答问题、来源格式、数字/日期/实体一致性、不可用或过期来源和未引用的关键事实。
- `ReviewDecision`:独立于 Writer 上下文运行,问题必须有目标、严重度、证据和修复要求;不能笼统地要求“整体再优化”。
- `ReportVersion`:覆盖矩阵、引用、模板、章节衔接、结论一致性和正式版本持久化全部通过才可创建。
验收:
- 无成果、空成果和不合 Schema 的成果不能完成节点。
- 未验证来源不能直接进入最终事实陈述。
- Reviewer 的返工目标可映射到具体节点和章节。
- 同一事实出现冲突或同一数字缺少口径时,QualityGate 必须拒绝或要求定向补证,不能由 Writer 擅自选择一个答案。
### BE-11:后台 Dispatcher、租约和恢复
**状态:完成(`RC-BE-012`)。** `worker_enabled` 默认仍为 false:Gateway lifespan 只有 `enabled` **且** `worker_enabled` 同时为真才 `dispatcher.start()`。测试走 `dispatch_once()` / `executor.execute()`,不要依赖后台循环。
复用现有成熟模式:
- active-dedupe key。
@@ -780,6 +933,8 @@ cost_budget
### BE-12:SSE 和 Live Hub
**状态:完成(`RC-BE-011`)。** `ConversationReplica` 已实现协议镜像;产品 SSE 经 `execution/live_hub.py` 挂载。报告正式版本写入已在 `RC-BE-015` 落地。
任务:
- 实现 event repository。
@@ -790,6 +945,7 @@ cost_budget
- 将高频 token delta 合帧。
- 建立 `ConversationReplica`:将 AgentScope 用户可见消息、TeamSay、assistant delta、`tool_calls`、`tool` 返回、协助请求、报告文件和领域状态写成可重放的统一消息事件。它是协议镜像而不是摘要生成器。
- 对话镜像完整保留用户有权查看的 TeamSay、原始工具参数和返回;不包含 chain-of-thought、系统/内部 prompt、密钥、权限不可见内容和异常堆栈。脱敏在入库/下发前一次执行,不能由前端自行推断。
- 对前端已支持的 `reasoning_delta`,仅推送由本系统生成或模型明确允许公开的短工作说明;它不等同于原始思维链,也不得用作质量门、调度或审计的唯一依据。
- `agentRunId` 是并行消息流的稳定归属。Live Hub 始终发布所有成员事件;前端标签切换只在本地选择消息,不启动第二条运行流,也不取消未聚焦成员的 delta。
验收:
@@ -799,10 +955,15 @@ cost_budget
- 终态前报告正式版本已写入并可读。
- SSE 断开不取消后台 run。
- 仅依靠会话快照和事件重放即可重建每个成员的完整原生消息、工具过程和左侧聚焦视图,不需要调用节点结果接口。
- 在模型/工具中断、取消和接管时,所有已开始的 tool call 都有对应的 completed tool result(成功、失败或 interrupted),不会让复用的 `MessageList` 永久显示执行中。
### BE-13:全过程命令与影响分析
`IntentRouter` 输入:
**状态:完成(`RC-BE-014`)。** `app/report_collaboration/interventions/` 已实现运行期 IntentRouterAgent、ImpactAnalyzer 和 RuntimeCommandService;SQL/memory store 通过 `patch_command(..., expected_statuses=...)` 对命令执行权做 CAS。置信度阈值由 `report_collaboration.intent_confirmation_threshold` 配置(默认 `0.8`)。
实现边界:本票负责识别、授权、影响计算、确认、定向失效、overlay 和安全重排队。`rewrite_section` / `polish_report` 已能严格保留研究链并只返工写作/审校链;replacement candidate、正式版本 apply/restore 已在 `RC-BE-015` 落地。`add_angle` 优先使用用户定向的研究分支,否则按冻结计划顺序确定性选择一个研究分支,只接入其必要下游;无研究支路立即 `rejected`。`rerun_node` 没有合法目标时不得扩成整图。需求补丁只在返工成功后写入。卡住的 `executing` 可在接管或租约超时后收回。`command.cancelled` 进入 SSE。运行中 `POST /sessions/{id}/messages` 返回 409。
`IntentRouterAgent`(运行中干预模式)输入:
- 用户文本。
- session/run 状态。
@@ -810,7 +971,7 @@ cost_budget
- 当前选中节点或章节上下文。
- 后端动作白名单。
输出:
先由规则层处理明确的回答、取消和节点/章节显式指令;其余由 `IntentRouterAgent` 输出:
```text
intent
@@ -823,6 +984,8 @@ cost_level
reason
```
模型输出必须通过 Pydantic 校验,并经服务端 target 授权与置信度/成本闸门后才创建可执行 command;`IntentRouterAgent` 不可调用分派、取消、成果写入或报告版本工具。
命令类型:
- `answer_question`
@@ -839,11 +1002,15 @@ reason
`ImpactAnalyzer`:
- 重新检索:失效指定 EvidenceBundle 和引用它的下游。
- 重新分析:保留 evidence,失效分析和对应章节。
- 章节重写:保留研究/分析,只生成 replacement candidate。
- 语气润色:创建新报告版本,不失效事实成果。
- 全局改向:新建 plan revision,确认后创建 run branch。
- `answer_question`:只解析相应的 clarification;仅解锁依赖该答案的节点,其他并行节点继续。
- `research_again`:为指定节点/角度追加或替换 EvidenceBundle,失效其验证、分析、综合和受影响章节,不误伤无关角度。
- `reanalyze`:保留 validated evidence,只失效指定分析、综合、写作和审校分支。
- `add_angle`:在当前冻结计划中选择一个明确目标或确定性的研究支路承接新角度,再只失效其必要下游;无可用支路时拒绝执行。运行中不暗改前端已选择的 DAG,真正新增画布节点只发生在后续重新规划产生的新 plan revision 中。
- `rerun_node`:以新 attempt 重跑指定节点及必要下游,旧结果保留为 superseded。
- `rewrite_section`:保留研究/分析,只生成 replacement candidate 和该章节审校;用户 apply 后才产生新版本。
- `polish_report`:不重检索;在当前正式版本上创建新版本,并再次执行模板/引用 lint。
- `update_requirements` / `replan`:均先展示整图影响与高成本确认;前者写入新的 requirement revision 后重开冻结计划,后者重开当前计划全部节点。生成新候选 plan/run branch 的交互留在后续规划版本能力,不能在旧 run 的 SSE 内偷偷切换 run。
- `ask_report`:只读取当前已验证成果或报告版本,不改变任务图。
Safe-point:
@@ -851,15 +1018,19 @@ Safe-point:
- 不可立即取消的 attempt 继续到边界,command 状态为 `accepted_waiting_boundary`。
- 晚到输出可保存但标记 superseded,不能进入下游。
- 每条 command 有分类、影响范围、状态和结果。
- command overlay 仅在模型调用之间、工具返回后或节点提交成果前生效;在不可中断工具期间不能伪称已经完成修改。
验收:
- 低置信度“改一下”必须追问。
- 相同幂等键不重复创建返工任务。
- 运行中命令不能造成 completed/cancelled 状态竞争。
- 用户针对画布/成员卡的定向修改只影响解析出的节点和成果血缘,不能被协调者扩大为整场重跑。
### BE-14:报告模板、写作、审稿和版本
**状态:完成(`RC-BE-015`)。** 改写 / apply / restore 已挂载;正式版本在质量门通过后落库。
复用 `/api/report-structures`,run 开始时冻结模板快照:
- 章节层级。
@@ -871,25 +1042,25 @@ Safe-point:
写作规则:
- Writer 只能使用 validated artifacts。
- 关键判断绑定 evidence ID。
- 无法证实的内容明确标为分析、假设或局限。
- 每章获得确定 evidence set。
- 完整报告执行引用、模板、衔接和结论一致性检查。
- Reviewer 输出结构化 ReviewDecision。
- Reviser 只修改指定问题。
- Writer 只能使用冻结模板、`CoverageMatrix` 和 validated artifacts,禁止携带 Web/知识库工具或原始检索上下文进入写作提示词。
- 关键判断绑定 evidence ID;无法证实的内容明确标为分析、假设或局限。
- 每章获得确定 evidence set、字数目标和必答问题,输出章节草稿及主张—引用映射。
- 完整报告先执行 CitationTemplateLinter,再由独立 Reviewer 进行事实、逻辑、受众适配和结论审校。
- Reviewer 输出结构化 ReviewDecision;Reviser 只修改指定问题,并由同一质量门复验。
- 达到返工上限后报告未解决问题,不无限循环。
版本规则:
- 流式 Markdown 是临时草稿,不覆盖当前正式版本。
- 正式版本落库并确认后 run 才能 completed。
- 正式版本须在所有质量门通过后落库,随后才发 `report.version.created` 并令 run completed。
- 章节改写生成 replacement candidate。
- 用户 apply 后创建新版本。
- restore 也创建新头版本,不破坏历史。
### BE-15:安全、审计和预算
**状态:完成(`RC-BE-016`)。** 快照与 SSE 走统一脱敏;资源 owner、跨会话幂等、预算上限与审计已落地。
- 所有资源校验 owner 或明确授权。
- agent snapshot 移除密钥和不可见 prompt。
- 工具参数/结果完整镜像到用户消息流;只有密钥、令牌、隐私字段、越权内容和明确的异常堆栈由统一脱敏器替换并记录原因。不能以大小截断或摘要替代用户有权查看的返回;超大返回以可重放 chunk 传输和按需折叠展示,正文仍可完整读取。
@@ -897,10 +1068,12 @@ Safe-point:
- 每用户 active run 上限。
- 每 run 团队、并发、检索、来源正文、模型调用、token、费用和总时长上限。
- 审计 agent 选择、工具调用、来源、用户命令、人工确认和报告版本。
- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型推理 token;这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。
- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型隐藏推理 token;前端可显示的 `reasoning_delta` 必须是经过独立策略筛选的公开工作说明。这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。
### BE-16:测试与质量评测
**状态:完成(`RC-BE-018`)。** 合同 / 适配器 / 规划 / 需求 / SSE / lease / 20 项标注评测集已有。打分器不调用模型;金标必须过门,会商风失败实例必须可复现。
单元测试:
- Pydantic 合同。
@@ -912,6 +1085,8 @@ Safe-point:
- AgentScope event adapter。
- 同一 `message_id` 的 token delta、同一 `tool_call_id` 的参数/结果 delta 以及脱敏字段替换;断线重放后消息正文不得被摘要化或重复。
- 两个 `agentRunId` 并行交错输出、其中一员等待用户协助时,消息归属、工具配对、状态和命令目标均正确。
- `session.snapshot` 增量 `agent_runs[]` 能驱动前端阶段卡从 pending → running → waiting_input → terminal;`node_run_id` 与画布节点前缀匹配。
- 流式中断后每个已创建 tool call 都有终态 tool result,`MessageList` 不留下旋转步骤。
集成测试:
@@ -925,7 +1100,7 @@ Safe-point:
真实 AgentScope 冒烟:
- 至少两个已配置模型。
- leader + 多 worker。
- ledger 驱动的多 worker(不依赖 AgentScope Team 自动分派)。
- 并发工具和 TeamSay。
- 取消、超时、异常成员和结果修复。
@@ -934,19 +1109,24 @@ Safe-point:
- 不少于 20 个真实任务。
- 覆盖政策影响、市场趋势、企业研究、事件研判、对比分析。
- 每个任务记录必选角度、关键事实、禁止编造项、模板和评审规则。
- 与现有多智能体会商进行盲评。
- 指标:事实准确、来源质量、角度覆盖、逻辑一致、格式遵循、可修改性、耗时和成本。
- 与现有多智能体会商进行盲评,并保留每项失败的可复现实例。
- 指标:事实准确、来源质量、关键主张引用覆盖、角度覆盖、逻辑一致、模板遵循、定向修改正确率、耗时和成本。
- 上线前为每个任务标注关键主张/数字、可接受来源、必选角度和模板约束;不能只以主观“文章更像报告”判定成功。
---
## 9. 首个纵向 POC
POC-1 的规则路径已通(三策略差异方案、select ≠ start)。**POC-2 脚本闭环已通**(研究员 → 撰写员,协调者建议不调度)。**POC-3 质量角色已通**(核验 / 分析 / 审稿定向返工,确定性内核)。真实检索与 AgentScope 成员仍待接。**POC-4 未开始**。
固定任务:
> 撰写一份新能源汽车主要品牌市场趋势分析报告,覆盖 2024 年至今的市场份额、价格策略、技术路线、海外进展、政策环境和未来 12~24 个月风险与机会,面向企业战略决策人员,要求所有关键数字给出来源。
### POC-1:候选计划
**状态:规则路径已通(`RC-BE-006`)。** 三策略差异方案、select ≠ start;尚未接规划 LLM。
- 生成 3 种真实有差异的方案。
- 每个方案只引用有效 agent/tool/template。
- 每个节点有职责、输入、输出和验收条件。
@@ -954,6 +1134,8 @@ Safe-point:
### POC-2:三角色闭环
**状态:脚本路径已通(`RC-BE-009`),SSE 重放已挂载(`RC-BE-011`)。** 真实 Web 检索仍待工具实装接入运行器。
角色:Coordinator、Researcher、Writer。
- 完成真实 Web 检索。
@@ -964,6 +1146,8 @@ Safe-point:
### POC-3:质量角色
**状态:完成(`RC-BE-010` 质检 + `RC-BE-013` 受控成员 + `RC-BE-017` 真实内核)。** `QualityRoleKernel` 跑通 Verifier / Analyst / Writer / Reviewer / Reviser;过期与跨包冲突写入 `VerificationResult`;缺失角度只重开检索链;审稿失败只重开目标写作节点。生产 worker 使用 `AgentScopeMemberKernel`。
加入 Verifier、Analyst、Reviewer、Reviser。
- 过期资料被识别。
@@ -973,6 +1157,8 @@ Safe-point:
### POC-4:全过程干预
**状态:命令与 safe-point 路径完成(`RC-BE-014`)。** 增加角度、指定角度重检索、章节重写/润色的定向影响已覆盖;运行中旧 attempt 在边界后 supersede,晚到成果不能进入下游。正式报告 replacement/version 展示已在 `RC-BE-015` 落地。
必须演示:
1. “增加供应链与原材料风险角度”。
@@ -986,28 +1172,36 @@ Safe-point:
## 10. 后端开发顺序
1. `RC-BE-000`:AgentScope 版本与兼容性尖峰。
已完成:
1. `RC-BE-000`:AgentScope 版本与兼容性尖峰(条件 Go;未写入 `uv.lock`)。
2. `RC-BE-001`:领域合同和状态机。
3. `RC-BE-002`:配置、依赖和 feature flag。
4. `RC-BE-003`:数据库迁移和 stores。
5. `RC-BE-004`:session/message/plan API。
5. `RC-BE-004`:session/message/plan API(改写 / 恢复见 `015`)。
6. `RC-BE-005`:需求解析与澄清。
7. `RC-BE-006`:agent catalog、selector、plan assembler/validator。
8. `RC-BE-007`:AgentScope model adapter。
9. `RC-BE-008`:tool/permission adapter。
10. `RC-BE-009`:三角色 team POC。
11. `RC-BE-010`:task ledger、artifact board、quality gate。
10. `RC-BE-009`:三角色 team POC(脚本内核 + TaskLedger 波次)。
11. `RC-BE-010`:artifact board 主张级质量门(Coverage / 核验 / 审稿)。
12. `RC-BE-011`:durable event、原生消息镜像、SSE 和 live hub。
13. `RC-BE-012`:dispatcher、executor、lease、cancel、recovery。
14. `RC-BE-013`:Verifier/Analyst/Reviewer/Reviser。
15. `RC-BE-014`:intent router、impact analyzer、safe-point commands。
16. `RC-BE-015`:report structures、report versions、rewrite candidates。
17. `RC-BE-016`:安全、审计、预算、监控。
18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试。
19. `RC-BE-018`:20 项质量评测和参数调优。
15. `RC-BE-014`:intent router、impact analyzer、safe-point commands(已完成)。
16. `RC-BE-015`:report structures、report versions、rewrite candidates(已完成)。
17. `RC-BE-016`:安全、审计、预算、监控(已完成)。
18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试(已完成)。
19. `RC-BE-018`:20 项质量评测和参数调优(已完成)。
下一票起:
20. `RC-BE-019`:灰度与离线发布接入。
前后端共同依赖:在 `RC-BE-001` 后冻结 OpenAPI、event envelope 和共享枚举,前端再开始真实 API 层开发。
编号说明:§8 的 `BE-n` 与上表 `RC-BE-00n` 在 **008 之后不完全一一对应**(例如 §8 `BE-11` dispatcher ≈ `RC-BE-012`,`BE-12` SSE ≈ `RC-BE-011`)。排期与代码以 `RC-BE-*` 为准。
前后端共同依赖:OpenAPI / event envelope / 共享枚举已按 `frontend-web/src/report-collaboration/api/types.ts` 冻结;前端页面与 mock 已完成。真实 SSE 可联调 `GET /runs/{id}/stream`(`RC-BE-011`);领取/恢复见 `RC-BE-012`;质量角色见 `RC-BE-013`;运行中自然语言命令见 `RC-BE-014`。正式报告改写候选与版本 apply/restore 已在 `RC-BE-015` 挂载。生产默认仍不挂后台循环(`worker_enabled=false`)。
---
@@ -1082,7 +1276,7 @@ TeamSay 不作为事实来源;报告事实只能引用 validated EvidenceBundl
后端只有同时满足以下条件才算完成:
1. 使用 AgentScope 2.0 Core 组织真实 leader-worker 团队。
1. 使用 AgentScope 2.0 Core 执行真实的受控任务成员,且由 DeerFlow `TaskLedger` 而非自治 Team 调度协作。
2. 不调用旧 Workflow Studio DAG scheduler。
3. 不改变现有多智能体会商行为。
4. DeerFlow 数据库是状态唯一事实源。
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
+6
View File
@@ -60,3 +60,9 @@
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
# 仅 .env.development 默认 true;其余环境保持 false。
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
# 内网环境保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
+3
View File
@@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 线上保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# SIT 环境保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
+1
View File
@@ -11,6 +11,7 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
+2
View File
@@ -45,6 +45,7 @@ import {
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
const PUBLIC_EMBED_PREFIXES = [
"/share/",
@@ -203,6 +204,7 @@ export function App() {
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
height="2591" viewBox="0 0 2064 2591">
<path fill-rule="nonzero"
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+6 -1
View File
@@ -3,6 +3,7 @@
import { ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import deerMaskUrl from "@/assets/deer.svg";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy";
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
/>
</div>
<FlickeringGrid
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
style={{
WebkitMaskImage: `url("${deerMaskUrl}")`,
maskImage: `url("${deerMaskUrl}")`,
}}
squareSize={4}
gridGap={4}
color={"white"}
+13 -1
View File
@@ -56,7 +56,7 @@ type PageSidebarProps = {
headerHidden?: boolean;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
menuItemContainsPath(item, pathname);
@@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -585,6 +591,12 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
* - 全量模式 Header 右上角。
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
*/
export function LayoutModeSwitcher({
tone,
fallback = "compact",
navigateTo = "/page/workspace/chats/new",
}: {
tone: AppearanceControlTone;
fallback?: "full" | "compact";
navigateTo?: string | null;
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
@@ -262,7 +259,7 @@ export function LayoutModeSwitcher({
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
const active = current === option.id;
const active = mode === option.id;
return (
<DropdownMenuItem
key={option.id}
@@ -85,7 +85,7 @@ import {
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins } from "@/core/streamdown";
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -93,6 +93,7 @@ import {
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
downloadSvgElementAsPng,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
@@ -428,15 +429,15 @@ export function ArtifactFileDetail({
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -1770,10 +1771,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
@@ -1791,7 +1792,7 @@ export function ArtifactFileDetail({
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
@@ -2011,6 +2012,9 @@ export function ArtifactFilePreview({
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const markdownPreviewRef = useRef<HTMLDivElement>(null);
const mermaidChartRef = useRef<HTMLDivElement>(null);
useStreamdownMermaidPngDownload(markdownPreviewRef);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
@@ -2135,6 +2139,7 @@ export function ArtifactFilePreview({
if (language === "markdown") {
return (
<div
ref={markdownPreviewRef}
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
@@ -2199,10 +2204,42 @@ export function ArtifactFilePreview({
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
<div className="relative mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 pt-12 shadow-sm">
<div className="absolute right-3 top-3 flex items-center gap-1">
<button
type="button"
className="text-muted-foreground hover:text-foreground rounded-md p-1"
title="下载图片"
onClick={() => {
const svg = mermaidChartRef.current?.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) {
toast.error("流程图尚未渲染完成,请稍后再下载");
return;
}
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
console.error("Failed to download mermaid image:", error);
toast.error(error instanceof Error ? error.message : "下载流程图失败");
});
}}
>
<DownloadIcon className="size-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-foreground rounded-md p-1"
title="新窗口打开"
onClick={() => {
void openMermaidSvgInNewTab(content ?? "").catch((error) => {
console.error("Failed to open mermaid preview:", error);
toast.error(error instanceof Error ? error.message : "打开流程图失败");
});
}}
>
<ExternalLinkIcon className="size-3.5" />
</button>
</div>
<div ref={mermaidChartRef} dangerouslySetInnerHTML={{ __html: mermaidSvg }} />
</div>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
@@ -25,7 +25,6 @@ import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -130,9 +129,8 @@ const ChatBox: React.FC<{
// batches through DeerFlow's backend APIs.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
(thread.values.messages ?? []).some((message) =>
isSingleRoundRagMessage(message),
),
[thread.values.messages],
);
@@ -1007,8 +1005,7 @@ function buildTurnReferenceBatches(
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
@@ -1059,7 +1056,7 @@ function findFrontendRagAssistant(
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
isSingleRoundRagMessage(message)
) {
return message;
}
@@ -6,13 +6,22 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding() {
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
const navigate = useNavigate();
const client = useQueryClient();
const version = useKnowledgeBaseProductVersion();
const landing = useOrdinaryKnowledgeLanding();
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion,
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
const assistant = location.pathname.includes("/knowledge/assistant");
return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
{isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
默认产品版本
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import type {
AnchorHTMLAttributes,
HTMLAttributes,
@@ -12,7 +12,7 @@ import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
@@ -57,7 +57,9 @@ export function MarkdownContent({
linkClassName,
onLinkClick,
}: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
@@ -138,7 +140,7 @@ export function MarkdownContent({
if (!content) return null;
return (
<div data-markdown-anchor-root className="contents">
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
@@ -7,8 +7,6 @@ import {
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
@@ -77,7 +75,6 @@ import { humanMessagePlugins } from '@/core/streamdown';
import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
type MultiRoundRagRound,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
@@ -90,6 +87,7 @@ import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
type EmbeddedPageContext = {
title?: string;
@@ -159,9 +157,9 @@ export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
questionMarkdown,
isLoading,
message,
questionMarkdown,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
@@ -178,9 +176,9 @@ export function MessageListItem({
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
@@ -330,12 +328,12 @@ export function MessageListItem({
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
@@ -380,12 +378,12 @@ function HumanMessageBlock({
messageId?: string;
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
filesList: ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: React.ReactNode;
deleteButton?: ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
@@ -629,9 +627,7 @@ function MessageContent_({
href={href}
{...props}
onReferenceClick={
isSingleRoundRag || isMultiRoundRag
? handleCitationClick
: undefined
isSingleRoundRag ? handleCitationClick : undefined
}
/>
);
@@ -658,7 +654,6 @@ function MessageContent_({
}),
[
handleCitationClick,
isMultiRoundRag,
isSingleRoundRag,
resolveArtifact,
threadId,
@@ -697,9 +692,12 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(rawContent ?? '');
}
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
@@ -734,7 +732,7 @@ function MessageContent_({
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
@@ -755,7 +753,11 @@ function MessageContent_({
// it exists server-side (real, non-optimistic id). Deleting it cascades to
// its answer on the backend.
const canDelete =
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
!!onMessageDeleted &&
!!message.id &&
!message.id.startsWith('opt-') &&
!isMultiRoundRag &&
!isSingleRoundRag;
return (
<HumanMessageBlock
messageId={message.id}
@@ -764,8 +766,10 @@ function MessageContent_({
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onEditResubmit={
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
}
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
deleteButton={
canDelete ? (
<DeleteMessageButton
@@ -785,14 +789,12 @@ function MessageContent_({
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
{isMultiRoundRag ? (
<MultiRoundRagAssistantBody
content={contentToDisplay}
displaySources={displaySources}
thinkingTrace={multiRoundTrace}
isLoading={isLoading}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
<AssistantDisplayContent
@@ -805,7 +807,7 @@ function MessageContent_({
/>
)}
</div>
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
@@ -820,7 +822,7 @@ function MessageContent_({
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
{(isSingleRoundRag || isMultiRoundRag) && (
{isSingleRoundRag && (
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
@@ -833,11 +835,9 @@ function MessageContent_({
}
/**
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
* 单轮 RAG 专属的「引用来源」折叠面板。
*
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
* 用户也能看到本次回答依据的资料。
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
*/
function FrontendRagSourcesPanel({
sources,
@@ -941,349 +941,6 @@ export function SingleRoundRagCitationDialog({
);
}
function MultiRoundRagContent({
trace,
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
onClick={() => setOpen((value) => !value)}
>
<div className="flex min-w-0 items-center gap-2">
{trace.isComplete ? (
<SearchIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">最终回答</div>
{content ? (
<AssistantDisplayContent
content={content}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={rehypePlugins}
components={components}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成最终回答…
</div>
)}
</section>
</div>
);
}
function MultiRoundRagRoundCard({
index,
round,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
const thoughtContent = useMemo(
() =>
applyInlineCitations(round.thought, displaySources, {
appendSummary: false,
}),
[displaySources, round.thought],
);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<MarkdownContent
content={thoughtContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</MultiRoundRagDetailSection>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
): string | undefined {
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
@@ -28,12 +28,12 @@ import {
hasToolCalls,
isHiddenFromUIMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import {
buildSkillDisplayMap,
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
@@ -136,6 +136,8 @@ export function MessageList({
onSetupReportStructureReject,
researchApprovalSlot,
taskReportImportSlot,
businessCardSlot,
speakerShell,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
@@ -176,6 +178,19 @@ export function MessageList({
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
*/
taskReportImportSlot?: (message: Message) => React.ReactNode;
/**
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
* 层定义,MessageList 不感知具体业务字段。
*/
businessCardSlot?: (message: Message) => React.ReactNode;
/**
* 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。
* 不传则问答页零变化。
*/
speakerShell?: (messages: Message[], children: ReactNode) => ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
@@ -300,23 +315,19 @@ export function MessageList({
}
return map;
}, [messages]);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
() => messages.some((message) => isSingleRoundRagMessage(message)),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
@@ -331,7 +342,7 @@ export function MessageList({
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
@@ -342,7 +353,7 @@ export function MessageList({
const msg = raw[i];
if (!msg || msg.type !== "ai" || !msg.id) continue;
const kwargs = msg.additional_kwargs;
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
if (!kwargs || kwargs.single_round_rag !== true) {
continue;
}
let sources: RuntimeDisplaySource[] = [];
@@ -353,19 +364,13 @@ export function MessageList({
if (prev.type === "human" || prev.type === "ai") break;
if (prev.type !== "tool") continue;
const prevKwargs = prev.additional_kwargs;
if (
!prevKwargs ||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
) {
if (!prevKwargs || prevKwargs.single_round_rag !== true) {
continue;
}
const results = extractFrontendRagResults(prev.content);
if (results && results.length > 0) {
const toolName = String(
(prev as { name?: unknown }).name ??
(prevKwargs.multi_round_rag === true
? "multi_round_rag_references"
: "knowledge_base_search"),
(prev as { name?: unknown }).name ?? "knowledge_base_search",
);
sources = results.map((item, index) =>
toRuntimeDisplaySource(item, index + 1, toolName),
@@ -381,6 +386,7 @@ export function MessageList({
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
@@ -536,7 +542,7 @@ export function MessageList({
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const items = group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
@@ -563,7 +569,12 @@ export function MessageList({
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
isLoading={
isMultiRoundRagMessage(msg)
? thread.isLoading &&
msg.id === messages[messages.length - 1]?.id
: messageIsLoading
}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -577,6 +588,14 @@ export function MessageList({
/>
);
});
if (speakerShell && group.type === "assistant") {
return (
<div key={group.id} className="w-full">
{speakerShell(group.messages, items)}
</div>
);
}
return items;
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@@ -692,6 +711,14 @@ export function MessageList({
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -786,13 +813,13 @@ export function MessageList({
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
);
}
results.push(
@@ -838,14 +865,14 @@ export function MessageList({
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
const processingBody = (
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -853,6 +880,11 @@ export function MessageList({
/>
</div>
);
return (
<div key={"group-" + group.id} className="w-full">
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
</div>
);
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
@@ -0,0 +1,472 @@
import {
BookOpenIcon,
ChevronDownIcon,
FileTextIcon,
Loader2Icon,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import { useState } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
} from "@/components/ai-elements/message";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { MessageResponseProps } from "@/components/ai-elements/message";
import type {
MultiRoundRagChatMessage,
MultiRoundRagRetrieval,
MultiRoundRagRound,
MultiRoundRagTrace,
} from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "./markdown-content";
export function MultiRoundRagMessageList({
className,
messages,
isLoading,
paddingBottom = 160,
}: {
className?: string;
messages: MultiRoundRagChatMessage[];
isLoading: boolean;
paddingBottom?: number;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
<ConversationContent
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
>
{messages.map((message, index) => {
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
return (
<AIElementMessage
key={message.id}
className="group/conversation-message relative w-full"
from={message.role === "user" ? "user" : "assistant"}
>
{message.role === "user" ? (
<AIElementMessageContent className="w-fit">
<p className="whitespace-pre-wrap text-[15px] leading-7">
{message.content}
</p>
</AIElementMessageContent>
) : (
<AIElementMessageContent className="w-full">
<MultiRoundRagAssistantBody
content={
typeof message.content === "string" ? message.content : ""
}
thinkingTrace={message.thinkingTrace}
isLoading={isLastAssistant && isLoading}
rehypePlugins={rehypePlugins}
/>
</AIElementMessageContent>
)}
</AIElementMessage>
);
})}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
<ConversationScrollButton
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
aria-label="回到最新"
title="回到最新"
/>
</Conversation>
);
}
export function MultiRoundRagAssistantBody({
content,
thinkingTrace,
isLoading,
rehypePlugins,
}: {
content: string;
thinkingTrace?: MultiRoundRagTrace | null;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
}) {
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
null,
);
const trace = thinkingTrace;
const answer = trace?.finalAnswerMarkdown || content;
return (
<>
{trace && (
<MultiRoundRagTraceView
trace={trace}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onSelectRetrieval={setSelected}
/>
)}
<section className="mt-4">
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
{answer ? (
<MarkdownContent
content={answer}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-0 text-[15px] leading-7"
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
)}
</section>
<RetrievalDetailDialog
item={selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
/>
</>
);
}
function MultiRoundRagTraceView({
trace,
isLoading,
rehypePlugins,
onSelectRetrieval,
}: {
trace: MultiRoundRagTrace;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(!trace.thinkingComplete);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose w-full min-w-0 max-w-5xl"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
>
{trace.thinkingComplete ? (
<SparklesIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<span>{trace.title || "思考过程"}</span>
<span className="text-muted-foreground/70 text-xs">
共 {trace.rounds.length} 轮推理与检索
</span>
<ChevronDownIcon
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
>
{index < trace.rounds.length - 1 && (
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
isActiveRound={isLoading && index === trace.rounds.length - 1}
rehypePlugins={rehypePlugins}
onSelectRetrieval={onSelectRetrieval}
/>
</div>
))}
</CollapsibleContent>
</Collapsible>
);
}
function MultiRoundRagRoundCard({
index,
round,
isLoading,
isActiveRound,
rehypePlugins,
onSelectRetrieval,
}: {
index: number;
round: MultiRoundRagRound;
isLoading: boolean;
isActiveRound: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const query = round.query.trim();
const searching = Boolean(query && round.queryLoading);
const showQuery = Boolean(query);
const showRetrievals = showQuery || round.retrievals.length > 0;
const [open, setOpen] = useState(true);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
{index}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
{searching ? (
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
<Loader2Icon className="size-3 animate-spin" />
检索中
</span>
) : isActiveRound ? null : (
<span className="text-muted-foreground text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
{round.thoughtMarkdown && (
<section>
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
<SparklesIcon className="size-4" />
思考内容
</div>
<MarkdownContent
content={round.thoughtMarkdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="text-muted-foreground my-0 text-sm leading-6"
/>
</section>
)}
{showQuery && (
<div
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
aria-live={searching ? "polite" : undefined}
>
{searching ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="text-primary/80 shrink-0 text-xs">
{round.queryTitle || "检索词"}
</span>
<span className="min-w-0 truncate text-xs font-medium">
{query}
</span>
</div>
)}
{showRetrievals && (
<section>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<FileTextIcon className="text-muted-foreground size-4" />
检索内容
<span className="text-muted-foreground text-xs font-normal">
{round.retrievals.length} 条
</span>
</div>
{searching ? (
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
<Loader2Icon className="text-primary size-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalCardList
items={round.retrievals}
onSelect={onSelectRetrieval}
/>
)}
</section>
)}
{(round.extractInfo || round.extractContent || round.extractLoading) && (
<section className="rounded-lg border border-border/70 px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BookOpenIcon className="text-muted-foreground size-4" />
阅读内容
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.extractInfo && (
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
)}
{round.extractContent && (
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
)}
</section>
)}
{round.textidContent && (
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
{round.textidContent}
</section>
)}
</CollapsibleContent>
</article>
</Collapsible>
);
}
function RetrievalCardList({
items,
onSelect,
}: {
items: MultiRoundRagRetrieval[];
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
if (items.length === 0) {
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<RetrievalCard
key={`${item.id ?? item.title}-${index}`}
item={item}
index={index + 1}
defaultOpen={index === 0}
onSelect={onSelect}
/>
))}
</div>
);
}
function RetrievalCard({
item,
index,
defaultOpen,
onSelect,
}: {
item: MultiRoundRagRetrieval;
index: number;
defaultOpen: boolean;
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
const meta = retrievalMeta(item);
if (item.type === "user_behavior") {
return (
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
{item.title}
</div>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
>
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
{meta && (
<div className="text-muted-foreground truncate text-xs">{meta}</div>
)}
</div>
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
onClick={() => onSelect(item)}
>
{item.content || "暂无资料摘要"}
</button>
</CollapsibleContent>
</div>
</Collapsible>
);
}
function retrievalMeta(item: MultiRoundRagRetrieval): string {
if (item.type === "more") return `时间:${item.time || "-"}`;
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
if (
item.type === "taiwan_news" ||
item.type === "world_news" ||
item.type === "messaage_comment" ||
item.type === "america_news"
) {
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
}
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
return [item.source, item.time].filter(Boolean).join(" · ");
}
function RetrievalDetailDialog({
item,
onOpenChange,
}: {
item: MultiRoundRagRetrieval | null;
onOpenChange: (open: boolean) => void;
}) {
const content = item?.content;
return (
<Dialog open={!!item} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
{item?.time && <span className="block">时间:{item.time}</span>}
{item?.source && <span className="block">来源:{item.source}</span>}
</DialogDescription>
</DialogHeader>
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
{content || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
@@ -188,7 +188,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault,
@@ -201,7 +201,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝";
}, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则内置默认简洁模式)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -14,16 +14,19 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "about";
| "memory"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -63,7 +67,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []),
{
id: "account",
label: "个人信息",
@@ -75,7 +82,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.about,
t.settings.sections.about,
isAdmin,
],
);
return (
@@ -125,7 +133,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -102,9 +102,16 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
return (
export function SkillSettingsPage({
onClose,
embedded = false,
}: {
onClose?: () => void;
embedded?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
@@ -1,4 +1,4 @@
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -8,8 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
type SkillKnowledgeTarget,
@@ -25,21 +24,37 @@ const PHASE_LABELS: Record<string, string> = {
awaiting_review: "等待审核",
merging: "合并实体关系",
writing_wiki: "写入 Wiki",
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
activating: "激活新版本",
completed: "完成",
};
export function SkillKnowledgePanel() {
const { skills, isLoading } = useAllSkillsAdmin(true);
const STATUS_LABELS: Record<string, string> = {
queued: "等待处理",
running: "处理中",
awaiting_review: "等待管理员审核",
completed: "已完成",
partial_failed: "部分失败",
failed: "失败",
canceled: "已取消",
};
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
const visibleSkills = useSkills();
const allSkills = useAllSkillsAdmin(adminMode);
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings();
const jobs = useSkillKnowledgeJobs();
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
const jobs = useSkillKnowledgeJobs(!adminMode);
const actions = useSkillKnowledgeActions();
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
@@ -118,7 +133,7 @@ export function SkillKnowledgePanel() {
const job = await actions.createJob.mutateAsync({
skill_names: selectedSkills,
targets: selectedTargets,
review_mode: reviewMode,
review_mode: "off",
});
toast.success(`已创建 ${job.total_count} 个归纳项`);
setSelectedSkills([]);
@@ -128,17 +143,17 @@ export function SkillKnowledgePanel() {
};
return (
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4">
<h2 className="font-semibold">技能到知识库管理</h2>
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
<h2 className="font-semibold">归纳技能知识</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。</p>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-4">
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
<span className="text-sm font-medium">第一步:选择技能(已选 {selectedSkills.length})</span>
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
</Button>
@@ -158,6 +173,13 @@ export function SkillKnowledgePanel() {
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
? "已在目标库"
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
</Badge>
)}
</label>
))}
{!isLoading && !filteredSkills.length && (
@@ -170,7 +192,7 @@ export function SkillKnowledgePanel() {
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
</div>
<div className="space-y-1 rounded-md border p-2">
{targets.map((target) => {
@@ -183,39 +205,32 @@ export function SkillKnowledgePanel() {
/>
<Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
</label>
);
})}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败:
读取普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p>
)}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">审核策略</span>
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
<SelectItem value="off">不阻塞写入</SelectItem>
</SelectContent>
</Select>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
</Button>
</div>
</ScrollArea>
@@ -224,22 +239,26 @@ export function SkillKnowledgePanel() {
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4">
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
<div><h2 className="font-semibold">我的归纳任务</h2><p className="text-xs text-muted-foreground">任务会自动刷新,失败项可直接重试</p></div>
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{(jobs.data ?? []).map((job) => {
const done = job.success_count + job.partial_failed_count + job.failed_count;
const countedDone = job.success_count + job.partial_failed_count + job.failed_count;
const itemDone = (job.items ?? []).filter((item) =>
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
).length;
const done = Math.max(countedDone, itemDone);
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
return (
<div key={job.id} className="rounded-md border p-3">
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{STATUS_LABELS[job.status] || job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<Progress className="my-2" value={progress} />
{job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div>
@@ -252,8 +271,8 @@ export function SkillKnowledgePanel() {
</ScrollArea>
</div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
</div>
</section>
</div>
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -108,6 +108,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-report-collaboration",
// label: "报告协作",
// icon: FileText,
// path: "/page/workspace/report-collaboration",
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
// },
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
@@ -83,7 +83,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
"appearance" | "memory" | "tools" | "encoder" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -189,6 +189,17 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("encoder");
setSettingsOpen(true);
}}
>
<CpuIcon />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -254,14 +265,6 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
>
<DatabaseZapIcon />
技能到知识库管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -7,7 +7,7 @@
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
* 搜集信息 → hits 落盘 → task-report-build 一条命令转换成 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 /
* task-report-import 技能入库),不走「配置管理」业务映射。
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
*
+8 -5
View File
@@ -109,8 +109,8 @@ export interface TaskButton {
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string;
/**
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/
idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
@@ -327,7 +327,8 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``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> {
const fail = (m: string) => ctx.onError?.(m);
@@ -355,9 +356,11 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const id = parseHostTaskId(raw);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`);
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
@@ -111,6 +111,28 @@ test("a lone deep-research progress bar still opens its own group", () => {
assert.deepEqual(types, ["assistant:processing"]);
});
test("multi-round RAG assistant rows render even while the final answer is still empty", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message,
{
type: "ai",
id: "a1",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: {
title: "多轮检索思考过程",
rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }],
finalAnswerMarkdown: "",
thinkingComplete: false,
},
},
} as Message,
]);
assert.deepEqual(types, ["human", "assistant"]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
+39 -1
View File
@@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -41,6 +43,7 @@ type MessageGroup =
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantTaskReportImportGroup
| AssistantBusinessCardGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -65,7 +68,8 @@ export function groupMessages<T>(
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval"
last.type !== "assistant:research-approval" &&
last.type !== "assistant:business-card"
) {
return last;
}
@@ -137,6 +141,16 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Multi-round RAG keeps rounds/retrievals on additional_kwargs while the
// visible answer is still empty. The default rule below only opens an
// assistant bubble when hasContent() is true, which would swallow the
// live thinking cards until the final answer arrives (or forever, if the
// turn is think/search-only). Force a normal assistant row so
// MessageListItem can render MultiRoundRagAssistantBody immediately.
if (message.additional_kwargs?.multi_round_rag === true) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
continue;
}
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
@@ -159,6 +173,17 @@ export function groupMessages<T>(
});
continue;
}
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
// (additional_kwargs.report_collaboration_card),由宿主注入并经
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
if (hasBusinessCard(message)) {
groups.push({
id: message.id,
type: "assistant:business-card",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -487,6 +512,19 @@ export function hasTaskReportImportApproval(message: Message) {
);
}
/**
* 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
* (additional_kwargs.report_collaboration_card),经 MessageList 的
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
*/
export function hasBusinessCard(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
message.additional_kwargs.report_collaboration_card !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -0,0 +1,13 @@
/**
* Hash-router entry used by the customized Vite application for the isolated
* official-style UI. Keeping this prefix distinct from `/page/*` prevents its
* routes and mock state from altering the customized application.
*/
export const OFFICIAL_DEERFLOW_ROUTE_PREFIX = "/official";
/** The official-style workspace home shown after a normal login. */
export const OFFICIAL_DEERFLOW_HOME_ROUTE =
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`;
/** Existing customized application's normal post-login destination. */
export const CUSTOMIZED_DEERFLOW_HOME_ROUTE = "/page/workspace/chats/new";
@@ -10,6 +10,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid";
const DOCX_MEDIA_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
@@ -38,13 +39,15 @@ export async function markdownToDocxBlob(
const source = markdown?.trim();
if (!source) throw new Error("文档内容为空");
const prepared = await replaceMermaidFencesForWordExport(source);
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
const response = await apiFetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title?.trim() || undefined,
markdown: source,
markdown: prepared.markdown,
embedded_images: prepared.embeddedImages,
}),
});
if (!response.ok) throw await responseError(response);
@@ -1 +1,2 @@
export * from "./plugins";
export * from "./mermaid-png-download";
@@ -0,0 +1,56 @@
import { useEffect, type RefObject } from "react";
import { toast } from "sonner";
import { downloadSvgElementAsPng } from "@/core/utils/mermaid";
function findStreamdownMermaidDownloadBlock(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
const button = target.closest("button");
if (!(button instanceof HTMLButtonElement)) return null;
if (button.getAttribute("title") !== "Download file") return null;
const block = button.closest('[data-streamdown="mermaid-block"]');
return block instanceof HTMLElement ? block : null;
}
function findRenderedMermaidChartSvg(block: HTMLElement): SVGSVGElement | null {
const fromChart = block.querySelector('[aria-label="Mermaid chart"] svg, [role="img"] svg');
if (fromChart instanceof SVGSVGElement && !fromChart.closest("button")) {
return fromChart;
}
for (const svg of block.querySelectorAll("svg")) {
if (svg instanceof SVGSVGElement && !svg.closest("button")) {
return svg;
}
}
return null;
}
/** Intercept Streamdown mermaid download buttons and save a PNG of the chart. */
export function useStreamdownMermaidPngDownload(rootRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const onClickCapture = (event: MouseEvent) => {
const root = rootRef.current;
if (!root || !root.contains(event.target as Node)) return;
const block = findStreamdownMermaidDownloadBlock(event.target);
if (!block) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const svg = findRenderedMermaidChartSvg(block);
if (!svg) {
toast.error("流程图尚未渲染完成,请稍后再下载");
return;
}
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
console.error("Failed to download mermaid image:", error);
toast.error(error instanceof Error ? error.message : "下载流程图失败");
});
};
document.addEventListener("click", onClickCapture, true);
return () => document.removeEventListener("click", onClickCapture, true);
}, [rootRef]);
}
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
});
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
@@ -164,19 +164,15 @@ export function useViewMode(
/**
* Effective interface layout mode (full chrome vs compact). A user's own choice
* (persisted in local settings) wins; otherwise the admin-configured system
* default applies; otherwise `undefined` (callers fall back to the session-level
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the
* user's override locally.
* default applies; otherwise the built-in default is compact (简洁模式).
* `setMode` persists the user's override locally.
*/
export function useLayoutMode(): [
LayoutMode | undefined,
(mode: LayoutMode) => void,
] {
export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
const [settings, setSettings] = useLocalSettings();
const { data: appearance } = useAppearanceSettings();
const userOverride = settings.display.layoutMode;
const adminDefault = appearance?.default_layout_mode;
const mode = userOverride ?? adminDefault;
const mode: LayoutMode = userOverride ?? adminDefault ?? "compact";
const setMode = (next: LayoutMode) =>
setSettings("display", { layoutMode: next });
return [mode, setMode];
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sanitizeMermaidSource } from "./mermaid";
import { findMermaidFenceMatches, getMermaidImageFileName, sanitizeMermaidSource } from "./mermaid";
test("inserts a missing arrow before a bare edge label", () => {
const input = "E --> G|价值输出 V_s| H[PPO 损失]";
@@ -69,3 +69,17 @@ test("repairs the DAC-PPO architecture diagram class block", () => {
assert.doesNotMatch(output, /class,/);
assert.doesNotMatch(output, /input,module/);
});
test("turns mermaid source filenames into png downloads", () => {
assert.equal(getMermaidImageFileName("outputs/图2.mmd"), "图2.png");
assert.equal(getMermaidImageFileName("chart.mermaid", "png"), "chart.png");
assert.equal(getMermaidImageFileName("flowchart.png"), "flowchart.png");
});
test("finds mermaid fences for Word image replacement", () => {
const markdown = "前文\n```mermaid\nflowchart TB\nA-->B\n```\n后文\n```mmd\ngraph LR\nC-->D\n```";
const fences = findMermaidFenceMatches(markdown);
assert.equal(fences.length, 2);
assert.equal(fences[0].source, "flowchart TB\nA-->B");
assert.equal(fences[1].source, "graph LR\nC-->D");
});
+202 -14
View File
@@ -96,12 +96,209 @@ function sanitizeMermaidClassStatements(source: string): string {
);
}
const MERMAID_FILE_EXT = /\.(mmd|mermaid)$/i;
export function isMermaidFile(filepath: string): boolean {
return /\.mmd$/i.test(filepath);
return MERMAID_FILE_EXT.test(filepath);
}
export function getMermaidImageFileName(filepath: string, extension = "svg"): string {
return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`);
export function getMermaidImageFileName(filepath: string, extension = "png"): string {
const name = getFileName(filepath) || "mermaid-diagram";
if (MERMAID_FILE_EXT.test(name)) {
return name.replace(MERMAID_FILE_EXT, `.${extension}`);
}
if (/\.(svg|png|jpe?g|webp)$/i.test(name)) {
return name.replace(/\.(svg|png|jpe?g|webp)$/i, `.${extension}`);
}
return `${name}.${extension}`;
}
function triggerBlobDownload(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
function readPositiveNumber(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getSvgExportSize(svg: SVGSVGElement): { width: number; height: number } {
const viewBox = svg.viewBox?.baseVal;
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { width: viewBox.width, height: viewBox.height };
}
const attrWidth = readPositiveNumber(svg.getAttribute("width"));
const attrHeight = readPositiveNumber(svg.getAttribute("height"));
if (attrWidth && attrHeight) {
return { width: attrWidth, height: attrHeight };
}
try {
const box = svg.getBBox();
if (box.width > 0 && box.height > 0) {
return { width: box.width, height: box.height };
}
} catch {
// Detached SVGs may throw on getBBox().
}
return {
width: Math.max(1, svg.clientWidth),
height: Math.max(1, svg.clientHeight),
};
}
function serializeSvg(svg: SVGSVGElement): string {
const clone = svg.cloneNode(true) as SVGSVGElement;
if (!clone.getAttribute("xmlns")) {
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
}
if (!clone.getAttribute("xmlns:xlink")) {
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
}
return new XMLSerializer().serializeToString(clone);
}
export async function renderSvgElementToPngBlob(svg: SVGSVGElement): Promise<Blob> {
const { width, height } = getSvgExportSize(svg);
const scale = 2;
const markup = serializeSvg(svg);
const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("流程图转图片失败"));
image.src = objectUrl;
});
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("无法创建画布");
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error("生成 PNG 失败"))),
"image/png",
);
});
return png;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
export async function downloadSvgElementAsPng(
svg: SVGSVGElement,
filename: string,
): Promise<void> {
const png = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(png, getMermaidImageFileName(filename, "png"));
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || "");
const comma = result.indexOf(",");
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(new Error("图片编码失败"));
reader.readAsDataURL(blob);
});
}
const MERMAID_FENCE_RE = /```(?:mermaid|mmd)[^\n]*\r?\n([\s\S]*?)```/gi;
export function findMermaidFenceMatches(markdown: string): Array<{
index: number;
length: number;
source: string;
}> {
const matches: Array<{ index: number; length: number; source: string }> = [];
const matcher = new RegExp(MERMAID_FENCE_RE.source, MERMAID_FENCE_RE.flags);
let match: RegExpExecArray | null;
while ((match = matcher.exec(markdown)) !== null) {
matches.push({
index: match.index,
length: match[0].length,
source: (match[1] ?? "").trim(),
});
}
return matches;
}
export async function renderMermaidToPngBlob(source: string): Promise<Blob> {
const markup = await renderMermaidToSvg(source);
const host = document.createElement("div");
host.style.position = "fixed";
host.style.left = "-10000px";
host.style.top = "-10000px";
host.style.pointerEvents = "none";
host.style.opacity = "0";
host.innerHTML = markup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) {
throw new Error("无法生成流程图图片");
}
return await renderSvgElementToPngBlob(svg);
} finally {
host.remove();
}
}
export async function replaceMermaidFencesForWordExport(markdown: string): Promise<{
markdown: string;
embeddedImages: Record<string, string>;
}> {
const fences = findMermaidFenceMatches(markdown);
if (fences.length === 0) {
return { markdown, embeddedImages: {} };
}
const embeddedImages: Record<string, string> = {};
const replacements: Array<{ index: number; length: number; text: string }> = [];
for (const [offset, fence] of fences.entries()) {
if (!fence.source) continue;
try {
const png = await renderMermaidToPngBlob(fence.source);
const id = `mermaid-${offset + 1}`;
embeddedImages[id] = await blobToBase64(png);
replacements.push({
index: fence.index,
length: fence.length,
text: `\n![流程图](embedded:${id})\n`,
});
} catch (error) {
console.error("Failed to render mermaid for Word export:", error);
}
}
let next = markdown;
for (const piece of replacements.sort((left, right) => right.index - left.index)) {
next = `${next.slice(0, piece.index)}${piece.text}${next.slice(piece.index + piece.length)}`;
}
return { markdown: next, embeddedImages };
}
async function getMermaidModule() {
@@ -152,15 +349,6 @@ export async function openMermaidSvgInNewTab(source: string): Promise<void> {
}
export async function downloadMermaidSvgFile(source: string, filepath: string): Promise<void> {
const svg = await renderMermaidToSvg(source);
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = getMermaidImageFileName(filepath);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
const png = await renderMermaidToPngBlob(source);
triggerBlobDownload(png, getMermaidImageFileName(filepath, "png"));
}
@@ -31,6 +31,7 @@ import {
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
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 "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -96,6 +97,8 @@ export function EightBfStep1Panel({
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
@@ -167,6 +170,8 @@ export function EightBfStep1Panel({
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
commonQuestions={intentAgent?.common_questions ?? null}
sendCommonQuestionOnClick
/>
)}
intentReady={intentReady}
@@ -331,8 +336,26 @@ function EightBfAgentWorkspace({
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(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
if (
selectedAgentId &&
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
!seats.some((seat) => seat.agent_id === selectedAgentId)
) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
@@ -412,7 +435,12 @@ function EightBfAgentWorkspace({
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
agentName={
selectedAgent?.name
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
? "8BF 任务理解"
: selectedSeat?.name ?? "")
}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
@@ -468,7 +496,7 @@ function EightBfAgentWorkspace({
</header>
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 ? (
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? (
<div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && (
@@ -477,6 +505,13 @@ function EightBfAgentWorkspace({
</div>
) : (
<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 }) => (
<EightBfAgentRow
key={seat.agent_id}
@@ -503,6 +538,19 @@ function EightBfAgentRow({
}) {
const name = agent?.name || seat.name || "未命名智能体";
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);
return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
EightBfStep1Panel,
@@ -76,6 +77,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 第二步「人工干预」:开启后每走一步暂停等待用户输入;关闭自动推进。会话内状态,
// 新建会话 / 刷新后回到默认关闭(与现状一致)。
const [step2Intervention, setStep2Intervention] = useState(false);
const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
@@ -260,6 +264,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
skipInitialDraftRestore={!taskId}
taskContext={taskContext as Record<string, unknown> | null}
// 第一步绑定 8BF 专属内置意图智能体(roundtable-intent 的同源复制,
// 提示词独立可调优),不再与通用多智能体会商共用一个 SOUL。
intentAgentId={EIGHTBF_INTENT_AGENT_ID}
// 页头「人工干预」开关:控制第二步是否每步暂停等待用户输入。
step2InterventionEnabled={step2Intervention}
onStep2InterventionChange={setStep2Intervention}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
+7
View File
@@ -41,6 +41,13 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Development-only default entry switch for the isolated Vite-native
* official-style workspace. It never starts or embeds a Next.js app.
*/
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY",
),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
@@ -0,0 +1,4 @@
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
export function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
const SHOW_NOTEBOOK_SPACE_ENTRIES = true;
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
+1 -13
View File
@@ -3,7 +3,6 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DatabaseZap,
Replace,
SearchIcon,
SparklesIcon,
@@ -20,7 +19,6 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
@@ -37,7 +35,7 @@ export default function AdminSkillsPage() {
const knowledgeBindings = useSkillKnowledgeBindings();
const activeTab = useMemo(() => {
const tab = searchParams.get("tab");
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
return tab === "addresses" ? tab : "skills";
}, [searchParams]);
const changeTab = (value: string) => {
@@ -89,10 +87,6 @@ export default function AdminSkillsPage() {
<Replace />
技能地址管理
</TabsTrigger>
<TabsTrigger value="knowledge">
<DatabaseZap />
技能到知识库管理
</TabsTrigger>
</TabsList>
</div>
</div>
@@ -189,12 +183,6 @@ export default function AdminSkillsPage() {
>
<SkillAddressesPanel />
</TabsContent>
<TabsContent
value="knowledge"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
+28 -6
View File
@@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation";
import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ import {
} from "@/components/workspace/agents/agent-guide";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { listThreadArtifacts } from "@/core/artifacts/listing";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
@@ -98,6 +100,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function isTaskReportsJsonPath(path: string | undefined): boolean {
const normalized = (path ?? "").replace(/\\/g, "/");
return (
normalized === "task-reports.json" ||
normalized.endsWith("/task-reports.json")
);
}
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
@@ -557,9 +567,11 @@ export default function AgentChatPage({
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制**
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并
// 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。
// 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义);
// 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、
// 「暂不入库」为本次会话内记忆。
const reportImportSessionKey = threadId
? `agentfx.reportImport.done.${threadId}`
: "";
@@ -572,9 +584,19 @@ export default function AgentChatPage({
}
});
const [reportImportDismissed, setReportImportDismissed] = useState(false);
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) =>
path.replace(/\\/g, "/").endsWith("/task-reports.json"),
);
const listedReportJsonQuery = useQuery({
queryKey: ["agentfx-task-reports", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: Boolean(
taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading,
),
retry: false,
});
const reportJsonArtifactReady =
(thread.values.artifacts ?? []).some(isTaskReportsJsonPath) ||
(listedReportJsonQuery.data?.files ?? []).some((file) =>
isTaskReportsJsonPath(file.path) || file.name === "task-reports.json",
);
const showReportImportCard =
taskBind &&
!taskGate &&
+247
View File
@@ -0,0 +1,247 @@
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>
);
}
File diff suppressed because it is too large. Load diff
+13 -20
View File
@@ -37,7 +37,6 @@ import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import {
buildFullChatNavigationPath,
isChatsIframeWorkspacePath,
@@ -70,7 +69,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -129,7 +128,7 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar();
const { t } = useI18n();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -357,19 +356,19 @@ export default function ChatPage() {
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
const ragMessages = toLangGraphMessages(multiRoundRag.messages);
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
ragMessages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return base;
}
const messages = [
...base.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages],
ragMessages,
);
return {
...base,
values: { ...base.values, messages },
@@ -380,9 +379,9 @@ export default function ChatPage() {
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
researchDisplayThread,
singleRoundRag.isLoading,
singleRoundRag.messages,
researchDisplayThread,
]);
const writingArtifactPath = useMemo(
@@ -695,16 +694,10 @@ export default function ChatPage() {
const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
const chromeByPreference =
fullChatMode || layoutMode === "full"
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
fullChatMode || effectiveLayoutMode === "full" ? true : false;
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
@@ -909,7 +902,7 @@ export default function ChatPage() {
status={
thread.error
? "error"
: displayThread.isLoading
: displayThread.isLoading || multiRoundRag.isLoading
? "streaming"
: "ready"
}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
}
},
}).catch((error: unknown) => {
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
return;
}
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
@@ -609,12 +610,12 @@ function TaskOverview({
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
+11 -3
View File
@@ -37,7 +37,6 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { readEmbedParam } from '@/core/embed/embed-session';
import {
GENERAL_QA_ROUTE,
loginByYUserId,
persistAccessToken,
persistToolsToken,
@@ -61,6 +60,11 @@ import { persistYLogToken } from '@/core/log/y-log-token';
import { persistLoginCredentials } from '@/core/auth/login-credentials';
import { OptType, writeYLog } from '@/core/log/y-log';
import { authStorageGet } from '@/core/auth/scoped-storage';
import {
CUSTOMIZED_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_HOME_ROUTE,
} from '@/core/official-deerflow/routes';
import { env } from '@/env';
/** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */
function maskLoginToken(value: string): string {
@@ -103,7 +107,11 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
const DEFAULT_USERNAME = 'guest';
const IFRAME_CHAT_MODE = 'iframe';
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
// Explicit redirects and task deep links retain their existing higher
// priority. This only picks the destination for a standard completed login.
const DEFAULT_REDIRECT_PATH = env.USE_OFFICIAL_DEERFLOW_ENTRY
? OFFICIAL_DEERFLOW_HOME_ROUTE
: CUSTOMIZED_DEERFLOW_HOME_ROUTE;
const FULL_CHAT_MODE = 'full';
type LoginStatus = 'loading' | 'success' | 'error';
@@ -191,7 +199,7 @@ export default function LoginPage() {
// External iframe callers frequently also provide yUserId for login.
// That identity parameter must not steal a deliberate iframe entry and
// send it to the generic full-chat home before the route split above.
if (yUserId) return GENERAL_QA_ROUTE;
if (yUserId) return DEFAULT_REDIRECT_PATH;
return resolvePostLoginRedirectPath(
searchParams.toString(),
DEFAULT_REDIRECT_PATH,
@@ -0,0 +1,856 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
Bot,
CalendarClock,
ChevronDown,
CircleHelp,
Clock3,
GitBranch,
LoaderCircle,
MessageSquareText,
Network,
PanelLeftClose,
Pause,
Play,
PlusSquare,
Search,
SendHorizontal,
Settings2,
Sparkles,
UserRound,
X,
} from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import {
type ScheduledTask,
useScheduledTaskActions,
useScheduledTaskRuns,
useScheduledTasks,
} from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/hooks";
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} 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";
type OfficialPage =
| "chat"
| "agents"
| "agent-chat"
| "scheduled"
| "multi-agent-tasks"
| "multi-agent-edit"
| "task-analysis"
| "task-analysis-detail"
| "settings";
function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat";
}
if (path.startsWith("/workspace/agents")) return "agents";
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";
return "chat";
}
function parseChatRoute(pathname: string) {
const chatMatch = pathname.match(/\/workspace\/chats\/([^/]+)/);
if (chatMatch) return { threadId: decodeURIComponent(chatMatch[1]!), agentId: null };
const agentMatch = pathname.match(
/\/workspace\/agents\/([^/]+)\/chats\/([^/]+)/,
);
if (agentMatch) {
return {
agentId: decodeURIComponent(agentMatch[1]!),
threadId: decodeURIComponent(agentMatch[2]!),
};
}
return { threadId: "new", agentId: null };
}
function parseScheduledTaskId(pathname: string) {
const match = pathname.match(/\/workspace\/scheduled-tasks\/([^/]+)/);
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) {
return error instanceof Error && error.message ? error.message : fallback;
}
function formatTimestamp(value: string | null | undefined) {
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.max(1, Math.floor(elapsed / 60_000))} 分钟前`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(date);
}
function statusForTask(task: ScheduledTask) {
const status = task.last_status;
if (status === "running" || status === "pending") {
return { className: status, label: "执行中" };
}
if (!task.enabled) return { className: "paused", label: "已暂停" };
if (status === "failed") return { className: "failed", label: "执行失败" };
if (status === "succeeded") return { className: "succeeded", label: "已完成" };
return { className: "ready", label: "已启用" };
}
function runStatusText(status: string) {
if (status === "running" || status === "pending") return "执行中";
if (status === "succeeded") return "已完成";
if (status === "failed") return "执行失败";
return status || "等待执行";
}
function OfficialNavItem({
active,
icon: Icon,
label,
onClick,
}: {
active: boolean;
icon: typeof MessageSquareText;
label: string;
onClick: () => void;
}) {
return (
<button
className={`official-nav-item${active ? " is-active" : ""}`}
onClick={onClick}
type="button"
>
<Icon aria-hidden="true" size={18} strokeWidth={1.8} />
<span>{label}</span>
</button>
);
}
function ThreadMessage({ message }: { message: Message }) {
const text = textOfMessage(message)?.trim();
if (!text) return null;
const isUser = message.type === "human";
return (
<div className={`official-message official-message-${isUser ? "user" : "assistant"}`}>
{!isUser && (
<div className="official-assistant-mark">
<Sparkles aria-hidden="true" size={16} />
DeerFlow
</div>
)}
<p>{text}</p>
</div>
);
}
function ChatWorkspace({
agentId,
routeThreadId,
}: {
agentId: string | null;
routeThreadId: string;
}) {
const navigate = useNavigate();
const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new";
// A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined,
context: {
...localSettings.context,
...(agentId ? { agent_id: agentId } : {}),
},
onStart: (createdThreadId) => {
const nextPath = agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(createdThreadId)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(createdThreadId)}`;
navigate(nextPath, { replace: true });
},
});
const messages = useMemo(
() =>
(thread.messages ?? []).filter(
(message): message is Message =>
message.type === "human" || message.type === "ai",
),
[thread.messages],
);
const title = isNewThread
? agent?.name ?? "新对话"
: thread.values?.title ?? agent?.name ?? "对话";
const streamError = thread.error
? readableError(thread.error, "对话加载失败,请稍后重试。")
: null;
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = prompt.trim();
if (!text || thread.isLoading || isUploading) return;
setPrompt("");
setSendError(null);
void sendMessage(activeThreadId, { text, files: [] }).catch((error) => {
setSendError(readableError(error, "消息发送失败,请稍后重试。"));
});
},
[activeThreadId, isUploading, prompt, sendMessage, thread.isLoading],
);
return (
<section className="official-chat-page">
<header className="official-page-header official-chat-header">
<div className="official-thread-title">
{title}
<ChevronDown aria-hidden="true" size={16} />
</div>
<div className="official-header-actions">
<button
className="official-outline-button"
onClick={() => navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`)}
type="button"
>
<CalendarClock aria-hidden="true" size={17} />
定时任务
</button>
<button aria-label="帮助" className="official-icon-button" type="button">
<CircleHelp aria-hidden="true" size={18} />
</button>
</div>
</header>
<div className={`official-chat-canvas${messages.length ? " has-history" : ""}`}>
{messages.length ? (
<div className="official-message-list">
{messages.map((message, index) => (
<ThreadMessage key={message.id ?? `${message.type}-${index}`} message={message} />
))}
{thread.isLoading && (
<div className="official-streaming-indicator">
<LoaderCircle aria-hidden="true" size={15} />
DeerFlow 正在回复…
</div>
)}
</div>
) : (
<div className="official-chat-welcome">
<div className="official-logo-orb">
<Sparkles aria-hidden="true" size={28} />
</div>
<h1>{agent?.name ? `与 ${agent.name} 开始对话` : "今天想一起完成什么?"}</h1>
<p>直接连接当前 DeerFlow 后端;消息与历史记录会同步保存。</p>
{!agentId && (
<div className="official-suggestion-grid">
{[
["做一份市场调研", "比较行业趋势并形成要点"],
["规划项目", "把目标拆解成清晰步骤"],
["写一篇初稿", "从想法到结构化内容"],
].map(([suggestion, detail]) => (
<button key={suggestion} onClick={() => setPrompt(suggestion)} type="button">
<strong>{suggestion}</strong>
<span>{detail}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{(streamError || sendError) && (
<p className="official-inline-error" role="alert">
{sendError ?? streamError}
</p>
)}
<form className="official-composer-shell" onSubmit={handleSubmit}>
<div className="official-composer">
<textarea
aria-label="输入消息"
disabled={thread.isLoading || isUploading}
onChange={(event) => setPrompt(event.target.value)}
placeholder="给 DeerFlow 发送消息…"
rows={1}
value={prompt}
/>
<button
aria-label="发送"
disabled={!prompt.trim() || thread.isLoading || isUploading}
type="submit"
>
<SendHorizontal aria-hidden="true" size={18} />
</button>
</div>
<p>连接当前 DeerFlow 后端</p>
</form>
</section>
);
}
function AgentsPage({ openAgent }: { openAgent: (id: string) => void }) {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const { agents, isLoading, error } = useAgents({ search: query });
return (
<section className="official-content-page">
<header className="official-page-header">
<div>
<p className="official-eyebrow">工作区</p>
<h1>智能体</h1>
<p>以下智能体直接来自当前 DeerFlow 后端。</p>
</div>
<button
className="official-primary-button"
onClick={() => navigate("/page/workspace/agents/new")}
type="button"
>
<PlusSquare aria-hidden="true" size={17} />
创建智能体
</button>
</header>
<div className="official-toolbar">
<label className="official-search">
<Search aria-hidden="true" size={17} />
<input
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索智能体"
value={query}
/>
</label>
</div>
{error ? (
<p className="official-query-error" role="alert">
{readableError(error, "智能体列表加载失败。")}
</p>
) : isLoading ? (
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载智能体…</div>
) : agents.length ? (
<div className="official-agent-grid">
{agents.map((agent, index) => (
<article className="official-agent-card" key={agent.id}>
<div className={`official-agent-avatar ${["violet", "sky", "amber"][index % 3]}`}>
{agent.name.slice(0, 2).toLocaleUpperCase()}
</div>
<h2>{agent.name}</h2>
<p>{agent.description || "暂无简介"}</p>
<button onClick={() => openAgent(agent.id)} type="button">
开始对话
</button>
</article>
))}
</div>
) : (
<div className="official-empty-panel">
<div className="official-empty-icon"><Bot aria-hidden="true" size={24} /></div>
<h2>暂无可用智能体</h2>
<p>可从“创建智能体”进入现有的完整配置页面。</p>
</div>
)}
</section>
);
}
function ScheduledTasksPage({ selectedTaskId }: { selectedTaskId: string | null }) {
const navigate = useNavigate();
const { data: tasks = [], isLoading, error } = useScheduledTasks();
const { data: runs = [], isLoading: runsLoading, error: runsError } =
useScheduledTaskRuns(selectedTaskId);
const actions = useScheduledTaskActions();
const [actionTaskId, setActionTaskId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.task_id === selectedTaskId) ?? null;
const openTask = useCallback(
(taskId: string) => {
navigate(
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks/${encodeURIComponent(taskId)}`,
);
},
[navigate],
);
const closeTask = useCallback(() => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`);
}, [navigate]);
const runTaskAction = useCallback(
async (task: ScheduledTask, action: "trigger" | "pause" | "resume") => {
setActionTaskId(task.task_id);
setActionError(null);
try {
if (action === "trigger") {
await actions.trigger.mutateAsync(task.task_id);
openTask(task.task_id);
} else if (action === "pause") {
await actions.pause.mutateAsync(task.task_id);
} else {
await actions.resume.mutateAsync(task.task_id);
}
} catch (actionFailure) {
setActionError(
readableError(
actionFailure,
action === "trigger" ? "任务触发失败。" : "任务状态更新失败。",
),
);
} finally {
setActionTaskId(null);
}
},
[actions.pause, actions.resume, actions.trigger, openTask],
);
const actionIsPending = (taskId: string) => actionTaskId === taskId;
return (
<section className="official-content-page">
<header className="official-page-header">
<div>
<p className="official-eyebrow">工作区</p>
<h1>定时任务</h1>
<p>任务、运行记录和控制操作均直接请求当前 DeerFlow 后端。</p>
</div>
<button
className="official-primary-button"
onClick={() => navigate("/page/workspace/scheduled/tasks")}
type="button"
>
<PlusSquare aria-hidden="true" size={17} />
新建任务
</button>
</header>
{actionError && (
<p className="official-query-error" role="alert">
{actionError}
</p>
)}
{selectedTask && (
<section className="official-task-detail" aria-label={`${selectedTask.name}详情`}>
<header>
<div>
<p className="official-eyebrow">任务详情</p>
<h2>{selectedTask.name}</h2>
<p>{selectedTask.description || selectedTask.prompt}</p>
</div>
<button
aria-label="关闭任务详情"
className="official-icon-button"
onClick={closeTask}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</header>
<div className="official-task-meta">
<span>计划:{selectedTask.schedule_text || selectedTask.cron_expr}</span>
<span>时区:{selectedTask.timezone}</span>
<span>下次执行:{formatTimestamp(selectedTask.next_run_at) || "未计划"}</span>
</div>
<div className="official-task-detail-actions">
<button
className="official-secondary-button"
disabled={actionIsPending(selectedTask.task_id)}
onClick={() => void runTaskAction(selectedTask, "trigger")}
type="button"
>
{actionIsPending(selectedTask.task_id) ? <LoaderCircle aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
立即执行
</button>
<button
className="official-secondary-button"
disabled={actionIsPending(selectedTask.task_id)}
onClick={() =>
void runTaskAction(
selectedTask,
selectedTask.enabled ? "pause" : "resume",
)
}
type="button"
>
{selectedTask.enabled ? <Pause aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
</button>
</div>
<div className="official-run-list">
<h3>最近运行</h3>
{runsError ? (
<p className="official-query-error" role="alert">
{readableError(runsError, "运行记录加载失败。")}
</p>
) : runsLoading ? (
<p className="official-run-empty"><LoaderCircle aria-hidden="true" size={15} /> 正在加载运行记录…</p>
) : runs.length ? (
runs.slice(0, 8).map((run) => (
<button
className="official-run-row"
key={run.id}
onClick={() => navigate(`/page/workspace/scheduled/runs/${encodeURIComponent(run.id)}`)}
type="button"
>
<span className={`official-run-status is-${run.status}`}>{runStatusText(run.status)}</span>
<span>{formatTimestamp(run.started_at ?? run.scheduled_for) || "等待执行"}</span>
<span>{run.result?.title || run.error || "查看完整运行结果"}</span>
</button>
))
) : (
<p className="official-run-empty">该任务暂时没有运行记录。</p>
)}
</div>
</section>
)}
{error ? (
<p className="official-query-error" role="alert">
{readableError(error, "定时任务列表加载失败。")}
</p>
) : isLoading ? (
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载定时任务…</div>
) : tasks.length ? (
<div className="official-task-list">
{tasks.map((task) => {
const status = statusForTask(task);
const pending = actionIsPending(task.task_id);
return (
<article
className={`official-task-card${selectedTaskId === task.task_id ? " is-selected" : ""}`}
key={task.task_id}
>
<button
className="official-task-card-main"
onClick={() => openTask(task.task_id)}
type="button"
>
<span className="official-task-icon"><Clock3 aria-hidden="true" size={18} /></span>
<span className="official-task-copy">
<strong>{task.name}</strong>
<small>{task.schedule_text || task.cron_expr}</small>
</span>
<span className={`official-task-status is-${status.className}`}>{status.label}</span>
<time>{formatTimestamp(task.next_run_at ?? task.updated_at)}</time>
</button>
<div className="official-task-card-actions">
<button
aria-label={`立即执行 ${task.name}`}
disabled={pending}
onClick={() => void runTaskAction(task, "trigger")}
title="立即执行"
type="button"
>
{pending ? <LoaderCircle aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
</button>
<button
aria-label={task.enabled ? `暂停 ${task.name}` : `恢复 ${task.name}`}
disabled={pending}
onClick={() => void runTaskAction(task, task.enabled ? "pause" : "resume")}
title={task.enabled ? "暂停任务" : "恢复任务"}
type="button"
>
{task.enabled ? <Pause aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
</button>
</div>
</article>
);
})}
</div>
) : (
<div className="official-empty-panel">
<div className="official-empty-icon"><Clock3 aria-hidden="true" size={24} /></div>
<h2>还没有定时任务</h2>
<p>创建一个任务,按天、周或自定义时间自动执行。</p>
<button
className="official-secondary-button"
onClick={() => navigate("/page/workspace/scheduled/tasks")}
type="button"
>
创建定时任务
</button>
</div>
)}
</section>
);
}
function SettingsPage() {
const { data: appearance, isLoading: appearanceLoading, error: appearanceError } =
useAppearanceSettings();
const { data: citation, isLoading: citationLoading, error: citationError } =
useCitationDisplaySettings();
const settings = [
appearance && {
label: "默认外观",
detail: `主题:${appearance.default_theme};布局:${appearance.default_layout_mode}`,
},
appearance && {
label: "默认展示方式",
detail: `智能体:${appearance.default_agent_view};技能:${appearance.default_skill_view}`,
},
citation && {
label: "引用展示",
detail: citation.reference_mode_enabled ? "已启用引用模式" : "未启用引用模式",
},
].filter(Boolean) as Array<{ label: string; detail: string }>;
const isLoading = appearanceLoading || citationLoading;
return (
<section className="official-content-page">
<header className="official-page-header">
<div>
<p className="official-eyebrow">工作区</p>
<h1>设置</h1>
<p>只展示已由当前 DeerFlow 后端提供的配置,不使用模拟值。</p>
</div>
</header>
{isLoading && !settings.length ? (
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载系统配置…</div>
) : settings.length ? (
<div className="official-settings-list">
{settings.map((setting) => (
<div className="official-setting-row" key={setting.label}>
<strong>{setting.label}</strong>
<small>{setting.detail}</small>
</div>
))}
</div>
) : (
<div className="official-empty-panel">
<div className="official-empty-icon"><Settings2 aria-hidden="true" size={24} /></div>
<h2>暂时没有可读取的系统配置</h2>
<p>不会用静态数据替代未返回的后端配置。</p>
</div>
)}
{(appearanceError || citationError) && (
<p className="official-query-error" role="alert">
部分系统配置不可读取:{readableError(appearanceError ?? citationError, "请检查登录权限。")}
</p>
)}
</section>
);
}
/**
* Vite-native official-style workspace. It reuses the current frontend's
* backend clients and stream adapter; only reference-only UI has local state.
*/
export default function OfficialDeerFlowEntry() {
const location = useLocation();
const navigate = useNavigate();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const page = pageFromPath(location.pathname);
const chatRoute = useMemo(
() => parseChatRoute(location.pathname),
[location.pathname],
);
const scheduledTaskId = useMemo(
() => parseScheduledTaskId(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 } =
useThreads({
limit: 20,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
});
const go = (path: string) => navigate(path);
const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, 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: 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;
return (
<main className={`official-deerflow${sidebarCollapsed ? " sidebar-collapsed" : ""}`}>
<aside className="official-sidebar">
<div className="official-brand-row">
<div className="official-brand">DeerFlow</div>
<button
aria-label="折叠侧栏"
className="official-collapse-button"
onClick={() => setSidebarCollapsed((value) => !value)}
type="button"
>
<PanelLeftClose aria-hidden="true" size={17} />
</button>
</div>
<nav aria-label="官方页面导航" className="official-nav">
{nav.map((item) => (
<OfficialNavItem
active={item.pages.some((candidate) => candidate === page)}
icon={item.icon}
key={item.label}
label={item.label}
onClick={() => go(item.path)}
/>
))}
</nav>
<div className="official-recent-section">
<p>最近对话</p>
{threadsLoading ? (
<div className="official-sidebar-loading"><LoaderCircle aria-hidden="true" size={14} /> 加载中…</div>
) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? (
threads.map((chat) => (
<button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
? chat.metadata.agent_id
: null;
go(
agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
);
}}
type="button"
>
<MessageSquareText aria-hidden="true" size={15} />
<span>{titleOfThread(chat, "未命名对话")}</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
))
) : (
<p className="official-sidebar-empty">暂无历史对话</p>
)}
</div>
<div className="official-sidebar-footer">
<button onClick={() => go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/settings`)} type="button">
<Settings2 aria-hidden="true" size={18} />
<span>设置和更多</span>
<ChevronDown aria-hidden="true" size={15} />
</button>
<div className="official-user-row">
<span className="official-user-avatar"><UserRound aria-hidden="true" size={15} /></span>
<span>{getAccountDisplayName()}</span>
</div>
</div>
</aside>
<div className="official-main-panel">
{page === "chat" && (
<ChatWorkspace
agentId={null}
key={location.key}
routeThreadId={chatRoute.threadId}
/>
)}
{page === "agents" && (
<AgentsPage
openAgent={(agentId) =>
go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/new`)
}
/>
)}
{page === "agent-chat" && (
<ChatWorkspace
agentId={chatRoute.agentId}
key={location.key}
routeThreadId={chatRoute.threadId}
/>
)}
{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 />}
</div>
<Toaster position="top-center" />
</main>
);
}
+3 -11
View File
@@ -5,7 +5,6 @@ import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isWujieEnv } from "@/core/embed/wujie";
import {
isEmbedActive,
@@ -34,7 +33,6 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const location = useLocation();
const { theme } = useTheme();
const isDark = theme === "dark";
@@ -45,17 +43,11 @@ function PageLayout() {
const thirdLevel = useActiveThirdLevel();
const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0;
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
// (default_layout_mode);两者都没有时使用内置默认「简洁模式」(useLayoutMode 兜底,
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode();
const chromeByPreference =
forceCollapsedSidebar
? false
: layoutMode === "full"
? true
: layoutMode === "compact"
? false
: sidebarEnabled;
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo(
+30 -19
View File
@@ -138,8 +138,9 @@ const emptyForm = {
html_negative_prompt: "",
html_reference_favorite_id: "",
html_model_name: "",
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:关闭=用模板默认数据,开启=检索抽取。
html_template_extract: false,
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退
// 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。
html_template_extract: true,
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
@@ -331,7 +332,8 @@ export default function ScheduledTasksPage({
html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "",
html_template_extract: !!html.template_extract,
// 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。
html_template_extract: html.template_extract !== false,
});
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
@@ -358,6 +360,10 @@ export default function ScheduledTasksPage({
isTemplateAgent || form.require_html,
);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 「参考收藏页面」选中的内置模板(require_html 模式,值形如 tpl:<id>)。
const selectedTplRef = htmlTemplates.find(
(t) => `tpl:${t.id}` === form.html_reference_favorite_id,
);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
isTemplateAgent && selectedTemplate?.convertible === false;
@@ -1396,23 +1402,28 @@ export default function ScheduledTasksPage({
)}
</div>
</div>
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关。关闭=用模板默认数据。 */}
{form.html_reference_favorite_id.startsWith("tpl:") && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">执行数据抽取</p>
<p className="text-muted-foreground text-xs">
关闭则直接用所选模板的默认数据生成(含全部子页面);开启才检索并按模板结构抽取数据填充。
</p>
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关(仅可转换模板;只展示模板给提示)。 */}
{form.html_reference_favorite_id.startsWith("tpl:") &&
(selectedTplRef?.convertible === false ? (
<p className="text-muted-foreground rounded-md border p-3 text-xs">
该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面,不做数据抽取。
</p>
) : (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">执行数据抽取</p>
<p className="text-muted-foreground text-xs">
开启后执行时由所选智能体检索生成内容,并按模板结构抽取数据填充;抽取不到的子页面自动回退模板原有数据。关闭则直接用所选模板的默认数据生成(含全部子页面)。
</p>
</div>
<Switch
checked={form.html_template_extract}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, html_template_extract: v }))
}
/>
</div>
<Switch
checked={form.html_template_extract}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, html_template_extract: v }))
}
/>
</div>
)}
))}
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">生成模型</p>
<Select
+49 -3
View File
@@ -1,9 +1,55 @@
import { BookOpenCheck, Sparkles } from "lucide-react";
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function SkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "knowledge") next.set("tab", "knowledge");
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillSettingsPage />
</div>
<Tabs
value={tab}
onValueChange={changeTab}
className="flex size-full min-h-0 flex-col gap-0 overflow-hidden"
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
</p>
</div>
<TabsList>
<TabsTrigger value="skills">
<Sparkles className="mr-2 size-4" />
技能列表
</TabsTrigger>
<TabsTrigger value="knowledge">
<BookOpenCheck className="mr-2 size-4" />
归纳到知识库
</TabsTrigger>
</TabsList>
</header>
<TabsContent value="skills" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<SkillSettingsPage embedded />
</TabsContent>
<TabsContent value="knowledge" className="mt-0 min-h-0 flex-1 overflow-hidden">
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
);
}
+18 -1
View File
@@ -309,7 +309,24 @@ export default function TaskButtonsPage() {
className="h-8 w-24"
title="发给宿主的参数名(默认 id)"
/>
<span className="text-muted-foreground">= 任务id(数字)</span>
<span className="text-muted-foreground">=</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>
)}
<span className="text-muted-foreground w-full text-[11px]">
+220 -123
View File
@@ -1,85 +1,97 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import {
Navigate,
Route,
Routes,
useLocation,
useParams,
} from "react-router-dom";
import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
} from "@/core/page-layout/chats-iframe-route";
import { isEmbeddedHost } from "@/core/embed/frame-context";
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
} from "@/components/workspace/appearance-controls";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import ArtifactLibraryPage from './ArtifactLibraryPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import AssistantKnowledgePage from './AssistantKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage";
import AgentsOrbitPage from "./AgentsOrbitPage";
import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminAgentsPage from "./AdminAgentsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminSkillsPage from "./AdminSkillsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmWikiKnowledgePage from "./LlmWikiKnowledgePage";
import AssistantKnowledgePage from "./AssistantKnowledgePage";
import WeKnoraWikiManagementPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import WeKnoraPackageTransfersPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraPackageTransfersPage";
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
import ModelManagementPage from "./ModelManagementPage";
import PublishSquaresSettingsPage from "./PublishSquaresSettingsPage";
import SkillCompressionSettingsPage from "./SkillCompressionSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import PositionsPage from "./PositionsPage";
import PositionRolesPage from "./PositionRolesPage";
import PluginManagementPage from "./PluginManagementPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import DispatchChainPreviewPage from "@/roundtable-planning/pages/DispatchChainPreviewPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import ReportCollaborationPage, {
ReportCollaborationSessionPage,
} from "@/report-collaboration/pages/ReportCollaborationPage";
import "@/report-collaboration/styles/report-collaboration.css";
import { cn } from "@/lib/utils";
function NotebookDetailPageWithKey() {
const { notebookId } = useParams<{ notebookId: string }>();
@@ -92,14 +104,37 @@ function NewAgentPageWithKey() {
return <NewAgentPage key={thread_id} />;
}
// 报告协作会话页:按 sessionId 重挂(旧 SSE/状态不串台)。
function ReportCollaborationSessionRoute({
layoutProps,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactActionsHidden: boolean;
compactWorkspaceSidebar: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
const { sessionId } = useParams<{ sessionId: string }>();
return (
<WorkspaceLayout {...layoutProps}>
<ReportCollaborationSessionPage key={sessionId} sessionId={sessionId ?? ""} />
</WorkspaceLayout>
);
}
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/library' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/")
);
}
@@ -157,47 +192,52 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const showCompactTopActions =
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
headerHidden &&
!compactActionsHidden &&
shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
? "0rem"
: pageSidebarDark
? pageSidebarCollapsed
? '0rem'
: '118px'
? "0rem"
: "118px"
: pageSidebarCollapsed
? '4rem'
: '16rem';
? "4rem"
: "16rem";
return (
<SidebarProvider
className={[
'relative h-full w-full overflow-x-hidden',
'[&_[data-slot=sidebar-container]]:absolute',
'[&_[data-slot=sidebar-container]]:left-0',
'[&_[data-slot=sidebar-container]]:top-0',
].join(' ')}
"relative h-full w-full overflow-x-hidden",
"[&_[data-slot=sidebar-container]]:absolute",
"[&_[data-slot=sidebar-container]]:left-0",
"[&_[data-slot=sidebar-container]]:top-0",
].join(" ")}
style={
{
'--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem',
"--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem",
} as CSSProperties
}
// Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? "general" : "workspace"}`}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
forceWorkspaceSidebarCollapsed
? false
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
: (defaultHistorySidebarOpen ??
(isGeneralQa || !compactWorkspaceSidebar))
}
open={forceWorkspaceSidebarCollapsed ? false : undefined}
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
onOpenChange={
forceWorkspaceSidebarCollapsed ? () => undefined : undefined
}
disableToggle={forceWorkspaceSidebarCollapsed}
>
{!workspaceSidebarHidden && (
@@ -207,7 +247,7 @@ export function WorkspaceLayout({
/>
)}
<SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
className={cn("min-w-0", insetOverflowHidden && "overflow-hidden")}
>
{showCompactTopActions ? (
<>
@@ -244,9 +284,14 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
*/
function NewChatContent() {
const location = useLocation();
const agentId = new URLSearchParams(location.search).get('agent');
const agentId = new URLSearchParams(location.search).get("agent");
if (agentId) {
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
return (
<AgentChatPage
key={`${agentId}:${location.key}`}
embeddedAgentId={agentId}
/>
);
}
return <ChatPage />;
}
@@ -393,8 +438,8 @@ function AgentAwareChatThreadContent() {
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
const isTaskThread = typeof record.metadata?.taskId === "string";
setAgentId(isTaskThread ? null : (agentRefOfThread(record) ?? null));
})
.catch(() => {
if (!cancelled) setAgentId(null);
@@ -537,11 +582,18 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats_iframe/new"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
element={
<IframeChatEntryRoute
layoutProps={layoutProps}
hasThreadParam={false}
/>
}
/>
<Route
path="chats_iframe/:thread_id"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
element={
<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />
}
/>
<Route
path="agents"
@@ -569,13 +621,26 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
<Route
path="agents/orbit"
element={
<WorkspaceLayout {...layoutProps}>
<AgentsOrbitPage />
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
<Navigate
to="/page/workspace/agents/sentiment-analysis/chats/new"
replace
/>
) : (
<Navigate to="/page/workspace/agents" replace />
)
@@ -605,7 +670,9 @@ export default function WorkspaceRoutes({
<AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
headerHidden && !compactActionsHidden ? (
<CompactAppearanceActions />
) : undefined
}
/>
</ChatRuntime>
@@ -704,7 +771,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
<RoundtablePlanningPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -714,7 +783,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<EightBfRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -724,7 +795,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<PositionRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -745,6 +818,20 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* AgentScope 报告协作工作台:左对话(复用 MessageList)/右画布的独立新页面,
不与旧多智能体会商共享状态。key 绑定 sessionId——切换会话重挂干净的页面。 */}
<Route
path="report-collaboration"
element={
<WorkspaceLayout {...layoutProps}>
<ReportCollaborationPage />
</WorkspaceLayout>
}
/>
<Route
path="report-collaboration/:sessionId"
element={<ReportCollaborationSessionRoute layoutProps={layoutProps} />}
/>
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
<Route
path="roundtable/chain-preview"
@@ -794,6 +881,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/packages"
element={
<WorkspaceLayout {...layoutProps}>
<WeKnoraPackageTransfersPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/markdown-editor"
element={
@@ -818,7 +913,9 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
<ScheduledTasksPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</WorkspaceLayout>
}
/>
@@ -834,7 +931,7 @@ export default function WorkspaceRoutes({
<Route
path="admin"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
@@ -859,7 +956,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
@@ -871,7 +968,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
</WorkspaceLayout>
@@ -883,7 +980,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
</WorkspaceLayout>
@@ -903,7 +1000,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/plugins"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PluginManagementPage />
</WorkspaceLayout>
@@ -915,7 +1012,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionsPage />
</WorkspaceLayout>
@@ -927,7 +1024,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
@@ -939,7 +1036,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
@@ -951,7 +1048,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
@@ -963,7 +1060,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
</WorkspaceLayout>
@@ -975,7 +1072,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
@@ -987,7 +1084,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/model-concurrency"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelConcurrencySettingsPage />
</WorkspaceLayout>
@@ -999,7 +1096,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/publish-squares"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PublishSquaresSettingsPage />
</WorkspaceLayout>
@@ -1011,7 +1108,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
@@ -1023,7 +1120,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
@@ -1035,7 +1132,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
@@ -99,6 +99,7 @@ import {
extractToolResultPayload,
hasToolCalls,
} from "@/core/messages/utils";
import { normalizeSearchResults } from "@/core/messages/search-results";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import {
type DeepResearchSession,
@@ -273,6 +274,23 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
return parts.join("\n\n");
}
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
function isRefusalReportText(value: string): boolean {
const text = value || "";
const compact = text.replace(/\s+/g, "");
// Empty means the writer has not emitted Markdown yet (thinking / connecting).
// Treating that as a refusal opened the sandbox on an error card while the
// model was still working.
if (!compact) return false;
const headings = text.match(/^#{1,6}\s+\S+/gm) ?? [];
// A structured report may discuss evidence gaps. Only treat a short,
// unstructured refusal as “no report”, otherwise the zero-source fallback
// is hidden behind the diagnostic card and looks like writing failed.
if (headings.length >= 2) return false;
return REPORT_REFUSAL_PATTERN.test(compact);
}
function researchExportBaseName(session: DeepResearchSession): string {
const title = (session.title || session.query || "研究报告")
.replace(/[\\/:*?"<>|]/g, "-")
@@ -416,6 +434,100 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
return unique.size;
}
const COLLECTOR_PERSIST_LIMIT = 80;
const WRITE_FALLBACK_MATERIAL_LIMIT = 20;
const SKIP_COLLECTOR_PERSIST_TOOLS = new Set(["ask_clarification", "write_todos"]);
function collectorMaterialKey(item: Record<string, unknown>): string {
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
return text(item.id) || text(item.url) || `${text(item.title)}\n${text(item.raw_content).slice(0, 240)}`;
}
function isCompleteToolPayload(message: Message): boolean {
const payload = extractToolResultPayload(message);
if (payload == null) return false;
if (typeof payload === "string") {
const trimmed = payload.trim();
if (!trimmed) return false;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
return trimmed.length > 20;
}
return typeof payload === "object";
}
/** Freeze a compact evidence pool for a worker-independent report job. */
function snapshotCollectorMaterials(
messages: readonly Message[] | undefined,
options?: { limit?: number; includeSummary?: boolean },
): Record<string, unknown>[] {
if (!messages?.length) return [];
const limit = options?.limit ?? COLLECTOR_PERSIST_LIMIT;
const frozen: Record<string, unknown>[] = [];
const seen = new Set<string>();
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
const addNormalized = (item: ReturnType<typeof normalizeSearchResults>[number], toolName: string) => {
const url = text(item.url);
const id = text(item.id) || text(item.recUuid) || text(item.uuid) || text(item.docId);
const title = text(item.title) || `检索结果 ${frozen.length + 1}`;
const content = text(item.content) || text(item.snippet);
if (!url && !id && !content) return;
const key = id || url || `${title}\n${content.slice(0, 240)}`;
if (seen.has(key) || frozen.length >= limit) return;
seen.add(key);
frozen.push({
id: id || undefined,
title: title.slice(0, 500),
url: url.slice(0, 2_000) || undefined,
raw_content: content.slice(0, 6_000),
snippet: (text(item.snippet) || content).slice(0, 500) || undefined,
source: text(item.source) || toolName || "collector",
source_type: toolName === "web_search" ? "web" : "other",
published_at: text(item.published_date) || text(item.publishedDate) || text(item.date) || undefined,
rec_uuid: text(item.recUuid) || text(item.uuid) || undefined,
});
};
for (const message of messages) {
if (message.type === "tool" && message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) {
continue;
}
const toolName = message.name || (message.type === "tool" ? "collector_tool" : "");
const payload = message.type === "tool" || message.type === "ai"
? extractToolResultPayload(message)
: undefined;
const rows = normalizeSearchResults(
payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: typeof payload === "string"
? payload
: undefined,
);
for (const row of rows) addNormalized(row, toolName || "collector");
}
if (!frozen.length && options?.includeSummary !== false) {
const summary = [...messages].reverse().find(
(message) => message.type === "ai" && extractContentFromMessage(message).trim(),
);
const content = summary ? extractContentFromMessage(summary).trim() : "";
if (content) {
frozen.push({
title: "收集员小结",
raw_content: content.slice(0, 6_000),
snippet: content.slice(0, 500),
source: "collector",
source_type: "other",
});
}
}
return frozen;
}
function countEventMaterials(events: ProgressEvent[]): number {
const unique = new Set<string>();
for (const event of events) {
@@ -512,9 +624,18 @@ function DeepResearchWorkbenchInner() {
const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null);
const boundStreamJobRef = useRef<string | null>(null);
const openReportRef = useRef<() => void>(() => {});
const refetchLiveJobRef = useRef<() => void>(() => {});
const inlineGenerationSessionRef = useRef<DeepResearchSession | null>(null);
// Chat-collection flow bookkeeping.
const pendingInitialQuery = useRef<string | null>(null);
const finishedTurnKey = useRef<string | null>(null);
const persistedCollectorKey = useRef("");
const persistedCollectorToolIds = useRef(new Set<string>());
const persistedCollectorMaterialKeys = useRef(new Set<string>());
const collectorPersistChain = useRef(Promise.resolve());
const collectorSessionIdRef = useRef<string | null>(null);
const followUpAbortRef = useRef<AbortController | null>(null);
const refetchSession = sessionQuery.refetch;
@@ -523,6 +644,22 @@ function DeepResearchWorkbenchInner() {
const refetchReportVariants = reportVariants.refetch;
const session = sessionQuery.data;
const loggedSessionDiagnosticKey = useRef<string | null>(null);
useEffect(() => {
if (!session) return;
const diagnostics = session.runtimeDiagnostics;
if (!session.error && !diagnostics) return;
const key = `${session.id}:${session.activeJobId ?? session.usage?.lastJobId ?? ""}:${session.error ?? ""}`;
if (loggedSessionDiagnosticKey.current === key) return;
loggedSessionDiagnosticKey.current = key;
console.warn("[深度研究] session_runtime_diagnostic", {
sessionId: session.id,
status: session.status,
error: session.error,
activeJobId: session.activeJobId,
runtimeDiagnostics: diagnostics,
});
}, [session]);
const generatedReportVariants = useMemo(() => {
const byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item]));
if (inlineGeneratedReport && session && inlineGeneratedReport.parentSessionId === session.id) {
@@ -585,7 +722,7 @@ function DeepResearchWorkbenchInner() {
: "";
// 全文改写启动后,右侧只渲染临时草稿:首个 token 到达前保持空白,
// 不把旧报告混在新稿前面。真实 report.md 仍只在后台校验 + CAS 成功后替换。
const displayedReport = reportDocumentRewrite.isActive
const rawDisplayedReport = reportDocumentRewrite.isActive
? reportDocumentRewrite.run.draft
: reportRewritePreview
|| (viewingLiveGeneration
@@ -593,6 +730,65 @@ function DeepResearchWorkbenchInner() {
: viewingParentReport
? (session?.reportMarkdown || "")
: sandboxVariantSession?.reportMarkdown || "");
// Never hide a backend response. Hiding refusal prose also removed the
// virtual report file and its sandbox-open trigger, leaving operators with
// neither a report nor an error. The backend now replaces refusal output at
// its final persistence gate; this client-side signal remains visible when
// a worker is stale or a new provider wording escapes that guard.
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
const streamStillOpen = Boolean(run.jobId && run.phase !== "done" && run.phase !== "failed");
const liveWriting = Boolean(
isRunning
|| isAwaitingReview
|| reportDocumentRewrite.isActive
|| streamStillOpen
|| (viewingLiveGeneration && run.phase !== "done"),
);
// Never replace a live write with the operator diagnostic. An empty sandbox
// during thinking is normal; the JSON dump is only for a finished run that
// still has no usable Markdown.
const repairOnlyError = typeof session?.error === "string"
&& session.error.includes("session_read_repair");
const showRunDiagnostic = Boolean(
!liveWriting && !hasUsableDisplayedReport && (
refusalReportDetected
|| session?.status === "failed"
|| (Boolean(session?.error) && !repairOnlyError)
),
);
const runtimeDiagnosticText = JSON.stringify(
session?.runtimeDiagnostics ?? {
revision: "frontend-fallback-diagnostic",
session: {
id: session?.id,
status: session?.status,
sourceCount: session?.sourceCount ?? 0,
activeJobId: session?.activeJobId,
},
error: session?.error ?? "报告正文被识别为拒绝型输出",
invalidOutputPreview: refusalReportDetected ? rawDisplayedReport.slice(0, 1_200) : undefined,
},
null,
2,
);
const diagnosticMarkdown = showRunDiagnostic
? [
"## 系统运行说明",
"",
"> 本次报告生成链路出现异常。系统不能据此判断为‘没有收集到素材’,已有素材不会被删除。请将下面的诊断信息复制给管理员排查。",
"",
"```json",
runtimeDiagnosticText,
"```",
].join("\n")
: "";
// Invalid refusal prose (or a failed run with no report) is replaced instead
// of repeated to the user. Any usable normal/fallback/partial report is shown
// as-is and never receives diagnostic text.
const displayedReport = showRunDiagnostic
? `# 报告生成异常\n\n${diagnosticMarkdown}`
: rawDisplayedReport;
const updateFullReportRewritePresentation = useCallback(
(presentationId: string | null, event: DocumentRewriteStreamEvent) => {
@@ -676,10 +872,19 @@ function DeepResearchWorkbenchInner() {
report: displayedReport,
writing: sandboxWriting,
onFirstReportContent: handleFirstReportContent,
// Open only after thinking has finished and the first write token exists.
// Thinking stays on the left progress card; an empty writing-phase stub
// must not steal the right panel.
activity: Boolean(run.report.trim()),
// Open on the first visible write, including first-generation reports and
// a new sibling. Stay put if the user has already switched to another card.
autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive,
});
openReportRef.current = openReport;
inlineGenerationSessionRef.current = inlineGenerationSession;
refetchLiveJobRef.current = () => {
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
};
const sandboxVisible = sandboxOpen && Boolean(
displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport,
);
@@ -688,6 +893,13 @@ function DeepResearchWorkbenchInner() {
openReport();
}, [openReport]);
useEffect(() => {
if (!viewingLiveGeneration && !reportDocumentRewrite.isActive) return;
if (!run.report.trim()) return;
setSandboxOpen(true);
openReport();
}, [openReport, reportDocumentRewrite.isActive, run.report, viewingLiveGeneration]);
// ArtifactFileList invokes its handler synchronously. Switch the virtual
// sandbox file first, then open it from an effect once useReportSandbox has
// rebuilt its URL for the generated report session.
@@ -986,6 +1198,66 @@ function DeepResearchWorkbenchInner() {
session,
]);
// Persist each completed search as it lands in the step list, instead of
// waiting until the whole collection turn finishes and writing 80 rows at once.
useEffect(() => {
collectorSessionIdRef.current = session?.id ?? null;
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
persistedCollectorKey.current = "";
collectorPersistChain.current = Promise.resolve();
}, [session?.id]);
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "collecting" && session.status !== "draft") return;
const remaining = COLLECTOR_PERSIST_LIMIT - persistedCollectorMaterialKeys.current.size;
if (remaining <= 0) return;
const pending: Record<string, unknown>[] = [];
const claimedToolIds: string[] = [];
const claimedKeys: string[] = [];
for (const message of collectorThread.messages ?? []) {
if (message.type !== "tool" || !message.id) continue;
if (message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) continue;
if (persistedCollectorToolIds.current.has(message.id)) continue;
if (!isCompleteToolPayload(message)) continue;
const rows = snapshotCollectorMaterials([message], {
limit: remaining - pending.length,
includeSummary: false,
});
persistedCollectorToolIds.current.add(message.id);
claimedToolIds.push(message.id);
for (const row of rows) {
const key = collectorMaterialKey(row);
if (!key || persistedCollectorMaterialKeys.current.has(key)) continue;
persistedCollectorMaterialKeys.current.add(key);
claimedKeys.push(key);
pending.push(row);
if (pending.length >= remaining) break;
}
if (pending.length >= remaining) break;
}
if (!pending.length) return;
const sessionId = session.id;
collectorPersistChain.current = collectorPersistChain.current
.then(() => actions.persistCollectorMaterials.mutateAsync({
sessionId,
collectorMaterials: pending,
quiet: true,
}))
.catch(() => {
if (collectorSessionIdRef.current !== sessionId) return;
for (const toolId of claimedToolIds) persistedCollectorToolIds.current.delete(toolId);
for (const key of claimedKeys) persistedCollectorMaterialKeys.current.delete(key);
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
// Turn finished without a pending clarification → surface the config card.
// Requiring a non-human tail closes the window between the optimistic user
// message and the run flag flipping, where the turn would look "ended".
@@ -1002,7 +1274,20 @@ function DeepResearchWorkbenchInner() {
if (finishedTurnKey.current === key) return;
finishedTurnKey.current = key;
if (session) {
void actions.updateStatus.mutate({ sessionId: session.id, status: "awaiting_report" });
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
}
}, [
actions.updateStatus,
@@ -1010,10 +1295,45 @@ function DeepResearchWorkbenchInner() {
collectorPendingClarification,
collectorStreaming,
collectorTailIsHuman,
collectorThread.messages,
isChatSession,
session,
]);
// After a refresh the collector messages reload from the thread. Persist
// anything the incremental writer missed so 「选择结构再生成」 still has rows.
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "awaiting_report") return;
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
if (!leftover.length) return;
const persistKey = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
if (persistedCollectorKey.current === persistKey) return;
persistedCollectorKey.current = persistKey;
void actions.persistCollectorMaterials.mutateAsync({
sessionId: session.id,
collectorMaterials: leftover,
}).catch(() => {
persistedCollectorKey.current = "";
for (const item of leftover) {
persistedCollectorMaterialKeys.current.delete(collectorMaterialKey(item));
}
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
const collectedMaterialCount = useMemo(
() => countCollectorMaterials(collectorThread.messages),
[collectorThread.messages],
@@ -1267,18 +1587,21 @@ function DeepResearchWorkbenchInner() {
if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return;
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Keep the sandbox closed until a report delta is available. `useReportSandbox`
// opens it at that first visible write, rather than during model thinking
// or after the report has already finished. If the user has already opened
// another file card, leave that view alone.
const keepCurrentSandbox = Boolean(
inlineGenerationSession && sandboxSessionId !== inlineGenerationSession.id,
inlineGenerationSessionRef.current && sandboxSessionId !== inlineGenerationSessionRef.current.id,
);
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
const jobChanged = boundStreamJobRef.current !== streamJobId;
if (jobChanged) {
boundStreamJobRef.current = streamJobId;
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Only close the sandbox when a *new* job starts. Reconnecting the same
// write stream used to wipe the live draft and flip auto-open off, so
// thinking could finish while the right panel stayed closed.
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
}
const flushReportEvents = () => {
reportAnimationFrame.current = null;
@@ -1329,6 +1652,7 @@ function DeepResearchWorkbenchInner() {
continue;
}
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion;
if (version < reportVersion) continue;
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
const currentTarget = reportTargets[target];
if (!currentTarget || version >= currentTarget.version) {
@@ -1353,6 +1677,13 @@ function DeepResearchWorkbenchInner() {
: eventKey(event);
if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key);
const firstWriteToken = (
event.type === "report_delta" || event.type === "report_chunk"
) && typeof event.payload?.delta === "string" && event.payload.delta.trim();
if (firstWriteToken && !keepCurrentSandbox) {
setSandboxOpen(true);
openReportRef.current();
}
if (
event.type === "plan_delta" ||
event.type === "plan_reasoning" ||
@@ -1385,8 +1716,9 @@ function DeepResearchWorkbenchInner() {
},
onTerminal: () => {
if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
if (inlineGenerationSession) {
void getResearchSession(inlineGenerationSession.id).then((completed) => {
const liveInline = inlineGenerationSessionRef.current;
if (liveInline) {
void getResearchSession(liveInline.id).then((completed) => {
setInlineGeneratedReport((current) => current?.session.id === completed.id
? { ...current, session: completed }
: current);
@@ -1395,7 +1727,7 @@ function DeepResearchWorkbenchInner() {
console.error("刷新新报告状态失败:", error);
});
}
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
refetchLiveJobRef.current();
},
onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current),
},
@@ -1409,15 +1741,25 @@ function DeepResearchWorkbenchInner() {
}
pendingReportEvents.current = [];
};
}, [activeJobId, inlineGenerationSession, isAwaitingReview, isRunning, refetchReportVariants, refetchSession, refetchSessions, refetchSources, sandboxSessionId, session?.status, streamJobId]);
}, [activeJobId, isAwaitingReview, isRunning, sandboxSessionId, session?.status, streamJobId]);
useEffect(() => {
if (!session?.reportMarkdown) return;
// A background regeneration writes into `run` for the child job. Do not
// copy the parent markdown over that live stream.
if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return;
setRun((current) => current.jobId === streamJobId ? { ...current, report: session.reportMarkdown! } : current);
}, [inlineGenerationSession, session?.id, session?.reportMarkdown, streamJobId]);
// A new write must start from a blank sandbox. Copying the previous
// durable report into `run` opened the panel with stale text, then the
// model only thought (summary / reasoning) and the sandbox never grew.
if (isRunning || isAwaitingReview) return;
const lastJobId = typeof session.usage?.lastJobId === "string" ? session.usage.lastJobId : null;
setRun((current) => {
if (current.jobId !== streamJobId) return current;
if (current.jobId && lastJobId !== current.jobId) return current;
if (current.report === session.reportMarkdown) return current;
return { ...current, report: session.reportMarkdown! };
});
}, [inlineGenerationSession, isAwaitingReview, isRunning, session?.id, session?.reportMarkdown, session?.usage?.lastJobId, streamJobId]);
// ── actions ────────────────────────────────────────────────────────────────
@@ -1435,6 +1777,10 @@ function DeepResearchWorkbenchInner() {
setFollowUpStream(null);
pendingInitialQuery.current = null;
finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
}, []);
// 历史报告不抢占对话区域:切换研究记录时总是先折叠右侧沙箱;用户可通过
@@ -1484,6 +1830,10 @@ function DeepResearchWorkbenchInner() {
});
pendingInitialQuery.current = query;
finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
setSelectedId(created.id);
setSandboxOpen(false);
} catch (error) {
@@ -1800,10 +2150,16 @@ function DeepResearchWorkbenchInner() {
if (!activeJobId) return;
try {
await actions.cancelJob.mutateAsync(activeJobId);
toast.success("已请求停止研究");
toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "停止研究失败");
const message = error instanceof Error ? error.message : "停止研究失败";
if (/not in a cancellable state/i.test(message) || /409/.test(message)) {
toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]);
return;
}
toast.error(message);
}
}, [actions.cancelJob, activeJobId, refetchSession, refetchSessions]);
@@ -1841,14 +2197,23 @@ function DeepResearchWorkbenchInner() {
const handleReopenReportCard = useCallback(async () => {
if (!session) return;
try {
await actions.updateStatus.mutateAsync({ sessionId: session.id, status: "awaiting_report" });
await actions.updateStatus.mutateAsync({
sessionId: session.id,
status: "awaiting_report",
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}),
});
await refetchSession();
} catch (error) {
toast.error(error instanceof Error ? error.message : "打开报告配置失败");
}
}, [actions.updateStatus, refetchSession, session]);
}, [actions.updateStatus, collectorThread.messages, refetchSession, session]);
const handleConfirmReportCard = useCallback(async (config: Record<string, unknown>) => {
const handleConfirmReportCard = useCallback(async (
config: Record<string, unknown>,
extras?: { writeWithoutMaterials?: boolean },
) => {
if (!session) return;
// Keep the composer's explicit choice authoritative when the collection
// phase turns into a report-writing job.
@@ -1856,17 +2221,47 @@ function DeepResearchWorkbenchInner() {
? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel }
: {};
try {
await actions.startJob.mutateAsync({
const writeWithoutMaterials = extras?.writeWithoutMaterials === true;
const collectorMaterials = writeWithoutMaterials
? []
: snapshotCollectorMaterials(collectorThread.messages, { limit: WRITE_FALLBACK_MATERIAL_LIMIT });
console.warn("[深度研究] browser_snapshot_sending", {
sessionId: session.id,
messageCount: collectorThread.messages?.length ?? 0,
toolMessageCount: (collectorThread.messages ?? []).filter((message) => message.type === "tool").length,
snapshotCount: collectorMaterials.length,
snapshotLimit: WRITE_FALLBACK_MATERIAL_LIMIT,
writeWithoutMaterials,
sampleTitles: collectorMaterials.slice(0, 5).map((item) => item.title),
});
const started = await actions.startJob.mutateAsync({
sessionId: session.id,
entry: "chat",
query: session.query,
title: session.title,
config: { ...config, ...modelConfig },
collectorMaterials,
forceNoMaterials: writeWithoutMaterials,
});
console.warn("[深度研究] browser_snapshot_accepted", started);
if (writeWithoutMaterials) {
toast.warning("已按无素材模式启动撰写,对话里收集到的资料本次不会送入写作。");
} else if (!collectorMaterials.length) {
toast.warning("未传入检索素材,将按无素材模式撰写。请看控制台 [深度研究] browser_snapshot_sending");
}
setSandboxOpen(false);
toast.success("开始撰写研究报告");
} catch (error) {
console.error("[深度研究] write_report_failed", {
sessionId: session.id,
query: session.query,
title: session.title,
detail: error instanceof Error ? error.message : String(error),
error,
});
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
}
}, [actions.startJob, selectedModel, session]);
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
const handleContinueCollecting = useCallback(() => {
document.getElementById("deep-research-composer")?.focus();
@@ -2059,7 +2454,7 @@ function DeepResearchWorkbenchInner() {
imageGenerationAvailable={imageGenerationAvailable}
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
submitting={actions.startJob.isPending}
onConfirm={(config) => void handleConfirmReportCard(config)}
onConfirm={(config, extras) => void handleConfirmReportCard(config, extras)}
onContinueCollecting={handleContinueCollecting}
/>
);
@@ -2739,7 +3134,7 @@ function ResearchConversation({
}) {
const active = session.status === "running" || session.status === "awaiting_input";
const hasTrace = active || events.length > 0;
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
const streamedSourceCount = countEventMaterials(events);
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
const requestedImageCount = requestedReportImageCount(session.config);
return (
@@ -2847,6 +3242,7 @@ type TraceSnapshot = {
images: TraceImage[];
imagePhaseFinished: boolean;
warnings: string[];
diagnostics: Record<string, unknown>[];
};
function traceRecord(value: unknown): Record<string, unknown> {
@@ -2884,6 +3280,8 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
const selectedIds = new Set<string>();
const removedIds = new Set<string>();
const warnings: string[] = [];
const diagnostics: Record<string, unknown>[] = [];
const diagnosticKeys = new Set<string>();
let curationReason: string | undefined;
let compressedSourceCount: number | undefined;
let reportCompleted = false;
@@ -2995,6 +3393,19 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
if (event.type === "warning" || event.type === "error") {
const message = traceString(payload.message) ?? traceString(payload.detail);
if (message && !warnings.includes(message)) warnings.push(message);
const diagnostic = traceRecord(payload.diagnostic);
if (Object.keys(diagnostic).length > 0) {
const item = {
code: traceString(payload.code) ?? event.type,
...(message ? { message } : {}),
...diagnostic,
};
const key = JSON.stringify(item);
if (!diagnosticKeys.has(key)) {
diagnosticKeys.add(key);
diagnostics.push(item);
}
}
if (traceString(payload.code) === "image_generation_failed") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
@@ -3027,6 +3438,7 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
imagePhaseFinished,
warnings,
diagnostics,
};
}
@@ -3123,6 +3535,7 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
</TraceStage>}
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
{snapshot.diagnostics.length > 0 && <details className="rounded-lg border border-amber-500/20 bg-amber-500/[0.05] px-3 py-2 text-xs"><summary className="cursor-pointer font-medium text-amber-800 dark:text-amber-200">运行诊断(用于排查,可复制)</summary><pre className="mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-background/70 p-2 text-[11px] leading-5 text-muted-foreground">{snapshot.diagnostics.map((item) => JSON.stringify(item, null, 2)).join("\n\n")}</pre></details>}
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
</div>}
</div>
@@ -507,7 +507,7 @@ export function ReportConfigCard({
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onConfirm: (config: Record<string, unknown>) => void;
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void;
}) {
const [mode, setMode] = useState<ResearchMode>(
@@ -521,6 +521,7 @@ export function ReportConfigCard({
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
@@ -572,9 +573,11 @@ export function ReportConfigCard({
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
<span className="font-medium">已收集 {materialCount} 条资料</span>
<span className="text-primary/70">将作为本次报告的写作依据</span>
<span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
</span>
</div>
</div>
</div>
@@ -621,6 +624,19 @@ export function ReportConfigCard({
)}
</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
<input
type="checkbox"
checked={writeWithoutMaterials}
onChange={(event) => setWriteWithoutMaterials(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">无素材写作</span>
忽略对话里已收集的资料,直接走无素材成稿,用来确认这条链路是否生效。已落库的来源不会被删除。
</span>
</label>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button
type="button"
@@ -710,7 +726,14 @@ export function ReportConfigCard({
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料
</Button>
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
<Button
size="sm"
disabled={submitting}
onClick={() => onConfirm(
buildReportCardConfig(mode, generateImages, options),
{ writeWithoutMaterials },
)}
>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告
@@ -8,8 +8,10 @@
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
* custom_outline),弹窗切换为展示 标题 + Markdown 大纲预览,右上角「返回」回列表。
* **点击卡片即「选择」该结构(内容回填到高级配置的报告结构 custom_outline,
* 卡片留在列表中高亮为当前使用,不进入详情)**。
* - 详情视图:通过卡片右上角「查看大纲结构」眼睛图标进入,展示 标题 + Markdown
* 大纲预览,右上角「返回」回列表。
*/
import { useEffect, useMemo, useState } from "react";
@@ -238,8 +240,9 @@ export function WorkflowConfigDialog({
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
return;
}
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
setDetailId(structure.id);
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
};
@@ -325,7 +328,7 @@ export function WorkflowConfigDialog({
</h2>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
</p>
</div>
<Button
@@ -501,7 +504,7 @@ export function WorkflowConfigDialog({
key={structure.id}
structure={structure}
applied={structure.id === appliedStructureId}
onOpen={() => handleSelect(structure)}
onSelect={() => handleSelect(structure)}
onPreview={(event) => {
event.stopPropagation();
setDetailId(structure.id);
@@ -750,7 +753,7 @@ export function WorkflowConfigDialog({
function StructureCard({
structure,
applied,
onOpen,
onSelect,
onPreview,
onDuplicate,
onEdit,
@@ -760,7 +763,7 @@ function StructureCard({
}: {
structure: ReportStructure;
applied: boolean;
onOpen: () => void;
onSelect: () => void;
onPreview: (event: React.MouseEvent) => void;
onDuplicate: (event: React.MouseEvent) => void;
onEdit: (event: React.MouseEvent) => void;
@@ -775,11 +778,11 @@ function StructureCard({
<div
role="button"
tabIndex={0}
onClick={onOpen}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
onSelect();
}
}}
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 ${
@@ -36,6 +36,14 @@ export type ReportStubAiMessage = {
}[];
};
export type ReportStubToolMessage = {
id: string;
type: "tool";
name: "write_file";
tool_call_id: string;
content: string;
};
interface UseReportSandboxOptions {
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
threadKey: string;
@@ -45,6 +53,13 @@ interface UseReportSandboxOptions {
writing: boolean;
/** First visible Markdown has arrived; the host can expand the right panel then. */
onFirstReportContent?: () => void;
/**
* The writing run has produced something observable (report text or model
* reasoning). Report text streams as non-durable live frames, so on a
* multi-worker backend it can lag well behind the actual write; this lets
* the host open the sandbox on the first sign of progress instead.
*/
activity?: boolean;
/**
* A structural regeneration writes a sibling file in the background. Leave
* the currently displayed report in the sandbox until the user opens that
@@ -58,6 +73,7 @@ export function useReportSandbox({
report,
writing,
onFirstReportContent,
activity = false,
autoOpen = true,
}: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts();
@@ -87,9 +103,37 @@ export function useReportSandbox({
wasWritingRef.current = writing;
}, [writing]);
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
const hadReportRef = useRef(Boolean(report.trim()));
useEffect(() => {
if (!report) return;
const hasReport = Boolean(report.trim());
// Keep the panel closed through reasoning. When the first Markdown token
// arrives, allow auto-open even if an earlier empty stub was prepared.
if (writing && hasReport && !hadReportRef.current) autoOpenedRef.current = false;
hadReportRef.current = hasReport;
}, [report, writing]);
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
//
// 清掉的是**正文**而不是文件本身:多 worker 部署下 report_delta 属于
// 进程内 live 帧,观察端可能连在另一个 worker 上,首帧要等 0.8s 的
// 落库轮询(report_chunk)才到。若此时把 stub 置为 null,右栏就没有
// 可打开的虚拟文件,"开始撰写即打开沙箱" 会整段失效。
useEffect(() => {
if (!report) {
if (writing) {
setStubMessage({
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content: "" } },
],
});
}
return;
}
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
@@ -106,16 +150,16 @@ export function useReportSandbox({
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
};
});
}, [messageId, report, toolCallId]);
}, [messageId, report, toolCallId, writing]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// 思考结束后的报告正文首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => {
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
if (!autoOpen || (!report.trim() && !activity) || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true;
onFirstReportContent?.();
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
}, [activity, autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => {
@@ -132,8 +176,23 @@ export function useReportSandbox({
return {
virtualUrl,
stubMessages: useMemo(
() => (currentStubMessage ? [currentStubMessage] : []),
[currentStubMessage],
() => {
if (!currentStubMessage) return [];
if (writing) return [currentStubMessage];
// ArtifactFileDetail determines whether a write is still streaming by
// matching the write_file call with its tool result. `isLoading=false`
// alone is intentionally ignored there, so completed and historical
// virtual reports must include this synthetic completion receipt.
const completedWrite: ReportStubToolMessage = {
id: `${toolCallId}-result`,
type: "tool",
name: "write_file",
tool_call_id: toolCallId,
content: "ok",
};
return [currentStubMessage, completedWrite];
},
[currentStubMessage, toolCallId, writing],
),
openReport,
};
@@ -0,0 +1,239 @@
.official-deerflow {
--official-ink: #232323;
--official-muted: #787878;
--official-border: #eeeeeb;
--official-sidebar: #fbfbfa;
--official-accent: #111111;
background: #fff;
color: var(--official-ink);
display: flex;
font-family: Inter, "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
height: 100vh;
isolation: isolate;
overflow: hidden;
}
.official-deerflow *,
.official-deerflow *::before,
.official-deerflow *::after { box-sizing: border-box; }
.official-deerflow button,
.official-deerflow input,
.official-deerflow textarea { font: inherit; }
.official-sidebar {
background: var(--official-sidebar);
border-right: 1px solid var(--official-border);
display: flex;
flex: 0 0 288px;
flex-direction: column;
min-width: 0;
padding: 16px 10px 12px;
transition: flex-basis 180ms ease;
}
.sidebar-collapsed .official-sidebar { flex-basis: 64px; }
.sidebar-collapsed .official-sidebar span,
.sidebar-collapsed .official-sidebar p,
.sidebar-collapsed .official-sidebar small,
.sidebar-collapsed .official-brand { display: none; }
.sidebar-collapsed .official-brand-row { justify-content: center; }
.sidebar-collapsed .official-collapse-button { transform: rotate(180deg); }
.sidebar-collapsed .official-nav-item,
.sidebar-collapsed .official-recent-chat,
.sidebar-collapsed .official-sidebar-footer > button,
.sidebar-collapsed .official-user-row { justify-content: center; }
.official-brand-row { align-items: center; display: flex; height: 34px; justify-content: space-between; padding: 0 4px 0 1px; }
.official-brand { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.official-collapse-button,
.official-icon-button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #787878; cursor: pointer; display: inline-flex; justify-content: center; padding: 6px; }
.official-collapse-button:hover,
.official-icon-button:hover { background: #f0f0ee; color: var(--official-ink); }
.official-nav { display: grid; gap: 3px; margin-top: 31px; }
.official-nav-item,
.official-recent-chat,
.official-sidebar-footer > button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #6e6e6e; cursor: pointer; display: flex; gap: 10px; min-width: 0; padding: 9px 8px; text-align: left; width: 100%; }
.official-nav-item { font-size: 16px; }
.official-nav-item:hover,
.official-recent-chat:hover,
.official-sidebar-footer > button:hover { background: #f0f0ee; color: #222; }
.official-nav-item.is-active { background: #efefed; color: #252525; font-weight: 600; }
.official-nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-recent-section { border-top: 1px solid var(--official-border); margin-top: 25px; padding-top: 18px; }
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-recent-chat small { color: #acaca9; font-size: 11px; }
.official-recent-chat.is-active { background: #efefed; color: #252525; }
.official-sidebar-loading,
.official-sidebar-error,
.official-sidebar-empty { align-items: center; color: #989895; display: flex; font-size: 12px; gap: 6px; margin: 0; padding: 8px; }
.official-sidebar-error { color: #b04747; }
.official-sidebar-footer { border-top: 1px solid var(--official-border); margin-top: auto; padding-top: 9px; }
.official-sidebar-footer > button { font-size: 14px; }
.official-sidebar-footer > button svg:last-child { margin-left: auto; }
.official-user-row { align-items: center; color: #777; display: flex; font-size: 13px; gap: 8px; margin: 10px 8px 1px; }
.official-user-avatar { align-items: center; background: #ecece9; border-radius: 50%; color: #71716f; display: inline-flex; height: 25px; justify-content: center; width: 25px; }
.official-main-panel { background: #fff; flex: 1; min-width: 0; overflow: auto; }
.official-page-header { align-items: flex-start; border-bottom: 1px solid var(--official-border); display: flex; justify-content: space-between; min-height: 104px; padding: 27px max(32px, 5vw); }
.official-page-header h1 { font-size: 24px; letter-spacing: -0.5px; line-height: 1.2; margin: 3px 0 7px; }
.official-page-header p { color: var(--official-muted); font-size: 14px; margin: 0; }
.official-eyebrow { color: #9a9a98 !important; font-size: 12px !important; }
.official-primary-button,
.official-secondary-button,
.official-outline-button { align-items: center; border-radius: 8px; cursor: pointer; display: inline-flex; gap: 7px; font-size: 14px; font-weight: 550; justify-content: center; white-space: nowrap; }
.official-primary-button { background: var(--official-accent); border: 1px solid var(--official-accent); color: #fff; padding: 9px 13px; }
.official-primary-button:hover { background: #303030; }
.official-secondary-button,
.official-outline-button { background: #fff; border: 1px solid #dededb; color: #3d3d3d; padding: 8px 12px; }
.official-secondary-button:hover,
.official-outline-button:hover { background: #f7f7f5; }
.official-secondary-button:disabled { cursor: not-allowed; opacity: .55; }
.official-chat-page { display: flex; flex-direction: column; height: 100%; min-height: 660px; }
.official-chat-header { align-items: center; min-height: 62px; padding-bottom: 12px; padding-top: 12px; }
.official-thread-title { align-items: center; display: flex; font-size: 14px; font-weight: 600; gap: 3px; }
.official-header-actions { align-items: center; display: flex; gap: 10px; }
.official-chat-canvas { align-items: center; display: flex; flex: 1; justify-content: center; min-height: 0; padding: 32px; }
.official-chat-canvas.has-history { align-items: stretch; justify-content: center; overflow: auto; }
.official-chat-welcome { margin: auto; max-width: 650px; text-align: center; transform: translateY(-35px); }
.official-logo-orb { align-items: center; background: linear-gradient(145deg, #202020, #626260); border-radius: 18px; color: white; display: inline-flex; height: 55px; justify-content: center; width: 55px; }
.official-chat-welcome h1 { font-size: clamp(25px, 3vw, 35px); letter-spacing: -1.15px; line-height: 1.2; margin: 21px 0 9px; }
.official-chat-welcome > p { color: var(--official-muted); font-size: 15px; margin: 0; }
.official-suggestion-grid { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); margin-top: 31px; text-align: left; }
.official-suggestion-grid button { background: #fff; border: 1px solid #e7e7e4; border-radius: 11px; color: #363636; cursor: pointer; min-height: 94px; padding: 14px; text-align: left; transition: border-color 120ms ease, box-shadow 120ms ease; }
.official-suggestion-grid button:hover { border-color: #bdbdba; box-shadow: 0 4px 18px #0000000a; }
.official-suggestion-grid strong,
.official-suggestion-grid span { display: block; }
.official-suggestion-grid strong { font-size: 14px; margin-bottom: 6px; }
.official-suggestion-grid span { color: #8a8a87; font-size: 12px; line-height: 1.45; }
.official-composer-shell { margin: 0 auto; max-width: 850px; padding: 0 24px 19px; width: 100%; }
.official-composer { align-items: flex-end; background: #fff; border: 1px solid #dcdcd8; border-radius: 13px; box-shadow: 0 7px 20px #00000008; display: flex; gap: 8px; padding: 11px 11px 10px 15px; }
.official-composer:focus-within { border-color: #adadaa; box-shadow: 0 0 0 3px #00000009; }
.official-composer textarea { background: transparent; border: 0; color: #333; flex: 1; font-size: 15px; line-height: 24px; max-height: 144px; outline: 0; resize: none; }
.official-composer textarea::placeholder { color: #a2a2a0; }
.official-composer button { align-items: center; background: #222; border: 0; border-radius: 8px; color: #fff; cursor: pointer; display: inline-flex; height: 31px; justify-content: center; transition: opacity 100ms ease; width: 31px; }
.official-composer button:disabled { cursor: not-allowed; opacity: .3; }
.official-composer-shell > p { color: #acaca8; font-size: 11px; margin: 9px 0 0; text-align: center; }
.official-message-list { display: grid; gap: 18px; margin: 0 auto; max-width: 760px; padding: 12px 0 22px; width: 100%; }
.official-message { border-radius: 12px; font-size: 15px; line-height: 1.65; padding: 14px 16px; }
.official-message-user { background: #f4f4f2; justify-self: end; max-width: 76%; }
.official-message-assistant { border: 1px solid #eeeeeb; justify-self: start; max-width: 88%; }
.official-message p { margin: 0; white-space: pre-wrap; }
.official-message-assistant p { margin: 10px 0 13px; }
.official-assistant-mark { align-items: center; color: #5c5c5a; display: flex; font-size: 12px; font-weight: 600; gap: 6px; }
.official-insight-card { background: #f7f7f5; border-radius: 8px; display: grid; gap: 3px; padding: 10px 11px; }
.official-insight-card strong { font-size: 13px; }
.official-insight-card span { color: #858582; font-size: 12px; }
.official-streaming-indicator { align-items: center; color: #7d7d7a; display: flex; font-size: 13px; gap: 7px; padding: 6px 1px; }
.official-streaming-indicator svg,
.official-loading-state svg,
.official-sidebar-loading svg { animation: official-spin 900ms linear infinite; }
.official-inline-error { color: #ae3e3e; font-size: 12px; margin: 0 auto 8px; max-width: 850px; padding: 0 24px; width: 100%; }
.official-content-page { margin: 0 auto; max-width: 1180px; min-height: 100%; }
.official-content-page .official-page-header { padding-left: max(32px, 5vw); padding-right: max(32px, 5vw); }
.official-toolbar { padding: 22px max(32px, 5vw) 0; }
.official-search { align-items: center; background: #f7f7f5; border: 1px solid transparent; border-radius: 8px; color: #8a8a87; display: flex; gap: 8px; max-width: 330px; padding: 8px 10px; }
.official-search:focus-within { background: #fff; border-color: #d1d1ce; }
.official-search input { background: transparent; border: 0; color: #333; font-size: 14px; min-width: 0; outline: 0; width: 100%; }
.official-agent-grid { display: grid; gap: 15px; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 24px max(32px, 5vw) 40px; }
.official-agent-card { border: 1px solid #e8e8e5; border-radius: 12px; min-height: 244px; padding: 19px; position: relative; }
.official-agent-card:hover { border-color: #cfcfcb; box-shadow: 0 8px 22px #00000008; }
.official-agent-avatar { align-items: center; border-radius: 9px; display: inline-flex; font-size: 13px; font-weight: 700; height: 38px; justify-content: center; width: 38px; }
.official-agent-avatar.violet { background: #eeeafe; color: #6d56b7; }
.official-agent-avatar.sky { background: #e6f4ff; color: #2786bc; }
.official-agent-avatar.amber { background: #fff1d7; color: #b46d0d; }
.official-agent-card h2 { font-size: 16px; letter-spacing: -.2px; margin: 17px 0 7px; }
.official-agent-card p { color: #80807e; font-size: 13px; line-height: 1.55; margin: 0; }
.official-agent-card button { background: transparent; border: 0; bottom: 17px; color: #3c3c3a; cursor: pointer; font-size: 13px; font-weight: 600; left: 14px; padding: 5px; position: absolute; }
.official-agent-card button:hover { text-decoration: underline; }
.official-query-error { color: #ae3e3e; font-size: 14px; margin: 26px max(32px, 5vw); }
.official-loading-state { align-items: center; color: #7e7e7b; display: flex; font-size: 14px; gap: 8px; justify-content: center; min-height: 300px; }
.official-empty-panel { align-items: center; display: flex; flex-direction: column; justify-content: center; min-height: 430px; padding: 32px; text-align: center; }
.official-empty-icon { align-items: center; background: #f3f3f1; border-radius: 13px; color: #666; display: flex; height: 52px; justify-content: center; width: 52px; }
.official-empty-panel h2 { font-size: 18px; margin: 17px 0 7px; }
.official-empty-panel p { color: #858582; font-size: 14px; margin: 0 0 18px; }
.official-settings-list { display: grid; gap: 8px; padding: 25px max(32px, 5vw); }
.official-setting-row { background: #fff; border: 1px solid #e8e8e5; border-radius: 10px; color: #3b3b39; display: block; padding: 15px 17px; text-align: left; }
.official-settings-list strong,
.official-settings-list small { display: block; }
.official-settings-list strong { font-size: 14px; }
.official-settings-list small { color: #888885; font-size: 12px; margin-top: 3px; }
.official-task-list { display: grid; gap: 9px; padding: 24px max(32px, 5vw) 40px; }
.official-task-card { align-items: center; background: #fff; border: 1px solid #e8e8e5; border-radius: 10px; color: #393937; display: flex; min-width: 0; padding: 5px 6px 5px 15px; transition: border-color 120ms ease, box-shadow 120ms ease; }
.official-task-card:hover { background: #fafaf8; border-color: #d2d2ce; }
.official-task-card.is-selected { border-color: #969692; box-shadow: 0 0 0 3px #00000008; }
.official-task-card-main { align-items: center; background: transparent; border: 0; color: inherit; cursor: pointer; display: grid; flex: 1; gap: 12px; grid-template-columns: 38px minmax(0, 1fr) auto auto; min-width: 0; padding: 8px 0; text-align: left; }
.official-task-icon { align-items: center; background: #f0f0ed; border-radius: 8px; color: #686865; display: flex; height: 34px; justify-content: center; width: 34px; }
.official-task-copy { min-width: 0; }
.official-task-copy strong,
.official-task-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-task-copy strong { font-size: 14px; }
.official-task-copy small { color: #888885; font-size: 12px; margin-top: 3px; }
.official-task-status { background: #eef5ef; border-radius: 999px; color: #46804e; font-size: 11px; padding: 3px 8px; white-space: nowrap; }
.official-task-status.is-running,
.official-task-status.is-pending { background: #fff4de; color: #a97018; }
.official-task-status.is-paused { background: #f0f0ee; color: #7e7e7b; }
.official-task-status.is-failed { background: #fff0f0; color: #b34e4e; }
.official-task-card time { color: #9a9a97; font-size: 12px; white-space: nowrap; }
.official-task-card-actions { display: flex; gap: 3px; margin-left: 11px; }
.official-task-card-actions button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #686865; cursor: pointer; display: inline-flex; height: 30px; justify-content: center; width: 30px; }
.official-task-card-actions button:hover { background: #ededeb; color: #242422; }
.official-task-card-actions button:disabled { cursor: not-allowed; opacity: .45; }
.official-task-card-actions button:disabled svg { animation: official-spin 900ms linear infinite; }
.official-task-detail { border-bottom: 1px solid var(--official-border); padding: 24px max(32px, 5vw); }
.official-task-detail > header { align-items: flex-start; display: flex; gap: 16px; justify-content: space-between; }
.official-task-detail h2 { font-size: 19px; margin: 3px 0 7px; }
.official-task-detail header p:not(.official-eyebrow) { color: #777774; font-size: 13px; line-height: 1.5; margin: 0; max-width: 720px; }
.official-task-meta { color: #787875; display: flex; flex-wrap: wrap; font-size: 12px; gap: 7px 16px; margin-top: 16px; }
.official-task-detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 17px; }
.official-task-detail-actions button:disabled svg { animation: official-spin 900ms linear infinite; }
.official-run-list { margin-top: 24px; }
.official-run-list h3 { font-size: 14px; margin: 0 0 10px; }
.official-run-row { align-items: center; background: transparent; border: 0; border-top: 1px solid #eeeeeb; color: #4c4c49; cursor: pointer; display: grid; font-size: 12px; gap: 13px; grid-template-columns: 76px 92px minmax(0, 1fr); padding: 11px 2px; text-align: left; width: 100%; }
.official-run-row:hover { color: #111; }
.official-run-row > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-run-status { background: #f0f0ee; border-radius: 999px; color: #777774; justify-self: start; padding: 3px 7px; }
.official-run-status.is-running,
.official-run-status.is-pending { background: #fff4de; color: #a97018; }
.official-run-status.is-succeeded { background: #eef5ef; color: #46804e; }
.official-run-status.is-failed { background: #fff0f0; color: #b34e4e; }
.official-run-empty { align-items: center; color: #858582; display: flex; font-size: 12px; gap: 6px; margin: 10px 0 0; }
.official-run-empty svg { animation: official-spin 900ms linear infinite; }
@keyframes official-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.official-sidebar { flex-basis: 62px; padding-left: 7px; padding-right: 7px; }
.official-sidebar span,
.official-sidebar p,
.official-sidebar small,
.official-brand { display: none; }
.official-brand-row { justify-content: center; }
.official-collapse-button { display: none; }
.official-nav-item,
.official-recent-chat,
.official-sidebar-footer > button,
.official-user-row { justify-content: center; }
.official-page-header { padding-left: 22px; padding-right: 22px; }
.official-page-header h1 { font-size: 21px; }
.official-agent-grid { grid-template-columns: 1fr; padding-left: 22px; padding-right: 22px; }
.official-toolbar { padding-left: 22px; padding-right: 22px; }
.official-content-page .official-page-header { padding-left: 22px; padding-right: 22px; }
.official-suggestion-grid { grid-template-columns: 1fr; }
.official-chat-welcome { transform: none; }
.official-message-list { padding-left: 0; padding-right: 0; }
.official-outline-button { font-size: 0; gap: 0; padding: 8px; }
.official-composer-shell { padding-left: 14px; padding-right: 14px; }
.official-task-card-main { gap: 8px; grid-template-columns: 38px minmax(0, 1fr) auto; }
.official-task-card time { display: none; }
.official-task-detail { padding-left: 22px; padding-right: 22px; }
.official-run-row { grid-template-columns: 76px minmax(0, 1fr); }
.official-run-row > span:nth-child(2) { display: none; }
}
@@ -0,0 +1,584 @@
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>
);
}
@@ -0,0 +1,942 @@
/**
* 多智能体定时任务 · 独立编辑页。
*
* 布局:顶部是紧凑表单条(任务名称 + 任务描述),其余表单项(执行频率、任务指令、
* 总控提示、启用开关)收进「执行设置」弹窗;主体是智能体编排。
* 编排交互与业务链条编辑器一致(复制其 @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>
);
}
@@ -0,0 +1,164 @@
/**
* 智能体详情抽屉:候选池卡片 / 编排席位条「查看详情」共用。
* 后端查不到(本地演示席位)时回退到编排里保存的名称/描述快照。
*/
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>
);
}
@@ -0,0 +1,136 @@
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>
);
}
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,361 @@
/**
* 多智能体定时任务的本地 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 };
}
@@ -0,0 +1,92 @@
/**
* 多智能体定时任务 —— 前端先行数据模型。
*
* 编排语义与业务链条保持一致:同一阶段(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;
}
@@ -0,0 +1,166 @@
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 };
}
@@ -0,0 +1,340 @@
/**
* 任务分析 · 详情页:左侧执行记录(定时 / 手动),右侧按选中运行回放流程图
* (业务链风格节点状态 + 耗时,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>
);
}
@@ -0,0 +1,460 @@
/**
* 任务分析 · 列表页:对方系统推送过来的外部任务 + 我们的绑定状态。
* 任务卡片沿用多智能体定时任务的卡片视觉(浅红规范)。
*/
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();
}
@@ -0,0 +1,19 @@
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" };
}
@@ -0,0 +1,223 @@
/**
* 绑定配置弹窗:选业务链(真实 /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>
);
}
@@ -0,0 +1,242 @@
/**
* 新建任务弹窗(一步式):任务信息(名称/任务号/简介)+ 业务链选择 +
* 执行设置(频率 / 总控提示 / 启用),提交后直接创建任务并完成绑定。
*/
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>
);
}
@@ -0,0 +1,122 @@
/**
* 席位执行过程抽屉:点击流程图节点打开,展示该智能体在某次运行里的完整过程
* (输入 → 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>
);
}
@@ -0,0 +1,4 @@
import type { TaRunView, TaTask } from "./types";
export type { TaRunView, TaTask };
export type TaTaskViewLike = { task: TaTask; runs: TaRunView[] };
@@ -0,0 +1,611 @@
/**
* 外部任务定时分析的本地 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
@@ -0,0 +1,105 @@
/**
* 外部任务多智能体定时分析 —— 前端先行数据模型(后端接口就绪后替换 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,6 +26,11 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
/**
* 点击常用问题 chip 时直接把问题发送出去(8BF 第一步)。
* 缺省 false:仅填充输入框(岗位会商等既有页面的行为保持不变)。
*/
sendCommonQuestionOnClick?: boolean;
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
hideUtilityActions?: boolean;
}
@@ -68,6 +73,7 @@ export function PositionIntentInput({
selectedModel,
onSelectedModelChange,
commonQuestions,
sendCommonQuestionOnClick = false,
hideUtilityActions = false,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
@@ -86,7 +92,13 @@ export function PositionIntentInput({
: (modelOptions[0]?.value ?? "");
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
// 直接发送模式:以问题全文为覆盖文本调 onSend,绕过输入框 state 的
// 异步更新(同 tick 里 onSend 闭包读不到刚 setValue 的新值)。
if (sendCommonQuestionOnClick) {
onSend(question.prompt);
} else {
onValueChange(question.prompt);
}
setShowMoreQuestions(false);
};
@@ -160,6 +172,7 @@ export function PositionIntentInput({
type="button"
className="position-roundtable-intent-common-question"
title={question.prompt}
disabled={sendCommonQuestionOnClick ? isSending : undefined}
onClick={() => pickCommonQuestion(question)}
>
<span aria-hidden="true" />
@@ -185,6 +198,7 @@ export function PositionIntentInput({
key={`${question.prompt}-${index}`}
type="button"
title={question.prompt}
disabled={sendCommonQuestionOnClick ? isSending : undefined}
onClick={() => pickCommonQuestion(question)}
>
{question.title}
@@ -268,7 +282,7 @@ export function PositionIntentInput({
<button
id="send-message-btn"
type="button"
onClick={isSending ? onStop : onSend}
onClick={() => (isSending ? onStop() : onSend())}
disabled={isSending ? false : !canSend}
aria-label={isSending ? "暂停生成" : "发送"}
title={isSending ? "暂停生成" : "发送"}
@@ -0,0 +1,68 @@
# 报告协作工作台(AgentScope 多智能体)—— 前端模块
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
**联调对接:** `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`(环境配置、API 时序、验收清单)。
**进度(2026-09-06):** 页面与契约已完成,默认对接真实后端(`GET/POST /api/report-collaboration/*`)。后端 `RC-BE-000`~`018` 已落地。联调见 `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`。
## 结构
```text
src/report-collaboration/
api/ # 契约类型 + REST/SSE 客户端
types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
driver.ts # 真实后端统一门面(getReportCollaborationDriver;Mock 驱动已移除)
state/
event-normalizer.ts # SSE/REST 事件防御性归一化
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
selectors.ts # 页面状态投影(并行聚焦过滤、画布方案、阶段推导)
message-adapter.ts # CollaborationMessage → LangGraph Message + BaseStream 外观 + 虚拟业务卡
hooks/
useReportCollaborationSession.ts # 快照→SSE→合帧 reducer→动作(send/select/startRun/commands)
useReportCollaborationStream.ts # SSE 连接/指数退避重连/after_seq 续接
useReportCollaborationMessages.ts # 消息组装(聚焦过滤 + 业务卡 + thread 外观)
useReportPanelLayout.ts # 两栏宽度(持久化)、窄屏切换、键盘分隔条
useReportPlanSelection.ts # 预览(纯前端)与选定(服务器事实)分离
components/ # Header / 两栏 Layout / 会话面板(复用 MessageList)/ 方案卡 /
# 画布(@xyflow/react)/ 节点抽屉 / Composer / 干预卡 / 报告预览 / 历史 / 状态栏
pages/ReportCollaborationPage.tsx
styles/report-collaboration.css # --rc-* 语义 token(Chrome 109 兼容,无 color-mix)
tests/ # node:test 单测(reducer / adapter),运行:pnpm test:report-collaboration
```
## 路由与入口
- `/page/workspace/report-collaboration` 新会话;`/page/workspace/report-collaboration/:sessionId` 会话页(按 sessionId 重挂)。
- 侧边栏导航项「报告协作」(`workspace-nav-chat-list.tsx`)。
- `WorkspaceRoutes.tsx` 引入页面与 CSS。
## 关键约束(与计划一致)
- 消息区**复用** `MessageList`:AgentScope 文本 / TeamSay / 工具调用与返回经
`message-adapter.ts` 一对一映射,经 `ThreadContext.Provider` + 合成 `BaseStream` 渲染;
不另做摘要卡或第二套渲染器。
- MessageList 新增的**通用业务卡插槽** `businessCardSlot`(分组 `assistant:business-card`,
判据 `additional_kwargs.report_collaboration_card`,见 `core/messages/utils.ts`)只在本页
传入;其他页面零行为变化。
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
## 只走真实后端
Mock 驱动(`mock.ts`)与切换开关(`deerflow.rc-mock` / `VITE_RC_MOCK`)已移除,
页面永远请求真实后端。历史遗留的浏览器设置可执行
`localStorage.removeItem('deerflow.rc-mock')` 清掉(不再被读取)。日常联调请保证:
- `config.yaml` → `report_collaboration.enabled: true`
- 需要成员自动执行时再开 `worker_enabled: true`
## 后端契约冻结说明
`api/types.ts` 按后端计划 §5/§7 编写,已与 `app/report_collaboration/contracts/` 对齐。
快照端点约定 `GET /sessions/{id}` 返回 `SessionSnapshot`
(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
`GET /runs/{id}/stream` 已按 `after_seq` / `Last-Event-ID` 重放 durable 事件(`RC-BE-011`)。queued run 的领取/租约恢复已落地(`RC-BE-012`),质量角色内核已落地(`RC-BE-013`)。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
@@ -0,0 +1,142 @@
/**
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
*
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
* (保留输入,允许同一幂等键重试)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { ApiErrorDetail } from "./types";
export const REPORT_COLLABORATION_BASE = () =>
`${getBackendBaseURL()}/api/report-collaboration`;
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly detail?: unknown;
constructor(status: number, message: string, code?: string, detail?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.detail = detail;
}
}
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
export class ConflictError extends ApiError {}
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
export class ValidationError extends ApiError {}
/** 429:预算、并发或速率限制,不自动重试。 */
export class RateLimitError extends ApiError {}
/** 5xx:服务端错误,可带同一幂等键重试。 */
export class ServerError extends ApiError {}
/** 网络层错误(fetch reject)。 */
export class NetworkError extends ApiError {
constructor(message: string) {
super(0, message, "network");
}
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `rc-${crypto.randomUUID()}`;
}
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
try {
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
const source = "message" in data ? data : null;
if (source) return source;
if (typeof data.detail === "string") {
return { message: data.detail };
}
return null;
} catch {
return null;
}
}
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
if (res.status === 409) return new ConflictError(409, message, code, detail);
if (res.status === 422) return new ValidationError(422, message, code, detail);
if (res.status === 429) return new RateLimitError(429, message, code, detail);
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
return new ApiError(res.status, message, code, detail);
}
export interface RcRequestInit extends Omit<RequestInit, "body"> {
body?: unknown;
idempotencyKey?: string;
expectedRevision?: number | null;
/** 404 时返回 null 而不是抛错。 */
nullOn404?: boolean;
signal?: AbortSignal;
}
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
const headers = new Headers();
headers.set("Accept", "application/json");
if (init.body !== undefined) {
headers.set("Content-Type", "application/json");
}
if (init.idempotencyKey) {
headers.set("X-Idempotency-Key", init.idempotencyKey);
}
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
headers.set("X-Expected-Revision", String(init.expectedRevision));
}
let res: Response;
try {
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
credentials: "include",
signal: init.signal,
});
} catch (err) {
throw new NetworkError(err instanceof Error ? err.message : String(err));
}
if (res.status === 404 && init.nullOn404) {
return null;
}
if (!res.ok) {
const detail = await parseErrorDetail(res);
throw errorForStatus(
res,
detail?.message ?? `request failed (${res.status})`,
detail?.code,
detail?.detail,
);
}
if (res.status === 204) {
return null;
}
const text = await res.text();
if (!text) {
return null;
}
return JSON.parse(text) as T;
}
/** GET 便捷封装(非空断言版)。 */
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const data = await rcRequest<T>(path, { method: "GET", signal });
if (data === null) {
throw new ApiError(404, `resource not found: ${path}`);
}
return data;
}
@@ -0,0 +1,117 @@
/**
* 驱动接口:直接请求真实后端(不再提供 Mock 驱动)。
*/
import * as plansApi from "@/report-collaboration/api/plans";
import * as reportsApi from "@/report-collaboration/api/reports";
import * as runsApi from "@/report-collaboration/api/runs";
import * as sessionsApi from "@/report-collaboration/api/sessions";
import type {
CollaborationCommand,
CollaborationEvent,
CollaborationMessage,
CreateCommandPayload,
CreateRewritePayload,
NodeContractDetail,
ReportRewriteProposal,
ReportRun,
ReportSession,
ReportSessionSummary,
ReportVersion,
SendMessagePayload,
SessionSnapshot,
StartRunPayload,
} from "@/report-collaboration/api/types";
export interface OpenStreamHandlers {
onEvent: (event: CollaborationEvent) => void;
onOpen?: () => void;
onClose?: () => void;
onError?: (message: string) => void;
}
export interface ReportCollaborationDriver {
listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]>;
getSnapshot(sessionId: string, signal?: AbortSignal): Promise<SessionSnapshot | null>;
createSession(title?: string): Promise<ReportSession>;
updateSession(
sessionId: string,
patch: { title?: string },
expectedRevision?: number | null,
): Promise<ReportSession | null>;
deleteSession(sessionId: string): Promise<void>;
sendMessage(
sessionId: string,
payload: SendMessagePayload,
idempotencyKey: string,
): Promise<CollaborationMessage>;
requestPlans(sessionId: string, expectedRevision?: number | null): Promise<void>;
selectPlan(
sessionId: string,
planId: string,
expectedRevision?: number | null,
): Promise<void>;
startRun(
sessionId: string,
payload: StartRunPayload,
expectedRevision?: number | null,
): Promise<ReportRun>;
getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null>;
listRunEvents(runId: string, afterSeq: number, signal?: AbortSignal): Promise<CollaborationEvent[]>;
openRunStream(
runId: string,
afterSeq: number,
handlers: OpenStreamHandlers,
): () => void;
cancelRun(runId: string): Promise<ReportRun>;
createCommand(
runId: string,
payload: CreateCommandPayload,
idempotencyKey: string,
): Promise<CollaborationCommand>;
confirmCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
cancelCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
getNodeContract(runId: string, nodeId: string, signal?: AbortSignal): Promise<NodeContractDetail>;
listReportVersions(sessionId: string, signal?: AbortSignal): Promise<ReportVersion[]>;
createRewrite(sessionId: string, payload: CreateRewritePayload): Promise<ReportRewriteProposal>;
applyRewrite(sessionId: string, rewriteId: string): Promise<ReportVersion>;
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
}
export const realDriver: ReportCollaborationDriver = {
listSessions: sessionsApi.listSessions,
getSnapshot: sessionsApi.getSessionSnapshot,
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
updateSession: sessionsApi.updateSession,
deleteSession: sessionsApi.deleteSession,
sendMessage: sessionsApi.sendMessage,
requestPlans: (sessionId, expectedRevision) =>
plansApi.requestPlans(sessionId, undefined, expectedRevision),
selectPlan: async (sessionId, planId, expectedRevision) => {
await plansApi.selectPlan(sessionId, planId, expectedRevision);
},
startRun: runsApi.startRun,
getRun: runsApi.getRun,
listRunEvents: runsApi.listRunEvents,
openRunStream: runsApi.openRunStream,
cancelRun: runsApi.cancelRun,
createCommand: runsApi.createCommand,
confirmCommand: runsApi.confirmCommand,
cancelCommand: runsApi.cancelCommand,
getNodeContract: runsApi.getNodeContract,
listReportVersions: reportsApi.listReportVersions,
createRewrite: reportsApi.createRewrite,
applyRewrite: reportsApi.applyRewrite,
restoreReportVersion: reportsApi.restoreReportVersion,
};
export function getReportCollaborationDriver(): ReportCollaborationDriver {
return realDriver;
}
@@ -0,0 +1,46 @@
/**
* 候选计划:plan request / 查询 / 选定(`/sessions/{id}/plans*`)。
*
* 选择方案与开始执行是两个独立命令:select 只写 `selected_plan_id`,
* 不创建 run。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type { ReportPlanCandidate } from "./types";
export async function requestPlans(
sessionId: string,
idempotencyKey = newIdempotencyKey(),
expectedRevision?: number | null,
): Promise<void> {
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}/plan-requests`, {
method: "POST",
body: {},
idempotencyKey,
expectedRevision,
});
}
export async function listPlans(
sessionId: string,
signal?: AbortSignal,
): Promise<ReportPlanCandidate[]> {
const data = await rcGet<{ plans: ReportPlanCandidate[] }>(
`/sessions/${encodeURIComponent(sessionId)}/plans`,
signal,
);
return data.plans ?? [];
}
/** 采用方案:409 表示方案 revision 过期或会话已选定,需刷新后重新确认。 */
export async function selectPlan(
sessionId: string,
planId: string,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<{ selected_plan_id: string }> {
return rcRequest<{ selected_plan_id: string }>(
`/sessions/${encodeURIComponent(sessionId)}/plans/${encodeURIComponent(planId)}/select`,
{ method: "POST", body: {}, idempotencyKey, expectedRevision },
).then((res) => res as { selected_plan_id: string });
}
@@ -0,0 +1,52 @@
/**
* 报告版本、章节改写候选与版本恢复(`/sessions/{id}/reports*`)。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type { CreateRewritePayload, ReportRewriteProposal, ReportVersion } from "./types";
export async function listReportVersions(
sessionId: string,
signal?: AbortSignal,
): Promise<ReportVersion[]> {
const data = await rcGet<{ versions: ReportVersion[] }>(
`/sessions/${encodeURIComponent(sessionId)}/reports`,
signal,
);
return data.versions ?? [];
}
/** 发起章节重写 / 润色:只生成 replacement candidate,不直接覆盖正式版本。 */
export async function createRewrite(
sessionId: string,
payload: CreateRewritePayload,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRewriteProposal> {
return rcRequest<ReportRewriteProposal>(
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites`,
{ method: "POST", body: payload, idempotencyKey },
).then((res) => res as ReportRewriteProposal);
}
export async function applyRewrite(
sessionId: string,
rewriteId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportVersion> {
return rcRequest<ReportVersion>(
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites/${encodeURIComponent(rewriteId)}/apply`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as ReportVersion);
}
/** 版本恢复:创建新的头版本,不删除历史。 */
export async function restoreReportVersion(
sessionId: string,
versionId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportVersion> {
return rcRequest<ReportVersion>(
`/sessions/${encodeURIComponent(sessionId)}/report-versions/${encodeURIComponent(versionId)}/restore`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as ReportVersion);
}
@@ -0,0 +1,205 @@
/**
* Run 执行与干预:查询、事件回放、SSE 流、命令、取消、节点合同。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { REPORT_COLLABORATION_BASE, newIdempotencyKey, rcGet, rcRequest } from "./client";
import type {
CollaborationCommand,
CollaborationEvent,
CreateCommandPayload,
NodeContractDetail,
ReportRun,
StartRunPayload,
} from "./types";
export async function startRun(
sessionId: string,
payload: StartRunPayload,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRun> {
return rcRequest<ReportRun>(`/sessions/${encodeURIComponent(sessionId)}/runs`, {
method: "POST",
body: payload,
idempotencyKey,
expectedRevision,
}).then((res) => res as ReportRun);
}
export async function getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null> {
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}`, {
method: "GET",
signal,
nullOn404: true,
});
}
/** 历史事件回放(断线补拉 / 序号缺口补齐)。 */
export async function listRunEvents(
runId: string,
afterSeq: number,
signal?: AbortSignal,
): Promise<CollaborationEvent[]> {
const data = await rcGet<{ events: CollaborationEvent[] }>(
`/runs/${encodeURIComponent(runId)}/events?after_seq=${Math.max(0, afterSeq)}`,
signal,
);
return data.events ?? [];
}
export async function cancelRun(
runId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRun> {
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}/cancel`, {
method: "POST",
body: {},
idempotencyKey,
}).then((res) => res as ReportRun);
}
export async function getNodeContract(
runId: string,
nodeId: string,
signal?: AbortSignal,
): Promise<NodeContractDetail> {
return rcGet<NodeContractDetail>(
`/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/contract`,
signal,
);
}
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
export async function createCommand(
runId: string,
payload: CreateCommandPayload,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(`/runs/${encodeURIComponent(runId)}/commands`, {
method: "POST",
body: payload,
idempotencyKey,
}).then((res) => res as CollaborationCommand);
}
export async function confirmCommand(
runId: string,
commandId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/confirm`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as CollaborationCommand);
}
export async function cancelCommand(
runId: string,
commandId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/cancel`,
{ method: "POST",
body: {},
idempotencyKey },
).then((res) => res as CollaborationCommand);
}
// ── SSE 订阅 ─────────────────────────────────────────────────────────────────
export interface RunStreamHandlers {
onEvent: (event: CollaborationEvent) => void;
onOpen?: () => void;
/** 服务端正常关流(终态)。 */
onClose?: () => void;
/** 传输层异常(触发重连)。 */
onError?: (message: string) => void;
}
/**
* 订阅 run 的 durable SSE 流。支持 `after_seq` 与 `Last-Event-ID`。
*
* 返回 close 函数:幂等,调用后中断连接且不再触发回调。
* 注意:fetch 流式读取无法携带 EventSource 的自动重连,重连由
* useReportCollaborationStream 以指数退避驱动。
*/
export function openRunStream(
runId: string,
afterSeq: number,
handlers: RunStreamHandlers,
): () => void {
const controller = new AbortController();
let closed = false;
const close = () => {
if (closed) return;
closed = true;
controller.abort();
};
void (async () => {
try {
const res = await apiFetch(
`${REPORT_COLLABORATION_BASE()}/runs/${encodeURIComponent(runId)}/stream?after_seq=${Math.max(0, afterSeq)}`,
{ method: "GET", signal: controller.signal, headers: { Accept: "text/event-stream" } },
);
if (!res.ok || !res.body) {
if (!closed) handlers.onError?.(`stream failed (${res.status})`);
return;
}
handlers.onOpen?.();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let dataLines: string[] = [];
let lastEventId: string | undefined;
const flushData = () => {
if (dataLines.length === 0) return;
const json = dataLines.join("\n").trim();
dataLines = [];
if (!json) return;
try {
const event = JSON.parse(json) as CollaborationEvent;
if (lastEventId) event.eventId = event.eventId || lastEventId;
handlers.onEvent(event);
} catch {
// 非 JSON 帧(如注释/心跳文本)忽略,不中断连接。
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.replace(/\r$/, "");
if (line === "") {
flushData();
continue;
}
if (line.startsWith("id:")) {
lastEventId = line.slice(3).trim() || undefined;
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
// `event:` / `:` 注释行不影响 data 解析。
}
}
flushData();
if (!closed) handlers.onClose?.();
} catch (err) {
if (!closed && !controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return close;
}
@@ -0,0 +1,84 @@
/**
* 会话 CRUD 与消息读写(`/api/report-collaboration/sessions`)。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type {
CollaborationMessage,
CreateSessionPayload,
ReportSession,
ReportSessionSummary,
SendMessagePayload,
SessionSnapshot,
UpdateSessionPayload,
} from "./types";
export async function listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]> {
const data = await rcGet<{ sessions: ReportSessionSummary[] }>("/sessions", signal);
return data.sessions ?? [];
}
export async function getSessionSnapshot(
sessionId: string,
signal?: AbortSignal,
): Promise<SessionSnapshot | null> {
return rcRequest<SessionSnapshot>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "GET",
signal,
nullOn404: true,
});
}
export async function createSession(
payload: CreateSessionPayload = {},
idempotencyKey = newIdempotencyKey(),
): Promise<ReportSession> {
return rcRequest<ReportSession>("/sessions", {
method: "POST",
body: payload,
idempotencyKey,
}).then((res) => res as ReportSession);
}
export async function updateSession(
sessionId: string,
patch: UpdateSessionPayload,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportSession | null> {
return rcRequest<ReportSession>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
body: patch,
idempotencyKey,
expectedRevision,
nullOn404: true,
});
}
export async function deleteSession(sessionId: string): Promise<void> {
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
}
export async function listMessages(
sessionId: string,
signal?: AbortSignal,
): Promise<CollaborationMessage[]> {
const data = await rcGet<{ messages: CollaborationMessage[] }>(
`/sessions/${encodeURIComponent(sessionId)}/messages`,
signal,
);
return data.messages ?? [];
}
export async function sendMessage(
sessionId: string,
payload: SendMessagePayload,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationMessage> {
return rcRequest<CollaborationMessage>(
`/sessions/${encodeURIComponent(sessionId)}/messages`,
{ method: "POST", body: payload, idempotencyKey },
).then((res) => res as CollaborationMessage);
}
@@ -0,0 +1,709 @@
/**
* 报告协作工作台(AgentScope 多智能体)—— 前后端共享领域类型。
*
* 契约来源:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` §5/§7。
* 字段与后端 Pydantic 合同保持 snake_case 一一对应;前端只在 selector/adapter
* 层做投影,不再二次推断状态。
*
* 后端尚未实现时本层即为冻结契约(RC-FE-004 前置条件),修改字段需同步两份计划文档。
*/
// ── 会话 ─────────────────────────────────────────────────────────────────────
/** 会话状态机(后端 §6 sessions.status)。 */
export type ReportSessionStatus =
| "empty"
| "clarifying"
| "planning"
| "proposal_ready"
| "running"
| "awaiting_input"
| "reviewing"
| "completed"
| "failed"
| "cancelled";
export interface ReportSession {
id: string;
title: string;
status: ReportSessionStatus;
requirement_revision: number;
selected_plan_id: string | null;
active_run_id: string | null;
current_report_version_id: string | null;
created_at: string;
updated_at: string;
}
/** 会话列表条目(轻量,无 messages)。 */
export type ReportSessionSummary = ReportSession;
// ── 需求快照 ─────────────────────────────────────────────────────────────────
export interface ReportRequirementSnapshot {
topic: string;
objective?: string | null;
audience?: string | null;
language?: string | null;
time_range?: string | null;
geography?: string | null;
required_angles: string[];
excluded_angles: string[];
source_constraints?: string | null;
report_structure_id?: string | null;
length_target?: string | null;
tone?: string | null;
deadline_hint?: string | null;
quality_priority?: string | null;
revision: number;
}
// ── 消息镜像(ConversationReplica)──────────────────────────────────────────
export type CollaborationMessageRole = "human" | "ai" | "tool";
/** ai 消息上的工具调用(args 流式期间为部分 JSON 字符串,完成后为对象)。 */
export interface CollaborationToolCall {
id: string;
name: string;
args: string | Record<string, unknown>;
}
/**
* 后端 `report_collaboration_messages` 行的镜像:用户可见的 assistant 文本、
* TeamSay、`tool_calls` 与 `tool` 返回都落在这同一张时间线上。
*/
export interface CollaborationMessage {
message_id: string;
role: CollaborationMessageRole;
content: string;
/** 成员显示名(TeamSay / 成员回复 / tool 名)。 */
name?: string | null;
tool_calls?: CollaborationToolCall[];
tool_call_id?: string | null;
/** 并行阶段稳定归属;协调者广播可为 null(共享上下文)。 */
agent_run_id?: string | null;
node_run_id?: string | null;
phase_id?: string | null;
visibility: "visible" | "internal";
metadata?: Record<string, unknown> | null;
command_id?: string | null;
created_at: string;
/** null = 仍在流式输出。 */
completed_at?: string | null;
}
// ── 候选计划 ─────────────────────────────────────────────────────────────────
export type ReportPlanStatus =
| "proposed"
| "selected"
| "superseded"
| "rejected"
| "executing"
| "completed";
export type ReportCostLevel = "low" | "medium" | "high";
export interface PlanRole {
key: string;
display_name: string;
responsibility: string;
angle?: string | null;
}
export interface QualityGate {
id: string;
label: string;
description?: string | null;
}
export interface PlanValidation {
ok: boolean;
errors: string[];
}
/** 计划图节点:节点合同的用户可见摘要(不含执行结果)。 */
export interface PlanNode {
id: string;
label: string;
role_key?: string | null;
role_display_name?: string | null;
angle?: string | null;
depends_on: string[];
/** 合同摘要;完整合同走 `GET /runs/{run_id}/nodes/{node_id}/contract`。 */
mission?: string | null;
output_artifact_type?: string | null;
input_artifact_types?: string[];
allowed_tools?: string[];
acceptance_criteria?: string[];
}
export interface PlanEdge {
source: string;
target: string;
label?: string | null;
}
export interface ReportPlanCandidate {
id: string;
proposal_group_id: string;
title: string;
strategy: string;
summary: string;
rationale: string;
recommended?: boolean;
estimated_duration_seconds: number;
estimated_cost_level: ReportCostLevel;
requirement_revision: number;
roles: PlanRole[];
nodes: PlanNode[];
edges: PlanEdge[];
quality_gates: QualityGate[];
report_structure_id: string | null;
validation: PlanValidation;
revision: number;
status: ReportPlanStatus;
}
// ── 节点运行状态 ─────────────────────────────────────────────────────────────
/** 后端 §5.5 状态机:completed 只能经 reopened 重新入队。 */
export type CollaborationNodeStatus =
| "planned"
| "queued"
| "running"
| "validating"
| "completed"
| "failed"
| "awaiting_input"
| "cancel_requested"
| "cancelled"
| "superseded"
| "reopened"
| "interrupted";
export interface NodeAwaitingInput {
reason: string;
question: string;
impact?: string[];
}
export interface NodeAttemptState {
attempt: number;
status: CollaborationNodeStatus;
started_at?: string | null;
ended_at?: string | null;
error?: string | null;
}
export interface CollaborationNodeState {
node_id: string;
label: string;
role_display_name?: string | null;
angle?: string | null;
status: CollaborationNodeStatus;
attempt: number;
progress?: string | null;
awaiting_input?: NodeAwaitingInput | null;
error?: string | null;
attempts: NodeAttemptState[];
}
// ── 智能体成员(并行阶段稳定身份)────────────────────────────────────────────
export type AgentRunStatus =
| "pending"
| "running"
| "waiting_input"
| "completed"
| "failed"
| "cancelled";
export interface AgentRun {
agent_run_id: string;
node_run_id?: string | null;
phase_id?: string | null;
role: string;
display_name: string;
avatar_key?: string | null;
status: AgentRunStatus;
started_at?: string | null;
ended_at?: string | null;
}
// ── Run ──────────────────────────────────────────────────────────────────────
export type ReportRunStatus =
| "queued"
| "planning"
| "running"
| "awaiting_input"
| "reviewing"
| "completed"
| "failed"
| "cancelled"
| "recovering";
export interface ReportRun {
id: string;
session_id: string;
plan_id: string;
status: ReportRunStatus;
cancel_requested: boolean;
next_event_seq: number;
current_phase?: string | null;
usage?: Record<string, unknown>;
budget_exhausted_reason?: string | null;
created_at: string;
updated_at: string;
}
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
export type CommandIntent =
| "answer_question"
| "update_requirements"
| "add_angle"
| "research_again"
| "reanalyze"
| "rerun_node"
| "rewrite_section"
| "polish_report"
| "ask_report"
| "replan"
| "cancel";
export type CommandStatus =
| "pending"
| "classified"
| "confirmation_required"
| "accepted"
| "accepted_waiting_boundary"
| "executing"
| "completed"
| "failed"
| "cancelled"
| "rejected";
export interface CollaborationCommand {
command_id: string;
idempotency_key: string;
text: string;
intent?: CommandIntent | null;
confidence?: number | null;
target_type?: string | null;
target_ids: string[];
normalized_instruction?: string | null;
requires_confirmation: boolean;
cost_level?: ReportCostLevel | null;
reason?: string | null;
impact_nodes?: string[];
status: CommandStatus;
expected_revision?: number | null;
error?: string | null;
created_at: string;
updated_at?: string | null;
}
// ── 成果物 / 来源 / 报告版本 ────────────────────────────────────────────────
export type ArtifactValidationStatus =
| "pending"
| "validated"
| "rejected"
| "superseded";
export interface CollaborationArtifact {
id: string;
run_id: string;
node_id: string;
attempt: number;
artifact_type: string;
schema_version: number;
validation_status: ArtifactValidationStatus;
/** 用户可见摘要(如「12 条来源,8 条近 90 天」);不含正文。 */
summary?: string | null;
created_at: string;
}
export interface ReportSource {
id: string;
url?: string | null;
title: string;
published_at?: string | null;
added_at: string;
agent_run_id?: string | null;
}
export type ReportChangeType =
| "initial"
| "section_rewrite"
| "polish"
| "restore"
| "revision";
export interface ReportVersion {
id: string;
session_id: string;
version: number;
parent_version_id: string | null;
change_type: ReportChangeType;
change_summary?: string | null;
markdown: string;
source_index?: ReportSource[];
created_at: string;
is_head?: boolean;
}
export interface ReportRewriteProposal {
id: string;
session_id: string;
section?: string | null;
instruction: string;
status: "proposed" | "applied" | "dismissed";
markdown?: string | null;
created_at: string;
}
// ── 澄清 ─────────────────────────────────────────────────────────────────────
export interface ClarificationRecord {
id: string;
questions: string[];
impact?: string[];
resolved: boolean;
created_at: string;
}
// ── SSE 事件协议 ─────────────────────────────────────────────────────────────
export type CollaborationEventType =
// 会话与消息镜像
| "session.snapshot"
| "message.created"
| "message.delta"
| "message.completed"
| "tool_call.created"
| "tool_call.delta"
| "tool_call.completed"
| "tool_result.created"
| "tool_result.delta"
| "tool_result.completed"
| "team_message.created"
| "team_message.delta"
| "team_message.completed"
// 需求与澄清
| "requirement.updated"
| "clarification.requested"
| "clarification.resolved"
// 计划
| "plan.progress"
| "plan.proposed"
| "plan.selected"
// Run / 节点
| "run.status.changed"
| "node.created"
| "node.status.changed"
| "node.progress"
| "node.attempt.created"
// 成果与来源
| "artifact.created"
| "artifact.validated"
| "artifact.rejected"
| "source.added"
// 命令
| "command.classified"
| "command.confirmation.required"
| "command.accepted"
| "command.completed"
| "command.failed"
| "command.cancelled"
// 报告与评审
| "report.delta"
| "report.version.created"
| "review.created"
// 其他
| "error"
| "heartbeat";
export interface CollaborationEvent<T = unknown> {
eventId: string;
sessionId: string;
runId?: string | null;
seq: number;
type: CollaborationEventType;
timestamp: string;
data: T;
}
// ── 事件 payload ─────────────────────────────────────────────────────────────
export interface MessageEventPayload {
message: CollaborationMessage;
}
export interface MessageDeltaPayload {
message_id: string;
agent_run_id?: string | null;
node_run_id?: string | null;
delta: string;
/** 思考过程增量,写入消息 `metadata.reasoning_content`,由适配器映射到问答 CoT。 */
reasoning_delta?: string;
}
export interface MessageCompletedPayload {
message_id: string;
agent_run_id?: string | null;
content?: string;
completed_at?: string;
}
export interface ToolCallCreatedPayload {
message_id: string;
agent_run_id?: string | null;
tool_call: CollaborationToolCall;
}
export interface ToolCallDeltaPayload {
message_id: string;
tool_call_id: string;
args_delta: string;
}
export interface ToolCallCompletedPayload {
message_id: string;
tool_call: CollaborationToolCall;
}
export interface ToolResultCreatedPayload {
/** 工具返回作为独立 tool 消息的稳定 id(缺省时前端以 `tool:{tool_call_id}` 派生)。 */
message_id?: string;
tool_call_id: string;
name: string;
agent_run_id?: string | null;
node_run_id?: string | null;
phase_id?: string | null;
content?: string;
}
export interface ToolResultDeltaPayload {
message_id?: string;
tool_call_id: string;
delta: string;
}
export interface ToolResultCompletedPayload {
message_id?: string;
tool_call_id: string;
content?: string;
}
export interface RequirementUpdatedPayload {
requirement: ReportRequirementSnapshot;
}
export interface ClarificationRequestedPayload {
clarification: ClarificationRecord;
/** 同时镜像为 ask_clarification tool 消息(由后端 ConversationReplica 发出)。 */
message?: CollaborationMessage;
}
export interface ClarificationResolvedPayload {
clarification_id: string;
}
export interface PlanProgressPayload {
stage: string;
detail?: string | null;
}
export interface PlanProposedPayload {
plans: ReportPlanCandidate[];
}
export interface PlanSelectedPayload {
plan_id: string;
}
export interface RunStatusChangedPayload {
status: ReportRunStatus;
phase?: string | null;
reason?: string | null;
}
export interface NodeCreatedPayload {
node: CollaborationNodeState;
}
export interface NodeStatusChangedPayload {
node_id: string;
status: CollaborationNodeStatus;
attempt: number;
awaiting_input?: NodeAwaitingInput | null;
error?: string | null;
}
export interface NodeProgressPayload {
node_id: string;
progress: string;
}
export interface NodeAttemptCreatedPayload {
node_id: string;
attempt: number;
reason?: string | null;
}
export interface ArtifactCreatedPayload {
artifact: CollaborationArtifact;
}
export interface ArtifactValidatedPayload {
artifact_id: string;
}
export interface ArtifactRejectedPayload {
artifact_id: string;
reasons: string[];
}
export interface SourceAddedPayload {
source: ReportSource;
}
export interface CommandClassifiedPayload {
command: CollaborationCommand;
}
export interface CommandConfirmationRequiredPayload {
command: CollaborationCommand;
}
export interface CommandAcceptedPayload {
command_id: string;
waiting_boundary: boolean;
impact_nodes?: string[];
}
export interface CommandCompletedPayload {
command_id: string;
result_summary?: string | null;
report_version_id?: string | null;
}
export interface CommandFailedPayload {
command_id: string;
error: string;
}
export interface CommandCancelledPayload {
command_id: string;
command?: CollaborationCommand;
}
export interface ReportDeltaPayload {
draft_id?: string | null;
delta: string;
}
export interface ReportVersionCreatedPayload {
version: ReportVersion;
}
export interface ReviewCreatedPayload {
review: {
reviewer: string;
verdict: "pass" | "revise";
issues: { severity: string; target: string; requirement: string }[];
};
}
export interface ErrorEventPayload {
message: string;
recover_hint?: string | null;
node_id?: string | null;
}
export interface HeartbeatPayload {
ts?: string;
}
// ── 快照 / 请求体 ────────────────────────────────────────────────────────────
/** `GET /sessions/{id}` 返回的完整快照:前端唯一事实源投影。 */
export interface SessionSnapshot {
session: ReportSession;
messages: CollaborationMessage[];
plans: ReportPlanCandidate[];
run: ReportRun | null;
nodes: CollaborationNodeState[];
agent_runs: AgentRun[];
commands: CollaborationCommand[];
report_versions: ReportVersion[];
/** 已持久化事件的最大 seq;SSE 以 after_seq=last_seq 续接。 */
last_seq: number;
}
export interface CreateSessionPayload {
title?: string;
}
export interface UpdateSessionPayload {
title?: string;
}
export interface SendMessagePayload {
text: string;
/** 从节点抽屉 / 并行选择器带出的目标上下文。 */
target_node_id?: string | null;
agent_run_id?: string | null;
}
export interface StartRunPayload {
plan_id: string;
}
export interface CreateCommandPayload {
text: string;
target_node_id?: string | null;
agent_run_id?: string | null;
}
export interface CreateRewritePayload {
instruction: string;
section?: string | null;
}
/** 统一错误模型(client 层抛出的 ApiError 携带这些字段)。 */
export interface ApiErrorDetail {
code?: string;
message: string;
detail?: unknown;
}
/**
* `GET /runs/{run_id}/nodes/{node_id}/contract` 返回的节点合同详情。
* 只含职责与输入输出合同,绝不含执行结果正文(后端 §7.1)。
*/
export interface NodeContractDetail {
node_id: string;
label: string;
role: string | null;
angle: string | null;
status: CollaborationNodeStatus;
attempt: number;
mission: string;
input_artifact_types: string[];
source_policy?: string | null;
output_artifact_type: string;
output_structure?: string | null;
acceptance_criteria: string[];
allowed_tools: string[];
forbidden?: string[];
time_budget_seconds?: number | null;
token_budget?: number | null;
max_attempts: number;
upstream: string[];
downstream: string[];
}
@@ -0,0 +1,133 @@
"use client";
import { memo } from "react";
import { Check, Loader2, X } from "lucide-react";
import type { AgentRun } from "@/report-collaboration/api/types";
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
interface AgentStageSelectorProps {
/** 当前正在执行的成员;空数组且无 pinned 时不渲染。 */
tabs: AgentRun[];
/** 点击画布节点后钉住的历史成员(波次结束后回看步骤条)。 */
pinnedAgent?: AgentRun | null;
phaseLabel?: string | null;
focusedAgentRunId: string | null;
onFocus: (agentRunId: string) => void;
/** 取消钉住,回到全量时间线。 */
onShowAll?: () => void;
/** 正在流式输出的成员。 */
streamingAgentRunIds: Set<string>;
/** 请求用户协助(待回复)的成员。 */
awaitingAgentRunIds: Set<string>;
}
function avatarStatus(
agentRun: AgentRun,
streaming: boolean,
awaiting: boolean,
): "running" | "awaiting" | "done" | "error" | "queued" {
if (awaiting) return "awaiting";
if (agentRun.status === "failed" || agentRun.status === "cancelled") return "error";
if (agentRun.status === "completed") return "done";
if (agentRun.status === "running" || streaming) return "running";
return "queued";
}
function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: string): string {
const suffix =
status === "running"
? "进行中"
: status === "awaiting"
? "待回复"
: status === "done"
? "已完成"
: status === "error"
? "失败"
: "排队中";
return `${displayName}(${suffix})`;
}
/**
* 执行中头像条(对齐会商第二步 sandbox):
* 单个模型节点与并行波次都展示;点头像切换到该成员的步骤条 / 模型输出。
* 未选中成员的事件继续接收并持久更新。
*/
export const AgentStageSelector = memo(function AgentStageSelector({
tabs,
pinnedAgent = null,
phaseLabel,
focusedAgentRunId,
onFocus,
onShowAll,
streamingAgentRunIds,
awaitingAgentRunIds,
}: AgentStageSelectorProps) {
const visibleTabs =
tabs.length > 0
? tabs
: pinnedAgent
? [pinnedAgent]
: [];
if (visibleTabs.length === 0) return null;
const focused = focusedAgentRunId ?? visibleTabs[0]?.agent_run_id;
const runningCount = visibleTabs.filter((tab) => {
const status = avatarStatus(
tab,
streamingAgentRunIds.has(tab.agent_run_id),
awaitingAgentRunIds.has(tab.agent_run_id),
);
return status === "running" || status === "awaiting";
}).length;
const stripLabel =
phaseLabel ??
(visibleTabs.length > 1 ? `并行 ${visibleTabs.length} 席` : visibleTabs[0]?.display_name ?? "当前节点");
return (
<div className="rc-agent-tabs" role="tablist" aria-label="执行成员">
<div className="rc-agent-strip-meta">
<span>{stripLabel}</span>
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
</div>
<div className="rc-agent-avatars">
{visibleTabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const status = avatarStatus(tab, streaming, awaiting);
const active = focused === tab.agent_run_id;
return (
<button
key={tab.agent_run_id}
type="button"
role="tab"
aria-selected={active}
data-active={active}
data-status={status}
data-role={tab.role}
className="rc-agent-avatar"
title={statusLabel(status, tab.display_name)}
aria-label={statusLabel(status, tab.display_name)}
onClick={() => onFocus(tab.agent_run_id)}
>
{status === "running" && <span className="rc-agent-avatar-ping" aria-hidden />}
<span className="rc-agent-avatar-face" aria-hidden>
{roleIcon(tab.role)}
</span>
<span className="rc-agent-avatar-badge" data-status={status} aria-hidden>
{status === "running" && <Loader2 className="rc-spin size-2.5" />}
{status === "awaiting" && <span className="rc-agent-avatar-dot" />}
{status === "done" && <Check className="size-2.5" strokeWidth={3} />}
{status === "error" && <X className="size-2.5" strokeWidth={3} />}
</span>
</button>
);
})}
</div>
{onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
<button type="button" className="rc-btn rc-agent-show-all" onClick={onShowAll}>
全部对话
</button>
) : null}
</div>
);
});
@@ -0,0 +1,41 @@
"use client";
import { useI18n } from "@/core/i18n/hooks";
import type { RcConnectionStatus } from "@/report-collaboration/hooks/useReportCollaborationStream";
const STATUS_TEXT: Record<RcConnectionStatus, string> = {
idle: "未连接",
connecting: "连接中…",
live: "实时",
reconnecting: "重连中…",
closed: "已关闭",
error: "连接异常",
};
/** 底部连接状态栏:连接态 + run + 事件游标(aria-live 播报)。 */
export function ConnectionStatusBar({
status,
runId,
lastSeq,
message,
}: {
status: RcConnectionStatus;
runId: string | null;
lastSeq: number;
message?: string;
}) {
const { t } = useI18n();
void t;
return (
<footer className="rc-status-bar" aria-live="polite">
<span>
<span className="rc-status-dot" data-status={status} />
SSE:{STATUS_TEXT[status]}
{status === "reconnecting" && message ? `(${message.slice(0, 80)})` : ""}
</span>
<span>run:{runId ? runId.slice(0, 18) : "—"}</span>
<span>事件 seq:{lastSeq}</span>
</footer>
);
}
@@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, Check, Loader2, X } from "lucide-react";
import type { CollaborationCommand } from "@/report-collaboration/api/types";
const INTENT_LABELS: Record<string, string> = {
answer_question: "只读问答",
update_requirements: "更新需求",
add_angle: "新增分析角度",
research_again: "重新检索",
reanalyze: "重新分析",
rerun_node: "重跑节点",
rewrite_section: "重写章节",
polish_report: "全文润色",
ask_report: "报告问答",
replan: "重新规划",
cancel: "取消",
};
interface InterventionCardProps {
command: CollaborationCommand;
busy: boolean;
onConfirm: (commandId: string) => void;
onCancel: (commandId: string) => void;
}
/**
* 命令确认卡:高成本 / 低置信度命令需要用户显式确认。
* 确认/取消都只是提交命令,不改变节点完成状态(状态只由后端状态机驱动)。
*/
export function InterventionCard({ command, busy, onConfirm, onCancel }: InterventionCardProps) {
return (
<div className="rc-intervention-card">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-hidden />
<div className="min-w-0">
<div className="text-[13px] font-semibold">需要确认:{INTENT_LABELS[command.intent ?? ""] ?? "修改操作"}</div>
<p className="mt-0.5 text-[12px] leading-5 text-[var(--rc-text-secondary)]">
「{command.text}」
</p>
</div>
</div>
<ul className="list-disc pl-8 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
<li>
影响范围:
{command.impact_nodes && command.impact_nodes.length > 0
? command.impact_nodes.join("、") + " 及其下游"
: "由协调者判定"}
</li>
<li>
成本:{command.cost_level === "high" ? "高(触发重新检索/重写)" : command.cost_level === "low" ? "低" : "中"}
</li>
{command.reason ? <li>理由:{command.reason}</li> : null}
</ul>
<div className="flex justify-end gap-2">
<button type="button" className="rc-btn" disabled={busy} onClick={() => onCancel(command.command_id)}>
<X className="size-3.5" aria-hidden />
取消
</button>
<button
type="button"
className="rc-btn rc-btn-primary"
disabled={busy}
onClick={() => onConfirm(command.command_id)}
>
{busy ? <Loader2 className="rc-spin size-3.5" /> : <Check className="size-3.5" aria-hidden />}
确认执行
</button>
</div>
</div>
);
}
@@ -0,0 +1,280 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Ban, ChevronDown, Target, X } from "lucide-react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { NodeContractDetail } from "@/report-collaboration/api/types";
import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode";
export interface NodeInspectorFallback {
label: string;
status: string;
role?: string | null;
angle?: string | null;
kind?: ReportFlowNodeKind;
mission?: string | null;
input_artifact_types?: string[];
output_artifact_type?: string | null;
allowed_tools?: string[];
acceptance_criteria?: string[];
upstream?: string[];
downstream?: string[];
}
interface NodeInspectorDrawerProps {
open: boolean;
nodeId: string | null;
runId: string | null;
/** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */
fallback?: NodeInspectorFallback | null;
onClose: () => void;
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
onComment: (nodeId: string, nodeName: string) => void;
}
const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const;
type SectionKey = (typeof SECTION_KEYS)[number];
/**
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
* 执行过程统一在左侧 MessageList 查看。
*/
export function NodeInspectorDrawer({
open,
nodeId,
runId,
fallback,
onClose,
onComment,
}: NodeInspectorDrawerProps) {
const [contract, setContract] = useState<NodeContractDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
mission: true,
input: true,
output: true,
tools: true,
graph: true,
});
useEffect(() => {
if (!open || !nodeId || !runId) {
setContract(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
getReportCollaborationDriver()
.getNodeContract(runId, nodeId)
.then((detail) => {
if (!cancelled) setContract(detail);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, nodeId, runId]);
const view = useMemo(() => {
return {
label: contract?.label ?? fallback?.label ?? nodeId ?? "",
status: contract?.status ?? fallback?.status ?? "planned",
role: contract?.role ?? fallback?.role ?? null,
angle: contract?.angle ?? fallback?.angle ?? null,
kind: fallback?.kind ?? "agent",
mission: contract?.mission ?? fallback?.mission ?? null,
input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [],
output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null,
allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [],
acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [],
upstream: contract?.upstream ?? fallback?.upstream ?? [],
downstream: contract?.downstream ?? fallback?.downstream ?? [],
forbidden: contract?.forbidden ?? [],
max_attempts: contract?.max_attempts ?? null,
time_budget_seconds: contract?.time_budget_seconds ?? null,
token_budget: contract?.token_budget ?? null,
source_policy: contract?.source_policy ?? null,
};
}, [contract, fallback, nodeId]);
if (!open || !nodeId) return null;
const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label);
const showLoading = loading && !hasBody;
const toggle = (key: SectionKey) => {
setOpenSections((current) => ({ ...current, [key]: !current[key] }));
};
return (
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${view.label}`}>
<header className="rc-drawer-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(view.role, view.kind)}
</span>
<div className="min-w-0 flex-1">
<div className="rc-drawer-title">{view.label}</div>
<div className="rc-drawer-subtitle">
{view.role ? <span>{view.role}</span> : null}
{view.angle ? <span>· {view.angle}</span> : null}
<span>· {statusText(view.status)}</span>
</div>
</div>
<button type="button" className="rc-drawer-icon-btn" onClick={onClose} aria-label="关闭节点说明">
<X className="size-4" />
</button>
</header>
<div className="rc-drawer-body">
{showLoading ? (
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
) : error && !hasBody ? (
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
读取节点合同失败:{error}
</div>
) : (
<>
<DrawerSection title="节点职责" open={openSections.mission} onToggle={() => toggle("mission")}>
<p className="rc-drawer-text">{view.mission || "暂无职责说明"}</p>
</DrawerSection>
<DrawerSection title="输入" open={openSections.input} onToggle={() => toggle("input")}>
<ul className="rc-drawer-list">
{view.input_artifact_types.length > 0 ? (
view.input_artifact_types.map((type) => (
<li key={type}>
<span className="rc-flow-pill rc-flow-pill-input">{shortArtifact(type)}</span>
</li>
))
) : (
<li className="text-[var(--rc-text-secondary)]">无上游成果要求</li>
)}
{view.upstream.length > 0 ? <li>上游节点:{view.upstream.join("、")}</li> : null}
{view.source_policy ? <li>资料策略:{view.source_policy}</li> : null}
</ul>
</DrawerSection>
<DrawerSection title="输出" open={openSections.output} onToggle={() => toggle("output")}>
<ul className="rc-drawer-list">
<li>
成果类型:
{view.output_artifact_type ? (
<span className="rc-flow-pill rc-flow-pill-output">{shortArtifact(view.output_artifact_type)}</span>
) : (
"未指定"
)}
</li>
{view.acceptance_criteria.map((criterion) => (
<li key={criterion}>验收:{criterion}</li>
))}
</ul>
</DrawerSection>
<DrawerSection title="技能" open={openSections.tools} onToggle={() => toggle("tools")}>
<div className="flex flex-wrap gap-1">
{view.allowed_tools.length > 0 ? (
view.allowed_tools.map((tool) => (
<span key={tool} className="rc-flow-pill rc-flow-pill-neutral">
{tool}
</span>
))
) : (
<span className="text-[12px] text-[var(--rc-text-secondary)]">未配置技能</span>
)}
</div>
{view.forbidden.length > 0 ? (
<ul className="rc-drawer-list mt-2">
{view.forbidden.map((rule) => (
<li key={rule}>
<Ban className="mr-1 inline size-3" aria-hidden />
{rule}
</li>
))}
</ul>
) : null}
{view.max_attempts != null ? (
<p className="mt-2 text-[12px] text-[var(--rc-text-secondary)]">
预算:至多 {view.max_attempts} 次尝试
{view.time_budget_seconds ? ` · ${view.time_budget_seconds}s` : ""}
{view.token_budget ? ` · ${view.token_budget} tokens` : ""}
</p>
) : null}
</DrawerSection>
<DrawerSection title="上下游" open={openSections.graph} onToggle={() => toggle("graph")}>
<p className="rc-drawer-text">
{view.downstream.length > 0 ? `下游:${view.downstream.join("、")}` : "终端节点(无下游)"}
</p>
</DrawerSection>
</>
)}
</div>
<footer className="rc-drawer-footer">
<button
type="button"
className="rc-btn rc-btn-primary w-full justify-center"
onClick={() => onComment(nodeId, view.label)}
>
<Target className="size-3.5" aria-hidden />
在对话中对此节点提出意见
</button>
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
</p>
</footer>
</aside>
);
}
function DrawerSection({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="rc-drawer-section" data-open={open}>
<button type="button" className="rc-drawer-section-toggle" onClick={onToggle} aria-expanded={open}>
<ChevronDown className="rc-drawer-chevron" aria-hidden />
<span>{title}</span>
</button>
{open ? <div className="rc-drawer-section-body">{children}</div> : null}
</section>
);
}
function shortArtifact(value: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
}
function statusText(status: string): string {
const map: Record<string, string> = {
planned: "规划中",
queued: "排队中",
running: "运行中",
validating: "校验中",
completed: "已完成",
failed: "失败",
awaiting_input: "等待用户",
cancel_requested: "取消中",
cancelled: "已取消",
superseded: "已被替代",
reopened: "已重开",
interrupted: "已中断",
};
return map[status] ?? status;
}
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more