From bf42a48fcc79e64e954c59fa4d4993c26828d0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 6 Sep 2026 20:25:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ope-多智能体报告协作工作台-前端对接指南.md | 481 ++++++ .../workspace/workspace-nav-chat-list.tsx | 16 +- .../src/report-collaboration/README.md | 25 +- .../src/report-collaboration/api/driver.ts | 120 +- .../src/report-collaboration/api/mock.ts | 1388 ----------------- .../components/SessionHistoryPanel.tsx | 71 +- .../hooks/useReportCollaborationSession.ts | 8 +- .../pages/ReportCollaborationPage.tsx | 81 +- .../agentscope_runtime/member_kernel.py | 1 - offline-backend-20260512/backend/config.yaml | 4 +- .../skill-packages/add-audience/SKILL.md | 2 +- .../add-audience/config/audience_api.json | 1 + .../add-audience/scripts/add_audiences.py | 68 +- scripts/env.sh | 4 +- scripts/start-backend.sh | 3 +- scripts/start-frontend.sh | 3 +- 16 files changed, 716 insertions(+), 1560 deletions(-) create mode 100644 docs/agentscope-多智能体报告协作工作台-前端对接指南.md delete mode 100644 frontend-web/src/report-collaboration/api/mock.ts diff --git a/docs/agentscope-多智能体报告协作工作台-前端对接指南.md b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md new file mode 100644 index 00000000..efef7c5f --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md @@ -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 " \ + 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/` | diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 4fc95646..107023e2 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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: "企业深度研究", diff --git a/frontend-web/src/report-collaboration/README.md b/frontend-web/src/report-collaboration/README.md index 9d648f0e..d0cee485 100644 --- a/frontend-web/src/report-collaboration/README.md +++ b/frontend-web/src/report-collaboration/README.md @@ -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`(确定性打分,不替代上线前真人盲评)。 diff --git a/frontend-web/src/report-collaboration/api/driver.ts b/frontend-web/src/report-collaboration/api/driver.ts index 09835123..168207c9 100644 --- a/frontend-web/src/report-collaboration/api/driver.ts +++ b/frontend-web/src/report-collaboration/api/driver.ts @@ -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; } -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(op: () => Promise, retry: () => Promise): Promise { - 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 = { - 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; } diff --git a/frontend-web/src/report-collaboration/api/mock.ts b/frontend-web/src/report-collaboration/api/mock.ts deleted file mode 100644 index bfabf7c6..00000000 --- a/frontend-web/src/report-collaboration/api/mock.ts +++ /dev/null @@ -1,1388 +0,0 @@ -/** - * 本地 Mock 驱动:后端(/api/report-collaboration)未就绪时的完整演示与联调自测。 - * - * 通过 driver.ts 的开关启用;实现与真实驱动相同的 ReportCollaborationDriver - * 接口 + 相同的 durable 事件协议(seq 单调、事件先落日志再广播、支持 - * after_seq 重放),因此前端的快照/SSE/reducer 全链路走的是与真实后端 - * 一致的代码路径。 - * - * 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 规划智能体 - * (步骤条 / 思考 / 正文)→ 方案卡 → 选择并开始 → 并行研究(TeamSay/工具调用/ - * 流式回复)→ 核验 → 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持 - * 命令确认卡演示。 - */ - -import type { ReportCollaborationDriver } from "@/report-collaboration/api/driver"; -import type { - AgentRun, - CollaborationCommand, - CollaborationEvent, - CollaborationEventType, - CollaborationMessage, - CollaborationNodeState, - PlanNode, - ReportPlanCandidate, - ReportRun, - ReportSession, - ReportVersion, -} from "@/report-collaboration/api/types"; - -// ── 内存存储 ───────────────────────────────────────────────────────────────── - -interface MockStore { - session: ReportSession; - run: ReportRun; - messages: CollaborationMessage[]; - events: CollaborationEvent[]; - subscribers: Set<(event: CollaborationEvent) => void>; - seq: number; - plans: ReportPlanCandidate[]; - commands: CollaborationCommand[]; - nodes: Record; - agentRuns: Record; - reportVersions: ReportVersion[]; - timers: number[]; - /** 已创建的成果物 id(演示里按节点顺序对应)。 */ - artifactIds: string[]; - /** 会话脚本位置:是否已澄清过一轮。 */ - clarified: boolean; - versionCounter: number; -} - -const stores = new Map(); -let storeCounter = 0; - -function uid(prefix: string): string { - storeCounter += 1; - return `${prefix}_mock_${storeCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -function getStore(sessionId: string): MockStore | null { - return stores.get(sessionId) ?? null; -} - -function emit(store: MockStore, type: CollaborationEventType, data: unknown): CollaborationEvent { - store.seq += 1; - const event: CollaborationEvent = { - eventId: `evt_${store.seq}`, - sessionId: store.session.id, - runId: store.run.id, - seq: store.seq, - type, - timestamp: new Date().toISOString(), - // 深拷贝:真实后端经 JSON 序列化下发,订阅方拿到的必须是不可变快照, - // 不能与 mock 的存活对象共享引用(否则脚本后续变异会穿透进前端状态)。 - data: JSON.parse(JSON.stringify(data ?? {})) as unknown, - }; - store.events.push(event); - for (const subscriber of store.subscribers) { - subscriber(event); - } - return event; -} - -function later(store: MockStore, ms: number, fn: () => void): void { - const timer = window.setTimeout(() => { - if (store.run.status === "cancelled" && store.session.status === "cancelled") return; - fn(); - }, ms); - store.timers.push(timer); -} - -function now(): string { - return new Date().toISOString(); -} - -// ── 消息辅助 ───────────────────────────────────────────────────────────────── - -function humanMessage(text: string, idempotencyKey?: string): CollaborationMessage { - return { - message_id: uid("msg"), - role: "human", - content: text, - visibility: "visible", - metadata: idempotencyKey ? { idempotency_key: idempotencyKey } : null, - created_at: now(), - completed_at: now(), - }; -} - -function aiMessage( - name: string, - agentRunId: string | null, - content = "", - nodeRunId?: string | null, - phaseId?: string | null, -): CollaborationMessage { - return { - message_id: uid("msg"), - role: "ai", - content, - name, - agent_run_id: agentRunId, - node_run_id: nodeRunId ?? null, - phase_id: phaseId ?? (agentRunId ? "phase_parallel_research" : null), - visibility: "visible", - created_at: now(), - completed_at: null, - }; -} - -/** 以 token delta 流式输出一条 assistant / TeamSay 消息。 */ -function streamAssistant( - store: MockStore, - message: CollaborationMessage, - text: string, - teamMessage = false, -): void { - const createdType = teamMessage ? "team_message.created" : "message.created"; - const deltaType = teamMessage ? "team_message.delta" : "message.delta"; - const completedType = teamMessage ? "team_message.completed" : "message.completed"; - - store.messages.push(message); - emit(store, createdType, { message }); - const chunkSize = 18; - const chunks: string[] = []; - for (let i = 0; i < text.length; i += chunkSize) { - chunks.push(text.slice(i, i + chunkSize)); - } - chunks.forEach((chunk, index) => { - later(store, 70 * (index + 1), () => { - message.content += chunk; - emit(store, deltaType, { - message_id: message.message_id, - agent_run_id: message.agent_run_id, - delta: chunk, - }); - if (index === chunks.length - 1) { - message.completed_at = now(); - emit(store, completedType, { - message_id: message.message_id, - agent_run_id: message.agent_run_id, - }); - } - }); - }); -} - -const TEXT_CHUNK = 18; -const TEXT_TICK = 70; -const REASONING_CHUNK = 22; -const REASONING_TICK = 45; - -function chunkCount(text: string, size: number): number { - return Math.max(1, Math.ceil(text.length / size)); -} - -function assistantDurationMs(text: string): number { - return TEXT_TICK * chunkCount(text, TEXT_CHUNK) + 80; -} - -function reasoningDurationMs(text: string): number { - return REASONING_TICK * chunkCount(text, REASONING_CHUNK) + 80; -} - -/** 流式写入 `metadata.reasoning_content`,适配器映射到问答 CoT 思考区。 */ -function streamReasoning(store: MockStore, message: CollaborationMessage, text: string): number { - const first = text.slice(0, REASONING_CHUNK) || text; - const rest = text.slice(first.length); - message.metadata = { ...(message.metadata ?? {}), reasoning_content: first }; - store.messages.push(message); - emit(store, "message.created", { message }); - const finish = (delay: number) => { - later(store, delay, () => { - message.completed_at = now(); - emit(store, "message.completed", { message_id: message.message_id }); - }); - }; - if (!rest) { - finish(reasoningDurationMs(text)); - return reasoningDurationMs(text); - } - const chunks: string[] = []; - for (let i = 0; i < rest.length; i += REASONING_CHUNK) { - chunks.push(rest.slice(i, i + REASONING_CHUNK)); - } - chunks.forEach((chunk, index) => { - later(store, REASONING_TICK * (index + 1), () => { - const prev = String(message.metadata?.reasoning_content ?? ""); - message.metadata = { ...(message.metadata ?? {}), reasoning_content: prev + chunk }; - emit(store, "message.delta", { - message_id: message.message_id, - agent_run_id: message.agent_run_id, - node_run_id: message.node_run_id, - delta: "", - reasoning_delta: chunk, - }); - if (index === chunks.length - 1) { - message.completed_at = now(); - emit(store, "message.completed", { message_id: message.message_id }); - } - }); - }); - return reasoningDurationMs(text); -} - -const COORDINATOR_RUN_ID = "agent_run_coordinator"; - -function ensureCoordinatorRun(store: MockStore, phaseId: string): AgentRun { - const existing = store.agentRuns[COORDINATOR_RUN_ID]; - if (existing) { - existing.status = "running"; - existing.phase_id = phaseId; - existing.ended_at = undefined; - emit(store, "session.snapshot", { agent_runs: [{ ...existing }], run: { ...store.run } }); - return existing; - } - const agentRun: AgentRun = { - agent_run_id: COORDINATOR_RUN_ID, - node_run_id: null, - phase_id: phaseId, - role: "协调者", - display_name: "协调者", - status: "running", - started_at: now(), - }; - store.agentRuns[COORDINATOR_RUN_ID] = agentRun; - emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] }); - return agentRun; -} - -const PLANNER_THINKING = - "用户已确认读者与覆盖范围。先拆需求要点,再对照可用的研究员、核验、分析、撰写、审稿角色与工具,装配均衡、深度、快速三套协作图,并检查依赖与质量门是否可行。思考和步骤要对用户可见,推荐方案卡放在完整输出之后。"; - -const PLANNER_OUTPUT = `已根据当前需求装配三种协作策略: - -**均衡协作(推荐)**:两条并行研究线(市场规模 / 品牌竞争)→ 交叉核验 → 综合分析 → 单人写作 → 独立审稿。覆盖主要决策角度,团队规模与成本适中,适合大多数正式报告。 - -**深度并行**:四条研究线(市场 / 品牌 / 海外 / 政策)加双重核验与分角度分析,证据最全,耗时和费用也最高,适合高风险决策。 - -**快速出稿**:单研究线收窄范围,仍保留核验与审稿,适合要尽快看到初稿的场景。 - -请在下方卡片中切换预览对应的协作流程;选中后点「开始协作」才会真正创建执行。`; - -/** 规划智能体完整回合:步骤条 → 思考 → 正文,最后才由调用方抛出方案卡。 */ -function runPlannerAgent(store: MockStore, onDone: () => void): void { - const agentRun = ensureCoordinatorRun(store, "phase_planning"); - const processId = `ai-tc-${agentRun.agent_run_id}-planner`; - - const tools: { id: string; name: string; args: Record; result: string }[] = [ - { - id: "tc_planner_parse", - name: "write_todos", - args: { description: "解析需求要点" }, - result: "已提取读者、必须覆盖的角度与资料约束。", - }, - { - id: "tc_planner_search", - name: "web_search", - args: { - query: "多智能体报告协作 角色与工具", - description: "筛选可用智能体与工具", - }, - result: JSON.stringify({ - roles: ["研究员", "核验员", "分析员", "撰写员", "审稿员"], - tools: ["web_search", "read_file", "write_file", "write_todos"], - }), - }, - { - id: "tc_planner_assemble", - name: "write_todos", - args: { description: "装配三种策略的协作图并校验可行性" }, - result: "已生成均衡 / 深度 / 快速三套 DAG,依赖与质量门可执行。", - }, - ]; - - const toolsStart = 220; - tools.forEach((tool, index) => { - emitToolCall(store, agentRun, toolsStart + index * 280, tool, { - messageId: processId, - callId: tool.id, - resultDelay: 1100 + (tools.length - 1 - index) * 280, - }); - }); - const thinkStart = toolsStart + (tools.length - 1) * 280 + 1100 + 220; - later(store, thinkStart, () => { - streamReasoning( - store, - aiMessage("协调者", agentRun.agent_run_id, "", null, "phase_planning"), - PLANNER_THINKING, - ); - }); - const outputStart = thinkStart + reasoningDurationMs(PLANNER_THINKING) + 120; - later(store, outputStart, () => { - streamAssistant( - store, - aiMessage("协调者", agentRun.agent_run_id, "", null, "phase_planning"), - PLANNER_OUTPUT, - ); - later(store, assistantDurationMs(PLANNER_OUTPUT), () => { - agentRun.status = "completed"; - agentRun.ended_at = now(); - emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] }); - onDone(); - }); - }); -} - -function sequentialThinkingFor(node: PlanNode): string { - return `先看「${node.label}」的输入与验收:${node.mission ?? "完成本节点任务"}。检查上游产物是否齐全,再调用工具取证或核对,最后给出可交给下游的过程结论,而不是一句「已完成」。`; -} - -function sequentialOutputFor(node: PlanNode, isWriter: boolean): string { - if (isWriter) { - return "已按分析结论写完初稿,并写入 /mnt/user-data/outputs/report.md。章节覆盖执行摘要、市场规模、品牌竞争、风险与建议;关键数字均绑定核验后的证据编号,没有手写未核验数据。"; - } - if (node.id.includes("verify") || /核验/.test(node.label)) { - return `交叉核验过程如下:\n\n- 市场规模与品牌份额均回溯到 2026 年公开统计,统一为批发销量口径\n- 两条研究线份额差 1.2 个百分点,已按同一时间窗对齐\n- 近 90 天来源占比 6/8,未发现互相矛盾的硬事实\n\nVerificationResult 已交给下游分析。`; - } - if (node.id.includes("review") || /审稿/.test(node.label)) { - return "独立审稿过程:逐章核对结论与证据编号,检查引用是否断裂、结构是否缺章。结构完整,结论可映射到核验后的证据,无高危问题。建议作为正式稿;若需定向修订请指出章节名。"; - } - if (node.id.includes("analysis") || /分析/.test(node.label)) { - return "综合分析过程:在核验后的证据上,市场侧增速放缓但高端份额上移;品牌侧价格带分化,技术路线向快充与固态预研并行。主要风险是政策补贴退坡与出口关税,机会在商用车电动化与海外本地化。AngleAnalysis 已提交撰写。"; - } - return `「${node.label}」过程结论:${node.mission ?? "本节点任务"}已形成可交付成果,质量门通过,下游可以继续。`; -} - -function researchThinkingFor(label: string, mission: string): string { - return `认领「${label}」后先定检索词与时效窗(近 90 天),再跑公开资料检索,最后把可溯源条目整理成证据包交给核验,而不是只给一句摘要。任务:${mission}`; -} - -function researchOutputFor(label: string, mission: string): string { - return `「${label}」检索过程:围绕「${mission}」检索公开资料,命中 8 条来源(其中 6 条在近 90 天)。初步判断该方向证据充分,口径暂无冲突。完整条目已写入 EvidenceBundle,提交核验后由下游综合,而不是用简要总结代替过程。`; -} - -// ── 演示内容 ───────────────────────────────────────────────────────────────── - -const CLARIFY_TEXT = - "收到您的报告需求。为了给出最合适的协作方案,请先确认两个问题:\n\n1. 这份报告的主要读者是谁?(例如企业战略决策层 / 投资机构 / 业务部门)\n2. 是否有必须覆盖的分析角度或资料范围限制?\n\n也可以直接回复「直接开始」,我会按默认假设(面向战略决策人员、覆盖市场/竞争/政策/风险四个角度)生成候选方案。"; - -function planNode( - id: string, - label: string, - role: string, - dependsOn: string[], - mission: string, - output: string, - tools: string[], - acceptance: string[], -): PlanNode { - return { - id, - label, - role_key: role, - role_display_name: role, - depends_on: dependsOn, - mission, - output_artifact_type: output, - input_artifact_types: dependsOn.length ? [`${dependsOn[0]}:${output}`] : [], - allowed_tools: tools, - acceptance_criteria: acceptance, - }; -} - -function buildDemoPlans(requirementRevision: number): ReportPlanCandidate[] { - const groupId = uid("group"); - const mk = ( - id: string, - strategy: string, - title: string, - summary: string, - rationale: string, - duration: number, - cost: "low" | "medium" | "high", - nodes: PlanNode[], - recommended = false, - ): ReportPlanCandidate => ({ - id, - proposal_group_id: groupId, - title, - strategy, - summary, - rationale, - recommended, - estimated_duration_seconds: duration, - estimated_cost_level: cost, - requirement_revision: requirementRevision, - roles: Array.from( - new Set(nodes.map((node) => node.role_display_name ?? "")), - ) - .filter(Boolean) - .map((displayName) => ({ - key: displayName, - display_name: displayName, - responsibility: `承担「${displayName}」相关节点的职责`, - })), - nodes, - edges: nodes.flatMap((node) => - node.depends_on.map((source) => ({ source, target: node.id, label: null })), - ), - quality_gates: [ - { id: `${id}-g1`, label: "来源核验:关键事实需可溯源" }, - { id: `${id}-g2`, label: "独立审稿:结论与证据一致" }, - ], - report_structure_id: null, - validation: { ok: true, errors: [] }, - revision: 1, - status: "recommended" as unknown as ReportPlanCandidate["status"], - }); - - return [ - mk( - "plan-balanced", - "balanced_review", - "均衡协作 · 研究核验写作审稿全链路", - "两条并行研究线(市场规模 / 品牌竞争)+ 交叉核验 + 综合分析 + 单人写作 + 独立审稿,质量与成本均衡。", - "在覆盖主要角度的同时控制团队规模,是大多数正式报告的稳妥选择。", - 1500, - "medium", - [ - planNode("n-req", "需求理解", "协调者", [], "固化需求快照与模板约束", "RequirementSnapshot", [], ["需求字段完整"]), - planNode("n-research-market", "市场规模研究", "研究员", ["n-req"], "检索并整理市场规模、份额与增速证据", "EvidenceBundle", ["web_search"], ["≥8 条来源且半数在 90 天内"]), - planNode("n-research-brand", "品牌竞争研究", "研究员", ["n-req"], "检索主要品牌动态、价格策略与技术路线", "EvidenceBundle", ["web_search"], ["≥8 条来源,覆盖 ≥3 个品牌"]), - planNode("n-verify", "交叉核验", "核验员", ["n-research-market", "n-research-brand"], "核对数字口径、时效与冲突来源", "VerificationResult", [], ["关键数字均有支持/反对证据标注"]), - planNode("n-analysis", "综合分析", "分析员", ["n-verify"], "形成各角度结论与风险机会判断", "AngleAnalysis", [], ["结论引用核验后的证据 ID"]), - planNode("n-write", "报告撰写", "撰写员", ["n-analysis"], "按模板产出带引用的完整报告", "ReportVersion", ["write_file"], ["模板章节完整、关键事实带引用"]), - planNode("n-review", "独立审稿", "审稿员", ["n-write"], "评审结论-证据一致性与结构完整性", "ReviewDecision", [], ["问题可映射到具体节点/章节"]), - ], - true, - ), - mk( - "plan-depth", - "parallel_depth", - "深度并行 · 四角度双重核验", - "四条并行研究线(市场 / 品牌 / 海外 / 政策)+ 双重核验 + 分角度分析 + 综合 + 写作 + 审稿 + 定向修订,最全面也最贵。", - "面向高风险决策场景:角度覆盖与证据强度优先,接受更高成本与时长。", - 2400, - "high", - [ - planNode("n-req", "需求理解", "协调者", [], "固化需求快照与模板约束", "RequirementSnapshot", [], ["需求字段完整"]), - planNode("n-research-market", "市场规模研究", "研究员", ["n-req"], "市场规模、份额与增速证据", "EvidenceBundle", ["web_search"], ["≥10 条来源"]), - planNode("n-research-brand", "品牌竞争研究", "研究员", ["n-req"], "品牌动态、价格策略与技术路线", "EvidenceBundle", ["web_search"], ["覆盖 ≥4 个品牌"]), - planNode("n-research-oversea", "海外进展研究", "研究员", ["n-req"], "出海布局与海外政策", "EvidenceBundle", ["web_search"], ["≥8 条来源,含近 30 天动态"]), - planNode("n-research-policy", "政策环境研究", "研究员", ["n-req"], "补贴、准入与法规变化", "EvidenceBundle", ["web_search"], ["≥6 条权威来源"]), - planNode("n-verify-1", "一次核验", "核验员", ["n-research-market", "n-research-brand"], "口径与时效核对", "VerificationResult", [], ["冲突来源标记"]), - planNode("n-verify-2", "二次核验", "核验员", ["n-research-oversea", "n-research-policy"], "交叉复核高风险结论", "VerificationResult", [], ["高风险结论双人复核"]), - planNode("n-analysis", "综合分析", "分析员", ["n-verify-1", "n-verify-2"], "分角度结论与 12-24 个月展望", "AngleAnalysis", [], ["结论引用证据 ID"]), - planNode("n-write", "报告撰写", "撰写员", ["n-analysis"], "完整报告", "ReportVersion", ["write_file"], ["章节与模板一致"]), - planNode("n-review", "独立审稿", "审稿员", ["n-write"], "一致性与完整性评审", "ReviewDecision", [], ["问题可定位"]), - planNode("n-revise", "定向修订", "修订员", ["n-review"], "按审稿意见返工目标节点", "ReportVersion", ["write_file"], ["仅修改指定问题"]), - ], - ), - mk( - "plan-fast", - "focused_fast", - "快速聚焦 · 窄范围短链路", - "单研究线 + 核验 + 分析 + 写作 + 审稿,范围收窄但保留引用核验与审稿,最快出稿。", - "适合时间紧、角度已知、需要可信结论而非全面覆盖的场景。", - 800, - "low", - [ - planNode("n-req", "需求理解", "协调者", [], "固化需求快照", "RequirementSnapshot", [], ["需求字段完整"]), - planNode("n-research-market", "聚焦研究", "研究员", ["n-req"], "按指定角度检索证据", "EvidenceBundle", ["web_search"], ["≥6 条来源"]), - planNode("n-verify", "核验", "核验员", ["n-research-market"], "核对关键事实", "VerificationResult", [], ["关键数字有来源"]), - planNode("n-analysis", "分析", "分析员", ["n-verify"], "形成结论", "AngleAnalysis", [], ["结论有证据支撑"]), - planNode("n-write", "撰写", "撰写员", ["n-analysis"], "产出报告", "ReportVersion", ["write_file"], ["模板章节完整"]), - planNode("n-review", "审稿", "审稿员", ["n-write"], "快速评审", "ReviewDecision", [], ["无高危问题"]), - ], - ), - ].map((plan) => ({ ...plan, status: "proposed" })); -} - -const DEMO_REPORT_MARKDOWN = `# 新能源汽车主要品牌市场趋势分析报告 - -> 版本 v1 · 由报告协作工作台(Mock 演示)生成 · 关键数字均附来源 - -## 一、市场规模与份额 - -2024 年以来中国新能源汽车市场保持较快增长,头部集中度持续提升。比亚迪、特斯拉与吉利/长安等传统转型阵营合计占据主要份额,新势力阵营分化明显。 - -- 2024 年新能源乘用车零售渗透率多个月份超过 50%(来源:乘联会月度数据) -- 插混与增程增速显著高于纯电(来源:中汽协产销快讯) - -## 二、价格策略 - -价格竞争从「全面降价」转向「增配保价」:头部品牌以智驾下沉与金融方案维持毛利,尾部品牌以限时权益清库。 - -## 三、技术路线 - -- 纯电平台 800V 化与快充网络竞赛加速; -- 插混/增程作为过渡路线的窗口期仍在; -- 智驾进入「全国都能开」的城市 NOA 竞赛。 - -## 四、海外进展 - -东南亚与拉美成为出海重点,欧盟关税促使本地化建厂评估提速。 - -## 五、政策环境 - -以旧换新补贴与地方购车指标倾斜延续,行业规范条件收紧尾部产能。 - -## 六、未来 12–24 个月风险与机会 - -**风险**:价格战侵蚀毛利、海外贸易壁垒、尾部产能出清的供应链波动。 -**机会**:智驾平权带来的产品溢价、海外本地化产能、商用车电动化第二曲线。 - ---- - -*本报告为 Mock 演示内容,用于验证前端全链路(消息镜像 / 工具步骤 / 版本 / 导出)。*`; - -// ── 节点执行脚本 ───────────────────────────────────────────────────────────── - -function ensureNodeState(store: MockStore, node: PlanNode): CollaborationNodeState { - if (!store.nodes[node.id]) { - store.nodes[node.id] = { - node_id: node.id, - label: node.label, - role_display_name: node.role_display_name ?? null, - angle: node.angle ?? null, - status: "planned", - attempt: 0, - attempts: [], - }; - } - return store.nodes[node.id]; -} - -function setNodeStatus( - store: MockStore, - nodeId: string, - status: CollaborationNodeState["status"], - attempt = 1, - extra: Partial = {}, -): void { - const node = store.nodes[nodeId]; - if (!node) return; - node.status = status; - node.attempt = attempt; - node.awaiting_input = extra.awaiting_input ?? null; - node.error = extra.error ?? null; - emit(store, "node.status.changed", { - node_id: nodeId, - status, - attempt, - awaiting_input: node.awaiting_input, - error: node.error, - }); -} - -interface ResearchScript { - agentRunId: string; - displayName: string; - nodeId: string; - teamSay: string; - searchQuery: string; - searchSummary: string; - replyMission: string; -} - -function emitToolCall( - store: MockStore, - agentRun: AgentRun, - delay: number, - tool: { name: string; args: Record; result: string }, - options?: { messageId?: string; callId?: string; resultDelay?: number }, -): string { - const callId = options?.callId ?? `tc_${agentRun.agent_run_id}_${tool.name}`; - const messageId = options?.messageId ?? `ai-tc-${agentRun.agent_run_id}-${callId}`; - const resultDelay = options?.resultDelay ?? 800; - later(store, delay, () => { - emit(store, "tool_call.created", { - message_id: messageId, - agent_run_id: agentRun.agent_run_id, - node_run_id: agentRun.node_run_id, - tool_call: { id: callId, name: tool.name, args: tool.args }, - }); - }); - later(store, delay + resultDelay, () => { - const toolResultId = `tool-result-${callId}`; - store.messages.push({ - message_id: toolResultId, - role: "tool", - content: tool.result, - name: tool.name, - tool_call_id: callId, - agent_run_id: agentRun.agent_run_id, - node_run_id: agentRun.node_run_id ?? null, - phase_id: agentRun.phase_id ?? null, - visibility: "visible", - created_at: now(), - completed_at: now(), - }); - emit(store, "tool_result.created", { - message_id: toolResultId, - tool_call_id: callId, - name: tool.name, - agent_run_id: agentRun.agent_run_id, - node_run_id: agentRun.node_run_id, - phase_id: agentRun.phase_id, - content: tool.result, - }); - }); - return messageId; -} - -function startAgentRun(store: MockStore, node: PlanNode, phaseId: string): AgentRun { - const agentRun: AgentRun = { - agent_run_id: `agent_run_${node.id}`, - node_run_id: `${node.id}-attempt1`, - phase_id: phaseId, - role: node.role_display_name ?? node.label, - display_name: node.label, - status: "running", - started_at: now(), - }; - store.agentRuns[agentRun.agent_run_id] = agentRun; - store.run.current_phase = phaseId; - emit(store, "session.snapshot", { - agent_runs: [{ ...agentRun }], - run: { ...store.run, current_phase: phaseId }, - }); - return agentRun; -} - -function completeAgentRun(store: MockStore, agentRun: AgentRun, nodeId: string): void { - agentRun.status = "completed"; - agentRun.ended_at = now(); - emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] }); - setNodeStatus(store, nodeId, "completed"); -} - -function sequentialToolFor(node: PlanNode): { name: string; args: Record; result: string } { - if (node.allowed_tools?.includes("web_search")) { - return { - name: "web_search", - args: { query: `${node.label} 新能源汽车 2026`, description: `检索「${node.label}」公开资料` }, - result: JSON.stringify({ - query: `${node.label} 新能源汽车 2026`, - count: 6, - results: Array.from({ length: 6 }, (_, i) => ({ - title: `${node.label} · 来源 ${i + 1}`, - url: `https://example.com/source/${node.id}/${i + 1}`, - published_at: "2026-08-15", - })), - }), - }; - } - if (node.id.includes("verify") || /核验/.test(node.label)) { - return { - name: "read_file", - args: { path: "/mnt/user-data/workspace/evidence.json", description: "读取上游证据包并核对口径" }, - result: "已核对 8 条来源,关键数字口径一致。", - }; - } - if (node.id.includes("review") || /审稿/.test(node.label)) { - return { - name: "read_file", - args: { path: "/mnt/user-data/outputs/report.md", description: "审阅报告全文与引用" }, - result: "结构完整,结论与证据一致,无高危问题。", - }; - } - return { - name: "write_todos", - args: { description: `整理「${node.label}」分析要点` }, - result: "ok", - }; -} - -function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: number): void { - const agentRun: AgentRun = { - agent_run_id: script.agentRunId, - node_run_id: `${script.nodeId}-attempt1`, - phase_id: "phase_parallel_research", - role: "研究员", - display_name: script.displayName, - status: "running", - started_at: now(), - }; - store.agentRuns[script.agentRunId] = agentRun; - const thinking = researchThinkingFor(script.searchSummary, script.replyMission); - const output = researchOutputFor(script.searchSummary, script.replyMission); - const processId = `ai-tc-${agentRun.agent_run_id}-process`; - const toolStart = baseDelay + 450; - const thinkStart = toolStart + 950; - const replyStart = thinkStart + reasoningDurationMs(thinking); - const doneStart = replyStart + assistantDurationMs(output); - - later(store, baseDelay, () => { - streamAssistant( - store, - aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id), - script.teamSay, - true, - ); - }); - - emitToolCall( - store, - agentRun, - toolStart, - { - name: "web_search", - args: { query: script.searchQuery, description: `检索「${script.searchSummary}」公开资料` }, - result: JSON.stringify({ - query: script.searchQuery, - count: 8, - results: Array.from({ length: 8 }, (_, i) => ({ - title: `${script.searchSummary} · 来源 ${i + 1}`, - url: `https://example.com/source/${script.agentRunId}/${i + 1}`, - published_at: "2026-08-15", - })), - }), - }, - { messageId: processId }, - ); - - later(store, thinkStart, () => { - streamReasoning( - store, - aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id), - thinking, - ); - }); - - later(store, replyStart, () => { - streamAssistant( - store, - aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id), - output, - ); - }); - - later(store, doneStart + 200, () => { - setNodeStatus(store, script.nodeId, "validating"); - const artifactId = uid("art"); - store.artifactIds.push(artifactId); - emit(store, "artifact.created", { - artifact: { - id: artifactId, - run_id: store.run.id, - node_id: script.nodeId, - attempt: 1, - artifact_type: "EvidenceBundle", - schema_version: 1, - validation_status: "pending", - summary: "8 条来源,6 条在 90 天内,无冲突", - created_at: now(), - }, - }); - }); - - later(store, doneStart + 700, () => { - setNodeStatus(store, script.nodeId, "completed"); - agentRun.status = "completed"; - agentRun.ended_at = now(); - emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] }); - const artifactId = store.artifactIds.at(-1); - if (artifactId) emit(store, "artifact.validated", { artifact_id: artifactId }); - }); -} - -function executePlan(store: MockStore, plan: ReportPlanCandidate): void { - for (const node of plan.nodes) { - ensureNodeState(store, node); - emit(store, "node.created", { node: store.nodes[node.id] }); - } - store.run.status = "running"; - emit(store, "run.status.changed", { status: "running", phase: "phase_parallel_research" }); - - const researchScripts: ResearchScript[] = plan.nodes - .filter((node) => node.allowed_tools?.includes("web_search")) - .map((node, index) => ({ - agentRunId: `agent_run_${node.id}`, - displayName: `${node.label}师`, - nodeId: node.id, - teamSay: `我认领「${node.label}」任务,先做一轮公开资料检索,重点看近 90 天的数据。`, - searchQuery: `${node.label} 新能源汽车 2026`, - searchSummary: node.label, - replyMission: node.mission ?? node.label, - })); - - researchScripts.forEach((script, index) => { - setNodeStatus(store, script.nodeId, "queued"); - later(store, 200 + index * 120, () => setNodeStatus(store, script.nodeId, "running")); - runResearchAgent(store, script, 600 + index * 150); - }); - - const researchIds = new Set(researchScripts.map((script) => script.nodeId)); - const rest = plan.nodes.filter((node) => !researchIds.has(node.id) && node.id !== "n-req"); - const coordRun: AgentRun = { - agent_run_id: "agent_run_coordinator", - node_run_id: "n-req-attempt1", - phase_id: "phase_coordinator", - role: "协调者", - display_name: "协调者", - status: "running", - started_at: now(), - }; - store.agentRuns[coordRun.agent_run_id] = coordRun; - - // 成员实例创建后经 session.snapshot 广播(协议内的快照事件携带 agent_runs), - // 前端据此渲染并行成员选择器。 - emit(store, "session.snapshot", { - session: { ...store.session }, - run: { ...store.run }, - agent_runs: Object.values(store.agentRuns).map((run) => ({ ...run })), - }); - - later(store, 300, () => { - setNodeStatus(store, "n-req", "completed"); - coordRun.status = "completed"; - emit(store, "session.snapshot", { agent_runs: [{ ...coordRun }] }); - }); - - let cursor = 4200; - for (const node of rest) { - const isWriter = node.id === "n-write"; - const isReview = node.id === "n-review" || /审稿/.test(node.label); - const isRevise = node.id === "n-revise"; - const delay = cursor; - const phaseId = `phase_${node.id}`; - const thinking = sequentialThinkingFor(node); - const output = sequentialOutputFor(node, isWriter); - const processId = `ai-tc-agent_run_${node.id}-process`; - const toolStart = delay + 700; - const thinkStart = isWriter ? delay + 700 : toolStart + 950; - const replyStart = thinkStart + reasoningDurationMs(thinking) + (isWriter ? 800 : 80); - const nodeDuration = replyStart - delay + assistantDurationMs(output) + 900; - - later(store, delay, () => setNodeStatus(store, node.id, "queued")); - later(store, delay + 180, () => { - setNodeStatus(store, node.id, "running"); - store.run.current_phase = phaseId; - const agentRun = startAgentRun(store, node, phaseId); - streamAssistant( - store, - aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId), - `我开始执行「${node.label}」:${node.mission ?? "处理当前节点任务"}。`, - true, - ); - }); - - if (isWriter) { - later(store, thinkStart, () => { - const agentRun = store.agentRuns[`agent_run_${node.id}`]; - if (!agentRun) return; - streamReasoning( - store, - aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId), - thinking, - ); - }); - later(store, thinkStart + reasoningDurationMs(thinking), () => { - const chunks = DEMO_REPORT_MARKDOWN.match(/[\s\S]{1,60}/g) ?? []; - chunks.forEach((chunk, index) => { - later(store, 60 * index, () => { - emit(store, "report.delta", { draft_id: "draft-v1", delta: chunk }); - }); - }); - }); - } else { - later(store, toolStart, () => { - const agentRun = store.agentRuns[`agent_run_${node.id}`]; - if (!agentRun) return; - emitToolCall(store, agentRun, 0, sequentialToolFor(node), { messageId: processId }); - }); - later(store, thinkStart, () => { - const agentRun = store.agentRuns[`agent_run_${node.id}`]; - if (!agentRun) return; - streamReasoning( - store, - aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId), - thinking, - ); - }); - } - - later(store, replyStart, () => { - const agentRun = store.agentRuns[`agent_run_${node.id}`]; - if (!agentRun) return; - streamAssistant( - store, - aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId), - output, - ); - }); - - later(store, delay + nodeDuration - 500, () => setNodeStatus(store, node.id, "validating")); - later(store, delay + nodeDuration, () => { - const agentRun = store.agentRuns[`agent_run_${node.id}`]; - if (agentRun) completeAgentRun(store, agentRun, node.id); - if (isWriter) { - store.versionCounter += 1; - const version: ReportVersion = { - id: uid("ver"), - session_id: store.session.id, - version: store.versionCounter, - parent_version_id: null, - change_type: "initial", - change_summary: "初版报告", - markdown: DEMO_REPORT_MARKDOWN, - source_index: [], - created_at: now(), - is_head: true, - }; - for (const old of store.reportVersions) old.is_head = false; - store.reportVersions.push(version); - store.session.current_report_version_id = version.id; - emit(store, "report.version.created", { version }); - } - if (isReview) { - emit(store, "review.created", { - review: { - reviewer: "审稿员", - verdict: "pass", - issues: [], - }, - }); - store.run.status = "reviewing"; - emit(store, "run.status.changed", { status: "reviewing", phase: "phase_review" }); - } - if (isRevise) { - store.run.status = "completed"; - store.session.status = "completed"; - emit(store, "run.status.changed", { status: "completed" }); - } - }); - cursor += nodeDuration; - } - - // 无修订节点的方案:最后一个节点完成后收尾。 - if (!rest.some((node) => node.id === "n-revise")) { - later(store, cursor + 400, () => { - if (store.run.status === "cancelled") return; - store.run.status = "completed"; - store.session.status = "completed"; - emit(store, "run.status.changed", { status: "completed" }); - streamAssistant( - store, - aiMessage("协调者", "agent_run_coordinator", "", null), - "协作完成:全部节点通过质量门,正式报告 v1 已生成,可在上方报告卡中预览与导出。你可以继续提问、要求重写章节或润色。", - ); - }); - } -} - -// ── Driver 实现 ────────────────────────────────────────────────────────────── - -function snapshotOf(store: MockStore) { - return { - session: { ...store.session }, - messages: store.messages.map((message) => ({ ...message })), - plans: store.plans.map((plan) => ({ ...plan })), - run: { ...store.run }, - nodes: Object.values(store.nodes).map((node) => ({ ...node })), - agent_runs: Object.values(store.agentRuns), - commands: Object.values(store.commands), - report_versions: store.reportVersions, - last_seq: store.seq, - }; -} - -function classifyCommand(text: string): { intent: CollaborationCommand["intent"]; confirmation: boolean } { - if (/重新检索|重新找|资料太旧/.test(text)) return { intent: "research_again", confirmation: true }; - if (/重写/.test(text)) return { intent: "rewrite_section", confirmation: true }; - if (/增加|新增/.test(text)) return { intent: "add_angle", confirmation: true }; - if (/润色|语气|正式/.test(text)) return { intent: "polish_report", confirmation: false }; - return { intent: "answer_question", confirmation: false }; -} - -const mockDriverImpl: ReportCollaborationDriver = { - async listSessions() { - return [...stores.values()] - .map((store) => ({ ...store.session })) - .sort((a, b) => b.updated_at.localeCompare(a.updated_at)); - }, - - async getSnapshot(sessionId) { - const store = getStore(sessionId); - return store ? snapshotOf(store) : null; - }, - - async createSession(title) { - const sessionId = uid("session"); - const runId = uid("run"); - const store: MockStore = { - session: { - id: sessionId, - title: title || "报告协作", - status: "empty", - requirement_revision: 1, - selected_plan_id: null, - active_run_id: runId, - current_report_version_id: null, - created_at: now(), - updated_at: now(), - }, - run: { - id: runId, - session_id: sessionId, - plan_id: "", - status: "queued", - cancel_requested: false, - next_event_seq: 0, - created_at: now(), - updated_at: now(), - }, - messages: [], - events: [], - subscribers: new Set(), - seq: 0, - plans: [], - commands: [], - nodes: {}, - agentRuns: {}, - reportVersions: [], - timers: [], - artifactIds: [], - clarified: false, - versionCounter: 0, - }; - stores.set(sessionId, store); - emit(store, "session.snapshot", { session: { ...store.session } }); - return { ...store.session }; - }, - - async updateSession(sessionId, patch) { - const store = getStore(sessionId); - if (!store) return null; - if (patch.title !== undefined) store.session.title = patch.title; - store.session.updated_at = now(); - emit(store, "session.snapshot", { session: { ...store.session } }); - return { ...store.session }; - }, - - async deleteSession(sessionId) { - const store = getStore(sessionId); - if (store) { - for (const timer of store.timers) window.clearTimeout(timer); - stores.delete(sessionId); - } - }, - - async sendMessage(sessionId, payload, idempotencyKey) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - const message = humanMessage(payload.text, idempotencyKey); - store.messages.push(message); - emit(store, "message.created", { message }); - store.session.updated_at = now(); - - const status = store.session.status; - if (status === "empty") { - store.session.status = "clarifying"; - later(store, 400, () => { - streamAssistant(store, aiMessage("协调者", "agent_run_coordinator"), CLARIFY_TEXT); - }); - } else if (status === "clarifying") { - store.session.status = "planning"; - emit(store, "session.snapshot", { session: { ...store.session } }); - runPlannerAgent(store, () => { - store.plans = buildDemoPlans(store.session.requirement_revision); - emit(store, "plan.proposed", { plans: store.plans }); - store.session.status = "proposal_ready"; - emit(store, "session.snapshot", { session: { ...store.session } }); - }); - } else if (status === "proposal_ready") { - later(store, 300, () => { - streamAssistant( - store, - aiMessage("协调者", COORDINATOR_RUN_ID), - "上面已经给出三种策略的完整过程与方案卡。选中一张后点「开始协作」即可执行;也可以点「重新生成」让我再跑一轮规划。", - ); - }); - } else if (status === "completed") { - later(store, 400, () => { - streamAssistant( - store, - aiMessage("报告助手", null), - "基于当前报告版本的回答:本报告覆盖市场、竞争、海外与政策四个角度,结论均绑定核验后的证据。你可以继续追问,或要求重写某一章 / 全文润色。", - ); - }); - } - return message; - }, - - async requestPlans(sessionId, expectedRevision) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - store.session.status = "planning"; - store.plans = []; - emit(store, "plan.proposed", { plans: [] }); - emit(store, "session.snapshot", { session: { ...store.session } }); - runPlannerAgent(store, () => { - store.plans = buildDemoPlans(expectedRevision ?? store.session.requirement_revision + 1); - emit(store, "plan.proposed", { plans: store.plans }); - store.session.status = "proposal_ready"; - emit(store, "session.snapshot", { session: { ...store.session } }); - }); - }, - - async selectPlan(sessionId, planId) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - store.session.selected_plan_id = planId; - store.plans = store.plans.map((plan) => ({ - ...plan, - status: plan.id === planId ? "selected" : "proposed", - })); - emit(store, "plan.selected", { plan_id: planId }); - emit(store, "session.snapshot", { session: { ...store.session } }); - }, - - async startRun(sessionId, payload) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - const plan = store.plans.find((candidate) => candidate.id === payload.plan_id) ?? store.plans[0]; - store.run.plan_id = plan.id; - store.run.status = "planning"; - store.session.status = "running"; - emit(store, "run.status.changed", { status: "planning", phase: "phase_prepare" }); - later(store, 300, () => executePlan(store, plan)); - return { ...store.run }; - }, - - async getRun(runId) { - for (const store of stores.values()) { - if (store.run.id === runId) return { ...store.run }; - } - return null; - }, - - async listRunEvents(runId, afterSeq) { - for (const store of stores.values()) { - if (store.run.id === runId) { - return store.events.filter((event) => event.seq > afterSeq); - } - } - return []; - }, - - openRunStream(runId, afterSeq, handlers) { - let store: MockStore | null = null; - for (const candidate of stores.values()) { - if (candidate.run.id === runId) store = candidate; - } - if (!store) { - handlers.onError?.("run not found"); - return () => undefined; - } - const subscriber = (event: CollaborationEvent) => handlers.onEvent(event); - // 先重放历史(after_seq 之后),再接实时。 - for (const event of store.events) { - if (event.seq > afterSeq) subscriber(event); - } - handlers.onOpen?.(); - store.subscribers.add(subscriber); - return () => { - store?.subscribers.delete(subscriber); - }; - }, - - async cancelRun(runId) { - for (const store of stores.values()) { - if (store.run.id !== runId) continue; - for (const timer of store.timers) window.clearTimeout(timer); - store.run.status = "cancelled"; - store.session.status = "cancelled"; - emit(store, "run.status.changed", { status: "cancelled", reason: "用户取消" }); - return { ...store.run }; - } - throw new Error("run not found"); - }, - - async createCommand(runId, payload, idempotencyKey) { - const store = [...stores.values()].find((candidate) => candidate.run.id === runId); - if (!store) throw new Error("run not found"); - const message = humanMessage(payload.text, idempotencyKey); - message.agent_run_id = payload.agent_run_id ?? null; - message.node_run_id = payload.target_node_id ?? null; - store.messages.push(message); - emit(store, "message.created", { message }); - - const { intent, confirmation } = classifyCommand(payload.text); - const command: CollaborationCommand = { - command_id: uid("cmd"), - idempotency_key: idempotencyKey, - text: payload.text, - intent, - confidence: 0.86, - target_type: payload.target_node_id ? "node" : "run", - target_ids: payload.target_node_id ? [payload.target_node_id] : [], - normalized_instruction: payload.text, - requires_confirmation: confirmation, - cost_level: confirmation ? "high" : "low", - reason: confirmation ? "该操作将触发重新检索/重写,影响下游成果" : "只读问答或轻量修改", - impact_nodes: payload.target_node_id ? [payload.target_node_id] : [], - status: "classified", - created_at: now(), - }; - store.commands[command.command_id] = command; - emit(store, "command.classified", { command }); - - if (confirmation) { - later(store, 300, () => { - command.status = "confirmation_required"; - emit(store, "command.confirmation.required", { command: { ...command } }); - }); - } else { - later(store, 300, () => { - command.status = "accepted"; - emit(store, "command.accepted", { command_id: command.command_id, waiting_boundary: false }); - later(store, 900, () => { - command.status = "completed"; - emit(store, "command.completed", { - command_id: command.command_id, - result_summary: "已处理", - }); - streamAssistant( - store, - aiMessage("协调者", "agent_run_coordinator"), - intent === "polish_report" - ? "已按「更正式的语气」生成新版报告草稿,确认后将作为 v2 生效。" - : "已解答。如需修改报告请直接说明章节与要求。", - ); - }); - }); - } - return { ...command }; - }, - - async confirmCommand(runId, commandId) { - const store = [...stores.values()].find((candidate) => candidate.run.id === runId); - if (!store) throw new Error("run not found"); - const command = store.commands[commandId]; - if (!command) throw new Error("command not found"); - command.status = "accepted"; - emit(store, "command.accepted", { command_id: commandId, waiting_boundary: false }); - - const targetNode = command.target_ids[0] ?? "n-research-market"; - later(store, 400, () => { - emit(store, "node.attempt.created", { node_id: targetNode, attempt: 2, reason: command.text }); - setNodeStatus(store, targetNode, "queued", 2); - }); - later(store, 800, () => setNodeStatus(store, targetNode, "running", 2)); - later(store, 1600, () => { - streamAssistant( - store, - aiMessage(store.nodes[targetNode]?.label ?? "研究员", `agent_run_${targetNode}`, "", `${targetNode}-attempt2`), - "已按新要求完成重做,本轮证据/文稿已重新提交质量门。", - ); - }); - later(store, 2400, () => { - setNodeStatus(store, targetNode, "completed", 2); - command.status = "completed"; - emit(store, "command.completed", { command_id: commandId, result_summary: "目标节点已重跑" }); - }); - return { ...command }; - }, - - async cancelCommand(runId, commandId) { - const store = [...stores.values()].find((candidate) => candidate.run.id === runId); - if (!store) throw new Error("run not found"); - const command = store.commands[commandId]; - if (!command) throw new Error("command not found"); - command.status = "cancelled"; - emit(store, "command.completed", { command_id: commandId, result_summary: "用户取消" }); - return { ...command }; - }, - - async getNodeContract(runId, nodeId) { - const store = [...stores.values()].find((candidate) => candidate.run.id === runId); - const planNode = store?.plans - .flatMap((plan) => plan.nodes.map((node) => ({ plan, node }))) - .find((entry) => entry.node.id === nodeId); - const nodeState = store?.nodes[nodeId]; - if (!store || (!nodeState && !planNode)) throw new Error("node not found"); - return { - node_id: nodeId, - label: nodeState?.label ?? planNode?.node.label ?? nodeId, - role: nodeState?.role_display_name ?? planNode?.node.role_display_name ?? null, - angle: nodeState?.angle ?? planNode?.node.angle ?? null, - status: nodeState?.status ?? "planned", - attempt: nodeState?.attempt ?? 0, - mission: planNode?.node.mission ?? "(演示)按计划合同执行本节点职责。", - input_artifact_types: planNode?.node.input_artifact_types ?? [], - source_policy: "公开网络检索,优先近 90 天,权威来源优先", - output_artifact_type: planNode?.node.output_artifact_type ?? "EvidenceBundle", - output_structure: null, - acceptance_criteria: planNode?.node.acceptance_criteria ?? ["来源可溯源", "结论有证据支撑"], - allowed_tools: planNode?.node.allowed_tools ?? ["web_search"], - forbidden: ["直接发布报告", "修改已核验证据"], - time_budget_seconds: 300, - token_budget: 60000, - max_attempts: 3, - upstream: planNode?.node.depends_on ?? [], - downstream: - planNode?.plan.edges.filter((edge) => edge.source === nodeId).map((edge) => edge.target) ?? [], - }; - }, - - async listReportVersions(sessionId) { - const store = getStore(sessionId); - return store ? [...store.reportVersions] : []; - }, - - async createRewrite(sessionId, payload) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - return { - id: uid("rw"), - session_id: sessionId, - section: payload.section ?? null, - instruction: payload.instruction, - status: "proposed", - created_at: now(), - }; - }, - - async applyRewrite(sessionId, rewriteId) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - store.versionCounter += 1; - const parent = store.reportVersions.at(-1); - const version: ReportVersion = { - id: uid("ver"), - session_id: sessionId, - version: store.versionCounter, - parent_version_id: parent?.id ?? null, - change_type: "section_rewrite", - change_summary: `章节改写(${rewriteId})`, - markdown: `${parent?.markdown ?? DEMO_REPORT_MARKDOWN}\n\n`, - source_index: [], - created_at: now(), - is_head: true, - }; - for (const old of store.reportVersions) old.is_head = false; - store.reportVersions.push(version); - store.session.current_report_version_id = version.id; - emit(store, "report.version.created", { version }); - return version; - }, - - async restoreReportVersion(sessionId, versionId) { - const store = getStore(sessionId); - if (!store) throw new Error("session not found"); - const source = store.reportVersions.find((version) => version.id === versionId); - if (!source) throw new Error("version not found"); - store.versionCounter += 1; - const version: ReportVersion = { - ...source, - id: uid("ver"), - version: store.versionCounter, - parent_version_id: source.id, - change_type: "restore", - change_summary: `恢复自 v${source.version}`, - created_at: now(), - is_head: true, - }; - for (const old of store.reportVersions) old.is_head = false; - store.reportVersions.push(version); - store.session.current_report_version_id = version.id; - emit(store, "report.version.created", { version }); - return version; - }, -}; - -export const mockDriver = mockDriverImpl; diff --git a/frontend-web/src/report-collaboration/components/SessionHistoryPanel.tsx b/frontend-web/src/report-collaboration/components/SessionHistoryPanel.tsx index 18a9f39b..1513735f 100644 --- a/frontend-web/src/report-collaboration/components/SessionHistoryPanel.tsx +++ b/frontend-web/src/report-collaboration/components/SessionHistoryPanel.tsx @@ -30,6 +30,7 @@ export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: stri const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(""); const [busyId, setBusyId] = useState(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 (
-
- +
+ {loading ? "加载中…" : `${sessions?.length ?? 0} 个会话`} - +
+ + +
{error ? (

diff --git a/frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts b/frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts index f97eca68..36f709d5 100644 --- a/frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts +++ b/frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts @@ -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) => { diff --git a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx index 93c498a1..4db6c59a 100644 --- a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx +++ b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx @@ -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 | 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={ - setComposerTarget(null)} - onSend={(text) => - void session.send(text, { - nodeId: composerTarget?.nodeId ?? null, - agentRunId: composerTarget?.agentRunId ?? null, - }) - } - focusSignal={focusSignal} - /> +

+ {showPlanRequestEntry ? ( +
+ +
+ ) : null} + setComposerTarget(null)} + onSend={(text) => + void session.send(text, { + nodeId: composerTarget?.nodeId ?? null, + agentRunId: composerTarget?.agentRunId ?? null, + }) + } + focusSignal={focusSignal} + /> +
} /> } diff --git a/offline-backend-20260512/backend/app/report_collaboration/agentscope_runtime/member_kernel.py b/offline-backend-20260512/backend/app/report_collaboration/agentscope_runtime/member_kernel.py index 489989d9..702d4ef4 100644 --- a/offline-backend-20260512/backend/app/report_collaboration/agentscope_runtime/member_kernel.py +++ b/offline-backend-20260512/backend/app/report_collaboration/agentscope_runtime/member_kernel.py @@ -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") diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 95a825ca..babc3250 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 diff --git a/offline-backend-20260512/backend/skill-packages/add-audience/SKILL.md b/offline-backend-20260512/backend/skill-packages/add-audience/SKILL.md index eb40cf21..01c674c0 100644 --- a/offline-backend-20260512/backend/skill-packages/add-audience/SKILL.md +++ b/offline-backend-20260512/backend/skill-packages/add-audience/SKILL.md @@ -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` 中的 `` 引用标记会被自动清洗,无需预处理。 完整字段定义、账号对象字段表与生成规则,见: diff --git a/offline-backend-20260512/backend/skill-packages/add-audience/config/audience_api.json b/offline-backend-20260512/backend/skill-packages/add-audience/config/audience_api.json index 494715d4..f11ab89b 100644 --- a/offline-backend-20260512/backend/skill-packages/add-audience/config/audience_api.json +++ b/offline-backend-20260512/backend/skill-packages/add-audience/config/audience_api.json @@ -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", diff --git a/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py b/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py index febd0d8f..08cce463 100644 --- a/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py +++ b/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py @@ -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, diff --git a/scripts/env.sh b/scripts/env.sh index dbd4f238..b7a62d9f 100644 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -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 } diff --git a/scripts/start-backend.sh b/scripts/start-backend.sh index 0aea2601..6431960d 100644 --- a/scripts/start-backend.sh +++ b/scripts/start-backend.sh @@ -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" \ diff --git a/scripts/start-frontend.sh b/scripts/start-frontend.sh index f560b2cc..50a376a1 100644 --- a/scripts/start-frontend.sh +++ b/scripts/start-frontend.sh @@ -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" \