# 队列消息撤回编辑 技术方案 ## 1. 需求背景 消息队列功能上线后(见 `2026-06-05-message-queue-design.md`),Agent 执行中用户发送的消息会进入队列等待自动消费。当前队列消息仅支持上移/下移/删除/立即发送,不支持编辑——用户发现已入队的消息写错了,只能删除后凭记忆重新输入,带图片的消息还要重新上传。 会话内最后一条用户消息的编辑能力已由 `edit_and_resend` 实现(`2026-06-05-feature-edit-user-message.md`),但它要求 Agent 空闲,对队列中的消息完全不适用。 ## 2. 需求描述 ### 2.1 要做的 - 队列面板中每条待发送消息提供「编辑」入口 - 点击编辑后,该消息从队列中删除,文本与图片回填到底部输入框,用户修改后重新发送 - 回填时若输入框已有未发送草稿,弹确认框告知将被覆盖,用户确认后才执行撤回 - 图片以「fetch URL → 转 File」方式回填到附件区,完全复用现有附件预览/删除/重发链路 - 重新发送时走现有 `sendMessageWithQueue` 链路:Agent 忙则排到队尾,Agent 空闲则直接执行 - desktop 端主聊天面板(ChatPanel)与边路任务面板(SideChatPanel)均支持 ### 2.2 不做的 - **不做队列内联编辑**(不提供队列条目原位展开 textarea 的编辑态) - **不做「编辑中」锁定状态**(原条目立即删除,不做置灰锁定、不做多端编辑锁同步) - **不做插回原位置**(编辑后重发一律排队尾,不新增指定位置入队协议) - **不改任何后端代码与 WS 协议**(撤回复用 `delete_queue_message`,重发复用 `enqueue_message`) - **不做 mao-agent CLI 的 `/queue` 编辑**(终端本地内存队列生命周期极短,不纳入范围) - **不做 admin 端改动** - **不做切换会话时的草稿保护**(撤回内容与普通草稿同等对待,切会话不清空不提示) ## 3. 现状分析(代码级事实) ### 3.1 队列链路现状 | 环节 | 位置 | 说明 | |------|------|------| | 入队 | `backend-ts/src/session/ws/streaming-ws-handler.ts` `handleEnqueueMessage` | 消息持久化到 `message_queue` 表,图片在上传后以 URL 存库 | | 删除 | `streaming-ws-handler.ts:802` `handleDeleteQueueMessage` | 校验归属后逻辑删除,广播 `queue_updated` | | 自动消费 | Agent 执行完成回调 dequeue 头部消息 | 发送即移除 | | 前端面板 | `desktop/src/components/chat/QueuePanel.vue` | 主面板与边路面板共用,emit `insert/delete/reorder` | | 前端接线 | `ChatPanel.vue:48`、`SideChatPanel.vue:53` | `` | ### 3.2 输入框现状 - `ChatInput.vue:296` 待发附件为本地 `File[]`(`pendingFiles`),预览用 `filePreviewUrls` - `ChatInput.vue:1111` 已 expose `focusInput / insertFileReference / clearInput`,其中 `clearInput()` 统一清理文本与附件 - 草稿在切换会话时不被清空(无跨会话清空逻辑) ### 3.3 关键差异 队列消息的图片是**已上传的 URL**,输入框附件是**本地 File 对象**。撤回回填必须完成 URL→File 的形态转换,转换后重发会再上传一次(产生一份重复存储副本,可接受)。 ## 4. 技术选型与决策记录 | # | 决策项 | 结论 | 理由 | |---|--------|------|------| | 1 | 编辑交互 | **撤回到输入框** | 复用输入框全部编辑能力(富文本、@引用、附件管理),实现成本最低 | | 2 | 原条目去向 | **立即删除** | 实现简单;丢失风险由草稿冲突确认兜底 | | 3 | 图片回填 | **fetch 转 File** | 完全复用现有附件链路,改动集中一处;代价是重发时重新上传一次 | | 4 | 重发位置 | **排到队尾** | 零新增协议,复用 `enqueue_message` | | 5 | 草稿冲突 | **覆盖前弹确认** | 兼顾安全与流畅 | | 6 | 功能范围 | **仅 desktop** | Web/Electron/安卓三端共用 UI 自然生效 | | 7 | 切换会话 | **不特殊处理** | 与现有草稿行为一致,零额外逻辑 | ## 5. 整体设计 ### 5.1 撤回流程 ``` 用户点击队列条目「编辑」 → ChatPanel.handleQueueEdit(msg) → ① chatInputRef.hasDraft() 为 true? → 是:ElMessageBox.confirm「输入框已有内容,撤回将覆盖,是否继续?」 取消 → 结束(队列条目不动) → 否:继续 → ② 并发 fetch 全部图片 URL → Blob → File[] 任一失败 → ElMessage.error 提示,结束(队列条目保留,不丢数据) → ③ useChat.deleteQueueMessage(msg.id) // 现有方法,后端广播 queue_updated → ④ chatInputRef.restoreContent(text, files) (内部先 clearInput() 再回填 editorContent + pendingFiles) → ⑤ chatInputRef.focusInput() ``` ### 5.2 重发流程(全部复用现有链路,零改动) ``` 用户修改后点击发送 → ChatInput emit send → sendMessageWithQueue(text, files) → Agent 忙碌 → enqueueMessage → 图片重新上传 → WS enqueue_message → 排到队尾 → Agent 空闲 → sendMessage 直接执行 ``` ### 5.3 竞态说明(接受,不做防护) 从点击编辑到步骤③删除完成的秒级窗口内,若该消息恰好被自动消费(Agent 完成、轮到它出队),删除请求会落空而输入框仍会回填内容——用户若再点发送会产生重复消息。该窗口极短、后果轻微(多一条消息),**不做加锁或二次校验**,删除固定在回填之前执行以缩小窗口。 ## 6. 实现步骤 ### 步骤 1:ChatInput 增加 expose 方法 **文件**:`desktop/src/components/chat/ChatInput.vue` `defineExpose`(当前 1111 行)增加两个方法: ```typescript /** 输入框是否有未发送内容(文本或附件) */ function hasDraft(): boolean { return editorContent.value.trim().length > 0 || pendingFiles.value.length > 0 } /** 清空当前内容后回填文本与图片附件(图片需已完成 URL→File 转换) */ function restoreContent(text: string, files: File[]) { clearInput() if (text) { editor.value?.commands.setContent(`

