# 编辑用户消息并重新发送 — 技术方案 ## 1. 需求概述 ### 1.1 功能定义 支持用户编辑已发送的最后一条用户消息内容,并重新触发 Agent 执行。 ### 1.2 约束条件 - **仅允许编辑最后一条用户消息**:不允许编辑中间的历史消息 - **截断策略**:编辑后删除该消息之后的所有消息(assistant、tool 等),重新生成回复 - **状态要求**:Session 处于非运行状态(IDLE、COMPLETED、FAILED、CANCELLED)时可编辑;RUNNING/RESUMING 状态下禁止编辑 ### 1.3 用户体验 - 交互方式:hover 消息气泡 → 点击编辑按钮 → 进入编辑态 → 修改内容 → 确认发送/取消 - 乐观更新:前端立即截断消息列表,无需等待服务端响应 --- ## 2. 数据库变更 ### 2.1 Message 表新增字段 ```sql -- Flyway 迁移脚本:V25__add_message_updated_at.sql ALTER TABLE message ADD COLUMN updated_at DATETIME NULL COMMENT '消息更新时间,仅用户消息编辑时填充'; ``` ### 2.2 Message 实体类更新 **文件**:`backend/src/main/java/cn/etarch/mao/session/entity/Message.java` 新增字段: ```java @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; ``` --- ## 3. 后端改动 ### 3.1 接口设计 #### 3.1.1 WebSocket 消息类型(主流程) **客户端 → 服务端** ```json { "type": "edit_and_resend", "sessionId": "123", "messageId": "456", "content": "编辑后的新内容", "images": [] } ``` **服务端 → 客户端** 复用现有事件类型: - `content_delta` — 流式文本 - `tool_call_start` / `tool_call_result` — 工具调用 - `session_status` — 会话状态变更 #### 3.1.2 HTTP 接口(备用/调试用) ``` PATCH /api/v1/sessions/{sessionId}/messages/{messageId} Content-Type: application/json Request: { "content": "编辑后的新内容", "images": ["https://oss.example.com/image1.jpg"] } Response: { "code": 0, "data": { "id": 456, "sessionId": 123, "role": "USER", "content": "编辑后的新内容", "createdAt": "2026-06-04T18:20:47", "updatedAt": "2026-06-05T10:30:00" } } ``` ### 3.2 SessionService 新增方法 **文件**:`backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` ```java /** * 编辑用户消息并截断后续消息 * * @param messageId 待编辑的消息ID * @param newContent 新的消息内容 * @param images 新的图片列表(可为空) * @return 更新后的消息对象 * @throws IllegalArgumentException 如果消息不存在或非用户消息 */ public Message editMessageAndTruncate(Long messageId, String newContent, List images) { // 1. 查询目标消息,校验 role=USER Message message = messageMapper.selectById(messageId); if (message == null || !"USER".equals(message.getRole())) { throw new IllegalArgumentException("只能编辑用户消息"); } // 2. 更新消息内容 message.setContent(buildContent(newContent, images)); message.setUpdatedAt(LocalDateTime.now()); messageMapper.updateById(message); // 3. 删除该消息之后的所有消息(按 created_at 降序删除) LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper() .eq(Message::getSessionId, message.getSessionId()) .gt(Message::getCreatedAt, message.getCreatedAt()); messageMapper.delete(deleteWrapper); return message; } /** * 构建消息内容(支持纯文本和多模态) */ private String buildContent(String text, List images) { if (images == null || images.isEmpty()) { return text; } // 多模态内容使用 JSON 数组格式 List> parts = new ArrayList<>(); parts.add(Map.of("type", "text", "text", text)); for (String imageUrl : images) { parts.add(Map.of("type", "image_url", "image_url", imageUrl)); } return JsonUtils.toJsonString(parts); } ``` ### 3.3 StreamingWsHandler 新增处理 **文件**:`backend/src/main/java/cn/etarch/mao/session/ws/StreamingWsHandler.java` 在 `handleTextMessage` 方法中新增 `edit_and_resend` 类型处理: ```java case "edit_and_resend": handleEditAndResend(session, user, messageNode); break; ``` 核心逻辑: ```java private void handleEditAndResend(WebSocketSession session, User user, JsonNode messageNode) { Long sessionId = messageNode.get("sessionId").asLong(); Long messageId = messageNode.get("messageId").asLong(); String content = messageNode.get("content").asText(); List images = parseImages(messageNode); // 1. 校验 session 状态(必须是非运行状态) Session sessionEntity = sessionService.getSession(sessionId); if (sessionEntity == null) { sendError(session, "会话不存在"); return; } TaskPhase phase = TaskPhase.valueOf(sessionEntity.getPhase()); if (phase == TaskPhase.RUNNING || phase == TaskPhase.RESUMING) { sendError(session, "会话正在执行中,无法编辑"); return; } // 2. 校验是否是最后一条用户消息 List messages = sessionService.getMessages(sessionId); Message lastUserMsg = messages.stream() .filter(m -> "USER".equals(m.getRole())) .reduce((a, b) -> b) .orElse(null); if (lastUserMsg == null || !lastUserMsg.getId().equals(messageId)) { sendError(session, "只能编辑最后一条用户消息"); return; } // 3. 编辑消息并截断后续消息 Message editedMessage = sessionService.editMessageAndTruncate(messageId, content, images); // 4. 订阅会话事件 wsRegistry.subscribe(user.getId(), sessionId, session); // 5. 构建多模态内容并提交执行 Object multimodalContent = buildMultimodalContent(content, images, sessionEntity); agentExecutor.submit(() -> executeAgent(sessionId, multimodalContent, sessionEntity.getAgentId(), sessionEntity.getExecutionMode(), user.getId())); } ``` ### 3.4 校验逻辑总结 | 校验项 | 条件 | 错误提示 | |--------|------|----------| | Session 存在 | sessionId 有效 | "会话不存在" | | Session 状态 | phase ∉ {RUNNING, RESUMING} | "会话正在执行中,无法编辑" | | 消息存在 | messageId 有效 | "消息不存在" | | 消息类型 | role = USER | "只能编辑用户消息" | | 最后一条 | 是 session 中最后一条 USER 消息 | "只能编辑最后一条用户消息" | --- ## 4. 前端改动 ### 4.1 类型定义 **文件**:`desktop/src/types/chat.ts` ```typescript // ChatMessage 新增字段 interface ChatMessage { // ... 现有字段 updatedAt?: string // 编辑时间,未编辑则为空 } ``` ### 4.2 MessageBubble 组件 **文件**:`desktop/src/components/chat/MessageBubble.vue` #### 4.2.1 新增 Props ```typescript interface Props { // ... 现有 props isEditing?: boolean canEdit?: boolean onEdit?: () => void onCancelEdit?: () => void onConfirmEdit?: (newContent: string) => void } ``` #### 4.2.2 UI 变更 1. **编辑按钮**:user 消息 hover 时显示铅笔图标按钮 2. **编辑态**:message content 区域切换为 textarea 3. **操作按钮**:编辑态显示确认(✓)和取消(✗)按钮 ``` ┌─────────────────────────────────────────────────────┐ │ 正常态: │ │ ┌─────────────────────────────────────────────┐ │ │ │ 用户消息内容 │ │ │ └─────────────────────────────────────────────┘ │ │ [编辑] [复制] │ ├─────────────────────────────────────────────────────┤ │ 编辑态: │ │ ┌─────────────────────────────────────────────┐ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ 编辑后的消息内容 │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────┘ │ │ [确认] [取消] │ └─────────────────────────────────────────────────────┘ ``` #### 4.2.3 关键代码结构 ```vue