Ctrl K
ComunidadGuías…

docs/plan/2026-06-05-feature-edit-user-message.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 编辑用户消息并重新发送 — 技术方案

## 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<String> 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<Message> deleteWrapper = new LambdaQueryWrapper<Message>()
            .eq(Message::getSessionId, message.getSessionId())
            .gt(Message::getCreatedAt, message.getCreatedAt());
    messageMapper.delete(deleteWrapper);

    return message;
}

/**
 * 构建消息内容(支持纯文本和多模态)
 */
private String buildContent(String text, List<String> images) {
    if (images == null || images.isEmpty()) {
        return text;
    }
    // 多模态内容使用 JSON 数组格式
    List<Map<String, String>> 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<String> 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<Message> 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
<template>
  <div class="message-bubble" @mouseenter="showActions = true" @mouseleave="showActions = false">
    <!-- 正常态 -->
    <div v-if="!isEditing" class="message-content">
      <slot />
    </div>

    <!-- 编辑态 -->
    <div v-else class="message-edit">
      <textarea
        ref="editInput"
        v-model="editContent"
        @keydown.escape="onCancelEdit"
        @keydown.enter.ctrl="handleConfirm"
        rows="3"
      />
      <div class="edit-actions">
        <button @click="handleConfirm" :disabled="!editContent.trim()">
          <CheckIcon /> 确认
        </button>
        <button @click="onCancelEdit">
          <XIcon /> 取消
        </button>
      </div>
    </div>

    <!-- 操作按钮(仅正常态显示) -->
    <div v-if="showActions && !isEditing && canEdit" class="message-actions">
      <button @click="onEdit" title="编辑消息">
        <PencilIcon />
      </button>
      <button @click="handleCopy" title="复制">
        <CopyIcon />
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'

const props = defineProps<{
  isEditing?: boolean
  canEdit?: boolean
  content?: string
  onEdit?: () => void
  onCancelEdit?: () => void
  onConfirmEdit?: (newContent: string) => void
}>()

const editContent = ref(props.content || '')
const editInput = ref<HTMLTextAreaElement>()

watch(() => props.isEditing, async (editing) => {
  if (editing) {
    editContent.value = props.content || ''
    await nextTick()
    editInput.value?.focus()
  }
})

const handleConfirm = () => {
  if (editContent.value.trim()) {
    props.onConfirmEdit?.(editContent.value)
  }
}
</script>
```

### 4.3 useChat composable

**文件**:`desktop/src/composables/useChat.ts`

#### 4.3.1 新增方法

```typescript
/**
 * 编辑最后一条用户消息并重新发送
 */
async function editAndResend(messageId: number, newContent: string, images: string[] = []) {
  if (!sessionId.value) return

  // 1. 校验状态
  if (sending.value) {
    console.warn('会话正在执行中,无法编辑')
    return
  }

  // 2. 校验是否是最后一条用户消息
  const messages = sessionStore.getSessionMessages(sessionId.value)
  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
  if (!lastUserMsg || lastUserMsg.id !== messageId) {
    console.warn('只能编辑最后一条用户消息')
    return
  }

  // 3. 乐观更新:截断后续消息,更新编辑内容
  sessionStore.truncateMessagesAfter(sessionId.value, messageId)
  sessionStore.updateMessageContent(sessionId.value, messageId, newContent, images)

  // 4. 添加空 assistant 占位消息
  const placeholderMsg: ChatMessage = {
    id: Date.now(), // 临时 ID
    role: 'assistant',
    content: '',
    segments: [],
    toolCalls: []
  }
  sessionStore.appendMessage(sessionId.value, placeholderMsg)

  // 5. 通过 WS 发送编辑请求
  const pendingCallback = createPendingCallback()
  ws.send(JSON.stringify({
    type: 'edit_and_resend',
    sessionId: sessionId.value,
    messageId,
    content: newContent,
    images
  }))

  // 6. 等待执行完成
  sending.value = true
  try {
    await pendingCallback.promise
  } finally {
    sending.value = false
  }
}
```

#### 4.3.2 返回值更新

```typescript
return {
  // ... 现有返回值
  editAndResend
}
```

### 4.4 Session Store

**文件**:`desktop/src/stores/session.ts`

#### 4.4.1 新增方法

```typescript
/**
 * 截断指定消息之后的所有消息
 */
function truncateMessagesAfter(sessionId: string, messageId: number) {
  const messages = sessionMessages.get(sessionId)
  if (!messages) return

  const targetIndex = messages.findIndex(m => m.id === messageId)
  if (targetIndex === -1) return

  // 保留目标消息及其之前的消息
  sessionMessages.set(sessionId, messages.slice(0, targetIndex + 1))
}

/**
 * 更新指定消息的内容
 */
function updateMessageContent(
  sessionId: string,
  messageId: number,
  newContent: string,
  images?: string[]
) {
  const messages = sessionMessages.get(sessionId)
  if (!messages) return

  const message = messages.find(m => m.id === messageId)
  if (message) {
    message.content = newContent
    message.images = images
    message.updatedAt = new Date().toISOString()
    // 触发响应式更新
    sessionMessages.set(sessionId, [...messages])
  }
}

/**
 * 追加消息到会话
 */
function appendMessage(sessionId: string, message: ChatMessage) {
  const messages = sessionMessages.get(sessionId) || []
  messages.push(message)
  sessionMessages.set(sessionId, messages)
}
```

#### 4.4.2 mapApiMessagesToChat 适配

**文件**:`desktop/src/utils/chatMessage.ts`

在 `mapApiMessagesToChat` 函数中处理 `updatedAt` 字段:

```typescript
export function mapApiMessagesToChat(apiMessages: ApiMessage[]): ChatMessage[] {
  // ... 现有逻辑

  return chatMessages.map(msg => ({
    // ... 现有映射
    updatedAt: msg.updatedAt || undefined
  }))
}
```

### 4.5 TaskView 适配

**文件**:`desktop/src/views/task/TaskView.vue`

#### 4.5.1 新增状态

```typescript
const editingMessageId = ref<number | null>(null)
```

#### 4.5.2 模板变更

```vue
<template>
  <!-- 用户消息渲染 -->
  <MessageBubble
    v-if="msg.role === 'user'"
    :message="msg"
    :show-time="true"
    :can-edit="canEditMessage(msg)"
    :is-editing="editingMessageId === msg.id"
    :content="msg.content"
    @edit="startEdit(msg)"
    @cancel-edit="cancelEdit"
    @confirm-edit="confirmEdit(msg.id, $event)"
  >
    <!-- 消息内容 -->
  </MessageBubble>
</template>
```

#### 4.5.3 方法定义

```typescript
const canEditMessage = (msg: ChatMessage) => {
  // 只允许编辑最后一条用户消息,且 session 未在运行中
  const messages = sessionStore.getSessionMessages(sessionId.value!)
  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
  return lastUserMsg?.id === msg.id && !useChatInstance.sending.value
}

const startEdit = (msg: ChatMessage) => {
  editingMessageId.value = msg.id
}

const cancelEdit = () => {
  editingMessageId.value = null
}

const confirmEdit = async (messageId: number, newContent: string) => {
  editingMessageId.value = null
  await useChatInstance.editAndResend(messageId, newContent)
}
```

---

## 5. 交互流程

### 5.1 正常流程

```
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   前端       │     │   后端       │     │   Agent     │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       │  1. 用户点击编辑    │                   │
       │  进入编辑态        │                   │
       │                   │                   │
       │  2. 乐观更新UI    │                   │
       │  截断消息列表      │                   │
       │                   │                   │
       │  3. WS: edit_and_resend              │
       │  ───────────────> │                   │
       │                   │                   │
       │                   │  4. 校验session状态 │
       │                   │  校验是否最后一条   │
       │                   │                   │
       │                   │  5. 编辑消息       │
       │                   │  截断后续消息       │
       │                   │                   │
       │                   │  6. 提交Agent执行   │
       │                   │  ───────────────> │
       │                   │                   │
       │  7. 流式返回       │  8. Agent执行     │
       │  content_delta    │  ───────────────> │
       │  <─────────────── │                   │
       │                   │                   │
       │  9. session_status │ 10. 执行完成      │
       │  (COMPLETED)      │ <─────────────── │
       │  <─────────────── │                   │
       │                   │                   │
       │  11. 更新UI       │                   │
       │  显示完整回复       │                   │
       │                   │                   │
```

### 5.2 异常流程

#### 5.2.1 Session 正在执行中

```
前端: 发送 edit_and_resend
后端: 检测到 phase = RUNNING
后端 → 前端: error "会话正在执行中,无法编辑"
前端: 恢复消息列表(取消乐观更新)
```

#### 5.2.2 非最后一条用户消息

```
前端: 发送 edit_and_resend
后端: 校验发现不是最后一条 USER 消息
后端 → 前端: error "只能编辑最后一条用户消息"
前端: 恢复消息列表(取消乐观更新)
```

---

## 6. 边界情况处理

| 场景 | 处理方式 |
|------|----------|
| 编辑内容为空 | 禁用确认按钮,不允许发送 |
| 编辑后取消 | 恢复原始消息内容,不触发任何后端操作 |
| 网络断开 | 乐观更新后无法完成,需提示用户刷新 |
| 多设备同时操作 | 后端校验最后一条消息逻辑保证一致性 |
| 编辑态时收到新消息 | 自动退出编辑态,显示新消息 |
| 图片编辑 | 编辑态支持重新上传/删除图片 |

---

## 7. 测试要点

### 7.1 后端测试

- [ ] 编辑最后一条用户消息成功
- [ ] 编辑非最后一条用户消息被拒绝
- [ ] 编辑时 session 运行中被拒绝
- [ ] 编辑后截断逻辑正确
- [ ] 多模态内容编辑

### 7.2 前端测试

- [ ] hover 显示编辑按钮(仅最后一条 user 消息)
- [ ] 进入编辑态,textarea 预填原内容
- [ ] Esc 取消编辑
- [ ] Ctrl+Enter / 点击确认发送
- [ ] 乐观更新立即反映 UI 变化
- [ ] 流式回复正常渲染
- [ ] 编辑态时自动退出(收到新消息)

---

## 8. 实施顺序

1. **后端优先**
   - 数据库迁移(V25)
   - Message 实体更新
   - SessionService.editMessageAndTruncate 方法
   - StreamingWsHandler 处理 edit_and_resend

2. **前端跟进**
   - 类型定义更新
   - Session Store 新增方法
   - useChat.editAndResend 方法
   - MessageBubble 编辑态 UI
   - TaskView 集成

3. **联调测试**
   - 完整流程验证
   - 异常场景覆盖

---

## 9. 设计决策记录

| 决策项 | 选择 | 原因 |
|--------|------|------|
| 截断 vs 分支 | 截断 | 简单清晰,与当前线性消息模型契合 |
| 编辑范围 | 仅最后一条 | 降低复杂度,符合常见交互模式 |
| 主流程通道 | WebSocket | 复用现有流式架构,避免 HTTP+WS 两次调用 |
| 乐观更新 | 是 | 提升用户体验,减少等待感 |
| updatedAt 字段 | 新增 | 标记编辑历史,支持未来审计需求 |