${escapeHtml(text)}

`) editorContent.value = text } for (const file of files) addPendingImageFile(file) // 复用现有粘贴/选择图片的入列逻辑(生成预览 URL) } ``` `defineExpose({ focusInput, insertFileReference, clearInput, hasDraft, restoreContent })` 注意:富文本回填需对原文做 HTML 转义,防止队列文本中的 `<` `>` 破坏编辑器文档结构。 ### 步骤 2:URL→File 工具函数 **文件**:`desktop/src/utils/file.ts`(新建;ChatPanel 与 SideChatPanel 两处使用,不放组件内) ```typescript /** 将图片 URL 转为 File;任一失败即整体抛错,由调用方决定中止 */ export async function fetchImagesAsFiles(urls: string[]): Promise { const files: File[] = [] for (const url of urls) { const resp = await fetch(url) if (!resp.ok) throw new Error(`图片获取失败: ${url}`) const blob = await resp.blob() const name = url.split('/').pop()?.split('?')[0] || `image-${Date.now()}.png` files.push(new File([blob], name, { type: blob.type || 'image/png' })) } return files } ``` 串行 fetch 即可(队列消息图片上限 10 张,量小),避免并发打满连接。 ### 步骤 3:QueuePanel 增加编辑按钮 **文件**:`desktop/src/components/chat/QueuePanel.vue` - 操作区(`queue-item-actions`)在「立即发送」左侧新增编辑按钮(铅笔图标,样式复用 `.action-btn`) - emits 增加 `edit: [msg: QueueMessage]`(传整条消息,调用方需要 text 与 images 两个字段) ### 步骤 4:ChatPanel 接线 **文件**:`desktop/src/components/chat/ChatPanel.vue` ```typescript async function handleQueueEdit(msg: QueueMessage) { if (chatInputRef.value?.hasDraft()) { try { await ElMessageBox.confirm('输入框已有未发送内容,撤回将覆盖,是否继续?', '编辑队列消息', { confirmButtonText: '覆盖并编辑', cancelButtonText: '取消', type: 'warning', }) } catch { return } } let files: File[] = [] try { files = msg.images?.length ? await fetchImagesAsFiles(msg.images) : [] } catch { ElMessage.error('图片获取失败,已取消编辑') return } await deleteQueueMessage(msg.id) chatInputRef.value?.restoreContent(msg.content, files) nextTick(() => chatInputRef.value?.focusInput()) } ``` 模板 ``(48 行处)增加 `@edit="handleQueueEdit"`。 ### 步骤 5:SideChatPanel 接线 **文件**:`desktop/src/components/chat/SideChatPanel.vue` 与步骤 4 相同的逻辑接入其自身的 QueuePanel 与 ChatInput ref(边路面板的 QueuePanel 带 `sessionId` prop,其余一致)。 ### 步骤 6:构建与回归 - `cd desktop && npx vue-tsc --noEmit && npm run build` 通过 - `cd backend-ts && npm test` 回归(零后端改动,确认无意外破坏) ## 7. 边界情况 | 场景 | 行为 | |------|------| | 输入框有草稿时点编辑 | 弹确认框;取消则什么都不发生 | | 图片 fetch 失败(网络抖动/URL 过期) | toast 提示,中止撤回,队列条目保留 | | 撤回后 Agent 变空闲再发送 | 直接执行不入队(`sendMessageWithQueue` 现有判断) | | 撤回后切换会话再发送 | 消息发到新会话,与普通草稿行为一致,不拦截 | | 撤回瞬间消息被自动消费 | 见 5.3 竞态说明,接受 | | 带图片消息撤回后重发 | 图片重新上传,产生新的存储副本,原副本成为孤儿(不做清理) | | 队列只剩这一条消息 | 撤回后面板隐藏(`v-if="queueMessages.length > 0"` 现有逻辑),输入框正常回填 | ## 8. 测试要点 手动验证(桌面端): - [ ] Agent 忙碌时入队 A、B 两条 → 点 B 的编辑 → B 从队列消失,输入框回填 B 的文本,附件区出现 B 的图片缩略图 - [ ] 输入框有草稿时点编辑 → 出现覆盖确认框 → 取消无任何变化;确认后草稿被替换 - [ ] 修改内容后发送,Agent 仍忙碌 → 消息出现在队列末尾 - [ ] 修改内容后发送,Agent 已空闲 → 消息直接执行 - [ ] 断网状态下点带图消息的编辑 → toast 报错,队列条目仍在 - [ ] 撤回后刷新页面 → 队列中无该条目(已删除持久化) - [ ] 边路任务面板重复以上核心路径 - [ ] 含 HTML 特殊字符(如 `
`)的队列消息撤回后文本完整显示 自动化: - backend-ts 单测无需新增(零后端改动),跑全量回归 - 根目录 Playwright 不强制覆盖本功能(队列场景依赖 Agent 运行态,成本高) ## 9. 落地清单 | 文件 | 动作 | 内容 | |------|------|------| | `desktop/src/components/chat/ChatInput.vue` | 修改 | 新增 `hasDraft()`、`restoreContent()` 并加入 defineExpose | | `desktop/src/utils/file.ts` | 新建 | `fetchImagesAsFiles()` | | `desktop/src/components/chat/QueuePanel.vue` | 修改 | 操作区新增编辑按钮,emits 增加 `edit` | | `desktop/src/components/chat/ChatPanel.vue` | 修改 | `handleQueueEdit()` + `@edit` 接线 | | `desktop/src/components/chat/SideChatPanel.vue` | 修改 | 同 ChatPanel 的接线 | | `backend-ts/**` | **无改动** | — | | `agent-cli/**` | **无改动** | — | | `admin/**` | **无改动** | — | | `CHANGELOG.md` | 实现合入时 | 在当期版本「前端」小节记录:队列消息支持撤回到输入框编辑 | ## 10. 设计决策记录 | 决策项 | 选择 | 原因 | |--------|------|------| | 撤回 vs 内联编辑 | 撤回 | 复用输入框能力,避免在窄条队列条目里再做一套编辑态 UI | | 删除时机 | 回填之前 | 缩小自动消费竞态窗口;删除失败(已被消费)时输入框仍有副本供用户处置 | | 图片形态转换位置 | 前端 fetch | 后端存 URL 是既定事实,前端转换让附件链路零改造 | | 协议 | 零新增 | `delete_queue_message` + `enqueue_message` 语义完全匹配,不动后端 |