From dcfaa199bf2814207bc1f57ee70225d648e1a665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 18 Aug 2026 20:08:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BC=9A?= =?UTF-8?q?=E5=95=86=20Markdown=20=E4=BA=A7=E7=89=A9=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=88=B0=E4=BB=BB=E5=8A=A1=E8=BF=87=E7=A8=8B=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20Angular=20=E5=AF=B9=E6=8E=A5=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8BF/会商工作区可将 Markdown 产物一键提交到任务过程报告,后端记录提交状态以便跨用户与刷新后保持已提交;同时为 Angular 智能体对话对接文档补上前后端参考代码索引。 Co-authored-by: Cursor --- .../docs/angular-智能体对话对接文档.md | 315 +++++++++++++++++- .../artifacts/artifact-file-detail.tsx | 11 +- .../eightbf-roundtable/api/task-context.ts | 77 +++++ .../pages/EightBfRoundtablePage.tsx | 13 + .../api/artifact-submissions.ts | 74 ++++ .../components/HighFidelityReport.tsx | 44 +++ .../components/Step2Panel.tsx | 75 +++-- .../components/Step2SandboxLayout.tsx | 15 + .../components/Step3Panel.tsx | 4 + .../components/Step3ReportMode.tsx | 31 ++ .../components/Step3SummaryMode.tsx | 31 ++ .../components/TaskArtifactSubmitButton.tsx | 50 +++ .../hooks/useTaskArtifactSubmission.ts | 207 ++++++++++++ .../lib/task-artifact-submission.ts | 40 +++ .../pages/RoundtablePlanningPage.tsx | 13 + .../roundtable_artifact_submissions.py | 22 ++ .../roundtable_artifact_submissions/base.py | 17 + .../roundtable_artifact_submissions/memory.py | 57 ++++ .../roundtable_artifact_submissions/sql.py | 80 +++++ 19 files changed, 1133 insertions(+), 43 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/artifact-submissions.ts create mode 100644 frontend-web/src/roundtable-planning/components/TaskArtifactSubmitButton.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts create mode 100644 frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts diff --git a/frontend-web/docs/angular-智能体对话对接文档.md b/frontend-web/docs/angular-智能体对话对接文档.md index 34f0b92b..05bc4855 100644 --- a/frontend-web/docs/angular-智能体对话对接文档.md +++ b/frontend-web/docs/angular-智能体对话对接文档.md @@ -12,6 +12,12 @@ http://localhost:5173/#/page/workspace/agents/roundtable-structure/chats/new 本文覆盖:**登录拿 Token → 列模型/切模型 → 建会话/存聊天记录 → 流式对话 → 思考内容 → 步骤条(含创建脚本/执行脚本/写入文件及全部工具)→ 用户协助卡片 → 消息列表渲染 → 停止/重连/上传/产物**。 **不包含**智能体配置(创建/编辑智能体、技能绑定等)。 +路径约定(均相对本仓库根目录 `deerflow-server/`): + +- 前端:`frontend-web/src/…` +- 后端:`offline-backend-20260512/backend/…`(下文若写 `app/`、`deerflow/`,都在这个 backend 目录下) +- 每节末尾有 **参考代码**;文末第 20 节是总索引。 + --- ## 0. 先看这一页 @@ -45,6 +51,17 @@ Content-Type: application/json 用 Bearer **不必**再带 CSRF。不要用 cookie 登录(`/login/local` 只把 JWT 写进 HttpOnly cookie,不适合 Angular 跨域 API 客户端)。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端两个 Base URL | `frontend-web/src/core/config/index.ts`(`getBackendBaseURL` / `getLangGraphBaseURL`) | +| 环境变量注入 | `frontend-web/src/env.ts` | +| LangGraph SDK 客户端(自动带 Bearer) | `frontend-web/src/core/api/api-client.ts` | +| Gateway fetch 自动带 Authorization | `frontend-web/src/core/api/fetch-client.ts` | +| CSRF:有 Bearer 则跳过 | `offline-backend-20260512/backend/app/gateway/csrf_middleware.py` | +| 登录接口白名单 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` | + --- ## 1. 架构与端到端流程 @@ -77,6 +94,19 @@ sequenceDiagram 2. `context.agent_id = "roundtable-structure"` 3. 会话 `metadata.agent_id = "roundtable-structure"`(历史列表按此过滤) +**参考代码** + +| 说明 | 路径 | +|------|------| +| 路由挂载 AgentChatPage | `frontend-web/src/pages/WorkspaceRoutes.tsx`(`agents/:agent_id/chats/:thread_id`) | +| 对话页:`assistantId` + `context.agent_id` | `frontend-web/src/pages/AgentChatPage.tsx` | +| 聊天气泡壳 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| 发消息时写入 metadata.agent_id | `frontend-web/src/core/threads/hooks.ts`(`onCreated` / `thread.submit`) | +| 非内置 assistant_id → agent_id | `offline-backend-20260512/backend/app/gateway/services.py`(`build_run_config`) | +| Lead Agent 工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` | +| 智能体种子(id / 中文名 / 技能) | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` | +| 智能体 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` | + --- ## 2. CORS / 跨域(Angular 必读) @@ -94,6 +124,8 @@ GATEWAY_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,https://your-an 或开发期临时 `GATEWAY_CORS_ALLOW_ALL=1`(勿用于生产)。 +**参考代码**:`offline-backend-20260512/backend/app/gateway/app.py`(搜 `GATEWAY_CORS_ORIGINS` / `local_vite_origins` / `cors_expose_headers`)。回归:`offline-backend-20260512/backend/tests/test_taskcop_cors.py`。 + 浏览器跨域读 SSE 还需要暴露这些响应头(Gateway 已配): - `Content-Location`(从中解析 `run_id`,取消/重连要用) @@ -210,6 +242,20 @@ function authHeader(): HttpHeaders { } ``` +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端登录 API | `frontend-web/src/core/auth/api.ts`(`loginByUsername` / `loginByToken`) | +| Token 存取、拼 Bearer | `frontend-web/src/core/auth/index.ts`(`getAuthorizationHeaderValue` / `setStoredAuth`) | +| 类型 `LoginByUsernameResponse` | `frontend-web/src/core/auth/index.ts` | +| 登录页(含 token 换票) | `frontend-web/src/pages/LoginPage.tsx` | +| 后端登录路由 | `offline-backend-20260512/backend/app/gateway/routers/auth.py`(`/login/username`、`/login/token`、`/me`) | +| JWT 签发 | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` | +| 请求里解析 cookie / Bearer | `offline-backend-20260512/backend/app/gateway/deps.py`(`get_current_user`) | +| 登录配置(口令门 / token_login) | `offline-backend-20260512/backend/config.yaml` → `auth_login`;schema:`deerflow/config/login_config.py` | +| 登录契约说明 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` | + --- ## 4. 模型列表与切换 @@ -266,6 +312,20 @@ Authorization: Bearer 每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端拉模型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` | +| 输入框模型下拉 + 档位 | `frontend-web/src/components/workspace/input-box.tsx`(`InputMode`、`ModelSelector`) | +| 档位写入 context | `frontend-web/src/core/threads/hooks.ts`(`thread.submit` 的 `context`:`thinking_enabled` / `is_plan_mode` / `subagent_enabled`) | +| 本地记住所选模型 | `frontend-web/src/core/settings/local.ts`(`deerflow.thread-model.{threadId}`) | +| 后端模型列表 | `offline-backend-20260512/backend/app/gateway/routers/models.py` | +| context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py`(`_CONTEXT_CONFIGURABLE_KEYS`) | +| 本轮实际用哪个模型 | `offline-backend-20260512/backend/app/gateway/services.py`(`_resolve_effective_run_model_name`) | +| 创建 ChatModel、关思考 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | +| 模型配置源 | `offline-backend-20260512/backend/config.yaml` → `models[]` | + --- ## 5. 会话(Thread)= 聊天记录的存储单位 @@ -393,6 +453,20 @@ DELETE {LG}/threads/{thread_id} `status`:`idle` | `busy` | `interrupted` | `error` 正在跑时为 `busy`。同一会话再发会被 `multitask_strategy: "reject"` 打回 **HTTP 409**。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 线程类型 / context 类型 | `frontend-web/src/core/threads/types.ts` | +| 列表、标题、路由 | `frontend-web/src/core/threads/utils.ts`(`titleOfThread` / `pathOfThread`) | +| 分页搜索 | `frontend-web/src/core/threads/hooks.ts`(`useThreadsPage`) | +| 智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` | +| 工作区会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` | +| 建/搜/改/删/state/history | `offline-backend-20260512/backend/app/gateway/routers/threads.py` | +| ThreadResponse / Search 模型 | 同上文件内 `ThreadResponse`、`ThreadCreateRequest`、`ThreadSearchRequest` | +| 删除时清本地目录 | `frontend-web/src/core/threads/hooks.ts`(delete 后再调 Gateway DELETE) | +| 409 中文文案 | `offline-backend-20260512/backend/app/gateway/services.py`(`create_or_reject` / RunManager) | + --- ## 6. 发起智能体对话(核心) @@ -511,6 +585,21 @@ Last-Event-ID: <上一帧 id> 心跳是注释行:`: heartbeat`,忽略即可。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端 `useStream` + `thread.submit` | `frontend-web/src/core/threads/hooks.ts`(`useThreadStream`、构造 `messages` / `context`) | +| SDK 包装 | `frontend-web/src/core/api/api-client.ts` | +| stream_mode 规范化 | `frontend-web/src/core/api/stream-mode.ts` | +| 停止:`thread.stop` / `runs.cancel` | `frontend-web/src/core/threads/hooks.ts`(`stopThreadRun`) | +| 输入框停止按钮 | `frontend-web/src/components/workspace/input-box.tsx` | +| 后端 stream / cancel / join | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py`(`RunCreateRequest`、`stream_run`、`cancel_run`、`join_run`) | +| 真正起跑 | `offline-backend-20260512/backend/app/gateway/services.py`(`start_run`) | +| 后台跑图 + 发 SSE | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`run_agent`) | +| Run 生命周期 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` | +| 流式设计说明 | `offline-backend-20260512/backend/docs/STREAMING.md` | + --- ## 7. SSE 事件协议(Angular 必须自己解析 POST 流) @@ -655,6 +744,18 @@ async function streamRun( } ``` +**参考代码** + +| 说明 | 路径 | +|------|------| +| SSE 组帧(`event:` / `data:` / `id:`) | `offline-backend-20260512/backend/app/gateway/services.py`(`format_sse`、`sse_consumer`) | +| 心跳 / 订阅 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` | +| 首帧 `event: metadata` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`bridge.publish(..., "metadata", {run_id, thread_id})`) | +| `messages-tuple` → 内部 `messages` | 同上 `run_agent`;SSE 事件名 `_lg_mode_to_sse_event` | +| chunk 序列化成 `[chunk, meta]` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py`(`serialize_messages_tuple` / `serialize_channel_values`) | +| 前端消费:onCustomEvent / onUpdateEvent | `frontend-web/src/core/threads/hooks.ts` | +| 协议说明 | `offline-backend-20260512/backend/docs/STREAMING.md` | + --- ## 8. 消息数据结构(历史 + 实时共用) @@ -707,6 +808,14 @@ async function streamRun( | `ai` 有 `tool_calls` 或仅有思考 | **不单独当最终气泡**,进入步骤条 | | `tool` | **几乎不单独成气泡**,并进步骤条 / 协助卡片 | +**参考代码** + +| 说明 | 路径 | +|------|------| +| 分组、抽文本、抽思考、隐藏消息 | `frontend-web/src/core/messages/utils.ts`(`groupMessages` / `extractContentFromMessage` / `extractReasoningContentFromMessage` / `isHiddenFromUIMessage`) | +| 检索结果识别 | `frontend-web/src/core/messages/search-results.ts` | +| 序列化后的 message dict | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` | + --- ## 9. 消息列表如何分组渲染 @@ -765,6 +874,20 @@ async function streamRun( `busy` 且还没有最终 `assistant` 气泡时,在列表底部显示跳动的「正在生成」。 上传文件未完成时可插一条 mock AI:`additional_kwargs.element === "task"`,文案「文件上传中,请稍候...」。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 列表入口、分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx` | +| 单条用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` | +| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` | +| 气泡/会话滚动容器 | `frontend-web/src/components/ai-elements/message.tsx`、`conversation.tsx` | +| 跳动「正在生成」 | `frontend-web/src/components/workspace/streaming-indicator.tsx` | +| 技能来源展示 | `frontend-web/src/core/skill-display/runtime.ts` | +| 导出 | `frontend-web/src/core/threads/export.ts`;Word:`frontend-web/src/components/workspace/messages/download-word-button.tsx` | +| 删除消息 | `frontend-web/src/components/workspace/messages/delete-message-button.tsx` | +| 问题跳转 | `frontend-web/src/components/workspace/question-navigator.tsx` | + --- ## 10. 思考内容如何对接 @@ -809,6 +932,17 @@ function splitThink(content: string): { answer: string; thinking: string | null `thinking_enabled: false` 时模型不应再吐思考;若仍出现 ``,继续剥掉以免露标签。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 抽思考 / 剥 ``(含未闭合) | `frontend-web/src/core/messages/utils.ts`(`extractReasoningContentFromMessage`、`splitInlineReasoning`) | +| 步骤条里的思考步 | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps` 的 `reasoning`) | +| 独立思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` | +| 文案「思考」 | `frontend-web/src/core/i18n/locales/zh-CN.ts`(`t.common.thinking`) | +| 模型思考流字段 | `offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py` | +| 关思考注入 extra_body | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | + --- ## 11. 步骤条(Chain of Thought)— 请做全 @@ -925,6 +1059,23 @@ interface WebFetchArgs { url: string } 字段名不统一(`content` / `page_content` / `snippet` / `m_title` / `name`),展示时做并集兜底。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| **步骤条主实现(必看)** | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps`、按 `name` 分支、`ArtifactWriteToolCall`) | +| ChainOfThought 组件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` | +| 中文底稿 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls`;类型 `frontend-web/src/core/i18n/locales/types.ts` | +| 工具一句话说明 | `frontend-web/src/core/tools/utils.ts`(`explainToolCall`) | +| 检索结果卡片 | `frontend-web/src/core/messages/search-results.ts` | +| 写文件实时预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` | +| bash / ls / read / write / glob / grep | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py`(参数都要求先填 `description`) | +| 工具装配(含 MCP、内置) | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` | +| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` | +| skill_list / skill_view / search_skills | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py` | +| 启用哪些工具 | `offline-backend-20260512/backend/config.yaml` → `tools[]` | +| 会商页步骤条(对照用,对话页以 message-group 为准) | `frontend-web/src/roundtable-planning/lib/step-display.tsx` | + --- ## 12. 用户协助卡片(ask_clarification) @@ -1007,6 +1158,17 @@ input.messages = [{ type: "human", content: "<上面拼好的文本>" }] 不要调 `/state` 的 resume,也不要自己造 ToolMessage。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 卡片 UI、拼「我选择:」、多卡合并提交 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`ClarificationCard`、`handleSubmitAllClarifications`) | +| 识别 tool 消息 | `frontend-web/src/core/messages/utils.ts`(`isClarificationToolMessage`) | +| 工具定义 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` | +| 截住工具、写 `additional_kwargs.clarification`、goto END | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py`(`_build_payload`) | +| `allow_multiple` 解析 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` | +| 提交后又走同一套 sendMessage | `frontend-web/src/pages/AgentChatPage.tsx`、`frontend-web/src/core/threads/hooks.ts`(**不是** Command resume) | + --- ## 13. 其它卡片与面板 @@ -1056,6 +1218,18 @@ interface Todo { content?: string; status?: 'pending' | 'in_progress' | 'complet `setup_agent` / `setup_writing` 带 approval kwargs。普通 `roundtable-structure` 对话 **不会出现**,可忽略。 Deep Research 的 `setup_research_approval` 同理。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| present_files 分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`assistant:present-files`) | +| 产物 API | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 产物前端 | `frontend-web/src/components/workspace/artifacts/`(如 `artifact-file-detail.tsx`) | +| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` | +| `task` 工具 / 子代理 | `offline-backend-20260512/backend/packages/harness/deerflow/subagents/` | +| Todo 面板 | `frontend-web/src/components/workspace/todo-list.tsx`;类型 `frontend-web/src/core/todos/types.ts` | +| write_todos 中间件装配 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py`(`_build_middlewares`) | + --- ## 14. 标题、追问、错误态 @@ -1095,6 +1269,18 @@ POST {GATEWAY}/api/threads/{thread_id}/suggestions | 上下文压缩 | `custom.context_compacting` | 「正在压缩上下文…」 | | 会话不属于当前用户 | 403/404 | 回到新建 | +**参考代码** + +| 说明 | 路径 | +|------|------| +| 标题展示 | `frontend-web/src/components/workspace/thread-title.tsx` | +| 列表用 title | `frontend-web/src/core/threads/utils.ts`(`titleOfThread`) | +| 临时标题 + LLM 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` | +| 追问 API 调用 | `frontend-web/src/components/workspace/input-box.tsx`(`/api/threads/${threadId}/suggestions`) | +| 追问后端 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` | +| 409 toast | `frontend-web/src/core/threads/hooks.ts`(`isActiveRunConflictError` / `onError`) | +| 压缩 toast | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py`(`custom.context_compacting`) | + --- ## 15. 文件上传(用户侧) @@ -1138,6 +1324,16 @@ PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。 限额:`GET /api/threads/{thread_id}/uploads/limits`。 列表/删除:`GET …/uploads/list`,`DELETE …/uploads/{filename}`。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端上传 | `frontend-web/src/components/workspace/input-box.tsx`;预览 `frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx` | +| 后端上传路由 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` | +| 目录与虚拟路径 | `offline-backend-20260512/backend/packages/harness/deerflow/uploads/manager.py` | +| 文档转 Markdown | `offline-backend-20260512/backend/packages/harness/deerflow/utils/file_conversion.py` | +| 说明文档 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` | + --- ## 16. 推荐的页面结构(对标 AgentChatPage) @@ -1162,6 +1358,20 @@ PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。 `roundtable-structure` 是「结构化抽取」智能体:给它方案报告或席位交付,产出 flow-json 流程图数据,常伴随写文件 + 展示文件。不要当成多智能体会商编排页(`/roundtable` 是另一套 API)。 +**参考代码** + +| 说明 | 路径 | +|------|------| +| 对话页整页 | `frontend-web/src/pages/AgentChatPage.tsx` | +| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| ChatBox(标题栏 + 列表 + 输入) | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| 输入框 | `frontend-web/src/components/workspace/input-box.tsx` | +| 左栏最近会话 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx`、`agent-recent-chats.tsx` | +| 欢迎/推荐问题 | `frontend-web/src/components/workspace/agent-welcome.tsx`、`agents/agent-question-chips.tsx` | +| 智能体种子 | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/` | +| 会商编排(不要和本页搞混) | `frontend-web/src/roundtable-planning/`、`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | +| iframe 嵌现网 UI(备选) | `frontend-web/docs/embed-iframe-chat.md` | + --- ## 17. 最小对接顺序(验收清单) @@ -1240,22 +1450,95 @@ Base:`{GATEWAY} = http://host:8001`,LangGraph `{LG} = {GATEWAY}/api`。 --- -## 20. 参考实现位置(本仓库) +## 20. 参考实现总索引 -| 主题 | 文件 | +路径均相对仓库根 `deerflow-server/`。后端根目录 = `offline-backend-20260512/backend/`。 +各节正文末尾也有对应表;这里按主题汇总,加粗的是对接时优先打开的文件。 + +### 20.1 前端:页面与 UI + +| 主题 | 路径 | |------|------| -| 对话页 | `frontend-web/src/pages/AgentChatPage.tsx` | -| 发消息 / SSE | `frontend-web/src/core/threads/hooks.ts` | -| 消息分组 | `frontend-web/src/core/messages/utils.ts` | -| 步骤条 | `frontend-web/src/components/workspace/messages/message-group.tsx` | -| 协助卡片 | `frontend-web/src/components/workspace/messages/message-list.tsx` | -| 中文步骤文案 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls` | -| 登录 | `frontend-web/src/core/auth/api.ts` | -| 模型 | `GET /api/models` ← `app/gateway/routers/models.py` | -| 会话 / 状态 | `app/gateway/routers/threads.py` | -| 流式 | `app/gateway/routers/thread_runs.py` | -| SSE 帧 | `app/gateway/services.py` `format_sse` | -| 协助卡 payload | `deerflow/agents/middlewares/clarification_middleware.py` | -| 沙箱工具 description | `deerflow/sandbox/tools.py`(bash / write_file / …) | +| **智能体对话页** | `frontend-web/src/pages/AgentChatPage.tsx` | +| 路由 `agents/:agent_id/chats/:thread_id` | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| 对话壳(标题 / 列表 / 输入 / 产物入口) | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| **消息列表 + 协助卡片** | `frontend-web/src/components/workspace/messages/message-list.tsx` | +| 用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` | +| **步骤条(创建脚本/执行脚本/写入文件等)** | `frontend-web/src/components/workspace/messages/message-group.tsx` | +| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` | +| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` | +| 思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` | +| ChainOfThought 控件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` | +| 输入框(模型、档位、发送、停止、追问、上传) | `frontend-web/src/components/workspace/input-box.tsx` | +| Todo 条 | `frontend-web/src/components/workspace/todo-list.tsx` | +| 会话标题 | `frontend-web/src/components/workspace/thread-title.tsx` | +| 正在生成 | `frontend-web/src/components/workspace/streaming-indicator.tsx` | +| 会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` | +| 该智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` | +| 左栏 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx` | +| 产物预览 | `frontend-web/src/components/workspace/artifacts/` | +| 中文步骤文案 `toolCalls.*` | `frontend-web/src/core/i18n/locales/zh-CN.ts` | -智能体种子:`offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/`(`id: roundtable-structure`,`name: 结构化抽取`)。 +### 20.2 前端:协议与状态 + +| 主题 | 路径 | +|------|------| +| **发消息 / useStream / cancel / 409** | `frontend-web/src/core/threads/hooks.ts` | +| 线程类型 | `frontend-web/src/core/threads/types.ts` | +| 标题与路由 | `frontend-web/src/core/threads/utils.ts` | +| **消息分组、思考、隐藏** | `frontend-web/src/core/messages/utils.ts` | +| 检索结果归一化 | `frontend-web/src/core/messages/search-results.ts` | +| **登录 API** | `frontend-web/src/core/auth/api.ts` | +| Bearer 存取 | `frontend-web/src/core/auth/index.ts` | +| 模型 API / 类型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` | +| LangGraph Client | `frontend-web/src/core/api/api-client.ts` | +| Gateway fetch + 401 | `frontend-web/src/core/api/fetch-client.ts` | +| Base URL | `frontend-web/src/core/config/index.ts`、`frontend-web/src/env.ts` | +| 写文件流式预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` | +| 工具一句话 | `frontend-web/src/core/tools/utils.ts` | + +### 20.3 后端:HTTP + +| 主题 | 路径 | +|------|------| +| **登录 /me** | `offline-backend-20260512/backend/app/gateway/routers/auth.py` | +| JWT | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` | +| **CORS** | `offline-backend-20260512/backend/app/gateway/app.py` | +| 鉴权中间件 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` | +| **模型列表** | `offline-backend-20260512/backend/app/gateway/routers/models.py` | +| **会话 CRUD / search / state** | `offline-backend-20260512/backend/app/gateway/routers/threads.py` | +| **runs/stream / cancel / join** | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py` | +| start_run、SSE、context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py` | +| 上传 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` | +| 产物 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 追问 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` | +| 简化开放聊天(不要用来复刻本页) | `offline-backend-20260512/backend/app/gateway/routers/open_chat.py` | + +### 20.4 后端:运行时与工具 + +| 主题 | 路径 | +|------|------| +| **跑图 + 发 SSE 事件** | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py` | +| RunManager | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` | +| StreamBridge | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` | +| 消息序列化 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` | +| Lead Agent | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` | +| **协助卡中间件** | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py` | +| 协助工具 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` | +| **bash / write_file 等(description)** | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py` | +| 工具总装 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` | +| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` | +| 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` | +| 模型工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | + +### 20.5 配置、种子、文档 + +| 主题 | 路径 | +|------|------| +| **roundtable-structure 种子** | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` | +| 同上 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` | +| 模型 / 工具开关 | `offline-backend-20260512/backend/config.yaml` | +| 流式协议 | `offline-backend-20260512/backend/docs/STREAMING.md` | +| 登录契约 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` | +| 上传 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` | +| iframe 嵌入备选 | `frontend-web/docs/embed-iframe-chat.md` | diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 9af34f0b..fda1e389 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -154,6 +154,7 @@ export function ArtifactFileDetail({ hideFileName = false, onDownloadClick, toolbarActions, + headerLeadingAction, researchSessionId, }: { className?: string; @@ -189,6 +190,8 @@ export function ArtifactFileDetail({ * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 */ toolbarActions?: ReactNode; + /** Replaces the left file-type badge for an embedded workspace action. */ + headerLeadingAction?: ReactNode; }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); @@ -1203,9 +1206,11 @@ export function ArtifactFileDetail({
- - {fileTypeLabel} - + {headerLeadingAction ?? ( + + {fileTypeLabel} + + )} {!hideFileName && ( {/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下 diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts index 280414a0..ef38b004 100644 --- a/frontend-web/src/eightbf-roundtable/api/task-context.ts +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -73,6 +73,8 @@ export function createEightBfFallbackTask(taskId: string): EightBfTask { } const TASK_DETAIL_PATH = "/task/{taskId}"; +const PROCESS_REPORT_ADD_PATH = "/task/process-report/add"; +const PROCESS_REPORT_PATH = "/task/process-report/{id}"; const DICTIONARY_TYPES = { priority: "task_priority", direction: "task_direction", @@ -94,6 +96,21 @@ function externalUrl(path: string): string { return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`; } +export interface EightBfTaskProcessReportInput { + taskId: string; + agentId: string; + agentName: string; + content: string; + /** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */ + position: number; + reportName: string; + submitStatus: 0 | 1; +} + +export interface EightBfTaskProcessReportReceipt { + id: string; +} + /** * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 @@ -122,6 +139,66 @@ async function fetchExternalTaskSystem( }); } +async function externalTaskError(response: Response, fallback: string): Promise { + const text = await response.text().catch(() => ""); + try { + const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown }; + const detail = firstText(payload.message, payload.msg, payload.detail); + if (detail) return new Error(`${fallback}(${response.status}):${detail}`); + } catch { + // Keep the HTTP status when this deployment returns a non-JSON error body. + } + return new Error(`${fallback}(${response.status})`); +} + +function findProcessReportId(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + const source = payload as Record; + const direct = firstText( + source.id, + source.processReportId, + source.process_report_id, + source.reportId, + ); + if (direct) return direct; + return findProcessReportId(source.data); +} + +/** + * Create the external task system's process report in its unsubmitted state. + * The local gateway stores the returned id so a refresh can later submit the + * same report instead of creating a duplicate one. + */ +export async function createEightBfTaskProcessReport( + input: EightBfTaskProcessReportInput, +): Promise { + const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); + const id = findProcessReportId(await response.json().catch(() => null)); + if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID"); + return { id }; +} + +/** Submit an already-registered process report to the external task system. */ +export async function updateEightBfTaskProcessReport( + reportId: string, + input: EightBfTaskProcessReportInput, +): Promise { + const response = await fetchExternalTaskSystem( + PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)), + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); +} + function firstText(...values: unknown[]): string { for (const value of values) { if (value === undefined || value === null) continue; diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index e66a0331..0bfc7447 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -15,9 +15,12 @@ import { fetchEightBfTask, fetchEightBfTaskDictionaries, normalizeEightBfTask, + createEightBfTaskProcessReport, + updateEightBfTaskProcessReport, type EightBfTask, type EightBfTaskDictionaries, } from "@/eightbf-roundtable/api/task-context"; +import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission"; import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import { listChains } from "@/roundtable-planning/api/chains"; @@ -79,6 +82,15 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa () => buildEightBfTaskContext(task, taskDictionaries), [task, taskDictionaries], ); + const taskArtifactSubmissionConfig = useMemo( + () => taskId ? { + taskId, + position: 0, + createProcessReport: createEightBfTaskProcessReport, + updateProcessReport: updateEightBfTaskProcessReport, + } : undefined, + [taskId], + ); useEffect(() => { if (!taskId) { @@ -305,6 +317,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa /> } rootClassName="eightbf-roundtable" + taskArtifactSubmissionConfig={taskArtifactSubmissionConfig} /> ); } diff --git a/frontend-web/src/roundtable-planning/api/artifact-submissions.ts b/frontend-web/src/roundtable-planning/api/artifact-submissions.ts new file mode 100644 index 00000000..ae6afaf7 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/artifact-submissions.ts @@ -0,0 +1,74 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface RoundtableArtifactReference { + sessionId?: string | null; + threadId: string; + nodeKey?: string | null; + sourceKey: string; + path: string; + name: string; + mimeType?: string | null; + revision: number; +} + +export interface RoundtableArtifactSubmissionRecord { + id: string; + taskId: string; + version: number; + submitted: boolean; + submittedAt?: string | null; + cancelledAt?: string | null; + taskDeliveryId?: string | null; + artifact: RoundtableArtifactReference; +} + +function endpoint(taskId: string): string { + return `${getBackendBaseURL()}/api/roundtable/tasks/${encodeURIComponent(taskId)}/artifacts`; +} + +async function readError(response: Response, fallback: string): Promise { + const body = await response.text().catch(() => ""); + try { + const parsed = JSON.parse(body) as { detail?: unknown; message?: unknown }; + const detail = typeof parsed.detail === "string" + ? parsed.detail + : typeof parsed.message === "string" + ? parsed.message + : ""; + if (detail) return new Error(detail); + } catch { + // The status fallback below is more useful than an opaque HTML response. + } + return new Error(`${fallback}(${response.status})`); +} + +export async function listRoundtableArtifactSubmissions( + taskId: string, + submitted: boolean, +): Promise { + const response = await apiFetch(`${endpoint(taskId)}?submitted=${submitted}`, { method: "GET" }); + if (!response.ok) throw await readError(response, "读取产物提交状态失败"); + const payload = await response.json() as { items?: RoundtableArtifactSubmissionRecord[] }; + return Array.isArray(payload.items) ? payload.items : []; +} + +export async function saveRoundtableArtifactSubmission( + taskId: string, + body: { + submitted: boolean; + registerOnly?: boolean; + submissionId?: string; + taskDeliveryId?: string; + expectedVersion?: number; + artifact: RoundtableArtifactReference; + }, +): Promise { + const response = await apiFetch(`${endpoint(taskId)}/submission`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskId, ...body }), + }); + if (!response.ok) throw await readError(response, "保存产物提交状态失败"); + return response.json() as Promise; +} diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 90903387..6ad55f75 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -32,6 +32,7 @@ import { type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; +import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton"; import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { @@ -46,6 +47,7 @@ import type { import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report"; import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary"; +import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission"; interface SeatDelivery { /** Display name of the seat (e.g. "情报收集"). */ @@ -93,6 +95,7 @@ interface HighFidelityReportProps { onReset: () => void; openArtifactPreview: (threadId: string, path: string) => void; seatStubMessages: Record; + taskArtifactSubmission?: TaskArtifactSubmissionController; } interface SeatArtifacts { @@ -139,6 +142,7 @@ export const HighFidelityReport: React.FC = ({ onReset, openArtifactPreview, seatStubMessages, + taskArtifactSubmission, }) => { // 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。 const consensusDocTitle = useMemo(() => { @@ -445,6 +449,7 @@ export const HighFidelityReport: React.FC = ({ savedSummary={savedSummary} summary={summaryFlow} onViewStatic={() => setSummaryMode(false)} + taskArtifactSubmission={taskArtifactSubmission} /> ); } @@ -467,6 +472,7 @@ export const HighFidelityReport: React.FC = ({ report={reportFlow} dashboard={dashboardFlow} onBack={() => setDrawMode(false)} + taskArtifactSubmission={taskArtifactSubmission} /> ); } @@ -917,6 +923,27 @@ export const HighFidelityReport: React.FC = ({
+ {seat.threadId && ( + + )} +
+ + {submitButton &&
{submitButton}
} +
); } diff --git a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx index 6c6bec18..7cc7a822 100644 --- a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx @@ -24,6 +24,8 @@ import { import { ThreadContext } from "@/components/workspace/messages/context"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; +import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton"; +import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission"; const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; @@ -48,6 +50,8 @@ interface Step2SandboxLayoutProps { * 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。 */ sidePanel?: React.ReactNode; + /** 任务深链页面的 Markdown 过程产物提交能力。 */ + taskArtifactSubmission?: TaskArtifactSubmissionController; } export function Step2SandboxLayout({ @@ -58,6 +62,7 @@ export function Step2SandboxLayout({ artifactsFill = false, onArtifactsClose, sidePanel, + taskArtifactSubmission, }: Step2SandboxLayoutProps) { const layoutRef = useRef(null); const { @@ -118,6 +123,16 @@ export function Step2SandboxLayout({ allowVirtualMarkdownEdit // 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。 disableDesensitize + headerLeadingAction={( + + )} /> ) : (
diff --git a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx index efd3f88e..6b309355 100644 --- a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx @@ -20,6 +20,7 @@ import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3 import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import { stripReasoning } from "@/roundtable-planning/lib/reasoning"; import { useMemo } from "react"; +import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission"; export interface Step3PanelProps { threadIds: ThreadIdMap | null; @@ -54,6 +55,7 @@ export interface Step3PanelProps { /** Step 2 沙箱预览产出文件(与第二步相同)。 */ openArtifactPreview: (threadId: string, path: string) => void; seatStubMessages: Record; + taskArtifactSubmission?: TaskArtifactSubmissionController; } export function Step3Panel(props: Step3PanelProps) { @@ -80,6 +82,7 @@ export function Step3Panel(props: Step3PanelProps) { onReset, openArtifactPreview, seatStubMessages, + taskArtifactSubmission, } = props; const seatDeliveries = useMemo( @@ -127,6 +130,7 @@ export function Step3Panel(props: Step3PanelProps) { onReset={onReset} openArtifactPreview={openArtifactPreview} seatStubMessages={seatStubMessages} + taskArtifactSubmission={taskArtifactSubmission} /> ); } diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index c4e33bca..dc8dc872 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -45,6 +45,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; +import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton"; import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; @@ -57,6 +58,7 @@ import type { ReportIntent, UseStep3ReportReturn, } from "@/roundtable-planning/hooks/useStep3Report"; +import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission"; interface SeatDelivery { sender: string; @@ -88,6 +90,7 @@ interface Step3ReportModeProps { /** 页面层常驻的大屏多页流(模式二)。 */ dashboard: UseStep3DashboardReturn; onBack: () => void; + taskArtifactSubmission?: TaskArtifactSubmissionController; } type SourceModal = "consensus" | "seats" | "files" | null; @@ -108,6 +111,7 @@ export function Step3ReportMode({ report, dashboard, onBack, + taskArtifactSubmission, }: Step3ReportModeProps) { const reportSeatDeliveries = useMemo( () => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })), @@ -376,6 +380,7 @@ export function Step3ReportMode({ activeThreadId={activeArtifactThreadId ?? reportThreadId} isStreaming={isStreaming} seatMessages={activeSeatMessages} + taskArtifactSubmission={taskArtifactSubmission} > @@ -428,6 +434,7 @@ export function Step3ReportMode({ threadIds={threadIds} coordinatorName={coordinatorName} seatNameMap={seatNameMap} + taskArtifactSubmission={taskArtifactSubmission} onClose={() => setModal(null)} /> )} @@ -513,6 +520,7 @@ function AgentProgressPanel({ availableModels, modelsLoading, setSelectedModel, + taskArtifactSubmission, }: { phase: UseStep3ReportReturn["phase"]; dialogues: UseStep3ReportReturn["dialogues"]; @@ -526,6 +534,7 @@ function AgentProgressPanel({ availableModels: ReadonlyArray; modelsLoading: boolean; setSelectedModel: (model: string) => void; + taskArtifactSubmission?: TaskArtifactSubmissionController; }) { const busy = phase === "creating" || phase === "streaming"; // 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送—— @@ -610,6 +619,7 @@ function AgentProgressPanel({ onFileCardClick={(path) => { if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); }} + taskArtifactSubmission={taskArtifactSubmission} /> ))} @@ -822,11 +832,13 @@ function SeatFilesModal({ threadIds, coordinatorName, seatNameMap, + taskArtifactSubmission, onClose, }: { threadIds: ThreadIdMap | null; coordinatorName: string | null; seatNameMap: Record; + taskArtifactSubmission?: TaskArtifactSubmissionController; onClose: () => void; }) { const seatEntries = useMemo(() => { @@ -932,6 +944,25 @@ function SeatFilesModal({
{formatBytes(f.sizeBytes)} +
{formatBytes(f.sizeBytes)} + + ); +} diff --git a/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts new file mode 100644 index 00000000..7dfbca9d --- /dev/null +++ b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { + listRoundtableArtifactSubmissions, + saveRoundtableArtifactSubmission, + type RoundtableArtifactReference, + type RoundtableArtifactSubmissionRecord, +} from "@/roundtable-planning/api/artifact-submissions"; +import { buildArtifactDownloadUrl, type ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; +import type { + TaskArtifactSubmissionConfig, + TaskArtifactSubmissionController, + TaskArtifactSubmissionState, + TaskArtifactTarget, + TaskProcessReportPayload, +} from "@/roundtable-planning/lib/task-artifact-submission"; + +type StatusEntry = { + state: TaskArtifactSubmissionState; + error: string | null; + record?: RoundtableArtifactSubmissionRecord; +}; + +function normalizedPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed.startsWith("write-file:")) return trimmed; + try { + return decodeURIComponent(new URL(trimmed).pathname).trim(); + } catch { + return trimmed; + } +} + +function fileName(path: string): string { + const normalized = normalizedPath(path); + return normalized.split("/").filter(Boolean).pop() || normalized || "产物.md"; +} + +function isMarkdownTarget(target: TaskArtifactTarget): boolean { + return /\.(md|markdown)$/i.test(normalizedPath(target.path)) + || target.mimeType?.toLowerCase().includes("markdown") === true; +} + +function recordKey(target: TaskArtifactTarget): string { + const source = target.agentId?.trim() || `thread:${target.threadId}`; + return `${source}\u001f${normalizedPath(target.path)}\u001f0`; +} + +function payloadFrom( + config: TaskArtifactSubmissionConfig, + target: TaskArtifactTarget, + content: string, + submitStatus: 0 | 1, +): TaskProcessReportPayload { + const name = target.name?.trim() || fileName(target.path); + const agentId = target.agentId?.trim() || target.threadId; + return { + taskId: config.taskId, + agentId, + agentName: target.agentName?.trim() || agentId, + content, + position: config.position, + reportName: name, + submitStatus, + }; +} + +/** + * Bridges the task system's process-report API and DeerFlow's task-scoped + * submission ledger. The ledger is read on mount, so "已提交" survives reloads + * and is shared by all users opening the same task. + */ +export function useTaskArtifactSubmission( + config: TaskArtifactSubmissionConfig | undefined, + threadIds: ThreadIdMap | null, + agentNameToRole: Record | undefined, +): TaskArtifactSubmissionController | undefined { + const [entries, setEntries] = useState>({}); + const entriesRef = useRef(entries); + entriesRef.current = entries; + const pendingRef = useRef(new Map>()); + + const resolveTarget = useCallback((target: TaskArtifactTarget): TaskArtifactTarget => { + const normalized = normalizedPath(target.path); + const matchedAgentId = target.agentId || Object.entries(threadIds ?? {}) + .find(([, threadId]) => threadId === target.threadId)?.[0]; + const role = matchedAgentId ? agentNameToRole?.[matchedAgentId.toLowerCase()] : undefined; + return { + ...target, + path: normalized, + name: target.name || fileName(normalized), + mimeType: target.mimeType || (isMarkdownTarget({ ...target, path: normalized }) ? "text/markdown" : null), + agentId: matchedAgentId || target.threadId, + agentName: target.agentName || role?.displayName || matchedAgentId || target.threadId, + }; + }, [agentNameToRole, threadIds]); + + useEffect(() => { + if (!config?.taskId) { + setEntries({}); + return; + } + let cancelled = false; + void Promise.all([ + listRoundtableArtifactSubmissions(config.taskId, true), + listRoundtableArtifactSubmissions(config.taskId, false), + ]).then(([submitted, registered]) => { + if (cancelled) return; + const next: Record = {}; + for (const record of [...submitted, ...registered]) { + const key = `${record.artifact.sourceKey}\u001f${normalizedPath(record.artifact.path)}\u001f${record.artifact.revision}`; + next[key] = { + state: record.submitted ? "submitted" : "idle", + error: null, + record, + }; + } + setEntries(next); + }).catch((error) => { + // State recovery is additive. A transient read failure must not prevent a + // user from submitting a Markdown artifact from the current session. + console.warn("[roundtable] 读取任务产物提交状态失败", error); + }); + return () => { cancelled = true; }; + }, [config?.taskId]); + + const submit = useCallback(async (rawTarget: TaskArtifactTarget) => { + if (!config || !isMarkdownTarget(rawTarget)) return null; + const target = resolveTarget(rawTarget); + const key = recordKey(target); + const existingPending = pendingRef.current.get(key); + if (existingPending) return existingPending; + const existing = entriesRef.current[key]; + if (existing?.state === "submitted" && existing.record) return existing.record; + + const operation = (async () => { + setEntries((current) => ({ ...current, [key]: { ...current[key], state: "submitting", error: null } })); + try { + let content = target.content; + if (content == null) { + const response = await apiFetch(buildArtifactDownloadUrl(target.threadId, target.path), { method: "GET" }); + if (!response.ok) throw new Error(`读取 Markdown 产物失败(${response.status})`); + content = await response.text(); + } + + let record = entriesRef.current[key]?.record; + let reportId = record?.taskDeliveryId || undefined; + if (!reportId) { + const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0)); + reportId = receipt.id; + const artifact: RoundtableArtifactReference = { + threadId: target.threadId, + sourceKey: target.agentId || `thread:${target.threadId}`, + path: target.path, + name: target.name || fileName(target.path), + mimeType: target.mimeType || "text/markdown", + revision: 0, + }; + record = await saveRoundtableArtifactSubmission(config.taskId, { + submitted: false, + registerOnly: true, + taskDeliveryId: reportId, + artifact, + }); + } + + await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1)); + const submitted = await saveRoundtableArtifactSubmission(config.taskId, { + submitted: true, + taskDeliveryId: reportId, + expectedVersion: record?.version, + artifact: { + threadId: target.threadId, + sourceKey: target.agentId || `thread:${target.threadId}`, + path: target.path, + name: target.name || fileName(target.path), + mimeType: target.mimeType || "text/markdown", + revision: 0, + }, + }); + setEntries((current) => ({ ...current, [key]: { state: "submitted", error: null, record: submitted } })); + return submitted; + } catch (error) { + const message = error instanceof Error ? error.message : "提交产物失败,请重试"; + setEntries((current) => ({ ...current, [key]: { ...current[key], state: "error", error: message } })); + throw error; + } finally { + pendingRef.current.delete(key); + } + })(); + pendingRef.current.set(key, operation); + return operation; + }, [config, resolveTarget]); + + return useMemo(() => { + if (!config) return undefined; + return { + enabled: true, + isMarkdown: isMarkdownTarget, + targetFor: resolveTarget, + stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle", + errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null, + submit, + } satisfies TaskArtifactSubmissionController; + }, [config, entries, resolveTarget, submit]); +} diff --git a/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts new file mode 100644 index 00000000..d9a1d5e1 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts @@ -0,0 +1,40 @@ +import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions"; + +export interface TaskProcessReportPayload { + taskId: string; + agentId: string; + agentName: string; + content: string; + position: number; + reportName: string; + submitStatus: 0 | 1; +} + +export interface TaskArtifactSubmissionConfig { + /** External task-system id, not the local roundtable draft storage key. */ + taskId: string; + position: number; + createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>; + updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise; +} + +export interface TaskArtifactTarget { + threadId: string; + path: string; + name?: string; + mimeType?: string | null; + agentId?: string; + agentName?: string; + content?: string; +} + +export type TaskArtifactSubmissionState = "idle" | "submitting" | "submitted" | "error"; + +export interface TaskArtifactSubmissionController { + enabled: boolean; + isMarkdown: (target: TaskArtifactTarget) => boolean; + targetFor: (target: TaskArtifactTarget) => TaskArtifactTarget; + stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState; + errorFor: (target: TaskArtifactTarget) => string | null; + submit: (target: TaskArtifactTarget) => Promise; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 15eb69ed..3b319763 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -152,6 +152,8 @@ import { listAgents } from "@/core/agents"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie"; import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables"; +import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission"; +import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission"; /** * 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏 @@ -324,6 +326,8 @@ export default function RoundtablePlanningPage({ newTaskLabel, /** 独立业务页可隐藏通用的设置入口。 */ hideHeaderSettings = false, + /** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */ + taskArtifactSubmissionConfig, }: { compactChrome?: boolean; lockedChainId?: string | null; @@ -351,6 +355,7 @@ export default function RoundtablePlanningPage({ hideHeaderSaveDraft?: boolean; newTaskLabel?: string; hideHeaderSettings?: boolean; + taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig; }) { const { theme } = useTheme(); // 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。 @@ -718,6 +723,12 @@ export default function RoundtablePlanningPage({ taskContext, }); + const taskArtifactSubmission = useTaskArtifactSubmission( + taskArtifactSubmissionConfig, + step2.threadIds, + step2.agentNameToRole, + ); + // 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。 useEffect(() => { step1StateRef.current = { @@ -2460,6 +2471,7 @@ export default function RoundtablePlanningPage({ onBackgroundSuspend={handleBackgroundSuspend} isViewingHistory={drafts.loadedFromDraftRef.current} onTriggerToast={triggerToast} + taskArtifactSubmission={taskArtifactSubmission} /> )} @@ -2489,6 +2501,7 @@ export default function RoundtablePlanningPage({ onReset={handleStartNewTask} openArtifactPreview={step2.openArtifactPreview} seatStubMessages={step2.seatStubMessages} + taskArtifactSubmission={taskArtifactSubmission} /> )} diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py index 3d7cdde9..b2bf48e9 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py @@ -52,6 +52,7 @@ class ArtifactSubmissionRequest(BaseModel): task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id")) submitted: bool + register_only: bool = Field(default=False, validation_alias=AliasChoices("registerOnly", "register_only")) submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id")) task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id")) expected_version: int | None = Field(default=None, ge=0, validation_alias=AliasChoices("expectedVersion", "expected_version")) @@ -200,6 +201,27 @@ async def set_artifact_submission( "submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False), }, ) from conflict + elif body.register_only: + if not artifact["thread_id"]: + raise HTTPException(status_code=422, detail="artifact.threadId is required when registering an artifact") + try: + row = await store.register( + path_task_id, + artifact, + task_delivery_id=body.task_delivery_id, + actor_id=actor_id, + expected_version=body.expected_version, + ) + except SubmissionVersionConflict as conflict: + raise HTTPException( + status_code=409, + detail={ + "code": "version_conflict", + "message": "登记产物版本冲突:另一写入已更新,请刷新后重试。", + "current_version": conflict.row.get("version", 0), + "submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False), + }, + ) from conflict else: try: row = await store.cancel( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py index b3d01ff7..83dcea9e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py @@ -37,6 +37,23 @@ class RoundtableArtifactSubmissionStore(ABC): """ raise NotImplementedError + @abstractmethod + async def register( + self, + task_id: str, + artifact: dict[str, Any], + *, + task_delivery_id: str | None = None, + actor_id: str | None = None, + expected_version: int | None = None, + ) -> dict[str, Any]: + """Persist a generated-but-not-yet-submitted artifact receipt. + + This lets the UI retain the external task-process-report id across a + refresh before the user explicitly submits the Markdown artifact. + """ + raise NotImplementedError + @abstractmethod async def cancel( self, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py index ea7bda10..2fc41c7b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py @@ -109,6 +109,63 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore) ) return deepcopy(row) + async def register( + self, + task_id: str, + artifact: dict[str, Any], + *, + task_delivery_id: str | None = None, + actor_id: str | None = None, + expected_version: int | None = None, + ) -> dict[str, Any]: + """Save an external receipt while keeping the artifact unsubmitted.""" + from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict + + key = submission_key(task_id, artifact) + row = self._rows.get((task_id, key)) + values = _artifact_values(artifact) + if row is None: + now = _now() + row = { + "id": uuid4().hex, + "task_id": task_id, + "idempotency_key": key, + **values, + "task_delivery_id": task_delivery_id, + "submitted": False, + "version": 0, + "submitted_at": None, + "cancelled_at": None, + "created_by": actor_id, + "updated_by": actor_id, + "submitted_by": None, + "cancelled_by": None, + "created_at": now, + "updated_at": now, + } + self._rows[(task_id, key)] = row + return deepcopy(row) + + if expected_version is not None and row.get("version", 0) != expected_version: + raise SubmissionVersionConflict(deepcopy(row)) + + changed = ( + row.get("task_delivery_id") != task_delivery_id + or any(row.get(name) != value for name, value in values.items()) + ) + if changed: + now = _now() + row.update(values) + row.update( + { + "task_delivery_id": task_delivery_id, + "updated_by": actor_id, + "updated_at": now, + "version": row.get("version", 0) + 1, + } + ) + return deepcopy(row) + async def cancel( self, task_id: str, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py index b4e33e44..5c8e9073 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py @@ -88,6 +88,22 @@ def _requires_submission_update( ) +def _requires_registration_update( + row: RoundtableArtifactSubmissionRow, + artifact: dict[str, Any], + task_delivery_id: str | None, +) -> bool: + """Compare receipt metadata without treating ``submitted=False`` as a change.""" + return ( + row.task_delivery_id != task_delivery_id + or row.session_id != artifact.get("session_id") + or row.thread_id != artifact.get("thread_id") + or row.node_key != artifact.get("node_key") + or row.artifact_name != artifact.get("name") + or row.mime_type != artifact.get("mime_type") + ) + + class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore): def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory @@ -181,6 +197,70 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore): raise RuntimeError("submit: retry loop exhausted (unexpected)") + async def register( + self, + task_id: str, + artifact: dict[str, Any], + *, + task_delivery_id: str | None = None, + actor_id: str | None = None, + expected_version: int | None = None, + ) -> dict[str, Any]: + """Persist an external process-report receipt before user submission.""" + key = submission_key(task_id, artifact) + for _attempt in range(2): + async with self._sf() as session: + result = await session.execute( + select(RoundtableArtifactSubmissionRow) + .where( + RoundtableArtifactSubmissionRow.task_id == task_id, + RoundtableArtifactSubmissionRow.idempotency_key == key, + ) + .with_for_update() + ) + row = result.scalars().first() + now = datetime.now(UTC) + + if row is None: + row = RoundtableArtifactSubmissionRow( + id=uuid4().hex, + task_id=task_id, + idempotency_key=key, + task_delivery_id=task_delivery_id, + submitted=False, + created_by=actor_id, + updated_by=actor_id, + created_at=now, + updated_at=now, + version=0, + ) + _apply_artifact(row, artifact) + session.add(row) + try: + await session.commit() + except IntegrityError: + await session.rollback() + continue + await session.refresh(row) + return _to_dict(row) + + if expected_version is not None and row.version != expected_version: + raise SubmissionVersionConflict(_to_dict(row)) + + if not _requires_registration_update(row, artifact, task_delivery_id): + return _to_dict(row) + + _apply_artifact(row, artifact) + row.task_delivery_id = task_delivery_id + row.updated_by = actor_id + row.updated_at = now + row.version = row.version + 1 + await session.commit() + await session.refresh(row) + return _to_dict(row) + + raise RuntimeError("register: retry loop exhausted (unexpected)") + async def cancel( self, task_id: str, From 30799f17be88d2b92a1fb05ba57ce22ce7336519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 18 Aug 2026 23:23:46 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E8=8A=82=E6=B5=81=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=86=99=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20Maximum=20update=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_file 的 content 在每个 SSE chunk 中持续增长,原先会反复触发 React 状态更新并打断整页;现改为按身份入参去重、节流发布 stub,并收口第 3 步为 Markdown 报告工作台。 Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 14 +- .../workspace/artifacts/context.tsx | 80 +-- .../src/eightbf-roundtable/styles/step2.css | 16 + .../src/eightbf-roundtable/styles/step3.css | 20 +- .../components/PositionIntentInput.tsx | 91 ++-- .../roundtable-planning/api/multi-agent.ts | 45 +- .../components/HighFidelityReport.tsx | 6 +- .../components/Step2Panel.tsx | 65 +-- .../components/Step2SandboxLayout.tsx | 43 +- .../components/Step3SummaryMode.tsx | 508 +++--------------- .../components/TaskArtifactSubmitButton.tsx | 3 +- .../hooks/useStep2Orchestration.ts | 240 +++++++-- .../hooks/useStep3Summary.ts | 195 ++++--- .../pages/RoundtablePlanningPage.tsx | 21 +- 14 files changed, 644 insertions(+), 703 deletions(-) diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index fda1e389..bed8565b 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -155,6 +155,7 @@ export function ArtifactFileDetail({ onDownloadClick, toolbarActions, headerLeadingAction, + headerLeadingActionBare = false, researchSessionId, }: { className?: string; @@ -192,6 +193,11 @@ export function ArtifactFileDetail({ toolbarActions?: ReactNode; /** Replaces the left file-type badge for an embedded workspace action. */ headerLeadingAction?: ReactNode; + /** + * 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。 + * 默认保留原有徽标容器,避免影响普通聊天和产物预览。 + */ + headerLeadingActionBare?: boolean; }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); @@ -1205,7 +1211,13 @@ export function ArtifactFileDetail({
-
+
{headerLeadingAction ?? ( {fileTypeLabel} diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx index e1819e2d..75163228 100644 --- a/frontend-web/src/components/workspace/artifacts/context.tsx +++ b/frontend-web/src/components/workspace/artifacts/context.tsx @@ -52,11 +52,17 @@ interface ArtifactsProviderProps { children: ReactNode; } -export function ArtifactsProvider({ children }: ArtifactsProviderProps) { - const [artifacts, setArtifactsState] = useState([]); - const setArtifacts = useCallback((next: string[]) => { - setArtifactsState(Array.isArray(next) ? next : []); - }, []); +export function ArtifactsProvider({ children }: ArtifactsProviderProps) { + const [artifacts, setArtifactsState] = useState([]); + const setArtifacts = useCallback((next: string[]) => { + const normalized = Array.isArray(next) ? next : []; + setArtifactsState((previous) => + previous.length === normalized.length && + previous.every((artifact, index) => artifact === normalized[index]) + ? previous + : normalized, + ); + }, []); const [selectedArtifact, setSelectedArtifact] = useState(null); const [selectedArtifactKey, setSelectedArtifactKey] = useState(null); const [autoSelect, setAutoSelect] = useState(true); @@ -65,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { ); const [autoOpen, setAutoOpen] = useState(true); const suppressedAutoOpenKeyRef = useRef(null); + // 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次 + // Context 状态变化都重建 setter 并触发依赖它的布局 effect。 + const selectedArtifactKeyRef = useRef(selectedArtifactKey); + const autoOpenRef = useRef(autoOpen); const sidebar = useSidebarSafe(); - const select = useCallback( + const select = useCallback( (artifact: string, autoSelect = false) => { - setSelectedArtifact(artifact); - setSelectedArtifactKey(artifactPreviewKey(artifact)); + const previewKey = artifactPreviewKey(artifact); + selectedArtifactKeyRef.current = previewKey; + setSelectedArtifact((previous) => (previous === artifact ? previous : artifact)); + setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { sidebar?.setOpen(false); } - if (!autoSelect) { - setAutoSelect(false); - } + if (!autoSelect) { + setAutoSelect((previous) => (previous ? false : previous)); + } }, [sidebar], ); @@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { if (suppressedAutoOpenKeyRef.current === previewKey) { return false; } - setSelectedArtifact(artifact); - setSelectedArtifactKey(previewKey); - setAutoSelect(true); - setAutoOpen(true); - setOpenState(true); + selectedArtifactKeyRef.current = previewKey; + autoOpenRef.current = true; + setSelectedArtifact((previous) => (previous === artifact ? previous : artifact)); + setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); + setAutoSelect((previous) => (previous ? previous : true)); + setAutoOpen((previous) => (previous ? previous : true)); + setOpenState((previous) => (previous ? previous : true)); if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { sidebar?.setOpen(false); } @@ -99,13 +113,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { [sidebar], ); - const deselect = useCallback(() => { - setSelectedArtifact(null); - setSelectedArtifactKey(null); - setAutoSelect(true); - setOpenState(false); + const deselect = useCallback(() => { + selectedArtifactKeyRef.current = null; + setSelectedArtifact((previous) => (previous === null ? previous : null)); + setSelectedArtifactKey((previous) => (previous === null ? previous : null)); + setAutoSelect((previous) => (previous ? previous : true)); + setOpenState((previous) => (previous ? false : previous)); suppressedAutoOpenKeyRef.current = null; }, []); + + const setOpen = useCallback((isOpen: boolean) => { + if (!isOpen && autoOpenRef.current) { + autoOpenRef.current = false; + setAutoOpen((previous) => (previous ? false : previous)); + setAutoSelect((previous) => (previous ? false : previous)); + } + if (!isOpen) { + suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current; + } + setOpenState((previous) => (previous === isOpen ? previous : isOpen)); + }, []); const value: ArtifactsContextType = { artifacts, @@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { open, autoOpen, autoSelect, - setOpen: (isOpen: boolean) => { - if (!isOpen && autoOpen) { - setAutoOpen(false); - setAutoSelect(false); - } - if (!isOpen) { - suppressedAutoOpenKeyRef.current = selectedArtifactKey; - } - setOpenState(isOpen); - }, + setOpen, selectedArtifact, selectedArtifactKey, diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css index a9481942..b8dd3849 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step2.css +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -65,6 +65,22 @@ padding: 0 12px; } +/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩 + 约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] { + width: 80px; + padding: 0 12px; +} + +/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl, +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl, +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row, +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row, +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card { + border-radius: 4px !important; +} + /* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其 #enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */ .dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input { diff --git a/frontend-web/src/eightbf-roundtable/styles/step3.css b/frontend-web/src/eightbf-roundtable/styles/step3.css index 078651d7..35936ad7 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step3.css +++ b/frontend-web/src/eightbf-roundtable/styles/step3.css @@ -76,4 +76,22 @@ background: #21958a; } -/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ +/* 第三步只保留「总结报告 + 沙箱」。沿用第一、二步的 4px 面板语言。 */ +.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-source-panel { + border: 1px solid rgb(43 143 254 / 50%); + border-radius: 4px; + background: #032650; + padding: 12px; +} + +.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-report-panel { + border-color: rgb(43 143 254 / 50%); + border-radius: 4px !important; + background: #032650; + box-shadow: none; +} + +/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */ +.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input { + flex: 0 0 auto; +} diff --git a/frontend-web/src/position-roundtable/components/PositionIntentInput.tsx b/frontend-web/src/position-roundtable/components/PositionIntentInput.tsx index 2b5bdb7f..57601321 100644 --- a/frontend-web/src/position-roundtable/components/PositionIntentInput.tsx +++ b/frontend-web/src/position-roundtable/components/PositionIntentInput.tsx @@ -8,7 +8,6 @@ import { Sparkles, X, } from "lucide-react"; -import { Select as TSelect } from "tdesign-react"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents"; @@ -27,6 +26,8 @@ interface PositionIntentInputProps extends Step1InputAreaProps { selectedModel?: string; onSelectedModelChange?: (model: string) => void; commonQuestions?: (string | AgentQuestion)[] | null; + /** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */ + hideUtilityActions?: boolean; } /** Keep attachment metadata presentation local to this position-only composer. */ @@ -67,6 +68,7 @@ export function PositionIntentInput({ selectedModel, onSelectedModelChange, commonQuestions, + hideUtilityActions = false, }: PositionIntentInputProps) { // Files are owned by Step1Panel. This ref only opens the native picker and // forwards its selection through onAddFiles, so replacement layouts do not @@ -76,6 +78,12 @@ export function PositionIntentInput({ const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions); const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2); const extraCommonQuestions = normalizedCommonQuestions.slice(2); + // 这里的模型列表会随着会商流的状态频繁刷新。TDesign Select 会在 options 的引用 + // 每次变化时执行一次内部同步;流式收尾阶段恰好会形成 effect 更新循环并打断整页。 + // 原生 select 是受控且无内部同步状态,三个步骤共用这个输入框后也能保持稳定。 + const modelValue = modelOptions.some((option) => option.value === selectedModel) + ? selectedModel + : (modelOptions[0]?.value ?? ""); const pickCommonQuestion = (question: AgentQuestion) => { onValueChange(question.prompt); @@ -242,16 +250,19 @@ export function PositionIntentInput({ title="鍒囨崲鎰忓浘鐞嗚В妯″瀷" > - onSelectedModelChange?.(String(value))} - className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none" - popupProps={{ overlayClassName: "z-[80]" }} - /> + onChange={(event) => onSelectedModelChange?.(event.target.value)} + className="h-7 w-[150px] shrink-0 cursor-pointer appearance-none bg-transparent px-1 pr-5 text-xs font-bold text-[#dceaff] outline-none disabled:cursor-not-allowed disabled:opacity-60" + > + {modelOptions.map((option) => ( + + ))} +
)} - + {!hideUtilityActions && ( + <> + - + + + )} {footerAction ?? (!hideEnterRoundtable && ( @@ -309,15 +324,17 @@ export function PositionIntentInput({ {enterButtonLabel} ))} - + {!hideUtilityActions && ( + + )}
diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index e0c921ef..aef7f6e5 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -584,18 +584,55 @@ export async function streamMultiAgent( // Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the // value actually changes (the reader loop sees many frames per phase). + // + // `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个 + // tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame + // 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态 + // 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。 + const phaseArgsSignature = (args?: Record) => { + if (!args) return ""; + const streamedPayloadKeys = new Set([ + "content", + "old_str", + "new_str", + "patch", + "diff", + ]); + try { + return JSON.stringify( + Object.fromEntries( + Object.entries(args) + .filter(([key]) => !streamedPayloadKeys.has(key)) + .sort(([left], [right]) => left.localeCompare(right)), + ), + ); + } catch { + return "[unserializable]"; + } + }; let currentPhase: StreamPhase = "connecting"; + let currentPhaseName = ""; + let currentPhaseArgsSignature = ""; req.onPhaseChange?.("connecting"); const setPhase = ( next: StreamPhase, name?: string, args?: Record, ) => { - // 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层; - // 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层 - // appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。 - if (next === currentPhase && !name) return; + const nextName = name ?? ""; + const nextArgsSignature = phaseArgsSignature(args); + // 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths + // 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。 + if ( + next === currentPhase && + nextName === currentPhaseName && + nextArgsSignature === currentPhaseArgsSignature + ) { + return; + } currentPhase = next; + currentPhaseName = nextName; + currentPhaseArgsSignature = nextArgsSignature; req.onPhaseChange?.(next, name, args); }; diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 6ad55f75..79d9fc76 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -152,8 +152,7 @@ export const HighFidelityReport: React.FC = ({ return `圆桌总控结论-${short}`; }, [intentReady?.objective]); - // 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。 - // 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。 + // 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。 const [summaryMode, setSummaryMode] = useState(true); const [drawMode, setDrawMode] = useState(false); // 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起 @@ -443,12 +442,10 @@ export const HighFidelityReport: React.FC = ({ intentReady={intentReady} selectedModel={selectedModel} availableModels={availableModels} - modelsLoading={modelsLoading} setSelectedModel={setSelectedModel} coordinatorPrompt={coordinatorPrompt} savedSummary={savedSummary} summary={summaryFlow} - onViewStatic={() => setSummaryMode(false)} taskArtifactSubmission={taskArtifactSubmission} /> ); @@ -1038,6 +1035,7 @@ export const HighFidelityReport: React.FC = ({ threadId={fileSandboxTarget.threadId} onClose={handleCloseFileSandbox} allowVirtualMarkdownEdit + headerLeadingActionBare headerLeadingAction={( >; // ─── Handlers ───────────────────────────────────────────────── onPauseRoundtable: () => void; - onResumeRoundtable: () => void; onHandleInjectToRoundtable: (files?: File[]) => void; onSubmitClarification: (answer: string) => void; onSavePersona: (id: number) => void; @@ -171,7 +167,6 @@ export function Step2Panel(props: Step2PanelProps) { useStep1InputAppearance = false, step2RoundtableDialogues, isInitializing, - isRoundTableRunning, isOrchestrating, pendingClarification, clarificationDraft, @@ -203,7 +198,6 @@ export function Step2Panel(props: Step2PanelProps) { setAgentConfigs, setSelectedAgentId, onPauseRoundtable, - onResumeRoundtable, onHandleInjectToRoundtable, onSubmitClarification, onSavePersona, @@ -381,9 +375,6 @@ export function Step2Panel(props: Step2PanelProps) { prevArtifactsOpenRef.current = artifactsOpenForFlow; }, [artifactsOpenForFlow]); - // 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动 - const [pauseFlash, setPauseFlash] = useState(false); - // 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。 const [injectFiles, setInjectFiles] = useState([]); @@ -406,13 +397,14 @@ export function Step2Panel(props: Step2PanelProps) { const handleInject = useCallback(() => { onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined); setInjectFiles([]); - setPauseFlash(true); - window.setTimeout(() => setPauseFlash(false), 1600); }, [onHandleInjectToRoundtable, injectFiles]); const isSandboxStreaming = isOrchestrating || step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); + // 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。 + // 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。 + const isStep2Responding = isInitializing || isSandboxStreaming; // 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。 const headerDzRef = useDesensitizeSubtree(); @@ -456,30 +448,7 @@ export function Step2Panel(props: Step2PanelProps) { ))} - {!isRoundTableRunning ? ( - - ) : ( - - )} - {/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory), + {/* 后台挂起:查看历史记录时隐藏(isViewingHistory), 重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */} {SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
- {!step2AtBottom && } + {!step2AtBottom && ( + + )}
{/* Clarification card */} @@ -748,11 +722,11 @@ export function Step2Panel(props: Step2PanelProps) { onAddFiles={addInjectFiles} onRemoveFile={removeInjectFile} charactersLeft={Math.max(0, 500 - customAgentInput.length)} - isSending={false} - canSend + isSending={isStep2Responding} + canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0} pendingClarification={false} onSend={handleInject} - onStop={() => undefined} + onStop={onPauseRoundtable} onFollowUp={() => undefined} onSkip={() => undefined} onEnterRoundtable={() => undefined} @@ -1199,14 +1173,13 @@ function PresentFileCard({ {filename} -
- - {typeLabel} - - 点击预览 -
- {submitButton &&
{submitButton}
} +
+ + {typeLabel} + + {submitButton} +
); } diff --git a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx index 7cc7a822..509700f8 100644 --- a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx @@ -29,6 +29,42 @@ import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; + +/** 从 write_file 虚拟地址反查流式工具调用中的正文,供任务产物提交直接使用。 */ +function virtualMarkdownContent( + artifact: string | null, + messages: unknown[] | undefined, +): string | undefined { + if (!artifact?.startsWith("write-file:") || !messages?.length) return undefined; + try { + const url = new URL(artifact); + const toolCallId = url.searchParams.get("tool_call_id"); + const path = decodeURIComponent(url.pathname).trim(); + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const toolCalls = (message as { tool_calls?: unknown }).tool_calls; + if (!Array.isArray(toolCalls)) continue; + for (const call of toolCalls) { + if (!call || typeof call !== "object") continue; + const toolCall = call as { + id?: unknown; + name?: unknown; + args?: { path?: unknown; content?: unknown }; + }; + if (toolCall.name !== "write_file") continue; + if (toolCallId && toolCall.id !== toolCallId) continue; + if (!toolCallId && toolCall.args?.path !== path) continue; + return typeof toolCall.args?.content === "string" + ? toolCall.args.content + : undefined; + } + } + } catch { + // 虚拟路径解析失败时交给提交层走它原有的真实文件读取兜底。 + } + return undefined; +} + interface Step2SandboxLayoutProps { children?: React.ReactNode; /** 当前预览文件所属的席位 thread id。 */ @@ -73,6 +109,10 @@ export function Step2SandboxLayout({ deselect, } = useArtifacts(); const artifacts = rawArtifacts ?? []; + const selectedArtifactContent = useMemo( + () => virtualMarkdownContent(selectedArtifact, seatMessages), + [selectedArtifact, seatMessages], + ); const artifactPanelOpen = artifactsOpen; // sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。 @@ -120,9 +160,9 @@ export function Step2SandboxLayout({ filepath={selectedArtifact} threadId={previewThreadId} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} - allowVirtualMarkdownEdit // 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。 disableDesensitize + headerLeadingActionBare headerLeadingAction={( )} diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index d6627c47..0c04a4a5 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -9,33 +9,27 @@ * 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、 * 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。 */ -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { - ArrowRight, CheckCircle, - Database, Download, Eye, FileText, Loader2, PanelLeftClose, PanelLeftOpen, - Paperclip, - Pause, RefreshCw, - SendHorizontal, Sparkles, - Workflow, X, } from "lucide-react"; -import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { useArtifacts } from "@/components/workspace/artifacts"; import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; +import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; import { buildArtifactDownloadUrl, listSessionArtifacts, @@ -44,12 +38,6 @@ import { } from "@/roundtable-planning/api/multi-agent"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; -import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel"; -import { - detectFlowIntent, - extractFlowGraph, - FLOW_FENCE_OPEN, -} from "@/roundtable-planning/lib/flow-graph"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; @@ -80,7 +68,6 @@ interface Step3SummaryModeProps { intentReady: ReportIntent | null; selectedModel: string; availableModels: ReadonlyArray; - modelsLoading: boolean; setSelectedModel: (model: string) => void; /** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */ coordinatorPrompt?: string | null; @@ -88,8 +75,6 @@ interface Step3SummaryModeProps { savedSummary?: DraftStep3SummarySnapshot | null; /** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */ summary: UseStep3SummaryReturn; - /** 切到「最终方案展示」静态页(次要入口)。 */ - onViewStatic: () => void; taskArtifactSubmission?: TaskArtifactSubmissionController; } @@ -105,12 +90,10 @@ export function Step3SummaryMode({ intentReady, selectedModel, availableModels, - modelsLoading, setSelectedModel, coordinatorPrompt, savedSummary, summary, - onViewStatic, taskArtifactSubmission, }: Step3SummaryModeProps) { void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容 @@ -144,42 +127,7 @@ export function Step3SummaryMode({ const { open: artifactsOpen } = useArtifacts(); - // ── 结构化流程图(Phase 1:后端「抽取+校验」)──────────────────────────── - // 数据来源优先级: - // · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制; - // · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制; - // · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。 - const flowGraph = useMemo(() => { - if (summary.flowPhase === "done" && summary.flowGraph) { - return { graph: summary.flowGraph, streaming: false }; - } - if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") { - return null; - } - for (let i = dialogues.length - 1; i >= 0; i--) { - const d = dialogues[i]; - if (d.avatarType === "human") continue; - const content = d.content ?? ""; - if (!content.includes(FLOW_FENCE_OPEN)) continue; - const graph = extractFlowGraph(content); - if (graph) return { graph, streaming: d.streaming === true }; - } - return null; - }, [dialogues, summary.flowPhase, summary.flowGraph]); - - const [flowView, setFlowView] = useState(false); - const flowAutoOpenedRef = useRef(false); - useEffect(() => { - if (!flowGraph || flowGraph.graph.nodes.length === 0) return; - if (flowAutoOpenedRef.current) return; - flowAutoOpenedRef.current = true; - // taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布 - // (即便此前 md 沙箱已自动打开,也切到流程图)。 - // 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。 - if (summary.autoFlow || !artifactsOpen) setFlowView(true); - }, [flowGraph, artifactsOpen, summary.autoFlow]); - - // 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。 + // 沙箱里已写出的 Markdown 总结报告。 const mdArtifact = useMemo(() => { for (const [threadId, msgs] of Object.entries(seatStubMessages)) { const msg = msgs?.[0]; @@ -200,25 +148,16 @@ export function Step3SummaryMode({ return null; }, [seatStubMessages]); - // 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着, - // 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。 - const handleAsk = (message: string, files?: File[]) => { - if (detectFlowIntent(message)) { - flowAutoOpenedRef.current = true; - setFlowView(true); - } - ask(message, files); - }; + const handleAsk = (message: string, files?: File[]) => ask(message, files); - // 流程图抽取/校验的过程(思考 `` + 文字说明 + flow-json 代码块)全部原样交给 - // Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染 - // (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。 + // 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染, + // 保持与第二步一致的流式过程可见性。 const displayDialogues = dialogues; const [leftCollapsed, setLeftCollapsed] = useState(false); - // 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 + // 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 // 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true), // 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。 - const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView); + const rightPanelVisible = artifactsOpen; const prevRightPanelVisibleRef = useRef(false); useEffect(() => { if (rightPanelVisible !== prevRightPanelVisibleRef.current) { @@ -292,23 +231,16 @@ export function Step3SummaryMode({ 重新生成 - {/* 主体:左源卡 + 右沙箱 */}
- {/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位 - (sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */} -
+ {/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */} +
setFlowView(false)} - onSwitchToReport={ - mdArtifact - ? () => { - setFlowView(false); - openArtifactPreview(mdArtifact.threadId, mdArtifact.path); - } - : undefined - } - onChange={summary.setFlowGraph} - /> - ) : undefined - } > - {/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */} - {flowGraph && !flowView && ( - - )} -
@@ -527,7 +410,7 @@ function SourceCard({ type="button" onClick={onClick} disabled={disabled} - className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60" + className="text-left bg-white border border-slate-200/60 rounded-[4px] p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60" >
{icon} @@ -560,7 +443,7 @@ function CollapsedSourceCard({ title={title} disabled={disabled} onClick={onClick} - className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed" + className="w-full shrink-0 flex items-center justify-center rounded-[4px] border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed" > {icon} @@ -575,18 +458,6 @@ function AgentProgressPanel({ error, busy, mdAvailable, - flowPhase, - onRegenerateFlow, - pendingClarification, - onResolveClarification, - ingestAvailable, - ingestStatus, - pendingIngestClarification, - onResolveIngestChoice, - onResolveIngestClarification, - pendingFlowForIngest, - onResolveFlowForIngest, - onManualIngest, onRetry, onAsk, onPause, @@ -594,7 +465,6 @@ function AgentProgressPanel({ onArtifactPreview, selectedModel, availableModels, - modelsLoading, setSelectedModel, taskArtifactSubmission, }: { @@ -602,25 +472,8 @@ function AgentProgressPanel({ dialogues: UseStep3SummaryReturn["dialogues"]; error: string | null; busy: boolean; - /** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */ + /** 沙箱里是否已有 Markdown 总结报告。 */ mdAvailable: boolean; - /** 后端流程图抽取阶段(Phase 1)。 */ - flowPhase: UseStep3SummaryReturn["flowPhase"]; - /** 仅重新生成流程图(模型挂了切模型后重试)。 */ - onRegenerateFlow: () => void; - /** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */ - pendingClarification: string | null; - onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"]; - /** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */ - ingestAvailable: boolean; - ingestStatus: UseStep3SummaryReturn["ingestStatus"]; - pendingIngestClarification: string | null; - onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"]; - onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"]; - /** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */ - pendingFlowForIngest: boolean; - onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"]; - onManualIngest: UseStep3SummaryReturn["runIngest"]; onRetry: () => void; onAsk: (message: string, files?: File[]) => void; onPause: () => void; @@ -628,14 +481,13 @@ function AgentProgressPanel({ onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"]; selectedModel: string; availableModels: ReadonlyArray; - modelsLoading: boolean; setSelectedModel: (model: string) => void; taskArtifactSubmission?: TaskArtifactSubmissionController; }) { const scrollRef = useRef(null); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); - // 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底, - // 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发, + // 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底, + // 让用户看到新阶段。仅在气泡**条数**增加时触发, // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 const prevDialogueCountRef = useRef(dialogues.length); useEffect(() => { @@ -651,22 +503,18 @@ function AgentProgressPanel({ const [askText, setAskText] = useState(""); // 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。 const [askFiles, setAskFiles] = useState([]); - const fileInputRef = useRef(null); - const pickFiles = (e: React.ChangeEvent) => { - const picked = Array.from(e.target.files ?? []); + const addAskFiles = (picked: File[]) => { if (picked.length > 0) { setAskFiles((prev) => { const seen = new Set(prev.map((f) => `${f.name}:${f.size}`)); return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))]; }); } - e.target.value = ""; }; const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx)); - const handleAskSubmit = (e: FormEvent) => { - e.preventDefault(); + const submitAsk = () => { const text = askText.trim(); const hasFiles = askFiles.length > 0; if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发 @@ -677,7 +525,7 @@ function AgentProgressPanel({ return (
-
+
报 @@ -687,38 +535,11 @@ function AgentProgressPanel({
据各席位成果撰写 Markdown 总结报告
-
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */} -
-
+
+
{phase === "creating" && dialogues.length === 0 && (
@@ -742,9 +563,7 @@ function AgentProgressPanel({ {busy && (
- {mdAvailable - ? "报告正文实时输出,右侧沙箱同步预览 Markdown" - : "报告正文实时输出;输出结构化数据时流程图画布同步绘制"} + {mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
)} @@ -763,243 +582,54 @@ function AgentProgressPanel({
)} - {/* 后端流程图「抽取+校验」状态条(Phase 1)。 */} - {(flowPhase === "extracting" || flowPhase === "validating") && ( -
- - {flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"} -
- )} - {flowPhase === "error" && ( -
-
流程图生成失败
-
模型可能不稳定。可在上方切换模型后重试。
- -
- )} - {phase === "done" && (
{mdAvailable - ? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。" - : "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"} + ? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。" + : "已生成方案总结,可在下方继续修改报告或提问。"}
)}
- {!atBottom && } - - {/* 意图模糊 → 让用户自己定(不自作主张) */} - {pendingClarification && ( -
-
- 没太理解你的意思,你是想—— -
-
- {( - [ - { k: "regenerate_report", t: "重新生成报告" }, - { k: "modify_flow", t: "修改流程图" }, - { k: "regenerate_flow", t: "重新生成流程图" }, - { k: "qa", t: "只是提问" }, - ] as const - ).map((o) => ( - - ))} -
-
+ {!atBottom && ( + )} +
- {/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */} - {ingestAvailable && ingestStatus === "asking" && ( -
-
- 流程图已生成,是否进行数据入库? -
-
- {( - [ - { k: "now", t: "现在入库" }, - { k: "later", t: "稍后入库" }, - { k: "never", t: "不入库" }, - ] as const - ).map((o) => ( - - ))} -
-
- )} - - {/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */} - {ingestAvailable && pendingFlowForIngest && ( -
-
- 当前还没有生成流程图,数据入库需要流程图。是否先生成流程图? -
-
- {( - [ - { k: "generate", t: "生成流程图" }, - { k: "cancel", t: "暂不入库" }, - ] as const - ).map((o) => ( - - ))} -
-
- )} - - {/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */} - {ingestAvailable && pendingIngestClarification && ( -
-
- 没太理解你的入库意思,你是想—— -
-
- {( - [ - { k: "start_ingest", t: "开始入库" }, - { k: "add_data", t: "补充内容后再入库" }, - { k: "other", t: "只是提问 / 改报告" }, - ] as const - ).map((o) => ( - - ))} -
-
- )} - -
- {/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */} - {askFiles.length > 0 && ( -
- {askFiles.map((file, idx) => ( - - - - {file.name} - - {formatBytes(file.size)} - - - ))} -
- )} -
- setAskText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - e.currentTarget.form?.requestSubmit(); - } - }} - rows={1} - placeholder={ - busy - ? "生成中也可随时插话干预:发送将打断当前轮并据此继续…" - : "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问" - } - className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none" - /> - - {/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */} - {ingestAvailable && - ingestStatus !== "asking" && - ingestStatus !== "running" && - ingestStatus !== "idle" && ( - - )} - - {/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */} - - - - {/* 暂停(仅生成中显示) */} - {busy && ( - - )} - - {/* 发送(随时可发:生成中发送=打断并续问) */} - -
-
+
+ 0 || askFiles.length > 0} + pendingClarification={false} + onSend={submitAsk} + onStop={onPause} + onFollowUp={() => undefined} + onSkip={() => undefined} + hideUtilityActions + onEnterRoundtable={() => undefined} + enterButtonLabel="" + intentReady={null} + hideEnterRoundtable + onManualSave={() => undefined} + onTriggerToast={() => undefined} + modelOptions={availableModels.map((model) => ({ + label: model.display_name && model.display_name !== model.name + ? `${model.display_name} (${model.name})` + : model.name, + value: model.name, + }))} + selectedModel={selectedModel} + onSelectedModelChange={setSelectedModel} + />
); diff --git a/frontend-web/src/roundtable-planning/components/TaskArtifactSubmitButton.tsx b/frontend-web/src/roundtable-planning/components/TaskArtifactSubmitButton.tsx index 5ee2e137..aba1ad69 100644 --- a/frontend-web/src/roundtable-planning/components/TaskArtifactSubmitButton.tsx +++ b/frontend-web/src/roundtable-planning/components/TaskArtifactSubmitButton.tsx @@ -39,7 +39,8 @@ export function TaskArtifactSubmitButton({ : error ? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300" : "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15", - compact && "h-7 px-2", + // 沙箱头部的其他分段切换器外框为 40px 高,提交入口与之对齐。 + compact && "h-10 px-3 text-xs", className, )} > diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index ff6ab7df..292f65f9 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -507,6 +507,14 @@ export function useStep2Orchestration( Record >({}); const seatStubMessagesRef = useRef>({}); + // write_file 的 tool args 会在一个 SSE response 里高频增长。ref 始终保留最新全文, + // 供自动打开 / 最终收口等同步逻辑立即读取;但不要逐帧把整份 stub 写进 React state。 + // 后者会重建沙箱 ThreadContext 并让 ArtifactFileDetail 的预览 effect 连锁执行, + // 在「继续会商 + 正在写文件」的高密度帧下曾触发 Maximum update depth exceeded。 + const pendingSeatStubMessagesRef = useRef< + Record | null + >(null); + const seatStubFlushTimerRef = useRef(null); const [selectedAgents, setSelectedAgents] = useState( DEFAULT_SELECTED_AGENTS, ); @@ -552,6 +560,11 @@ export function useStep2Orchestration( new Map(), ); const parallelFlushTimerRef = useRef(null); + // 非并行的 leader / 单席位流同样不能逐 token 直接 setState。SSE reader 可能在一次 + // 浏览器任务里吐出大量已缓冲帧;逐帧 setState 会与消息区的 effect 连锁,最终触发 + // React 的 Maximum update depth 保护。先累积到 ref,再以固定节流频率提交到界面。 + const directStreamDeltaRef = useRef>(new Map()); + const directStreamFlushTimerRef = useRef(null); // flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免 // 把 flush 函数绑进会频繁变化的 state 依赖。 const selectedParallelSeatsRef = useRef([]); @@ -684,6 +697,57 @@ export function useStep2Orchestration( return ids[agentName] ?? ids[agentName.toLowerCase()]; }, []); + /** + * 把写文件预览的 stub 以约 12fps 合并交给 React。 + * + * `seatStubMessagesRef` 已在调用处同步更新,故这里的延迟只影响右侧 Markdown + * 重绘频率,不影响随后读取最新 path/content、也不影响自动打开沙箱。 + */ + const flushSeatStubMessages = useCallback(() => { + seatStubFlushTimerRef.current = null; + const pending = pendingSeatStubMessagesRef.current; + if (!pending) return; + pendingSeatStubMessagesRef.current = null; + setSeatStubMessages((previous) => (previous === pending ? previous : pending)); + }, []); + + const scheduleSeatStubMessagesFlush = useCallback(() => { + if (seatStubFlushTimerRef.current !== null) return; + seatStubFlushTimerRef.current = window.setTimeout( + flushSeatStubMessages, + 80, + ); + }, [flushSeatStubMessages]); + + /** 清掉未提交的流式预览,并立即发布一个确定的快照(草稿恢复 / 重置时使用)。 */ + const replaceSeatStubMessages = useCallback( + (next: Record) => { + pendingSeatStubMessagesRef.current = null; + if (seatStubFlushTimerRef.current !== null) { + window.clearTimeout(seatStubFlushTimerRef.current); + seatStubFlushTimerRef.current = null; + } + seatStubMessagesRef.current = next; + setSeatStubMessages((previous) => (previous === next ? previous : next)); + }, + [], + ); + + const resetSeatStubMessages = useCallback(() => { + replaceSeatStubMessages({}); + }, [replaceSeatStubMessages]); + + // 卸载时清理挂起的预览 flush,避免已离开的页面仍收到一次 setState。 + useEffect( + () => () => { + if (seatStubFlushTimerRef.current !== null) { + window.clearTimeout(seatStubFlushTimerRef.current); + seatStubFlushTimerRef.current = null; + } + }, + [], + ); + /** * 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次 * 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。 @@ -734,10 +798,11 @@ export function useStep2Orchestration( [seatThreadId]: [nextAiMsg], }; seatStubMessagesRef.current = nextMap; - setSeatStubMessages(nextMap); + pendingSeatStubMessagesRef.current = nextMap; + scheduleSeatStubMessagesFlush(); return { messageId: nextAiMsg.id, toolCallId }; }, - [], + [scheduleSeatStubMessagesFlush], ); /** @@ -1026,12 +1091,6 @@ export function useStep2Orchestration( args, ); const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); - if (typeof window !== "undefined") { - console.debug( - "[roundtable-sandbox] auto-open from phase", - { toolName, path, seatThreadId, virtualUrl }, - ); - } openSeatVirtualArtifact(seatThreadId, virtualUrl, true); }, [ @@ -1272,9 +1331,15 @@ export function useStep2Orchestration( const setDialogueConfidence = useCallback( (id: number, confidence: string) => { - setStep2RoundtableDialogues((prev) => - prev.map((d) => (d.id === id ? { ...d, confidence } : d)), - ); + setStep2RoundtableDialogues((prev) => { + const index = prev.findIndex((dialogue) => dialogue.id === id); + // onPhaseChange 可在一个 SSE read 内收到多帧相同的 phase。状态文字没有变化 + // 时必须返回原数组;否则 React 会把无意义的新数组当成一次嵌套更新。 + if (index < 0 || prev[index].confidence === confidence) return prev; + const next = [...prev]; + next[index] = { ...prev[index], confidence }; + return next; + }); }, [], ); @@ -1286,30 +1351,39 @@ export function useStep2Orchestration( key: string, args?: Record, ) => { - setStep2RoundtableDialogues((prev) => - prev.map((d) => { - if (d.id !== bubbleId) return d; - const steps: Step2Dialogue["steps"] = d.steps ?? []; - const last = steps[steps.length - 1]; - if (last && last.key === key) { - // 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files - // 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。 - if (args && args !== last.args) { - const next = [...steps]; - next[next.length - 1] = { ...last, args }; - return { ...d, steps: next }; - } - return d; - } - return { - ...d, + setStep2RoundtableDialogues((prev) => { + const dialogueIndex = prev.findIndex((dialogue) => dialogue.id === bubbleId); + if (dialogueIndex < 0) return prev; + + const dialogue = prev[dialogueIndex]; + const steps: Step2Dialogue["steps"] = dialogue.steps ?? []; + const last = steps[steps.length - 1]; + let nextDialogue: Step2Dialogue; + + if (last && last.key === key) { + // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 + // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 + // content chunk 更新对话 state。 + const hasVisibleArgs = (value?: Record) => + !!value && Object.keys(value).some((argKey) => argKey !== "content"); + if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; + const nextSteps = [...steps]; + nextSteps[nextSteps.length - 1] = { ...last, args }; + nextDialogue = { ...dialogue, steps: nextSteps }; + } else { + nextDialogue = { + ...dialogue, steps: [ ...steps, { id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args }, ], }; - }), - ); + } + + const next = [...prev]; + next[dialogueIndex] = nextDialogue; + return next; + }); }, [], ); @@ -1342,20 +1416,45 @@ export function useStep2Orchestration( [], ); + /** 将非并行流的已积累增量一次性写入 React state(约 20fps)。 */ + const flushDirectStreamDeltas = useCallback(() => { + directStreamFlushTimerRef.current = null; + const pending = directStreamDeltaRef.current; + if (pending.size === 0) return; + directStreamDeltaRef.current = new Map(); + setStep2RoundtableDialogues((prev) => { + let changed = false; + const next = prev.map((dialogue) => { + const delta = pending.get(dialogue.id); + if (!delta) return dialogue; + changed = true; + return { ...dialogue, content: (dialogue.content ?? "") + delta }; + }); + return changed ? next : prev; + }); + }, []); + + const scheduleDirectStreamFlush = useCallback(() => { + if (directStreamFlushTimerRef.current !== null) return; + directStreamFlushTimerRef.current = window.setTimeout(flushDirectStreamDeltas, 50); + }, [flushDirectStreamDeltas]); + const appendToDialogue = useCallback((id: number, deltaText: string) => { if (!deltaText) return; - setStep2RoundtableDialogues((prev) => - prev.map((d) => - d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d, - ), - ); - }, []); + const pending = directStreamDeltaRef.current; + pending.set(id, (pending.get(id) ?? "") + deltaText); + scheduleDirectStreamFlush(); + }, [scheduleDirectStreamFlush]); /** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */ const setDialogueContent = useCallback((id: number, fullText: string) => { - setStep2RoundtableDialogues((prev) => - prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)), - ); + setStep2RoundtableDialogues((prev) => { + const index = prev.findIndex((dialogue) => dialogue.id === id); + if (index < 0 || prev[index].content === fullText) return prev; + const next = [...prev]; + next[index] = { ...prev[index], content: fullText }; + return next; + }); }, []); /** @@ -1372,6 +1471,7 @@ export function useStep2Orchestration( const who = info.agentName || "智能体"; triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`); if (isParallel) parallelStreamRef.current.delete(bubbleId); + directStreamDeltaRef.current.delete(bubbleId); const systemBubble: Step2Dialogue = { id: Date.now() + Math.random(), sender: "系统提示", @@ -1440,8 +1540,17 @@ export function useStep2Orchestration( } }, []); + const resetDirectStreamDeltas = useCallback(() => { + directStreamDeltaRef.current = new Map(); + if (directStreamFlushTimerRef.current !== null) { + window.clearTimeout(directStreamFlushTimerRef.current); + directStreamFlushTimerRef.current = null; + } + }, []); + // 卸载兜底:清掉可能挂起的 flush 定时器。 useEffect(() => resetParallelStreams, [resetParallelStreams]); + useEffect(() => resetDirectStreamDeltas, [resetDirectStreamDeltas]); // 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出, // 证明流没丢、只是之前没渲染,§6.5)。 @@ -1508,21 +1617,40 @@ export function useStep2Orchestration( const finalizeDialogue = useCallback( (id: number, finalContent: string, confidence?: string) => { - setStep2RoundtableDialogues((prev) => - prev.map((d) => { + // 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 + directStreamDeltaRef.current.delete(id); + if ( + directStreamDeltaRef.current.size === 0 && + directStreamFlushTimerRef.current !== null + ) { + window.clearTimeout(directStreamFlushTimerRef.current); + directStreamFlushTimerRef.current = null; + } + setStep2RoundtableDialogues((prev) => { + let changed = false; + const next = prev.map((d) => { if (d.id !== id) return d; const next = finalContent && finalContent.trim().length > 0 ? finalContent : d.content || "(无文本输出)"; + if ( + d.content === next && + d.confidence === (confidence ?? d.confidence) && + d.streaming === false + ) { + return d; + } + changed = true; return { ...d, content: next, confidence: confidence ?? d.confidence, streaming: false, }; - }), - ); + }); + return changed ? next : prev; + }); }, [], ); @@ -2552,7 +2680,7 @@ export function useStep2Orchestration( seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false, signal: subAborter.signal, // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 - // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 + // stage 也经 appendToDialogue 的节流缓冲,避免高频 SSE 帧嵌套更新。 onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel), onTextDelta: (text) => isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), @@ -3182,6 +3310,7 @@ export function useStep2Orchestration( // 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。 threadIdsRef.current = snapshot.threadIds; coordinatorNameRef.current = snapshot.coordinatorName; + resetDirectStreamDeltas(); // 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」, // 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些 // 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。 @@ -3203,15 +3332,14 @@ export function useStep2Orchestration( setConsensusPercentage(snapshot.consensusPercentage); setBudgetLimit(snapshot.budgetLimit); const restoredStubs = snapshot.seatStubMessages ?? {}; - seatStubMessagesRef.current = restoredStubs; - setSeatStubMessages(restoredStubs); + replaceSeatStubMessages(restoredStubs); // 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。 hasAutoOpenedRef.current = new Set(); // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, // 调用方需要的话再 resetInitGate()。 step2InitRef.current = true; }, - [], + [replaceSeatStubMessages, resetDirectStreamDeltas], ); // 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动 @@ -3225,13 +3353,12 @@ export function useStep2Orchestration( lastLeaderContent: string; }) => { setStep2RoundtableDialogues(frame.dialogues); - seatStubMessagesRef.current = frame.seatStubMessages; - setSeatStubMessages(frame.seatStubMessages); + replaceSeatStubMessages(frame.seatStubMessages); setConsensusPercentage(frame.consensusPercentage); setHasConsensus(frame.hasConsensus); if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent); }, - [], + [replaceSeatStubMessages], ); // 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后, @@ -3279,11 +3406,11 @@ export function useStep2Orchestration( setSelectedParallelSeats([]); setDagNodeStates({}); resetParallelStreams(); + resetDirectStreamDeltas(); setActiveArtifactThreadId(null); artifactPathsRef.current = []; setArtifacts([]); - seatStubMessagesRef.current = {}; - setSeatStubMessages({}); + resetSeatStubMessages(); hasAutoOpenedRef.current = new Set(); pendingInjectPromptRef.current = null; dagPlanExecutedRef.current = false; @@ -3292,7 +3419,14 @@ export function useStep2Orchestration( gatheredDeliveriesRef.current = []; deselectArtifact(); step2InitRef.current = false; - }, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]); + }, [ + abortAllInFlight, + deselectArtifact, + setArtifacts, + resetParallelStreams, + resetDirectStreamDeltas, + resetSeatStubMessages, + ]); return { selectedAgents, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 8085cff9..15d7a77c 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -16,16 +16,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useArtifacts } from "@/components/workspace/artifacts"; -import { apiFetch } from "@/core/api"; import { uploadFiles } from "@/core/uploads/api"; import type { DraftStep3SummarySnapshot, } from "@/roundtable-planning/api/drafts"; import { - buildArtifactDownloadUrl, initStructureThread, initSummaryThread, - listSessionArtifacts, streamMultiAgent, type StreamMultiAgentRequest, type StreamPhase, @@ -78,6 +75,8 @@ interface UseStep3SummaryOptions { * 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。 */ autoFlow?: boolean; + /** 第三步是否允许流程图分支。当前报告工作台只交付 Markdown,默认关闭。 */ + flowEnabled?: boolean; /** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */ savedSummary?: DraftStep3SummarySnapshot | null; /** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */ @@ -485,6 +484,7 @@ export function useStep3Summary( coordinatorPrompt, chainLevels, autoFlow, + flowEnabled = false, savedSummary, onSummarySaved, ingestAvailable, @@ -573,6 +573,10 @@ export function useStep3Summary( >(null); const stubRef = useRef>({}); + // write_file 会在流式过程中多次带回完整 tool_calls。stub 保持同步更新供预览读取, + // React 状态则节流发布,避免每一个工具帧都触发沙箱和消息列表的同步重渲染。 + const pendingStubMessagesRef = useRef | null>(null); + const stubFlushTimerRef = useRef(null); const runningRef = useRef(false); const aborterRef = useRef(null); // 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。 @@ -589,6 +593,47 @@ export function useStep3Summary( // run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。 const flowAborterRef = useRef(null); const activeFlowBubbleRef = useRef(null); + const autoArtifactTimerRef = useRef(null); + + /** 将写文件预览以约 12fps 合并发布给 React,读取 stub 的流逻辑始终使用最新 ref。 */ + const flushStubMessages = useCallback(() => { + stubFlushTimerRef.current = null; + const pending = pendingStubMessagesRef.current; + if (!pending) return; + pendingStubMessagesRef.current = null; + setSeatStubMessages((previous) => (previous === pending ? previous : pending)); + }, []); + + const scheduleStubMessagesFlush = useCallback(() => { + if (stubFlushTimerRef.current !== null) return; + stubFlushTimerRef.current = window.setTimeout(flushStubMessages, 80); + }, [flushStubMessages]); + + /** 重置/恢复草稿时立即发布确定快照,同时取消未提交的流式预览。 */ + const replaceStubMessages = useCallback( + (next: Record) => { + pendingStubMessagesRef.current = null; + if (stubFlushTimerRef.current !== null) { + window.clearTimeout(stubFlushTimerRef.current); + stubFlushTimerRef.current = null; + } + stubRef.current = next; + setSeatStubMessages((previous) => (previous === next ? previous : next)); + }, + [], + ); + + useEffect( + () => () => { + if (stubFlushTimerRef.current !== null) { + window.clearTimeout(stubFlushTimerRef.current); + } + if (autoArtifactTimerRef.current !== null) { + window.clearTimeout(autoArtifactTimerRef.current); + } + }, + [], + ); // ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)── const appendStreamingDialogue = useCallback( @@ -683,12 +728,15 @@ export function useStep3Summary( const steps: Step2Dialogue["steps"] = d.steps ?? []; const last = steps[steps.length - 1]; if (last && last.key === key) { - if (args && args !== last.args) { - const next = [...steps]; - next[next.length - 1] = { ...last, args }; - return { ...d, steps: next }; - } - return d; + // write_file 的流式工具帧会反复带着新的 content 对象。步骤卡只需要 + // 首次拿到可见参数(如 path)时补充一次,正文已经由 stub 节流同步给沙箱。 + // 不能为每个 content chunk 更新对话 state,否则会造成嵌套更新风暴。 + const hasVisibleArgs = (value?: Record) => + !!value && Object.keys(value).some((argKey) => argKey !== "content"); + if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return d; + const next = [...steps]; + next[next.length - 1] = { ...last, args }; + return { ...d, steps: next }; } return { ...d, @@ -762,14 +810,15 @@ export function useStep3Summary( const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls }; const nextMap = { ...stubRef.current, [threadId]: [nextMsg] }; stubRef.current = nextMap; - setSeatStubMessages(nextMap); + pendingStubMessagesRef.current = nextMap; + scheduleStubMessagesFlush(); // 记住当前报告全文(用于续问写底 / 存草稿)。 if (typeof storeArgs.content === "string" && storeArgs.content.trim()) { currentMdRef.current = storeArgs.content; } return { messageId: nextMsg.id, toolCallId }; }, - [], + [scheduleStubMessagesFlush], ); const buildWriteFileVirtualUrl = useCallback( @@ -816,14 +865,36 @@ export function useStep3Summary( const autoOpenArtifact = useCallback( (threadId: string, virtualUrl: string) => { - lastAutoThreadRef.current = threadId; if (!previewEnabledRef.current) { // 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。 lastAutoOpenedUrlRef.current = virtualUrl; + lastAutoThreadRef.current = threadId; return; } - if (lastAutoOpenedUrlRef.current === virtualUrl) return; - openArtifact(threadId, virtualUrl); + if ( + lastAutoOpenedUrlRef.current === virtualUrl && + lastAutoThreadRef.current === threadId + ) { + return; + } + // onPhaseChange 在 SSE 回调栈中触发。延后一帧再打开沙箱,避免工具帧与 + // ArtifactProvider/Markdown 预览的状态更新嵌套在同一次 React 更新里。 + lastAutoOpenedUrlRef.current = virtualUrl; + lastAutoThreadRef.current = threadId; + if (autoArtifactTimerRef.current !== null) { + window.clearTimeout(autoArtifactTimerRef.current); + } + autoArtifactTimerRef.current = window.setTimeout(() => { + autoArtifactTimerRef.current = null; + if ( + !previewEnabledRef.current || + lastAutoOpenedUrlRef.current !== virtualUrl || + lastAutoThreadRef.current !== threadId + ) { + return; + } + openArtifact(threadId, virtualUrl); + }, 80); }, [openArtifact], ); @@ -953,61 +1024,24 @@ export function useStep3Summary( [upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview], ); - const openRealArtifactFallback = useCallback( - async (threadId: string) => { - if (!previewEnabledRef.current) return; // 不在前台不抢沙箱 - try { - const res = await listSessionArtifacts(threadId); - const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0]; - if (!file) return; - setActiveArtifactThreadId(threadId); - setArtifacts([file.path]); - selectArtifact(file.path, false); - setArtifactsOpen(true); - } catch { - /* 列文件失败就保持当前状态 */ - } - }, - [setArtifacts, selectArtifact, setArtifactsOpen], - ); - - // 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。 - // `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。 + // 收尾时直接使用流式 write_file 捕获的 Markdown。当前任务沙箱的文件列表与下载 + // 端点不是同一套存储:列表返回的 /mnt/... 路径会被预览器拿去请求 /artifacts/mnt/... + // 并稳定返回 404。此处若再尝试“真实文件对账”,既拿不到更完整的内容,还会触发 + // 预览器的失败重试和状态更新循环。write_file 的 stub 在每个上游帧同步更新,因此它 + // 是本步骤报告的唯一预览/保存来源。 const reconcileStubFromRealFile = useCallback( async ( threadId: string, - opts?: { signal?: AbortSignal; force?: boolean }, + _opts?: { signal?: AbortSignal; force?: boolean }, ): Promise => { const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find( (tc) => tc.name === "write_file", ); - const stubPath = - typeof (writeTc?.args as { path?: unknown })?.path === "string" - ? ((writeTc!.args as { path?: string }).path as string) - : SUMMARY_OUTPUT_PATH; - const stubContent = - typeof (writeTc?.args as { content?: unknown })?.content === "string" - ? ((writeTc!.args as { content?: string }).content as string) - : ""; - try { - const list = await listSessionArtifacts(threadId, { signal: opts?.signal }); - const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null; - if (!mdFile) return stubContent; - const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), { - signal: opts?.signal, - }); - if (!res.ok) return stubContent; - const real = await res.text(); - if (real.trim() && (opts?.force || real.length > stubContent.length)) { - upsertStub(threadId, "write_file", { path: stubPath, content: real }); - return real; - } - } catch { - /* 拉取失败退回 stub 内容 */ - } - return stubContent; + return typeof (writeTc?.args as { content?: unknown })?.content === "string" + ? ((writeTc!.args as { content?: string }).content as string) + : ""; }, - [upsertStub], + [], ); const abort = useCallback(() => { @@ -1112,11 +1146,8 @@ export function useStep3Summary( }); if (aborter.signal.aborted) return; - const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; - // 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。 - if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId); - - // 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。 + // 以流式 write_file stub 收尾。不要把沙箱文件列表返回的真实路径塞给全局预览: + // 该路径在 artifacts 下载端点不存在,会导致 404 与预览循环。 const md = await reconcileStubFromRealFile(threadId, { signal: aborter.signal, force: forceReconcile, @@ -1155,7 +1186,6 @@ export function useStep3Summary( appendStepIfNew, handleArtifactToolPhase, handleUpstreamFrame, - openRealArtifactFallback, reconcileStubFromRealFile, finalizeDialogue, seedStubWithCurrentMd, @@ -1301,6 +1331,9 @@ export function useStep3Summary( /** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */ const regenerateFlow = useCallback(() => { + // 第三步报告工作台已关闭流程图交付。历史草稿/旧按钮即使仍持有这个回调, + // 也不能在总结收尾后意外启动结构化抽取。 + if (!flowEnabled) return; if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return; aborterRef.current?.abort(); // 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。 @@ -1308,7 +1341,7 @@ export function useStep3Summary( const aborter = new AbortController(); aborterRef.current = aborter; void runFlowExtract(aborter); - }, [runFlowExtract, seatDeliveries, resetIngestState]); + }, [flowEnabled, runFlowExtract, seatDeliveries, resetIngestState]); /** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */ const updateFlowGraph = useCallback( @@ -1335,10 +1368,10 @@ export function useStep3Summary( setPhase("creating"); setDialogues([]); lastAutoOpenedUrlRef.current = null; - stubRef.current = {}; + lastAutoThreadRef.current = null; + replaceStubMessages({}); currentMdRef.current = ""; liveThreadRef.current = null; - setSeatStubMessages({}); setFlowGraphState(null); setFlowPhase("idle"); // 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。 @@ -1359,7 +1392,7 @@ export function useStep3Summary( try { // 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。 // 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。 - const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt); + const flowMode = flowEnabled && !autoFlow && detectFlowIntent(coordinatorPrompt); flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正 const prompt = buildSummaryPrompt({ intentReady, @@ -1381,6 +1414,7 @@ export function useStep3Summary( // ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图, // 由 ask 的手动出图独占,避免 auto + manual 双卡片。 if ( + flowEnabled && autoFlow && !aborter.signal.aborted && aborterRef.current === aborter && @@ -1404,11 +1438,13 @@ export function useStep3Summary( consensusText, coordinatorPrompt, autoFlow, + flowEnabled, appendStreamingDialogue, finalizeDialogue, streamOnce, runFlowExtract, resetIngestState, + replaceStubMessages, ]); /** @@ -1566,6 +1602,12 @@ export function useStep3Summary( (choice: "generate" | "cancel") => { setPendingFlowForIngest(false); if (choice === "generate") { + if (!flowEnabled) { + // 老草稿可能遗留「先生成流程图再入库」的协助卡;报告模式不再提供这条链路。 + setIngestStatus("later"); + persistSummary(); + return; + } // 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。 setIngestStatus("idle"); regenerateFlow(); @@ -1660,6 +1702,11 @@ export function useStep3Summary( await runIngest(); return; } + // 当前第三步只维护 Markdown 报告。即使意图分类命中旧的流程图关键词, + // 也按“重写报告”处理,绝不启动流程图抽取请求。 + if (!flowEnabled && (intent === "regenerate_flow" || intent === "modify_flow")) { + intent = "regenerate_report"; + } if (intent === "regenerate_flow" || intent === "modify_flow") { // 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮), // runFlowExtract 出新图后会在 idle 时重新询问是否入库。 @@ -1809,6 +1856,7 @@ export function useStep3Summary( runFlowExtract, runIngest, resetIngestState, + flowEnabled, ], ); @@ -1902,7 +1950,7 @@ export function useStep3Summary( // 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。 const effectiveStub = savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub; - stubRef.current = effectiveStub; + replaceStubMessages(effectiveStub); currentMdRef.current = snap.md; liveThreadRef.current = realThreadId; // 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。 @@ -1919,7 +1967,6 @@ export function useStep3Summary( lastAutoOpenedUrlRef.current = null; lastAutoThreadRef.current = null; } - setSeatStubMessages(effectiveStub); setSummaryThreadId(synthThreadId); setActiveArtifactThreadId(synthThreadId); @@ -1962,7 +2009,7 @@ export function useStep3Summary( // 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的 // 「点击预览」步骤卡随时可开(stub 已种好)。 }, - [abort, buildWriteFileVirtualUrl], + [abort, buildWriteFileVirtualUrl, replaceStubMessages], ); const busy = phase === "creating" || phase === "streaming"; diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 3b319763..96b9722d 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1534,9 +1534,9 @@ export default function RoundtablePlanningPage({ .filter((g) => g.length > 0), [orchestrationPlan], ); - // 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链, - // 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。 - const autoSummaryFlow = true; + // 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物, + // 避免报告写完后额外触发一次结构化抽取请求。 + const autoSummaryFlow = false; const summaryFlow = useStep3Summary({ intentReady: step1.intentReady, @@ -1546,6 +1546,7 @@ export default function RoundtablePlanningPage({ coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, chainLevels, autoFlow: autoSummaryFlow, + flowEnabled: false, savedSummary: step3Report?.summaryReport ?? null, onSummarySaved: handleSummarySaved, ingestAvailable, @@ -2422,7 +2423,6 @@ export default function RoundtablePlanningPage({ useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false} step2RoundtableDialogues={step2.step2RoundtableDialogues} isInitializing={step2.isInitializing} - isRoundTableRunning={step2.isRoundTableRunning} isOrchestrating={step2.isOrchestrating} pendingClarification={step2.pendingClarification} clarificationDraft={step2.clarificationDraft} @@ -2450,7 +2450,6 @@ export default function RoundtablePlanningPage({ openArtifactFromStep={step2.openArtifactFromStep} openArtifactPreview={step2.openArtifactPreview} onPauseRoundtable={step2.pauseRoundtable} - onResumeRoundtable={step2.resumeRoundtable} onHandleInjectToRoundtable={step2.handleInjectToRoundtable} setClarificationDraft={step2.setClarificationDraft} setCustomAgentInput={step2.setCustomAgentInput} @@ -2995,7 +2994,7 @@ function LeftSidebar({ className={cn( "min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out", isStep2 ? "overflow-y-auto" : "overflow-hidden", - isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]", + isStep2 && !sidebarExpanded ? "w-[80px]" : "w-[280px]", )} > {isStep2 && !sidebarExpanded && ( @@ -3004,7 +3003,7 @@ function LeftSidebar({ onClick={() => setSidebarExpanded(true)} title="展开侧栏" aria-expanded={false} - className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]" + className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]" > @@ -3731,7 +3730,7 @@ function Step2RunSwitcher({ type="button" onClick={() => onSelect(run.id)} className={cn( - "w-full rounded-xl border p-2.5 text-left transition-colors", + "roundtable-step2-run-card w-full rounded-xl border p-2.5 text-left transition-colors", active ? "border-primary/40 bg-primary/5 text-slate-900" : "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white", @@ -3842,7 +3841,7 @@ function HumanParticipationCard({ : undefined } className={cn( - "flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition", + "roundtable-step2-human-row flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition", clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40", )} @@ -4229,7 +4228,7 @@ function CollapsedSidebarSection({
@@ -4366,7 +4365,7 @@ function RoleRow({ : undefined } className={cn( - "flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition", + "roundtable-step2-role-row flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition", isActive ? "bg-slate-50 border border-slate-100" : isBootingUp From 5e1765d8896bcf960151777891097510203baf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 19 Aug 2026 12:12:35 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=208BF=20?= =?UTF-8?q?=E4=BC=9A=E5=95=86=E7=AC=AC=E4=B8=80=E6=AD=A5=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=B8=8E=E6=99=BA=E8=83=BD=E4=BD=93=E5=88=97=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=AD=A5=E5=AF=B9=E8=AF=9D=E5=8C=BA=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=AC=AC=E4=B8=80=E6=AD=A5=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。 Co-authored-by: Cursor --- frontend-web/public/runtime-config.js | 4 + .../assets/eightbf-roundtable/back-arrow.svg | 3 + .../AnalysisResultCardList.tsx | 104 ++++++ .../AnalysisResultDetailDialog.tsx | 141 +++++++ .../analysis-results/analysis-results.css | 130 +++++++ .../src/components/analysis-results/index.ts | 3 + .../src/components/analysis-results/types.ts | 17 + .../eightbf-roundtable/api/task-context.ts | 5 + .../components/EightBfStep1Workspace.tsx | 186 +++++++--- .../lib/collect-md-artifacts.ts | 343 ++++++++++++++++++ .../pages/EightBfRoundtablePage.tsx | 1 + .../src/eightbf-roundtable/styles/step1.css | 215 ++++++++--- .../src/eightbf-roundtable/styles/step2.css | 40 ++ .../pages/PositionRoundtablePage.tsx | 119 ++---- .../styles/intent-understanding.css | 28 +- .../components/Step2Panel.tsx | 125 +++++-- .../pages/RoundtablePlanningPage.tsx | 20 + 17 files changed, 1254 insertions(+), 230 deletions(-) create mode 100644 frontend-web/src/assets/eightbf-roundtable/back-arrow.svg create mode 100644 frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx create mode 100644 frontend-web/src/components/analysis-results/AnalysisResultDetailDialog.tsx create mode 100644 frontend-web/src/components/analysis-results/analysis-results.css create mode 100644 frontend-web/src/components/analysis-results/index.ts create mode 100644 frontend-web/src/components/analysis-results/types.ts create mode 100644 frontend-web/src/eightbf-roundtable/lib/collect-md-artifacts.ts diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index e24ea0c7..b9e96530 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -54,6 +54,10 @@ window.__CM_RUNTIME_CONFIG__ = { // 不影响原多智能体会商使用的任务详情接口。 VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。 + // 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。 + VITE_8BF_ROUNDTABLE_BACK_URL: "", + // 佳来模型对话接口。 VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", diff --git a/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg b/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg new file mode 100644 index 00000000..801cd281 --- /dev/null +++ b/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg @@ -0,0 +1,3 @@ + diff --git a/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx b/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx new file mode 100644 index 00000000..2788cf70 --- /dev/null +++ b/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx @@ -0,0 +1,104 @@ +import { FileOutput } from "lucide-react"; +import { toast } from "sonner"; + +import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg"; +import resultTimeIcon from "@/assets/position-roundtable/时间.svg"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +import "./analysis-results.css"; +import type { AnalysisResultCardItem } from "./types"; + +function formatResultTime(value: string | undefined, fallback: string): string { + if (!value) return fallback; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +export function AnalysisResultCardList({ + items, + emptyTitle = "暂无交付产物", + emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。", + padded = true, + onOpen, +}: { + items: AnalysisResultCardItem[]; + emptyTitle?: string; + emptyDescription?: string; + padded?: boolean; + onOpen: (item: AnalysisResultCardItem) => void; +}) { + if (items.length === 0) { + return ( +
+ +

{emptyTitle}

+

+ {emptyDescription} +

+
+ ); + } + + return ( +
+ {items.map((item, index) => { + const displayedTime = formatResultTime( + item.updatedAt, + item.fallbackTime || "刚刚", + ); + const displayedProductName = item.delivered ? item.productName : "未交付"; + return ( +