代码提交
This commit is contained in:
1 parent
742bbdf3ac
commit
920fde9cd0
5 files changed
+263
-43
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# 圆桌 Step 2 沙箱预览 · 开发进度
|
# 圆桌 Step 2 沙箱预览 · 开发进度
|
||||||
|
|
||||||
> 最后更新:2026-05-27
|
> 最后更新:2026-05-27
|
||||||
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
|
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
|
||||||
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
|
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,13 +12,14 @@
|
|||||||
|
|
||||||
| 能力 | 主聊天 | Step 2 目标 |
|
| 能力 | 主聊天 | Step 2 目标 |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 已实现(虚拟 URL) |
|
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
|
||||||
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ 已实现 |
|
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
|
||||||
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ❌ 未做 |
|
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
|
||||||
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
|
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
|
||||||
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
|
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
|
||||||
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages` |
|
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
|
||||||
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ⚠️ 仅 `present_files` 手动路径 |
|
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
|
||||||
|
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
| 项 | 状态 | 说明 |
|
| 项 | 状态 | 说明 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
|
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
|
||||||
| 60/40 分栏壳 | ✅ | 新建 `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
|
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
|
||||||
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
|
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
|
||||||
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
|
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
|
||||||
|
|
||||||
@@ -57,6 +58,33 @@
|
|||||||
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
|
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
|
||||||
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
|
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
|
||||||
|
|
||||||
|
### 2.4 上游帧兜底注入(Phase 3)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
|
||||||
|
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
|
||||||
|
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
|
||||||
|
|
||||||
|
### 2.5 `present_files` 文件卡(Phase 4)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
|
||||||
|
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
|
||||||
|
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
|
||||||
|
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
|
||||||
|
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
|
||||||
|
|
||||||
|
### 2.6 草稿持久化(Phase 5)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
|
||||||
|
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
|
||||||
|
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
|
||||||
|
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
|
||||||
|
|
||||||
**主要文件**:
|
**主要文件**:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -66,29 +94,34 @@ frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
|
|||||||
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
|
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
|
||||||
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
|
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
|
||||||
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
|
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
|
||||||
|
frontend-web/src/roundtable-planning/utils/drafts.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.4 与主聊天对齐的核心数据流(当前实现)
|
### 2.7 与主聊天对齐的核心数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
special seat SSE (streamMultiAgent)
|
special seat SSE (streamMultiAgent)
|
||||||
onPhaseChange("tool_calling", "write_file", args)
|
├── onPhaseChange("tool_calling", "write_file", args)
|
||||||
↓
|
│ ↓
|
||||||
upsertSeatToolCall(seatThreadId, "write_file", args)
|
│ upsertSeatToolCall(seatThreadId, "write_file", args)
|
||||||
→ seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
|
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
|
||||||
↓
|
│ ↓
|
||||||
buildWriteFileVirtualUrl(path, messageId, toolCallId)
|
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
|
||||||
→ "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
|
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
|
||||||
↓
|
│ ↓
|
||||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
|
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
|
||||||
→ selectArtifact(virtualUrl) + setArtifactsOpen(true)
|
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
|
||||||
↓
|
│
|
||||||
|
└── onUpstreamFrame(frame) ← 兜底通道
|
||||||
|
↓
|
||||||
|
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
|
||||||
|
|
||||||
Step2SandboxLayout
|
Step2SandboxLayout
|
||||||
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
|
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
|
||||||
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
|
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
|
||||||
↓
|
↓
|
||||||
useArtifactContent → loadArtifactContentFromToolCall
|
useArtifactContent → loadArtifactContentFromToolCall
|
||||||
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
|
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
|
||||||
```
|
```
|
||||||
|
|
||||||
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
|
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
|
||||||
@@ -101,21 +134,19 @@ useArtifactContent → loadArtifactContentFromToolCall
|
|||||||
|
|
||||||
| 优先级 | 项 | 状态 | 说明 |
|
| 优先级 | 项 | 状态 | 说明 |
|
||||||
|--------|----|------|------|
|
|--------|----|------|------|
|
||||||
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL 改造后需确认:自动打开时机、MD 内容、点击步骤、左栏收起 |
|
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
|
||||||
| P1 | `present_files` 文件卡片 | ❌ | 主聊天在消息正文下方渲染 `RichFileCard`(`message-list-item.tsx`);Step 2 气泡仅有 Markdown 文本,**无文件卡片 UI** |
|
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
|
||||||
| P1 | `onUpstreamFrame` 完整消息注入 | ❌ | 当前仅从 `onPhaseChange` 的 `args` 写 stub;若 LangGraph 在 tool_call_chunks 阶段不传完整 `args.content`,预览可能空白,需从上游 messages-tuple 帧补全 |
|
|
||||||
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace |
|
|
||||||
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
|
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
|
||||||
| P2 | 草稿持久化 `seatStubMessages` | ❌ | `Step2Snapshot` / `hydrateFromDraft` 未保存 stub;加载草稿后点击历史 write_file 步骤可能无法预览 |
|
|
||||||
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
|
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
|
||||||
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
|
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
|
||||||
|
|
||||||
### 3.2 已知风险 / 边界
|
### 3.2 已知风险 / 边界
|
||||||
|
|
||||||
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致,但可能比用户预期晚。
|
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
|
||||||
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
|
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
|
||||||
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId`;总控若直接 write_file,需单独接 thread id。
|
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
|
||||||
4. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
|
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
|
||||||
|
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,10 +155,10 @@ useArtifactContent → loadArtifactContentFromToolCall
|
|||||||
| 阶段 | 内容 | 状态 |
|
| 阶段 | 内容 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
|
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
|
||||||
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 代码完成,⚠️ 待 E2E 验证 |
|
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
|
||||||
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ❌ 未开始 |
|
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
|
||||||
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ❌ 未开始 |
|
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
|
||||||
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ❌ 未开始 |
|
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -137,12 +168,13 @@ useArtifactContent → loadArtifactContentFromToolCall
|
|||||||
|
|
||||||
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
|
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
|
||||||
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
|
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
|
||||||
- [ ] 流式期间 MD 内容随 `args.content` 更新(需 stub 收到完整 args)
|
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
|
||||||
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
|
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
|
||||||
|
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
|
||||||
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
|
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
|
||||||
- [ ] 关闭沙箱(X)后对话区恢复全宽
|
- [ ] 关闭沙箱(X)后对话区恢复全宽
|
||||||
- [ ] `present_files` 步骤点击 → HTTP 预览(文件已落盘时)
|
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
|
||||||
- [ ] 「新建任务」后 artifacts / 沙箱状态清空
|
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
|
||||||
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
|
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
|
||||||
|
|
||||||
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
|
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
|
||||||
@@ -154,18 +186,22 @@ useArtifactContent → loadArtifactContentFromToolCall
|
|||||||
| 用途 | 路径 |
|
| 用途 | 路径 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
|
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
|
||||||
|
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
|
||||||
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
|
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
|
||||||
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
||||||
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
|
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
|
||||||
| Step 2 编排 + 沙箱状态 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
||||||
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
|
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
|
||||||
| SSE phase 推断 | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
|
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
|
||||||
|
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
|
||||||
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||||
|
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 下一步建议(给接续开发者)
|
## 7. 下一步建议(给接续开发者)
|
||||||
|
|
||||||
1. **先跑通 P0 测试清单**,确认虚拟 URL 改造后无回归;若内容空白,优先做 Phase 3(`onUpstreamFrame` 解析 messages-tuple 写入 stub)。
|
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
|
||||||
2. **Phase 4**:在 `Step2Message` / 气泡 Markdown 渲染后检测 `present_files` 或解析 assistant 正文里的文件引用,复用 `RichFileCard` 或抽轻量 `RoundtableFileCard`。
|
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
|
||||||
3. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
|
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
|
||||||
|
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
|
FileIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -76,6 +77,8 @@ export interface Step2PanelProps {
|
|||||||
dialogue: Step2Dialogue,
|
dialogue: Step2Dialogue,
|
||||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
) => void;
|
) => void;
|
||||||
|
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
|
||||||
|
openArtifactPreview: (threadId: string, path: string) => void;
|
||||||
// ─── Setters ──────────────────────────────────────────────────
|
// ─── Setters ──────────────────────────────────────────────────
|
||||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||||
@@ -112,6 +115,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
activeArtifactThreadId,
|
activeArtifactThreadId,
|
||||||
seatStubMessages,
|
seatStubMessages,
|
||||||
openArtifactFromStep,
|
openArtifactFromStep,
|
||||||
|
openArtifactPreview,
|
||||||
setIsRoundTableRunning,
|
setIsRoundTableRunning,
|
||||||
setClarificationDraft,
|
setClarificationDraft,
|
||||||
setCustomAgentInput,
|
setCustomAgentInput,
|
||||||
@@ -231,6 +235,11 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
key={msg.id}
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
|
onFileCardClick={(path) => {
|
||||||
|
if (msg.agentThreadId) {
|
||||||
|
openArtifactPreview(msg.agentThreadId, path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -377,9 +386,15 @@ interface Step2MessageProps {
|
|||||||
onArtifactStepClick?: (
|
onArtifactStepClick?: (
|
||||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
) => void;
|
) => void;
|
||||||
|
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||||
|
onFileCardClick?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
function Step2Message({
|
||||||
|
msg,
|
||||||
|
onArtifactStepClick,
|
||||||
|
onFileCardClick,
|
||||||
|
}: Step2MessageProps) {
|
||||||
const isInjected = msg.sender === "用户 (插话干预)";
|
const isInjected = msg.sender === "用户 (插话干预)";
|
||||||
const isStreaming = msg.streaming === true;
|
const isStreaming = msg.streaming === true;
|
||||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||||
@@ -388,6 +403,7 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
|||||||
const rawContent = msg.content ?? "";
|
const rawContent = msg.content ?? "";
|
||||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||||
const hasContent = content.length > 0;
|
const hasContent = content.length > 0;
|
||||||
|
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -417,6 +433,122 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
|||||||
<MarkdownContent>{content}</MarkdownContent>
|
<MarkdownContent>{content}</MarkdownContent>
|
||||||
{isStreaming && <StreamingCursor />}
|
{isStreaming && <StreamingCursor />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{presentedFiles.length > 0 && (
|
||||||
|
<PresentFilesRow
|
||||||
|
files={presentedFiles}
|
||||||
|
onClick={onFileCardClick}
|
||||||
|
disabled={!msg.agentThreadId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
|
||||||
|
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
|
||||||
|
*/
|
||||||
|
function collectPresentedFiles(
|
||||||
|
steps: Step2Dialogue["steps"],
|
||||||
|
): string[] {
|
||||||
|
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.key !== "tool_calling:present_files") continue;
|
||||||
|
const fps = step.args?.filepaths;
|
||||||
|
if (!Array.isArray(fps)) continue;
|
||||||
|
for (const fp of fps) {
|
||||||
|
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
|
||||||
|
seen.add(fp);
|
||||||
|
out.push(fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresentFilesRow({
|
||||||
|
files,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
onClick?: (path: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{files.map((path) => (
|
||||||
|
<PresentFileCard
|
||||||
|
key={path}
|
||||||
|
path={path}
|
||||||
|
disabled={disabled || !onClick}
|
||||||
|
onClick={() => onClick?.(path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXT_LABELS: Record<string, string> = {
|
||||||
|
md: "Markdown",
|
||||||
|
json: "JSON",
|
||||||
|
csv: "CSV",
|
||||||
|
txt: "TXT",
|
||||||
|
py: "Python",
|
||||||
|
js: "JS",
|
||||||
|
ts: "TS",
|
||||||
|
tsx: "TSX",
|
||||||
|
jsx: "JSX",
|
||||||
|
html: "HTML",
|
||||||
|
css: "CSS",
|
||||||
|
xml: "XML",
|
||||||
|
yaml: "YAML",
|
||||||
|
yml: "YAML",
|
||||||
|
pdf: "PDF",
|
||||||
|
png: "PNG",
|
||||||
|
jpg: "JPG",
|
||||||
|
jpeg: "JPEG",
|
||||||
|
svg: "SVG",
|
||||||
|
zip: "ZIP",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PresentFileCard({
|
||||||
|
path,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
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="flex max-w-50 min-w-30 flex-col gap-1 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 items-start gap-2">
|
||||||
|
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||||
|
<span
|
||||||
|
className="truncate text-[12px] font-medium text-slate-700"
|
||||||
|
title={filename}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">点击预览</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ export type Step2Snapshot = {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
|
||||||
|
* 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
|
||||||
|
* 老草稿可能没有这字段,反序列化时按空对象兜底。
|
||||||
|
*/
|
||||||
|
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -514,6 +520,39 @@ export function useStep2Orchestration(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
|
||||||
|
* 与 `handleArtifactToolPhase` 相比:
|
||||||
|
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||||
|
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||||
|
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||||
|
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||||
|
*/
|
||||||
|
const handleUpstreamFrame = useCallback(
|
||||||
|
(frame: unknown, seatThreadId: string | undefined) => {
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||||
|
const chunk = frame[0];
|
||||||
|
if (!chunk || typeof chunk !== "object") return;
|
||||||
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
const toolCalls = obj.tool_calls;
|
||||||
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
if (!tc || typeof tc !== "object") continue;
|
||||||
|
const tcObj = tc as Record<string, unknown>;
|
||||||
|
const name = typeof tcObj.name === "string" ? tcObj.name : "";
|
||||||
|
if (name !== "write_file" && name !== "str_replace") continue;
|
||||||
|
const tcArgs = tcObj.args;
|
||||||
|
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||||
|
const argsObj = tcArgs as Record<string, unknown>;
|
||||||
|
const path = argsObj.path;
|
||||||
|
if (typeof path !== "string" || !path) continue;
|
||||||
|
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upsertSeatToolCall],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
||||||
const appendDialogue = useCallback(
|
const appendDialogue = useCallback(
|
||||||
(card: {
|
(card: {
|
||||||
@@ -890,6 +929,8 @@ export function useStep2Orchestration(
|
|||||||
seatThreadId,
|
seatThreadId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onUpstreamFrame: (frame) =>
|
||||||
|
handleUpstreamFrame(frame, seatThreadId),
|
||||||
});
|
});
|
||||||
if (orchestrationCancelRef.current) {
|
if (orchestrationCancelRef.current) {
|
||||||
finalizeDialogue(
|
finalizeDialogue(
|
||||||
@@ -958,6 +999,7 @@ export function useStep2Orchestration(
|
|||||||
clearAllStreamingSpeakers,
|
clearAllStreamingSpeakers,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
handleArtifactToolPhase,
|
handleArtifactToolPhase,
|
||||||
|
handleUpstreamFrame,
|
||||||
resolveSeatThreadId,
|
resolveSeatThreadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1236,6 +1278,9 @@ export function useStep2Orchestration(
|
|||||||
setHasConsensus(snapshot.hasConsensus);
|
setHasConsensus(snapshot.hasConsensus);
|
||||||
setConsensusPercentage(snapshot.consensusPercentage);
|
setConsensusPercentage(snapshot.consensusPercentage);
|
||||||
setBudgetLimit(snapshot.budgetLimit);
|
setBudgetLimit(snapshot.budgetLimit);
|
||||||
|
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||||
|
seatStubMessagesRef.current = restoredStubs;
|
||||||
|
setSeatStubMessages(restoredStubs);
|
||||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||||
// 调用方需要的话再 resetInitGate()。
|
// 调用方需要的话再 resetInitGate()。
|
||||||
step2InitRef.current = true;
|
step2InitRef.current = true;
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
hasConsensus: step2.hasConsensus,
|
hasConsensus: step2.hasConsensus,
|
||||||
consensusPercentage: step2.consensusPercentage,
|
consensusPercentage: step2.consensusPercentage,
|
||||||
budgetLimit: step2.budgetLimit,
|
budgetLimit: step2.budgetLimit,
|
||||||
|
seatStubMessages: step2.seatStubMessages,
|
||||||
}),
|
}),
|
||||||
hydrateStep1: step1.hydrateFromDraft,
|
hydrateStep1: step1.hydrateFromDraft,
|
||||||
hydrateStep2: step2.hydrateFromDraft,
|
hydrateStep2: step2.hydrateFromDraft,
|
||||||
@@ -494,6 +495,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
activeArtifactThreadId={step2.activeArtifactThreadId}
|
activeArtifactThreadId={step2.activeArtifactThreadId}
|
||||||
seatStubMessages={step2.seatStubMessages}
|
seatStubMessages={step2.seatStubMessages}
|
||||||
openArtifactFromStep={step2.openArtifactFromStep}
|
openArtifactFromStep={step2.openArtifactFromStep}
|
||||||
|
openArtifactPreview={step2.openArtifactPreview}
|
||||||
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
||||||
setClarificationDraft={step2.setClarificationDraft}
|
setClarificationDraft={step2.setClarificationDraft}
|
||||||
setCustomAgentInput={step2.setCustomAgentInput}
|
setCustomAgentInput={step2.setCustomAgentInput}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
|
||||||
|
* loader. Older drafts won't have this — callers should default to `{}`.
|
||||||
|
*/
|
||||||
|
seatStubMessages?: Record<string, unknown[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftRecord {
|
export interface DraftRecord {
|
||||||
|
|||||||
Reference in new issue
Block a user