代码提交

This commit is contained in:
董宏凯 committed 2026-09-06 20:25:14 +08:00
1 parent 5e953c9fcb
commit bf42a48fcc
16 files changed
+716 -1560

No files matched your search

@@ -0,0 +1,481 @@
# 报告协作工作台 — 前端对接指南
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
> **配套文档:**
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
---
## 1. 结论速览
| 问题 | 答案 |
|------|------|
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
---
## 2. 架构:前端已有什么
```
ReportCollaborationPage
└─ useReportCollaborationSession(唯一数据入口)
├─ driver.getSnapshot() ← REST 冷启动
├─ driver.openRunStream() ← SSE 实时事件
├─ driver.sendMessage / createCommand / startRun …
└─ event-reducer ← 事件 → UI 状态
ReportConversationPanel
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
```
### 2.1 目录结构
```text
frontend-web/src/report-collaboration/
api/
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
sessions.ts # 会话 CRUD、消息
plans.ts # 方案请求 / 列表 / 选定
runs.ts # Run、SSE、命令、节点合同
reports.ts # 报告版本、改写、恢复
driver.ts # 真实后端统一门面(Mock 驱动已移除)
state/
event-normalizer.ts
event-reducer.ts
selectors.ts
message-adapter.ts
hooks/
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
useReportCollaborationStream.ts # 断线重连、after_seq 续接
useReportCollaborationMessages.ts
components/ …
pages/ReportCollaborationPage.tsx
```
### 2.2 事实源原则
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
---
## 3. 后端启用与部署
### 3.1 必改配置
文件:`offline-backend-20260512/backend/config.yaml`
```yaml
report_collaboration:
enabled: false # 联调必须改为 true
worker_enabled: false # 要看成员执行必须 true
planner_model: null # 建议填可用模型名
coordinator_model: null
research_model: null
writer_model: null
reviewer_model: null
# … token_budget、max_concurrent_runs_per_user 等见同文件
```
| 配置 | 现象 |
|------|------|
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
| 两者 `true` + 模型配置完整 | 完整联调 |
### 3.2 MySQL / 表结构
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
### 3.3 前端环境变量
| 变量 | 说明 |
|------|------|
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
---
## 4. API 基址与请求约定
### 4.1 Base URL
```text
{VITE_BACKEND_BASE_URL}/api/report-collaboration
```
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
### 4.2 请求头
| Header | 何时携带 | 说明 |
|--------|----------|------|
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
| `Accept: text/event-stream` | SSE | `openRunStream` |
### 4.3 错误响应格式
```json
{
"code": "REPORT_COLLABORATION_DISABLED",
"message": "报告协作工作台未启用",
"detail": { }
}
```
前端映射(`api/client.ts`):
| HTTP | 异常类 | 典型场景 |
|------|--------|----------|
| 409 | `ConflictError` | revision 过期、active run 冲突 |
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
| 5xx | `ServerError` | 可带同一幂等键重试 |
| 0 | `NetworkError` | fetch 失败 |
### 4.4 完整路由清单(与前端 client 对齐)
| 方法 | 路径 | 前端模块 |
|------|------|----------|
| POST | `/sessions` | `sessions.createSession` |
| GET | `/sessions` | `sessions.listSessions` |
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
| POST | `/sessions/{id}/runs` | `runs.startRun` |
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
| GET | `/runs/{id}` | `runs.getRun` |
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
| POST | `/runs/{id}/commands` | `runs.createCommand` |
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
---
## 5. 用户流程与 API 时序
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端 RC
participant API as Gateway
participant W as Worker
U->>FE: 输入需求
FE->>API: POST /sessions
FE->>API: POST /sessions/{id}/messages
API-->>FE: 澄清(快照 messages)
U->>FE: 回答澄清
FE->>API: POST /messages
FE->>API: POST /plan-requests
API-->>FE: 候选方案(快照 plans)
U->>FE: 采用方案
FE->>API: POST /plans/{id}/select
U->>FE: 开始协作
FE->>API: POST /runs
FE->>API: GET /runs/{id}/stream
W-->>API: 持久化事件
API-->>FE: SSE
U->>FE: 运行中干预
FE->>API: POST /commands → confirm
```
### 5.1 阶段 0:进入页面
| 路由 | 行为 |
|------|------|
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
### 5.2 阶段 1:需求澄清(无 run)
```http
POST /api/report-collaboration/sessions
POST /api/report-collaboration/sessions/{sessionId}/messages
Content-Type: application/json
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
```
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
### 5.3 阶段 2:候选方案
```http
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
```
- **预览方案**(`previewPlanId`):纯前端,不调 API。
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
### 5.4 阶段 3:开始执行与 SSE
```http
POST /api/report-collaboration/sessions/{sessionId}/runs
{ "plan_id": "plan-xxx" }
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
Accept: text/event-stream
```
SSE 每条 `data:` 为 JSON:
```typescript
interface CollaborationEvent {
eventId: string;
sessionId: string;
runId?: string | null;
seq: number;
type: CollaborationEventType;
timestamp: string;
data: unknown;
}
```
常用 `type`(完整列表见 `api/types.ts`):
| 类型 | 含义 |
|------|------|
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
| `team_message.*` | 成员 / 协调者广播 |
| `plan.proposed` / `plan.selected` | 方案生命周期 |
| `run.status.changed` | Run 状态 |
| `node.status.changed` / `node.progress` | 节点执行 |
| `command.confirmation.required` | 需用户确认的干预 |
| `report.version.created` | 报告版本落盘 |
| `heartbeat` | 保活 |
### 5.5 阶段 4:运行中干预
```http
POST /api/report-collaboration/runs/{runId}/commands
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
POST /api/report-collaboration/runs/{runId}/cancel
```
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
### 5.6 阶段 5:报告版本与改写
```http
GET /api/report-collaboration/sessions/{sessionId}/reports
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
```
- `createRewrite` 只生成候选,不直接覆盖正式版本。
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
---
## 6. 前端状态机(接入方须知)
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
```text
SessionSnapshot → applySessionSnapshot()
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
→ selectors(derivePagePhase、selectPlanForCanvas、…)
```
### 6.1 核心 Selector
| Selector | 用途 |
|----------|------|
| `derivePagePhase` | Composer 占位、方案卡、空态 |
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
| `selectAgentTabs` | 并行成员 Tab |
| `selectNodeStatusMap` | 节点颜色 / 状态 |
| `selectPendingConfirmationCommands` | 干预确认卡 |
| `selectHeadReportVersion` | 报告预览入口 |
| `selectStreamingAgentRunIds` | 流式成员指示 |
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
### 6.3 乐观消息
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
---
## 7. 消息与 MessageList 复用
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
| 来源 | 渲染 |
|------|------|
| `human` / `ai` 文本 | 普通气泡 |
| `tool_calls` + `tool` | 现有工具卡 |
| `team_message` 事件 | 带 `name` 的成员发言 |
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
### 7.1 在其他页面嵌入 RC(最小集)
1. `useReportCollaborationSession(sessionId)`
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
3. 引入 `report-collaboration/styles/report-collaboration.css`
---
## 8. 单测
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
单测:
```bash
cd frontend-web
pnpm test:report-collaboration
```
---
## 9. 联调验收清单
### 9.1 准备
- [ ] `report_collaboration.enabled: true`
- [ ] `report_collaboration.worker_enabled: true`
- [ ] 五个 `*_model` 已配置且可用
- [ ] Gateway 已重启(MySQL 表/列就绪)
- [ ] 前端已登录(JWT 有效)
### 9.2 探活
```bash
curl -s -H "Authorization: Bearer <token>" \
http://127.0.0.1:8001/api/report-collaboration/sessions
```
期望:**200** + `{ "sessions": [...] }`,而非 503。
### 9.3 页面流程(浏览器 Network)
1. 打开 `/#/page/workspace/report-collaboration`
2. 输入需求 → `POST .../sessions` **200**
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
4. 首条消息 → `POST .../messages` **200**
5. 请求方案 → `POST .../plan-requests` **200**
6. 采用方案 → `POST .../plans/{id}/select` **200**
7. 开始协作 → `POST .../runs` **200**
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
9. 刷新页面 → 消息与 `last_seq` 续接正确
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
### 9.4 常见失败
| 现象 | 原因 | 处理 |
|------|------|------|
| 全部 503 | `enabled: false` | 改配置并重启 |
| run 一直 queued | `worker_enabled: false` | 改为 true |
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
---
## 10. 契约与类型维护
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
`SessionSnapshot` 形状(摘要):
```typescript
interface SessionSnapshot {
session: ReportSession;
messages: CollaborationMessage[];
plans: ReportPlanCandidate[];
run: ReportRun | null;
nodes: CollaborationNodeState[];
agent_runs: AgentRun[];
commands: CollaborationCommand[];
report_versions: ReportVersion[];
last_seq: number;
}
```
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
---
## 11. 后续工作(非对接阻塞)
| 项 | 说明 |
|----|------|
| RC-BE-019 | 灰度发布与离线部署文档 |
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
---
## 12. 相关代码索引
| 用途 | 路径 |
|------|------|
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -100,13 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-report-collaboration",
// label: "报告协作",
// icon: FileText,
// path: "/page/workspace/report-collaboration",
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
// },
{
id: "workspace-report-collaboration",
label: "报告协作",
icon: FileText,
path: "/page/workspace/report-collaboration",
match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
+12 -13
View File
@@ -3,18 +3,19 @@
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
**进度(2026-09-05):** 本模块页面与契约已完成。后端 `RC-BE-000`~`011` 已通规划前 REST 与 durable SSE;成员自动执行 / 报告改写仍未就绪,联调见后端计划 §0。
**联调对接:** `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`(环境配置、API 时序、验收清单)。
**进度(2026-09-06):** 页面与契约已完成,默认对接真实后端(`GET/POST /api/report-collaboration/*`)。后端 `RC-BE-000`~`018` 已落地。联调见 `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`。
## 结构
```text
src/report-collaboration/
api/ # 契约类型 + REST/SSE 客户端 + Mock 驱动
api/ # 契约类型 + REST/SSE 客户端
types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
driver.ts # 真实 / Mock 统一门面(getReportCollaborationDriver)
mock.ts # 本地演示驱动:澄清→3 方案→并行执行→报告版本,全走同一事件协议
driver.ts # 真实后端统一门面(getReportCollaborationDriver;Mock 驱动已移除)
state/
event-normalizer.ts # SSE/REST 事件防御性归一化
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
@@ -50,20 +51,18 @@ src/report-collaboration/
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
## Mock 演示(执行内核未就绪时)
## 只走真实后端
规划前 REST 可走真实后端(需打开 `report_collaboration.enabled`)。成员自动执行、报告改写仍用 mock:
Mock 驱动(`mock.ts`)与切换开关(`deerflow.rc-mock` / `VITE_RC_MOCK`)已移除,
页面永远请求真实后端。历史遗留的浏览器设置可执行
`localStorage.removeItem('deerflow.rc-mock')` 清掉(不再被读取)。日常联调请保证:
- `localStorage.setItem('deerflow.rc-mock','1')` 后刷新;
- 环境变量 `VITE_RC_MOCK=1`。
Mock 走与真实后端相同的驱动接口与事件协议(seq 单调、可重放),可完整演示:
输入需求 → 澄清 → 回答 → 3 个候选方案 → 预览/采用 → 开始协作 → 并行成员流式输出 +
工具调用 → 命令确认卡(输入「重新检索…」)→ 报告版本 → 预览/下载/恢复。
- `config.yaml` → `report_collaboration.enabled: true`
- 需要成员自动执行时再开 `worker_enabled: true`
## 后端契约冻结说明
`api/types.ts` 按后端计划 §5/§7 编写,已与 `app/report_collaboration/contracts/` 对齐。
快照端点约定 `GET /sessions/{id}` 返回 `SessionSnapshot`
(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
`GET /runs/{id}/stream` 已按 `after_seq` / `Last-Event-ID` 重放 durable 事件(`RC-BE-011`)。queued run 的领取/租约恢复已落地(`RC-BE-012`),质量角色内核已落地(`RC-BE-013`)。默认 `worker_enabled=false` 仍不挂后台循环。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
`GET /runs/{id}/stream` 已按 `after_seq` / `Last-Event-ID` 重放 durable 事件(`RC-BE-011`)。queued run 的领取/租约恢复已落地(`RC-BE-012`),质量角色内核已落地(`RC-BE-013`)。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
@@ -1,18 +1,11 @@
/**
* 驱动接口:真实后端 / 本地 Mock 的统一门面。
*
* Mock 开关(任一为真即 Mock,便于后端未就绪时演示与联调自测):
* - 环境变量 `VITE_RC_MOCK=1`;
* - localStorage `deerflow.rc-mock` = `"1"`(浏览器里临时打开:
* `localStorage.setItem('deerflow.rc-mock','1')` 后刷新)。
* 驱动接口:直接请求真实后端(不再提供 Mock 驱动)。
*/
import * as plansApi from "@/report-collaboration/api/plans";
import * as reportsApi from "@/report-collaboration/api/reports";
import * as runsApi from "@/report-collaboration/api/runs";
import * as sessionsApi from "@/report-collaboration/api/sessions";
import { mockDriver } from "@/report-collaboration/api/mock";
import { ApiError } from "@/report-collaboration/api/client";
import type {
CollaborationCommand,
CollaborationEvent,
@@ -92,7 +85,7 @@ export interface ReportCollaborationDriver {
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
}
const realDriver: ReportCollaborationDriver = {
export const realDriver: ReportCollaborationDriver = {
listSessions: sessionsApi.listSessions,
getSnapshot: sessionsApi.getSessionSnapshot,
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
@@ -119,113 +112,6 @@ const realDriver: ReportCollaborationDriver = {
restoreReportVersion: reportsApi.restoreReportVersion,
};
let cachedDriver: ReportCollaborationDriver | null = null;
/** DEV 下探测到后端无 /api/report-collaboration(404/网络失败/非 JSON)后自动切 Mock。 */
let devFallbackToMock = false;
export function isMockEnabled(): boolean {
try {
if (import.meta.env.VITE_RC_MOCK === "1") return true;
if (typeof window !== "undefined") {
return window.localStorage.getItem("deerflow.rc-mock") === "1";
}
} catch {
// localStorage 不可用时按真实后端处理。
}
return false;
}
/** 显式关闭(localStorage "0"),优先级高于 DEV 自动回退。 */
function isMockExplicitlyDisabled(): boolean {
try {
return typeof window !== "undefined" && window.localStorage.getItem("deerflow.rc-mock") === "0";
} catch {
return false;
}
}
/** 后端尚未实现该路由的特征:404/405/501、网络失败、或 SPA fallback 返回的非 JSON。 */
function isFeatureMissingError(err: unknown): boolean {
if (err instanceof ApiError) {
// 405:网关对未知 POST 路由的响应形态;404/501:路由未注册。
return err.status === 404 || err.status === 405 || err.status === 501 || err.status === 0;
}
// rcRequest 对 200+HTML(dev server SPA fallback)会抛 JSON.parse 的 SyntaxError。
return err instanceof SyntaxError;
}
async function withDevFallback<T>(op: () => Promise<T>, retry: () => Promise<T>): Promise<T> {
try {
return await op();
} catch (err) {
if (!canDevFallback(err)) throw err;
enableMockFallback();
return retry();
}
}
function canDevFallback(err: unknown): boolean {
return (
import.meta.env.DEV &&
!devFallbackToMock &&
!isMockExplicitlyDisabled() &&
isFeatureMissingError(err)
);
}
function enableMockFallback(): void {
devFallbackToMock = true;
console.info(
"[report-collaboration] 后端 /api/report-collaboration 尚不可用,开发模式自动切换到本地 Mock 演示驱动(localStorage deerflow.rc-mock=0 可强制关闭)。",
);
}
/** DEV 自动回退驱动:命中回退标志后所有方法直达 Mock;否则先走真实后端。 */
const devFallbackEntries: Partial<ReportCollaborationDriver> = {
async listSessions(signal) {
if (devFallbackToMock) return mockDriver.listSessions(signal);
return withDevFallback(
() => realDriver.listSessions(signal),
() => mockDriver.listSessions(signal),
);
},
async getSnapshot(sessionId, signal) {
if (devFallbackToMock) return mockDriver.getSnapshot(sessionId, signal);
const snapshot = await realDriver.getSnapshot(sessionId, signal);
// 真实后端 404 且 DEV 未定态时,可能是功能未部署:探测一次并转 Mock。
if (
snapshot === null &&
import.meta.env.DEV &&
!isMockExplicitlyDisabled() &&
!devFallbackToMock
) {
enableMockFallback();
return mockDriver.getSnapshot(sessionId, signal);
}
return snapshot;
},
async createSession(title) {
if (devFallbackToMock) return mockDriver.createSession(title);
return withDevFallback(
() => realDriver.createSession(title),
() => mockDriver.createSession(title),
);
},
};
const devFallbackDriver: ReportCollaborationDriver = new Proxy(devFallbackEntries, {
get(target, prop, receiver) {
if (Reflect.has(target, prop)) {
return Reflect.get(target, prop, receiver);
}
const active = devFallbackToMock ? mockDriver : realDriver;
const value = Reflect.get(active, prop);
return typeof value === "function" ? value.bind(active) : value;
},
}) as ReportCollaborationDriver;
export function getReportCollaborationDriver(): ReportCollaborationDriver {
if (cachedDriver) return cachedDriver;
cachedDriver = isMockEnabled() ? mockDriver : devFallbackDriver;
return cachedDriver;
return realDriver;
}
File diff suppressed because it is too large. Load diff
@@ -30,6 +30,7 @@ export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: stri
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [busyId, setBusyId] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const reload = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
@@ -82,20 +83,70 @@ export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: stri
}
};
const clearAllSessions = async () => {
const list = sessions ?? [];
if (list.length === 0 || clearing) return;
if (
!window.confirm(
`将删除全部 ${list.length} 个历史会话(含消息、方案与报告版本),且不可恢复。确定清空?`,
)
) {
return;
}
setClearing(true);
try {
// 个别会话可能因运行中的协作删除失败(409 等),用 allSettled 尽量多删。
const results = await Promise.allSettled(
list.map((session) => getReportCollaborationDriver().deleteSession(session.id)),
);
const failedCount = results.filter((result) => result.status === "rejected").length;
const activeDeleted = list.some(
(session, index) => results[index].status === "fulfilled" && session.id === activeSessionId,
);
await reload(true);
if (failedCount > 0) {
setError(`已删除 ${list.length - failedCount} 个会话,${failedCount} 个删除失败(可能存在运行中的协作),可稍后重试。`);
} else {
setError(null);
}
if (activeDeleted) {
navigate("/page/workspace/report-collaboration");
}
} finally {
setClearing(false);
}
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[12px] text-[var(--rc-text-secondary)]">
<div className="flex items-center justify-between gap-2 px-3 py-2">
<span className="shrink-0 text-[12px] text-[var(--rc-text-secondary)]">
{loading ? "加载中…" : `${sessions?.length ?? 0} 个会话`}
</span>
<button
type="button"
className="rc-btn"
onClick={() => navigate("/page/workspace/report-collaboration")}
>
<MessageSquarePlus className="size-3.5" aria-hidden />
新建会话
</button>
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
className="rc-btn"
disabled={clearing || !sessions?.length}
title={sessions?.length ? "删除全部历史会话" : "暂无历史会话"}
onClick={() => void clearAllSessions()}
>
{clearing ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Trash2 className="size-3.5" aria-hidden />
)}
清空历史
</button>
<button
type="button"
className="rc-btn"
onClick={() => navigate("/page/workspace/report-collaboration")}
>
<MessageSquarePlus className="size-3.5" aria-hidden />
新建会话
</button>
</div>
</div>
{error ? (
<p className="px-3 pb-1 text-[11px]" style={{ color: "var(--rc-danger)" }}>
@@ -288,6 +288,9 @@ export function useReportCollaborationSession(sessionId: string): UseReportColla
idempotencyKey,
);
}
// POST /messages 只回用户消息;协调者澄清/就绪回复写在同一次请求里,
// 必须立刻拉快照,否则本地 status 仍是 empty,轮询不会启动、AI 气泡不出现。
await loadSnapshot(true);
} catch (err) {
// 失败保留乐观消息与输入,提供重试入口(不回滚 UI)。
setActionError(err instanceof Error ? err.message : String(err));
@@ -295,17 +298,18 @@ export function useReportCollaborationSession(sessionId: string): UseReportColla
setSendBusy(false);
}
},
[driver, sessionId, state.run],
[driver, loadSnapshot, sessionId, state.run, state.session?.status],
);
const requestPlans = useCallback(async () => {
setActionError(null);
try {
await driver.requestPlans(sessionId, state.session?.requirement_revision ?? null);
await loadSnapshot(true);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
}, [driver, sessionId, state.session]);
}, [driver, loadSnapshot, sessionId, state.session]);
const selectPlan = useCallback(
async (planId: string) => {
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import { FileText, Sparkles } from "lucide-react";
import { FileText, Loader2, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
@@ -341,6 +341,35 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
[sessionId, session],
);
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
const requirementReady = useMemo(
() =>
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
),
[state.messagesById],
);
const showPlanRequestEntry =
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
const [planRequestBusy, setPlanRequestBusy] = useState(false);
const handleRequestPlans = useCallback(async () => {
setPlanRequestBusy(true);
try {
await getReportCollaborationDriver().requestPlans(
sessionId,
state.session?.requirement_revision ?? null,
);
await session.refreshSnapshot();
toast.success("已生成候选方案,可在对话中选择");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setPlanRequestBusy(false);
}
}, [sessionId, session, state.session?.requirement_revision]);
const businessCardSlot = useCallback(
(message: Message) => {
const card = readReportCollaborationCard(message);
@@ -536,19 +565,43 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
: undefined
}
composer={
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
<div className="flex flex-col">
{showPlanRequestEntry ? (
<div className="mb-1.5 flex justify-center">
<button
type="button"
className="rc-btn"
style={{
background: "var(--rc-accent)",
color: "#fff",
borderColor: "var(--rc-accent)",
}}
disabled={planRequestBusy}
onClick={() => void handleRequestPlans()}
>
{planRequestBusy ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Sparkles className="size-3.5" aria-hidden />
)}
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
</button>
</div>
) : null}
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
</div>
}
/>
}
@@ -176,7 +176,6 @@ class AgentScopeMemberKernel:
model_snapshot,
app_config=self._app_config,
node_run_id=assignment.node_run_id,
budget_guard=budget,
)
)
permission = context.get("permission")
+2 -2
View File
@@ -723,8 +723,8 @@ workflows:
# AgentScope 多智能体报告协作工作台(默认关闭;不改变现有会商 / Workflow Studio)
report_collaboration:
enabled: false
worker_enabled: false
enabled: true
worker_enabled: true
planner_model: null
coordinator_model: null
research_model: null
@@ -39,7 +39,7 @@ allowed-tools:
```
- `taskId` / `actionId` 必须是整数,等于当前任务与行动 ID。
- `userList` 每项是完整 user 对象:`id`、`name` 必填;`platformType` 写入 accountPlat;`accountSource` 优先取 `dataSource`,缺失时回退 `score`;整个对象 JSON 序列化后存入 `frofileInfo`。
- `userList` 每项是完整 user 对象:`id`、`name` 必填;`platformType` 写入 accountPlat——缺失时可只给 `platform` 平台名(如 `facebook`、`instagram`),脚本登录后自动查平台字典表(sys-dict-data,dictType=platform)按前端 platformTypeChange 同款规则映射为编码(未识别回退 1,两者都缺则置空);`accountSource` 优先取 `dataSource`,缺失时回退 `score`;整个对象 JSON 序列化后存入 `frofileInfo`。
- `recReason` 中的 `<n>` 引用标记会被自动清洗,无需预处理。
完整字段定义、账号对象字段表与生成规则,见:
@@ -13,6 +13,7 @@
"endpoints": {
"login": "/api/auth/login",
"platform_dict": "/api/sys/sys-dict-data",
"design_list": "/api/task/task-action-design",
"design_create": "/api/task/task-action-design",
"design_delete": "/api/task/task-action-design",
@@ -86,6 +86,48 @@ def clean_reason(text) -> str:
return text.replace("、", "").replace("引用", "").replace("()", "")
def platform_type_change(platform, options):
"""
平台名/别名 → 平台字典编码(写入 task-action-design-account.accountPlat)。
移植自前端 chat-data.utils.platformTypeChange:子串匹配 fb / x|witter / in / re / th → 字典 label
命中取 value;否则 label 全量小写精确匹配;仍匹配不到回退 1(facebook,与前端一致)。
options 形如 [{"label": "facebook", "value": 1}, ...],来自 sys-dict-data 的 platform 字典。
技能侧扩展:入参已是编码(int 或纯数字字符串)时原样透传;完全缺失(None/空)返回 None(不臆造)。
"""
if platform is None or (isinstance(platform, str) and not platform.strip()):
return None
if isinstance(platform, bool):
return None
if isinstance(platform, int):
return platform
if isinstance(platform, str) and platform.strip().isdigit():
return int(platform.strip())
low = str(platform).strip().lower()
def val(*labels):
for lab in labels:
for o in options:
if str(o.get("label", "")).strip().lower() == lab and o.get("value") is not None:
return o.get("value")
return None
if "fb" in low:
return val("facebook") or 1
if "x" in low or "witter" in low:
return val("x(twitter)", "x") or 1
if "in" in low:
return val("instagram") or 1
if "re" in low:
return val("reddit") or 1
if "th" in low:
return val("threads") or 1
for o in options:
if str(o.get("label", "")).strip().lower() == low and o.get("value") is not None:
return o.get("value")
return 1
class ApiClient:
def __init__(self, cfg: dict, dry_run: bool = False):
self.base = cfg["base_url"].rstrip("/")
@@ -239,6 +281,24 @@ def main():
client.token = token
result["登录"] = f"成功(已获取 {cfg['auth']['token_field']},后续请求头 {cfg['auth']['header_name']}: {cfg['auth']['header_template']})"
# ---- 1.5 平台字典(platform 名称/别名 → 字典编码,写入 accountPlat 前映射;与前端 platformTypeChange 同款) ----
platform_options = []
dict_status, dict_resp = client.request("查询平台字典", "GET", ep(cfg, "platform_dict"),
query={"filter": ["dictType||$eq||platform"], "limit": 99999})
dict_rows = (dict_resp or {}).get("data") or [] if isinstance(dict_resp, dict) else []
if dict_status == 200:
platform_options = [
{"label": r.get("dictLabel"), "value": int(str(r["dictValue"]).strip())}
for r in dict_rows
if r.get("dictValue") is not None and str(r.get("dictValue")).strip().lstrip("-").isdigit()
]
result["平台字典"] = (
f"已加载 {len(platform_options)} 项:"
+ "、".join(f"{o['label']}={o['value']}" for o in platform_options)
)
else:
result["平台字典"] = f"查询失败(HTTP {dict_status}),平台名只能按子串规则映射且未命中回退 1,建议检查字典表"
# ---- 2. 查询已有受众(查重) ----
q = {"filter": [f"taskId||$eq||{task_id}", f"actionId||$eq||{action_id}"], "limit": 99999}
status, designs_resp = client.request("查询已有受众", "GET", ep(cfg, "design_list"), query=q)
@@ -298,11 +358,17 @@ def main():
# ---- 5. 创建账号关联 ----
for user in g.get("userList") or []:
account_source = user.get("dataSource") if user.get("dataSource") is not None else user.get("score")
# 平台编码:user.platformType(编码)缺失时回退 user.platform(名称),统一经平台字典映射为编码
raw_platform = user.get("platformType") if user.get("platformType") is not None else user.get("platform")
account_plat = platform_type_change(raw_platform, platform_options)
group.setdefault("账号平台映射", []).append(
{"accountName": user.get("name"), "原始值": raw_platform, "accountPlat": account_plat}
)
account_body = {
"taskId": task_id, "actionId": action_id,
"accountId": user.get("id"),
"accountName": user.get("name"),
"accountPlat": user.get("platformType"),
"accountPlat": account_plat,
"accountSource": account_source,
"frofileInfo": json.dumps(user, ensure_ascii=False),
"designId": design_id,
+3 -1
View File
@@ -15,7 +15,9 @@ FRONTEND_PORT="${FRONTEND_PORT:-5174}"
detect_host_ip() {
if command -v hostname >/dev/null 2>&1; then
hostname -I 2>/dev/null | awk '{print $1}'
# `hostname -I` is unsupported on Windows Git Bash; `|| true` keeps the
# pipefail-safe fallback (HOST_IP then defaults to 127.0.0.1 below).
hostname -I 2>/dev/null | awk '{print $1}' || true
fi
}
+2 -1
View File
@@ -46,7 +46,8 @@ if [[ "${BACKEND_INSTALL_DEPS}" != "0" ]]; then
fi
fi
setsid bash -c '
# nohup instead of setsid: setsid does not exist on Windows Git Bash.
nohup bash -c '
cd "$0" || exit 1
exec env \
GATEWAY_CORS_ORIGINS="$1" \
+2 -1
View File
@@ -17,7 +17,8 @@ rm -f "${PID_FILE}"
echo "Starting frontend..."
echo "Log: ${LOG_FILE}"
setsid bash -c '
# nohup instead of setsid: setsid does not exist on Windows Git Bash.
nohup bash -c '
cd "$0" || exit 1
exec env \
VITE_BACKEND_BASE_URL="$1" \