feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档

8BF/会商工作区可将 Markdown 产物一键提交到任务过程报告,后端记录提交状态以便跨用户与刷新后保持已提交;同时为 Angular 智能体对话对接文档补上前后端参考代码索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-18 20:08:51 +08:00
1 parent 33b0af333c
commit dcfaa199bf
19 files changed
+1133 -43

No files matched your search

@@ -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 <jwt>
每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端拉模型 | `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` 时模型不应再吐思考;若仍出现 `<think>`,继续剥掉以免露标签。
**参考代码**
| 说明 | 路径 |
|------|------|
| 抽思考 / 剥 `<think>`(含未闭合) | `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` |
@@ -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({
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
</span>
{headerLeadingAction ?? (
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
</span>
)}
{!hideFileName && (
<ArtifactTitle className="min-w-0">
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
@@ -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<Error> {
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<string, unknown>;
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<EightBfTaskProcessReportReceipt> {
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<void> {
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;
@@ -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<TaskArtifactSubmissionConfig | undefined>(
() => 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}
/>
);
}
@@ -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<Error> {
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<RoundtableArtifactSubmissionRecord[]> {
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<RoundtableArtifactSubmissionRecord> {
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<RoundtableArtifactSubmissionRecord>;
}
@@ -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<string, unknown[]>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
interface SeatArtifacts {
@@ -139,6 +142,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
onReset,
openArtifactPreview,
seatStubMessages,
taskArtifactSubmission,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const consensusDocTitle = useMemo(() => {
@@ -445,6 +449,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
savedSummary={savedSummary}
summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
@@ -467,6 +472,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
report={reportFlow}
dashboard={dashboardFlow}
onBack={() => setDrawMode(false)}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
@@ -917,6 +923,27 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
<div className="flex items-center gap-1 shrink-0">
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary dark:text-muted-foreground" />
{seat.threadId && (
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.key,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.key,
agentName: seat.displayName,
}}
/>
)}
<button
type="button"
onClick={(e) => {
@@ -1011,6 +1038,23 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}) ?? {
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
)}
/>
</ThreadContext.Provider>
)}
@@ -16,7 +16,7 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Cpu,
FileIcon,
@@ -50,6 +50,7 @@ import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpe
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import {
MarkdownContent,
@@ -87,6 +88,7 @@ import { desensitize } from "@/lib/desensitize";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface ModelOption {
name: string;
@@ -160,6 +162,8 @@ export interface Step2PanelProps {
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
/** 任务深链页面注入的外部过程产物提交控制器。 */
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Panel(props: Step2PanelProps) {
@@ -207,6 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
taskArtifactSubmission,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
@@ -592,6 +597,7 @@ export function Step2Panel(props: Step2PanelProps) {
onFileCardClick={(path) => {
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
) : (
<div className="py-6 text-center text-xs text-slate-400">
@@ -648,6 +654,7 @@ export function Step2Panel(props: Step2PanelProps) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))
)}
@@ -808,6 +815,7 @@ export function Step2Panel(props: Step2PanelProps) {
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
</Step2SandboxLayout>
@@ -950,6 +958,7 @@ export interface Step2MessageProps {
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Message({
@@ -960,6 +969,7 @@ export function Step2Message({
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
taskArtifactSubmission,
}: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
@@ -1038,6 +1048,8 @@ export function Step2Message({
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
threadId={msg.agentThreadId}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
@@ -1103,10 +1115,14 @@ function PresentFilesRow({
files,
onClick,
disabled,
threadId,
taskArtifactSubmission,
}: {
files: string[];
onClick?: (path: string) => void;
disabled: boolean;
threadId?: string;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
return (
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
@@ -1116,6 +1132,12 @@ function PresentFilesRow({
path={path}
disabled={disabled || !onClick}
onClick={() => onClick?.(path)}
submitButton={threadId ? (
<TaskArtifactSubmitButton
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({ threadId, path }) ?? { threadId, path }}
/>
) : null}
/>
))}
</div>
@@ -1149,37 +1171,42 @@ function PresentFileCard({
path,
disabled,
onClick,
submitButton,
}: {
path: string;
disabled: boolean;
onClick: () => void;
submitButton?: ReactNode;
}) {
const filename = path.split("/").pop() ?? path;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button>
<div className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50">
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex min-w-0 flex-col gap-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button>
{submitButton && <div className="mt-1 flex justify-end">{submitButton}</div>}
</div>
);
}
@@ -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<GroupImperativeHandle>(null);
const {
@@ -118,6 +123,16 @@ export function Step2SandboxLayout({
allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: previewThreadId,
path: selectedArtifact,
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
/>
)}
/>
) : (
<div className="relative flex size-full justify-center">
@@ -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<string, unknown[]>;
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}
/>
);
}
@@ -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}
>
<AgentProgressPanel
phase={phase}
@@ -390,6 +395,7 @@ export function Step3ReportMode({
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
</section>
@@ -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<ModelOption>;
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<string, string>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
@@ -932,6 +944,25 @@ function SeatFilesModal({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}}
/>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
@@ -53,6 +53,7 @@ import {
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 { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
@@ -61,6 +62,7 @@ import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface SeatDelivery {
sender: string;
@@ -88,6 +90,7 @@ interface Step3SummaryModeProps {
summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
type SourceModal = "consensus" | "seats" | "files" | null;
@@ -108,6 +111,7 @@ export function Step3SummaryMode({
savedSummary,
summary,
onViewStatic,
taskArtifactSubmission,
}: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -389,6 +393,7 @@ export function Step3SummaryMode({
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
flowGraph && flowView ? (
<FlowGraphPanel
@@ -435,6 +440,7 @@ export function Step3SummaryMode({
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
@@ -491,6 +497,7 @@ export function Step3SummaryMode({
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
taskArtifactSubmission={taskArtifactSubmission}
onClose={() => setModal(null)}
/>
)}
@@ -589,6 +596,7 @@ function AgentProgressPanel({
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
@@ -622,6 +630,7 @@ function AgentProgressPanel({
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
@@ -726,6 +735,7 @@ function AgentProgressPanel({
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))}
@@ -1106,11 +1116,13 @@ function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
taskArtifactSubmission,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
@@ -1216,6 +1228,25 @@ function SeatFilesModal({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}}
/>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
@@ -0,0 +1,50 @@
import { Check, Loader2, UploadCloud } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskArtifactSubmissionController, TaskArtifactTarget } from "@/roundtable-planning/lib/task-artifact-submission";
export function TaskArtifactSubmitButton({
controller,
target,
compact = false,
className,
}: {
controller?: TaskArtifactSubmissionController;
target: TaskArtifactTarget;
compact?: boolean;
className?: string;
}) {
if (!controller?.enabled || !controller.isMarkdown(target)) return null;
const state = controller.stateFor(target);
const error = controller.errorFor(target);
const isSubmitted = state === "submitted";
const isSubmitting = state === "submitting";
const label = isSubmitted ? "已提交" : isSubmitting ? "提交中…" : "提交";
const title = error || (isSubmitted ? "该 Markdown 产物已提交到任务过程" : "提交 Markdown 过程产物");
return (
<button
type="button"
disabled={isSubmitting || isSubmitted}
aria-label={`${target.name || target.path}:${label}`}
title={title}
onClick={(event) => {
event.stopPropagation();
void controller.submit(target).catch(() => undefined);
}}
className={cn(
"inline-flex shrink-0 items-center justify-center gap-1 rounded-md border px-2 py-1 text-[10px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 disabled:cursor-not-allowed",
isSubmitted
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
: 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",
className,
)}
>
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : isSubmitted ? <Check className="size-3" /> : <UploadCloud className="size-3" />}
<span>{label}</span>
</button>
);
}
@@ -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<string, { displayName: string }> | undefined,
): TaskArtifactSubmissionController | undefined {
const [entries, setEntries] = useState<Record<string, StatusEntry>>({});
const entriesRef = useRef(entries);
entriesRef.current = entries;
const pendingRef = useRef(new Map<string, Promise<RoundtableArtifactSubmissionRecord | null>>());
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<string, StatusEntry> = {};
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]);
}
@@ -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<void>;
}
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<RoundtableArtifactSubmissionRecord | null>;
}
@@ -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}
/>
)}
</section>
@@ -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(
@@ -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,
@@ -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,
@@ -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,