Compare commits

..
1 Commits
Author SHA1 Message Date
gozeros 1d5dd8fc90 sync update from LAN repo (source master-nj @ 067eb808) 2026-11-10 14:04:09 +08:00
220 changed files with 18801 additions and 11719 deletions

No files matched your search

+6 -3
View File
@@ -100,9 +100,11 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py !offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned. # 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.
!offline-backend-20260512/backend/tests/test_fixed_questions.py !offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned. # Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py !offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
@@ -110,6 +112,7 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py !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_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.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_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
+1
View File
@@ -83,6 +83,7 @@ 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) - **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. - **定时任务「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`. - **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). - **轻应用中心 (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`. - **菜单管理 (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`. - **白名单管理 (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`.
@@ -1,186 +0,0 @@
# 报告协作工作台 — 交互重设计方案(v2)
> 日期:2026-09-07
> 状态:**待确认**(确认后按 §7 分期实施)
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
---
## 1. 设计目标与总体形态
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
```
┌─────────────────────────────────────────────────────────┐
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
│ └───────────┘ └───────────┘ └──────────┘ │
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
└─────────────────────────────────────────────────────────┘
```
与现行的核心差异:
| 维度 | 现行(做偏的) | v2 |
|------|----------------|-----|
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
---
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
### 2.1 计划模式状态
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
### 2.2 规划师对话(LLM,替换现行规则路由)
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
### 2.3 需求摘要卡
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
### 2.4 工作流生成与迭代
- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
---
## 3. 阶段 2:执行 — 过程在流程图上可见
### 3.1 流程图动画规格(执行状态的可视化主载体)
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
| 状态 | 节点样式 | 连入边 |
|------|----------|--------|
| planned / queued | 灰色常态 | 静态 |
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
| failed | 红色 + 错误角标 | 红色虚线 |
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
| interrupted / superseded | 半透明降级 | 灰 |
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
### 3.2 聊天区(保留现有 Tab 切换)
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
---
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
### 4.1 输入框常开,协调者智能体分流
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
| 用户意图 | 协调者行为 | 生效时机 |
|----------|-----------|----------|
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
| 停止 | 直接取消 | 立即 |
### 4.2 指定智能体重跑(新增核心交互)
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
两个入口,同一后端动作:
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
**级联规则**(复用并补全 ledger 的 targeted rework):
```
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
→ 受影响章节重新生成
```
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
---
## 5. 阶段 4:报告成稿与对话式修改
### 5.1 成稿
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
### 5.2 修改意图分流(协调者 LLM 判定)
| 用户说 | 判定 | 动作 |
|--------|------|------|
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
---
## 6. 工程改造清单
### 6.1 保留(本次联调已验证)
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
- 「清空历史」「生成候选方案」等本次新增入口。
### 6.2 后端重做 / 新建
| 项 | 说明 |
|----|------|
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
### 6.3 前端重做 / 新建
| 项 | 说明 |
|----|------|
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
---
## 7. 实施分期
| 期 | 内容 | 验收 |
|----|------|------|
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
+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_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 # 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
+35
View File
@@ -0,0 +1,35 @@
# 项目空间
入口:全量与简洁导航中的「项目空间」,路由 `/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,6 +51,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
@@ -69,6 +73,7 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname; const isActive = href === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && ( {pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" /> <Pin className="text-muted-foreground/70 size-3 shrink-0" />
)} )}
<span className="truncate"> <span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -152,6 +152,7 @@ export function AgentDetailPanel({
positionName, positionName,
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
positionName?: string; positionName?: string;
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
/> />
</Section> </Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />} {showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div> </div>
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks"; import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => { {threads.map((thread) => {
const href = pathOfThread(thread); const href = pathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground", : "text-muted-foreground hover:bg-accent hover:text-foreground",
)} )}
> >
<MessageSquareIcon {projectType ? (
className={cn( <MyProjectThreadIcon type={projectType} />
"size-3.5 shrink-0", ) : (
isActive ? "text-primary" : "text-muted-foreground/70", <MessageSquareIcon
)} className={cn(
/> "size-3.5 shrink-0",
<span className="truncate"> isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, "新对话"))} {desensitize(titleOfThread(thread, "新对话"))}
</span> </span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link> </Link>
); );
})} })}
@@ -83,10 +83,12 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({ }> = ({
children, children,
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
writingMode,
}) => { }) => {
const { const {
isMock, isMock,
@@ -114,7 +116,7 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode; const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -60,6 +60,10 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; 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 { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -75,6 +79,7 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
); );
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span className="truncate"> <span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -2,7 +2,6 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
@@ -143,7 +142,6 @@ export function InputBox({
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -151,14 +149,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean; compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -167,7 +160,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -184,7 +176,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -397,14 +388,6 @@ export function InputBox({
}); });
}, [artifactDirectoryModeEnabled, setLocalSettings]); }, [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 ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
@@ -813,32 +796,6 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </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 {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
@@ -373,6 +373,8 @@ export function LlmWikiKnowledgeSelector({
readOnly = false, readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover", variant = "popover",
}: { }: {
value: string[]; value: string[];
@@ -387,6 +389,8 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -474,10 +478,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3"> <div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间 <Database className="size-4 text-primary" />{panelTitle}
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。 {panelDescription}
</p> </p>
</div> </div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge> <Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className, className,
triggerClassName, triggerClassName,
defaultLabel = "默认模型", defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: { }: {
value: string | null; value: string | null;
onChange: (modelName: string | null) => void; onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string; className?: string;
triggerClassName?: string; triggerClassName?: string;
defaultLabel?: string; defaultLabel?: string;
title?: string;
}) { }) {
const { models } = useModels(); const { models } = useModels();
return ( return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
> >
<SelectTrigger <SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)} className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)" title={title}
> >
<SelectValue placeholder={defaultLabel} /> <SelectValue placeholder={defaultLabel} />
</SelectTrigger> </SelectTrigger>
@@ -0,0 +1,51 @@
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,7 +49,11 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -63,7 +67,8 @@ import {
useToggleThreadFavorite, useToggleThreadFavorite,
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -245,7 +250,8 @@ export function RecentChatList({
{shownThreads.map((thread) => { {shownThreads.map((thread) => {
const href = listPathOfThread(thread); const href = listPathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -279,8 +285,10 @@ export function RecentChatList({
} }
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : ( ) : projectType ? (
compact && ( <MyProjectThreadIcon type={projectType} />
) : (
compact && (
<MessageSquare <MessageSquare
className={ className={
"size-3.5 shrink-0 " + "size-3.5 shrink-0 " +
@@ -294,9 +302,15 @@ export function RecentChatList({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span className="truncate"> <span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -0,0 +1,67 @@
"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"; "use client";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react"; import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -61,15 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
// }, // },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, // { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, // { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
// 记忆已移至侧边栏入口 (/page/workspace/memory) ...(isAdmin
// { ? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
// id: "memory", : []),
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []), : []),
{ {
id: "account", id: "account",
@@ -132,7 +129,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && ( {activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} /> <AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)} )}
{activeSection === "memory" && <MemorySettingsPage />} {activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />} {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides, applyCompactMenuOverrides,
type CompactMenuItem, type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides"; } from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu"; import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides"; import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -51,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
}; };
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ 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", id: "workspace-new-chat",
label: "新对话", label: "新对话",
@@ -65,6 +75,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats", path: "/page/workspace/chats",
match: (pathname) => pathname === "/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", id: "workspace-library",
label: "资料库", label: "资料库",
@@ -86,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills", path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/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", id: "workspace-ai-writing",
label: "AI写作", label: "AI写作",
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/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),
}, },
]; ];
@@ -35,6 +35,7 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = const isGeneralQa =
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") || pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats"); pathname.startsWith("/page/strategy/chats");
@@ -0,0 +1,137 @@
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 }),
});
@@ -0,0 +1,141 @@
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;
}
+21 -11
View File
@@ -364,12 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return null; return null;
} }
if ( if (
message.additional_kwargs && message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs "reasoning_content" in message.additional_kwargs
) { ) {
return message.additional_kwargs.reasoning_content as string | null; 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 (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
if (part && "thinking" in part) { if (part && "thinking" in part) {
@@ -385,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
export function removeReasoningContentFromMessage(message: Message) { export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) { if (message.type !== "ai" || !message.additional_kwargs) {
return; return;
} }
delete message.additional_kwargs.reasoning_content; delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
} }
export function extractURLFromImageURLContent( export function extractURLFromImageURLContent(
@@ -421,9 +428,12 @@ export function hasReasoning(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return false; return false;
} }
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
@@ -21,6 +21,7 @@ import {
FileBox, FileBox,
FileSearch, FileSearch,
FileText, FileText,
FolderKanban,
Gauge, Gauge,
Grid3X3, Grid3X3,
HelpCircle, HelpCircle,
@@ -48,12 +49,23 @@ export type MenuItem = {
path?: string; path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */ /** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[]; aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string; userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
children?: MenuItem[]; 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[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
@@ -61,6 +73,7 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { 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: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{ {
id: "multi-agent-roundtable", id: "multi-agent-roundtable",
@@ -68,6 +81,13 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users, icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable", 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", // id: "position-roundtable",
// label: "岗位会商", // label: "岗位会商",
@@ -258,12 +278,7 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { 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", id: "mgmt-tasks",
label: "定时任务", label: "定时任务",
@@ -330,6 +345,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
}, },
{ {
id: "resource-knowledge-base", id: "resource-knowledge-base",
@@ -365,6 +381,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */ /** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean { 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; if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false; return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
} }
@@ -13,6 +13,44 @@ export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora"; 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> { export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`); const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置"); if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
@@ -563,6 +601,48 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>; 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> { export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`, `${getBackendBaseURL()}/api/system-settings/skill-compression`,
@@ -8,30 +8,36 @@ import {
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings, loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings, loadSkillCompressionSettings,
loadStrategyProjectSettings,
updateAgentModuleConfig, updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings, updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings, updateSkillCompressionSettings,
updateStrategyProjectSettings,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate, type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate, type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -44,7 +50,30 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] 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 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() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -86,6 +115,26 @@ 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 configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */ * the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() { export function usePublishSquares() {
+9 -4
View File
@@ -886,8 +886,15 @@ export function useThreadStream({
// Move to a fresh chat without a full browser reload; the old reload path // 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 // made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token. // different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel(); 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();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
); );
@@ -1591,8 +1598,6 @@ export function useThreadStream({
: mergedContext.mode === "thinking" : mergedContext.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled, summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
@@ -0,0 +1,46 @@
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);
}
});
@@ -0,0 +1,94 @@
/**
* 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,7 +19,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
+24 -6
View File
@@ -45,10 +45,17 @@ export function pathOfThread(
base = "/page/workspace", base = "/page/workspace",
) { ) {
const threadId = typeof thread === "string" ? thread : thread.thread_id; 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 = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: agentRefOfThread(thread) ?? pickAgentRef(context, null); : (agentRefOfThread(thread) ?? pickAgentRef(context, null));
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}): boolean { }): boolean {
const threadType = thread.metadata?.thread_type; const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup"; return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
} }
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ /** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata; const metadata = thread.metadata;
if (metadata?.system === true) return true; if (metadata?.system === true) return true;
const threadType = metadata?.thread_type; 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 过滤时,不要再套一层「最近对话」隐藏。 */ /** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -158,11 +170,15 @@ export function listPathOfThread(thread: AgentThread): string {
} }
export function isThreadPinned(thread: AgentThread): boolean { 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 { 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
);
} }
/** /**
@@ -179,7 +195,9 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest]; return [...pinned, ...rest];
} }
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) { export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
if (!title) return "Untitled"; if (!title) return "Untitled";
const cleaned = title const cleaned = title
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
id: string; id: string;
} }
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/** /**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
} }
function findProcessReportId(payload: unknown): string { function findProcessReportId(payload: unknown): string {
if (!payload || typeof payload !== "object") return ""; if (payload == null) return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
const source = payload as Record<string, unknown>; const source = payload as Record<string, unknown>;
const direct = firstText( const direct = firstText(
source.id, source.id,
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
} }
/** /**
* Create the external task system's process report in its unsubmitted state. * Create the external task system's process report **and submit it in one
* The local gateway stores the returned id so a refresh can later submit the * call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* same report instead of creating a duplicate one. * `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.
*/ */
export async function createEightBfTaskProcessReport( export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: processReportBody(input),
}); });
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null)); const id = findProcessReportId(await response.json().catch(() => null));
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
return { id }; return { id };
} }
/** Submit an already-registered process report to the external task system. */ /** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
export async function updateEightBfTaskProcessReport( export async function updateEightBfTaskProcessReport(
reportId: string, reportId: string,
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: processReportBody(input),
}, },
); );
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -32,6 +32,7 @@ import {
} from "@/position-roundtable/lib/positions"; } from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent"; import { 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 type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css"; import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string; selectedModel: string;
modelOptions: Array<{ label: string; value: string }>; modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void; onSelectedModelChange: (model: string) => void;
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
selectedModel, selectedModel,
modelOptions, modelOptions,
onSelectedModelChange, onSelectedModelChange,
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
chain={chain} chain={chain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain} onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)} onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues} step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds} step2ThreadIds={step2ThreadIds}
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
onCollapse, onCollapse,
step2Dialogues, step2Dialogues,
step2ThreadIds, step2ThreadIds,
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void; onCollapse: () => void;
step2Dialogues: unknown[]; step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null; step2ThreadIds: ThreadIdMap | null;
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""} jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />} skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表" skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions showCommonQuestions
panelTitle="智能体配置" panelTitle="智能体配置"
embedded embedded
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
} }
return []; 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,14 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries), () => buildEightBfTaskContext(task, taskDictionaries),
[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>( const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? { () => taskId ? {
taskId, taskId,
position: 0, 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, createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport, updateProcessReport: updateEightBfTaskProcessReport,
} : undefined, } : undefined,
[taskId], [taskId, seatArtifactPositions],
); );
useEffect(() => { useEffect(() => {
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true); if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]); }, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = ( const toolbarSlot = (
<> <>
{canConfigureChain && isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain} chain={configuredChain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings} onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/> />
)} )}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => ( renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} }
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig} taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/> />
</div> </div>
); );
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing'; const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体'; const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`; const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
const SHOW_NOTEBOOK_SPACE_ENTRIES = true; const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
-247
View File
@@ -1,247 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Bot } from "lucide-react";
import { useRouter } from "next/navigation";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import "@/styles/agents-orbit.css";
import "@/styles/agents-orbit-dark.css";
import "@/styles/agents-orbit-light.css";
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
const DIAL_TRANSITION_MS = 850;
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
interface AgentGroup {
id: string;
name: string;
agents: Agent[];
}
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
const map = new Map<string, AgentGroup>();
for (const agent of agents) {
const tag = agent.tags?.[0];
const id = tag?.id ?? "__uncategorized__";
let group = map.get(id);
if (!group) {
group = { id, name: tag?.name ?? "未分类", agents: [] };
map.set(id, group);
}
group.agents.push(agent);
}
return Array.from(map.values()).sort((a, b) =>
a.name.localeCompare(b.name, "zh-Hans-CN"),
);
}
const mod = (value: number, n: number): number => ((value % n) + n) % n;
/**
* 密码转盘模型:
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
*/
export default function AgentsOrbitPage() {
const router = useRouter();
const { theme } = useTheme();
const isDark = themeToBaseMode(theme) === "dark";
const { agents, isLoading } = useAgents();
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
const [activeId, setActiveId] = useState<string | null>(null);
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
const [anim, setAnim] = useState<Record<string, number> | null>(null);
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
const [frozen, setFrozen] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
};
}, []);
if (isLoading) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<div className="ao-spinner" />
<span>正在加载智能体…</span>
</div>
</div>
);
}
if (groups.length === 0) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<span>还没有可展示的智能体</span>
</div>
</div>
);
}
const n = groups.length;
const midSlot = Math.floor((n - 1) / 2);
const step = 180 / n;
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
const requestedIdx = activeId
? groups.findIndex((g) => g.id === activeId)
: -1;
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
const active = groups[activeIdx];
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
const shift = midSlot - activeIdx;
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
const handleTypeClick = (slot: number) => {
const group = groupAtSlot(slot);
if (group.id === active.id) return;
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
const angles: Record<string, number> = {};
for (let j = 0; j < n; j++) {
const g = groupAtSlot(j);
const nextSlot = j + k;
let angle = slotAngle(mod(nextSlot, n));
if (nextSlot >= n) {
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
} else if (nextSlot < 0) {
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
}
angles[g.id] = angle;
}
setActiveId(group.id);
setAnim(angles);
if (timerRef.current != null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
setFrozen(true);
setAnim(null);
requestAnimationFrame(() =>
requestAnimationFrame(() => setFrozen(false)),
);
}, DIAL_TRANSITION_MS + 60);
};
const handleAgentClick = (agent: Agent) => {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
};
const themeClass = isDark ? "ao-dark" : "ao-light";
return (
<div
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
>
<div className="ao-header">
<span className="ao-header-title">智能体 · 星轨转盘</span>
<button
type="button"
className="ao-header-link"
onClick={() => router.push("/page/workspace/agents")}
>
经典视图
</button>
</div>
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
<div className="ao-stage">
<div className="ao-root">
<div className="ao-ring ao-ring--ticks" />
<div className="ao-ring ao-ring--dash" />
<div className="ao-ring ao-ring--arc" />
<div className="ao-sat-orbit">
<div className="ao-sat" />
</div>
<div className="ao-core">
<div className="ao-core-img">
<Bot size={60} strokeWidth={1.4} />
</div>
</div>
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
<div className="ao-types">
{groups.map((group, gi) => {
const slot = mod(gi + shift, n);
const angle = anim
? (anim[group.id] ?? slotAngle(slot))
: slotAngle(slot);
const isActive = group.id === active.id;
const transitionStyle = frozen ? { transition: "none" } : {};
return (
<div
key={group.id}
className="ao-type-arm"
style={{
transform: `rotate(${angle}deg)`,
...transitionStyle,
}}
>
<button
type="button"
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
style={{
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
...transitionStyle,
}}
onClick={() => handleTypeClick(slot)}
title={`${group.name} · ${group.agents.length} 个智能体`}
>
<span
className="ao-type-chip"
style={{ animationDelay: `${-gi * 0.8}s` }}
>
{group.name}
</span>
</button>
</div>
);
})}
</div>
</div>
</div>
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
<aside className="ao-panel">
<div className="ao-panel-head">
<div className="ao-panel-title">{active.name}</div>
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
</div>
{active.agents.length > 0 ? (
<div className="ao-agents" key={active.id}>
{active.agents.map((agent) => (
<button
key={agent.id}
type="button"
className="ao-agent"
onClick={() => handleAgentClick(agent)}
title={agent.description || agent.name}
>
<span className="ao-agent-icon">
{(agent.name || "?").trim().charAt(0).toUpperCase()}
</span>
<span className="ao-agent-name">{agent.name}</span>
</button>
))}
</div>
) : (
<div className="ao-state">
<span>该类型下暂时没有智能体</span>
</div>
)}
</aside>
</div>
);
}
@@ -0,0 +1,808 @@
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>
);
}
+28 -14
View File
@@ -64,6 +64,10 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; 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 { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -285,16 +289,28 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); 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({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: streamContext, context: streamContext,
threadMetadata: knowledgeSelectionReady threadMetadata: newThreadMetadata,
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock, isMock,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId; knowledgeSelectionThread.current = createdThreadId;
@@ -405,7 +421,9 @@ export default function ChatPage() {
try { try {
let targetThreadId = threadId; let targetThreadId = threadId;
if (isNewThread) { if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({}); const created = await getAPIClient(isMock).threads.create({
metadata: newThreadMetadata ?? {},
});
targetThreadId = created.thread_id; targetThreadId = created.thread_id;
setThreadId(targetThreadId); setThreadId(targetThreadId);
setIsNewThread(false); setIsNewThread(false);
@@ -465,13 +483,8 @@ export default function ChatPage() {
try { try {
const created = await getAPIClient(isMock).threads.create({ const created = await getAPIClient(isMock).threads.create({
metadata: { metadata: {
...newThreadMetadata,
agent_id: selectedAgentId, agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}, },
}); });
sessionStorage.setItem( sessionStorage.setItem(
@@ -490,7 +503,7 @@ export default function ChatPage() {
); );
saveLastAgentId(selectedAgentId); saveLastAgentId(selectedAgentId);
router.push( router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`, `${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
); );
} catch (err) { } catch (err) {
agentHandoffPendingRef.current = false; agentHandoffPendingRef.current = false;
@@ -549,6 +562,7 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds, selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds, selectedKnowledgeBaseIds,
knowledgeSelectionReady, knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending, agentHandoffPending,
isMock, isMock,
router, router,
-18
View File
@@ -2,7 +2,6 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
ShrinkIcon, ShrinkIcon,
@@ -400,13 +399,6 @@ export default function IframeChatPage() {
}); });
}, [setSettings, settings.context.writing_mode]); }, [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(() => { const toggleRagMode = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
@@ -531,9 +523,6 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({ const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId, agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode, writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -555,13 +544,6 @@ export default function IframeChatPage() {
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700", 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": case "references":
return { return {
...button, ...button,
@@ -5,10 +5,9 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
Clock3, Clock3,
GitBranch, FolderKanban,
LoaderCircle, LoaderCircle,
MessageSquareText, MessageSquareText,
Network,
PanelLeftClose, PanelLeftClose,
Pause, Pause,
Play, Play,
@@ -22,7 +21,12 @@ import {
} from "lucide-react"; } from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react"; import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; 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 { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth"; import { getAccountDisplayName } from "@/core/auth";
import { import {
@@ -31,36 +35,33 @@ import {
useScheduledTaskRuns, useScheduledTaskRuns,
useScheduledTasks, useScheduledTasks,
} from "@/core/scheduled-tasks"; } from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import { import {
useAppearanceSettings, useAppearanceSettings,
useCitationDisplaySettings, useCitationDisplaySettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/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 { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { import {
OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX, OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes"; } from "@/core/official-deerflow/routes";
import { Toaster } from "@/components/ui/sonner"; import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage";
import TaskEditPage from "./official-multi-agent-tasks/TaskEditPage";
import TaskAnalysisPage from "./official-task-analysis/TaskAnalysisPage";
import TaskAnalysisDetailPage from "./official-task-analysis/TaskAnalysisDetailPage";
import "./official-deerflow-entry.css"; import "./official-deerflow-entry.css";
type OfficialPage = type OfficialPage =
| "chat" | "chat"
| "projects"
| "agents" | "agents"
| "agent-chat" | "agent-chat"
| "scheduled" | "scheduled"
| "multi-agent-tasks"
| "multi-agent-edit"
| "task-analysis"
| "task-analysis-detail"
| "settings"; | "settings";
function pageFromPath(pathname: string): OfficialPage { function pageFromPath(pathname: string): OfficialPage {
@@ -68,15 +69,9 @@ function pageFromPath(pathname: string): OfficialPage {
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) { if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat"; return "agent-chat";
} }
if (path.startsWith("/workspace/projects")) return "projects";
if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/multi-agent-tasks/new")) return "multi-agent-edit";
if (/^\/workspace\/multi-agent-tasks\/[^/]+\/edit$/.test(path)) {
return "multi-agent-edit";
}
if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks";
if (/^\/workspace\/task-analysis\/[^/]+/.test(path)) return "task-analysis-detail";
if (path.startsWith("/workspace/task-analysis")) return "task-analysis";
if (path.startsWith("/workspace/settings")) return "settings"; if (path.startsWith("/workspace/settings")) return "settings";
return "chat"; return "chat";
} }
@@ -102,33 +97,11 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null; return match ? decodeURIComponent(match[1]!) : null;
} }
function parseMultiAgentTaskId(pathname: string) { function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/); const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
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; return match ? decodeURIComponent(match[1]!) : null;
} }
function parseMultiAgentEditTarget(pathname: string): {
mode: "create" | "edit";
taskId: string | null;
} {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/multi-agent-tasks/new")) {
return { mode: "create", taskId: null };
}
const match = path.match(/^\/workspace\/multi-agent-tasks\/([^/]+)\/edit$/);
return match
? { mode: "edit", taskId: decodeURIComponent(match[1]!) }
: { mode: "create", taskId: null };
}
function readableError(error: unknown, fallback: string) { function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
@@ -210,20 +183,36 @@ function ChatWorkspace({
routeThreadId: string; routeThreadId: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new"; 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 // A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts. // existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string); const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId; const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId }); const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId, threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined, assistantId: agentId ?? undefined,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: { context: {
...localSettings.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,
...(agentId ? { agent_id: agentId } : {}), ...(agentId ? { agent_id: agentId } : {}),
}, },
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
@@ -709,16 +698,8 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname), () => parseScheduledTaskId(location.pathname),
[location.pathname], [location.pathname],
); );
const multiAgentTaskId = useMemo( const projectId = useMemo(
() => parseMultiAgentTaskId(location.pathname), () => parseProjectId(location.pathname),
[location.pathname],
);
const multiAgentEditTarget = useMemo(
() => parseMultiAgentEditTarget(location.pathname),
[location.pathname],
);
const taskAnalysisId = useMemo(
() => parseTaskAnalysisId(location.pathname),
[location.pathname], [location.pathname],
); );
const { data: threads = [], isLoading: threadsLoading, error: threadsError } = const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
@@ -729,14 +710,14 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"], select: ["thread_id", "updated_at", "values", "metadata"],
}); });
const go = (path: string) => navigate(path); const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
const nav = [ const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] }, { icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
{ icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks", "multi-agent-edit"] },
{ icon: GitBranch, label: "任务分析定时", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`, pages: ["task-analysis", "task-analysis-detail"] },
] as const; ] as const;
return ( return (
@@ -771,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? ( ) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p> <p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? ( ) : threads.length ? (
threads.map((chat) => ( threads.map((chat) => {
<button const projectType = myProjectConversationTypeOfThread(chat);
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`} return (
key={chat.thread_id} <button
onClick={() => { className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
const agentId = typeof chat.context?.agent_id === "string" key={chat.thread_id}
? chat.context.agent_id onClick={() => {
: typeof chat.metadata?.agent_id === "string" const agentId = typeof chat.context?.agent_id === "string"
? chat.metadata.agent_id ? chat.context.agent_id
: null; : typeof chat.metadata?.agent_id === "string"
go( ? chat.metadata.agent_id
agentId : null;
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}` go(
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`, 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} /> type="button"
<span>{titleOfThread(chat, "未命名对话")}</span> >
<small>{formatTimestamp(chat.updated_at)}</small> {projectType ? (
</button> <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>
);
})
) : ( ) : (
<p className="official-sidebar-empty">暂无历史对话</p> <p className="official-sidebar-empty">暂无历史对话</p>
)} )}
@@ -819,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId} routeThreadId={chatRoute.threadId}
/> />
)} )}
{page === "projects" && (
projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "agents" && ( {page === "agents" && (
<AgentsPage <AgentsPage
openAgent={(agentId) => openAgent={(agentId) =>
@@ -834,20 +840,6 @@ export default function OfficialDeerFlowEntry() {
/> />
)} )}
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />} {page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "multi-agent-tasks" && (
<MultiAgentTasksPage routeTaskId={multiAgentTaskId} />
)}
{page === "multi-agent-edit" && (
<TaskEditPage
key={multiAgentEditTarget.taskId ?? "create"}
mode={multiAgentEditTarget.mode}
taskId={multiAgentEditTarget.taskId}
/>
)}
{page === "task-analysis" && <TaskAnalysisPage />}
{page === "task-analysis-detail" && (
<TaskAnalysisDetailPage routeTaskId={taskAnalysisId} />
)}
{page === "settings" && <SettingsPage />} {page === "settings" && <SettingsPage />}
</div> </div>
<Toaster position="top-center" /> <Toaster position="top-center" />
+22 -3
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
@@ -62,7 +62,6 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage"; import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -78,6 +77,8 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes"; import { ChatRuntime } from "./WorkspaceRoutes";
/** /**
@@ -98,6 +99,11 @@ function StrategyChatThreadLayout() {
); );
} }
function StrategyProjectWorkbenchRoute() {
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
return <StrategySelectionPage key={projectId} projectId={projectId} />;
}
export default function StrategyRoutes() { export default function StrategyRoutes() {
const storedAuth = getStoredAuth(); const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -110,6 +116,20 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} /> <Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} /> <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 <Route
path="qa/expert-qa" path="qa/expert-qa"
element={ element={
@@ -402,7 +422,6 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route <Route
path="business-chain-mapping" path="business-chain-mapping"
element={ element={
+12 -22
View File
@@ -35,11 +35,11 @@ import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils"; import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage"; import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage"; import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import AgentsOrbitPage from "./AgentsOrbitPage";
import AgentChatPage from "./AgentChatPage"; import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage"; import SentimentAgentChatPage from "./SentimentAgentChatPage";
import NewAgentPage from "./NewAgentPage"; import NewAgentPage from "./NewAgentPage";
@@ -56,7 +56,6 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage"; import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage"; import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage"; import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
@@ -131,7 +130,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
return ( return (
pathname === "/page/workspace/agents" || pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" || pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" || pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" || pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/") pathname.startsWith("/page/workspace/knowledge/")
@@ -192,7 +190,9 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname; const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/workspace/projects");
const showCompactTopActions = const showCompactTopActions =
headerHidden && headerHidden &&
!compactActionsHidden && !compactActionsHidden &&
@@ -549,6 +549,14 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace"> <RouteBaseProvider value="/page/workspace">
<Routes> <Routes>
<Route index element={<Navigate to="chats/new" replace />} /> <Route index element={<Navigate to="chats/new" replace />} />
<Route
path="projects/*"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
<AnalysisProjectsPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="chats" path="chats"
element={ element={
@@ -621,16 +629,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
<Route
path="agents/orbit"
element={
<WorkspaceLayout {...layoutProps}>
<AgentsOrbitPage />
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发 {/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */} 外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route <Route
@@ -695,14 +693,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="ai-writing/*" path="ai-writing/*"
element={ element={
@@ -0,0 +1,242 @@
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>
);
}
@@ -0,0 +1,215 @@
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,10 +175,12 @@ export function WorkflowConfigDialog({
appliedContent, appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */ /** 点击卡片选择结构时回填内容与结构约束。 */
onApply, onApply,
inlineCreate = false,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
appliedContent?: string; appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) { }) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -400,6 +402,11 @@ export function WorkflowConfigDialog({
<Button <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" 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={() => { onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false); onOpenChange(false);
const base = location.pathname.replace(/\/+$/, ""); const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`); navigate(`${base}/structures/new`);
@@ -1192,6 +1199,7 @@ export function WorkflowConfigButton({
variant = "outline", variant = "outline",
size = "sm", size = "sm",
disabled = false, disabled = false,
inlineCreate = false,
}: { }: {
appliedContent?: string; appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1199,6 +1207,7 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"]; variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"]; size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean; disabled?: boolean;
inlineCreate?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation(); const location = useLocation();
@@ -1222,6 +1231,7 @@ export function WorkflowConfigButton({
return ( return (
<> <>
<Button <Button
type="button"
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"} aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn( className={cn(
className, className,
@@ -1237,6 +1247,7 @@ export function WorkflowConfigButton({
<span className="max-w-40 truncate">{buttonLabel}</span> <span className="max-w-40 truncate">{buttonLabel}</span>
</Button> </Button>
<WorkflowConfigDialog <WorkflowConfigDialog
inlineCreate={inlineCreate}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
appliedContent={appliedContent} appliedContent={appliedContent}
@@ -4,11 +4,10 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly. // @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts"; import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => { test("does not expose a per-chat memory toggle", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true, referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
@@ -28,27 +27,10 @@ test("hides the memory toggle while retaining the other iframe chat toolbar cont
); );
}); });
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", () => { test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false, referenceModeEntryEnabled: false,
ragModeEnabled: true, ragModeEnabled: true,
ragModeLocked: false, ragModeLocked: false,
@@ -63,7 +45,6 @@ test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: true, agentSelected: true,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true, referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
@@ -2,7 +2,6 @@ export type IframeModeButtonState = {
key: key:
| "agent" | "agent"
| "writing" | "writing"
| "memory"
| "references" | "references"
| "contextCompression" | "contextCompression"
| "pro"; | "pro";
@@ -14,9 +13,6 @@ export type IframeModeButtonState = {
type BuildIframeModeButtonsInput = { type BuildIframeModeButtonsInput = {
agentSelected: boolean; agentSelected: boolean;
writingModeEnabled: boolean; writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean; referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean; ragModeEnabled: boolean;
ragModeLocked: boolean; ragModeLocked: boolean;
@@ -27,8 +23,6 @@ type BuildIframeModeButtonsInput = {
export function buildIframeModeButtons({ export function buildIframeModeButtons({
agentSelected, agentSelected,
writingModeEnabled, writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -41,11 +35,6 @@ export function buildIframeModeButtons({
label: "写作", label: "写作",
active: writingModeEnabled, active: writingModeEnabled,
}, },
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{ {
key: "references", key: "references",
label: "参考文献", label: "参考文献",
@@ -69,8 +58,6 @@ export function buildIframeModeButtons({
}, },
]; ];
return buttons.filter( return buttons.filter(
(button) => (button) => referenceModeEntryEnabled || button.key !== "references",
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
); );
} }
@@ -64,6 +64,8 @@
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; } .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 { 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 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 small { color: #acaca9; font-size: 11px; }
.official-recent-chat.is-active { background: #efefed; color: #252525; } .official-recent-chat.is-active { background: #efefed; color: #252525; }
.official-sidebar-loading, .official-sidebar-loading,
@@ -1,584 +0,0 @@
import {
CalendarClock,
CircleAlert,
CircleCheck,
Clock3,
History,
LoaderCircle,
Network,
Pencil,
Pause,
Play,
Plus,
Search,
Trash2,
Zap,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, DialogPlugin, Drawer, Input, Popconfirm, Switch, Tabs, Tag } from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "./components/AgentFlow";
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
import type { MatRunView, MatTaskView } from "./types";
import {
describeCron,
expandStages,
formatDateTime,
formatDuration,
formatRelative,
runStatusLabel,
taskBadgeOf,
} from "./utils";
import "./multi-agent-tasks.css";
type StatusFilter = "all" | "running" | "enabled" | "paused";
function isTaskLive(task: MatTaskView) {
return task.lastRun?.status === "running" || task.lastRun?.status === "pending";
}
function matchFilter(task: MatTaskView, filter: StatusFilter) {
if (filter === "all") return true;
if (filter === "running") return isTaskLive(task);
if (filter === "paused") return !task.enabled;
return task.enabled && !isTaskLive(task);
}
function runTagTheme(status: MatRunView["status"]): "success" | "danger" | "warning" | "default" {
if (status === "succeeded") return "success";
if (status === "failed") return "danger";
if (status === "running") return "warning";
return "default";
}
export default function MultiAgentTasksPage({
routeTaskId,
}: {
routeTaskId: string | null;
}) {
const navigate = useNavigate();
const { tasks, isLoading } = useMultiAgentTasks();
const actions = useMultiAgentTaskActions();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<StatusFilter>("all");
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null;
const openTask = useCallback(
(taskId: string) => {
setActiveRunId(null);
navigate(
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`,
);
},
[navigate],
);
const closeTask = useCallback(() => {
setActiveRunId(null);
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
}, [navigate]);
const visibleTasks = useMemo(() => {
const keyword = search.trim().toLowerCase();
return tasks.filter((task) => {
if (!matchFilter(task, filter)) return false;
if (!keyword) return true;
return (
task.name.toLowerCase().includes(keyword) ||
(task.description ?? "").toLowerCase().includes(keyword) ||
task.seats.some((seat) => seat.name.toLowerCase().includes(keyword))
);
});
}, [tasks, search, filter]);
const stats = useMemo(() => {
const total = tasks.length;
const enabled = tasks.filter((task) => task.enabled).length;
const running = tasks.filter(isTaskLive).length;
const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0);
return { total, enabled, running, runTotal };
}, [tasks]);
const openCreate = () => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/new`);
};
const openEdit = (task: MatTaskView) => {
navigate(
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(task.id)}/edit`,
);
};
const runTrigger = async (task: MatTaskView) => {
setBusyTaskId(task.id);
try {
await actions.trigger.mutateAsync(task.id);
setActiveRunId(null);
openTask(task.id);
toast.success("已触发执行");
} catch {
toast.error("触发失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const runToggle = async (task: MatTaskView) => {
setBusyTaskId(task.id);
try {
await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled });
toast.success(task.enabled ? "任务已暂停" : "任务已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const confirmDelete = (task: MatTaskView) => {
const dialog = DialogPlugin.confirm({
header: "删除定时任务",
body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`,
confirmBtn: { content: "删除", theme: "danger" },
cancelBtn: "取消",
onConfirm: () => {
dialog.destroy();
void actions.remove.mutateAsync(task.id).then(() => {
if (selectedTask?.id === task.id) closeTask();
toast.success("任务已删除");
});
},
onClose: () => dialog.destroy(),
});
};
const selectedStages = selectedTask ? expandStages(selectedTask) : [];
const selectedRuns = selectedTask?.runs ?? [];
const activeRun =
selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null;
return (
<section className="mat-page">
<header className="mat-page-head">
<div className="mat-page-head-copy">
<h1>多智能体定时任务</h1>
<p>
把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。
</p>
</div>
<Button theme="primary" onClick={openCreate}>
<Plus aria-hidden="true" size={15} />
新建编排任务
</Button>
</header>
<div className="mat-stats" aria-label="任务统计">
<div className="mat-stat">
<span className="mat-stat-icon is-primary" aria-hidden="true">
<Network size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.total}</strong>
<span>编排任务</span>
</div>
</div>
<div className="mat-stat">
<span className="mat-stat-icon is-success" aria-hidden="true">
<CircleCheck size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.enabled}</strong>
<span>已启用</span>
</div>
</div>
<div className="mat-stat">
<span className={`mat-stat-icon${stats.running ? " is-live" : " is-muted"}`} aria-hidden="true">
{stats.running ? <LoaderCircle size={18} className="mat-spin" /> : <Clock3 size={18} />}
</span>
<div className="mat-stat-copy">
<strong className={stats.running ? "is-live" : undefined}>{stats.running}</strong>
<span>执行中</span>
</div>
</div>
<div className="mat-stat">
<span className="mat-stat-icon is-info" aria-hidden="true">
<History size={18} />
</span>
<div className="mat-stat-copy">
<strong>{stats.runTotal}</strong>
<span>累计执行</span>
</div>
</div>
</div>
<div className="mat-body">
<div className="mat-toolbar">
<div className="mat-toolbar-search">
<Input
clearable
placeholder="搜索任务名称 / 描述 / 智能体"
prefixIcon={<Search aria-hidden="true" size={15} />}
value={search}
onChange={(value) => setSearch(String(value ?? ""))}
/>
</div>
<Tabs
className="mat-toolbar-tabs"
value={filter}
onChange={(value) => setFilter(value as StatusFilter)}
>
<Tabs.TabPanel label="全部" value="all" />
<Tabs.TabPanel label="执行中" value="running" />
<Tabs.TabPanel label="已启用" value="enabled" />
<Tabs.TabPanel label="已暂停" value="paused" />
</Tabs>
</div>
{isLoading ? (
<div className="mat-state">
<Clock3 aria-hidden="true" className="mat-spin" size={18} />
正在加载编排任务…
</div>
) : visibleTasks.length ? (
<div className="mat-grid">
{visibleTasks.map((task) => {
const badge = taskBadgeOf(task);
const live = isTaskLive(task);
const busy = busyTaskId === task.id;
return (
<article className="mat-card" key={task.id}>
<header className="mat-card-head" onClick={() => openTask(task.id)}>
<span className="mat-card-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="mat-card-title">
<h3>{task.name}</h3>
<p>
<CalendarClock aria-hidden="true" size={13} />
{describeCron(task.cron_expr)}
</p>
</div>
<Tag size="small" theme={badge.theme} variant="light">
{badge.label}
</Tag>
</header>
{task.description && (
<p className="mat-card-desc" onClick={() => openTask(task.id)}>
{task.description}
</p>
)}
<div className="mat-card-flow" onClick={() => openTask(task.id)}>
<AgentFlow
seats={task.seats}
size="sm"
stages={expandStages(task)}
/>
</div>
<footer className="mat-card-foot">
<div className="mat-card-meta">
<span title={task.nextRunAt ?? undefined}>
下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"}
</span>
<span>
上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"}
{task.lastRun && task.lastRun.status !== "pending" && (
<em className={`mat-run-text is-${task.lastRun.status}`}>
· {runStatusLabel(task.lastRun.status)}
</em>
)}
</span>
</div>
<div className="mat-card-ops">
<Button
disabled={busy}
loading={busy}
size="small"
theme="primary"
variant="text"
onClick={() => void runTrigger(task)}
>
<Zap aria-hidden="true" size={14} />
立即执行
</Button>
<Button
disabled={busy}
size="small"
variant="text"
onClick={() => void runToggle(task)}
>
{task.enabled ? (
<Pause aria-hidden="true" size={14} />
) : (
<Play aria-hidden="true" size={14} />
)}
{task.enabled ? "暂停" : "恢复"}
</Button>
<Button size="small" variant="text" onClick={() => openEdit(task)}>
<Pencil aria-hidden="true" size={14} />
编辑
</Button>
<Popconfirm
content="删除后不可恢复,确定删除该任务?"
placement="bottom-right"
theme="danger"
onConfirm={() => confirmDelete(task)}
>
<Button className="mat-danger-btn" size="small" variant="text">
<Trash2 aria-hidden="true" size={14} />
</Button>
</Popconfirm>
</div>
</footer>
</article>
);
})}
</div>
) : (
<div className="mat-state mat-state--empty">
<span className="mat-state-glyph" aria-hidden="true">
<Network size={22} />
</span>
<h3>{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}</h3>
<p>
{tasks.length
? "换个关键词或筛选条件试试。"
: "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"}
</p>
{!tasks.length && (
<Button theme="primary" onClick={openCreate}>
<Plus aria-hidden="true" size={15} />
新建编排任务
</Button>
)}
</div>
)}
<p className="mat-footnote">
<CircleAlert aria-hidden="true" size={13} />
前端演示阶段:任务数据保存在本浏览器,执行过程为本地模拟;后端服务接入后自动切换。
</p>
</div>
<Drawer
className="mat-detail-drawer"
closeBtn
destroyOnClose
footer={
selectedTask ? (
<div className="mat-detail-footer">
<Button variant="outline" onClick={() => openEdit(selectedTask)}>
<Pencil aria-hidden="true" size={15} />
编辑编排
</Button>
<Button
variant="outline"
onClick={() => void runToggle(selectedTask)}
>
{selectedTask.enabled ? (
<Pause aria-hidden="true" size={15} />
) : (
<Play aria-hidden="true" size={15} />
)}
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
</Button>
<Button
className="mat-danger-btn"
theme="danger"
variant="outline"
onClick={() => confirmDelete(selectedTask)}
>
<Trash2 aria-hidden="true" size={15} />
删除
</Button>
<Button
disabled={busyTaskId === selectedTask.id}
loading={busyTaskId === selectedTask.id}
theme="primary"
onClick={() => void runTrigger(selectedTask)}
>
<Zap aria-hidden="true" size={15} />
立即执行
</Button>
</div>
) : null
}
header={
selectedTask ? (
<div className="mat-detail-header">
<span className="mat-detail-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="mat-detail-heading">
<h3>{selectedTask.name}</h3>
<p>{describeCron(selectedTask.cron_expr)}</p>
</div>
{(() => {
const badge = taskBadgeOf(selectedTask);
return (
<Tag theme={badge.theme} variant="light">
{badge.label}
</Tag>
);
})()}
<Switch
value={selectedTask.enabled}
onChange={() => void runToggle(selectedTask)}
/>
</div>
) : null
}
placement="right"
size="680px"
visible={!!selectedTask}
onClose={closeTask}
>
{selectedTask && (
<div className="mat-detail">
{selectedTask.description && <p className="mat-detail-desc">{selectedTask.description}</p>}
<div className="mat-detail-grid">
<div>
<span>下次执行</span>
<strong>
{selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"}
</strong>
</div>
<div>
<span>最近执行</span>
<strong>
{selectedTask.lastRun
? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}`
: "未执行"}
</strong>
</div>
<div>
<span>累计执行</span>
<strong>{selectedTask.runs.length} 次</strong>
</div>
<div>
<span>智能体 / 阶段</span>
<strong>
{selectedTask.seats.length} 个 · {selectedStages.length} 阶段
</strong>
</div>
</div>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>协作编排</h4>
{activeRun && (activeRun.status === "running" || activeRun.status === "pending") && (
<span className="mat-run-text is-running">
阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "}
{activeRun.stage_total} 执行中…
</span>
)}
</header>
<div className="mat-detail-flow">
<AgentFlow
activeStageIndex={
activeRun && activeRun.status !== "succeeded" ? activeRun.stage_index : null
}
seatStates={activeRun?.seat_states ?? null}
seats={selectedTask.seats}
showGoals
size="sm"
stageGoals={selectedTask.stage_goals}
stages={selectedStages}
/>
</div>
{activeRun && (
<div className="mat-flow-legend">
<span>
<i className="mat-seat-dot is-done" /> 完成
</span>
<span>
<i className="mat-seat-dot is-running" /> 执行中
</span>
<span>
<i className="mat-seat-dot is-waiting" /> 等待
</span>
<span>
<i className="mat-seat-dot is-failed" /> 失败
</span>
</div>
)}
</section>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>任务指令</h4>
</header>
<p className="mat-detail-prompt">{selectedTask.prompt}</p>
{selectedTask.coordinator_prompt && (
<>
<header className="mat-detail-section-head mat-detail-section-head--sub">
<h5>总控提示</h5>
</header>
<p className="mat-detail-prompt is-sub">{selectedTask.coordinator_prompt}</p>
</>
)}
</section>
<section className="mat-detail-section">
<header className="mat-detail-section-head">
<h4>运行记录</h4>
{!!selectedRuns.length && (
<small>点击记录可在上方流程图回看各智能体状态</small>
)}
</header>
{selectedRuns.length ? (
<div className="mat-run-list">
{selectedRuns.map((run) => {
const active = activeRun?.id === run.id;
const duration = run.finished_at
? formatDuration(
new Date(run.finished_at).getTime() -
new Date(run.started_at).getTime(),
)
: null;
return (
<button
className={`mat-run-item${active ? " is-active" : ""}`}
key={run.id}
onClick={() => setActiveRunId(run.id)}
type="button"
>
<Tag size="small" theme={runTagTheme(run.status)} variant="light">
{runStatusLabel(run.status)}
</Tag>
<span className="mat-run-item-time">
{formatDateTime(run.started_at)}
</span>
<span className="mat-run-item-trigger">
{run.trigger === "manual" ? "手动触发" : "按计划执行"}
</span>
<span className="mat-run-item-duration">
{duration ?? `阶段 ${Math.min(run.stage_index + 1, run.stage_total)}/${run.stage_total}`}
</span>
</button>
);
})}
</div>
) : (
<p className="mat-detail-empty">还没有执行记录,可点「立即执行」体验完整流程。</p>
)}
{activeRun?.error && (
<p className="mat-detail-error">
<CircleAlert aria-hidden="true" size={14} />
{activeRun.error}
</p>
)}
</section>
</div>
)}
</Drawer>
</section>
);
}
@@ -1,942 +0,0 @@
/**
* 多智能体定时任务 · 独立编辑页。
*
* 布局:顶部是紧凑表单条(任务名称 + 任务描述),其余表单项(执行频率、任务指令、
* 总控提示、启用开关)收进「执行设置」弹窗;主体是智能体编排。
* 编排交互与业务链条编辑器一致(复制其 @dnd-kit 模式 + 复用 chain-stages 纯函数):
* - 左栏候选池:拖左侧手柄到右侧任意阶段 / 新建阶段落区;点卡片本体 = 追加为新阶段。
* - 右栏阶段组:阶段是 droppable,席位是 sortable;席位可跨阶段拖动(变并行)或拖到
* 「新建阶段」落区独立成阶段;按钮兜底(并入上/下一阶段、独立成阶段、移除)。
* 保存时弹出确认弹窗:任务摘要 + 编排流程图(同阶段并行 · 阶段间串行)+ 图例。
*/
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
Eye,
GripVertical,
Plus,
Save,
Search,
Settings2,
SplitSquareVertical,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import {
type ScheduleConfig,
buildCron,
parseCron,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import {
addEmptyStage,
addSeatAsNewStage,
addSeatToStage,
countSeats,
deleteStage,
findStageIndexOfSeat,
moveSeatToStage,
moveStage,
removeSeat,
setStageGoal,
splitSeatToNewStage,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroups,
} from "@/roundtable-planning/lib/chain-stages";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "./components/AgentFlow";
import AgentDetailDrawer, {
type AgentDetailTarget,
} from "./components/AgentDetailDrawer";
import SchedulePicker from "./components/SchedulePicker";
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
import type { MatSeat, MatTask, MatTaskPayload } from "./types";
import {
defaultScheduleConfig,
describeCron,
describeScheduleConfig,
expandStages,
} from "./utils";
const POOL_PREFIX = "matpool::";
const SEAT_PREFIX = "matseat::";
const STAGE_PREFIX = "matstage::";
const NEWSTAGE_DROPPABLE_ID = "mat-newstage";
const MAX_SEATS = 20;
const MIN_SEATS = 2;
const DEFAULT_TIMEZONE = "Asia/Shanghai";
type ActiveDrag = { name: string } | null;
function seatFromAgent(agent: Agent): MatSeat {
return {
agent_id: agent.id,
name: agent.name,
description: agent.description ?? undefined,
};
}
function draftFromTask(task: MatTask) {
const stages = expandStages(task);
const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat]));
return {
name: task.name,
description: task.description ?? "",
schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(),
enabled: task.enabled,
prompt: task.prompt,
coordinatorPrompt: task.coordinator_prompt ?? "",
stageGroups: stages.map((stage, index) => ({
seats: stage
.map((agentId) => seatById.get(agentId))
.filter((seat): seat is MatSeat => !!seat),
goal: task.stage_goals?.[index] ?? "",
})) as StageGroups,
};
}
function groupsToPayloadSeats(groups: StageGroups): MatSeat[] {
return groups
.filter((group) => group.seats.length > 0)
.flatMap((group) => group.seats as MatSeat[]);
}
// ── 左栏:候选智能体卡片(点卡片看详情 / 加号或手柄加入)──────────────────
function PoolAgentCard({
agent,
used,
onAdd,
onShowDetail,
}: {
agent: Agent;
used: boolean;
onAdd: () => void;
onShowDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: POOL_PREFIX + agent.id,
disabled: used,
});
return (
<div
className={`mat-pool-item${used ? " is-used" : ""}${isDragging ? " is-dragging" : ""}`}
ref={setNodeRef}
{...attributes}
>
<button
aria-label={used ? "已在编排中" : "拖动加入编排"}
className="mat-pool-grip"
onClick={(event) => event.stopPropagation()}
title={used ? "已在编排中" : "拖动到右侧阶段加入(可与其它智能体并行)"}
type="button"
{...(used ? {} : listeners)}
>
<GripVertical size={14} />
</button>
<button className="mat-pool-main" onClick={onShowDetail} type="button">
<span className="mat-pool-icon" aria-hidden="true">
<Bot size={13} />
</span>
<span className="mat-pool-copy">
<span className="mat-pool-item-name">{agent.name}</span>
<span className="mat-pool-item-desc">{agent.description || "暂无简介"}</span>
</span>
</button>
{used ? (
<span className="mat-pool-used">已加入</span>
) : (
<button
aria-label={`加入 ${agent.name}`}
className="mat-pool-join"
onClick={(event) => {
event.stopPropagation();
onAdd();
}}
title="追加为新阶段"
type="button"
>
<Plus size={13} />
</button>
)}
</div>
);
}
// ── 右栏:单个席位长条(sortable)─────────────────────────────────────────
function SeatStrip({
seat,
globalIndex,
isFirstStage,
isLastStage,
onRemove,
onMergeUp,
onMergeDown,
onSplit,
onDetail,
}: {
seat: MatSeat;
globalIndex: number;
isFirstStage: boolean;
isLastStage: boolean;
onRemove: () => void;
onMergeUp: () => void;
onMergeDown: () => void;
onSplit: () => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
return (
<div
className={`mat-seat-row${isDragging ? " is-dragging" : ""}`}
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<button
aria-label="拖动调整编排"
className="mat-seat-row-grip"
title="拖动到其它阶段(并行)/ 排序 / 拖到底部虚线框独立成阶段"
type="button"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</button>
<span className="mat-seat-row-no">{globalIndex + 1}</span>
<span className="mat-seat-row-icon" aria-hidden="true">
<Bot size={13} />
</span>
<div className="mat-seat-row-copy" title={seat.description}>
<span className="mat-seat-row-name">{seat.name}</span>
{seat.description && <span className="mat-seat-row-desc">{seat.description}</span>}
</div>
<span className="mat-seat-row-ops">
<button
aria-label={`查看 ${seat.name} 详情`}
onClick={onDetail}
title="查看详情"
type="button"
>
<Eye size={14} />
</button>
<button
aria-label="并入上一阶段(与其并行)"
disabled={isFirstStage}
onClick={onMergeUp}
title="并入上一阶段(与其并行)"
type="button"
>
<ChevronUp size={14} />
</button>
<button
aria-label="独立成阶段"
onClick={onSplit}
title="独立成阶段"
type="button"
>
<SplitSquareVertical size={14} />
</button>
<button
aria-label="并入下一阶段(与其并行)"
disabled={isLastStage}
onClick={onMergeDown}
title="并入下一阶段(与其并行)"
type="button"
>
<ChevronDown size={14} />
</button>
<button aria-label="移除智能体" onClick={onRemove} title="移除" type="button">
<X size={14} />
</button>
</span>
</div>
);
}
// ── 右栏:阶段容器(droppable + 组内 sortable)────────────────────────────
function StageBox({
group,
stageIdx,
stageCount,
startIndex,
onGoalChange,
onStageReorder,
onStageDelete,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onSeatDetail,
}: {
group: StageGroups[number];
stageIdx: number;
stageCount: number;
startIndex: number;
onGoalChange: (goal: string) => void;
onStageReorder: (dir: -1 | 1) => void;
onStageDelete: () => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStage: number) => void;
onSplitSeat: (agentId: string) => void;
onSeatDetail: (seat: MatSeat) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const seats = group.seats as MatSeat[];
const isParallel = seats.length > 1;
const isFirstStage = stageIdx === 0;
const isLastStage = stageIdx === stageCount - 1;
const seatItemIds = seats.map((seat) => SEAT_PREFIX + seat.agent_id);
return (
<article className={`mat-stage-box${isOver ? " is-over" : ""}`}>
<header className="mat-stage-box-head">
<span className="mat-stage-box-no">阶段 {stageIdx + 1}</span>
{isParallel ? (
<span className="mat-flow-mode is-parallel">并行 ×{seats.length}</span>
) : (
<span className="mat-flow-mode is-serial">串行</span>
)}
<span className="mat-stage-box-ops">
<button
aria-label="阶段上移"
disabled={isFirstStage}
onClick={() => onStageReorder(-1)}
title="上移"
type="button"
>
<ChevronUp size={14} />
</button>
<button
aria-label="阶段下移"
disabled={isLastStage}
onClick={() => onStageReorder(1)}
title="下移"
type="button"
>
<ChevronDown size={14} />
</button>
<button
aria-label="删除阶段"
onClick={onStageDelete}
title="删除整个阶段"
type="button"
>
<Trash2 size={14} />
</button>
</span>
</header>
<Textarea
autosize
className="mat-stage-goal"
maxlength={120}
placeholder="本阶段目标(可选):本阶段要产出什么、如何衔接上一阶段"
value={group.goal ?? ""}
onChange={(value) => onGoalChange(String(value ?? ""))}
/>
<div className="mat-stage-seats" ref={setNodeRef}>
{seats.length === 0 ? (
<div className="mat-stage-seat-empty">空阶段 —— 把左侧智能体拖到此处,或删除该阶段。</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{seats.map((seat, index) => (
<SeatStrip
globalIndex={startIndex + index}
isLastStage={isLastStage}
isFirstStage={isFirstStage}
key={seat.agent_id}
onDetail={() => onSeatDetail(seat)}
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
onRemove={() => onRemoveSeat(seat.agent_id)}
onSplit={() => onSplitSeat(seat.agent_id)}
seat={seat}
/>
))}
</SortableContext>
)}
</div>
</article>
);
}
// ── 「新建阶段」落区(点击新增 / 拖席位到此独立成最后阶段)────────────────
function NewStageDropZone({
hasEmptyStage,
onAddStage,
}: {
hasEmptyStage: boolean;
onAddStage: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
return (
<button
className={`mat-stage-dropzone${isOver ? " is-over" : ""}`}
disabled={hasEmptyStage}
onClick={onAddStage}
ref={setNodeRef}
title={hasEmptyStage ? "已有空阶段,先填充它" : "新建一个串在最后的阶段"}
type="button"
>
<Plus size={15} />
新建阶段(串在最后) · 或把席位拖到此处独立成阶段
</button>
);
}
// ── 页面 ──────────────────────────────────────────────────────────────────
export default function TaskEditPage({
mode,
taskId,
}: {
mode: "create" | "edit";
taskId: string | null;
}) {
const navigate = useNavigate();
const { tasks, isLoading: tasksLoading } = useMultiAgentTasks();
const actions = useMultiAgentTaskActions();
const [poolSearch, setPoolSearch] = useState("");
const { agents, isLoading: agentsLoading } = useAgents({
search: poolSearch.trim() || undefined,
});
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [prompt, setPrompt] = useState("");
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [initialized, setInitialized] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [detailTarget, setDetailTarget] = useState<AgentDetailTarget | null>(null);
const editingTask = useMemo(
() => (mode === "edit" ? (tasks.find((task) => task.id === taskId) ?? null) : null),
[mode, taskId, tasks],
);
useEffect(() => {
if (initialized || mode === "create") {
setInitialized(true);
return;
}
if (!tasksLoading && taskId) {
if (editingTask) {
const draft = draftFromTask(editingTask);
setName(draft.name);
setDescription(draft.description);
setSchedule(draft.schedule);
setEnabled(draft.enabled);
setPrompt(draft.prompt);
setCoordinatorPrompt(draft.coordinatorPrompt);
setStageGroups(draft.stageGroups);
}
setInitialized(true);
}
}, [editingTask, initialized, mode, taskId, tasksLoading]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const usedAgentIds = useMemo(
() => new Set(stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))),
[stageGroups],
);
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
const hasEmptyStage = stageGroups.some((group) => group.seats.length === 0);
const backToList = useCallback(() => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
}, [navigate]);
// ── 编排操作(全部复用 chain-stages 纯函数,agent_id 定位)───────────────
const addAgentAsStage = useCallback((agent: Agent) => {
if (seatCount >= MAX_SEATS) {
toast.warning(`编排最多 ${MAX_SEATS} 个智能体`);
return;
}
setStageGroups((prev) => addSeatAsNewStage(prev, seatFromAgent(agent)));
}, [seatCount]);
const onDragStart = useCallback((event: DragStartEvent) => {
const id = String(event.active.id);
const agentId = id.startsWith(POOL_PREFIX)
? id.slice(POOL_PREFIX.length)
: id.startsWith(SEAT_PREFIX)
? id.slice(SEAT_PREFIX.length)
: null;
if (!agentId) return;
const seatName =
agents.find((agent) => agent.id === agentId)?.name ??
stageGroups
.flatMap((group) => group.seats as MatSeat[])
.find((seat) => seat.agent_id === agentId)?.name ??
agentId;
setActiveDrag({ name: seatName });
}, [agents, stageGroups]);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId) return;
let targetStage = -1;
let atIndex: number | undefined;
if (overId === NEWSTAGE_DROPPABLE_ID) {
targetStage = -1;
} else if (overId.startsWith(STAGE_PREFIX)) {
targetStage = Number(overId.slice(STAGE_PREFIX.length));
} else if (overId.startsWith(SEAT_PREFIX)) {
const overAgent = overId.slice(SEAT_PREFIX.length);
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
atIndex = stageGroups[targetStage]?.seats.findIndex(
(seat) => seat.agent_id === overAgent,
);
}
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
if (activeId.startsWith(POOL_PREFIX)) {
const agentId = activeId.slice(POOL_PREFIX.length);
if (usedAgentIds.has(agentId)) return;
const agent = agents.find((candidate) => candidate.id === agentId);
if (!agent || seatCount >= MAX_SEATS) return;
const seat = seatFromAgent(agent);
setStageGroups((prev) =>
toNewStage || targetStage < 0
? addSeatAsNewStage(prev, seat)
: addSeatToStage(prev, seat, targetStage, atIndex),
);
return;
}
if (activeId.startsWith(SEAT_PREFIX)) {
const agentId = activeId.slice(SEAT_PREFIX.length);
setStageGroups((prev) => {
if (toNewStage) {
const withEmpty = addEmptyStage(prev);
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
}
if (targetStage < 0) return prev;
return moveSeatToStage(prev, agentId, targetStage, atIndex);
});
}
},
[agents, seatCount, stageGroups, usedAgentIds],
);
// ── 保存:校验 → 确认弹窗 → 提交 ────────────────────────────────────────
const previewGroups = stageGroups.filter((group) => group.seats.length > 0);
const previewStages = previewGroups.map((group) =>
(group.seats as MatSeat[]).map((seat) => seat.agent_id),
);
const previewSeats = groupsToPayloadSeats(stageGroups);
const tryOpenConfirm = () => {
if (!name.trim()) {
toast.warning("请填写任务名称");
return;
}
if (seatCount < MIN_SEATS) {
toast.warning(`编排至少需要 ${MIN_SEATS} 个智能体`);
return;
}
if (hasEmptyStage) {
toast.warning("还有空阶段,请填充或删除");
return;
}
if (!prompt.trim()) {
setSettingsOpen(true);
toast.warning("请先在「执行设置」中填写任务指令");
return;
}
setConfirmOpen(true);
};
const submitTask = async () => {
setSaving(true);
try {
// 填了总控提示也强制落显式分层(该提示仅分层模式注入),与业务链条语义一致。
const forceExplicit = coordinatorPrompt.trim().length > 0;
const payload: MatTaskPayload = {
name: name.trim(),
description: description.trim() || undefined,
prompt: prompt.trim(),
coordinator_prompt: coordinatorPrompt.trim() || undefined,
cron_expr: buildCron(schedule),
timezone: editingTask?.timezone || DEFAULT_TIMEZONE,
enabled,
seats: previewSeats,
stages: stageGroupsToStagePayload(stageGroups, forceExplicit),
stage_goals: stageGroupsToStageGoals(stageGroups, forceExplicit),
};
if (mode === "edit" && taskId) {
await actions.update.mutateAsync({ id: taskId, payload });
toast.success("任务已更新");
} else {
await actions.create.mutateAsync(payload);
toast.success("任务已创建");
}
setConfirmOpen(false);
backToList();
} catch {
toast.error("保存失败,请重试");
} finally {
setSaving(false);
}
};
if (mode === "edit" && !tasksLoading && initialized && !editingTask) {
return (
<section className="mat-edit-page">
<div className="mat-state mat-state--empty">
<h3>任务不存在或已被删除</h3>
<Button theme="primary" onClick={backToList}>
返回任务列表
</Button>
</div>
</section>
);
}
let seatCursor = 0;
const settingsSummary = `${describeScheduleConfig(schedule)} · ${enabled ? "已启用" : "已暂停"}`;
return (
<section className="mat-edit-page">
<header className="mat-edit-header">
<button aria-label="返回任务列表" className="mat-edit-back" onClick={backToList} type="button">
<ArrowLeft size={18} />
</button>
<div className="mat-edit-heading">
<h1>{mode === "edit" ? `编辑:${editingTask?.name ?? "多智能体定时任务"}` : "新建多智能体定时任务"}</h1>
<p>同阶段并行执行 · 阶段之间串行执行;拖拽或点击左侧候选智能体加入编排</p>
</div>
<Button
variant="outline"
onClick={() => setSettingsOpen(true)}
title="执行频率 / 任务指令 / 总控提示 / 启用"
>
<Settings2 aria-hidden="true" size={15} />
执行设置
<span className="mat-settings-summary">{settingsSummary}</span>
</Button>
<Button theme="primary" onClick={tryOpenConfirm}>
<Save aria-hidden="true" size={15} />
保存任务
</Button>
</header>
<div className="mat-edit-formbar">
<label className="mat-formbar-field">
<span className="mat-formbar-label">
任务名称<i>*</i>
</span>
<Input
clearable
maxlength={40}
placeholder="例如:每日舆情早报"
value={name}
onChange={(value) => setName(String(value ?? ""))}
/>
</label>
<label className="mat-formbar-field mat-formbar-field--wide">
<span className="mat-formbar-label">任务描述</span>
<Input
clearable
maxlength={200}
placeholder="一句话说明这个编排任务要达成什么(可选)"
value={description}
onChange={(value) => setDescription(String(value ?? ""))}
/>
</label>
</div>
<div className="mat-edit-formbar mat-edit-formbar--area">
<span className="mat-formbar-label">总控提示</span>
<Textarea
autosize={{ minRows: 1, maxRows: 4 }}
className="mat-formbar-textarea"
maxlength={1000}
placeholder="写给总控(协调者)的编排要求,例如各阶段如何衔接、产出格式约束等(可选)"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</div>
<div className="mat-edit-body">
<section className="mat-edit-card">
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
sensors={sensors}
>
<div className="mat-edit-orchestra">
<aside className="mat-pool">
<header className="mat-pool-head">
<span>候选智能体</span>
<em>
{seatCount}/{MAX_SEATS} 已编排 · {agents.length} 个可选
</em>
</header>
<div className="mat-pool-search">
<Search aria-hidden="true" size={15} />
<input
onChange={(event) => setPoolSearch(event.target.value)}
placeholder="搜索智能体"
value={poolSearch}
/>
</div>
<div className="mat-pool-list">
{agentsLoading ? (
<p className="mat-pool-tip">正在加载智能体…</p>
) : agents.length ? (
agents.map((agent) => (
<PoolAgentCard
agent={agent}
key={agent.id}
onAdd={() => addAgentAsStage(agent)}
onShowDetail={() =>
setDetailTarget({
agentId: agent.id,
fallbackName: agent.name,
fallbackDescription: agent.description ?? undefined,
})
}
used={usedAgentIds.has(agent.id)}
/>
))
) : (
<p className="mat-pool-tip">没有匹配的智能体</p>
)}
</div>
</aside>
<div className="mat-stage-editor">
<div className="mat-stage-list">
{stageGroups.map((group, stageIdx) => {
const startIndex = seatCursor;
seatCursor += (group.seats as MatSeat[]).length;
return (
<StageBox
group={group}
key={`stage-box-${stageIdx}`}
onGoalChange={(goal) =>
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal))
}
onMergeSeat={(agentId, target) =>
setStageGroups((prev) => moveSeatToStage(prev, agentId, target))
}
onRemoveSeat={(agentId) =>
setStageGroups((prev) => removeSeat(prev, agentId))
}
onSeatDetail={(seat) =>
setDetailTarget({
agentId: seat.agent_id,
fallbackName: seat.name,
fallbackDescription: seat.description,
})
}
onSplitSeat={(agentId) =>
setStageGroups((prev) => splitSeatToNewStage(prev, agentId))
}
onStageDelete={() =>
setStageGroups((prev) => deleteStage(prev, stageIdx))
}
onStageReorder={(dir) =>
setStageGroups((prev) => moveStage(prev, stageIdx, dir))
}
stageCount={stageGroups.length}
stageIdx={stageIdx}
startIndex={startIndex}
/>
);
})}
<NewStageDropZone
hasEmptyStage={hasEmptyStage}
onAddStage={() => setStageGroups((prev) => addEmptyStage(prev))}
/>
{!stageGroups.length && (
<p className="mat-stage-empty">
从左侧点击或拖拽候选智能体加入编排,至少 {MIN_SEATS} 个。
</p>
)}
</div>
</div>
</div>
<DragOverlay>
{activeDrag ? (
<div className="mat-drag-overlay">
<GripVertical size={14} />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
</section>
</div>
<Dialog
className="mat-settings-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="mat-confirm-footer">
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
取消
</Button>
<Button theme="primary" onClick={() => setSettingsOpen(false)}>
确定
</Button>
</div>
}
header="执行设置"
placement="top"
visible={settingsOpen}
width={620}
onClose={() => setSettingsOpen(false)}
>
<div className="mat-settings">
<div className="mat-field">
<span className="mat-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="mat-field">
<span className="mat-field-label">
任务指令<i>*</i>
</span>
<Textarea
autosize={{ minRows: 4, maxRows: 8 }}
maxlength={2000}
placeholder="描述每次定时执行要让智能体编队完成的事情,例如:汇总过去 24 小时重点舆情并生成早报…"
value={prompt}
onChange={(value) => setPrompt(String(value ?? ""))}
/>
</label>
<div className="mat-field mat-field--switch">
<span className="mat-field-label">保存后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</Dialog>
<Dialog
className="mat-confirm-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="mat-confirm-footer">
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
再改改
</Button>
<Button loading={saving} theme="primary" onClick={() => void submitTask()}>
确认保存
</Button>
</div>
}
header="确认保存任务"
placement="top"
visible={confirmOpen}
width={720}
onClose={() => setConfirmOpen(false)}
>
<div className="mat-confirm">
<div className="mat-confirm-grid">
<div>
<span>任务名称</span>
<strong>{name.trim() || "—"}</strong>
</div>
<div>
<span>执行频率</span>
<strong>{describeCron(buildCron(schedule))}</strong>
</div>
<div>
<span>启用状态</span>
<strong>{enabled ? "启用" : "暂停"}</strong>
</div>
<div>
<span>智能体 / 阶段</span>
<strong>
{previewSeats.length} 个智能体 · {previewStages.length} 个阶段
</strong>
</div>
</div>
<div className="mat-confirm-flow">
<AgentFlow
seats={previewSeats}
showGoals
size="sm"
stageGoals={previewGroups.map((group) => (group.goal ?? "").trim() || null)}
stages={previewStages}
/>
</div>
<p className="mat-confirm-legend">
<span>
<i className="mat-flow-mode is-parallel" style={{ cursor: "default" }} /> 并行:同阶段智能体同时执行
</span>
<span>
<i className="mat-flow-mode is-serial" style={{ cursor: "default" }} /> 串行:阶段之间依次执行
</span>
</p>
{prompt.trim() && (
<div className="mat-confirm-prompt">
<span>任务指令</span>
<p>{prompt.trim()}</p>
</div>
)}
</div>
</Dialog>
<AgentDetailDrawer
target={detailTarget}
onClose={() => setDetailTarget(null)}
/>
</section>
);
}
@@ -1,164 +0,0 @@
/**
* 智能体详情抽屉:候选池卡片 / 编排席位条「查看详情」共用。
* 后端查不到(本地演示席位)时回退到编排里保存的名称/描述快照。
*/
import { Bot } from "lucide-react";
import { Drawer, Tag } from "tdesign-react";
import { useAgent } from "@/core/agents";
export interface AgentDetailTarget {
agentId: string;
fallbackName?: string;
fallbackDescription?: string;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export default function AgentDetailDrawer({
target,
onClose,
}: {
target: AgentDetailTarget | null;
onClose: () => void;
}) {
const { agent, isLoading } = useAgent(target?.agentId ?? null, {
enabled: !!target,
});
const name = agent?.name ?? target?.fallbackName ?? target?.agentId ?? "";
const description =
agent?.description ?? target?.fallbackDescription ?? agent?.description ?? "";
return (
<Drawer
className="mat-agent-drawer"
closeBtn
destroyOnClose
footer={null}
header={
<div className="mat-agent-header">
<span className="mat-agent-glyph" aria-hidden="true">
<Bot size={17} />
</span>
<div className="mat-agent-heading">
<h3>{name}</h3>
<p>智能体详情</p>
</div>
{agent && (
<Tag size="small" theme={agent.builtin ? "default" : "primary"} variant="light">
{agent.builtin ? "内置" : agent.published ? "已发布" : "私有"}
</Tag>
)}
</div>
}
placement="right"
size="460px"
visible={!!target}
onClose={onClose}
>
{target && (
<div className="mat-agent">
{isLoading ? (
<p className="mat-agent-loading">正在加载智能体详情…</p>
) : agent ? (
<>
<div className="mat-detail-grid">
<div>
<span>创建者</span>
<strong>
{agent.owner_username
? agent.owner_username
: agent.builtin
? "内置智能体"
: "—"}
</strong>
</div>
<div>
<span>执行模型</span>
<strong>{agent.model || "默认模型"}</strong>
</div>
<div>
<span>创建时间</span>
<strong>{formatDateTime(agent.created_at)}</strong>
</div>
<div>
<span>技能数</span>
<strong>{agent.skills?.length ?? 0} 个</strong>
</div>
</div>
{agent.description && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>简介</h4>
</header>
<p className="mat-agent-desc">{agent.description}</p>
</section>
)}
{!!agent.tags?.length && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>标签</h4>
</header>
<div className="mat-agent-tags">
{agent.tags.map((tag) => (
<Tag key={tag.id} size="small" variant="light">
{tag.name}
</Tag>
))}
</div>
</section>
)}
{!!agent.skills?.length && (
<section className="mat-agent-section">
<header className="mat-detail-section-head">
<h4>已配置技能</h4>
</header>
<div className="mat-agent-tags">
{agent.skills.map((skill) => (
<Tag key={skill} size="small" theme="primary" variant="light">
{skill}
</Tag>
))}
</div>
</section>
)}
</>
) : (
<div className="mat-agent-local">
<div className="mat-detail-grid">
<div>
<span>创建者</span>
<strong>本地演示</strong>
</div>
<div>
<span>数据来源</span>
<strong>编排内置快照</strong>
</div>
</div>
<p className="mat-agent-desc">
{description || "该席位来自演示种子数据,未对应后端智能体目录中的记录。接入后端后,编排将引用真实智能体。"}
</p>
</div>
)}
</div>
)}
</Drawer>
);
}
@@ -1,136 +0,0 @@
import { Bot, MoveRight } from "lucide-react";
import { Fragment } from "react";
import type { MatSeat, MatSeatRunState } from "../types";
import { seatColorIndex } from "../utils";
export interface AgentFlowProps {
stages: string[][];
seats: MatSeat[];
stageGoals?: (string | null)[] | null;
/** 提供时在席位上渲染执行状态点(运行图例)。 */
seatStates?: Record<string, MatSeatRunState> | null;
/** 当前执行到的阶段下标;提供时高亮进度。 */
activeStageIndex?: number | null;
size?: "sm" | "md";
showGoals?: boolean;
}
const STATE_LABEL: Record<MatSeatRunState, string> = {
waiting: "等待",
running: "执行中",
done: "完成",
failed: "失败",
};
export default function AgentFlow({
stages,
seats,
stageGoals,
seatStates,
activeStageIndex,
size = "md",
showGoals = false,
}: AgentFlowProps) {
const seatById = new Map(seats.map((seat) => [seat.agent_id, seat]));
// 只有一个阶段时不画阶段框:席位直接横向排列。
if (stages.length === 1) {
return (
<div className={`mat-flow mat-flow--single mat-flow--${size}`}>
{stages[0]!.map((agentId) => {
const seat = seatById.get(agentId);
const state = seatStates?.[agentId] ?? null;
return (
<div
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
state ? ` is-${state}` : ""
}`}
key={agentId}
title={
seat?.description
? `${seat.name}:${seat.description}`
: (seat?.name ?? agentId)
}
>
<span className="mat-seat-avatar">
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
</span>
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
{state && (
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
)}
</div>
);
})}
</div>
);
}
return (
<div className={`mat-flow mat-flow--${size}`}>
{stages.map((stage, stageIndex) => {
const parallel = stage.length > 1;
const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex;
const isPast =
typeof activeStageIndex === "number" && activeStageIndex > stageIndex;
const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null;
return (
<Fragment key={`stage-${stageIndex}`}>
{stageIndex > 0 && (
<div className="mat-flow-arrow" aria-hidden="true">
<MoveRight size={size === "sm" ? 14 : 16} strokeWidth={2} />
</div>
)}
<section
className={[
"mat-flow-stage",
isActive ? "is-active" : "",
isPast ? "is-done" : "",
]
.filter(Boolean)
.join(" ")}
>
<header className="mat-flow-stage-head">
<span className="mat-flow-stage-no">阶段 {stageIndex + 1}</span>
{parallel ? (
<span className="mat-flow-mode is-parallel">并行 ×{stage.length}</span>
) : (
<span className="mat-flow-mode is-serial">串行</span>
)}
</header>
<div className="mat-flow-seats">
{stage.map((agentId) => {
const seat = seatById.get(agentId);
const state = seatStates?.[agentId] ?? null;
return (
<div
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
state ? ` is-${state}` : ""
}`}
key={agentId}
title={
seat?.description
? `${seat.name}:${seat.description}`
: (seat?.name ?? agentId)
}
>
<span className="mat-seat-avatar">
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
</span>
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
{state && (
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
)}
</div>
);
})}
</div>
{goal && <p className="mat-flow-goal">{goal}</p>}
</section>
</Fragment>
);
})}
</div>
);
}
@@ -1,76 +0,0 @@
import { Select } from "tdesign-react";
import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { describeScheduleConfig } from "../utils";
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const HOURS = Array.from({ length: 24 }, (_, index) => index);
const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, index) => index + 1);
const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [
{ label: "每小时", value: "hourly" },
{ label: "每天", value: "daily" },
{ label: "每周", value: "weekly" },
{ label: "每月", value: "monthly" },
];
export default function SchedulePicker({
value,
onChange,
}: {
value: ScheduleConfig;
onChange: (config: ScheduleConfig) => void;
}) {
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
const toNumber = (input: unknown) => Number(input as string | number);
return (
<div className="mat-schedule-picker">
<div className="mat-schedule-row">
<Select
options={FREQ_OPTIONS}
value={value.freq}
onChange={(next) => set({ freq: next as ScheduleFreq })}
/>
{value.freq === "hourly" && (
<Select
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
value={value.minute}
onChange={(next) => set({ minute: toNumber(next) })}
/>
)}
{value.freq === "weekly" && (
<Select
options={WEEKDAYS.map((label, index) => ({ label, value: index }))}
value={value.weekday}
onChange={(next) => set({ weekday: toNumber(next) })}
/>
)}
{value.freq === "monthly" && (
<Select
options={DATES.map((date) => ({ label: `第 ${date} 日`, value: date }))}
value={value.date}
onChange={(next) => set({ date: toNumber(next) })}
/>
)}
{value.freq !== "hourly" && (
<Select
options={HOURS.map((hour) => ({ label: `${hour} 时`, value: hour }))}
value={value.hour}
onChange={(next) => set({ hour: toNumber(next) })}
/>
)}
{value.freq !== "hourly" && (
<Select
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
value={value.minute}
onChange={(next) => set({ minute: toNumber(next) })}
/>
)}
</div>
<p className="mat-schedule-preview">{describeScheduleConfig(value)} 自动执行</p>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,361 +0,0 @@
/**
* 多智能体定时任务的本地 mock 存储层。
*
* 后端接口尚未实现,先用 localStorage 持久化任务与运行记录:
* - 「立即执行」写入一条运行记录,状态由 started_at + duration_ms 在
* 读取时按当前时间推导(排队 → 按阶段推进 → 成功/失败),配合
* React Query 的轮询即可获得无后端的“实时”执行动画。
* - 接口就绪后,把本文件的 CRUD 换成 HTTP 调用即可,页面无需改动。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { uuid } from "@/core/utils/uuid";
import { expandStages, nextCronDate } from "./utils";
import type {
MatRunRecord,
MatRunStatus,
MatRunView,
MatSeatRunState,
MatTask,
MatTaskPayload,
MatTaskView,
} from "./types";
const STORAGE_KEY = "deerflow.official.multi-agent-tasks.v1";
interface MatStore {
tasks: MatTask[];
runs: MatRunRecord[];
}
const PENDING_MS = 1_500;
const STAGE_MS = 4_000;
function readStore(): MatStore {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<MatStore>;
if (Array.isArray(parsed.tasks)) {
return { tasks: parsed.tasks as MatTask[], runs: parsed.runs ?? [] };
}
}
} catch {
// 解析失败按首次访问处理,重写种子数据。
}
const seeded = seedStore();
writeStore(seeded);
return seeded;
}
function writeStore(store: MatStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// 存储被禁用时静默降级为内存态。
}
}
function mutateStore(mutator: (store: MatStore) => MatStore): MatStore {
const next = mutator(readStore());
writeStore(next);
return next;
}
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString();
}
function seedStore(): MatStore {
const seats = {
collector: { agent_id: "mat-seed-collector", name: "全网舆情采集", description: "多源检索并汇总最新舆情线索" },
monitor: { agent_id: "mat-seed-monitor", name: "舆情监测雷达", description: "盯住重点信源并输出监测明细" },
analyst: { agent_id: "mat-seed-analyst", name: "舆情研判分析", description: "对采集结果做情感与趋势研判" },
writer: { agent_id: "mat-seed-writer", name: "报告撰写助手", description: "把各阶段产出整理成结构化报告" },
verifier: { agent_id: "mat-seed-verifier", name: "数据核查员", description: "交叉校验数据与结论的可靠性" },
rival: { agent_id: "mat-seed-rival", name: "竞品情报员", description: "跟踪竞品动态与公开信息" },
researcher: { agent_id: "mat-seed-researcher", name: "行业研究员", description: "梳理行业政策与市场变化" },
forecaster: { agent_id: "mat-seed-forecaster", name: "趋势预测专家", description: "基于数据推演后续走势" },
reviewer: { agent_id: "mat-seed-reviewer", name: "内容审核官", description: "审核报告口径与合规风险" },
} satisfies Record<string, MatTask["seats"][number]>;
const tasks: MatTask[] = [
{
id: "mat-task-daily",
name: "每日舆情早报",
description: "每天早上自动汇总过去 24 小时重点舆情,并行采集 + 研判 + 成稿。",
prompt:
"汇总过去 24 小时与本公司相关的重点舆情:先并行完成多源采集与监测,再研判情感与趋势,最后汇总为一份不超过 1500 字的舆情早报,附风险提示。",
coordinator_prompt: "采集阶段允许两个智能体各自独立检索,不做信息合并;研判阶段需引用采集阶段的原始线索。",
cron_expr: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: true,
seats: [seats.collector, seats.monitor, seats.analyst, seats.writer],
stages: [
[seats.collector.agent_id, seats.monitor.agent_id],
[seats.analyst.agent_id],
[seats.writer.agent_id],
],
stage_goals: ["并行完成全网采集与重点信源监测", "基于原始线索完成情感与趋势研判", "汇总为可直发的舆情早报"],
created_at: hoursAgo(24 * 18),
updated_at: hoursAgo(26),
},
{
id: "mat-task-weekly",
name: "周度竞品情报速览",
description: "每周一串行走查竞品动态,核查后输出速览简报。",
prompt: "跟踪 5 家核心竞品过去一周的公开动态(产品、市场、人事),核查数据可信度后输出竞品速览简报。",
cron_expr: "30 8 * * 1",
timezone: "Asia/Shanghai",
enabled: false,
seats: [seats.rival, seats.verifier, seats.writer],
stages: null,
stage_goals: null,
created_at: hoursAgo(24 * 40),
updated_at: hoursAgo(24 * 3),
},
{
id: "mat-task-monthly",
name: "月度行业洞察报告",
description: "每月并行完成行业研究、趋势预测与数据核查,审核后成稿。",
prompt:
"围绕所在行业输出月度洞察:行业研究员、趋势预测专家与数据核查员并行推进,随后由内容审核官把关,最终产出月度洞察报告。",
cron_expr: "0 10 1 * *",
timezone: "Asia/Shanghai",
enabled: true,
seats: [seats.researcher, seats.forecaster, seats.verifier, seats.reviewer, seats.writer],
stages: [
[seats.researcher.agent_id, seats.forecaster.agent_id, seats.verifier.agent_id],
[seats.reviewer.agent_id],
[seats.writer.agent_id],
],
stage_goals: ["并行完成行业研究、趋势预测与数据核查", "审核结论口径与合规风险", "整合为月度洞察报告"],
created_at: hoursAgo(24 * 9),
updated_at: hoursAgo(24 * 9),
},
];
const runs: MatRunRecord[] = [
runSeed("mat-task-daily", 2.2, 3, null),
runSeed("mat-task-daily", 26, 3, null),
runSeed("mat-task-daily", 50, 3, null),
runSeed("mat-task-weekly", 24 * 3, 3, 2),
runSeed("mat-task-weekly", 24 * 10, 3, null),
];
return { tasks, runs };
}
function runSeed(
taskId: string,
hoursAgoValue: number,
stageTotal: number,
failStage: number | null,
): MatRunRecord {
const started = hoursAgo(hoursAgoValue);
return {
id: uuid() as string,
task_id: taskId,
trigger: "schedule",
scheduled_for: started,
started_at: started,
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
fail_stage: failStage,
};
}
/** 由记录 + 当前时间推导运行视图:排队 → 按阶段推进 → 成功/失败。 */
function resolveRunView(run: MatRunRecord, stages: string[][], now = Date.now()): MatRunView {
const startedAt = new Date(run.started_at).getTime();
const elapsed = now - startedAt;
const stageTotal = Math.max(1, stages.length);
const duration = run.duration_ms || PENDING_MS + stageTotal * STAGE_MS;
const buildStates = (
status: MatRunStatus,
activeStage: number,
): Record<string, MatSeatRunState> => {
const states: Record<string, MatSeatRunState> = {};
stages.forEach((stage, index) => {
const seatState: MatSeatRunState =
status === "succeeded"
? "done"
: index < activeStage
? "done"
: index === activeStage
? status === "failed"
? "failed"
: "running"
: "waiting";
stage.forEach((agentId) => {
states[agentId] = seatState;
});
});
return states;
};
if (elapsed < PENDING_MS) {
return {
...run,
finished_at: null,
status: "pending",
stage_index: 0,
stage_total: stageTotal,
seat_states: buildStates("pending", -1),
error: null,
};
}
const failStage = typeof run.fail_stage === "number" ? run.fail_stage : null;
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
const activeStage = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
if (failStage !== null && activeStage >= failStage) {
const finishedAt = new Date(startedAt + PENDING_MS + (failStage + 1) * STAGE_MS).toISOString();
return {
...run,
finished_at: finishedAt,
status: "failed",
stage_index: failStage,
stage_total: stageTotal,
seat_states: buildStates("failed", failStage),
error: `阶段 ${failStage + 1} 执行失败:模拟的执行异常(前端演示数据)`,
};
}
if (elapsed >= duration) {
return {
...run,
finished_at: new Date(startedAt + duration).toISOString(),
status: "succeeded",
stage_index: stageTotal - 1,
stage_total: stageTotal,
seat_states: buildStates("succeeded", stageTotal),
error: null,
};
}
return {
...run,
finished_at: null,
status: "running",
stage_index: activeStage,
stage_total: stageTotal,
seat_states: buildStates("running", activeStage),
error: null,
};
}
function buildTaskViews(now = Date.now()): MatTaskView[] {
const store = readStore();
return store.tasks.map((task) => {
const stages = expandStages(task);
const runs = store.runs
.filter((run) => run.task_id === task.id)
.map((run) => resolveRunView(run, stages, now))
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
const next = task.enabled ? nextCronDate(task.cron_expr, new Date(now)) : null;
return {
...task,
runs,
lastRun: runs[0] ?? null,
nextRunAt: next ? next.toISOString() : null,
};
});
}
function anyRunLive(tasks: MatTaskView[] | undefined): boolean {
return !!tasks?.some((task) =>
task.runs.some((run) => run.status === "pending" || run.status === "running"),
);
}
export function useMultiAgentTasks() {
const query = useQuery({
queryKey: ["mat", "tasks"],
queryFn: () => buildTaskViews(),
refetchInterval: (q) => (anyRunLive(q.state.data as MatTaskView[] | undefined) ? 1_800 : false),
});
return { tasks: query.data ?? [], isLoading: query.isLoading, error: query.error };
}
export function useMultiAgentTaskActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mat", "tasks"] });
const create = useMutation({
mutationFn: async (payload: MatTaskPayload) => {
const now = new Date().toISOString();
const task: MatTask = { ...payload, id: uuid() as string, created_at: now, updated_at: now };
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
return task;
},
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: MatTaskPayload }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === id ? { ...task, ...payload, updated_at: new Date().toISOString() } : task,
),
}));
},
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: async (id: string) => {
mutateStore((store) => ({
tasks: store.tasks.filter((task) => task.id !== id),
runs: store.runs.filter((run) => run.task_id !== id),
}));
},
onSuccess: invalidate,
});
const setEnabled = useMutation({
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === id ? { ...task, enabled, updated_at: new Date().toISOString() } : task,
),
}));
},
onSuccess: invalidate,
});
const trigger = useMutation({
mutationFn: async (id: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === id);
if (!task) throw new Error("任务不存在");
const stageTotal = expandStages(task).length;
const now = new Date().toISOString();
const run: MatRunRecord = {
id: uuid() as string,
task_id: id,
trigger: "manual",
scheduled_for: now,
started_at: now,
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
fail_stage: null,
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
const removeRun = useMutation({
mutationFn: async (runId: string) => {
mutateStore((store) => ({ ...store, runs: store.runs.filter((run) => run.id !== runId) }));
},
onSuccess: invalidate,
});
return { create, update, remove, setEnabled, trigger, removeRun };
}
@@ -1,92 +0,0 @@
/**
* 多智能体定时任务 —— 前端先行数据模型。
*
* 编排语义与业务链条保持一致:同一阶段(stage)内的多个智能体并行执行,
* 阶段之间串行执行;`stages: null` 表示纯串行线性链(每个智能体一层)。
* 当前后端尚未提供接口,数据先落 localStorage,接口就绪后只需替换 store 层。
*/
export type MatRunStatus = "pending" | "running" | "succeeded" | "failed";
export type MatSeatRunState = "waiting" | "running" | "done" | "failed";
export type MatRunTrigger = "manual" | "schedule";
/** 编排中的一个智能体席位(落库时保存名称快照,展示优先用快照)。 */
export interface MatSeat {
agent_id: string;
name: string;
description?: string;
}
export interface MatTask {
id: string;
name: string;
description?: string;
/** 任务指令:本次定时执行要让智能体编队完成什么。 */
prompt: string;
/** 总控(协调者)提示词:告诉总控如何编排各阶段的产出。 */
coordinator_prompt?: string;
cron_expr: string;
timezone: string;
enabled: boolean;
seats: MatSeat[];
/** `stages[i]` 内的智能体并行,i 递增串行;null = 线性串行。 */
stages: string[][] | null;
/** 与 stages 同下标对齐的每阶段目标。 */
stage_goals?: (string | null)[] | null;
created_at: string;
updated_at: string;
}
/** 落库的运行记录;状态由 started_at + duration_ms 在读取时推导。 */
export interface MatRunRecord {
id: string;
task_id: string;
trigger: MatRunTrigger;
scheduled_for: string;
started_at: string;
duration_ms: number;
/** 模拟在该阶段(下标)失败;null = 成功。 */
fail_stage?: number | null;
}
/** 读取时推导出的运行视图。 */
export interface MatRunView {
id: string;
task_id: string;
trigger: MatRunTrigger;
scheduled_for: string;
started_at: string;
finished_at: string | null;
status: MatRunStatus;
/** 当前执行到的阶段下标(从 0 开始)。 */
stage_index: number;
stage_total: number;
seat_states: Record<string, MatSeatRunState>;
error: string | null;
}
export interface MatTaskView extends MatTask {
runs: MatRunView[];
lastRun: MatRunView | null;
nextRunAt: string | null;
}
/** 编辑器内部使用的阶段草稿。 */
export interface MatStageGroup {
seats: MatSeat[];
goal: string;
}
/** 编辑器保存时对外提交的载荷。 */
export interface MatTaskPayload {
name: string;
description?: string;
prompt: string;
coordinator_prompt?: string;
cron_expr: string;
timezone: string;
enabled: boolean;
seats: MatSeat[];
stages: string[][] | null;
stage_goals: (string | null)[] | null;
}
@@ -1,166 +0,0 @@
import {
DEFAULT_SCHEDULE_CONFIG,
type ScheduleConfig,
parseCron,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import type { MatSeat, MatTask, MatTaskView } from "./types";
const WEEKDAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function pad2(value: number) {
return String(value).padStart(2, "0");
}
/** 把任务里的 stages 语义展开成显式阶段列表;null = 每个席位独立一层。 */
export function expandStages(task: Pick<MatTask, "seats" | "stages">): string[][] {
const known = new Set(task.seats.map((seat) => seat.agent_id));
if (!task.stages?.length) {
return task.seats.map((seat) => [seat.agent_id]);
}
const expanded = task.stages
.map((stage) => stage.filter((id) => known.has(id)))
.filter((stage) => stage.length > 0);
return expanded.length ? expanded : task.seats.map((seat) => [seat.agent_id]);
}
export function describeScheduleConfig(cfg: ScheduleConfig): string {
const clock = `${pad2(cfg.hour)}:${pad2(cfg.minute)}`;
switch (cfg.freq) {
case "hourly":
return `每小时 ${cfg.minute} 分`;
case "weekly":
return `每${WEEKDAY_LABELS[cfg.weekday] ?? "周一"} ${clock}`;
case "monthly":
return `每月 ${cfg.date} 日 ${clock}`;
default:
return `每天 ${clock}`;
}
}
/** 由 cron 反推人类可读的执行频率描述(覆盖本页生成的所有 cron 形态)。 */
export function describeCron(cron: string | null | undefined): string {
const cfg = parseCron(cron ?? undefined);
if (!cfg) return cron || "未设置";
return describeScheduleConfig(cfg);
}
/** 计算下一次执行时间;仅支持本页 schedule picker 生成的 5 段 cron 形态。 */
export function nextCronDate(cron: string | null | undefined, from = new Date()): Date | null {
const cfg = parseCron(cron ?? undefined);
if (!cfg) return null;
const candidate = new Date(from);
candidate.setSeconds(0, 0);
switch (cfg.freq) {
case "hourly": {
candidate.setMinutes(cfg.minute);
if (candidate <= from) candidate.setHours(candidate.getHours() + 1);
return candidate;
}
case "daily": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
if (candidate <= from) candidate.setDate(candidate.getDate() + 1);
return candidate;
}
case "weekly": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
const day = candidate.getDay();
let delta = (cfg.weekday - day + 7) % 7;
if (delta === 0 && candidate <= from) delta = 7;
candidate.setDate(candidate.getDate() + delta);
return candidate;
}
case "monthly": {
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
candidate.setDate(cfg.date);
if (candidate <= from) candidate.setMonth(candidate.getMonth() + 1);
return candidate;
}
default:
return null;
}
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function formatRelative(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const elapsed = Date.now() - date.getTime();
if (elapsed < 60_000) return "刚刚";
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
if (elapsed < 7 * 86_400_000) return `${Math.floor(elapsed / 86_400_000)} 天前`;
return formatDateTime(value);
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "—";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds} 秒`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`;
}
export function runStatusLabel(status: string | null | undefined): string {
switch (status) {
case "succeeded":
return "执行成功";
case "failed":
return "执行失败";
case "running":
return "执行中";
case "pending":
return "排队中";
default:
return "暂无执行";
}
}
export type MatTaskBadge = {
label: string;
theme: "success" | "warning" | "danger" | "default";
};
/** 任务卡右上角的状态徽标:以最近一次执行状态优先,其次启停。 */
export function taskBadgeOf(task: MatTaskView): MatTaskBadge {
const status = task.lastRun?.status;
if (status === "running" || status === "pending") {
return { label: "执行中", theme: "warning" };
}
if (!task.enabled) return { label: "已暂停", theme: "default" };
if (status === "failed") return { label: "上次失败", theme: "danger" };
if (status === "succeeded") return { label: "运行正常", theme: "success" };
return { label: "已启用", theme: "success" };
}
/** 席位配色槽位:按 agent_id 稳定散列到 0..7。 */
export function seatColorIndex(agentId: string): number {
let hash = 0;
for (let i = 0; i < agentId.length; i += 1) {
hash = (hash * 31 + agentId.charCodeAt(i)) >>> 0;
}
return hash % 8;
}
export function seatIndexOf(seats: MatSeat[], agentId: string): number {
const index = seats.findIndex((seat) => seat.agent_id === agentId);
return index >= 0 ? index : seats.length;
}
export function defaultScheduleConfig(): ScheduleConfig {
return { ...DEFAULT_SCHEDULE_CONFIG };
}
@@ -1,340 +0,0 @@
/**
* 任务分析 · 详情页:左侧执行记录(定时 / 手动),右侧按选中运行回放流程图
* (业务链风格节点状态 + 耗时,5 秒自动刷新),点击智能体节点打开执行过程抽屉。
*/
import {
ArrowLeft,
CalendarClock,
CircleAlert,
GitBranch,
MessageSquareText,
Pencil,
Pause,
Play,
RefreshCw,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Switch, Tag } from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import {
formatDateTime,
formatDuration,
formatRelative,
} from "@/pages/official-multi-agent-tasks/utils";
import BindingDialog from "./components/BindingDialog";
import SeatProcessDrawer from "./components/SeatProcessDrawer";
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
import { taskBadgeOf } from "./badge";
import { useTaTaskActions, useTaTasks, useTaTaskRuns } from "./store";
const RUN_STATUS_TEXT: Record<string, string> = {
succeeded: "成功",
failed: "失败",
running: "执行中",
pending: "排队中",
};
const RUN_STATUS_THEME: Record<string, "success" | "danger" | "warning" | "default"> = {
succeeded: "success",
failed: "danger",
running: "warning",
pending: "default",
};
export default function TaskAnalysisDetailPage({
routeTaskId,
}: {
routeTaskId: string | null;
}) {
const navigate = useNavigate();
const { views, isLoading } = useTaTasks();
const actions = useTaTaskActions();
const taskView = views.find(({ task }) => task.id === routeTaskId) ?? null;
const task = taskView?.task ?? null;
const { runs, isLoading: runsLoading } = useTaTaskRuns(task?.id ?? null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [seatAgentId, setSeatAgentId] = useState<string | null>(null);
const [bindingVisible, setBindingVisible] = useState(false);
const [busy, setBusy] = useState(false);
// 默认选中最近一次运行;新触发的运行自动跟随。
useEffect(() => {
if (!runs.length) return;
if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) {
setSelectedRunId(runs[0]!.id);
}
}, [runs, selectedRunId]);
const selectedRun = useMemo(
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
[runs, selectedRunId],
);
const backToList = () => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`);
};
const runTrigger = async (manual: boolean) => {
if (!task) return;
setBusy(true);
try {
if (manual) {
await actions.triggerManualRun.mutateAsync(task.id);
toast.success("已发起手动执行(总控问答)");
} else {
await actions.triggerRun.mutateAsync(task.id);
toast.success("已触发定时执行");
}
setSelectedRunId(null);
} catch {
toast.error("任务未绑定分析方案");
} finally {
setBusy(false);
}
};
const runToggle = async () => {
if (!task?.binding) return;
setBusy(true);
try {
await actions.setEnabled.mutateAsync({
taskId: task.id,
enabled: !task.binding?.enabled,
});
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusy(false);
}
};
if (!isLoading && !task) {
return (
<section className="ta-page">
<div className="ta-state ta-state--empty">
<h3>任务不存在或已被删除</h3>
<Button theme="primary" onClick={backToList}>
返回任务列表
</Button>
</div>
</section>
);
}
const binding = task?.binding ?? null;
const taskBadge = task && taskView ? taskBadgeOf(task, taskView.runs) : null;
const live = selectedRun?.status === "running" || selectedRun?.status === "pending";
return (
<section className="ta-page ta-detail-page">
<header className="ta-detail-head">
<button
aria-label="返回任务列表"
className="ta-back"
onClick={backToList}
type="button"
>
<ArrowLeft size={18} />
</button>
<div className="ta-detail-heading">
<h1>
{task?.name ?? "…"}
<em>{task?.sourceTaskId}</em>
</h1>
<p>
{binding ? (
<>
<GitBranch aria-hidden="true" size={13} />
{binding.chainTitle} · {binding.cronText}
{binding.directionNotes.length > 0 &&
` · ${binding.directionNotes.length} 条方向备注`}
</>
) : (
task?.description
)}
</p>
</div>
{taskBadge && (
<Tag theme={taskBadge.theme} variant="light">
{taskBadge.label}
</Tag>
)}
{binding && (
<Switch value={binding.enabled} onChange={() => void runToggle()} />
)}
<div className="ta-detail-ops">
{binding && (
<>
<Button
disabled={busy}
loading={busy}
theme="primary"
onClick={() => void runTrigger(false)}
>
<Zap aria-hidden="true" size={15} />
立即执行
</Button>
<Button variant="outline" disabled={busy} onClick={() => void runTrigger(true)}>
<MessageSquareText aria-hidden="true" size={15} />
模拟手动执行
</Button>
</>
)}
<Button variant="outline" onClick={() => setBindingVisible(true)}>
<Pencil aria-hidden="true" size={15} />
{binding ? "编辑绑定" : "配置分析"}
</Button>
</div>
</header>
{!binding ? (
<div className="ta-detail-empty">
<GitBranch aria-hidden="true" size={26} />
<h3>尚未配置分析方案</h3>
<p>绑定业务链与内容块映射后,这里将展示执行记录与各智能体的执行流程。</p>
<Button theme="primary" onClick={() => setBindingVisible(true)}>
配置分析
</Button>
</div>
) : (
<div className="ta-detail-layout">
<aside className="ta-runs">
<header className="ta-runs-head">
<h2>执行记录</h2>
<span>
<RefreshCw aria-hidden="true" className={live ? "ta-spin" : undefined} size={12} />
每 5 秒刷新
</span>
</header>
<div className="ta-runs-list">
{runsLoading && !runs.length ? (
<p className="ta-runs-empty">正在加载执行记录…</p>
) : runs.length ? (
runs.map((run) => {
const active = selectedRun?.id === run.id;
const duration = run.finishedAt
? formatDuration(
new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(),
)
: null;
return (
<button
className={`ta-run-item${active ? " is-active" : ""}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
type="button"
>
<span className="ta-run-item-top">
{run.trigger === "manual" ? (
<MessageSquareText aria-hidden="true" size={13} />
) : (
<CalendarClock aria-hidden="true" size={13} />
)}
<b>{run.trigger === "manual" ? "手动执行" : "定时执行"}</b>
<Tag size="small" theme={RUN_STATUS_THEME[run.status] ?? "default"} variant="light">
{RUN_STATUS_TEXT[run.status] ?? run.status}
</Tag>
</span>
<span className="ta-run-item-time">
{formatDateTime(run.startedAt)} · {duration ?? "进行中"}
</span>
{run.trigger === "manual" && run.manualSummary && (
<span className="ta-run-item-summary">{run.manualSummary}</span>
)}
{run.trigger === "schedule" && (
<span className="ta-run-item-blocks">
内容块 {run.blockStates.filter((b) => b.state === "done").length}/
{run.blockStates.length}
</span>
)}
</button>
);
})
) : (
<p className="ta-runs-empty">还没有执行记录,点「立即执行」体验完整流程。</p>
)}
</div>
</aside>
<section className="ta-flow-panel">
{selectedRun ? (
<>
<header className="ta-flow-head">
<h2>
执行流程
<small>
{selectedRun.trigger === "manual" ? "手动执行" : "定时执行"} ·{" "}
{formatRelative(selectedRun.startedAt)}
{selectedRun.status === "running" &&
` · 阶段 ${Math.min(selectedRun.stageIndex + 1, selectedRun.stageTotal)}/${selectedRun.stageTotal} 执行中`}
</small>
</h2>
<span className="ta-flow-legend">
<i className="ta-dot is-done" /> 完成
<i className="ta-dot is-running" /> 执行中
<i className="ta-dot is-waiting" /> 等待
<i className="ta-dot is-failed" /> 失败
</span>
</header>
<div className="ta-flow-canvas">
<ChainFlowDiagram
className="ta-flow-diagram"
onNodeClick={(agentId) => setSeatAgentId(agentId)}
seatStates={selectedRun.seatInfos}
stages={selectedRun.stages.map((stage, index) => ({
seats: stage.map((agentId) => ({
agent_id: agentId,
name: selectedRun.seatNames[agentId] ?? agentId,
})),
goal: selectedRun.stageGoals?.[index] ?? null,
}))}
/>
</div>
{selectedRun.error && (
<p className="ta-flow-error">
<CircleAlert aria-hidden="true" size={14} />
{selectedRun.error}
</p>
)}
</>
) : (
<div className="ta-flow-panel-empty">选择左侧一条执行记录查看流程</div>
)}
</section>
</div>
)}
<SeatProcessDrawer
onClose={() => setSeatAgentId(null)}
run={selectedRun}
seatAgentId={seatAgentId}
task={task ?? undefined}
/>
<BindingDialog
onClose={() => setBindingVisible(false)}
onSubmit={async (payload) => {
if (!task) return;
try {
await actions.bind.mutateAsync({ taskId: task.id, payload });
toast.success(binding ? "绑定已更新" : "分析方案已绑定");
setBindingVisible(false);
} catch {
toast.error("保存失败,请重试");
}
}}
submitting={actions.bind.isPending}
task={task}
visible={bindingVisible}
/>
</section>
);
}
@@ -1,460 +0,0 @@
/**
* 任务分析 · 列表页:对方系统推送过来的外部任务 + 我们的绑定状态。
* 任务卡片沿用多智能体定时任务的卡片视觉(浅红规范)。
*/
import {
CalendarClock,
CircleAlert,
CircleCheck,
Clock3,
GitBranch,
Link2,
Network,
Pencil,
Pause,
Play,
Plus,
Search,
Trash2,
Zap,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
DialogPlugin,
Input,
Popconfirm,
Switch,
Tabs,
Tag,
} from "tdesign-react";
import { toast } from "sonner";
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import {
formatDateTime,
formatRelative,
} from "@/pages/official-multi-agent-tasks/utils";
import BindingDialog from "./components/BindingDialog";
import CreateTaskDialog, {
type TaCreateTaskPayload,
} from "./components/CreateTaskDialog";
import { chainStagesOf, useTaTaskActions, useTaTasks } from "./store";
import type { TaTask, TaTaskViewLike } from "./page-types";
import { taskBadgeOf } from "./badge";
import "./task-analysis.css";
type StatusFilter = "all" | "unbound" | "enabled" | "paused" | "failed";
function isTaskLive(runs: TaTaskViewLike["runs"]) {
return runs.some((run) => run.status === "running" || run.status === "pending");
}
export default function TaskAnalysisPage() {
const navigate = useNavigate();
const { views, isLoading } = useTaTasks();
const actions = useTaTaskActions();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<StatusFilter>("all");
const [bindingTask, setBindingTask] = useState<TaTask | null>(null);
const [bindingVisible, setBindingVisible] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const visibleViews = useMemo(() => {
const keyword = search.trim().toLowerCase();
return views.filter(({ task, runs }) => {
const lastStatus = runs[0]?.status ?? null;
const bound = !!task.binding;
const live = isTaskLive(runs);
if (filter === "unbound" && bound) return false;
if (filter === "enabled" && (!bound || !task.binding!.enabled || live)) return false;
if (filter === "paused" && (!bound || task.binding!.enabled)) return false;
if (filter === "failed" && lastStatus !== "failed") return false;
if (!keyword) return true;
return (
task.name.toLowerCase().includes(keyword) ||
task.description.toLowerCase().includes(keyword) ||
task.sourceTaskId.toLowerCase().includes(keyword)
);
});
}, [views, search, filter]);
const stats = useMemo(
() => ({
total: views.length,
unbound: views.filter(({ task }) => !task.binding).length,
enabled: views.filter(({ task }) => task.binding?.enabled).length,
failed: views.filter(({ runs }) => runs[0]?.status === "failed").length,
}),
[views],
);
const openDetail = (taskId: string) => {
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis/${encodeURIComponent(taskId)}`);
};
const openBinding = (task: TaTask) => {
setBindingTask(task);
setBindingVisible(true);
};
const handleCreate = async (payload: TaCreateTaskPayload) => {
try {
const created = await actions.createTask.mutateAsync({
name: payload.name,
sourceTaskId: payload.sourceTaskId,
description: payload.description,
});
await actions.bind.mutateAsync({ taskId: created.id, payload: payload.binding });
toast.success("任务已创建并绑定分析方案");
setCreateVisible(false);
} catch {
toast.error("创建失败,请重试");
}
};
const handleBind = async (payload: Parameters<typeof actions.bind.mutateAsync>[0]["payload"]) => {
if (!bindingTask) return;
try {
await actions.bind.mutateAsync({ taskId: bindingTask.id, payload });
toast.success(bindingTask.binding ? "绑定已更新" : "分析方案已绑定");
setBindingVisible(false);
setBindingTask(null);
} catch {
toast.error("保存失败,请重试");
}
};
const runTrigger = async (taskId: string) => {
setBusyTaskId(taskId);
try {
await actions.triggerRun.mutateAsync(taskId);
toast.success("已触发执行");
openDetail(taskId);
} catch {
toast.error("触发失败:任务未绑定分析方案");
} finally {
setBusyTaskId(null);
}
};
const runToggle = async (task: TaTask) => {
setBusyTaskId(task.id);
try {
await actions.setEnabled.mutateAsync({ taskId: task.id, enabled: !task.binding?.enabled });
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
} catch {
toast.error("操作失败,请重试");
} finally {
setBusyTaskId(null);
}
};
const confirmDelete = (task: TaTask) => {
const dialog = DialogPlugin.confirm({
header: "删除外部任务",
body: `删除「${task.name}」将同时清除其绑定与全部执行记录(对应对外删除接口语义),确定删除?`,
confirmBtn: { content: "删除", theme: "danger" },
cancelBtn: "取消",
onConfirm: () => {
dialog.destroy();
void actions.removeTask.mutateAsync(task.id).then(() => {
toast.success("任务已删除");
});
},
onClose: () => dialog.destroy(),
});
};
return (
<section className="ta-page">
<header className="ta-page-head">
<div className="ta-page-head-copy">
<h1>任务分析定时</h1>
<p>
外部系统任务接入多智能体定时分析:绑定业务链与内容块映射,按计划执行并沉淀时间线版本。
</p>
</div>
<div className="ta-page-head-actions">
<Button
variant="outline"
onClick={() => {
const unbound = views.find(({ task }) => !task.binding)?.task;
if (unbound) openBinding(unbound);
else toast.info("当前任务均已绑定分析方案");
}}
>
配置未绑定任务
</Button>
<Button theme="primary" onClick={() => setCreateVisible(true)}>
<Plus aria-hidden="true" size={15} />
新建任务
</Button>
</div>
</header>
<div className="ta-stats" aria-label="任务统计">
<div className="ta-stat">
<span className="ta-stat-icon is-primary" aria-hidden="true">
<Network size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.total}</strong>
<span>外部任务</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-warning" aria-hidden="true">
<Link2 size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.unbound}</strong>
<span>未配置</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-success" aria-hidden="true">
<CircleCheck size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.enabled}</strong>
<span>已启用</span>
</div>
</div>
<div className="ta-stat">
<span className="ta-stat-icon is-danger" aria-hidden="true">
<CircleAlert size={18} />
</span>
<div className="ta-stat-copy">
<strong>{stats.failed}</strong>
<span>最近失败</span>
</div>
</div>
</div>
<div className="ta-body">
<div className="ta-toolbar">
<div className="ta-toolbar-search">
<Input
clearable
placeholder="搜索任务名称 / 简介 / 对方任务号"
prefixIcon={<Search aria-hidden="true" size={15} />}
value={search}
onChange={(value) => setSearch(String(value ?? ""))}
/>
</div>
<Tabs
className="ta-toolbar-tabs"
value={filter}
onChange={(value) => setFilter(value as StatusFilter)}
>
<Tabs.TabPanel label="全部" value="all" />
<Tabs.TabPanel label="未配置" value="unbound" />
<Tabs.TabPanel label="已启用" value="enabled" />
<Tabs.TabPanel label="已暂停" value="paused" />
<Tabs.TabPanel label="最近失败" value="failed" />
</Tabs>
</div>
{isLoading ? (
<div className="ta-state">
<Clock3 aria-hidden="true" className="ta-spin" size={18} />
正在加载外部任务…
</div>
) : visibleViews.length ? (
<div className="ta-grid">
{visibleViews.map(({ task, runs }) => {
const badge = taskBadgeOf(task, runs);
const binding = task.binding;
const lastRun = runs[0] ?? null;
const busy = busyTaskId === task.id;
const stages = binding ? chainStagesOf(binding) : [];
const seats: MatSeat[] = stages
.flat()
.filter((id, index, all) => all.indexOf(id) === index)
.map((id) => ({
agent_id: id,
name: lastRun?.seatNames[id] ?? id,
}));
return (
<article className="ta-card" key={task.id}>
<header className="ta-card-head" onClick={() => openDetail(task.id)}>
<span className="ta-card-glyph" aria-hidden="true">
<Network size={17} />
</span>
<div className="ta-card-title">
<h3>
{task.name}
<em>{task.sourceTaskId}</em>
</h3>
<p>{task.description}</p>
</div>
<Tag size="small" theme={badge.theme} variant="light">
{badge.label}
</Tag>
</header>
{binding ? (
<>
<div className="ta-card-binding" onClick={() => openDetail(task.id)}>
<span>
<GitBranch aria-hidden="true" size={13} />
{binding.chainTitle}
</span>
<span>
<CalendarClock aria-hidden="true" size={13} />
{binding.cronText}
</span>
</div>
<div className="ta-card-flow" onClick={() => openDetail(task.id)}>
<AgentFlow seats={seats} size="sm" stages={stages} />
</div>
</>
) : (
<button className="ta-card-unbound" onClick={() => openBinding(task)} type="button">
<GitBranch aria-hidden="true" size={15} />
配置分析:选择业务链 · 内容块映射 · 执行频率
</button>
)}
<footer className="ta-card-foot">
<div className="ta-card-meta">
<span>
下次{" "}
{binding?.enabled
? formatDateTime(nextRunAt(binding.cron))
: binding
? "已暂停"
: "未配置"}
</span>
<span>
上次 {lastRun ? formatRelative(lastRun.startedAt) : "未执行"}
{lastRun && lastRun.status !== "pending" && (
<em className={`ta-run-text is-${lastRun.status}`}>
· {lastRun.status === "succeeded" ? "成功" : lastRun.status === "failed" ? "失败" : "执行中"}
</em>
)}
</span>
</div>
<div className="ta-card-ops">
{binding && (
<>
<Button
disabled={busy}
loading={busy}
size="small"
theme="primary"
variant="text"
onClick={() => void runTrigger(task.id)}
>
<Zap aria-hidden="true" size={14} />
立即执行
</Button>
<Button
disabled={busy}
size="small"
variant="text"
onClick={() => void runToggle(task)}
>
{binding.enabled ? (
<Pause aria-hidden="true" size={14} />
) : (
<Play aria-hidden="true" size={14} />
)}
{binding.enabled ? "暂停" : "恢复"}
</Button>
<Button size="small" variant="text" onClick={() => openBinding(task)}>
<Pencil aria-hidden="true" size={14} />
编辑绑定
</Button>
</>
)}
<Popconfirm
content="删除后绑定与执行记录一并清除,确定?"
placement="bottom-right"
theme="danger"
onConfirm={() => confirmDelete(task)}
>
<Button className="ta-danger-btn" size="small" variant="text">
<Trash2 aria-hidden="true" size={14} />
</Button>
</Popconfirm>
</div>
</footer>
</article>
);
})}
</div>
) : (
<div className="ta-state ta-state--empty">
<span className="ta-state-glyph" aria-hidden="true">
<Network size={22} />
</span>
<h3>{views.length ? "没有符合条件的任务" : "还没有外部任务"}</h3>
<p>
{views.length
? "换个关键词或筛选条件试试。"
: "对方系统创建任务并调用创建接口后,任务会出现在这里。"}
</p>
</div>
)}
<p className="ta-footnote">
<CircleAlert aria-hidden="true" size={13} />
前端演示阶段:任务与执行数据保存在本浏览器,业务链来自真实接口;后端服务接入后自动切换。
</p>
</div>
<CreateTaskDialog
onClose={() => setCreateVisible(false)}
onSubmit={handleCreate}
submitting={actions.createTask.isPending}
visible={createVisible}
/>
<BindingDialog
onClose={() => {
setBindingVisible(false);
setBindingTask(null);
}}
onSubmit={handleBind}
submitting={actions.bind.isPending}
task={bindingTask}
visible={bindingVisible}
/>
</section>
);
}
/** 简易下次执行时间(覆盖本页生成的 cron 形态)。 */
function nextRunAt(cron: string): string | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hour, , , wd] = parts;
const now = new Date();
const candidate = new Date(now);
const minute = parseInt(min ?? "0", 10);
const hourNum = parseInt(hour ?? "9", 10);
if (`${hour}` === "*") {
candidate.setMinutes(minute, 0, 0);
if (candidate <= now) candidate.setHours(candidate.getHours() + 1);
} else if (`${wd}` !== "*") {
const weekday = parseInt(wd ?? "1", 10);
candidate.setHours(hourNum, minute, 0, 0);
let delta = (weekday - candidate.getDay() + 7) % 7;
if (delta === 0 && candidate <= now) delta = 7;
candidate.setDate(candidate.getDate() + delta);
} else {
candidate.setHours(hourNum, minute, 0, 0);
if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
}
return candidate.toISOString();
}
@@ -1,19 +0,0 @@
import type { TaRunView, TaTask } from "./types";
export interface TaBadge {
label: string;
theme: "success" | "warning" | "danger" | "default";
}
/** 任务卡右上角状态徽标:最近一次执行优先,其次启停,未绑定给引导态。 */
export function taskBadgeOf(task: TaTask, runs: TaRunView[]): TaBadge {
const status = runs[0]?.status;
if (status === "running" || status === "pending") {
return { label: "执行中", theme: "warning" };
}
if (!task.binding) return { label: "未配置", theme: "default" };
if (!task.binding.enabled) return { label: "已暂停", theme: "default" };
if (status === "failed") return { label: "上次失败", theme: "danger" };
if (status === "succeeded") return { label: "运行正常", theme: "success" };
return { label: "已启用", theme: "success" };
}
@@ -1,223 +0,0 @@
/**
* 绑定配置弹窗:选业务链(真实 /api/roundtable-chains + 内置演示链)→ 配置
* 对方详情块 → 席位映射 → 执行频率 / 启用 / 总控提示。
*/
import { Radio } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Dialog, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import {
defaultScheduleConfig,
describeScheduleConfig,
} from "@/pages/official-multi-agent-tasks/utils";
import type { TaBinding, TaBindingPayload, TaTask } from "../types";
/** 内置演示链(种子里用它,保证无后端数据时链路可演示)。 */
export const SEED_CHAIN: BusinessChain = {
id: "seed-chain-std",
title: "标准舆情分析链(内置)",
description: "采集 + 核查并行 → 研判 → 成稿,演示用内置编排。",
seats: [
{ agent_id: "collector", name: "全网舆情采集" },
{ agent_id: "verifier", name: "数据核查员" },
{ agent_id: "analyst", name: "舆情研判分析" },
{ agent_id: "writer", name: "报告撰写助手" },
],
stages: [
["collector", "verifier"],
["analyst"],
["writer"],
],
stageGoals: null,
isPublic: true,
};
export function chainStages(chain: BusinessChain): string[][] {
if (chain.stages?.length) return chain.stages;
return chain.seats.map((seat) => [seat.agent_id]);
}
export function chainSeats(chain: BusinessChain): MatSeat[] {
return chain.seats.map((seat) => ({
agent_id: seat.agent_id,
name: seat.name,
description: seat.description,
}));
}
/** 模块级空数组:data 未就绪时保持引用稳定,避免 effect 依赖无限循环。 */
export const NO_CHAINS: BusinessChain[] = [];
export default function BindingDialog({
visible,
task,
submitting,
onClose,
onSubmit,
}: {
visible: boolean;
task: TaTask | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: TaBindingPayload) => void;
}) {
const [chainId, setChainId] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const { data, isLoading: chainsLoading } = useQuery({
queryKey: ["ta", "chains"],
queryFn: async () => {
const [mine, publicChains] = await Promise.all([
listChains("mine").catch(() => [] as BusinessChain[]),
listChains("public").catch(() => [] as BusinessChain[]),
]);
const byId = new Map<string, BusinessChain>();
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
if (!byId.has(chain.id)) byId.set(chain.id, chain);
}
return [...byId.values()];
},
enabled: visible,
});
const chains = data ?? NO_CHAINS;
useEffect(() => {
if (!visible) return;
const binding: TaBinding | null = task?.binding ?? null;
const initialChain =
chains.find((chain) => chain.id === binding?.chainId) ??
chains[0] ??
SEED_CHAIN;
setChainId(binding?.chainId ?? initialChain.id);
setEnabled(binding?.enabled ?? true);
setCoordinatorPrompt(binding?.coordinatorPrompt ?? "");
}, [visible, task, chains]);
const selectedChain = useMemo(
() => chains.find((chain) => chain.id === chainId) ?? null,
[chains, chainId],
);
const handleChainChange = (nextId: string) => {
setChainId(nextId);
};
const trySubmit = () => {
if (!selectedChain) {
toast.warning("请选择业务链");
return;
}
onSubmit({
chainId: selectedChain.id,
chainTitle: selectedChain.title,
schedule,
enabled,
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
});
};
return (
<Dialog
className="ta-binding-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="ta-dialog-footer">
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button loading={submitting} theme="primary" onClick={trySubmit}>
{task?.binding ? "保存绑定" : "创建绑定"}
</Button>
</div>
}
header={task?.binding ? `编辑绑定 · ${task.name}` : `配置分析 · ${task?.name ?? ""}`}
placement="top"
visible={visible}
width={860}
onClose={onClose}
>
<div className="ta-binding">
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>选择业务链</h4>
<span>定时执行将按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
</header>
{chainsLoading ? (
<p className="ta-binding-tip">正在加载业务链…</p>
) : (
<div className="ta-chain-grid">
{chains.map((chain) => {
const active = chain.id === chainId;
return (
<button
className={`ta-chain-card${active ? " is-active" : ""}`}
key={chain.id}
onClick={() => handleChainChange(chain.id)}
type="button"
>
<header>
<Radio aria-hidden="true" size={14} />
<span className="ta-chain-card-title">{chain.title}</span>
<em>
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
</em>
</header>
{chain.description && <p>{chain.description}</p>}
<div className="ta-chain-card-flow">
<AgentFlow
seats={chainSeats(chain)}
size="sm"
stages={chainStages(chain)}
/>
</div>
</button>
);
})}
</div>
)}
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>执行设置</h4>
<span>{describeScheduleConfig(schedule)} 自动执行</span>
</header>
<div className="ta-binding-settings">
<div className="ta-field">
<span className="ta-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="ta-field">
<span className="ta-field-label">总控提示(可选)</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={500}
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</label>
<div className="ta-field ta-field--switch">
<span className="ta-field-label">绑定后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</section>
</div>
</Dialog>
);
}
@@ -1,242 +0,0 @@
/**
* 新建任务弹窗(一步式):任务信息(名称/任务号/简介)+ 业务链选择 +
* 执行设置(频率 / 总控提示 / 启用),提交后直接创建任务并完成绑定。
*/
import { Radio } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
import { toast } from "sonner";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
import {
defaultScheduleConfig,
describeScheduleConfig,
} from "@/pages/official-multi-agent-tasks/utils";
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
import { NO_CHAINS, SEED_CHAIN, chainSeats, chainStages } from "./BindingDialog";
import type { TaBindingPayload } from "../types";
export interface TaCreateTaskPayload {
name: string;
sourceTaskId?: string;
description?: string;
binding: TaBindingPayload;
}
export default function CreateTaskDialog({
visible,
submitting,
onClose,
onSubmit,
}: {
visible: boolean;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: TaCreateTaskPayload) => void;
}) {
const [name, setName] = useState("");
const [sourceTaskId, setSourceTaskId] = useState("");
const [description, setDescription] = useState("");
const [chainId, setChainId] = useState("");
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
const [enabled, setEnabled] = useState(true);
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
const { data, isLoading: chainsLoading } = useQuery({
queryKey: ["ta", "chains"],
queryFn: async () => {
const [mine, publicChains] = await Promise.all([
listChains("mine").catch(() => [] as BusinessChain[]),
listChains("public").catch(() => [] as BusinessChain[]),
]);
const byId = new Map<string, BusinessChain>();
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
if (!byId.has(chain.id)) byId.set(chain.id, chain);
}
return [...byId.values()];
},
enabled: visible,
});
const chains = data ?? NO_CHAINS;
useEffect(() => {
if (!visible) return;
setName("");
setSourceTaskId("");
setDescription("");
setSchedule(defaultScheduleConfig());
setEnabled(true);
setCoordinatorPrompt("");
setChainId((prev) => (chains.some((chain) => chain.id === prev) ? prev : chains[0]?.id ?? SEED_CHAIN.id));
}, [visible, chains]);
const selectedChain = useMemo(
() => chains.find((chain) => chain.id === chainId) ?? null,
[chains, chainId],
);
const trySubmit = () => {
if (!name.trim()) {
toast.warning("请填写任务名称");
return;
}
if (!selectedChain) {
toast.warning("请选择业务链");
return;
}
onSubmit({
name,
sourceTaskId: sourceTaskId.trim() || undefined,
description: description.trim() || undefined,
binding: {
chainId: selectedChain.id,
chainTitle: selectedChain.title,
schedule,
enabled,
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
},
});
};
return (
<Dialog
className="ta-create-dialog"
closeBtn
closeOnOverlayClick={false}
destroyOnClose
footer={
<div className="ta-dialog-footer">
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button loading={submitting} theme="primary" onClick={trySubmit}>
创建任务并绑定
</Button>
</div>
}
header="新建任务"
placement="top"
visible={visible}
width={860}
onClose={onClose}
>
<div className="ta-binding">
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>任务信息</h4>
<span>由本页录入;接入对方系统后,推送创建的任务同样出现在列表中</span>
</header>
<div className="ta-create-grid">
<label className="ta-field">
<span className="ta-field-label">
任务名称<i className="ta-required">*</i>
</span>
<Input
clearable
maxlength={40}
placeholder="例如:东南亚舆论环境监测"
value={name}
onChange={(value) => setName(String(value ?? ""))}
/>
</label>
<label className="ta-field">
<span className="ta-field-label">对方任务号(可选)</span>
<Input
clearable
maxlength={32}
placeholder="留空自动生成"
value={sourceTaskId}
onChange={(value) => setSourceTaskId(String(value ?? ""))}
/>
</label>
<label className="ta-field ta-field--wide">
<span className="ta-field-label">任务简介</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={200}
placeholder="一句话说明任务背景与分析目标,定时执行时作为任务上下文注入各智能体"
value={description}
onChange={(value) => setDescription(String(value ?? ""))}
/>
</label>
</div>
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>执行设置</h4>
<span>{describeScheduleConfig(schedule)} 自动执行</span>
</header>
<div className="ta-binding-settings">
<div className="ta-field">
<span className="ta-field-label">执行频率</span>
<SchedulePicker value={schedule} onChange={setSchedule} />
</div>
<label className="ta-field">
<span className="ta-field-label">总控提示(可选)</span>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
maxlength={500}
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
value={coordinatorPrompt}
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
/>
</label>
<div className="ta-field ta-field--switch">
<span className="ta-field-label">创建后立即启用</span>
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
</div>
</div>
</section>
<section className="ta-binding-section">
<header className="ta-binding-section-head">
<h4>选择业务链</h4>
<span>定时执行按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
</header>
{chainsLoading ? (
<p className="ta-binding-tip">正在加载业务链…</p>
) : (
<div className="ta-chain-grid">
{chains.map((chain) => {
const active = chain.id === chainId;
return (
<button
className={`ta-chain-card${active ? " is-active" : ""}`}
key={chain.id}
onClick={() => setChainId(chain.id)}
type="button"
>
<header>
<Radio aria-hidden="true" size={14} />
<span className="ta-chain-card-title">{chain.title}</span>
<em>
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
</em>
</header>
{chain.description && <p>{chain.description}</p>}
<div className="ta-chain-card-flow">
<AgentFlow
seats={chainSeats(chain) as MatSeat[]}
size="sm"
stages={chainStages(chain)}
/>
</div>
</button>
);
})}
</div>
)}
</section>
</div>
</Dialog>
);
}
@@ -1,122 +0,0 @@
/**
* 席位执行过程抽屉:点击流程图节点打开,展示该智能体在某次运行里的完整过程
* (输入 → AI 分析 → 工具调用 → 产物块)。后端就绪后由 runs/{id}/messages?seat= 提供数据。
*/
import { ArrowDownRight, Bot, Wrench } from "lucide-react";
import { Drawer, Tag } from "tdesign-react";
import { seatColorIndex } from "@/pages/official-multi-agent-tasks/utils";
import { buildSeatProcess } from "../store";
import type { TaRunView, TaSeatState, TaTask } from "../types";
const STATE_THEME: Record<TaSeatState, "success" | "warning" | "danger" | "default"> = {
done: "success",
running: "warning",
failed: "danger",
waiting: "default",
};
const STATE_TEXT: Record<TaSeatState, string> = {
waiting: "等待执行",
running: "执行中",
done: "已完成",
failed: "执行失败",
};
const KIND_LABEL: Record<string, string> = {
input: "输入",
ai: "分析",
tool: "工具",
artifact: "产物",
};
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "—";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds} 秒`;
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
}
export default function SeatProcessDrawer({
run,
seatAgentId,
task,
onClose,
}: {
run: TaRunView | null;
seatAgentId: string | null;
task: TaTask | undefined;
onClose: () => void;
}) {
const visible = !!run && !!seatAgentId;
const process =
visible && run ? buildSeatProcess(run, seatAgentId!, task) : null;
const info = run?.seatInfos[seatAgentId ?? ""] ?? null;
return (
<Drawer
className="ta-seat-drawer"
closeBtn
destroyOnClose
footer={null}
header={
process ? (
<div className="ta-seat-header">
<span className={`ta-seat-glyph ta-seat--c${seatColorIndex(seatAgentId ?? "")}`} aria-hidden="true">
<Bot size={18} />
</span>
<div className="ta-seat-heading">
<h3>{process.seatName}</h3>
<p>执行过程 · {run?.trigger === "manual" ? "手动执行(总控问答)" : "定时执行"}</p>
</div>
{info && (
<Tag size="small" theme={STATE_THEME[info.state]} variant="light">
{STATE_TEXT[info.state]}
</Tag>
)}
</div>
) : null
}
placement="right"
size="520px"
visible={visible}
onClose={onClose}
>
{process && (
<div className="ta-seat-process">
{info && (
<div className="ta-seat-meta">
<span>状态:{STATE_TEXT[info.state]}</span>
<span>耗时:{formatDuration(info.durationMs)}</span>
<span>所属阶段:第 {(run?.stages.findIndex((stage) => stage.includes(seatAgentId ?? "")) ?? -1) + 1} 阶段</span>
</div>
)}
<ol className="ta-seat-timeline">
{process.messages.map((message, index) => (
<li className={`ta-seat-item is-${message.kind}`} key={index}>
<span className="ta-seat-item-marker">
{message.kind === "tool" ? (
<Wrench size={11} />
) : (
<ArrowDownRight size={11} />
)}
</span>
<div className="ta-seat-item-body">
<header>
<em className={`ta-seat-item-kind is-${message.kind}`}>
{KIND_LABEL[message.kind] ?? message.kind}
</em>
<span className="ta-seat-item-title">{message.title}</span>
</header>
<p>{message.content}</p>
{message.detail && <pre>{message.detail}</pre>}
</div>
</li>
))}
</ol>
</div>
)}
</Drawer>
);
}
@@ -1,4 +0,0 @@
import type { TaRunView, TaTask } from "./types";
export type { TaRunView, TaTask };
export type TaTaskViewLike = { task: TaTask; runs: TaRunView[] };
@@ -1,611 +0,0 @@
/**
* 外部任务定时分析的本地 mock 存储层(与 multi-agent-tasks 同款模式)。
*
* - 任务/绑定/运行记录落 localStorage;「立即执行 / 模拟手动执行」写入运行记录,
* 状态由 startedAt + durationMs 按当前时间推导(排队 → 按阶段推进 → 成功/失败),
* 配合 React Query 轮询获得无后端的"实时"流程图推进。
* - 席位执行过程消息在读取侧按席位生成(buildSeatProcess),不入库。
* - 对外接口(创建/删除/详情时间线/问答)就绪后替换本层。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { buildCron } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { describeScheduleConfig } from "@/pages/official-multi-agent-tasks/utils";
import { uuid } from "@/core/utils/uuid";
import type {
TaBlockMapping,
TaBinding,
TaBindingPayload,
TaRunRecord,
TaRunStatus,
TaRunView,
TaSeatRunInfo,
TaSeatState,
TaTask,
} from "./types";
const STORAGE_KEY = "deerflow.official.task-analysis.v2";
interface TaStore {
tasks: TaTask[];
runs: TaRunRecord[];
/** 上次绑定时选择的业务链 id(创建接口响应里的 default_chain_id 记忆)。 */
lastChainId: string | null;
}
const PENDING_MS = 2_000;
const STAGE_MS = 6_000;
/** 对方系统定义的详情块类型(mock 固定清单;接接口后由创建方传入)。 */
export const TA_BLOCK_TYPES: Array<{ key: string; name: string }> = [
{ key: "background", name: "任务背景" },
{ key: "dynamics", name: "动态分析" },
{ key: "risk", name: "风险研判" },
{ key: "action", name: "行动建议" },
{ key: "conclusion", name: "结论汇总" },
];
function readStore(): TaStore {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<TaStore>;
if (Array.isArray(parsed.tasks)) {
return {
tasks: parsed.tasks as TaTask[],
runs: parsed.runs ?? [],
lastChainId: parsed.lastChainId ?? null,
};
}
}
} catch {
// 解析失败按首次访问处理。
}
const seeded = seedStore();
writeStore(seeded);
return seeded;
}
function writeStore(store: TaStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// 存储被禁用时静默降级为内存态。
}
}
function mutateStore(mutator: (store: TaStore) => TaStore): TaStore {
const next = mutator(readStore());
writeStore(next);
return next;
}
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString();
}
function seedStore(): TaStore {
const seats = {
collector: "全网舆情采集",
analyst: "舆情研判分析",
writer: "报告撰写助手",
verifier: "数据核查员",
forecaster: "趋势预测专家",
};
const tasks: TaTask[] = [
{
id: "ta-task-1",
sourceTaskId: "TS-2026-0147",
name: "东南亚舆论环境监测",
description: "对方系统推送的任务:持续跟踪东南亚重点渠道舆论动向,输出结构化分析详情。",
binding: {
chainId: "seed-chain-std",
chainTitle: "标准舆情分析链",
cron: "0 9 * * *",
cronText: "每天 09:00",
enabled: true,
blocks: [
{ key: "background", name: "任务背景", seatAgentId: "collector" },
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
{ key: "risk", name: "风险研判", seatAgentId: "analyst" },
{ key: "action", name: "行动建议", seatAgentId: "analyst" },
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
],
coordinatorPrompt: "采集阶段允许独立检索;研判阶段需引用采集阶段的原始线索。",
directionNotes: ["重点关注宗教节日前后的舆论波动。"],
timezone: "Asia/Shanghai",
},
createdAt: hoursAgo(24 * 12),
},
{
id: "ta-task-2",
sourceTaskId: "TS-2026-0152",
name: "竞品新品发布追踪",
description: "追踪 5 家核心竞品的新品发布与市场反应,按块更新详情内容。",
binding: {
chainId: "seed-chain-single",
chainTitle: "单阶段并行分析链",
cron: "30 8 * * 1",
cronText: "每周一 08:30",
enabled: false,
blocks: [
{ key: "background", name: "任务背景", seatAgentId: "verifier" },
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
],
directionNotes: [],
timezone: "Asia/Shanghai",
},
createdAt: hoursAgo(24 * 8),
},
{
id: "ta-task-3",
sourceTaskId: "TS-2026-0160",
name: "行业政策周报编制",
description: "对方系统刚创建的任务,尚未配置分析方案。",
binding: null,
createdAt: hoursAgo(5),
},
];
const stdStages = [
["collector", "verifier"],
["analyst"],
["writer"],
];
const singleStages = [["collector", "verifier", "analyst"]];
const stdNames = seats;
const runs: TaRunRecord[] = [
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "schedule",
scheduledFor: hoursAgo(2.1),
startedAt: hoursAgo(2.1),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: null,
},
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "manual",
scheduledFor: hoursAgo(20),
startedAt: hoursAgo(20),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: null,
manualSummary: "总控问答:要求研判阶段补充跨平台对比维度,已确认应用到后续执行。",
},
{
id: uuid() as string,
taskId: "ta-task-1",
trigger: "schedule",
scheduledFor: hoursAgo(26),
startedAt: hoursAgo(26),
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
stages: stdStages,
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
seatNames: stdNames,
failStage: 1,
},
{
id: uuid() as string,
taskId: "ta-task-2",
trigger: "schedule",
scheduledFor: hoursAgo(24 * 3),
startedAt: hoursAgo(24 * 3),
durationMs: PENDING_MS + singleStages.length * STAGE_MS,
stages: singleStages,
stageGoals: ["三席并行:采集 / 核查 / 研判同时产出"],
seatNames: stdNames,
failStage: null,
},
];
return { tasks, runs, lastChainId: "seed-chain-std" };
}
/** 由记录 + 当前时间推导运行视图:各席位状态/耗时、块产出状态。 */
function resolveRunView(run: TaRunRecord, task: TaTask | undefined, now = Date.now()): TaRunView {
const startedAt = new Date(run.startedAt).getTime();
const elapsed = now - startedAt;
const stageTotal = Math.max(1, run.stages.length);
const duration = run.durationMs || PENDING_MS + stageTotal * STAGE_MS;
const buildSeatInfos = (
status: TaRunStatus,
activeStage: number,
): Record<string, TaSeatRunInfo> => {
const infos: Record<string, TaSeatRunInfo> = {};
run.stages.forEach((stage, index) => {
const stageStart = PENDING_MS + index * STAGE_MS;
const stageEnd = stageStart + STAGE_MS;
const finished = status === "succeeded" || (status === "failed" && index < activeStage);
const active = !finished && index === activeStage && status !== "pending";
const state: TaSeatState = finished
? "done"
: active
? status === "failed"
? "failed"
: "running"
: "waiting";
const durationMs = finished
? Math.min(elapsed - stageStart, STAGE_MS)
: active
? Math.min(elapsed - stageStart, STAGE_MS)
: 0;
stage.forEach((agentId) => {
infos[agentId] = { state, durationMs };
});
});
return infos;
};
let status: TaRunStatus;
let stageIndex: number;
let finishedAt: string | null = null;
let error: string | null = null;
if (elapsed < PENDING_MS) {
status = "pending";
stageIndex = 0;
} else {
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
stageIndex = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
if (run.failStage !== null && stageIndex >= run.failStage) {
status = "failed";
stageIndex = run.failStage;
finishedAt = new Date(startedAt + PENDING_MS + (run.failStage + 1) * STAGE_MS).toISOString();
const failSeat = run.stages[run.failStage]?.[0];
error = `阶段 ${run.failStage + 1} 执行失败:${run.seatNames[failSeat ?? ""] ?? "席位"} 模拟执行异常`;
} else if (elapsed >= duration) {
status = "succeeded";
stageIndex = stageTotal - 1;
finishedAt = new Date(startedAt + duration).toISOString();
} else {
status = "running";
}
}
const seatInfos = buildSeatInfos(status, status === "pending" ? -1 : stageIndex);
const blockStates = (task?.binding?.blocks ?? []).map((block) => ({
key: block.key,
name: block.name,
state: seatInfos[block.seatAgentId]?.state ?? "waiting",
}));
return {
...run,
finishedAt,
status,
stageIndex,
stageTotal,
seatInfos,
error,
blockStates,
};
}
function buildTaskViews(now = Date.now()): Array<{ task: TaTask; runs: TaRunView[] }> {
const store = readStore();
return store.tasks.map((task) => {
const runs = store.runs
.filter((run) => run.taskId === task.id)
.map((run) => resolveRunView(run, task, now))
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
return { task, runs };
});
}
function anyRunLive(views: Array<{ runs: TaRunView[] }> | undefined): boolean {
return !!views?.some(({ runs }) =>
runs.some((run) => run.status === "pending" || run.status === "running"),
);
}
/** 列表 + 每任务运行视图;运行中 5 秒轮询(设计定稿:流程图每 5 秒刷新)。 */
export function useTaTasks() {
const query = useQuery({
queryKey: ["ta", "tasks"],
queryFn: () => buildTaskViews(),
refetchInterval: (q) =>
anyRunLive(q.state.data as Array<{ runs: TaRunView[] }> | undefined) ? 5_000 : false,
});
const views = query.data ?? [];
return { views, tasks: views.map((view) => view.task), isLoading: query.isLoading };
}
export function useTaTaskRuns(taskId: string | null) {
const query = useQuery({
queryKey: ["ta", "tasks", taskId],
queryFn: () => {
const view = buildTaskViews().find((item) => item.task.id === taskId);
return view?.runs ?? [];
},
enabled: !!taskId,
refetchInterval: (q) => {
const runs = q.state.data as TaRunView[] | undefined;
const live = !!runs?.some((run) => run.status === "pending" || run.status === "running");
return live ? 5_000 : false;
},
});
return { runs: query.data ?? [], isLoading: query.isLoading };
}
export function useTaTaskActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ta"] });
/** 页面手动创建外部任务(名称必填;对方任务号留空则自动生成)。 */
const createTask = useMutation({
mutationFn: async (payload: {
name: string;
sourceTaskId?: string;
description?: string;
}) => {
const task: TaTask = {
id: uuid() as string,
sourceTaskId:
payload.sourceTaskId?.trim() ||
`TS-${new Date().getFullYear()}-${String(Date.now()).slice(-5)}`,
name: payload.name.trim(),
description: payload.description?.trim() || "",
binding: null,
createdAt: new Date().toISOString(),
};
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
return task;
},
onSuccess: invalidate,
});
/** 绑定 / 修改绑定(记录 lastChainId 供创建接口返回默认项)。 */
const bind = useMutation({
mutationFn: async ({ taskId, payload }: { taskId: string; payload: TaBindingPayload }) => {
const cron = buildCron(payload.schedule);
mutateStore((store) => ({
...store,
lastChainId: payload.chainId,
tasks: store.tasks.map((task) =>
task.id === taskId
? {
...task,
binding: {
chainId: payload.chainId,
chainTitle: payload.chainTitle,
cron,
cronText: describeScheduleConfig(payload.schedule),
enabled: payload.enabled,
blocks: payload.blocks ?? task.binding?.blocks ?? [],
coordinatorPrompt: payload.coordinatorPrompt,
directionNotes: task.binding?.directionNotes ?? [],
timezone: task.binding?.timezone ?? "Asia/Shanghai",
} satisfies TaBinding,
}
: task,
),
}));
},
onSuccess: invalidate,
});
const unbind = useMutation({
mutationFn: async (taskId: string) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === taskId ? { ...task, binding: null } : task,
),
runs: store.runs.filter((run) => run.taskId !== taskId),
}));
},
onSuccess: invalidate,
});
const setEnabled = useMutation({
mutationFn: async ({ taskId, enabled }: { taskId: string; enabled: boolean }) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.map((task) =>
task.id === taskId && task.binding
? { ...task, binding: { ...task.binding, enabled } }
: task,
),
}));
},
onSuccess: invalidate,
});
/** 删除外部任务(对应对外删除接口:任务 + 绑定 + 执行记录彻底删除)。 */
const removeTask = useMutation({
mutationFn: async (taskId: string) => {
mutateStore((store) => ({
...store,
tasks: store.tasks.filter((task) => task.id !== taskId),
runs: store.runs.filter((run) => run.taskId !== taskId),
}));
},
onSuccess: invalidate,
});
/** 立即触发一次定时执行(schedule)。 */
const triggerRun = useMutation({
mutationFn: async (taskId: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === taskId);
const binding = task?.binding;
if (!task || !binding) throw new Error("任务未绑定分析方案");
const stages = chainStagesOf(binding, store);
const now = new Date().toISOString();
const run: TaRunRecord = {
id: uuid() as string,
taskId,
trigger: "schedule",
scheduledFor: now,
startedAt: now,
durationMs: PENDING_MS + stages.length * STAGE_MS,
stages,
stageGoals: stages.map((_, index) => stageGoalOf(index)),
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
failStage: null,
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
/** 模拟一次手动执行(对方详情页总控问答产生)。 */
const triggerManualRun = useMutation({
mutationFn: async (taskId: string) => {
const store = readStore();
const task = store.tasks.find((item) => item.id === taskId);
const binding = task?.binding;
if (!task || !binding) throw new Error("任务未绑定分析方案");
const stages = chainStagesOf(binding, store);
const now = new Date().toISOString();
const run: TaRunRecord = {
id: uuid() as string,
taskId,
trigger: "manual",
scheduledFor: now,
startedAt: now,
durationMs: PENDING_MS + stages.length * STAGE_MS,
stages,
stageGoals: stages.map((_, index) => stageGoalOf(index)),
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
failStage: null,
manualSummary: "总控问答:按对方详情页提问,总控给对应席位派活并汇总结论。",
};
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
return run;
},
onSuccess: invalidate,
});
return { createTask, bind, unbind, setEnabled, removeTask, triggerRun, triggerManualRun };
}
/** 绑定链的编排(种子任务用内置编排;真实业务链按其 stages 展开,null = 线性)。 */
export function chainStagesOf(
binding: Pick<TaBinding, "chainId">,
store: TaStore = readStore(),
): string[][] {
if (binding.chainId === "seed-chain-std") {
return [
["collector", "verifier"],
["analyst"],
["writer"],
];
}
if (binding.chainId === "seed-chain-single") {
return [["collector", "verifier", "analyst"]];
}
// 真实业务链的编排由页面侧传入的 chain 数据展开;存储层兜底为单席位串行。
const seats = seedSeatsOf(store);
return seats.map((agentId) => [agentId]);
}
function seedSeatsOf(_store: TaStore): string[] {
return ["collector", "verifier", "analyst", "writer"];
}
/** 演示用的阶段目标(seed 链固定;真实链由绑定快照提供)。 */
function stageGoalOf(stageIndex: number): string | null {
const goals = ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"];
return goals[stageIndex] ?? null;
}
export function seatDisplayName(agentId: string): string {
const names: Record<string, string> = {
collector: "全网舆情采集",
verifier: "数据核查员",
analyst: "舆情研判分析",
writer: "报告撰写助手",
forecaster: "趋势预测专家",
};
return names[agentId] ?? agentId;
}
// ── 席位执行过程(读取侧生成 mock)────────────────────────────────────────
export interface TaSeatMessage {
kind: "input" | "ai" | "tool" | "artifact";
title: string;
content: string;
detail?: string;
}
/** 生成某席位在某次运行里的执行过程消息(后端就绪后换 runs/{id}/messages?seat=)。 */
export function buildSeatProcess(
run: TaRunView,
seatAgentId: string,
task: TaTask | undefined,
): { seatName: string; messages: TaSeatMessage[]; blocks: TaBlockMapping[] } {
const seatName = run.seatNames[seatAgentId] ?? seatAgentId;
const info = run.seatInfos[seatAgentId] ?? { state: "waiting" as TaSeatState, durationMs: 0 };
const stageIndex = run.stages.findIndex((stage) => stage.includes(seatAgentId));
const blocks = (task?.binding?.blocks ?? []).filter(
(block) => block.seatAgentId === seatAgentId,
);
const messages: TaSeatMessage[] = [
{
kind: "input",
title: "输入 · 任务上下文",
content:
`${task?.name ?? ""}(对方任务 ${task?.sourceTaskId ?? "-"})\n` +
`${task?.description ?? ""}\n\n` +
(blocks.length
? `本席位负责产出:${blocks.map((block) => block.name).join("、")}。`
: "按业务链席位职责执行。") +
(stageIndex > 0
? `\n上一阶段产出摘要:${run.stages[stageIndex - 1]
?.map((id) => run.seatNames[id])
.join("、")} 的中间结论已注入。`
: ""),
},
];
if (info.state === "waiting") {
return { seatName, messages, blocks };
}
messages.push({
kind: "ai",
title: "AI 分析",
content:
`(${seatName} 的分析过程 · 演示内容)\n` +
`围绕任务简介拆解检索方向,交叉核对来源可信度,形成本席位负责块的结构化结论。`,
});
messages.push({
kind: "tool",
title: "工具调用 · web_search",
content: "query=\"任务关键词 最新动态\" → 命中 12 条,采纳 7 条(含来源与时间)",
detail:
'{"results":[{"title":"来源A:行业动态","snippet":"……"},{"title":"来源B:政策更新","snippet":"……"}]}',
});
if (info.state === "done") {
blocks.forEach((block) => {
messages.push({
kind: "artifact",
title: `产出 · ${block.name}`,
content: `(${block.name} 块内容摘要 · 演示)已按对方系统约定的结构生成,随本次执行写入时间线版本。`,
});
});
}
return { seatName, messages, blocks };
}
File diff suppressed because it is too large. Load diff
@@ -1,105 +0,0 @@
/**
* 外部任务多智能体定时分析 —— 前端先行数据模型(后端接口就绪后替换 store 层)。
*
* 对应设计定稿:
* - 外部任务(对方系统推送创建/删除)1:1 绑定「业务链 + 频率 + 块→席位映射」。
* - 执行记录两类:schedule 定时执行 / manual 手动执行(对方详情页总控问答)。
* - 时间线内容版本:每次定时执行产出各块内容(供对方详情接口拉取),管理页内
* 在席位执行过程抽屉里预览。
*/
import type { ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
export type TaRunTrigger = "schedule" | "manual";
export type TaRunStatus = "pending" | "running" | "succeeded" | "failed";
export type TaSeatState = "waiting" | "running" | "done" | "failed";
/** 对方详情的分块内容与业务链席位的映射(存在绑定上,业务链保持纯粹)。 */
export interface TaBlockMapping {
/** 块类型 key(对方系统定义)。 */
key: string;
/** 块展示名。 */
name: string;
/** 产出该块的席位(业务链 agent_id)。 */
seatAgentId: string;
}
export interface TaBinding {
chainId: string;
/** 业务链标题快照(展示用,链被删后仍可读)。 */
chainTitle: string;
cron: string;
cronText: string;
enabled: boolean;
blocks: TaBlockMapping[];
coordinatorPrompt?: string;
/** 问答沉淀的「执行方向备注」,注入后续每次定时执行(累积追加)。 */
directionNotes: string[];
/** 绑定时记住,作为下次创建任务的默认业务链返回给对方系统。 */
timezone: string;
}
export interface TaTask {
id: string;
/** 对方系统任务 id。 */
sourceTaskId: string;
name: string;
description: string;
binding: TaBinding | null;
createdAt: string;
}
/** 落库的运行记录;状态由 startedAt + durationMs 在读取时按当前时间推导。 */
export interface TaRunRecord {
id: string;
taskId: string;
trigger: TaRunTrigger;
scheduledFor: string;
startedAt: string;
durationMs: number;
/** 该次执行的业务链编排快照(席位/阶段),链后续修改不影响历史回放。 */
stages: string[][];
/** 各阶段目标快照(与 stages 同下标),供流程图连线标签展示。 */
stageGoals?: (string | null)[] | null;
seatNames: Record<string, string>;
/** 在该阶段(下标)失败;null = 成功。 */
failStage: number | null;
/** 手动执行(总控问答)的摘要。 */
manualSummary?: string;
}
/** 读取时推导出的运行视图(每席位状态 + 耗时)。 */
export interface TaSeatRunInfo {
state: TaSeatState;
durationMs: number;
}
export interface TaRunView {
id: string;
taskId: string;
trigger: TaRunTrigger;
scheduledFor: string;
startedAt: string;
finishedAt: string | null;
status: TaRunStatus;
stageIndex: number;
stageTotal: number;
seatInfos: Record<string, TaSeatRunInfo>;
error: string | null;
/** 各块产出状态(按绑定块映射推导;未绑定返回空)。 */
blockStates: Array<{ key: string; name: string; state: TaSeatState }>;
manualSummary?: string;
stages: string[][];
stageGoals?: (string | null)[] | null;
seatNames: Record<string, string>;
}
/** 绑定弹窗的提交载荷(内容块映射已移除;blocks 仅为兼容旧数据保留)。 */
export interface TaBindingPayload {
chainId: string;
chainTitle: string;
schedule: ScheduleConfig;
enabled: boolean;
blocks?: TaBlockMapping[];
coordinatorPrompt?: string;
}
@@ -114,10 +114,12 @@ export function PositionBusinessChainFlow({
)} )}
/> />
<div className="min-w-0"> <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")}>业务链条</h2> <h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
{!embedded && ( {chain?.title?.trim() || "业务链条"}
</h2>
{!embedded && !chain && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite"> <p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")} {loading ? "正在加载管理员配置…" : "等待管理员配置"}
</p> </p>
)} )}
</div> </div>
@@ -1,12 +1,9 @@
"use client"; "use client";
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react"; import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
import type { PagePhase } from "@/report-collaboration/state/selectors"; import type { PagePhase } from "@/report-collaboration/state/selectors";
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = { const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" }, empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" }, clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
@@ -54,16 +51,6 @@ export function ReportCollaborationHeader({
<span className="rc-header-title" title={title}> <span className="rc-header-title" title={title}>
报告协作 / {title} 报告协作 / {title}
</span> </span>
{PLAN_MODE_PHASES.has(phase) ? (
<span
className="rc-chip"
title="规划师正在梳理需求并规划工作流,确认后开始执行"
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
>
<ClipboardList className="size-3" aria-hidden />
计划模式
</span>
) : null}
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}> <span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
{badge.label} {badge.label}
</span> </span>
@@ -156,23 +156,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
const edges: Edge[] = useMemo(() => { const edges: Edge[] = useMemo(() => {
if (!plan) return []; if (!plan) return [];
return plan.edges.map((edge, index) => { return plan.edges.map((edge, index) => {
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画; const targetState = nodeStates[edge.target];
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。 const active = targetState?.status === "running" || targetState?.status === "validating";
const targetStatus = nodeStates[edge.target]?.status;
let className = "rc-edge";
let stroke = "var(--rc-flow-edge)";
if (targetStatus === "running" || targetStatus === "validating") {
className = "rc-edge-active";
stroke = "var(--rc-running)";
} else if (targetStatus === "failed") {
className = "rc-edge-failed";
stroke = "var(--rc-danger)";
} else if (targetStatus === "awaiting_input") {
className = "rc-edge-waiting";
stroke = "var(--rc-waiting)";
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
className = "rc-edge-dimmed";
}
return { return {
id: `${edge.source}->${edge.target}-${index}`, id: `${edge.source}->${edge.target}-${index}`,
source: edge.source, source: edge.source,
@@ -180,8 +165,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
label: edge.label ?? undefined, label: edge.label ?? undefined,
type: "default", type: "default",
animated: false, animated: false,
className, className: active ? "rc-edge-active" : "rc-edge",
style: { stroke, strokeWidth: 2 }, style: {
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
strokeWidth: 2,
},
}; };
}); });
}, [plan, nodeStates]); }, [plan, nodeStates]);
@@ -1,52 +0,0 @@
"use client";
import { ClipboardCheck } from "lucide-react";
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
interface RequirementSummaryCardProps {
requirement: ReportRequirementSnapshot;
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
editable?: boolean;
}
function Field({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null;
return (
<div className="flex gap-2 text-[12px] leading-5">
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
<span className="min-w-0 flex-1 break-words">{value}</span>
</div>
);
}
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
return (
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
<div className="mb-2 flex items-center gap-2">
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
计划模式
</span>
</div>
<div className="flex flex-col gap-1">
<Field label="主题" value={requirement.topic} />
<Field label="目的" value={requirement.objective} />
<Field label="读者" value={requirement.audience} />
<Field label="角度" value={angles || null} />
<Field label="时间范围" value={requirement.time_range} />
<Field label="地域" value={requirement.geography} />
<Field label="篇幅" value={requirement.length_target} />
<Field label="语气" value={requirement.tone} />
</div>
{editable ? (
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
</p>
) : null}
</div>
);
}
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileText, Sparkles } from "lucide-react"; import { FileText, Loader2, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
@@ -26,7 +26,6 @@ import {
} from "@/report-collaboration/components/ReportPreviewDialog"; } from "@/report-collaboration/components/ReportPreviewDialog";
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay"; import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell"; import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel"; import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout"; import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages"; import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
@@ -36,7 +35,6 @@ import {
buildCommandConfirmCard, buildCommandConfirmCard,
buildPlanGroupCard, buildPlanGroupCard,
buildReportAvailableCard, buildReportAvailableCard,
buildRequirementReadyCard,
readReportCollaborationCard, readReportCollaborationCard,
} from "@/report-collaboration/state/message-adapter"; } from "@/report-collaboration/state/message-adapter";
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker"; import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
@@ -249,23 +247,11 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
if (next) setFocusedAgentRunId(next); if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]); }, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。 // 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。 // 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
const runStarted = Boolean(state.run && state.run.plan_id); const runStarted = Boolean(state.run && state.run.plan_id);
const extraCards = useMemo<Message[]>(() => { const extraCards = useMemo<Message[]>(() => {
const cards: Message[] = []; const cards: Message[] = [];
if (
state.requirement &&
state.plans.length === 0 &&
!runStarted &&
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
)
) {
cards.push(buildRequirementReadyCard(state.requirement));
}
if ( if (
state.plans.length > 0 && state.plans.length > 0 &&
!runStarted && !runStarted &&
@@ -355,16 +341,39 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
[sessionId, session], [sessionId, session],
); );
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划), // 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。 const requirementReady = useMemo(
() =>
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
),
[state.messagesById],
);
const showPlanRequestEntry =
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
const [planRequestBusy, setPlanRequestBusy] = useState(false);
const handleRequestPlans = useCallback(async () => {
setPlanRequestBusy(true);
try {
await getReportCollaborationDriver().requestPlans(
sessionId,
state.session?.requirement_revision ?? null,
);
await session.refreshSnapshot();
toast.success("已生成候选方案,可在对话中选择");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setPlanRequestBusy(false);
}
}, [sessionId, session, state.session?.requirement_revision]);
const businessCardSlot = useCallback( const businessCardSlot = useCallback(
(message: Message) => { (message: Message) => {
const card = readReportCollaborationCard(message); const card = readReportCollaborationCard(message);
if (!card) return null; if (!card) return null;
if (card.kind === "requirement_ready") {
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
}
if (card.kind === "plan_group") { if (card.kind === "plan_group") {
return ( return (
<ReportPlanCards <ReportPlanCards
@@ -405,7 +414,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
} }
return null; return null;
}, },
[previewPlan, previewPlanId, runStarted, session], [previewPlan, previewPlanId, session],
); );
const speakerShell = useCallback( const speakerShell = useCallback(
@@ -556,19 +565,43 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
: undefined : undefined
} }
composer={ composer={
<ReportComposer <div className="flex flex-col">
phase={phase} {showPlanRequestEntry ? (
busy={session.sendBusy} <div className="mb-1.5 flex justify-center">
target={composerTarget} <button
onClearTarget={() => setComposerTarget(null)} type="button"
onSend={(text) => className="rc-btn"
void session.send(text, { style={{
nodeId: composerTarget?.nodeId ?? null, background: "var(--rc-accent)",
color: "#fff",
borderColor: "var(--rc-accent)",
}}
disabled={planRequestBusy}
onClick={() => void handleRequestPlans()}
>
{planRequestBusy ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Sparkles className="size-3.5" aria-hidden />
)}
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
</button>
</div>
) : null}
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null, agentRunId: composerTarget?.agentRunId ?? null,
}) })
} }
focusSignal={focusSignal} focusSignal={focusSignal}
/> />
</div>
} }
/> />
} }
@@ -21,7 +21,6 @@ import type {
CollaborationCommand, CollaborationCommand,
CollaborationMessage, CollaborationMessage,
ReportPlanCandidate, ReportPlanCandidate,
ReportRequirementSnapshot,
ReportVersion, ReportVersion,
} from "@/report-collaboration/api/types"; } from "@/report-collaboration/api/types";
@@ -114,11 +113,6 @@ export type ReportCollaborationCardPayload =
selectedPlanId: string | null; selectedPlanId: string | null;
requirementRevision: number; requirementRevision: number;
} }
| {
kind: "requirement_ready";
requirement: ReportRequirementSnapshot;
revision: number;
}
| { | {
kind: "command_confirm"; kind: "command_confirm";
command: CollaborationCommand; command: CollaborationCommand;
@@ -169,17 +163,6 @@ export function buildPlanGroupCard(
}); });
} }
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
export function buildRequirementReadyCard(
requirement: ReportRequirementSnapshot,
): Message {
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
kind: "requirement_ready",
requirement,
revision: requirement.revision,
});
}
/** 命令确认卡(command.confirmation.required 之后)。 */ /** 命令确认卡(command.confirmation.required 之后)。 */
export function buildCommandConfirmCard(command: CollaborationCommand): Message { export function buildCommandConfirmCard(command: CollaborationCommand): Message {
return buildCardMessage(`rc-card-confirm-${command.command_id}`, { return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
@@ -850,21 +850,6 @@
animation: rc-edge-flow 0.5s linear infinite; animation: rc-edge-flow 0.5s linear infinite;
} }
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
.rc-edge-failed .react-flow__edge-path {
stroke: var(--rc-danger);
stroke-dasharray: 4 4;
}
.rc-edge-waiting .react-flow__edge-path {
stroke: var(--rc-waiting);
}
.rc-edge-dimmed .react-flow__edge-path {
stroke: var(--rc-flow-edge);
opacity: 0.45;
}
@keyframes rc-edge-flow { @keyframes rc-edge-flow {
from { from {
stroke-dashoffset: 10; stroke-dashoffset: 10;
@@ -39,16 +39,11 @@ export interface ChainFlowStage {
goal?: string | null; goal?: string | null;
} }
/** 节点执行状态(任务分析回放传入;缺省展示 Ready)。 */
export type ChainFlowSeatState = "waiting" | "running" | "done" | "failed";
export interface ChainFlowDiagramProps { export interface ChainFlowDiagramProps {
stages: ChainFlowStage[]; stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */ /** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void; onNodeClick?: (agentId: string, name?: string) => void;
className?: string; className?: string;
/** 传入时节点卡片叠加执行状态徽标(任务分析的运行回放);不影响原有展示。 */
seatStates?: Record<string, { state: ChainFlowSeatState; durationMs?: number }> | null;
} }
// ── 布局常量(世界坐标,单位 px)───────────────────────────────────────────── // ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
@@ -89,7 +84,7 @@ interface PlacedNode {
rightX: number; rightX: number;
} }
export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }: ChainFlowDiagramProps) { export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ────────────────────────────────────────── // ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => { const layout = useMemo(() => {
// 列:总控 + 各 Stage。 // 列:总控 + 各 Stage。
@@ -320,43 +315,15 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
</svg> </svg>
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 + {/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
seatStates 传入时(任务分析回放)叠加执行状态徽标与状态描边。 */}
{layout.cols.flatMap((col, colIdx) => {layout.cols.flatMap((col, colIdx) =>
col.nodes.map((n) => { col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID; const isCoordinator = n.id === COORDINATOR_ID;
const nc = nodeColorOf(n.avatarType); const nc = nodeColorOf(n.avatarType);
const seatState = !isCoordinator && seatStates ? seatStates[n.id] : undefined;
const state = seatState?.state;
const stateStyle = state
? ({
waiting: "text-muted-foreground",
running: "text-amber-600 dark:text-amber-300",
done: "text-emerald-600 dark:text-emerald-300",
failed: "text-red-600 dark:text-red-400",
} as const)[state]
: null;
const stateLabel =
state === "waiting"
? "等待"
: state === "running"
? "执行中"
: state === "done"
? "完成"
: state === "failed"
? "失败"
: null;
const stateSeconds = seatState?.durationMs
? Math.max(1, Math.round(seatState.durationMs / 1000))
: null;
return ( return (
<div <div
key={n.id} key={n.id}
className={cn( className={cn("absolute", !isCoordinator && "cursor-pointer")}
"absolute",
!isCoordinator && "cursor-pointer",
state === "waiting" && "opacity-60",
)}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }} style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onClick={() => { onClick={() => {
@@ -372,15 +339,9 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
isCoordinator isCoordinator
? "border-border bg-primary/[0.04]" ? "border-border bg-primary/[0.04]"
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]", : "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
state === "running" && "border-amber-400/80",
state === "failed" && "border-red-500/70",
)} )}
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。 // 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
style={{ style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
borderLeftColor: nc.hex,
borderLeftWidth: 4,
...(state === "running" ? { boxShadow: "0 0 0 3px rgba(245,158,11,0.15)" } : {}),
}}
> >
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */} {/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
<div className="flex items-start gap-2 overflow-hidden"> <div className="flex items-start gap-2 overflow-hidden">
@@ -398,34 +359,13 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
</div> </div>
</div> </div>
{/* 下:身份 + 状态行(回放模式显示执行状态,否则 Ready 心跳条) */} {/* 下:身份 + Ready 心跳条 */}
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground"> <div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span> <span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
{state && stateLabel ? ( <span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span <span className="size-1 rounded-full bg-emerald-500" />
className={cn( Ready
"ml-1.5 flex shrink-0 items-center gap-1 rounded px-1 py-0.5 font-medium", </span>
stateStyle,
)}
>
<span
className={cn(
"size-1.5 rounded-full",
state === "done" && "bg-emerald-500",
state === "running" && "animate-pulse bg-amber-500",
state === "failed" && "bg-red-500",
state === "waiting" && "bg-muted-foreground/60",
)}
/>
{stateLabel}
{stateSeconds != null && state !== "waiting" ? ` · ${stateSeconds}s` : ""}
</span>
) : (
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -505,33 +445,22 @@ export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }:
style={{ width: mmW, height: mmH }} style={{ width: mmW, height: mmH }}
onClick={onMinimapClick} onClick={onMinimapClick}
> >
{/* 节点缩略块(回放模式按状态着色) */} {/* 节点缩略块 */}
{layout.allNodes.map((n) => { {layout.allNodes.map((n) => (
const state = n.id === COORDINATOR_ID ? null : seatStates?.[n.id]?.state ?? null; <div
return ( key={n.id}
<div className={cn(
key={n.id} "absolute rounded-[1px]",
className={cn( n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
"absolute rounded-[1px]", )}
n.id === COORDINATOR_ID style={{
? "bg-primary/70" left: n.x * mmScale,
: state === "done" top: n.y * mmScale,
? "bg-emerald-500/70" width: NODE_W * mmScale,
: state === "running" height: NODE_H * mmScale,
? "bg-amber-500/80" }}
: state === "failed" />
? "bg-red-500/80" ))}
: "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
);
})}
{/* 当前视口框 */} {/* 当前视口框 */}
<div <div
className="absolute border-2 border-primary bg-primary/10" className="absolute border-2 border-primary bg-primary/10"
@@ -34,6 +34,8 @@ export interface PersonaDrawerProps {
selectedModel: string; selectedModel: string;
availableModels: ModelOption[]; availableModels: ModelOption[];
modelsLoading: boolean; modelsLoading: boolean;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示该行。 */
seatArtifactPositions?: Record<string, number>;
onClose: () => void; onClose: () => void;
onSave: (id: number) => void; onSave: (id: number) => void;
} }
@@ -66,11 +68,16 @@ export function PersonaDrawer({
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading, modelsLoading,
seatArtifactPositions,
onClose, onClose,
onSave, onSave,
}: PersonaDrawerProps) { }: PersonaDrawerProps) {
const cfg = agentConfigs[seatId]; const cfg = agentConfigs[seatId];
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体"; 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;
return ( 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"> <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">
@@ -153,6 +160,22 @@ export function PersonaDrawer({
</select> </select>
</div> </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="space-y-1">
<div className="flex items-center justify-between text-[9px] font-black text-slate-500"> <div className="flex items-center justify-between text-[9px] font-black text-slate-500">
<span>随机创造性倾向 (Temp)</span> <span>随机创造性倾向 (Temp)</span>
@@ -115,6 +115,8 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>; agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null; selectedAgentId: number | null;
roles: RoleEntry[]; roles: RoleEntry[];
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示。 */
seatArtifactPositions?: Record<string, number>;
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)────────────── // ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */ /** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null; seatFilter: SeatFilter | null;
@@ -187,6 +189,7 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs, agentConfigs,
selectedAgentId, selectedAgentId,
roles, roles,
seatArtifactPositions,
seatFilter, seatFilter,
setSeatFilter, setSeatFilter,
parallelBatch, parallelBatch,
@@ -580,6 +583,7 @@ export function Step2Panel(props: Step2PanelProps) {
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading} modelsLoading={modelsLoading}
seatArtifactPositions={seatArtifactPositions}
onClose={() => setSelectedAgentId(null)} onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona} onSave={onSavePersona}
/> />
@@ -55,12 +55,14 @@ function payloadFrom(
): TaskProcessReportPayload { ): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path); const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId; const agentId = target.agentId?.trim() || target.threadId;
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
const position = config.positionFor?.(target) ?? config.position;
return { return {
taskId: config.taskId, taskId: config.taskId,
agentId, agentId,
agentName: target.agentName?.trim() || agentId, agentName: target.agentName?.trim() || agentId,
content, content,
position: config.position, position,
reportName: name, reportName: name,
submitStatus, submitStatus,
}; };
@@ -146,8 +148,11 @@ export function useTaskArtifactSubmission(
let record = entriesRef.current[key]?.record; let record = entriesRef.current[key]?.record;
let reportId = record?.taskDeliveryId || undefined; let reportId = record?.taskDeliveryId || undefined;
const resumedRegistration = Boolean(reportId);
if (!reportId) { if (!reportId) {
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0)); // 外部任务系统(8BF)的实际契约:add 接口以 submitStatus=1 直接完成提交,
// 返回 {code:200, data:id}。登记即提交,不再走「先落 0 再改 1」两段式。
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 1));
reportId = receipt.id; reportId = receipt.id;
const artifact: RoundtableArtifactReference = { const artifact: RoundtableArtifactReference = {
threadId: target.threadId, threadId: target.threadId,
@@ -163,9 +168,11 @@ export function useTaskArtifactSubmission(
taskDeliveryId: reportId, taskDeliveryId: reportId,
artifact, 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, { const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: true, submitted: true,
taskDeliveryId: reportId, taskDeliveryId: reportId,
@@ -55,6 +55,13 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。 * 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/ */
position_id?: string | null; position_id?: string | null;
/**
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
* 注意与 `position_id`(岗位协同的岗位归属)无关。
*/
artifact_position?: number | null;
/** /**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示; * 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。 * 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
@@ -13,7 +13,14 @@ export interface TaskProcessReportPayload {
export interface TaskArtifactSubmissionConfig { export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */ /** External task-system id, not the local roundtable draft storage key. */
taskId: string; taskId: string;
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
position: number; position: number;
/**
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
* null / undefined = 该席位未配置,回退 `position` 默认值。
*/
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>; createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>; updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
} }
@@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null, seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null, position_id: s.position_id ?? null,
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
artifact_position: s.artifact_position ?? null,
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。 // 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null, job_responsibilities: s.job_responsibilities?.trim() || null,
}; };
@@ -361,6 +361,11 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false, hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */ /** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig, taskArtifactSubmissionConfig,
/**
* 席位 → 业务链配置的「产物提交 position」映射(键为小写 agent_id / 席位名)。
* 仅用于「智能体配置」抽屉里的只读展示,帮助核对提交产物时会带的 position 值。
*/
seatArtifactPositions,
/** /**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏; * 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。 * 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
@@ -417,6 +422,8 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string; newTaskLabel?: string;
hideHeaderSettings?: boolean; hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig; taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值),只读展示用。 */
seatArtifactPositions?: Record<string, number>;
step3Mode?: "full" | "report-only"; step3Mode?: "full" | "report-only";
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -2614,6 +2621,7 @@ export default function RoundtablePlanningPage({
orchestrationPlan={orchestrationPlan} orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates} dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole} agentNameToRole={step2.agentNameToRole}
seatArtifactPositions={seatArtifactPositions}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading} modelsLoading={modelsLoading}
@@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]", : "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 = { type HeaderProps = {
collapsed?: boolean; collapsed?: boolean;
}; };
@@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => {
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname); const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id)); // 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id)); // 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。
const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2);
const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex);
const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex);
const handleNavItemClick = (item: MenuItem) => { const handleNavItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children); const path = item.path ?? findFirstRoutePath(item.children);
@@ -73,6 +73,21 @@ export interface LlmWikiKnowledgeBase {
is_conversation_deposit?: boolean; 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 { export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
wiki: { wiki: {
status: "ready" | "empty" | "unavailable"; status: "ready" | "empty" | "unavailable";
@@ -268,6 +283,9 @@ export interface LlmWikiWikiIndexStatus {
export interface LocalWikiEncoderStatus { export interface LocalWikiEncoderStatus {
provider: string; provider: string;
configured_provider?: string | null;
mode?: "configured" | "local";
using_local?: boolean;
model: string; model: string;
dimensions?: number | null; dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed"; state: "idle" | "starting" | "ready" | "failed";
@@ -424,6 +442,17 @@ 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( export async function createLlmWikiWeKnoraEmbedSession(
id: string, id: string,
tab: "documents" | "wiki" | "graph", tab: "documents" | "wiki" | "graph",
@@ -457,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: {
description: string; description: string;
type: "document" | "faq"; type: "document" | "faq";
wiki_enabled?: boolean; wiki_enabled?: boolean;
embedding_model_id?: string;
}): Promise<LlmWikiKnowledgeBase> { }): Promise<LlmWikiKnowledgeBase> {
return readJson( return readJson(
await apiFetch(`${API()}/knowledge-bases`, { await apiFetch(`${API()}/knowledge-bases`, {
@@ -654,6 +684,13 @@ 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( export async function uploadLocalWikiEncoder(
files: File[], files: File[],
onProgress?: (percent: number) => void, onProgress?: (percent: number) => void,
@@ -688,10 +725,15 @@ export async function uploadLocalWikiEncoder(
payload = null; payload = null;
} }
if (request.status < 200 || request.status >= 300) { if (request.status < 200 || request.status >= 300) {
const detail = payload && typeof payload === "object" && "detail" in payload const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail ? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`; : `请求失败 (${request.status})`;
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail))); 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)));
return; return;
} }
onProgress?.(100); onProgress?.(100);
@@ -1268,6 +1310,17 @@ 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() { export function useLlmWikiActions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateKnowledge = () => const invalidateKnowledge = () =>
@@ -34,6 +34,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -48,7 +55,9 @@ import {
searchLlmWiki, searchLlmWiki,
startLocalWikiEncoder, startLocalWikiEncoder,
uploadLocalWikiEncoder, uploadLocalWikiEncoder,
restoreConfiguredWikiEncoder,
useLlmWikiActions, useLlmWikiActions,
useLlmWikiEmbeddingModels,
useLlmWikiKnowledgeBases, useLlmWikiKnowledgeBases,
useLlmWikiSettings, useLlmWikiSettings,
useLlmWikiSettingsActions, useLlmWikiSettingsActions,
@@ -68,6 +77,14 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试"; 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"]) { function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索"; if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索"; if (mode === "mixed") return "Wiki 混合检索";
@@ -457,18 +474,50 @@ function CreateKnowledgeDialog({
open: boolean; open: boolean;
pending: boolean; pending: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>; onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true); 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(() => { useEffect(() => {
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); } if (!open) {
setName("");
setDescription("");
setWikiEnabled(true);
setEmbeddingModelId("");
}
}, [open]); }, [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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader> <DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2"> <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-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> <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>
@@ -476,8 +525,33 @@ 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(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> <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></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> </div>
<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> <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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -501,11 +575,12 @@ export function LocalEncoderSettingsCard() {
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => { const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status); queryClient.setQueryData(["llmwiki-local-encoder"], status);
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
setModelPath(status.model_path || ""); setModelPath(status.model_path || "");
}; };
const configure = useMutation({ const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()), mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); }, onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
onError: (error) => toast.error(messageOf(error)), onError: (error) => toast.error(messageOf(error)),
}); });
const upload = useMutation({ const upload = useMutation({
@@ -514,7 +589,7 @@ export function LocalEncoderSettingsCard() {
onSuccess: (status) => { onSuccess: (status) => {
applyStatus(status); applyStatus(status);
setFiles([]); setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`); toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
}, },
onError: (error) => toast.error(messageOf(error)), onError: (error) => toast.error(messageOf(error)),
}); });
@@ -526,7 +601,17 @@ export function LocalEncoderSettingsCard() {
}, },
onError: (error) => toast.error(messageOf(error)), 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 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 selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3 const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB` ? `${(value / 1024 ** 3).toFixed(2)} GB`
@@ -535,29 +620,49 @@ export function LocalEncoderSettingsCard() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle> <CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription> <CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}> <Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"} {encoder.isError
? "状态读取失败"
: encoder.isLoading
? "读取中"
: !usingLocal
? "配置编码器生效中"
: status?.state === "ready"
? "本地编码器已就绪"
: status?.state === "starting"
? "本地编码器启动中"
: status?.state === "failed"
? "本地编码器启动失败"
: "本地编码器未启动"}
</Badge> </Badge>
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span> {status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</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>}
</div> </div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>} {encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.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>} {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>}
<div className="space-y-2"> <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"> <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" /> <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()}> <Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录 {configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p> <p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
</div> </div>
<div className="space-y-2 rounded-xl border border-dashed p-4"> <div className="space-y-2 rounded-xl border border-dashed p-4">
@@ -576,7 +681,7 @@ export function LocalEncoderSettingsCard() {
)} )}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"} {files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
</p> </p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}> <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" />} {upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
@@ -586,10 +691,16 @@ export function LocalEncoderSettingsCard() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}> <Button disabled={!usingLocal || !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" />} {(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"} {!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button> </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()}> <Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态 <RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button> </Button>
@@ -601,13 +601,19 @@ export function WeKnoraWikiManagementPage() {
} }
}; };
const vectorStatusText = !indexStatus.data?.local_index_enabled const vectorStatusText = indexStatus.isLoading
? "本地向量未启用,检索使用 Wiki 接口" ? "正在读取 Wiki 镜像向量状态"
: indexStatus.data.fully_vectorized : indexStatus.isError
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)` ? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding" : !indexStatus.data?.local_index_enabled
? "整库向量化进行中,完成前检索使用 Wiki 接口" ? "Wiki 镜像向量未启用,检索使用 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 接口";
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-full min-h-0 flex-col bg-background">
@@ -626,7 +632,7 @@ export function WeKnoraWikiManagementPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => void vectorize()} onClick={() => void vectorize()}
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"} disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
> >
{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" /> <Loader2 className="mr-2 size-4 animate-spin" />
@@ -652,6 +658,17 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden"> <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"> <aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3"> <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 && ( {indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3"> <div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
@@ -0,0 +1,736 @@
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
@@ -0,0 +1,44 @@
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;
}
}
@@ -0,0 +1,350 @@
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. 脆弱群体保障覆盖率和公众反馈闭环时长。
## 五、止损与调整机制
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
`;
@@ -0,0 +1,329 @@
/**
* 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
@@ -0,0 +1,408 @@
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);
}
@@ -0,0 +1,29 @@
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",
);
});
@@ -0,0 +1,28 @@
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;
}
@@ -0,0 +1,147 @@
/**
* 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);
});
@@ -0,0 +1,362 @@
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();
}
}
@@ -0,0 +1,469 @@
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);
}
@@ -0,0 +1,112 @@
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] : [];
});
}
@@ -0,0 +1,66 @@
/**
* 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,
);
});
@@ -0,0 +1,128 @@
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