Compare commits

..
13 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
吉鑫升 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
董宏凯 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
董宏凯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
220 changed files with 11718 additions and 18800 deletions

No files matched your search

+3 -6
View File
@@ -100,11 +100,9 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
@@ -112,7 +110,6 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
-1
View File
@@ -83,7 +83,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
@@ -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 -1
View File
@@ -18,7 +18,7 @@ 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=false
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
-35
View File
@@ -1,35 +0,0 @@
# 项目空间
入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
## 使用流程
1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
模式在创建后不可修改,前后端都不提供修改入口。项目、对话归属和智能体访问权限在服务端校验;方案结构及每条对话所选智能体也以服务端配置为准。
## 代码与运行
- `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
启动新版 Gateway 后自动创建项目表。生产持久化依赖已配置的数据库;memory 模式中的项目随服务重启丢失。前端沿用仓库的 Vite 开发与构建命令。
## 验证范围
后端回归命令:在 `offline-backend-20260512/backend` 执行
```shell
python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q
```
涵盖内存与 SQLite 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
@@ -51,10 +51,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
@@ -73,7 +69,6 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
bootstrapResumePath,
isBootstrapThread,
@@ -484,7 +479,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
@@ -511,8 +505,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -524,15 +516,9 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -152,7 +152,6 @@ export function AgentDetailPanel({
positionName,
skillsIcon,
skillsTitle,
belowSkillsSlot,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
@@ -182,8 +181,6 @@ export function AgentDetailPanel({
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -943,9 +940,6 @@ export function AgentDetailPanel({
/>
</Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
@@ -4,12 +4,7 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -50,7 +45,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return (
<Link
key={thread.thread_id}
@@ -63,20 +57,15 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span className="min-w-0 flex-1 truncate">
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link>
);
})}
@@ -83,12 +83,10 @@ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
writingMode,
}) => {
const {
isMock,
@@ -116,7 +114,7 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -60,10 +60,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
@@ -79,7 +75,6 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -488,7 +483,6 @@ export function GeneralQaSidebar() {
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -513,8 +507,6 @@ export function GeneralQaSidebar() {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -526,15 +518,9 @@ export function GeneralQaSidebar() {
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="min-w-0 flex-1 truncate">
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -2,6 +2,7 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
@@ -142,6 +143,7 @@ export function InputBox({
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -149,9 +151,14 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -160,6 +167,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -176,6 +184,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -388,6 +397,14 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
@@ -796,6 +813,32 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
@@ -373,8 +373,6 @@ export function LlmWikiKnowledgeSelector({
readOnly = false,
className,
label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover",
}: {
value: string[];
@@ -389,8 +387,6 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean;
className?: string;
label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
@@ -478,10 +474,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />{panelTitle}
<Database className="size-4 text-primary" />绑定知识空间
</div>
<p className="mt-1 text-xs text-muted-foreground">
{panelDescription}
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
@@ -25,7 +25,6 @@ export function ModelNameSelect({
className,
triggerClassName,
defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
@@ -33,7 +32,6 @@ export function ModelNameSelect({
className?: string;
triggerClassName?: string;
defaultLabel?: string;
title?: string;
}) {
const { models } = useModels();
return (
@@ -44,7 +42,7 @@ export function ModelNameSelect({
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title={title}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
@@ -1,51 +0,0 @@
import { FolderKanban } from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<FolderKanban
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn("size-3.5 shrink-0 text-violet-500", className)}
/>
);
}
export function MyProjectThreadBadge({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<span
className={cn(
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
>
{MY_PROJECT_CONVERSATION_LABELS[type]}
</span>
);
}
@@ -49,11 +49,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -67,8 +63,7 @@ import {
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -250,8 +245,7 @@ export function RecentChatList({
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const isBoot = isBootstrapThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -285,10 +279,8 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
compact && (
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
@@ -302,15 +294,9 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -1,67 +0,0 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -61,12 +61,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
: []),
// 记忆已移至侧边栏入口 (/page/workspace/memory)
// {
// id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []),
{
id: "account",
@@ -129,9 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, 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";
@@ -24,10 +24,7 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -54,13 +51,6 @@ export type WorkspaceCompactNavItem = MenuItem & {
};
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-projects",
label: "项目空间",
icon: BookOpen,
path: "/page/workspace/projects",
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
},
{
id: "workspace-new-chat",
label: "新对话",
@@ -75,15 +65,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
match: (pathname) =>
pathname.startsWith("/page/strategy/qa/projects") ||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
},
{
id: "workspace-library",
label: "资料库",
@@ -105,6 +86,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -152,13 +140,7 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
},
];
@@ -35,7 +35,6 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
@@ -1,137 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getStoredAuth } from "@/core/auth";
import { getBackendBaseURL } from "@/core/config";
export type ProjectMode = "qa" | "strategy" | "solution";
export type ProjectReportConfig = {
custom_outline: string;
structure_mode: "fixed" | "adaptive";
retrieval_directions: string[];
retrieval_skills: string[];
};
export type ProjectStrategyConfig = { lenses: string[] };
export const DEFAULT_STRATEGY_LENSES = [
"增长视角",
"风险视角",
"竞争视角",
"资源视角",
"长期价值视角",
] as const;
export type ProjectFields = {
name: string;
background: string;
knowledge_base_ids: string[];
assistant_knowledge_base_ids: string[];
agent_id: string | null;
report_config: ProjectReportConfig;
strategy_config: ProjectStrategyConfig;
};
export type AnalysisProject = ProjectFields & {
id: string;
mode: ProjectMode;
created_at: string;
updated_at: string;
solution_template: { id: string; name: string; outline: string } | null;
};
export type ProjectThread = {
thread_id: string;
title?: string;
status: string;
updated_at: string;
metadata?: Record<string, unknown>;
};
export const PROJECT_MODES = {
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
strategy: {
name: "策略智能模式",
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
},
solution: {
name: "方案生成模式",
description: "选择报告结构,沿用研究管线生成方案",
},
} as const;
export const PROJECT_OUTLINE = [
"背景与依据",
"目标与约束",
"实施路径与重点任务",
"资源配置与时间计划",
"风险与应对措施",
"评估指标与参考依据",
];
export const projectKeys = () =>
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/analysis-projects${path}`,
{
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
},
);
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(
typeof error.detail === "string"
? error.detail
: `项目操作失败(${response.status})`,
);
}
return response.json() as Promise<T>;
}
export function useAnalysisProjects() {
return useQuery({
queryKey: projectKeys(),
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
});
}
export function useAnalysisProject(id: string) {
return useQuery({
queryKey: [...projectKeys(), id],
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
enabled: !!id,
});
}
export function useProjectThreads(id: string) {
return useQuery({
queryKey: [...projectKeys(), id, "threads"],
queryFn: () =>
request<{ threads: ProjectThread[] }>(
`/${encodeURIComponent(id)}/threads`,
),
enabled: !!id,
refetchInterval: 15000,
});
}
export function useProjectThreadAccess(projectId: string, threadId?: string) {
return useQuery({
queryKey: [...projectKeys(), projectId, "thread", threadId],
queryFn: () =>
request<ProjectThread>(
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
),
enabled: !!threadId,
retry: false,
});
}
export const createAnalysisProject = (
fields: ProjectFields & { mode: ProjectMode },
) =>
request<AnalysisProject>("", {
method: "POST",
body: JSON.stringify(fields),
});
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(fields),
});
export const createProjectThread = (id: string, agentId?: string | null) =>
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
method: "POST",
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
});
@@ -1,141 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
export const STRATEGY_COLUMNS = [
"意图理解",
"多视角策略分析",
"完整策略方案",
] as const;
const STRATEGY_HEADING_INDEX: Record<string, number> = {
意图理解: 0,
意图理解与分析角度: 0,
多视角策略分析: 1,
推荐策略: 1,
完整策略方案: 2,
综合结果: 2,
结论: 2,
};
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
export function splitStrategySections(text: string): [string, string, string] {
const parts: string[][] = [[], [], []];
let section = 0;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading =
!fence &&
line.match(
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
);
if (heading) {
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
continue;
}
parts[section]!.push(line);
}
return parts.map((lines) => lines.join("\n").trim()) as [
string,
string,
string,
];
}
export type StrategySubsection = { title: string; content: string };
export type StrategyClarification = {
question: string;
context?: string;
options: { id: string; label: string; description?: string }[];
allow_custom: boolean;
};
export function pendingStrategyClarification(
messages: Message[],
): StrategyClarification | null {
let pending: StrategyClarification | null = null;
for (const message of messages) {
const raw = message.additional_kwargs?.clarification;
if (raw && typeof raw === "object") {
const data = raw as Record<string, unknown>;
const question = typeof data.question === "string" ? data.question : "";
if (!question) continue;
pending = {
question,
context: typeof data.context === "string" ? data.context : undefined,
options: Array.isArray(data.options)
? data.options.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const option = item as Record<string, unknown>;
if (typeof option.label !== "string") return [];
return [{
id: typeof option.id === "string" ? option.id : option.label,
label: option.label,
description:
typeof option.description === "string"
? option.description
: undefined,
}];
})
: [],
allow_custom: data.allow_custom !== false,
};
} else if (pending && message.type === "human") {
pending = null;
}
}
return pending;
}
export function splitStrategySubsections(text: string): StrategySubsection[] {
const sections: StrategySubsection[] = [];
let current: StrategySubsection | null = null;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
if (heading) {
current = { title: heading[1]!, content: "" };
sections.push(current);
} else if (current) {
current.content += `${current.content ? "\n" : ""}${line}`;
}
}
return sections.map((item) => ({ ...item, content: item.content.trim() }));
}
export function strategyRounds(messages: Message[]) {
const rounds: { id: string; question: string; answer: string }[] = [];
for (const message of messages) {
if (
message.additional_kwargs?.hide_from_ui ||
!["human", "ai"].includes(message.type)
)
continue;
const text =
typeof message.content === "string"
? message.content
: message.content
.filter(
(item): item is Extract<typeof item, { type: "text" }> =>
item.type === "text" &&
"text" in item &&
typeof item.text === "string",
)
.map((item) => item.text)
.join("\n");
if (message.type === "human") {
if (text.trim())
rounds.push({
id: message.id || `round-${rounds.length}`,
question: text,
answer: "",
});
} else if (text.trim()) {
if (!rounds.length)
rounds.push({
id: message.id || "round-0",
question: "策略分析",
answer: "",
});
rounds[rounds.length - 1]!.answer +=
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
}
}
return rounds;
}
+11 -21
View File
@@ -364,18 +364,12 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
}
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
if (part && "thinking" in part) {
@@ -391,9 +385,8 @@ export function extractReasoningContentFromMessage(message: Message) {
export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) {
return;
}
delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
}
delete message.additional_kwargs.reasoning_content;
}
export function extractURLFromImageURLContent(
@@ -428,12 +421,9 @@ export function hasReasoning(message: Message) {
if (message.type !== "ai") {
return false;
}
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
@@ -21,7 +21,6 @@ import {
FileBox,
FileSearch,
FileText,
FolderKanban,
Gauge,
Grid3X3,
HelpCircle,
@@ -49,23 +48,12 @@ export type MenuItem = {
path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
@@ -73,7 +61,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{
id: "multi-agent-roundtable",
@@ -81,13 +68,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
aliasPaths: ["/page/strategy/qa/strategy-selection"],
},
// {
// id: "position-roundtable",
// label: "岗位会商",
@@ -278,7 +258,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
MARKDOWN_DOCX_MENU_ITEM,
{
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{
id: "mgmt-tasks",
label: "定时任务",
@@ -345,7 +330,6 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
},
{
id: "resource-knowledge-base",
@@ -381,9 +365,6 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
@@ -13,44 +13,6 @@ export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export interface StrategyProjectSettings {
taiwan_strait_demo_enabled: boolean;
}
export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
);
if (!response.ok) {
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
}
return (await response.json()) as StrategyProjectSettings;
}
export async function updateStrategyProjectSettings(
body: StrategyProjectSettingsUpdate,
): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存策略项目设置失败" }));
throw new Error(detail.detail ?? "保存策略项目设置失败");
}
return (await response.json()) as StrategyProjectSettings;
}
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
@@ -601,48 +563,6 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>;
}
export interface MemoryRuntimeSettings {
enabled: boolean;
}
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
);
if (!response.ok) {
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
}
return (await response.json()) as MemoryRuntimeSettings;
}
export async function updateMemoryRuntimeSettings(
body: MemoryRuntimeSettings,
): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
}
const data = (await response.json()) as MemoryRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-问答记忆开关",
url: "/api/system-settings/memory",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
@@ -8,36 +8,30 @@ import {
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
loadStrategyProjectSettings,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
updateSkillCompressionSettings,
updateStrategyProjectSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode,
type ViewMode,
} from "./api";
@@ -50,30 +44,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
export function useStrategyProjectSettings() {
return useQuery({
queryKey: STRATEGY_PROJECTS_KEY,
queryFn: () => loadStrategyProjectSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateStrategyProjectSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: StrategyProjectSettingsUpdate) =>
updateStrategyProjectSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
},
});
}
export function usePromptPrefixSettings() {
return useQuery({
@@ -115,26 +86,6 @@ export function useUpdatePrivateSquareSettings() {
});
}
export function useMemoryRuntimeSettings() {
return useQuery({
queryKey: MEMORY_RUNTIME_KEY,
queryFn: () => loadMemoryRuntimeSettings(),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateMemoryRuntimeSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: MemoryRuntimeSettings) =>
updateMemoryRuntimeSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
},
});
}
/** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
+4 -9
View File
@@ -886,15 +886,8 @@ export function useThreadStream({
// Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const projectHash = typeof window !== "undefined" ? window.location.hash : "";
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
if (projectMatch) {
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
navigate("/page/workspace/projects", { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
@@ -1598,6 +1591,8 @@ export function useThreadStream({
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId,
@@ -1,46 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
myProjectConversationTypeFromSearchParams,
myProjectConversationTypeOfThread,
myProjectThreadMetadata,
withMyProjectConversationMarker,
} from "./project-metadata";
test("project conversation type is read from persisted metadata", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("strategy"),
}),
"strategy",
);
assert.equal(
myProjectConversationTypeOfThread({
metadata: { project_source: "elsewhere", project_type: "qa" },
}),
null,
);
});
test("metadata wins over the compatibility context fallback", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("solution"),
context: myProjectThreadMetadata("qa"),
}),
"solution",
);
});
test("new-chat route marker round-trips all supported project types", () => {
for (const type of ["qa", "strategy", "solution"] as const) {
const path = withMyProjectConversationMarker(
"/page/strategy/chats/new?keep=1",
type,
);
const params = new URLSearchParams(path.split("?", 2)[1]);
assert.equal(params.get("keep"), "1");
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
}
});
@@ -1,94 +0,0 @@
/**
* Stable marker for conversations entered from 「我的项目」.
*
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
* folder-style conversation projects, while this marker describes the product
* mode that created (or explicitly continued) a conversation.
*/
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
export type MyProjectConversationType = "qa" | "strategy" | "solution";
export const MY_PROJECT_CONVERSATION_LABELS: Record<
MyProjectConversationType,
string
> = {
qa: "普通问答",
strategy: "策略优选",
solution: "方案生成",
};
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
"qa",
"strategy",
"solution",
]);
function typeFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationType | null {
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
export function myProjectThreadMetadata(
type: MyProjectConversationType,
): Record<string, unknown> {
return {
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
[MY_PROJECT_THREAD_TYPE_KEY]: type,
};
}
/** Read persisted metadata first; context is only a compatibility fallback. */
export function myProjectConversationTypeOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): MyProjectConversationType | null {
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
}
export function myProjectConversationTypeFromSearchParams(params: {
get(name: string): string | null;
}): MyProjectConversationType | null {
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
/** Add an explicit, one-shot marker to a new-conversation route. */
export function withMyProjectConversationMarker(
path: string,
type: MyProjectConversationType,
): string {
const hashIndex = path.indexOf("#");
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
const queryIndex = pathWithoutHash.indexOf("?");
const pathname =
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
const params = new URLSearchParams(
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
);
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
+1
View File
@@ -19,6 +19,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
+6 -24
View File
@@ -45,17 +45,10 @@ export function pathOfThread(
base = "/page/workspace",
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const projectId =
typeof thread === "string"
? undefined
: thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
}
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -127,9 +120,7 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -139,10 +130,7 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return (
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -170,15 +158,11 @@ export function listPathOfThread(thread: AgentThread): string {
}
export function isThreadPinned(thread: AgentThread): boolean {
return (
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
}
/**
@@ -195,9 +179,7 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest];
}
export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
const cleaned = title
@@ -116,14 +116,6 @@ export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -165,10 +157,7 @@ async function externalTaskError(response: Response, fallback: string): Promise<
}
function findProcessReportId(payload: unknown): string {
if (payload == null) return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
if (!payload || typeof payload !== "object") return "";
const source = payload as Record<string, unknown>;
const direct = firstText(
source.id,
@@ -181,10 +170,9 @@ function findProcessReportId(payload: unknown): string {
}
/**
* Create the external task system's process report **and submit it in one
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* `data` is the new record id. The local gateway stores that id so a refresh
* can still reconcile the same report instead of creating a duplicate.
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
*/
export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput,
@@ -192,7 +180,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: processReportBody(input),
body: JSON.stringify(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
@@ -200,7 +188,7 @@ export async function createEightBfTaskProcessReport(
return { id };
}
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
/** Submit an already-registered process report to the external task system. */
export async function updateEightBfTaskProcessReport(
reportId: string,
input: EightBfTaskProcessReportInput,
@@ -210,7 +198,7 @@ export async function updateEightBfTaskProcessReport(
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: processReportBody(input),
body: JSON.stringify(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -32,7 +32,6 @@ import {
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { updateChain } from "@/roundtable-planning/api/chains";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -47,8 +46,6 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
@@ -87,7 +84,6 @@ export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
selectedModel,
modelOptions,
onSelectedModelChange,
@@ -157,7 +153,6 @@ export function EightBfStep1Panel({
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
@@ -304,7 +299,6 @@ function EightBfAgentWorkspace({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
onCollapse,
step2Dialogues,
step2ThreadIds,
@@ -316,7 +310,6 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
@@ -455,13 +448,6 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions
panelTitle="智能体配置"
embedded
@@ -626,82 +612,3 @@ function arrayValue(...values: unknown[]): string[] {
}
return [];
}
/**
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
*
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
*/
function SeatArtifactPositionEditor({
chain,
seat,
onUpdated,
}: {
chain: BusinessChain;
seat: ChainSeat;
onUpdated?: (chain: BusinessChain) => void;
}) {
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
const [saving, setSaving] = useState(false);
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
useEffect(() => {
setValue(seat.artifact_position?.toString() ?? "");
}, [seat.agent_id, seat.artifact_position]);
const trimmed = value.trim();
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
const dirty = nextValue !== (seat.artifact_position ?? null);
const handleSave = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const seats = chain.seats.map((s) =>
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
);
const updated = await updateChain(chain.id, { seats });
if (!updated) throw new Error("业务链已不存在,可能被删除");
onUpdated?.(updated);
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
<div className="flex items-center gap-1.5">
<input
type="number"
inputMode="numeric"
value={value}
placeholder="0"
aria-label="产物提交 position"
onChange={(event) => setValue(event.target.value)}
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
disabled={!dirty || saving}
onClick={() => void handleSave()}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && <Loader2 className="size-3 animate-spin" />}
保存
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
</p>
</div>
);
}
@@ -88,50 +88,14 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
// 表面可能以 id 或名称出现,双键匹配避免查不到);
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
// 避免不同链的同名席位互相误配)。
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
const seatArtifactPositions = useMemo(() => {
const map: Record<string, number> = {};
const put = (key: string, value: number) => {
const normalized = key.trim().toLowerCase();
if (normalized && !(normalized in map)) map[normalized] = value;
};
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
}
}
for (const chain of chains) {
if (chain.id === configuredChain?.id) continue;
for (const seat of chain.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
}
}
}
return map;
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId, seatArtifactPositions],
[taskId],
);
useEffect(() => {
@@ -235,12 +199,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = (
<>
{canConfigureChain && isSettingsOpen && (
@@ -335,7 +293,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -386,8 +343,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/>
</div>
);
@@ -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 };
+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>
);
}
@@ -1,808 +0,0 @@
import { useEffect, useState } from "react";
import {
Link,
Route,
Routes,
useLocation,
useMatch,
useNavigate,
useParams,
} from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Bot,
FolderOpen,
Loader2,
LockKeyhole,
Plus,
Settings,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useAgentSelector } from "@/core/agents/hooks";
import { WorkflowConfigButton } from "./deep-research/WorkflowConfigDialog";
import {
createAnalysisProject,
createProjectThread,
DEFAULT_STRATEGY_LENSES,
PROJECT_MODES,
PROJECT_OUTLINE,
projectKeys,
updateAnalysisProject,
useAnalysisProject,
useAnalysisProjects,
useProjectThreadAccess,
useProjectThreads,
type AnalysisProject,
type ProjectFields,
type ProjectMode,
type ProjectReportConfig,
type ProjectStrategyConfig,
} from "@/core/analysis-projects/api";
import { cn } from "@/lib/utils";
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
const BASE = "/page/workspace/projects";
const defaultReport: ProjectReportConfig = {
custom_outline: PROJECT_OUTLINE.map((s, i) => `## ${i + 1}. ${s}`).join("\n"),
structure_mode: "fixed",
retrieval_directions: [],
retrieval_skills: [],
};
const defaultStrategy: ProjectStrategyConfig = {
lenses: [...DEFAULT_STRATEGY_LENSES],
};
function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
return (
<div
role="alert"
className="m-4 rounded-lg border border-destructive/30 p-4 text-sm"
>
<p>{error instanceof Error ? error.message : "读取失败,请重试"}</p>
{retry && (
<Button variant="outline" className="mt-3" onClick={retry}>
重试
</Button>
)}
</div>
);
}
function Loading() {
return (
<div className="flex flex-1 items-center justify-center p-10">
<Loader2 className="mr-2 size-4 animate-spin" />
加载中…
</div>
);
}
function AgentSelect({
value,
onChange,
label = "选择智能体",
}: {
value: string | null;
onChange: (id: string | null) => void;
label?: string;
}) {
const { agents, isLoading, error } = useAgentSelector();
return (
<label className="flex min-w-0 flex-col gap-2 text-sm">
<span>{label}</span>
<select
aria-label={label}
className="h-9 max-w-full rounded-md border bg-background px-3 text-sm"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">通用助手</option>
{value && !agents.some((a) => a.id === value) && (
<option value={value}>所选智能体(加载中或已不可用)</option>
)}
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
{isLoading && (
<span className="text-xs text-muted-foreground">加载智能体…</span>
)}
{error && (
<span role="alert" className="text-xs text-destructive">
智能体列表加载失败,请刷新重试
</span>
)}
</label>
);
}
function ReportConfiguration({
value,
onChange,
}: {
value: ProjectReportConfig;
onChange: (next: ProjectReportConfig) => void;
}) {
return (
<section className="space-y-4 rounded-xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">工作流配置 · 报告结构</h2>
<p className="mt-1 text-xs text-muted-foreground">
选择结构后自动填充;结构库支持新增、启停、编辑和删除。
</p>
</div>
<WorkflowConfigButton
inlineCreate
appliedContent={value.custom_outline}
onApply={(
content,
structureMode,
retrievalDirections,
retrievalSkills,
) =>
onChange({
custom_outline: content,
structure_mode: structureMode,
retrieval_directions: retrievalDirections,
retrieval_skills: retrievalSkills,
})
}
/>
</div>
<label className="block space-y-2 text-sm">
<span>报告结构配置</span>
<Textarea
aria-label="报告结构配置"
rows={8}
value={value.custom_outline}
onChange={(e) =>
onChange({ ...value, custom_outline: e.target.value })
}
placeholder="选择报告结构,或输入 Markdown 章节大纲"
/>
</label>
<label className="flex items-center gap-3 text-sm">
<span>结构约束</span>
<select
aria-label="结构约束"
className="h-9 rounded-md border bg-background px-3"
value={value.structure_mode}
onChange={(e) =>
onChange({
...value,
structure_mode: e.target.value as "fixed" | "adaptive",
})
}
>
<option value="fixed">固定大纲</option>
<option value="adaptive">弹性大纲</option>
</select>
</label>
<p className="text-xs text-muted-foreground">
检索方向:
{value.retrieval_directions.join("、") || "由智能体根据问题确定"}
;检索技能:{value.retrieval_skills.join("、") || "沿用智能体配置"}
</p>
<p className="text-xs text-muted-foreground">
项目保存当前结构副本。结构库变更后可重新选择应用,也可直接修改本项目大纲;保存后用于下一轮方案生成。
</p>
</section>
);
}
function StrategyConfiguration({
value,
onChange,
}: {
value: ProjectStrategyConfig;
onChange: (next: ProjectStrategyConfig) => void;
}) {
const [draft, setDraft] = useState("");
const toggle = (lens: string) => {
const selected = value.lenses.includes(lens);
if (selected && value.lenses.length === 1) return;
onChange({
lenses: selected
? value.lenses.filter((item) => item !== lens)
: [...value.lenses, lens].slice(0, 10),
});
};
const add = () => {
const lens = draft.trim().replace(/视角$/, "") + "视角";
if (!draft.trim() || value.lenses.includes(lens) || value.lenses.length >= 10)
return;
onChange({ lenses: [...value.lenses, lens] });
setDraft("");
};
return (
<section className="space-y-4 rounded-xl border p-4">
<div>
<h2 className="text-sm font-medium">Strategy Lens · 默认分析视角</h2>
<p className="mt-1 text-xs text-muted-foreground">
每个视角独立形成判断与候选策略,再进入策略辩论和综合决策。至少保留一个,最多十个。
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{DEFAULT_STRATEGY_LENSES.map((lens) => (
<label
key={lens}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm",
value.lenses.includes(lens) && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
checked={value.lenses.includes(lens)}
onChange={() => toggle(lens)}
className="accent-primary"
/>
{lens}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
{value.lenses
.filter(
(lens) =>
!DEFAULT_STRATEGY_LENSES.includes(
lens as (typeof DEFAULT_STRATEGY_LENSES)[number],
),
)
.map((lens) => (
<Badge key={lens} variant="secondary" className="gap-1 py-1">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
onClick={() => toggle(lens)}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
aria-label="自定义分析视角"
maxLength={38}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
add();
}
}}
placeholder="例如:用户体验、供应链、敌方"
/>
<Button type="button" variant="outline" onClick={add}>
添加视角
</Button>
</div>
</section>
);
}
function ProjectForm({
project,
onCancel,
}: {
project?: AnalysisProject;
onCancel: () => void;
}) {
const navigate = useNavigate(),
queryClient = useQueryClient();
const [name, setName] = useState(project?.name ?? ""),
[background, setBackground] = useState(project?.background ?? "");
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? "qa");
const [agentId, setAgentId] = useState<string | null>(
project?.agent_id ?? null,
);
const [report, setReport] = useState<ProjectReportConfig>({
...defaultReport,
...project?.report_config,
custom_outline:
project?.report_config?.custom_outline || defaultReport.custom_outline,
});
const [strategy, setStrategy] = useState<ProjectStrategyConfig>({
...defaultStrategy,
...project?.strategy_config,
lenses: project?.strategy_config?.lenses?.length
? project.strategy_config.lenses
: defaultStrategy.lenses,
});
const save = useMutation({
mutationFn: () => {
const fields: ProjectFields = {
name: name.trim(),
background: background.trim(),
agent_id: agentId,
report_config:
mode === "solution"
? report
: { ...defaultReport, custom_outline: "" },
strategy_config: strategy,
knowledge_base_ids: [],
assistant_knowledge_base_ids: [],
};
return project
? updateAnalysisProject(project.id, fields)
: createAnalysisProject({ ...fields, mode });
},
onSuccess: async (result) => {
queryClient.setQueryData([...projectKeys(), result.id], result);
await queryClient.invalidateQueries({ queryKey: projectKeys() });
toast.success(project ? "项目配置已保存" : "项目已创建");
navigate(`${BASE}/${result.id}`);
},
});
return (
<form
className="mx-auto w-full max-w-4xl space-y-6 p-5 md:p-7"
onSubmit={(e) => {
e.preventDefault();
if (!save.isPending && name.trim()) save.mutate();
}}
>
<div className="flex items-center gap-3">
<SidebarTrigger />
<Button type="button" variant="ghost" onClick={onCancel}>
<ArrowLeft className="size-4" />
返回
</Button>
<h1 className="text-xl font-semibold">
{project ? "项目设置" : "新建项目"}
</h1>
</div>
<label className="block space-y-2 text-sm">
<span>项目名称</span>
<Input
required
maxLength={160}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:区域市场背景研究"
/>
</label>
<fieldset className="space-y-3">
<legend className="text-sm font-medium">项目问答模式</legend>
{project ? (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Badge variant="secondary">{PROJECT_MODES[mode].name}</Badge>
<span className="text-xs text-muted-foreground">
创建时已确定,不可更改
</span>
</div>
) : (
<>
<div className="grid gap-3 lg:grid-cols-3">
{(Object.keys(PROJECT_MODES) as ProjectMode[]).map((value) => (
<label
key={value}
className={cn(
"flex cursor-pointer gap-3 rounded-xl border p-4",
mode === value && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="project-mode"
checked={mode === value}
onChange={() => setMode(value)}
className="mt-1 accent-primary"
/>
<span>
<span className="text-sm font-medium">
{PROJECT_MODES[value].name}
</span>
<span className="mt-2 block text-xs leading-5 text-muted-foreground">
{PROJECT_MODES[value].description}
</span>
</span>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">
模式创建后不可更改,项目内所有对话使用同一模式。
</p>
</>
)}
</fieldset>
<section className="space-y-2">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="项目默认智能体"
/>
<p className="text-xs text-muted-foreground">
沿用智能体的能力、技能和知识配置。新建对话时可以选择其他智能体。
</p>
</section>
<label className="block space-y-2 text-sm">
<span>项目说明</span>
<Textarea
value={background}
maxLength={12000}
onChange={(e) => setBackground(e.target.value)}
rows={3}
placeholder="填写项目目标、业务背景与约束…"
/>
</label>
{mode === "solution" && (
<ReportConfiguration value={report} onChange={setReport} />
)}
{mode === "strategy" && (
<StrategyConfiguration value={strategy} onChange={setStrategy} />
)}
{save.error && <ErrorState error={save.error} />}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onCancel}>
取消
</Button>
<Button
type="submit"
disabled={
save.isPending ||
!name.trim() ||
(mode === "solution" && !report.custom_outline.trim())
}
>
{save.isPending && <Loader2 className="size-4 animate-spin" />}
{project ? "保存设置" : "创建项目"}
</Button>
</div>
</form>
);
}
function ProjectList() {
const query = useAnalysisProjects();
return (
<div className="h-full overflow-y-auto p-5 md:p-7">
<div className="mx-auto max-w-6xl">
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<SidebarTrigger />
<div>
<h1 className="text-xl font-semibold">项目空间</h1>
<p className="mt-1 text-sm text-muted-foreground">
按项目归类对话,使用熟悉的问答工作区。
</p>
</div>
</div>
<Button asChild>
<Link to={`${BASE}/new`}>
<Plus className="size-4" />
新建项目
</Link>
</Button>
</header>
{query.isLoading ? (
<Loading />
) : query.error ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<div className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{query.data?.projects.map((project) => (
<article
key={project.id}
className="rounded-xl border bg-card p-5"
>
<Badge variant="secondary">
{PROJECT_MODES[project.mode].name}
</Badge>
<h2 className="mt-4 text-lg font-medium">{project.name}</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{project.background ||
PROJECT_MODES[project.mode].description}
</p>
<Button asChild variant="outline" className="mt-4 w-full">
<Link to={`${BASE}/${project.id}`}>进入项目</Link>
</Button>
</article>
))}
</div>
)}
{!query.isLoading && !query.error && !query.data?.projects.length && (
<div className="mt-8 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
<FolderOpen className="mx-auto mb-3 size-8" />
创建项目,将相关问题和分析放在一起。
</div>
)}
</div>
</div>
);
}
function WorkspaceContent({ project }: { project: AnalysisProject }) {
const location = useLocation(),
navigate = useNavigate(),
queryClient = useQueryClient();
const threadsQuery = useProjectThreads(project.id),
match = useMatch(`${BASE}/:projectId/chats/:thread_id`);
const threadId = match?.params.thread_id,
access = useProjectThreadAccess(project.id, threadId);
const [agentId, setAgentId] = useState<string | null>(project.agent_id);
const [lensDraft, setLensDraft] = useState("");
const [addingLens, setAddingLens] = useState(false);
const strategyLenses = project.strategy_config?.lenses?.length
? project.strategy_config.lenses
: [...DEFAULT_STRATEGY_LENSES];
const saveLenses = useMutation({
mutationFn: (lenses: string[]) =>
updateAnalysisProject(project.id, {
name: project.name,
background: project.background,
agent_id: project.agent_id,
report_config: project.report_config,
strategy_config: { lenses },
knowledge_base_ids: [],
assistant_knowledge_base_ids: [],
}),
onSuccess: (updated) => {
queryClient.setQueryData([...projectKeys(), project.id], updated);
void queryClient.invalidateQueries({ queryKey: projectKeys() });
},
onError: (error) => toast.error(error.message),
});
const addLens = () => {
const raw = lensDraft.trim();
const lens = raw ? `${raw.replace(/视角$/, "")}视角` : "";
if (!lens || strategyLenses.includes(lens) || strategyLenses.length >= 10)
return;
saveLenses.mutate([...strategyLenses, lens]);
setLensDraft("");
setAddingLens(false);
};
const create = useMutation({
mutationFn: () => createProjectThread(project.id, agentId),
onSuccess: async (thread) => {
await queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
await queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
navigate(`${BASE}/${project.id}/chats/${thread.thread_id}`);
},
onError: (e) => toast.error(e.message),
});
const threads = threadsQuery.data?.threads ?? [];
useEffect(() => {
if (threadsQuery.data) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}
}, [queryClient, threadsQuery.data]);
if (location.pathname.endsWith("/settings"))
return (
<div className="h-full overflow-y-auto">
<ProjectForm
key={project.updated_at}
project={project}
onCancel={() => navigate(`${BASE}/${project.id}`)}
/>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="shrink-0 border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<SidebarTrigger />
<Link to={BASE} className="text-sm text-muted-foreground">
项目空间
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="min-w-0 flex-1 truncate font-semibold">
{project.name}
</h1>
<Badge variant="secondary">{PROJECT_MODES[project.mode].name}</Badge>
<LockKeyhole
className="size-3 text-muted-foreground"
aria-label="项目模式已锁定"
/>
<Button size="sm" variant="ghost" asChild>
<Link to={`${BASE}/${project.id}/settings`}>
<Settings className="size-4" />
{project.mode === "solution"
? "报告结构与设置"
: project.mode === "strategy"
? "分析视角与设置"
: "项目设置"}
</Link>
</Button>
</div>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div className="w-48">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="新对话使用的智能体"
/>
</div>
<Button
size="sm"
disabled={create.isPending}
onClick={() => create.mutate()}
>
<Plus className="size-4" />
新建对话
</Button>
<label className="ml-auto flex max-w-full items-center gap-2 text-xs text-muted-foreground">
<span>项目对话</span>
<select
aria-label="项目对话"
className="h-9 max-w-56 rounded-md border bg-background px-2 text-sm"
value={threadId ?? ""}
onChange={(e) =>
navigate(
e.target.value
? `${BASE}/${project.id}/chats/${e.target.value}`
: `${BASE}/${project.id}`,
)
}
>
<option value="">选择历史对话</option>
{threads.map((t) => (
<option key={t.thread_id} value={t.thread_id}>
{t.title || "新对话"}
</option>
))}
</select>
</label>
</div>
{project.mode === "strategy" && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground">
分析视角
</span>
{strategyLenses.map((lens) => (
<Badge key={lens} variant="outline" className="gap-1 bg-background">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
disabled={strategyLenses.length === 1 || saveLenses.isPending}
onClick={() =>
saveLenses.mutate(
strategyLenses.filter((item) => item !== lens),
)
}
>
<X className="size-3" />
</button>
</Badge>
))}
{addingLens ? (
<div className="flex items-center gap-1">
<Input
autoFocus
aria-label="添加分析视角"
className="h-7 w-40 text-xs"
value={lensDraft}
maxLength={38}
placeholder="如:就业安全"
onChange={(event) => setLensDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addLens();
if (event.key === "Escape") setAddingLens(false);
}}
/>
<Button type="button" size="sm" className="h-7" onClick={addLens}>
确定
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={strategyLenses.length >= 10}
onClick={() => setAddingLens(true)}
>
<Plus className="size-3" />
添加视角
</Button>
)}
</div>
)}
{threadsQuery.error && (
<ErrorState
error={threadsQuery.error}
retry={() => void threadsQuery.refetch()}
/>
)}
</header>
{threadId ? (
access.isLoading ? (
<Loading />
) : access.error ? (
<ErrorState error={access.error} />
) : (
<ProjectConversation
key={threadId}
project={project}
threadId={threadId}
agentId={
typeof access.data?.metadata?.agent_id === "string"
? access.data.metadata.agent_id
: null
}
/>
)
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-3xl py-8">
<Bot className="mb-4 size-8 text-primary" />
<h2 className="text-xl font-medium">在项目中继续问答</h2>
<p className="mt-3 text-sm text-muted-foreground">
{project.background || PROJECT_MODES[project.mode].description}
。选择智能体后新建对话,已有对话也会正常出现在左侧聊天记录中。
</p>
{project.mode === "strategy" && (
<div className="mt-6 grid gap-3 md:grid-cols-3">
{[
"01 意图理解",
"02 多视角策略分析与辩论",
"03 完整策略方案与执行路线",
].map((s) => (
<div className="rounded-xl border p-5 text-sm" key={s}>
{s}
</div>
))}
</div>
)}
<div className="mt-6 divide-y rounded-xl border">
{threads.map((t) => (
<Link
key={t.thread_id}
to={`${BASE}/${project.id}/chats/${t.thread_id}`}
className="block px-4 py-3 text-sm hover:bg-muted/40"
>
{t.title || "新对话"}
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
}
function ProjectWorkspace() {
const { projectId = "" } = useParams();
const query = useAnalysisProject(projectId);
return query.isLoading ? (
<Loading />
) : query.error || !query.data ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<WorkspaceContent key={query.data.id} project={query.data} />
);
}
function NewProject() {
const navigate = useNavigate();
return (
<div className="h-full overflow-y-auto">
<ProjectForm onCancel={() => navigate(BASE)} />
</div>
);
}
export default function AnalysisProjectsPage() {
const { userId } = useLiveAccount();
return (
<Routes key={userId ?? "anonymous"}>
<Route index element={<ProjectList />} />
<Route path="new" element={<NewProject />} />
<Route path=":projectId/*" element={<ProjectWorkspace />} />
</Routes>
);
}
+14 -28
View File
@@ -64,10 +64,6 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import {
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -289,28 +285,16 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification();
const projectConversationType =
myProjectConversationTypeFromSearchParams(searchParams);
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
const metadata: Record<string, unknown> = projectConversationType
? myProjectThreadMetadata(projectConversationType)
: {};
if (knowledgeSelectionReady) {
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}, [
knowledgeSelectionReady,
projectConversationType,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
]);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: newThreadMetadata,
threadMetadata: knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
@@ -421,9 +405,7 @@ export default function ChatPage() {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({
metadata: newThreadMetadata ?? {},
});
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
@@ -483,8 +465,13 @@ export default function ChatPage() {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: {
...newThreadMetadata,
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
});
sessionStorage.setItem(
@@ -503,7 +490,7 @@ export default function ChatPage() {
);
saveLastAgentId(selectedAgentId);
router.push(
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
);
} catch (err) {
agentHandoffPendingRef.current = false;
@@ -562,7 +549,6 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending,
isMock,
router,
+18
View File
@@ -2,6 +2,7 @@
import {
BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
@@ -399,6 +400,13 @@ export default function IframeChatPage() {
});
}, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => {
if (ragModeLocked) {
return;
@@ -523,6 +531,9 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -544,6 +555,13 @@ export default function IframeChatPage() {
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
};
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references":
return {
...button,
@@ -5,9 +5,10 @@ import {
ChevronDown,
CircleHelp,
Clock3,
FolderKanban,
GitBranch,
LoaderCircle,
MessageSquareText,
Network,
PanelLeftClose,
Pause,
Play,
@@ -21,12 +22,7 @@ import {
} from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Toaster } from "sonner";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import {
@@ -35,33 +31,36 @@ import {
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 {
myProjectConversationTypeOfThread,
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
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 StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
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"
| "projects"
| "agents"
| "agent-chat"
| "scheduled"
| "multi-agent-tasks"
| "multi-agent-edit"
| "task-analysis"
| "task-analysis-detail"
| "settings";
function pageFromPath(pathname: string): OfficialPage {
@@ -69,9 +68,15 @@ function pageFromPath(pathname: string): OfficialPage {
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat";
}
if (path.startsWith("/workspace/projects")) return "projects";
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";
}
@@ -97,11 +102,33 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null;
}
function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
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;
}
@@ -183,36 +210,20 @@ function ChatWorkspace({
routeThreadId: string;
}) {
const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new";
const projectConversationType = isNewThread
? myProjectConversationTypeFromSearchParams(
new URLSearchParams(location.search),
)
: null;
// 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,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: {
// This compact official shell has no model/mode controls of its own.
// Do not inherit a hidden selection saved by the customized workspace
// (for example an obsolete or quota-exhausted model); an omitted model
// lets the backend resolve the current system default or the agent's
// globally configured model.
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
...localSettings.context,
...(agentId ? { agent_id: agentId } : {}),
},
onStart: (createdThreadId) => {
@@ -698,8 +709,16 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname),
[location.pathname],
);
const projectId = useMemo(
() => parseProjectId(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 } =
@@ -710,14 +729,14 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"],
});
const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
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: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ 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 (
@@ -752,43 +771,29 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? (
threads.map((chat) => {
const projectType = myProjectConversationTypeOfThread(chat);
return (
<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"
>
{projectType ? (
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
) : (
<MessageSquareText aria-hidden="true" size={15} />
)}
<span className="official-recent-chat-copy">
<span className="official-recent-chat-title">
{titleOfThread(chat, "未命名对话")}
</span>
{projectType && (
<MyProjectThreadBadge type={projectType} />
)}
</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
);
})
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>
)}
@@ -814,17 +819,6 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId}
/>
)}
{page === "projects" && (
projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "agents" && (
<AgentsPage
openAgent={(agentId) =>
@@ -840,6 +834,20 @@ export default function OfficialDeerFlowEntry() {
/>
)}
{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" />
+3 -22
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
@@ -62,6 +62,7 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -77,8 +78,6 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
@@ -99,11 +98,6 @@ function StrategyChatThreadLayout() {
);
}
function StrategyProjectWorkbenchRoute() {
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
return <StrategySelectionPage key={projectId} projectId={projectId} />;
}
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -116,20 +110,6 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/projects"
element={
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
}
/>
<Route
path="qa/projects/:project_id"
element={<StrategyProjectWorkbenchRoute />}
/>
<Route
path="qa/strategy-selection"
element={<Navigate to="/page/strategy/qa/projects" replace />}
/>
<Route
path="qa/expert-qa"
element={
@@ -422,6 +402,7 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route
path="business-chain-mapping"
element={
+22 -12
View File
@@ -35,11 +35,11 @@ import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
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";
@@ -56,6 +56,7 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
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";
@@ -130,6 +131,7 @@ 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/")
@@ -190,9 +192,7 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/workspace/projects");
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const showCompactTopActions =
headerHidden &&
!compactActionsHidden &&
@@ -549,14 +549,6 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace">
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="projects/*"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
<AnalysisProjectsPage />
</WorkspaceLayout>
}
/>
<Route
path="chats"
element={
@@ -629,6 +621,16 @@ 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
@@ -693,6 +695,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing/*"
element={
@@ -1,242 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { MessageSquare, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAgent } from "@/core/agents/hooks";
import { StrategyBoard } from "./StrategyBoard";
import {
PromptInputProvider,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import {
ArtifactsProvider,
ArtifactTrigger,
} from "@/components/workspace/artifacts";
import { ChatBox } from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useThreadSettings } from "@/core/settings";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import {
PROJECT_MODES,
projectKeys,
type AnalysisProject,
} from "@/core/analysis-projects/api";
function Conversation({
project,
threadId,
agentId,
}: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
}) {
const queryClient = useQueryClient();
const [settings, setSettings] = useThreadSettings(threadId);
const { agent } = useAgent(agentId);
const [showMessages, setShowMessages] = useState(false);
const context = useMemo(
() => ({
model_name: settings.context.model_name,
mode: settings.context.mode,
reasoning_effort: settings.context.reasoning_effort,
writing_mode: project.mode === "solution",
agent_id: agentId ?? undefined,
}),
[
settings.context.model_name,
settings.context.mode,
settings.context.reasoning_effort,
project,
agentId,
],
);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId,
context,
assistantId: agentId || "lead_agent",
runOptions: { onDisconnect: "continue", multitaskStrategy: "reject" },
onFinish: () => {
void queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
},
});
const { displayThread } = useResearchWritingFeed(threadId, thread);
const submit = useCallback(
(message: PromptInputMessage) => {
if (!thread.isLoading && !isUploading)
void sendMessage(threadId, message);
},
[thread.isLoading, isUploading, sendMessage, threadId],
);
const clarify = useCallback(
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[threadId, thread, sendMessage],
);
const hasMessages = displayThread.messages.length > 0;
const strategyActions = [
["改变约束", "请列出当前策略最关键的可调整约束,并询问我希望修改哪一项。"],
["增加视角", "请建议一个当前尚未覆盖的分析视角;说明价值,并在加入该视角后重新推演。"],
["删除视角", "请指出当前贡献最低或重复的分析视角;说明影响,并在移除后重新推演。"],
["重新推演", "请基于当前对话中的全部条件,按完整 Lens 分析、策略辩论和综合流程重新推演。"],
["挑战当前策略", "请从最有力的反方立场挑战当前推荐策略,识别失败条件,并给出必要的调整。"],
] as const;
return (
<ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled: true,
}}
>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatBox threadId={threadId} writingMode={project.mode === "solution"}>
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
<span className="truncate text-sm text-muted-foreground">
{displayThread.values.title || "项目对话"}
<span className="ml-2 text-xs">
· {agent?.name || (agentId ? "所选智能体" : "通用助手")}
</span>
</span>
<div className="flex items-center gap-2">
{project.mode === "strategy" && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMessages((value) => !value)}
>
{showMessages ? "三层策略视图" : "对话与引用"}
</Button>
)}
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</div>
<div className="min-h-0 flex-1">
{project.mode === "strategy" && !showMessages ? (
<StrategyBoard
messages={displayThread.messages}
loading={thread.isLoading}
lenses={project.strategy_config?.lenses ?? []}
onClarificationSubmit={clarify}
/>
) : hasMessages ? (
<MessageList
threadId={threadId}
thread={displayThread}
className="h-full"
paddingBottom={200}
onClarificationSubmit={clarify}
onEditResubmit={(text) => submit({ text, files: [] })}
/>
) : (
<div className="flex h-full flex-col items-center justify-center px-6 pb-44 text-center">
<MessageSquare className="mb-4 size-8 text-primary" />
<h2 className="text-lg font-medium">
{PROJECT_MODES[project.mode].name}
</h2>
<p className="mt-3 max-w-md text-sm text-muted-foreground">
{PROJECT_MODES[project.mode].description}
。在下方输入问题或要求开始。
</p>
{project.mode === "solution" && (
<p className="mt-2 text-xs text-muted-foreground">
按项目所选报告结构生成,正文完成后自动打开方案画布。
</p>
)}
</div>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-background via-background to-transparent px-4 pt-4 pb-3">
<div className="mx-auto max-w-3xl">
{project.mode === "strategy" && (
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1" aria-label="策略快捷操作">
{strategyActions.map(([label, prompt]) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 rounded-full bg-background/90 px-2.5 text-xs"
disabled={thread.isLoading || isUploading}
onClick={() => submit({ text: prompt, files: [] })}
>
<Sparkles className="mr-1 size-3" />
{label}
</Button>
))}
</div>
)}
{!!thread.error && (
<p role="alert" className="mb-2 text-sm text-destructive">
{thread.error instanceof Error
? thread.error.message
: "本轮问答失败,请重试"}
</p>
)}
<InputBox
threadId={threadId}
context={context}
hideContextTools
hideAdminRecommendedQuestions
isNewThread={false}
disabled={isUploading}
status={thread.isLoading ? "streaming" : "ready"}
onSubmit={submit}
onStop={() => void thread.stop()}
onContextChange={(next) =>
setSettings("context", {
...settings.context,
model_name:
typeof next.model_name === "string"
? next.model_name
: undefined,
mode: next.mode,
reasoning_effort: next.reasoning_effort,
})
}
/>
<p className="mt-2 text-center text-xs text-muted-foreground">
项目内持续问答 ·{" "}
{agent?.name || (agentId ? "所选智能体" : "通用助手")}
</p>
</div>
</div>
</div>
</ChatBox>
</div>
</ThreadContext.Provider>
);
}
export function ProjectConversation(props: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
}) {
// Remount all conversation-scoped providers together when switching threads.
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>
<Conversation {...props} />
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -1,215 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useEffect, useState } from "react";
import {
ArrowRight,
BrainCircuit,
Compass,
GitBranch,
Loader2,
Medal,
MessagesSquare,
} from "lucide-react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { reasoningPlugins } from "@/core/streamdown";
import {
splitStrategySections,
splitStrategySubsections,
pendingStrategyClarification,
strategyRounds,
STRATEGY_COLUMNS,
} from "@/core/analysis-projects/strategy";
const descriptions = [
"确认 AI 理解的问题、目标、约束、变量与启用视角",
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
];
const icons = [Compass, GitBranch, Medal];
export function StrategyBoard({
messages,
loading,
lenses,
onClarificationSubmit,
}: {
messages: Message[];
loading: boolean;
lenses: string[];
onClarificationSubmit: (answer: string) => void | Promise<void>;
}) {
const rounds = strategyRounds(messages);
const clarification = pendingStrategyClarification(messages);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => setCustomAnswer(""), [clarification?.question]);
const answerClarification = async (answer: string) => {
if (!answer.trim() || submitting) return;
setSubmitting(true);
try {
await onClarificationSubmit(answer.trim());
} finally {
setSubmitting(false);
}
};
const displayed = rounds.length
? rounds
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
return (
<div
className="h-full overflow-y-auto px-4 pt-4 pb-56"
data-testid="strategy-board"
>
{displayed.map((round, index) => {
const parts = splitStrategySections(round.answer);
const active = loading && index === displayed.length - 1;
return (
<article key={round.id} className="mb-6">
<div className="mb-3 flex items-center gap-2">
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
{index + 1}
</span>
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
{round.question}
</h2>
{active && (
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
)}
</div>
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
{STRATEGY_COLUMNS.map((title, column) => {
const Icon = icons[column]!;
const subsections = splitStrategySubsections(parts[column]!);
return (
<section
key={title}
aria-label={title}
className="min-w-0 overflow-hidden rounded-xl border bg-card"
>
<header className="border-b bg-muted/30 px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-primary" />
{title}
</h3>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{descriptions[column]}
</p>
</header>
<div className="min-h-44 break-words p-4 text-sm">
{column === 0 && clarification && (
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
待确认条件 · 回答后继续多视角推演
</p>
{clarification.context && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{clarification.context}
</p>
)}
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
<div className="mt-3 grid gap-2">
{clarification.options.map((option) => (
<Button
key={option.id}
type="button"
variant="outline"
size="sm"
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
disabled={submitting || loading}
onClick={() => void answerClarification(option.label)}
>
{option.label}
</Button>
))}
</div>
{clarification.allow_custom && (
<div className="mt-2 flex gap-2">
<Input
aria-label="补充澄清条件"
className="h-8 bg-background text-xs"
value={customAnswer}
onChange={(event) => setCustomAnswer(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void answerClarification(customAnswer);
}}
placeholder="输入其他情况"
/>
<Button
type="button"
size="sm"
className="h-8"
disabled={!customAnswer.trim() || submitting || loading}
onClick={() => void answerClarification(customAnswer)}
>
继续
</Button>
</div>
)}
</div>
)}
{column === 1 && (
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
<BrainCircuit className="size-3.5 text-primary" />
<span>当前问题</span>
<ArrowRight className="size-3" />
<span>Lens Manager</span>
<ArrowRight className="size-3" />
{lenses.map((lens) => (
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
{lens}
</span>
))}
</div>
)}
{parts[column] ? (
column === 1 && subsections.length ? (
<div className="grid gap-3 xl:grid-cols-2">
{subsections.map((section) => {
const debate = /策略辩论/.test(section.title);
return (
<section
key={section.title}
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
>
<h4 className="mb-2 flex items-center gap-2 font-medium">
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
{section.title.replace(/^Lens[::]\s*/, "")}
</h4>
<MarkdownContent
content={section.content}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
/>
</section>
);
})}
</div>
) : (
<MarkdownContent
content={parts[column]!}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
/>
)
) : (
<p className="py-8 text-center text-xs text-muted-foreground">
{active
? "等待本层分析结果…"
: rounds.length
? "本轮尚未输出该层内容"
: "在下方输入问题后开始"}
</p>
)}
</div>
</section>
);
})}
</div>
</article>
);
})}
</div>
);
}
@@ -175,12 +175,10 @@ export function WorkflowConfigDialog({
appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */
onApply,
inlineCreate = false,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -402,11 +400,6 @@ export function WorkflowConfigDialog({
<Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false);
const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`);
@@ -1199,7 +1192,6 @@ export function WorkflowConfigButton({
variant = "outline",
size = "sm",
disabled = false,
inlineCreate = false,
}: {
appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1207,7 +1199,6 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
inlineCreate?: boolean;
}) {
const [open, setOpen] = useState(false);
const location = useLocation();
@@ -1231,7 +1222,6 @@ export function WorkflowConfigButton({
return (
<>
<Button
type="button"
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn(
className,
@@ -1247,7 +1237,6 @@ export function WorkflowConfigButton({
<span className="max-w-40 truncate">{buttonLabel}</span>
</Button>
<WorkflowConfigDialog
inlineCreate={inlineCreate}
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
@@ -4,10 +4,11 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("does not expose a per-chat memory toggle", () => {
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -27,10 +28,27 @@ test("does not expose a per-chat memory toggle", () => {
);
});
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false,
ragModeEnabled: true,
ragModeLocked: false,
@@ -45,6 +63,7 @@ test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -2,6 +2,7 @@ export type IframeModeButtonState = {
key:
| "agent"
| "writing"
| "memory"
| "references"
| "contextCompression"
| "pro";
@@ -13,6 +14,9 @@ export type IframeModeButtonState = {
type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
@@ -23,6 +27,8 @@ type BuildIframeModeButtonsInput = {
export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -35,6 +41,11 @@ export function buildIframeModeButtons({
label: "写作",
active: writingModeEnabled,
},
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{
key: "references",
label: "参考文献",
@@ -58,6 +69,8 @@ export function buildIframeModeButtons({
},
];
return buttons.filter(
(button) => referenceModeEntryEnabled || button.key !== "references",
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
);
}
@@ -64,8 +64,6 @@
.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-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
.official-recent-chat-title { flex: 1; min-width: 0; }
.official-recent-chat small { color: #acaca9; font-size: 11px; }
.official-recent-chat.is-active { background: #efefed; color: #252525; }
.official-sidebar-loading,
@@ -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;
}
@@ -114,12 +114,10 @@ export function PositionBusinessChainFlow({
)}
/>
<div className="min-w-0">
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
{chain?.title?.trim() || "业务链条"}
</h2>
{!embedded && !chain && (
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{loading ? "正在加载管理员配置…" : "等待管理员配置"}
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
)}
</div>
@@ -1,9 +1,12 @@
"use client";
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react";
import type { PagePhase } from "@/report-collaboration/state/selectors";
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
@@ -51,6 +54,16 @@ export function ReportCollaborationHeader({
<span className="rc-header-title" title={title}>
报告协作 / {title}
</span>
{PLAN_MODE_PHASES.has(phase) ? (
<span
className="rc-chip"
title="规划师正在梳理需求并规划工作流,确认后开始执行"
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
>
<ClipboardList className="size-3" aria-hidden />
计划模式
</span>
) : null}
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
{badge.label}
</span>
@@ -156,8 +156,23 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
const edges: Edge[] = useMemo(() => {
if (!plan) return [];
return plan.edges.map((edge, index) => {
const targetState = nodeStates[edge.target];
const active = targetState?.status === "running" || targetState?.status === "validating";
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画;
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。
const targetStatus = nodeStates[edge.target]?.status;
let className = "rc-edge";
let stroke = "var(--rc-flow-edge)";
if (targetStatus === "running" || targetStatus === "validating") {
className = "rc-edge-active";
stroke = "var(--rc-running)";
} else if (targetStatus === "failed") {
className = "rc-edge-failed";
stroke = "var(--rc-danger)";
} else if (targetStatus === "awaiting_input") {
className = "rc-edge-waiting";
stroke = "var(--rc-waiting)";
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
className = "rc-edge-dimmed";
}
return {
id: `${edge.source}->${edge.target}-${index}`,
source: edge.source,
@@ -165,11 +180,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
label: edge.label ?? undefined,
type: "default",
animated: false,
className: active ? "rc-edge-active" : "rc-edge",
style: {
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
strokeWidth: 2,
},
className,
style: { stroke, strokeWidth: 2 },
};
});
}, [plan, nodeStates]);
@@ -0,0 +1,52 @@
"use client";
import { ClipboardCheck } from "lucide-react";
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
interface RequirementSummaryCardProps {
requirement: ReportRequirementSnapshot;
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
editable?: boolean;
}
function Field({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null;
return (
<div className="flex gap-2 text-[12px] leading-5">
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
<span className="min-w-0 flex-1 break-words">{value}</span>
</div>
);
}
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
return (
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
<div className="mb-2 flex items-center gap-2">
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
计划模式
</span>
</div>
<div className="flex flex-col gap-1">
<Field label="主题" value={requirement.topic} />
<Field label="目的" value={requirement.objective} />
<Field label="读者" value={requirement.audience} />
<Field label="角度" value={angles || null} />
<Field label="时间范围" value={requirement.time_range} />
<Field label="地域" value={requirement.geography} />
<Field label="篇幅" value={requirement.length_target} />
<Field label="语气" value={requirement.tone} />
</div>
{editable ? (
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
</p>
) : null}
</div>
);
}
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import { FileText, Loader2, Sparkles } from "lucide-react";
import { FileText, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
@@ -26,6 +26,7 @@ import {
} from "@/report-collaboration/components/ReportPreviewDialog";
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
@@ -35,6 +36,7 @@ import {
buildCommandConfirmCard,
buildPlanGroupCard,
buildReportAvailableCard,
buildRequirementReadyCard,
readReportCollaborationCard,
} from "@/report-collaboration/state/message-adapter";
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
@@ -247,11 +249,23 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
const runStarted = Boolean(state.run && state.run.plan_id);
const extraCards = useMemo<Message[]>(() => {
const cards: Message[] = [];
if (
state.requirement &&
state.plans.length === 0 &&
!runStarted &&
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
)
) {
cards.push(buildRequirementReadyCard(state.requirement));
}
if (
state.plans.length > 0 &&
!runStarted &&
@@ -341,39 +355,16 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
[sessionId, session],
);
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
const requirementReady = useMemo(
() =>
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
),
[state.messagesById],
);
const showPlanRequestEntry =
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
const [planRequestBusy, setPlanRequestBusy] = useState(false);
const handleRequestPlans = useCallback(async () => {
setPlanRequestBusy(true);
try {
await getReportCollaborationDriver().requestPlans(
sessionId,
state.session?.requirement_revision ?? null,
);
await session.refreshSnapshot();
toast.success("已生成候选方案,可在对话中选择");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setPlanRequestBusy(false);
}
}, [sessionId, session, state.session?.requirement_revision]);
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划),
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。
const businessCardSlot = useCallback(
(message: Message) => {
const card = readReportCollaborationCard(message);
if (!card) return null;
if (card.kind === "requirement_ready") {
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
}
if (card.kind === "plan_group") {
return (
<ReportPlanCards
@@ -414,7 +405,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
}
return null;
},
[previewPlan, previewPlanId, session],
[previewPlan, previewPlanId, runStarted, session],
);
const speakerShell = useCallback(
@@ -565,43 +556,19 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
: undefined
}
composer={
<div className="flex flex-col">
{showPlanRequestEntry ? (
<div className="mb-1.5 flex justify-center">
<button
type="button"
className="rc-btn"
style={{
background: "var(--rc-accent)",
color: "#fff",
borderColor: "var(--rc-accent)",
}}
disabled={planRequestBusy}
onClick={() => void handleRequestPlans()}
>
{planRequestBusy ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Sparkles className="size-3.5" aria-hidden />
)}
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
</button>
</div>
) : null}
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
</div>
}
/>
}
@@ -21,6 +21,7 @@ import type {
CollaborationCommand,
CollaborationMessage,
ReportPlanCandidate,
ReportRequirementSnapshot,
ReportVersion,
} from "@/report-collaboration/api/types";
@@ -113,6 +114,11 @@ export type ReportCollaborationCardPayload =
selectedPlanId: string | null;
requirementRevision: number;
}
| {
kind: "requirement_ready";
requirement: ReportRequirementSnapshot;
revision: number;
}
| {
kind: "command_confirm";
command: CollaborationCommand;
@@ -163,6 +169,17 @@ export function buildPlanGroupCard(
});
}
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
export function buildRequirementReadyCard(
requirement: ReportRequirementSnapshot,
): Message {
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
kind: "requirement_ready",
requirement,
revision: requirement.revision,
});
}
/** 命令确认卡(command.confirmation.required 之后)。 */
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
@@ -850,6 +850,21 @@
animation: rc-edge-flow 0.5s linear infinite;
}
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
.rc-edge-failed .react-flow__edge-path {
stroke: var(--rc-danger);
stroke-dasharray: 4 4;
}
.rc-edge-waiting .react-flow__edge-path {
stroke: var(--rc-waiting);
}
.rc-edge-dimmed .react-flow__edge-path {
stroke: var(--rc-flow-edge);
opacity: 0.45;
}
@keyframes rc-edge-flow {
from {
stroke-dashoffset: 10;
@@ -39,11 +39,16 @@ export interface ChainFlowStage {
goal?: string | null;
}
/** 节点执行状态(任务分析回放传入;缺省展示 Ready)。 */
export type ChainFlowSeatState = "waiting" | "running" | "done" | "failed";
export interface ChainFlowDiagramProps {
stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void;
className?: string;
/** 传入时节点卡片叠加执行状态徽标(任务分析的运行回放);不影响原有展示。 */
seatStates?: Record<string, { state: ChainFlowSeatState; durationMs?: number }> | null;
}
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
@@ -84,7 +89,7 @@ interface PlacedNode {
rightX: number;
}
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => {
// 列:总控 + 各 Stage。
@@ -315,15 +320,43 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
</svg>
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。
seatStates 传入时(任务分析回放)叠加执行状态徽标与状态描边。 */}
{layout.cols.flatMap((col, colIdx) =>
col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID;
const nc = nodeColorOf(n.avatarType);
const seatState = !isCoordinator && seatStates ? seatStates[n.id] : undefined;
const state = seatState?.state;
const stateStyle = state
? ({
waiting: "text-muted-foreground",
running: "text-amber-600 dark:text-amber-300",
done: "text-emerald-600 dark:text-emerald-300",
failed: "text-red-600 dark:text-red-400",
} as const)[state]
: null;
const stateLabel =
state === "waiting"
? "等待"
: state === "running"
? "执行中"
: state === "done"
? "完成"
: state === "failed"
? "失败"
: null;
const stateSeconds = seatState?.durationMs
? Math.max(1, Math.round(seatState.durationMs / 1000))
: null;
return (
<div
key={n.id}
className={cn("absolute", !isCoordinator && "cursor-pointer")}
className={cn(
"absolute",
!isCoordinator && "cursor-pointer",
state === "waiting" && "opacity-60",
)}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
@@ -339,9 +372,15 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
isCoordinator
? "border-border bg-primary/[0.04]"
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
state === "running" && "border-amber-400/80",
state === "failed" && "border-red-500/70",
)}
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
style={{
borderLeftColor: nc.hex,
borderLeftWidth: 4,
...(state === "running" ? { boxShadow: "0 0 0 3px rgba(245,158,11,0.15)" } : {}),
}}
>
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
<div className="flex items-start gap-2 overflow-hidden">
@@ -359,13 +398,34 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
</div>
</div>
{/* 下:身份 + Ready 心跳条 */}
{/* 下:身份 + 状态行(回放模式显示执行状态,否则 Ready 心跳条) */}
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
{state && stateLabel ? (
<span
className={cn(
"ml-1.5 flex shrink-0 items-center gap-1 rounded px-1 py-0.5 font-medium",
stateStyle,
)}
>
<span
className={cn(
"size-1.5 rounded-full",
state === "done" && "bg-emerald-500",
state === "running" && "animate-pulse bg-amber-500",
state === "failed" && "bg-red-500",
state === "waiting" && "bg-muted-foreground/60",
)}
/>
{stateLabel}
{stateSeconds != null && state !== "waiting" ? ` · ${stateSeconds}s` : ""}
</span>
) : (
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
)}
</div>
</div>
</div>
@@ -445,22 +505,33 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
style={{ width: mmW, height: mmH }}
onClick={onMinimapClick}
>
{/* 节点缩略块 */}
{layout.allNodes.map((n) => (
<div
key={n.id}
className={cn(
"absolute rounded-[1px]",
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
))}
{/* 节点缩略块(回放模式按状态着色) */}
{layout.allNodes.map((n) => {
const state = n.id === COORDINATOR_ID ? null : seatStates?.[n.id]?.state ?? null;
return (
<div
key={n.id}
className={cn(
"absolute rounded-[1px]",
n.id === COORDINATOR_ID
? "bg-primary/70"
: state === "done"
? "bg-emerald-500/70"
: state === "running"
? "bg-amber-500/80"
: state === "failed"
? "bg-red-500/80"
: "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
);
})}
{/* 当前视口框 */}
<div
className="absolute border-2 border-primary bg-primary/10"
@@ -34,8 +34,6 @@ export interface PersonaDrawerProps {
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示该行。 */
seatArtifactPositions?: Record<string, number>;
onClose: () => void;
onSave: (id: number) => void;
}
@@ -68,16 +66,11 @@ export function PersonaDrawer({
selectedModel,
availableModels,
modelsLoading,
seatArtifactPositions,
onClose,
onSave,
}: PersonaDrawerProps) {
const cfg = agentConfigs[seatId];
const roleEntry = roles.find((r) => r.id === seatId);
const roleName = roleEntry?.name ?? "专家智能体";
// 该席位在业务链上配置的「产物提交 position」——提交过程产物时传给外部任务系统。
const lookupKey = roleEntry?.agentId?.trim().toLowerCase() || roleName.trim().toLowerCase();
const artifactPosition = lookupKey ? seatArtifactPositions?.[lookupKey] : undefined;
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
return (
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
@@ -160,22 +153,6 @@ export function PersonaDrawer({
</select>
</div>
{seatArtifactPositions && (
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">
产物提交 position
</label>
<div
className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs font-bold font-mono"
title="该席位提交过程产物时传给外部任务系统的 position 值(业务链条里逐席位配置)"
>
{typeof artifactPosition === "number"
? artifactPosition
: <span className="text-slate-400 font-sans">未配置(提交按 0)</span>}
</div>
</div>
)}
<div className="space-y-1">
<div className="flex items-center justify-between text-[9px] font-black text-slate-500">
<span>随机创造性倾向 (Temp)</span>
@@ -115,8 +115,6 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示。 */
seatArtifactPositions?: Record<string, number>;
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
@@ -189,7 +187,6 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
seatArtifactPositions,
seatFilter,
setSeatFilter,
parallelBatch,
@@ -583,7 +580,6 @@ export function Step2Panel(props: Step2PanelProps) {
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
seatArtifactPositions={seatArtifactPositions}
onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona}
/>
@@ -55,14 +55,12 @@ function payloadFrom(
): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId;
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
const position = config.positionFor?.(target) ?? config.position;
return {
taskId: config.taskId,
agentId,
agentName: target.agentName?.trim() || agentId,
content,
position,
position: config.position,
reportName: name,
submitStatus,
};
@@ -148,11 +146,8 @@ export function useTaskArtifactSubmission(
let record = entriesRef.current[key]?.record;
let reportId = record?.taskDeliveryId || undefined;
const resumedRegistration = Boolean(reportId);
if (!reportId) {
// 外部任务系统(8BF)的实际契约:add 接口以 submitStatus=1 直接完成提交,
// 返回 {code:200, data:id}。登记即提交,不再走「先落 0 再改 1」两段式。
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 1));
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
reportId = receipt.id;
const artifact: RoundtableArtifactReference = {
threadId: target.threadId,
@@ -168,11 +163,9 @@ export function useTaskArtifactSubmission(
taskDeliveryId: reportId,
artifact,
});
} else {
// 仅历史「已登记未提交」的记录(旧流程登记时 submitStatus=0)需要补一次
// update 把外部记录翻成已提交;这条路径失败仍视为提交失败。
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
}
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: true,
taskDeliveryId: reportId,
@@ -55,13 +55,6 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/
position_id?: string | null;
/**
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
* 注意与 `position_id`(岗位协同的岗位归属)无关。
*/
artifact_position?: number | null;
/**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
@@ -13,14 +13,7 @@ export interface TaskProcessReportPayload {
export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */
taskId: string;
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
position: number;
/**
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
* null / undefined = 该席位未配置,回退 `position` 默认值。
*/
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
}
@@ -697,8 +697,6 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null,
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
artifact_position: s.artifact_position ?? null,
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null,
};
@@ -361,11 +361,6 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig,
/**
* 席位 → 业务链配置的「产物提交 position」映射(键为小写 agent_id / 席位名)。
* 仅用于「智能体配置」抽屉里的只读展示,帮助核对提交产物时会带的 position 值。
*/
seatArtifactPositions,
/**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
@@ -422,8 +417,6 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string;
hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值),只读展示用。 */
seatArtifactPositions?: Record<string, number>;
step3Mode?: "full" | "report-only";
}) {
const { theme } = useTheme();
@@ -2621,7 +2614,6 @@ export default function RoundtablePlanningPage({
orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole}
seatArtifactPositions={seatArtifactPositions}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
@@ -41,6 +41,10 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
type HeaderProps = {
collapsed?: boolean;
};
@@ -118,11 +122,8 @@ const Header = ({ collapsed }: HeaderProps) => {
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
// 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为
// 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。
const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2);
const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex);
const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex);
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
const handleNavItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
@@ -73,21 +73,6 @@ export interface LlmWikiKnowledgeBase {
is_conversation_deposit?: boolean;
}
export interface LlmWikiEmbeddingModel {
id: string;
name: string;
provider?: string | null;
model_name?: string | null;
status?: string | null;
is_default: boolean;
available: boolean;
}
export interface LlmWikiEmbeddingModelsResponse {
models: LlmWikiEmbeddingModel[];
default_model_id?: string | null;
}
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
wiki: {
status: "ready" | "empty" | "unavailable";
@@ -283,9 +268,6 @@ export interface LlmWikiWikiIndexStatus {
export interface LocalWikiEncoderStatus {
provider: string;
configured_provider?: string | null;
mode?: "configured" | "local";
using_local?: boolean;
model: string;
dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed";
@@ -442,17 +424,6 @@ export async function getLlmWikiKnowledgeBase(
);
}
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
await apiFetch(`${API()}/embedding-models`),
"读取知识服务向量模型",
);
return {
models: payload.models ?? [],
default_model_id: payload.default_model_id ?? null,
};
}
export async function createLlmWikiWeKnoraEmbedSession(
id: string,
tab: "documents" | "wiki" | "graph",
@@ -486,7 +457,6 @@ export async function createLlmWikiKnowledgeBase(input: {
description: string;
type: "document" | "faq";
wiki_enabled?: boolean;
embedding_model_id?: string;
}): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases`, {
@@ -684,13 +654,6 @@ export async function configureLocalWikiEncoder(
);
}
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
"恢复配置文件编码器",
);
}
export async function uploadLocalWikiEncoder(
files: File[],
onProgress?: (percent: number) => void,
@@ -725,15 +688,10 @@ export async function uploadLocalWikiEncoder(
payload = null;
}
if (request.status < 200 || request.status >= 300) {
const rawDetail = payload && typeof payload === "object" && "detail" in payload
const detail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`;
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: typeof rawDetail === "string"
? rawDetail
: JSON.stringify(rawDetail);
reject(new Error(userFacingKnowledgeMessage(detail)));
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
return;
}
onProgress?.(100);
@@ -1310,17 +1268,6 @@ export function useLlmWikiSettings(enabled = true) {
});
}
export function useLlmWikiEmbeddingModels(enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
queryFn: listLlmWikiEmbeddingModels,
enabled,
staleTime: 60_000,
refetchOnMount: "always",
});
}
export function useLlmWikiActions() {
const queryClient = useQueryClient();
const invalidateKnowledge = () =>
@@ -34,13 +34,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
@@ -55,9 +48,7 @@ import {
searchLlmWiki,
startLocalWikiEncoder,
uploadLocalWikiEncoder,
restoreConfiguredWikiEncoder,
useLlmWikiActions,
useLlmWikiEmbeddingModels,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
useLlmWikiSettingsActions,
@@ -77,14 +68,6 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function encoderProviderLabel(provider?: string | null): string {
const normalized = provider?.trim().toLowerCase();
if (normalized === "local") return "本地 BGE-M3";
if (normalized === "openai") return "OpenAI";
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
return provider?.trim() || "未配置";
}
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索";
@@ -474,50 +457,18 @@ function CreateKnowledgeDialog({
open: boolean;
pending: boolean;
onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true);
const [embeddingModelId, setEmbeddingModelId] = useState("");
const embeddingModels = useLlmWikiEmbeddingModels(open);
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
useEffect(() => {
if (!open) {
setName("");
setDescription("");
setWikiEnabled(true);
setEmbeddingModelId("");
}
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
}, [open]);
useEffect(() => {
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const provider = model.provider?.trim();
const implementation = model.model_name?.trim();
const suffix = [provider, implementation].filter(Boolean).join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
@@ -525,33 +476,8 @@ function CreateKnowledgeDialog({
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
</div></div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
)}
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
</DialogContent>
</Dialog>
);
@@ -575,12 +501,11 @@ export function LocalEncoderSettingsCard() {
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status);
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
setModelPath(status.model_path || "");
};
const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onError: (error) => toast.error(messageOf(error)),
});
const upload = useMutation({
@@ -589,7 +514,7 @@ export function LocalEncoderSettingsCard() {
onSuccess: (status) => {
applyStatus(status);
setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
},
onError: (error) => toast.error(messageOf(error)),
});
@@ -601,17 +526,7 @@ export function LocalEncoderSettingsCard() {
},
onError: (error) => toast.error(messageOf(error)),
});
const restoreConfigured = useMutation({
mutationFn: restoreConfiguredWikiEncoder,
onSuccess: (nextStatus) => {
applyStatus(nextStatus);
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data;
const usingLocal = status?.using_local ?? status?.provider === "local";
const localOverrideActive = status?.mode === "local";
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB`
@@ -620,49 +535,29 @@ export function LocalEncoderSettingsCard() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
{encoder.isError
? "状态读取失败"
: encoder.isLoading
? "读取中"
: !usingLocal
? "配置编码器生效中"
: status?.state === "ready"
? "本地编码器已就绪"
: status?.state === "starting"
? "本地编码器启动中"
: status?.state === "failed"
? "本地编码器启动失败"
: "本地编码器未启动"}
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
</Badge>
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>}
<span className="text-muted-foreground">
{status
? usingLocal
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
: "正在读取状态"}
</span>
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label>
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button>
</div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
@@ -681,7 +576,7 @@ export function LocalEncoderSettingsCard() {
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
</p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
@@ -691,16 +586,10 @@ export function LocalEncoderSettingsCard() {
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button>
{localOverrideActive && (
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
恢复 config.yaml 编码器
</Button>
)}
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button>
@@ -601,19 +601,13 @@ export function WeKnoraWikiManagementPage() {
}
};
const vectorStatusText = indexStatus.isLoading
? "正在读取 Wiki 镜像向量状态"
: indexStatus.isError
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
: !indexStatus.data?.local_index_enabled
? "Wiki 镜像向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: indexStatus.data.last_error
? `整库向量化失败:${indexStatus.data.last_error}`
: "整库尚未完成向量化,检索使用 Wiki 接口";
const vectorStatusText = !indexStatus.data?.local_index_enabled
? "本地向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: "整库尚未完成向量化,检索使用 Wiki 接口";
return (
<div className="flex h-full min-h-0 flex-col bg-background">
@@ -632,7 +626,7 @@ export function WeKnoraWikiManagementPage() {
variant="outline"
size="sm"
onClick={() => void vectorize()}
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
>
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -658,17 +652,6 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3">
{indexStatus.isLoading && (
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
</div>
)}
{indexStatus.isError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
</div>
)}
{indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs">
@@ -1,736 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Bot,
Check,
CirclePlus,
Layers3,
LoaderCircle,
LockKeyhole,
Power,
RotateCcw,
Save,
Sparkles,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { cn } from "@/lib/utils";
import {
createStrategyAngle,
disableStrategyAngle,
STRATEGY_AGENTS_QUERY_KEY,
updateStrategyAgent,
useStrategyAgents,
type StrategyAgent,
type StrategyAgentCategory,
} from "./strategy-agents";
interface AgentConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface AgentConfigDraft {
name: string;
description: string;
soul: string;
model: string | null;
skills: string[];
enabled: boolean;
sortOrder: number;
}
const NEW_ANGLE_ID = "__new_strategy_angle__";
const CATEGORY_COPY: Record<
StrategyAgentCategory,
{ title: string; description: string }
> = {
intent: {
title: "意图理解",
description: "把用户对话整理为结构化任务,固定流程入口",
},
angle: {
title: "分析角度",
description: "并行生成独立策略,可按业务需要扩展",
},
judge: {
title: "智能评判",
description: "统一比较各角度策略并给出推荐结论",
},
solution: {
title: "方案整理",
description: "将用户选中的策略消冲突并汇总成方案",
},
};
function normalizeSkills(skills: string[]) {
return [...new Set(skills)].sort((left, right) =>
left.localeCompare(right),
);
}
function sameSkills(left: string[], right: string[]) {
const normalizedLeft = normalizeSkills(left);
const normalizedRight = normalizeSkills(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((skill, index) => skill === normalizedRight[index])
);
}
function draftFromAgent(agent: StrategyAgent): AgentConfigDraft {
return {
name: agent.name,
description: agent.description,
soul: agent.soul ?? "",
model: agent.model,
skills: agent.skills ?? [],
enabled: agent.enabled,
sortOrder: agent.sort_order,
};
}
function emptyAngleDraft(sortOrder: number): AgentConfigDraft {
return {
name: "",
description: "",
soul: "",
model: null,
skills: [],
enabled: true,
sortOrder,
};
}
function sameDraft(left: AgentConfigDraft, right: AgentConfigDraft) {
return (
left.name === right.name &&
left.description === right.description &&
left.soul === right.soul &&
left.model === right.model &&
left.enabled === right.enabled &&
left.sortOrder === right.sortOrder &&
sameSkills(left.skills, right.skills)
);
}
export function AgentConfigDialog({
open,
onOpenChange,
}: AgentConfigDialogProps) {
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<AgentConfigDraft>(() =>
emptyAngleDraft(100),
);
const [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const queryClient = useQueryClient();
const {
data: agents = [],
isLoading,
error,
refetch,
} = useStrategyAgents({
enabled: open,
includeDisabled: true,
includeConfig: true,
});
const {
skills,
isLoading: isSkillsLoading,
error: skillsError,
} = useAllSkillsAdmin(open);
const fixedAgents = useMemo(
() => agents.filter((agent) => agent.fixed),
[agents],
);
const angleAgents = useMemo(
() => agents.filter((agent) => agent.category === "angle"),
[agents],
);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedId) ?? null,
[agents, selectedId],
);
const creating = selectedId === NEW_ANGLE_ID;
const dirty = Boolean(savedDraft && !sameDraft(draft, savedDraft));
const selectableSkills = useMemo(
() =>
skills.filter((skill) => {
const globallyAvailable =
Boolean(skill.builtin) || Boolean(skill.published);
return (
skill.enabled &&
(skill.state ?? "active") === "active" &&
globallyAvailable
);
}),
[skills],
);
useEffect(() => {
if (!open || isLoading || creating || agents.length === 0) return;
if (selectedAgent) return;
setSelectedId(agents[0].id);
}, [agents, creating, isLoading, open, selectedAgent]);
useEffect(() => {
if (!open || creating || !selectedAgent) return;
const next = draftFromAgent(selectedAgent);
setDraft(next);
setSavedDraft(next);
}, [creating, open, selectedAgent?.id, selectedAgent?.updated_at]);
const confirmDiscardChanges = () =>
!dirty || window.confirm("当前配置尚未保存,确定放弃这些修改吗?");
const selectAgent = (agentId: string) => {
if (agentId === selectedId || busy || !confirmDiscardChanges()) return;
setSelectedId(agentId);
setSavedDraft(null);
};
const startCreate = () => {
if (busy || !confirmDiscardChanges()) return;
const maxOrder = angleAgents.reduce(
(current, agent) => Math.max(current, agent.sort_order),
99,
);
const next = emptyAngleDraft(maxOrder + 1);
setSelectedId(NEW_ANGLE_ID);
setDraft(next);
setSavedDraft(next);
};
const save = async () => {
const name = draft.name.trim();
if (!name) {
toast.error("请填写智能体名称");
return;
}
setBusy(true);
try {
if (creating) {
const created = await createStrategyAngle({
name,
description: draft.description.trim(),
soul: draft.soul,
model: draft.model,
skills: draft.skills,
sort_order: draft.sortOrder,
});
setSelectedId(created.id);
const next = draftFromAgent(created);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${created.name} 已加入分析角度库`);
} else if (selectedAgent) {
const updated = await updateStrategyAgent(selectedAgent.id, {
name,
description: draft.description.trim(),
soul: draft.soul,
model: draft.model,
skills: draft.skills,
enabled: selectedAgent.fixed ? undefined : draft.enabled,
sort_order: selectedAgent.fixed ? undefined : draft.sortOrder,
});
const next = draftFromAgent(updated);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${updated.name} 配置已保存`);
}
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
} finally {
setBusy(false);
}
};
const softDelete = async () => {
if (!selectedAgent || selectedAgent.fixed || busy) return;
const confirmed = window.confirm(
`确定停用“${selectedAgent.name}”吗?\n\n它不会再出现在新项目的角度选择中,但历史项目、SOUL 和技能配置都会保留,可随时恢复。`,
);
if (!confirmed) return;
setBusy(true);
try {
const updated = await disableStrategyAngle(selectedAgent.id);
const next = draftFromAgent(updated);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${updated.name} 已停用,历史项目不受影响`);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "停用失败");
} finally {
setBusy(false);
}
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && (busy || !confirmDiscardChanges())) return;
onOpenChange(nextOpen);
};
const category = creating ? "angle" : selectedAgent?.category;
const categoryCopy = category ? CATEGORY_COPY[category] : null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="grid h-[min(860px,calc(100vh-40px))] max-w-[1180px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1180px]">
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
<DialogTitle className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
统一策略智能体
</DialogTitle>
<DialogDescription>
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
<div className="mb-3 flex items-center justify-between px-1">
<div>
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
固定流程角色
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
仅可修改配置,不可新增、停用或删除
</p>
</div>
<Badge variant="secondary">{fixedAgents.length}</Badge>
</div>
<div className="space-y-1.5">
{fixedAgents.map((agent) => {
const selected = agent.id === selectedId;
const copy = CATEGORY_COPY[agent.category];
return (
<button
key={agent.id}
type="button"
disabled={busy}
onClick={() => selectAgent(agent.id)}
className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/45 bg-primary/10 shadow-sm"
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<LockKeyhole className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{copy.title}
</span>
</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</span>
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
{agent.name}
</span>
</button>
);
})}
</div>
<div className="mb-3 mt-6 flex items-center justify-between px-1">
<div>
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
分析角度库
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
新项目只使用当前启用的角度
</p>
</div>
<Button
type="button"
size="sm"
className="h-8 gap-1.5"
disabled={busy}
onClick={startCreate}
>
<CirclePlus className="size-3.5" />
新增
</Button>
</div>
<div className="space-y-1.5">
{angleAgents.map((agent) => {
const selected = agent.id === selectedId;
return (
<button
key={agent.id}
type="button"
disabled={busy}
onClick={() => selectAgent(agent.id)}
className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/45 bg-primary/10 shadow-sm"
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
!agent.enabled && "opacity-65",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<Layers3 className="size-3.5 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">
{agent.name}
</span>
</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</span>
<span className="mt-2 flex items-center justify-between gap-2 pl-5.5">
<span className="truncate text-[11px] text-muted-foreground">
顺序 {agent.sort_order}
</span>
<Badge
variant={agent.enabled ? "secondary" : "outline"}
className="h-5 text-[10px]"
>
{agent.enabled ? "已启用" : "已停用"}
</Badge>
</span>
</button>
);
})}
{!isLoading && angleAgents.length === 0 ? (
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
暂无分析角度,点击“新增”建立第一个角度。
</div>
) : null}
</div>
</aside>
<main className="min-h-0 overflow-y-auto px-6 py-5">
{isLoading ? (
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
正在读取全局配置…
</div>
) : error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
<p className="font-medium">策略智能体配置加载失败</p>
<p className="mt-1 leading-6 opacity-85">
{error instanceof Error ? error.message : "请稍后重试"}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => void refetch()}
>
<RotateCcw className="size-3.5" />
重新加载
</Button>
</div>
) : categoryCopy ? (
<div className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">
{creating ? "新增分析角度" : categoryCopy.title}
</h3>
{selectedAgent?.fixed ? (
<Badge variant="outline" className="gap-1">
<LockKeyhole className="size-3" /> 固定角色
</Badge>
) : null}
{!creating && selectedAgent && !selectedAgent.fixed ? (
<Badge
variant={draft.enabled ? "secondary" : "outline"}
>
{draft.enabled ? "已启用" : "已停用"}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{creating
? "创建后会成为所有策略项目可选的全局分析角度。"
: categoryCopy.description}
</p>
</div>
{!creating && selectedAgent ? (
<Badge
variant="outline"
className="max-w-[300px] truncate font-mono text-[10px]"
title={selectedAgent.id}
>
{selectedAgent.id}
</Badge>
) : null}
</div>
<section className="rounded-xl border bg-muted/15 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
智能体名称
</span>
<Input
value={draft.name}
disabled={busy}
maxLength={100}
placeholder="例如:科技与产业链视角"
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
运行模型
</span>
<ModelNameSelect
value={draft.model}
onChange={(model) =>
setDraft((current) => ({ ...current, model }))
}
disabled={busy}
defaultLabel="使用系统默认模型"
title="选择该全局智能体运行时使用的大模型"
triggerClassName="h-9 w-full bg-background"
/>
</label>
<label className="block sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">
职责简介
</span>
<Textarea
value={draft.description}
disabled={busy}
maxLength={2000}
className="min-h-20 resize-y"
placeholder="说明该角色关注的问题、交付内容和边界…"
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
</div>
{!creating && selectedAgent && !selectedAgent.fixed ? (
<div className="mt-4 grid gap-3 border-t pt-4 sm:grid-cols-[1fr_180px] dark:border-[#173d68]">
<div className="flex items-center justify-between rounded-lg border bg-background px-3 py-2.5 dark:border-[#28507c] dark:bg-[#08274c]">
<div>
<p className="text-sm font-medium">用于新项目</p>
<p className="mt-0.5 text-xs text-muted-foreground">
停用后历史项目仍可回显,新的角度选择将隐藏它
</p>
</div>
<Switch
checked={draft.enabled}
disabled={busy}
aria-label="启用该分析角度"
onCheckedChange={(enabled) =>
setDraft((current) => ({ ...current, enabled }))
}
/>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
显示顺序
</span>
<Input
type="number"
min={-10000}
max={10000}
value={draft.sortOrder}
disabled={busy}
onChange={(event) =>
setDraft((current) => ({
...current,
sortOrder: Number(event.target.value) || 0,
}))
}
/>
</label>
</div>
) : creating ? (
<label className="mt-4 block max-w-[180px] border-t pt-4 dark:border-[#173d68]">
<span className="mb-1.5 block text-xs font-medium">
显示顺序
</span>
<Input
type="number"
min={-10000}
max={10000}
value={draft.sortOrder}
disabled={busy}
onChange={(event) =>
setDraft((current) => ({
...current,
sortOrder: Number(event.target.value) || 0,
}))
}
/>
</label>
) : null}
</section>
<label className="block">
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-primary" /> SOUL
</span>
<Textarea
value={draft.soul}
disabled={busy}
maxLength={200000}
onChange={(event) =>
setDraft((current) => ({
...current,
soul: event.target.value,
}))
}
className="min-h-[210px] resize-y font-mono text-xs leading-6"
placeholder="定义角色身份、分析方法、行为边界和输出格式…"
/>
</label>
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">系统技能选择</span>
<Badge variant="secondary">
已选 {draft.skills.length} 个
</Badge>
</div>
<p className="text-xs leading-5 text-muted-foreground">
只展示当前启用且全局可见的系统技能;通过穿梭框选择,不接受手工输入技能名。
</p>
<SkillTransferSelector
skills={selectableSkills}
selected={draft.skills}
onChange={(selectedSkills) =>
setDraft((current) => ({
...current,
skills: selectedSkills,
}))
}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
{skillsError ? (
<p className="text-xs text-destructive">
技能库加载失败,请稍后重试。
</p>
) : null}
</section>
</div>
) : (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
暂无可配置角色
</div>
)}
</main>
</div>
<DialogFooter className="flex-row items-center justify-between border-t px-6 py-4 dark:border-[#173d68] sm:justify-between">
<div>
{!creating && selectedAgent && !selectedAgent.fixed ? (
selectedAgent.enabled ? (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={busy}
onClick={() => void softDelete()}
>
<Trash2 className="size-4" />
停用并从新项目移除
</Button>
) : (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Power className="size-3.5" />
打开“用于新项目”并保存即可恢复
</span>
)
) : (
<span className="text-xs text-muted-foreground">
{creating
? "新角度创建后立即全局可用"
: "固定角色始终参与对应流程阶段"}
</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={busy}
onClick={() => handleOpenChange(false)}
>
关闭
</Button>
<Button
disabled={
!categoryCopy ||
!draft.name.trim() ||
busy ||
isLoading ||
isSkillsLoading ||
(!creating && !dirty)
}
onClick={() => void save()}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : creating ? (
<CirclePlus className="size-4" />
) : (
<Save className="size-4" />
)}
{creating ? "创建分析角度" : "保存当前配置"}
</Button>
</div>
</DialogFooter>
</DialogContent>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDetailSkillName(null);
}}
/>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,44 +0,0 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { parseSseChunk } from "./lib";
import type { ParallelAgentEvent, ParallelAgentPayload } from "./types";
async function readError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
export async function streamParallelAgents(
payload: ParallelAgentPayload,
onEvent: (event: ParallelAgentEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/parallel-agents/run/stream`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal,
},
);
if (!response.ok) throw new Error(await readError(response));
if (!response.body) throw new Error("服务器未返回流式响应");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const parsed = parseSseChunk(buffer, done);
buffer = parsed.rest;
parsed.events.forEach(onEvent);
if (done) break;
}
}
@@ -1,350 +0,0 @@
import type {
AgentOutputState,
IntentTurn,
StrategyAngle,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
export const STRATEGY_AGENT_ROLES = [
{
id: "strategy-selection-intent",
name: "意图理解智能体",
role: "将自然语言需求整理为可执行任务",
},
{
id: "strategy-selection-angle-governance",
name: "治理统筹智能体",
role: "关注目标、机制、跨部门协同与执行闭环",
},
{
id: "strategy-selection-angle-diplomacy",
name: "外交沟通智能体",
role: "关注沟通渠道、危机管控与多边协调",
},
{
id: "strategy-selection-angle-economy",
name: "经济韧性智能体",
role: "关注供应链、产业、贸易与市场预期",
},
{
id: "strategy-selection-angle-security",
name: "安全风险智能体",
role: "关注预警、误判防范与非升级性风险处置",
},
{
id: "strategy-selection-angle-public-opinion",
name: "社会民生智能体",
role: "关注公众沟通、民生连续性与人道保障",
},
{
id: "strategy-selection-angle-law-policy",
name: "法律规则智能体",
role: "关注适用规则、合规边界与证据链",
},
{
id: "strategy-selection-judge",
name: "策略评判智能体",
role: "以统一量表比较策略的收益、风险与可执行性",
},
{
id: "strategy-selection-solution",
name: "方案整理智能体",
role: "将入选策略去重、消冲突并整理为最终方案",
},
] as const;
export const STRATEGY_ANGLES: StrategyAngle[] = [
{
id: "governance",
agentId: "strategy-selection-angle-governance",
name: "治理与统筹",
shortName: "治理",
description: "目标拆解、组织协同、阶段推进和反馈闭环",
accent: "#6d5dfc",
},
{
id: "diplomacy",
agentId: "strategy-selection-angle-diplomacy",
name: "外交与沟通",
shortName: "外交",
description: "危机沟通、多边协调、信号一致性与降温安排",
accent: "#0ea5e9",
},
{
id: "economy",
agentId: "strategy-selection-angle-economy",
name: "经济与产业韧性",
shortName: "经济",
description: "供应链连续性、市场预期、贸易和关键产业保障",
accent: "#10b981",
},
{
id: "security",
agentId: "strategy-selection-angle-security",
name: "安全与风险管控",
shortName: "安全",
description: "态势预警、误判防范、事件分级和非升级性处置",
accent: "#f97316",
},
{
id: "public_opinion",
agentId: "strategy-selection-angle-public-opinion",
name: "社会民生与舆情",
shortName: "民生",
description: "公众沟通、基本服务、社会心理和人道保障",
accent: "#ec4899",
},
{
id: "law_policy",
agentId: "strategy-selection-angle-law-policy",
name: "法律与规则",
shortName: "法律",
description: "适用规则、合规审查、责任边界与证据留存",
accent: "#a855f7",
},
];
export const EMPTY_TASK_DRAFT: StrategyTaskDraft = {
name: "",
detail: "",
objective: "",
prerequisites: "",
constraints: "",
deliverables: "",
acceptanceCriteria: "",
horizon: "",
selectedAngleIds: STRATEGY_ANGLES.slice(0, 4).map((angle) => angle.id),
};
export const INITIAL_INTENT_TURNS: IntentTurn[] = [
{
id: "welcome",
role: "assistant",
content:
"请描述你想解决的问题、希望达成的目标以及已知约束。我会通过对话补齐关键信息,并把需求实时整理到右侧任务草稿。",
},
];
export const TAIWAN_STRAIT_DEMO_TASK: StrategyTaskDraft = {
name: "台海局势风险管控与区域韧性提升策略",
detail:
"面向台海局势高不确定性场景,形成一套不预设立场、以避免误判和升级为核心的综合政策方案。方案聚焦危机沟通、区域经济与民生连续性、规则遵循及信息透明,不包含战术行动、目标选择或武力运用指导。",
objective:
"未来12—24个月内,降低突发事件转化为系统性危机的概率,保护平民福祉与关键经济活动,维持可验证、可持续的沟通和协调机制。",
prerequisites:
"建立跨部门联合研判机制;明确授权与对外沟通口径;掌握航运、能源、金融、通信和公共服务的基础风险数据;预留与各相关方沟通的技术渠道。",
constraints:
"遵守适用的国际法及人道原则;坚持克制、对等、可逆和非升级;不得输出军事战术、具体目标、武器使用或规避监管建议;关键事实须标注时点并交叉核验。",
deliverables:
"一份综合策略;分阶段行动清单;跨部门责任矩阵;风险预警指标;对外沟通要点;每月复盘和动态调整机制。",
acceptanceCriteria:
"措施均有责任主体、触发条件和退出条件;重要建议至少经两个角度交叉验证;高风险措施设置法律审查与升级门槛;方案可在72小时内启动首轮响应。",
horizon: "12—24个月,按0—30天、1—6个月、6—24个月分三阶段推进",
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
};
export const TAIWAN_STRAIT_DEMO_TURNS: IntentTurn[] = [
...INITIAL_INTENT_TURNS,
{
id: "demo-user",
role: "user",
isDemo: true,
content:
"请围绕台海局势做一个综合风险管控策略,重点是减少误判升级,同时兼顾经济、民生和规则约束,时间范围看未来一到两年。",
},
{
id: "demo-assistant",
role: "assistant",
isDemo: true,
content:
"已将需求整理为“风险管控与区域韧性提升”任务:以降级避险为目标,采用治理、外交、经济、安全、民生和法律六个角度并行论证;明确排除战术性军事建议,并补充了阶段周期、验收标准和启动条件。右侧字段仍可直接修改。",
},
];
export const TAIWAN_STRAIT_DEMO_OUTPUTS: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
isDemo: true,
steps: ["拆解目标", "形成协同机制", "设置复盘闭环"],
content: `### 治理统筹策略:建立“联合研判—分级响应—复盘校正”闭环
1. 设立常态化跨部门风险研判单元,统一风险词典、数据口径和事件分级。
2. 为不同风险等级预先定义责任主体、会商时限、可逆措施和退出条件,避免临时决策放大误判。
3. 建立月度桌面推演与季度复盘,重点校验交通、能源、金融、通信和公共服务之间的级联风险。
4. 重大调整实行“政策效果—升级风险—民生影响—法律合规”四项并联审查。
**优先动作:** 30天内完成责任矩阵和跨部门值守清单;90天内完成首次全链路压力测试。`,
},
"strategy-selection-angle-diplomacy": {
status: "done",
isDemo: true,
steps: ["识别沟通对象", "设计危机渠道", "校准公开信号"],
content: `### 外交沟通策略:保持多层次、可验证的危机沟通
1. 维护工作层、专业部门和高层三类沟通渠道,约定紧急事件的确认窗口与回执机制。
2. 对外表述采用“事实、原则、下一步安排”三段式,减少模糊威慑和相互误读。
3. 支持以航行安全、灾害救援、公共卫生等低政治敏感议题开展技术性接触,积累最低限度信任。
4. 引入可信第三方作为信息转递与事实核验的备份通道,但不替代直接沟通。
**触发条件:** 当出现信息不一致、渠道中断或舆情快速放大时,立即启动双渠道交叉确认。`,
},
"strategy-selection-angle-economy": {
status: "done",
isDemo: true,
steps: ["识别关键链路", "评估替代能力", "配置韧性措施"],
content: `### 经济韧性策略:先保连续性,再做结构性分散
1. 对航运、半导体、能源、金融结算和通信链路建立关键节点清单与中断情景。
2. 以库存天数、替代供应商覆盖率、运输改道时间和流动性缺口作为月度韧性指标。
3. 对短期冲击优先使用信息协调、应急库存和融资支持,避免仓促、不可逆的全面脱钩措施。
4. 中长期推动来源与路线多元化,同时保留正常经贸交往的政策空间。
**优先动作:** 对关键民生物资开展30/60/90天压力测试,并为中小企业准备定向流动性工具。`,
},
"strategy-selection-angle-security": {
status: "done",
isDemo: true,
steps: ["构建预警指标", "划分风险等级", "设计降级动作"],
content: `### 安全风险策略:以预警和降级机制减少偶发事件外溢
1. 建立覆盖异常活动、通信中断、错误信息扩散、交通受阻和市场波动的综合预警看板。
2. 将风险划分为关注、紧张、严重三级,每级只配置克制、可逆、可解释的非升级性应对动作。
3. 对来源未核实的信息实行“双源确认+时点标注”,避免单一信号直接触发重大政策响应。
4. 所有高风险决策设置冷静期、法律复核和跨部门反方意见席位。
**止损线:** 一旦沟通渠道失效或民生影响超过阈值,优先恢复沟通与公共服务,暂停可能叠加风险的新措施。`,
},
"strategy-selection-angle-public-opinion": {
status: "done",
isDemo: true,
steps: ["分析公众需求", "保障基本服务", "设计信息发布"],
content: `### 社会民生策略:稳定预期并保护基本服务
1. 建立权威、固定频率的信息发布窗口,明确已知、未知和正在核验的事项。
2. 为交通、医疗、能源、通信和教育准备连续性方案,优先保障脆弱群体。
3. 监测谣言与恐慌性行为时,采用事实澄清和服务指引,不扩大未经证实的信息。
4. 设置独立反馈渠道,收集企业、社区和民众的实际困难并纳入每日研判。
**评价指标:** 基本服务可用率、权威信息触达率、谣言纠正时长和重点群体保障覆盖率。`,
},
"strategy-selection-angle-law-policy": {
status: "done",
isDemo: true,
steps: ["识别规则边界", "建立审查清单", "设计留痕机制"],
content: `### 法律规则策略:把合规审查嵌入每个决策节点
1. 为数据共享、经济措施、应急管理和对外沟通分别建立适用规则清单。
2. 重大措施记录事实依据、授权来源、比例性判断、影响评估和退出条件。
3. 涉及平民权益、基本服务和跨境活动的措施优先进行人道影响评估。
4. 对时效性强的事项采用“快速初审—事后完整复核”,但不得跳过最低合规门槛。
**优先动作:** 建立统一法律审查模板和决策证据包,保证后续可解释、可追溯。`,
},
};
export const TAIWAN_STRAIT_DEMO_EVALUATIONS: StrategyEvaluation[] = [
{
agentId: "strategy-selection-angle-governance",
score: 92,
conclusion: "优先推荐",
comment: "跨部门闭环清晰,是其他策略落地的共同底座。",
strengths: ["责任明确", "阶段完整", "便于复盘"],
risks: ["需要稳定的牵头机制"],
},
{
agentId: "strategy-selection-angle-diplomacy",
score: 90,
conclusion: "优先推荐",
comment: "直接作用于误判风险,且措施克制、可逆。",
strengths: ["降级效果直接", "渠道有备份"],
risks: ["依赖相关方持续参与"],
},
{
agentId: "strategy-selection-angle-economy",
score: 86,
conclusion: "建议采用",
comment: "兼顾短期连续性与长期韧性,需控制过度防御成本。",
strengths: ["指标可量化", "避免一刀切"],
risks: ["跨行业数据获取成本较高"],
},
{
agentId: "strategy-selection-angle-security",
score: 91,
conclusion: "优先推荐",
comment: "预警、确认和冷静期设计可显著降低错误触发。",
strengths: ["止损线清楚", "强调双源核验"],
risks: ["阈值需持续校准"],
},
{
agentId: "strategy-selection-angle-public-opinion",
score: 84,
conclusion: "建议采用",
comment: "增强社会承压能力,建议与经济连续性方案合并实施。",
strengths: ["关注脆弱群体", "沟通指标明确"],
risks: ["需避免信息发布碎片化"],
},
{
agentId: "strategy-selection-angle-law-policy",
score: 88,
conclusion: "建议采用",
comment: "为高压情境下的决策提供必要边界和可追溯性。",
strengths: ["审查嵌入流程", "留痕充分"],
risks: ["快速响应与完整审查需平衡"],
},
];
export const TAIWAN_STRAIT_DEMO_SYNTHESIS = `# 台海局势风险管控与区域韧性提升综合方案
> **示例说明:** 本方案为产品功能演示数据,采用中立、降级避险和民生优先视角,不构成现实决策建议;正式使用前应依据最新可靠信息进行专业复核。
## 一、总体目标
在未来12—24个月内,以“减少误判、阻断升级、保护民生、维持连续性”为主线,建立可监测、可启动、可退出、可复盘的风险管控体系。任何措施均应符合适用规则,坚持克制、对等、可逆和可解释。
## 二、四条主策略
### 1. 联合研判与分级响应
- 建立跨部门常态研判单元,统一风险词典、数据口径和三级事件标准。
- 每个级别明确牵头主体、会商时限、启动阈值、允许动作和退出条件。
- 重大建议同时接受政策效果、升级风险、民生影响和法律合规审查。
### 2. 多层危机沟通与事实核验
- 维护工作层、专业部门和高层沟通渠道,并配置可信第三方备份。
- 紧急事项执行“双渠道回执、双来源确认、统一时点标注”。
- 公开沟通采用“事实—原则—下一步安排”结构,降低信号歧义。
### 3. 经济与公共服务连续性
- 围绕航运、能源、金融、通信、医疗和关键供应链建立中断情景清单。
- 用库存天数、替代覆盖率、改道时长、服务可用率等指标开展月度压力测试。
- 短期优先使用应急库存、信息协调和定向融资;中长期有序提升来源与路线多元化。
### 4. 规则嵌入与社会韧性
- 将授权来源、比例性、人道影响、退出条件和证据留存纳入统一审查模板。
- 通过固定频率的权威发布说明已知、未知和核验进度,提供清晰服务指引。
- 优先保障脆弱群体,并建立企业、社区与公众反馈的快速回流机制。
## 三、阶段安排
| 阶段 | 重点动作 | 主要产出 |
| --- | --- | --- |
| 0—30天 | 完成责任矩阵、沟通清单、关键链路盘点与法律审查模板 | 一图一表一清单、首版预警看板 |
| 1—6个月 | 开展压力测试和桌面推演,打通双渠道确认与公众反馈 | 演练报告、阈值校准、改进台账 |
| 6—24个月 | 推进结构性韧性项目并季度复盘,动态退出低效措施 | 韧性提升项目、季度评估报告 |
## 四、核心预警指标
1. 沟通渠道可用率与紧急事项回执时长。
2. 未核实信息传播速度及权威澄清时长。
3. 关键运输、能源、金融、通信和公共服务可用率。
4. 市场异常波动、关键物资库存天数与替代供应覆盖率。
5. 脆弱群体保障覆盖率和公众反馈闭环时长。
## 五、止损与调整机制
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
`;
@@ -1,329 +0,0 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/strategy-selection.test.mjs
* node --test .runtime/unit-tests/strategy-selection.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
areStrategyTaskDraftsEquivalent,
buildIntentBrief,
buildJudgeBrief,
buildSynthesisBrief,
extractFencedJson,
normalizeIntentClarification,
parseEvaluationsFromText,
parseSseChunk,
parseTaskDraftFromText,
stripStructuredBlocks,
} from "./lib";
import { EMPTY_TASK_DRAFT, STRATEGY_ANGLES } from "./constants";
import type { AgentOutputState, StrategyAngle } from "./types";
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
const first = parseSseChunk(
'data: {"type":"agent_start","agent_id":"a"}\n\ndata: {"type":"del',
);
assert.equal(first.events.length, 1);
assert.equal(first.events[0].type, "agent_start");
const second = parseSseChunk(`${first.rest}ta","agent_id":"a","text":"你好"}\n\n`);
assert.deepEqual(second.events, [
{ type: "delta", agent_id: "a", text: "你好" },
]);
});
test("normalizeIntentClarification accepts string and object options", () => {
const clarification = normalizeIntentClarification({
type: "agent_clarification",
agent_id: "strategy-selection-intent",
question: "请选择时间范围",
options: ["短期", { id: "long", label: "长期", description: "一年以上" }],
allow_custom: true,
allow_multiple: false,
});
assert.equal(clarification.question, "请选择时间范围");
assert.deepEqual(clarification.options, [
{ id: "option-1", label: "短期", description: undefined },
{ id: "long", label: "长期", description: "一年以上" },
]);
assert.equal(clarification.allowCustom, true);
});
test("task draft equivalence ignores whitespace and angle ordering only", () => {
const left = {
...EMPTY_TASK_DRAFT,
name: "区域 风险方案",
detail: "第一行\n第二行",
selectedAngleIds: ["governance", "diplomacy"],
};
const presentationOnlyChange = {
...left,
name: " 区域 风险方案 ",
detail: "第一行 第二行",
selectedAngleIds: ["diplomacy", "governance", "diplomacy"],
};
assert.equal(
areStrategyTaskDraftsEquivalent(left, presentationOnlyChange),
true,
);
assert.equal(
areStrategyTaskDraftsEquivalent(left, {
...presentationOnlyChange,
objective: "新增目标",
}),
false,
);
});
test("intent brief retains the clarification question and the user's confirmed answer", () => {
const brief = buildIntentBrief(
[
{
id: "clarify",
role: "assistant",
content: "",
clarification: {
question: "方案主要面向谁?",
options: [{ id: "public", label: "公共部门" }],
allowCustom: true,
allowMultiple: false,
},
clarificationAnswered: true,
clarificationAnswer: "公共部门",
},
{ id: "answer", role: "user", content: "对澄清问题的回答:公共部门" },
],
EMPTY_TASK_DRAFT,
);
assert.match(brief, /意图助手澄清:方案主要面向谁/);
assert.match(brief, /用户对该澄清的确认:公共部门/);
});
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
assert.equal(
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
"说明文字",
);
});
test("extractFencedJson and parseTaskDraftFromText read the structured task block", () => {
const text = [
"已整理任务。",
"```strategy-task-json",
JSON.stringify({
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
}),
"```",
].join("\n");
assert.deepEqual(extractFencedJson(text, "strategy-task-json"), {
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
});
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "测试任务");
assert.deepEqual(task?.selectedAngleIds, ["governance", "law_policy"]);
});
test("task parser accepts backend SOUL field aliases", () => {
const text = `\`\`\`strategy-task-json\n${JSON.stringify({
task_name: "别名任务",
task_detail: "别名详情",
expected_outputs: "综合方案",
recommended_angles: ["public_opinion", "strategy-selection-angle-law-policy"],
})}\n\`\`\``;
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "别名任务");
assert.equal(task?.detail, "别名详情");
assert.equal(task?.deliverables, "综合方案");
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
});
test("task parser rejects incomplete or unrecognized current-turn payloads", () => {
const completeFallback = {
...EMPTY_TASK_DRAFT,
name: "旧任务",
detail: "旧详情",
selectedAngleIds: ["governance"],
};
const wrap = (payload: unknown) =>
`\`\`\`strategy-task-json\n${JSON.stringify(payload)}\n\`\`\``;
assert.equal(parseTaskDraftFromText(wrap({}), completeFallback), null);
assert.equal(parseTaskDraftFromText(wrap([]), completeFallback), null);
assert.equal(
parseTaskDraftFromText(
wrap({ name: "新任务", detail: "新详情", selectedAngleIds: ["unknown"] }),
completeFallback,
),
null,
);
assert.equal(
parseTaskDraftFromText(
wrap({ name: "新任务", detail: "新详情" }),
completeFallback,
),
null,
);
});
test("evaluation parser validates scores without inventing missing candidates", () => {
const outputs: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
content: "A",
steps: [],
},
"strategy-selection-angle-law-policy": { status: "done", content: "B", steps: [] },
"strategy-selection-angle-security": {
status: "error",
content: "PARTIAL_FAILED_OUTPUT",
steps: [],
error: "failed",
},
};
const text = `\`\`\`strategy-evaluation-json\n${JSON.stringify({
evaluations: [
{
agentId: "strategy-selection-angle-governance",
score: 100,
conclusion: "优先推荐",
comment: "清晰",
},
],
})}\n\`\`\``;
const evaluations = parseEvaluationsFromText(text, outputs);
assert.equal(evaluations.length, 1);
assert.equal(evaluations[0].score, 100);
assert.equal(
evaluations.some(
(item) => item.agentId === "strategy-selection-angle-law-policy",
),
false,
);
assert.equal(buildJudgeBrief(EMPTY_TASK_DRAFT, outputs).includes("PARTIAL_FAILED_OUTPUT"), false);
});
test("judge prompt and evaluation parser only use task-selected angles", () => {
const outputs: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": { status: "done", content: "治理策略", steps: [] },
"strategy-selection-angle-law-policy": { status: "done", content: "法律策略", steps: [] },
};
const task = { ...EMPTY_TASK_DRAFT, selectedAngleIds: ["governance"] };
const brief = buildJudgeBrief(task, outputs);
assert.equal(brief.includes("治理策略"), true);
assert.equal(brief.includes("法律策略"), false);
const evaluations = parseEvaluationsFromText("无结构化量表", outputs, [
"strategy-selection-angle-governance",
]);
assert.deepEqual(evaluations, []);
});
test("dynamic administrator angles flow through intent parsing and judging without fabricated evaluation", () => {
const dynamic: StrategyAngle = {
id: "technology",
agentId: "strategy-selection-angle-dynamic-tech",
name: "科技与产业链",
shortName: "科技",
description: "关注技术依赖与产业协同",
accent: "#2563eb",
enabled: true,
};
const task = {
...EMPTY_TASK_DRAFT,
selectedAngleIds: [dynamic.id],
};
const parsed = parseTaskDraftFromText(
`\`\`\`strategy-task-json\n${JSON.stringify({
name: "动态任务",
detail: "测试管理员新增角度",
selectedAngleIds: [dynamic.agentId],
})}\n\`\`\``,
task,
[dynamic],
);
assert.deepEqual(parsed?.selectedAngleIds, [dynamic.id]);
assert.match(buildIntentBrief([], task, [dynamic]), /technology:科技与产业链/);
const outputs: Record<string, AgentOutputState> = {
[dynamic.agentId]: { status: "done", content: "科技策略", steps: [] },
};
assert.match(buildJudgeBrief(task, outputs, [dynamic]), /科技策略/);
assert.deepEqual(
parseEvaluationsFromText("没有结构化评判", outputs, [dynamic.agentId], [dynamic]),
[],
);
});
test("evaluation parser rejects missing fields, invalid scores, and duplicate agent rows", () => {
const agentId = "strategy-selection-angle-governance";
const outputs: Record<string, AgentOutputState> = {
[agentId]: { status: "done", content: "治理策略", steps: [] },
};
const wrap = (evaluations: Record<string, unknown>[]) =>
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations })}\n\`\`\``;
assert.deepEqual(
parseEvaluationsFromText(
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations: [null] })}\n\`\`\``,
outputs,
[agentId],
),
[],
);
assert.deepEqual(
parseEvaluationsFromText(wrap([{ agentId, conclusion: "建议采用" }]), outputs, [agentId]),
[],
);
assert.deepEqual(
parseEvaluationsFromText(
wrap([{ agentId, score: 80.5, conclusion: "建议采用" }]),
outputs,
[agentId],
),
[],
);
assert.deepEqual(
parseEvaluationsFromText(
wrap([
{ agentId, score: 80, conclusion: "建议采用" },
{ agentId, score: 75, conclusion: "保留备选" },
]),
outputs,
[agentId],
),
[],
);
});
test("synthesis prompt exposes only the user's final strategy basket", () => {
const governance = STRATEGY_ANGLES.find((angle) => angle.id === "governance")!;
const security = STRATEGY_ANGLES.find((angle) => angle.id === "security")!;
const task = {
...EMPTY_TASK_DRAFT,
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
};
const outputs: Record<string, AgentOutputState> = {
[governance.agentId]: { status: "done", content: "治理入选正文", steps: [] },
[security.agentId]: { status: "done", content: "安全未入选正文", steps: [] },
};
const brief = buildSynthesisBrief(
task,
[governance.agentId],
outputs,
[],
STRATEGY_ANGLES,
);
assert.match(
brief,
new RegExp(`最终只允许整合以下 1 项:${governance.name}`),
);
assert.match(brief, /治理入选正文/);
assert.equal(brief.includes("安全未入选正文"), false);
assert.match(brief, /"selectedAngleIds": \[\s*"governance"\s*\]/);
});
-408
View File
@@ -1,408 +0,0 @@
import { STRATEGY_ANGLES } from "./constants";
import type {
AgentOutputState,
IntentClarification,
IntentTurn,
ParallelAgentEvent,
StrategyAngle,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeIntentClarification(
event: Extract<ParallelAgentEvent, { type: "agent_clarification" }>,
): IntentClarification {
const question =
stringValue(event.question) ||
stringValue(event.content) ||
"请补充完成任务定义所需的信息。";
const options = Array.isArray(event.options)
? event.options
.map((option, index) => {
const label =
typeof option === "string" ? stringValue(option) : stringValue(option?.label);
if (!label) return null;
return {
id:
(typeof option === "string" ? "" : stringValue(option.id)) ||
`option-${index + 1}`,
label,
description:
(typeof option === "string" ? "" : stringValue(option.description)) ||
undefined,
};
})
.filter((option): option is NonNullable<typeof option> => option !== null)
: [];
return {
question,
context:
stringValue(event.clarification_context) ||
stringValue(event.context) ||
undefined,
clarificationType: stringValue(event.clarification_type) || undefined,
options,
allowCustom: event.allow_custom !== false || options.length === 0,
allowMultiple: event.allow_multiple === true,
};
}
function selectedStrategyAgentIds(
task: StrategyTaskDraft,
angles: StrategyAngle[],
): Set<string> {
return new Set(
angles.filter((angle) => task.selectedAngleIds.includes(angle.id)).map(
(angle) => angle.agentId,
),
);
}
const TASK_DRAFT_TEXT_FIELDS = [
"name",
"detail",
"objective",
"prerequisites",
"constraints",
"deliverables",
"acceptanceCriteria",
"horizon",
] as const satisfies readonly (keyof StrategyTaskDraft)[];
function normalizeTaskDraftText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
/**
* Treat presentation-only whitespace and angle ordering as equivalent so an
* intent-agent round trip cannot invalidate an already generated workflow
* unless the task's meaning actually changed.
*/
export function areStrategyTaskDraftsEquivalent(
left: StrategyTaskDraft,
right: StrategyTaskDraft,
): boolean {
const textFieldsMatch = TASK_DRAFT_TEXT_FIELDS.every(
(field) =>
normalizeTaskDraftText(left[field] as string) ===
normalizeTaskDraftText(right[field] as string),
);
if (!textFieldsMatch) return false;
const leftAngles = [...new Set(left.selectedAngleIds)].sort();
const rightAngles = [...new Set(right.selectedAngleIds)].sort();
return (
leftAngles.length === rightAngles.length &&
leftAngles.every((angleId, index) => angleId === rightAngles[index])
);
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(stringValue).filter(Boolean);
}
export function extractFencedJson<T>(text: string, marker?: string): T | null {
const escaped = marker?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
escaped ? new RegExp("```" + escaped + "\\s*([\\s\\S]*?)```", "i") : null,
/```(?:json)?\s*([\s\S]*?)```/i,
].filter(Boolean) as RegExp[];
for (const pattern of patterns) {
const match = pattern.exec(text);
if (!match?.[1]) continue;
try {
return JSON.parse(match[1].trim()) as T;
} catch {
// The model may have emitted another fenced block first; fall through to
// the balanced-object fallback below.
}
}
const firstObject = text.indexOf("{");
const lastObject = text.lastIndexOf("}");
if (firstObject >= 0 && lastObject > firstObject) {
try {
return JSON.parse(text.slice(firstObject, lastObject + 1)) as T;
} catch {
return null;
}
}
return null;
}
export function stripStructuredBlocks(text: string): string {
return text
.replace(/```(?:strategy-task-json|strategy-evaluation-json)\s*[\s\S]*?```/gi, "")
.replace(/\[(?:INTENT_READY|JUDGEMENT_READY)\]/gi, "")
.trim();
}
export function parseTaskDraftFromText(
text: string,
fallback: StrategyTaskDraft,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyTaskDraft | null {
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const rawName = parsed.name ?? parsed.task_name;
const rawDetail = parsed.detail ?? parsed.task_detail;
const rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
if (
typeof rawName !== "string" ||
!rawName.trim() ||
typeof rawDetail !== "string" ||
!rawDetail.trim() ||
!Array.isArray(rawAngles)
) {
return null;
}
const angleIds = [
...new Set(
rawAngles
.map(stringValue)
.map((value) => {
const normalized = value.toLowerCase().replaceAll("-", "_");
return angles.find(
(angle) =>
angle.id === normalized ||
angle.agentId === value ||
angle.name === value ||
angle.shortName === value,
)?.id;
})
.filter((id): id is string => Boolean(id)),
),
];
if (!angleIds.length) return null;
return {
name: stringValue(rawName),
detail: stringValue(rawDetail),
objective: stringValue(parsed.objective) || fallback.objective,
prerequisites:
stringValue(parsed.prerequisites) || fallback.prerequisites,
constraints: stringValue(parsed.constraints) || fallback.constraints,
deliverables:
stringValue(parsed.deliverables ?? parsed.expected_outputs) || fallback.deliverables,
acceptanceCriteria:
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
horizon: stringValue(parsed.horizon) || fallback.horizon,
selectedAngleIds: angleIds,
};
}
export function parseEvaluationsFromText(
text: string,
outputs: Record<string, AgentOutputState>,
allowedAgentIds?: Iterable<string>,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyEvaluation[] {
const parsed = extractFencedJson<
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
>(text, "strategy-evaluation-json");
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
if (!Array.isArray(rows)) return [];
const allowed = allowedAgentIds ? new Set(allowedAgentIds) : null;
const knownIds = new Set(
angles.filter((angle) => !allowed || allowed.has(angle.agentId)).map(
(angle) => angle.agentId,
),
);
const evaluations: StrategyEvaluation[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
const agentId = stringValue(row.agentId ?? row.agent_id);
if (!knownIds.has(agentId)) continue;
const score = row.score;
const conclusion = stringValue(row.conclusion);
if (
seen.has(agentId) ||
outputs[agentId]?.status !== "done" ||
!outputs[agentId]?.content.trim() ||
typeof score !== "number" ||
!Number.isFinite(score) ||
!Number.isInteger(score) ||
score < 0 ||
score > 100 ||
(conclusion !== "优先推荐" &&
conclusion !== "建议采用" &&
conclusion !== "保留备选")
) {
// Reject the entire structured result instead of inventing defaults or
// allowing duplicate rows to be selected twice downstream.
return [];
}
seen.add(agentId);
evaluations.push({
agentId,
score,
conclusion,
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
strengths: stringList(row.strengths),
risks: stringList(row.risks),
});
}
if (allowed && [...allowed].some((agentId) => !seen.has(agentId))) return [];
return evaluations.sort((a, b) => b.score - a.score);
}
export function buildIntentBrief(
turns: IntentTurn[],
currentDraft: StrategyTaskDraft,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const conversation = turns
.filter(
(turn) =>
turn.content.trim() ||
turn.clarification?.question.trim() ||
turn.clarificationAnswer?.trim(),
)
.map((turn) => {
const lines: string[] = [];
if (turn.content.trim()) {
lines.push(
`${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`,
);
}
if (turn.clarification) {
const optionText = turn.clarification.options
.map((option) => option.label)
.filter(Boolean)
.join(" / ");
lines.push(
`意图助手澄清:${turn.clarification.question}${
optionText ? `(可选:${optionText})` : ""
}`,
);
}
if (turn.clarificationAnswer?.trim()) {
lines.push(`用户对该澄清的确认:${turn.clarificationAnswer}`);
}
return lines.join("\n");
})
.join("\n\n");
const availableAngles = angles
.filter((angle) => angle.enabled !== false)
.map((angle) => `- ${angle.id}:${angle.name}(${angle.description})`)
.join("\n");
const exampleAngleIds = angles
.filter((angle) => angle.enabled !== false)
.slice(0, 3)
.map((angle) => angle.id);
return `你负责意图理解与任务定义。基于以下对话整理任务。不要虚构实时事实。\n\n当前草稿(可据新信息修订):\n${JSON.stringify(currentDraft, null, 2)}\n\n本系统当前可选角度(只能从下列 id 中选择):\n${availableAngles}\n\n对话:\n${conversation}\n\n请严格二选一执行:\n1. 如果不同答案会显著改变任务方向,立即调用 ask_clarification,只提一个最关键问题并提供清晰互斥选项;调用后停止,不输出任务 JSON,也不要在普通正文里假装提问。\n2. 如果信息已足够(包括用户刚回答了澄清),先用简洁自然语言复述整理结果,再在回复末尾追加以下可机器读取代码块,字段名不得改变:\n\`\`\`strategy-task-json\n{"name":"任务名称","detail":"任务详情","objective":"目标","prerequisites":"前置条件","constraints":"约束与边界","deliverables":"交付物","acceptanceCriteria":"验收标准","horizon":"时间范围","selectedAngleIds":${JSON.stringify(exampleAngleIds)}}\n\`\`\`\n只选择与任务有关的角度。`;
}
export function buildStrategyBrief(
task: StrategyTaskDraft,
selectedAngles = STRATEGY_ANGLES.filter((angle) =>
task.selectedAngleIds.includes(angle.id),
),
): string {
const angles = selectedAngles
.map((angle) => `- ${angle.name}:${angle.description}`)
.join("\n");
return `你是其中一个专业角度的策略生成智能体,只从自己的职责角度输出一套可独立审阅的策略。\n\n任务详情:\n${JSON.stringify(task, null, 2)}\n\n本轮并行角度:\n${angles}\n\n请给出:核心判断、3—6项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入策略正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
}
export function buildJudgeBrief(
task: StrategyTaskDraft,
outputs: Record<string, AgentOutputState>,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
const strategies = angles.filter(
(angle) =>
selectedAgentIds.has(angle.agentId) &&
outputs[angle.agentId]?.status === "done" &&
outputs[angle.agentId]?.content.trim(),
)
.map(
(angle) =>
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
)
.join("\n\n");
return `你是独立策略评判智能体。请依据任务目标,对候选策略的目标贡献、可执行性、风险可控性、协同性和合规性进行比较。先输出总体评判与冲突提醒,再在末尾输出结构化量表。工具失败、文件路径、权限问题和检索计划属于执行过程,不得写进评判正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n候选策略:\n${strategies}\n\n末尾必须追加:\n\`\`\`strategy-evaluation-json\n{"evaluations":[{"agentId":"候选agentId","score":0,"conclusion":"优先推荐|建议采用|保留备选","comment":"一句评价","strengths":["优势"],"risks":["风险"]}]}\n\`\`\`\n必须覆盖每个候选策略,score为0—100整数。`;
}
export function buildSynthesisBrief(
task: StrategyTaskDraft,
selectedAgentIds: string[],
outputs: Record<string, AgentOutputState>,
evaluations: StrategyEvaluation[],
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
const uniqueSelectedAgentIds = [...new Set(selectedAgentIds)];
const selectedAngles = uniqueSelectedAgentIds
.map((agentId) => angles.find((item) => item.agentId === agentId))
.filter((angle): angle is StrategyAngle => Boolean(angle));
const selectedAngleIds = new Set(selectedAngles.map((angle) => angle.agentId));
const exactTask = {
...task,
selectedAngleIds: selectedAngles.map((angle) => angle.id),
};
const selections = uniqueSelectedAgentIds
.filter((agentId) => selectedAngleIds.has(agentId))
.map((agentId) => {
const angle = angles.find((item) => item.agentId === agentId);
const evaluation = evaluationMap.get(agentId);
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
})
.join("\n\n");
const selectedNames = selectedAngles.map((angle) => angle.name).join("、");
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务(selectedAngleIds 已收敛为用户最终选入的策略角度):\n${JSON.stringify(exactTask, null, 2)}\n\n本次最终只允许整合以下 ${selectedAngles.length} 项:${selectedNames}\n不得补写、复活或暗中纳入任何未列出的角度或策略。\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入方案正文;确有信息缺口时仅归入“待核验信息”小节。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
}
export function parseSseChunk(
buffer: string,
flush = false,
): { events: ParallelAgentEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = flush ? "" : (parts.pop() ?? "");
const complete = flush ? parts.concat(parts.length ? [] : [normalized]) : parts;
const events: ParallelAgentEvent[] = [];
for (const frame of complete) {
const payload = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!payload || payload === "[DONE]") continue;
try {
const event = JSON.parse(payload) as ParallelAgentEvent;
if (event && typeof event.type === "string") events.push(event);
} catch {
// Ignore one malformed frame instead of discarding the rest of a stream.
}
}
return { events, rest };
}
export function downloadMarkdown(markdown: string, fileName: string): void {
const cleanName =
fileName.replace(/\.(md|markdown)$/i, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim() ||
"strategy-plan";
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${cleanName}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
@@ -1,29 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
strategyProjectListPath,
strategyProjectWorkbenchPath,
} from "./project-routes";
test("solution list paths work in both application shells", () => {
assert.equal(
strategyProjectListPath("/page/strategy/qa/projects", "solution"),
"/page/strategy/qa/projects?type=solution",
);
assert.equal(
strategyProjectListPath("/official/workspace/projects/", "solution"),
"/official/workspace/projects?type=solution",
);
});
test("solution workbench paths encode ids and request the solution module", () => {
assert.equal(
strategyProjectWorkbenchPath(
"/official/workspace/projects",
"strategy-selection-a/b",
"solution",
),
"/official/workspace/projects/strategy-selection-a%2Fb?module=solution",
);
});
@@ -1,28 +0,0 @@
export type StrategyProjectListCategory =
| "all"
| "qa"
| "strategy"
| "solution";
function normalizeBasePath(basePath: string): string {
return basePath.replace(/\/+$/, "");
}
export function strategyProjectListPath(
basePath: string,
category: StrategyProjectListCategory = "all",
): string {
const path = normalizeBasePath(basePath);
return category === "all"
? path
: `${path}?type=${encodeURIComponent(category)}`;
}
export function strategyProjectWorkbenchPath(
basePath: string,
projectId: string,
module?: "solution",
): string {
const path = `${normalizeBasePath(basePath)}/${encodeURIComponent(projectId)}`;
return module === "solution" ? `${path}?module=solution` : path;
}
@@ -1,147 +0,0 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/project-wal.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/project-wal.test.mjs
* node --test .runtime/unit-tests/project-wal.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { StrategyProjectSnapshot } from "./projects";
import {
clearStrategyProjectWal,
isStrategyProjectWalExpired,
LatestByKeyScheduler,
resolveStrategyProjectWal,
strategyProjectSnapshotHash,
strategyProjectWalKey,
writeStrategyProjectWal,
} from "./project-wal";
const createSnapshot = (): StrategyProjectSnapshot => ({
version: 1,
angleCatalog: [],
stage: "intent",
taskDraft: {
name: "",
detail: "",
objective: "",
prerequisites: "",
constraints: "",
deliverables: "",
acceptanceCriteria: "",
horizon: "",
selectedAngleIds: [],
},
turns: [],
outputs: {},
judgeStatus: "idle",
judgeContent: "",
evaluations: [],
selectedStrategyIds: [],
synthesis: "",
synthesisStatus: "idle",
solutionSavedAt: null,
isDemo: false,
workflowStale: false,
});
test("WAL keys isolate accounts and projects", () => {
assert.notEqual(
strategyProjectWalKey("account:one", "project-a"),
strategyProjectWalKey("account:two", "project-a"),
);
assert.notEqual(
strategyProjectWalKey("account:one", "project-a"),
strategyProjectWalKey("account:one", "project-b"),
);
});
test("snapshot hashes are stable and change with project content", async () => {
const snapshot = createSnapshot();
const same = createSnapshot();
const changed = {
...createSnapshot(),
taskDraft: {
...createSnapshot().taskDraft,
name: "已修改任务",
},
};
assert.equal(
await strategyProjectSnapshotHash(snapshot),
await strategyProjectSnapshotHash(same),
);
assert.notEqual(
await strategyProjectSnapshotHash(snapshot),
await strategyProjectSnapshotHash(changed),
);
});
test("WAL degrades safely when IndexedDB is unavailable", async () => {
const snapshot = createSnapshot();
assert.equal(
await writeStrategyProjectWal(
"account:test",
"project-test",
"base-hash",
snapshot,
),
null,
);
assert.equal(
await clearStrategyProjectWal("account:test", "project-test", snapshot),
false,
);
});
test("WAL recovery uses ancestry hashes instead of incomparable clocks", async () => {
const server = createSnapshot();
const local = {
...createSnapshot(),
taskDraft: { ...createSnapshot().taskDraft, name: "本地编辑" },
};
const otherServer = {
...createSnapshot(),
taskDraft: { ...createSnapshot().taskDraft, name: "服务器编辑" },
};
const serverHash = await strategyProjectSnapshotHash(server);
const localHash = await strategyProjectSnapshotHash(local);
const otherServerHash = await strategyProjectSnapshotHash(otherServer);
const wal = { baseServerHash: serverHash, snapshotHash: localHash };
assert.equal(resolveStrategyProjectWal(wal, serverHash), "recover");
assert.equal(resolveStrategyProjectWal(wal, localHash), "same");
assert.equal(resolveStrategyProjectWal(wal, otherServerHash), "conflict");
});
test("latest-by-key scheduler bounds queued streaming snapshots", async () => {
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const calls: number[] = [];
const scheduler = new LatestByKeyScheduler<number, number>(async (value) => {
calls.push(value);
if (value === 1) await firstBlocked;
return value;
});
const first = scheduler.schedule("project", 1);
await Promise.resolve();
const superseded = scheduler.schedule("project", 2);
const latest = scheduler.schedule("project", 3);
assert.equal(await superseded, null);
releaseFirst();
assert.equal(await first, 1);
assert.equal(await latest, 3);
assert.deepEqual(calls, [1, 3]);
await scheduler.waitForIdle("project");
});
test("WAL expiry is deterministic at the retention boundary", () => {
const now = Date.now();
const sixDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
assert.equal(isStrategyProjectWalExpired(sixDaysAgo, now), false);
assert.equal(isStrategyProjectWalExpired(eightDaysAgo, now), true);
});
@@ -1,362 +0,0 @@
import type { StrategyProjectSnapshot } from "./projects";
const DATABASE_NAME = "deerflow-strategy-project-wal";
const STORE_NAME = "drafts";
const DATABASE_VERSION = 1;
const WAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface StrategyProjectWalRecord {
key: string;
userKey: string;
projectId: string;
/** Server snapshot on top of which this local edit was made. */
baseServerHash: string;
snapshotHash: string;
snapshot: StrategyProjectSnapshot;
updatedAt: number;
}
interface PendingJob<Input, Result> {
input: Input;
resolve: (result: Result | null) => void;
reject: (reason: unknown) => void;
}
interface KeyQueueState<Input, Result> {
running: boolean;
pending?: PendingJob<Input, Result>;
idleWaiters: Array<() => void>;
}
/** Keep at most one active and one latest pending operation per key. */
export class LatestByKeyScheduler<Input, Result> {
private readonly states = new Map<string, KeyQueueState<Input, Result>>();
public constructor(
private readonly worker: (input: Input) => Promise<Result>,
) {}
public schedule(key: string, input: Input): Promise<Result | null> {
let state = this.states.get(key);
if (!state) {
state = { running: false, idleWaiters: [] };
this.states.set(key, state);
}
return new Promise<Result | null>((resolve, reject) => {
// The superseded value has not started hashing or IndexedDB work. Resolve
// it now so its growing snapshot closure can be released immediately.
state!.pending?.resolve(null);
state!.pending = { input, resolve, reject };
void this.pump(key, state!);
});
}
public waitForIdle(key: string): Promise<void> {
const state = this.states.get(key);
if (!state || (!state.running && !state.pending)) return Promise.resolve();
return new Promise((resolve) => state.idleWaiters.push(resolve));
}
private async pump(
key: string,
state: KeyQueueState<Input, Result>,
): Promise<void> {
if (state.running) return;
const job = state.pending;
if (!job) {
this.finishKey(key, state);
return;
}
state.pending = undefined;
state.running = true;
try {
job.resolve(await this.worker(job.input));
} catch (reason) {
job.reject(reason);
} finally {
state.running = false;
if (state.pending) {
void this.pump(key, state);
} else {
this.finishKey(key, state);
}
}
}
private finishKey(
key: string,
state: KeyQueueState<Input, Result>,
): void {
this.states.delete(key);
state.idleWaiters.splice(0).forEach((resolve) => resolve());
}
}
export function strategyProjectWalKey(
userKey: string,
projectId: string,
): string {
return `${encodeURIComponent(userKey)}::${encodeURIComponent(projectId)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isUsableSnapshot(value: unknown): value is StrategyProjectSnapshot {
if (!isRecord(value) || value.version !== 1) return false;
if (value.stage !== "intent" && value.stage !== "workspace") return false;
if (!isRecord(value.taskDraft) || !Array.isArray(value.taskDraft.selectedAngleIds)) {
return false;
}
return (
Array.isArray(value.angleCatalog) &&
Array.isArray(value.turns) &&
isRecord(value.outputs) &&
Array.isArray(value.evaluations) &&
Array.isArray(value.selectedStrategyIds) &&
typeof value.synthesis === "string"
);
}
function fallbackHash(text: string): string {
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193);
second = Math.imul(second ^ code, 0x85ebca6b);
}
return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
.toString(16)
.padStart(8, "0")}`;
}
export async function strategyProjectSnapshotHash(
snapshot: StrategyProjectSnapshot,
): Promise<string> {
const serialized = JSON.stringify(snapshot);
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(serialized),
);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
return fallbackHash(serialized);
}
export function isStrategyProjectWalExpired(
updatedAt: number,
now = Date.now(),
): boolean {
return now - updatedAt > WAL_TTL_MS;
}
export function resolveStrategyProjectWal(
wal: Pick<StrategyProjectWalRecord, "baseServerHash" | "snapshotHash">,
serverHash: string,
): "same" | "recover" | "conflict" {
if (wal.snapshotHash === serverHash) return "same";
if (wal.baseServerHash === serverHash) return "recover";
return "conflict";
}
function openDatabase(): Promise<IDBDatabase | null> {
if (typeof indexedDB === "undefined") return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: "key" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("无法打开项目恢复存储"));
request.onblocked = () => reject(new Error("项目恢复存储正在升级,请关闭其他页面后重试"));
});
}
function waitForTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("项目恢复存储事务失败"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("项目恢复存储事务已中止"));
});
}
function readRequest(request: IDBRequest): Promise<unknown> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("无法读取项目恢复记录"));
});
}
async function writeWalRecord(
record: StrategyProjectWalRecord,
): Promise<StrategyProjectWalRecord | null> {
const database = await openDatabase();
if (!database) return null;
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const transactionDone = waitForTransaction(transaction);
const store = transaction.objectStore(STORE_NAME);
// Queue the mutation from inside IDB's success callback. Awaiting the GET
// and only then issuing PUT is racy in Safari because the transaction may
// become inactive between promise turns.
const outcome = new Promise<StrategyProjectWalRecord>((resolve, reject) => {
const request = store.get(record.key);
request.onerror = () =>
reject(request.error ?? new Error("无法读取项目恢复记录"));
request.onsuccess = () => {
try {
const existing = request.result;
if (
!isRecord(existing) ||
typeof existing.updatedAt !== "number" ||
existing.updatedAt <= record.updatedAt
) {
store.put(record);
resolve(record);
return;
}
resolve(existing as unknown as StrategyProjectWalRecord);
} catch (reason) {
reject(reason);
}
};
});
const [result] = await Promise.all([outcome, transactionDone]);
return result;
} finally {
database.close();
}
}
interface WalWriteInput {
userKey: string;
projectId: string;
baseServerHash: string;
snapshot: StrategyProjectSnapshot;
updatedAt: number;
}
const writeScheduler = new LatestByKeyScheduler<
WalWriteInput,
StrategyProjectWalRecord | null
>(async ({ userKey, projectId, baseServerHash, snapshot, updatedAt }) => {
const record: StrategyProjectWalRecord = {
key: strategyProjectWalKey(userKey, projectId),
userKey,
projectId,
baseServerHash,
snapshotHash: await strategyProjectSnapshotHash(snapshot),
snapshot,
updatedAt,
};
return writeWalRecord(record);
});
export function writeStrategyProjectWal(
userKey: string,
projectId: string,
baseServerHash: string,
snapshot: StrategyProjectSnapshot,
): Promise<StrategyProjectWalRecord | null> {
const key = strategyProjectWalKey(userKey, projectId);
return writeScheduler.schedule(key, {
userKey,
projectId,
baseServerHash,
snapshot,
updatedAt: Date.now(),
});
}
export async function readStrategyProjectWal(
userKey: string,
projectId: string,
): Promise<StrategyProjectWalRecord | null> {
const key = strategyProjectWalKey(userKey, projectId);
await writeScheduler.waitForIdle(key);
const database = await openDatabase();
if (!database) return null;
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const transactionDone = waitForTransaction(transaction);
const [record] = await Promise.all([
readRequest(transaction.objectStore(STORE_NAME).get(key)),
transactionDone,
]);
if (!isRecord(record) || !isUsableSnapshot(record.snapshot)) return null;
if (
record.userKey !== userKey ||
record.projectId !== projectId ||
typeof record.baseServerHash !== "string" ||
typeof record.snapshotHash !== "string" ||
typeof record.updatedAt !== "number"
) {
return null;
}
const typed = record as unknown as StrategyProjectWalRecord;
if (isStrategyProjectWalExpired(typed.updatedAt)) {
void clearStrategyProjectWal(userKey, projectId, typed.snapshot).catch(
() => undefined,
);
return null;
}
return typed;
} finally {
database.close();
}
}
export async function clearStrategyProjectWal(
userKey: string,
projectId: string,
expectedSnapshot?: StrategyProjectSnapshot,
): Promise<boolean> {
const key = strategyProjectWalKey(userKey, projectId);
const expectedHash = expectedSnapshot
? await strategyProjectSnapshotHash(expectedSnapshot)
: null;
await writeScheduler.waitForIdle(key);
const database = await openDatabase();
if (!database) return false;
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const transactionDone = waitForTransaction(transaction);
const store = transaction.objectStore(STORE_NAME);
if (!expectedSnapshot) {
store.delete(key);
await transactionDone;
return true;
}
const deletedPromise = new Promise<boolean>((resolve, reject) => {
const request = store.get(key);
request.onerror = () =>
reject(request.error ?? new Error("无法读取项目恢复记录"));
request.onsuccess = () => {
try {
const existing = request.result;
const matches =
isRecord(existing) && existing.snapshotHash === expectedHash;
if (matches) store.delete(key);
resolve(matches);
} catch (reason) {
reject(reason);
}
};
});
const [deleted] = await Promise.all([deletedPromise, transactionDone]);
return deleted;
} finally {
database.close();
}
}
@@ -1,469 +0,0 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getAccountIdentityKey } from "@/core/auth";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import {
EMPTY_TASK_DRAFT,
INITIAL_INTENT_TURNS,
STRATEGY_ANGLES,
TAIWAN_STRAIT_DEMO_EVALUATIONS,
TAIWAN_STRAIT_DEMO_OUTPUTS,
TAIWAN_STRAIT_DEMO_SYNTHESIS,
TAIWAN_STRAIT_DEMO_TASK,
TAIWAN_STRAIT_DEMO_TURNS,
} from "./constants";
import type {
AgentOutputState,
IntentTurn,
StrategyAngle,
StrategyEvaluation,
StrategyStage,
StrategyTaskDraft,
} from "./types";
import { clearStrategyProjectWal } from "./project-wal";
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
const SNAPSHOT_KIND = "strategy-selection-project";
// A strategy project can be mounted twice briefly during route transitions or
// opened in more than one tab. Serialize saves in this JS realm and, where
// supported, across same-browser tabs so an older PUT cannot finish after and
// overwrite a newer snapshot.
const projectSaveQueues = new Map<string, Promise<void>>();
async function withBrowserProjectLock<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
if (typeof navigator !== "undefined" && navigator.locks) {
return navigator.locks.request(`deerflow-strategy-save:${key}`, operation);
}
return operation();
}
function serializeProjectSave<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = projectSaveQueues.get(key) ?? Promise.resolve();
const request = previous
.catch(() => undefined)
.then(() => withBrowserProjectLock(key, operation));
const tail = request.then(
() => undefined,
() => undefined,
);
projectSaveQueues.set(key, tail);
void tail.then(() => {
if (projectSaveQueues.get(key) === tail) projectSaveQueues.delete(key);
});
return request;
}
const DEMO_JUDGE_CONTENT = `### 评判结论
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
export interface StrategyProjectSnapshot {
version: 1;
/** Copy of the angle catalog used by this project, for historical rendering. */
angleCatalog: StrategyAngle[];
stage: StrategyStage;
taskDraft: StrategyTaskDraft;
turns: IntentTurn[];
outputs: Record<string, AgentOutputState>;
judgeStatus: AgentOutputState["status"];
judgeContent: string;
evaluations: StrategyEvaluation[];
selectedStrategyIds: string[];
synthesis: string;
synthesisStatus: AgentOutputState["status"];
solutionSavedAt: string | null;
isDemo: boolean;
workflowStale: boolean;
}
export interface StrategyProjectSummary {
id: string;
title: string;
brief: string;
createdAt: string | null;
updatedAt: string | null;
stage: StrategyStage;
questionCount: number;
angleCount: number;
completedStrategyCount: number;
hasEvaluation: boolean;
hasSolution: boolean;
isDemo: boolean;
workflowStale: boolean;
}
export interface StrategyProject extends StrategyProjectSummary {
snapshot: StrategyProjectSnapshot;
}
export class StrategyProjectConflictError extends Error {
public constructor(message: string) {
super(message);
this.name = "StrategyProjectConflictError";
}
}
interface ParallelTaskResponse {
id: string;
title?: string;
brief?: string | null;
agents?: Array<{ agent_id: string; name?: string; avatarType?: string | null }>;
messages?: Array<Record<string, unknown>> | null;
created_at?: string | null;
updated_at?: string | null;
}
interface ParallelTaskListResponse {
tasks?: ParallelTaskResponse[];
}
interface SnapshotEnvelope extends Record<string, unknown> {
kind: typeof SNAPSHOT_KIND;
version: 1;
snapshot: StrategyProjectSnapshot;
}
const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
...task,
selectedAngleIds: [...task.selectedAngleIds],
});
const cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
angles.map((angle) => ({ ...angle }));
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
turns.map((turn) => ({ ...turn, streaming: false }));
const cloneOutputs = (
outputs: Record<string, AgentOutputState>,
): Record<string, AgentOutputState> =>
Object.fromEntries(
Object.entries(outputs).map(([id, output]) => [
id,
{ ...output, steps: [...(output.steps ?? [])] },
]),
);
const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
evaluations.map((item) => ({
...item,
strengths: [...item.strengths],
risks: [...item.risks],
}));
export function createEmptyStrategySnapshot(
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyProjectSnapshot {
const enabledAngles = angleCatalog.filter((angle) => angle.enabled !== false);
return {
version: 1,
angleCatalog: cloneAngles(enabledAngles),
stage: "intent",
taskDraft: {
...cloneTask(EMPTY_TASK_DRAFT),
selectedAngleIds: enabledAngles.slice(0, 4).map((angle) => angle.id),
},
turns: cloneTurns(INITIAL_INTENT_TURNS),
outputs: {},
judgeStatus: "idle",
judgeContent: "",
evaluations: [],
selectedStrategyIds: [],
synthesis: "",
synthesisStatus: "idle",
solutionSavedAt: null,
isDemo: false,
workflowStale: false,
};
}
export function canCreateTaiwanDemo(
angleCatalog: StrategyAngle[],
): boolean {
const enabledAgentIds = new Set(
angleCatalog
.filter((angle) => angle.enabled !== false)
.map((angle) => angle.agentId),
);
return STRATEGY_ANGLES.every((angle) => enabledAgentIds.has(angle.agentId));
}
export function createDemoStrategySnapshot(
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyProjectSnapshot {
if (!canCreateTaiwanDemo(angleCatalog)) {
throw new Error("台海示例所需的六个默认角度未全部启用,请先由管理员恢复配置");
}
const enabledAgentIds = new Set(
angleCatalog
.filter((angle) => angle.enabled !== false)
.map((angle) => angle.agentId),
);
const defaultDemoAgentIds = new Set(
STRATEGY_ANGLES.map((angle) => angle.agentId),
);
const demoAngles = angleCatalog.filter(
(angle) =>
angle.enabled !== false &&
enabledAgentIds.has(angle.agentId) &&
defaultDemoAgentIds.has(angle.agentId),
);
const effectiveAngles = demoAngles;
const demoOutputs = Object.fromEntries(
Object.entries(cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS)).filter(([agentId]) =>
effectiveAngles.some((angle) => angle.agentId === agentId),
),
);
const demoEvaluations = TAIWAN_STRAIT_DEMO_EVALUATIONS.filter((evaluation) =>
effectiveAngles.some((angle) => angle.agentId === evaluation.agentId),
);
return {
version: 1,
angleCatalog: cloneAngles(effectiveAngles),
stage: "workspace",
taskDraft: {
...cloneTask(TAIWAN_STRAIT_DEMO_TASK),
selectedAngleIds: effectiveAngles.map((angle) => angle.id),
},
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
outputs: demoOutputs,
judgeStatus: "done",
judgeContent: DEMO_JUDGE_CONTENT,
evaluations: cloneEvaluations(demoEvaluations),
selectedStrategyIds: demoEvaluations.filter(
(item) => item.score >= 88,
).map((item) => item.agentId),
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
synthesisStatus: "done",
solutionSavedAt: null,
isDemo: true,
workflowStale: false,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | null {
const envelope = task.messages?.find(
(message) => message.kind === SNAPSHOT_KIND && message.version === 1,
);
if (!isObject(envelope) || !isObject(envelope.snapshot)) return null;
const snapshot = envelope.snapshot as unknown as StrategyProjectSnapshot;
if (
snapshot.version !== 1 ||
(snapshot.stage !== "intent" && snapshot.stage !== "workspace") ||
!isObject(snapshot.taskDraft) ||
!Array.isArray(snapshot.taskDraft.selectedAngleIds) ||
!Array.isArray(snapshot.turns) ||
!isObject(snapshot.outputs) ||
!Array.isArray(snapshot.evaluations) ||
!Array.isArray(snapshot.selectedStrategyIds)
) {
return null;
}
return {
...snapshot,
angleCatalog: cloneAngles(
Array.isArray(snapshot.angleCatalog) && snapshot.angleCatalog.length
? snapshot.angleCatalog.filter(
(angle) =>
isObject(angle) &&
typeof angle.id === "string" &&
typeof angle.agentId === "string" &&
typeof angle.name === "string",
)
: STRATEGY_ANGLES,
),
workflowStale: snapshot.workflowStale === true,
solutionSavedAt:
typeof snapshot.solutionSavedAt === "string"
? snapshot.solutionSavedAt
: null,
taskDraft: cloneTask(snapshot.taskDraft),
turns: cloneTurns(snapshot.turns),
outputs: cloneOutputs(snapshot.outputs),
evaluations: cloneEvaluations(snapshot.evaluations),
selectedStrategyIds: [...snapshot.selectedStrategyIds],
};
}
function toProject(task: ParallelTaskResponse): StrategyProject | null {
const snapshot = readSnapshot(task);
if (!snapshot) return null;
const completedStrategyCount = Object.values(snapshot.outputs).filter(
(output) => output.status === "done" && output.content.trim(),
).length;
return {
id: task.id,
title: snapshot.taskDraft.name.trim() || task.title?.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || task.brief?.trim() || "尚未补充项目说明",
createdAt: task.created_at ?? null,
updatedAt: task.updated_at ?? null,
stage: snapshot.stage,
questionCount: snapshot.turns.filter((turn) => turn.role === "user").length,
angleCount: snapshot.taskDraft.selectedAngleIds.length,
completedStrategyCount,
hasEvaluation:
!snapshot.workflowStale &&
snapshot.judgeStatus === "done" &&
Boolean(snapshot.judgeContent.trim()),
hasSolution:
!snapshot.workflowStale &&
snapshot.synthesisStatus === "done" &&
Boolean(snapshot.synthesis.trim()),
isDemo: snapshot.isDemo,
workflowStale: snapshot.workflowStale,
snapshot,
};
}
async function readError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
const endpoint = (suffix = "") =>
`${getBackendBaseURL()}/api/parallel-agent-tasks${suffix}`;
async function getParallelTask(id: string): Promise<ParallelTaskResponse> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`));
if (!response.ok) throw new Error(await readError(response));
return (await response.json()) as ParallelTaskResponse;
}
export async function listStrategyProjects(): Promise<StrategyProjectSummary[]> {
const response = await apiFetch(endpoint());
if (!response.ok) throw new Error(await readError(response));
const body = (await response.json()) as ParallelTaskListResponse;
const candidates = (body.tasks ?? []).filter((task) =>
task.id.startsWith(STRATEGY_PROJECT_ID_PREFIX),
);
const records = await Promise.allSettled(
candidates.map((task) => getParallelTask(task.id)),
);
return records
.flatMap((result) => {
if (result.status !== "fulfilled") return [];
const project = toProject(result.value);
return project ? [project] : [];
})
.sort((a, b) =>
String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")),
);
}
export async function getStrategyProject(id: string): Promise<StrategyProject> {
if (!id.startsWith(STRATEGY_PROJECT_ID_PREFIX)) {
throw new Error("这不是一个策略优选项目");
}
const task = await getParallelTask(id);
const project = toProject(task);
if (!project) throw new Error("项目数据不完整,无法恢复策略工作台");
return project;
}
function projectAgents(snapshot: StrategyProjectSnapshot) {
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
const angleAgents = snapshot.angleCatalog.filter((angle) => selected.has(angle.id)).map(
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
);
return [
{ agent_id: "strategy-selection-intent", name: "意图理解智能体" },
...angleAgents,
{ agent_id: "strategy-selection-judge", name: "策略评判智能体" },
{ agent_id: "strategy-selection-solution", name: "方案整理智能体" },
];
}
function snapshotEnvelope(snapshot: StrategyProjectSnapshot): SnapshotEnvelope {
return {
kind: SNAPSHOT_KIND,
version: 1,
snapshot,
};
}
export async function createStrategyProject(
snapshot: StrategyProjectSnapshot = createEmptyStrategySnapshot(),
): Promise<StrategyProject> {
const id = `${STRATEGY_PROJECT_ID_PREFIX}${createClientId()}`;
const response = await apiFetch(endpoint(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id,
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || null,
agents: projectAgents(snapshot),
messages: [snapshotEnvelope(snapshot)],
}),
});
if (!response.ok) throw new Error(await readError(response));
const project = toProject((await response.json()) as ParallelTaskResponse);
if (!project) throw new Error("项目已创建,但返回数据无法识别");
return project;
}
export async function saveStrategyProject(
id: string,
snapshot: StrategyProjectSnapshot,
options: {
expectedUpdatedAt?: string | null;
keepalive?: boolean;
signal?: AbortSignal;
} = {},
): Promise<StrategyProject> {
const account = getAccountIdentityKey() ?? "anonymous";
return serializeProjectSave(`${account}:${id}`, async () => {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
keepalive: options.keepalive,
signal: options.signal,
body: JSON.stringify({
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || null,
agents: projectAgents(snapshot),
messages: [snapshotEnvelope(snapshot)],
expected_updated_at: options.expectedUpdatedAt ?? undefined,
}),
});
if (!response.ok) {
const message = await readError(response);
if (response.status === 409) {
throw new StrategyProjectConflictError(message);
}
throw new Error(message);
}
const project = toProject((await response.json()) as ParallelTaskResponse);
if (!project) throw new Error("项目已保存,但返回数据无法识别");
return project;
});
}
export async function deleteStrategyProject(id: string): Promise<void> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "DELETE",
});
if (!response.ok && response.status !== 404) {
throw new Error(await readError(response));
}
void clearStrategyProjectWal(
getAccountIdentityKey() ?? "anonymous",
id,
).catch(() => undefined);
}
@@ -1,112 +0,0 @@
import type { StrategyAngle } from "./types";
export type StrategyAgentCategory =
| "intent"
| "angle"
| "judge"
| "solution";
export interface StrategyAgent {
id: string;
category: StrategyAgentCategory;
fixed: boolean;
enabled: boolean;
sort_order: number;
name: string;
description: string;
model: string | null;
soul: string | null;
skills: string[] | null;
created_at: string | null;
updated_at: string | null;
}
const STRATEGY_ANGLE_PREFIX = "strategy-selection-angle-";
const LEGACY_ANGLE_IDS: Record<string, string> = {
"strategy-selection-angle-governance": "governance",
"strategy-selection-angle-diplomacy": "diplomacy",
"strategy-selection-angle-economy": "economy",
"strategy-selection-angle-security": "security",
"strategy-selection-angle-public-opinion": "public_opinion",
"strategy-selection-angle-law-policy": "law_policy",
};
const LEGACY_SHORT_NAMES: Record<string, string> = {
"strategy-selection-angle-governance": "治理",
"strategy-selection-angle-diplomacy": "外交",
"strategy-selection-angle-economy": "经济",
"strategy-selection-angle-security": "安全",
"strategy-selection-angle-public-opinion": "民生",
"strategy-selection-angle-law-policy": "法律",
};
const LEGACY_ACCENTS: Record<string, string> = {
"strategy-selection-angle-governance": "#6d5dfc",
"strategy-selection-angle-diplomacy": "#0ea5e9",
"strategy-selection-angle-economy": "#10b981",
"strategy-selection-angle-security": "#f97316",
"strategy-selection-angle-public-opinion": "#ec4899",
"strategy-selection-angle-law-policy": "#a855f7",
};
const ACCENT_PALETTE = [
"#2563eb",
"#0891b2",
"#059669",
"#ca8a04",
"#ea580c",
"#dc2626",
"#db2777",
"#7c3aed",
] as const;
function stableAccent(id: string) {
if (LEGACY_ACCENTS[id]) return LEGACY_ACCENTS[id];
let hash = 2166136261;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length];
}
function displayAngleName(name: string) {
const cleaned = name
.trim()
.replace(/^策略(?:分析)?视角[·•::\s-]*/, "")
.replace(/(?:分析)?智能体$/, "")
.trim();
return cleaned || name.trim() || "未命名视角";
}
/** Map the public API shape to the workbench's stable angle representation. */
export function strategyAgentToAngle(
agent: StrategyAgent,
): StrategyAngle | null {
if (agent.category !== "angle") return null;
const suffix = agent.id.startsWith(STRATEGY_ANGLE_PREFIX)
? agent.id.slice(STRATEGY_ANGLE_PREFIX.length)
: agent.id;
const name = displayAngleName(agent.name);
return {
id: LEGACY_ANGLE_IDS[agent.id] ?? suffix,
agentId: agent.id,
name,
shortName:
LEGACY_SHORT_NAMES[agent.id] ??
name.split(/[与和及、·/]/u, 1)[0].slice(0, 6),
description: agent.description,
accent: stableAccent(agent.id),
enabled: agent.enabled,
};
}
export function strategyAgentsToAngles(
agents: StrategyAgent[],
): StrategyAngle[] {
return agents.flatMap((agent) => {
const angle = strategyAgentToAngle(agent);
return angle ? [angle] : [];
});
}
@@ -1,66 +0,0 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/strategy-agents.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-agents.test.mjs
* node --test .tmp-tests/strategy-agents.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
strategyAgentToAngle,
strategyAgentsToAngles,
type StrategyAgent,
} from "./strategy-agent-mapping";
function agent(overrides: Partial<StrategyAgent> = {}): StrategyAgent {
return {
id: "strategy-selection-angle-public-opinion",
category: "angle",
fixed: false,
enabled: true,
sort_order: 100,
name: "策略视角·社会民生",
description: "关注公共服务连续性",
model: null,
soul: null,
skills: null,
created_at: null,
updated_at: null,
...overrides,
};
}
test("keeps legacy project ids while accepting the public redacted shape", () => {
assert.deepEqual(strategyAgentToAngle(agent()), {
id: "public_opinion",
agentId: "strategy-selection-angle-public-opinion",
name: "社会民生",
shortName: "民生",
description: "关注公共服务连续性",
accent: "#ec4899",
enabled: true,
});
});
test("derives stable dynamic ids and accents from the reserved prefix", () => {
const dynamic = agent({
id: "strategy-selection-angle-abc123",
name: "科技与产业链智能体",
});
const first = strategyAgentToAngle(dynamic);
const second = strategyAgentToAngle({ ...dynamic });
assert.equal(first?.id, "abc123");
assert.equal(first?.shortName, "科技");
assert.equal(first?.accent, second?.accent);
});
test("filters fixed workflow roles out of the angle catalog", () => {
assert.equal(
strategyAgentsToAngles([
agent(),
agent({ id: "strategy-selection-intent", category: "intent" }),
]).length,
1,
);
});
@@ -1,128 +0,0 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import {
strategyAgentsToAngles,
type StrategyAgent,
} from "./strategy-agent-mapping";
export {
strategyAgentToAngle,
strategyAgentsToAngles,
} from "./strategy-agent-mapping";
export type {
StrategyAgent,
StrategyAgentCategory,
} from "./strategy-agent-mapping";
export interface CreateStrategyAngleInput {
name: string;
description?: string;
soul?: string;
model?: string | null;
skills?: string[];
sort_order?: number;
}
export interface UpdateStrategyAgentInput {
name?: string;
description?: string;
soul?: string;
model?: string | null;
skills?: string[];
enabled?: boolean;
sort_order?: number;
}
export const STRATEGY_AGENTS_QUERY_KEY = ["strategy-agents"] as const;
function endpoint(suffix = "") {
return `${getBackendBaseURL()}/api/strategy-agents${suffix}`;
}
async function responseError(response: Response) {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
export async function listStrategyAgents(options?: {
includeDisabled?: boolean;
includeConfig?: boolean;
}): Promise<StrategyAgent[]> {
const query = new URLSearchParams();
if (options?.includeDisabled) query.set("include_disabled", "true");
if (options?.includeConfig) query.set("include_config", "true");
const response = await apiFetch(
`${endpoint()}${query.size ? `?${query.toString()}` : ""}`,
);
if (!response.ok) throw new Error(await responseError(response));
const body = (await response.json()) as { agents?: StrategyAgent[] };
return body.agents ?? [];
}
export async function createStrategyAngle(
input: CreateStrategyAngleInput,
): Promise<StrategyAgent> {
const response = await apiFetch(endpoint("/angles"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export async function updateStrategyAgent(
id: string,
input: UpdateStrategyAgentInput,
): Promise<StrategyAgent> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export async function disableStrategyAngle(id: string): Promise<StrategyAgent> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "DELETE",
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export function useStrategyAgents(options?: {
enabled?: boolean;
includeDisabled?: boolean;
includeConfig?: boolean;
}) {
const includeDisabled = options?.includeDisabled ?? false;
const includeConfig = options?.includeConfig ?? false;
return useQuery({
queryKey: [
...STRATEGY_AGENTS_QUERY_KEY,
includeDisabled,
includeConfig,
],
queryFn: () => listStrategyAgents({ includeDisabled, includeConfig }),
enabled: options?.enabled ?? true,
staleTime: 30_000,
});
}
/** Reusable active-angle query for the workbench and project creation flow. */
export function useStrategyAngleCatalog(enabled = true) {
const query = useStrategyAgents({ enabled });
const angles = useMemo(
() => strategyAgentsToAngles(query.data ?? []),
[query.data],
);
return { ...query, angles };
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more