docs/plan/2026-06-08-desktop-session-refactoring-plan.md
Rama main · réplica de lectura del repositorio original.
# 桌面客户端会话代码重构方案
> 目标:消除状态管理的拧巴之处,减少代码重复,功能不变。
## 目录
- [背景与现状](#背景与现状)
- [问题清单](#问题清单)
- [重构设计](#重构设计)
- [1. 统一 phase 与本地 pending 状态模型](#1-统一-phase-与本地-pending-状态模型)
- [2. 引入 turnTracker,移除 pendingCallbacks 双写](#2-引入-turntracker移除-pendingcallbacks-双写)
- [3. 统一会话切换入口](#3-统一-会话切换入口)
- [4. 提取 sendMessage/editAndResend 共享流程](#4-提取-sendmessageeditandresend-共享流程)
- [5. 清理审批组件遗留代码](#5-清理-审批组件遗留代码)
- [6. Store Map 响应式优化](#6-store-map-响应式优化)
- [7. 消除常量重复定义](#7-消除-常量重复定义)
- [执行顺序与依赖关系](#执行顺序与依赖关系)
- [风险与验证要点](#风险与验证要点)
---
## 背景与现状
桌面客户端的会话逻辑分布在三个核心文件中:
| 文件 | 行数 | 职责 |
|------|------|------|
| `composables/useChat.ts` | 563 | 聊天编排:发送/取消/审批/队列/会话切换 |
| `composables/useStreamWS.ts` | 472 | WebSocket 连接管理 + 事件路由 |
| `stores/session.ts` (store) | 558 | 多会话状态缓存 |
| `views/task/TaskView.vue` | 736 | 主视图:渲染/滚动/轮次折叠/路由联动 |
当前代码功能完整,但存在 **状态双轨、职责错位、路径过多** 三类结构性问题,导致每次修改执行相关逻辑时都需要在 5+ 个位置检查竞态。
---
## 问题清单
| # | 问题 | 严重度 | 位置 |
|---|------|--------|------|
| 1 | `sending`/`cancelling` 与 `phase` 双轨跟踪同一状态,5 个写入点互相冲突 | P0 | composables/useChat.ts + views/task/TaskView.vue |
| 2 | `pendingCallbacks` 定义在 useStreamWS,被 useChat 和 routeEvent 双方读写 | P1 | composables/useStreamWS.ts + composables/useChat.ts |
| 3 | 会话切换有 3 条路径(onMounted / watcher / sendMessage),轻量路径重复 restoreSession 逻辑 | P1 | views/task/TaskView.vue |
| 4 | `sendMessage` 与 `editAndResend` 后半段(connect → subscribe → await callback → refresh)完全重复 | P2 | composables/useChat.ts |
| 5 | `ApprovalStack` 与 `ToolApprovalBar` 两个审批组件并存,类型定义分散在 3 处 | P2 | components/chat/ |
| 6 | Store 的 9 个独立 `ref<Map>` 每次 mutation 需手动 spread 触发响应式 | P3 | stores/session.ts |
| 7 | `TASK_TOOL_NAMES` 在 chatMessage.ts 和 session.ts 各定义一次 | P3 | 两处 |
---
## 重构设计
### 1. 统一 phase 与本地 pending 状态模型
#### 问题详解
`sending` 当前被 5 个入口写入:
```
sendMessage() → true (useChat.ts:146)
sendMessage() catch → false (useChat.ts:225)
phase watcher RUNNING → true (useChat.ts:348)
phase watcher terminal → false (useChat.ts:354)
restoreSession() → true/false (useChat.ts:475-506)
pendingCallback.then → false (useChat.ts:487)
```
为了防止它们互相冲突,又引入了 `switchingSession`(useChat.ts:36)和 `sendingSessionId`(useChat.ts:37)两个哨兵 flag。
TaskView.vue 中 `currentPhase` 同样被两个 watcher 双向同步(:341-352 和 :355-359)。
#### 设计方案
**核心原则:`phase` 是后端执行状态的唯一事实来源;客户端额外保留一个很小的本地 pending 状态,用来覆盖“用户已点击发送,但后端尚未回 RUNNING”的窗口。**
不能简单删除 `sendMessage()` 入口的 `sending.value = true`。当前发送链路在上传图片、创建 session、WS 发送之前就需要禁用重复提交,否则会出现连续点击导致重复创建会话或重复发送的问题。
推荐拆成两个概念:
| 概念 | 含义 | 来源 |
|------|------|------|
| `phase` | 后端确认的会话执行阶段 | `session_status` / session API |
| `localPendingSessionId` | 客户端已发起本轮发送,但还没收到后端终态 | `sendMessage` / `editAndResend` 本地设置,终态清除 |
| `localCancellingSessionId` | 用户已点击停止,但还没收到 `CANCELLING` / 终态 | `stopExecution` 本地设置,终态清除 |
新增公共 phase 判断函数:
```typescript
// domain/session/phase.ts
import type { TaskPhase } from '../../stores/session'
/** 后端正在执行的 phase(agent 在跑或正在取消) */
export const EXECUTING_PHASES = new Set<TaskPhase>(['RUNNING', 'RESUMING', 'CANCELLING'])
/** session 处于活跃状态(包括等待审批),可用于判断是否走队列路径 */
export const ACTIVE_PHASES = new Set<TaskPhase>(['RUNNING', 'RESUMING', 'WAITING_APPROVAL', 'CANCELLING'])
export const TERMINAL_PHASES = new Set<TaskPhase>(['COMPLETED', 'FAILED', 'CANCELLED', 'IDLE'])
/** 后端正在执行(不含 WAITING_APPROVAL),用于 sending computed */
export function isExecutingPhase(phase?: TaskPhase | null): boolean {
return !!phase && EXECUTING_PHASES.has(phase)
}
export function isActivePhase(phase?: TaskPhase | null): boolean {
return !!phase && ACTIVE_PHASES.has(phase)
}
export function isTerminalPhase(phase?: TaskPhase | null): boolean {
return !!phase && TERMINAL_PHASES.has(phase)
}
```
> **关键区分**:`isExecutingPhase`(RUNNING/RESUMING/CANCELLING)用于 `sending` computed,控制 UI 是否锁定;`isActivePhase`(额外包含 WAITING_APPROVAL)用于 `isActive` computed,控制消息走队列还是直发。当前代码中 `sending` 和 `isActive` 是两个独立 computed,语义不同——`WAITING_APPROVAL` 期间用户可以排队消息,UI 不应锁定。
`useChat.ts` 中 `sending` / `cancelling` 改为派生状态:
```typescript
const localPendingSessionId = ref<string | null>(null)
const localCancellingSessionId = ref<string | null>(null)
const activePhase = computed(() => sessionStore.activeSession?.phase ?? 'IDLE')
// sending:UI 锁定(禁用输入框)。使用 isExecutingPhase,排除 WAITING_APPROVAL。
// WAITING_APPROVAL 期间用户可以排队消息,UI 不应锁定。
const sending = computed(() => {
const activeId = sessionStore.activeSessionId
return isExecutingPhase(activePhase.value) ||
(!!activeId && localPendingSessionId.value === String(activeId))
})
// isActive:session 活跃(含审批),决定消息走队列还是直发。
// 保留独立的 isActive,不合并到 sending 中。
const isActive = computed(() => isActivePhase(activePhase.value))
const cancelling = computed(() => {
const activeId = sessionStore.activeSessionId
return activePhase.value === 'CANCELLING' ||
(!!activeId && localCancellingSessionId.value === String(activeId))
})
```
`sendMessage` 入口保留本地 pending,但不再直接把后端状态写成 running:
```typescript
async function sendMessage(text: string, files?: File[]) {
if ((!text && (!files || files.length === 0)) || sending.value) return
// session 创建前还没有 sid,先用一个临时哨兵防重复点击
localPendingSessionId.value = sessionId.value || '__creating__'
startedAt.value = new Date().toISOString()
try {
// ... upload images, create session if needed ...
const sid = sessionId.value!
localPendingSessionId.value = sid
// ... optimistic messages, subscribe, ws send ...
await waitForSessionTurn(sid, eventId)
} finally {
// 终态通常由 turnTracker 清理,这里兜底清理当前本地 pending
if (localPendingSessionId.value === sessionId.value || localPendingSessionId.value === '__creating__') {
localPendingSessionId.value = null
}
}
}
```
`stopExecution` 保留本地 cancelling,以便点击后 UI 立即反馈。需要处理 `'__creating__'` 阶段(session 尚未创建、`sessionId.value` 为 null):此时无法向服务端发取消请求,直接清除本地 pending 即可。
```typescript
function stopExecution() {
if (!sessionId.value) {
// Session still being created (localPendingSessionId === '__creating__').
// Cannot send cancel to server — just clear local pending state so UI unlocks.
localPendingSessionId.value = null
// 不设置 localCancellingSessionId(没有 sessionId 可用),
// sending computed 会因 localPendingSessionId 为 null 而回到 false。
return
}
localCancellingSessionId.value = sessionId.value
wsCancel(sessionId.value)
}
```
> **注意**:`cancelling` computed 依赖 `localCancellingSessionId`,在 `__creating__` 分支中不会被设置,因此 `cancelling` 保持 false——这是正确的行为,因为没有服务端请求可取消,UI 应直接解锁而非显示"取消中"。
终态清理不放在 active session watcher 中,而是在 WebSocket 事件按 `sessionId` 处理时统一触发(见第 2 节)。这样即使用户切到别的会话,后台会话完成也能正确清理 pending turn。
**`restoreSession` 简化**(当前 50 行 → ~30 行):
注意:不能直接删除当前 `restoreSession` 中 484-506 行的 phase 同步 + callback 注册逻辑。这段代码是**队列自动消费**的核心:用户发多条消息排队,第一条完成后后端自动消费下一条,phase 从 COMPLETED 跳回 RUNNING。此时 `sendMessage` 的 Promise 已 resolve,如果没有在 `restoreSession` 中重新注册 callback,新的 RUNNING 状态就没有对应的等待者。用 `turnTracker` 的 `hasPendingTurn` + `waitForSessionTurn` 替代直接操作 `pendingCallbacks`:
```typescript
async function restoreSession(sessionIdVal: string, mode: string, initialWorkspace?: string) {
// Unsubscribe from previous session
if (sessionId.value && sessionId.value !== sessionIdVal) {
unsubscribe(sessionId.value)
}
sessionId.value = sessionIdVal
executionMode.value = mode
if (initialWorkspace) workspace.value = initialWorkspace
sessionStore.setActiveSession(sessionIdVal)
// Ensure WS connection
try { await connect() } catch { /* best-effort */ }
subscribe(sessionIdVal)
// Restore pending turn tracking for active sessions.
// Covers queue auto-consume: when one turn completes and the backend
// automatically starts the next queued message, phase goes COMPLETED→RUNNING.
// Without re-registering a pending turn here, the new RUNNING phase has no
// corresponding Promise waiter.
const phase = sessionStore.activeSession?.phase
if (isActivePhase(phase) && !hasPendingTurn(sessionIdVal)) {
waitForSessionTurn(sessionIdVal) // no eventId — any terminal event resolves
}
fetchMessages()
fetchTodos()
fetchQueue()
}
```
> **注意**:`restoreSession` 不再设置 `sending.value`——`sending` 由 computed 自动派生(`isExecutingPhase(phase)` || `localPendingSessionId` 匹配)。切到正在执行的 session 时,phase 为 RUNNING → `isExecutingPhase` 为 true → `sending` 自动为 true。
**`sendMessage` 简化** — 移除所有 `sending.value = true/false` 赋值(`sending` 现在是 computed),改用 `localPendingSessionId` 驱动:
```typescript
async function sendMessage(text: string, files?: File[]) {
if ((!text && (!files || files.length === 0)) || sending.value) return
localPendingSessionId.value = sessionId.value || '__creating__'
startedAt.value = new Date().toISOString()
// ... session creation, image upload, WS send ...
// 等待完成 — turnTracker 会在终态/error 时 resolve/reject
await waitForSessionTurn(sid, eventId)
// ... refresh, durationMs ...
}
```
**`stopExecution` 简化** — 不写后端 phase,但保留本地 cancelling。处理 session 尚未创建的情况:
```typescript
function stopExecution() {
if (!sessionId.value) {
localPendingSessionId.value = null // '__creating__' 阶段,直接清除
return
}
localCancellingSessionId.value = sessionId.value
wsCancel(sessionId.value)
}
```
**删除 `useChat.ts` 的 phase watcher(:336-367),替换为轻量的队列自动消费 watcher**:
当前 phase watcher 职责:
1. 终态时重置 sending/cancelling、resolve pendingCallbacks、fetchSession/fetchMessages → **由 turnTracker + routeEvent 替代**(见第 2 节)
2. RUNNING/WAITING_APPROVAL 时设置 sending=true 并注册新 pendingCallback(队列自动消费) → **保留,但简化为只处理队列自动消费**
```typescript
// 替代原有 phase watcher — 仅处理队列自动消费
// 终态处理已移至 turnTracker(routeEvent 调用 resolveSessionTurn)
// sending 由 computed 自动派生,无需手动设置
watch(() => sessionStore.activeSession?.phase, (phase, oldPhase) => {
if (oldPhase === undefined) return // session 切换,非 phase 变化
if (phase === oldPhase) return // phase 未变
// 队列自动消费:phase 从 COMPLETED 跳回 RUNNING,但 sendMessage 的 Promise
// 已 resolve,需要注册新的 pending turn 以等待下一轮完成。
// 如果已有 pending turn(sendMessage 注册的),跳过。
if ((phase === 'RUNNING' || phase === 'WAITING_APPROVAL') &&
sessionId.value && !hasPendingTurn(sessionId.value)) {
waitForSessionTurn(sessionId.value) // turnTracker 会在终态 resolve
}
})
```
> **为什么保留这个 watcher**:队列自动消费时,后端在当前 turn 完成后自动开始下一条消息,phase 从 COMPLETED 直接跳回 RUNNING。此时 `sendMessage` 的 Promise 已 resolve,如果没有新的 pending turn 注册,`sending` computed 会因 `isExecutingPhase` 为 true 保持正确值(UI 不锁),但没有 Promise 等待者来触发 turn 完成后的 refresh 逻辑。这个 watcher 确保新 turn 有对应的等待者。
**TaskView.vue 简化** — 删除 `currentPhase` ref 和两个 watcher:
```typescript
// 删除:
// const currentPhase = ref<TaskPhase>('IDLE')
// watch(() => sending.value, ...) // :341-352
// watch(() => sessionStore.activeSession?.phase, ...) // :355-359
// 替换为 computed:
const currentPhase = computed(() => sessionStore.activeSession?.phase ?? 'IDLE')
```
**删除的变量/flag**:
| 变量 | 原因 |
|------|------|
| `switchingSession` | 原 phase watcher 已删除终态处理逻辑,新 watcher 只处理队列自动消费,不需要抑制 |
| `sendingSessionId` | 被 `turnTracker` + live message 判断替代(见第 3 节) |
---
### 2. 引入 turnTracker,移除 pendingCallbacks 双写
#### 问题详解
`pendingCallbacks` 定义在 `useStreamWS.ts`(:27-32),但被多个位置操作:
| 位置 | 操作 |
|------|------|
| `useChat.sendMessage` | `.set(sid, {resolve, reject})` |
| `useChat.restoreSession` | `.set(sid, {resolve, reject})` |
| `useChat` phase watcher | `.set()` / `.delete()` / `.resolve()` |
| `useStreamWS` routeEvent session_status | `.delete()` / `.resolve()` |
| `useStreamWS` routeEvent error | `.delete()` / `.reject()` |
#### 设计方案
不要把 `pendingCallbacks` 移入组件级 `useChat.ts`,也不要给 `useStreamWS(opts)` 传回调。原因是 `useStreamWS` 是模块级 WebSocket 单例,`TaskView`、`useChat` 等多个调用方都可能调用它;如果第一次建立连接的调用没有传 opts,终态回调就会丢失。
推荐新增独立 `turnTracker` 模块。它不是组件状态,不依赖 `useChat` 生命周期,WebSocket routeEvent 可以稳定调用它。
```typescript
// domain/session/turnTracker.ts
interface PendingTurn {
sessionId: string
eventId?: string
resolve: () => void
reject: (err: Error) => void
}
const pendingTurns = new Map<string, PendingTurn>()
const terminalListeners = new Set<(sessionId: string) => void>()
export function waitForSessionTurn(sessionId: string, eventId?: string): Promise<void> {
return new Promise((resolve, reject) => {
pendingTurns.set(String(sessionId), { sessionId: String(sessionId), eventId, resolve, reject })
})
}
export function resolveSessionTurn(sessionId: string) {
const sid = String(sessionId)
const turn = pendingTurns.get(sid)
if (turn) {
pendingTurns.delete(sid)
turn.resolve()
}
terminalListeners.forEach(listener => listener(sid))
}
export function rejectSessionTurn(sessionId: string, err: Error) {
const sid = String(sessionId)
const turn = pendingTurns.get(sid)
if (turn) {
pendingTurns.delete(sid)
turn.reject(err)
}
terminalListeners.forEach(listener => listener(sid))
}
export function hasPendingTurn(sessionId: string): boolean {
return pendingTurns.has(String(sessionId))
}
export function onTurnSettled(listener: (sessionId: string) => void): () => void {
terminalListeners.add(listener)
return () => terminalListeners.delete(listener)
}
```
**关于 `onSending` 回调**:当前 `pendingCallbacks` 的值类型包含 `onSending?: () => void`,但实际代码中没有任何调用方设置过该字段。`turnTracker` 的 `PendingTurn` 接口故意省略了它。迁移时可安全删除。如果未来需要"服务端确认 RUNNING"的回调,应在 `turnTracker` 中新增,而非恢复旧的双写模式。
`useStreamWS.ts` 中按事件 `sessionId` 处理终态:
```typescript
case 'session_status':
if (sessionId) {
sessionStore.updateSessionPhase(sessionId, data.phase as TaskPhase)
// unread handling...
if (isTerminalPhase(data.phase)) {
sessionStore.setStreaming(sessionId, false)
resolveSessionTurn(sessionId)
// 仅 active session 需要 fetchSession + fetchMessages
// 后台 session 的终态只需更新 phase,无需额外 API 调用
if (sessionId === sessionStore.activeSessionId) {
sessionStore.fetchSession(sessionId)
// fetchMessages 由 useChat 的 onTurnSettled 回调触发,
// 确保在 turnTracker resolve 之后执行,与 turn 完成的时序一致
}
}
}
break
case 'error':
if (sessionId) {
sessionStore.updateSessionPhase(sessionId, 'FAILED' as TaskPhase)
rejectSessionTurn(sessionId, new Error(data.message || 'Agent 执行异常'))
}
break
```
> **`fetchSession` 仅限 active session**:当前 `fetchSession` 仅在 active session 的终态时被调用。新方案保持相同行为——在 `routeEvent` 中加 `sessionId === sessionStore.activeSessionId` 条件,避免后台 session 终态触发不必要的 API 调用。`fetchMessages` 不在 `routeEvent` 中调用,而是由 `useChat` 的 `onTurnSettled` 回调触发(见下文),确保与 turn 完成的时序一致。
`useChat.ts` 只等待 turn,不直接读写 `pendingCallbacks`:
```typescript
const eventId = crypto.randomUUID()
wsSendMessage(sid, text || '', eventId, imageUrls)
await waitForSessionTurn(sid, eventId)
```
`useChat.ts` 订阅 `onTurnSettled` 清理本地 pending/cancelling + 刷新消息:
```typescript
const disposeTurnSettled = onTurnSettled((sid) => {
if (localPendingSessionId.value === sid) localPendingSessionId.value = null
if (localCancellingSessionId.value === sid) localCancellingSessionId.value = null
// 仅刷新当前 active session 的消息
if (sid === sessionStore.activeSessionId) {
sessionStore.fetchSession(sid)
fetchMessages() // 用 API 结构化消息替换流式单消息,启用轮次折叠
}
})
```
> **`fetchMessages` 从 phase watcher 移到 `onTurnSettled`**:原 phase watcher 在终态时调用 `fetchMessages`,新方案由 `onTurnSettled` 回调负责。好处是:(1) 不依赖 active session 的 phase 变化,非 active session 的 turn 完成不会误触发;(2) 与 turnTracker 的 resolve 时序一致,确保消息在 turn 结束后才刷新。
**效果**:
- WebSocket 单例不依赖某个组件传入的回调。
- 普通发送、编辑重发、队列自动消费都由同一个 turn 结算入口处理。
- 非当前 active session 的终态也能按 `sessionId` 正确结算。
- `pendingCallbacks` 不再暴露给 UI,也不在 `useChat` 和 `useStreamWS` 双写。
---
### 3. 统一会话切换入口
#### 问题详解
进入一个会话有 3 条路径:
```
路径 A: onMounted → loadSession(sid) → restoreSession()
路径 B: sessionIdParam watcher → loadSession(sid) → restoreSession()
路径 C: sessionIdParam watcher → 轻量切换(:526-541,手动重复 restoreSession 部分逻辑)
```
路径 C 存在的原因:`sendMessage` 创建新会话后路由跳转触发 watcher,此时流式消息在内存中,`fetchMessages` 会覆盖它们。为避免覆盖,路径 C(TaskView.vue:526-541)绕过了 `loadSession`,但代价是手动同步了 agentName、phase、executionMode、workspace。
#### 设计方案
给 `restoreSession` 增加 `preserveLiveMessages` 参数。注意:不要再用 `sendingSessionId` 判断,它会在第 1 节中删除。判断标准改为“目标会话是否 active,且本地缓存里存在正在流式/占位的最后一条 assistant”。
新增工具函数(依赖 store 中新增的 per-session getter,见下方):
```typescript
function hasLiveAssistantMessage(sessionId: string): boolean {
const phase = sessionStore.sessions.find(s => String(s.id) === String(sessionId))?.phase
if (!isActivePhase(phase)) return false
const list = sessionStore.getMessages(sessionId)
const last = list[list.length - 1]
return !!last &&
last.role === 'assistant' &&
(
!last.content?.trim() ||
sessionStore.isSessionStreaming(sessionId) ||
sessionStore.isSessionThinking(sessionId) ||
(last.toolCalls?.some(tc => tc.status === 'pending' || tc.status === 'running') ?? false)
)
}
```
Store 中需要新增 per-session getter(在第 6 步 Store 优化中一并实现,或提前单独加):
```typescript
// stores/session.ts — 新增
function isSessionStreaming(sessionId: string): boolean {
return sessionStreaming.value.get(String(sessionId)) ?? false
}
function isSessionThinking(sessionId: string): boolean {
return sessionThinking.value.get(String(sessionId)) ?? false
}
```
> **为什么不能用 `activeStreaming` / `activeThinking`**:这两个 computed 只反映当前 active session 的状态。当用户从 session A 切走再切回时,`activeStreaming` 可能是 session B 的值(或 false),导致 `hasLiveAssistantMessage(A)` 误判为 false,触发 `fetchMessages` 覆盖流式消息。per-session getter 按 sessionId 精确查询,不受 active session 切换影响。
> **`preserveLiveMessages` 的前提条件**:跳过 `fetchMessages` 是安全的,因为 WS 事件通过 `routeEvent` 按 `sessionId` 路由到 store——即使用户切到其他会话,只要没有 `unsubscribe`,流式事件仍会更新缓存。当前代码中 `unsubscribe` 只在 `restoreSession` 切换到不同 session 时调用(useChat.ts:475),切走期间 WS 订阅保持有效。因此 store 缓存在切走期间持续接收 `content_delta`、`tool_call_start` 等事件,切回时缓存是完整的。但如果 WS 断连且重连失败(`connected` 为 false),缓存可能不完整——这是一个可接受的边界情况,重连后 `subscribe` 会触发 `session_snapshot` 事件重新同步。
`restoreSession` 参数:
```typescript
async function restoreSession(
sessionIdVal: string,
mode: string,
initialWorkspace?: string,
preserveLiveMessages = false
) {
// ... 状态切换、WS 连接 ...
if (!preserveLiveMessages) {
fetchMessages()
}
fetchTodos()
fetchQueue()
}
```
TaskView.vue 的 watcher 简化:
```typescript
watch(sessionIdParam, async (newSid, oldSid) => {
if (newSid && newSid !== oldSid) {
const preserveLiveMessages = hasLiveAssistantMessage(newSid)
await loadSession(newSid, preserveLiveMessages)
} else if (!newSid && oldSid) {
// ... 返回首页逻辑不变 ...
}
})
async function loadSession(sid: string, preserveLiveMessages = false) {
sessionStore.setActiveSession(sid)
// ... fetch session details, set local state ...
restoreSession(sid, executionMode.value, workspace.value || undefined, preserveLiveMessages)
initialLoading.value = false
}
```
**效果**:删除 TaskView.vue 中 526-541 行的 16 行轻量切换代码,统一走 `loadSession → restoreSession`。
---
### 4. 提取 sendMessage/editAndResend 共享流程
#### 问题详解
`sendMessage`(:146-232)和 `editAndResend`(:268-308)的后半段几乎逐行重复:
```typescript
// 两处都有的模式:
sending.value = true
startedAt.value = new Date().toISOString()
await connect()
subscribe(sessionId.value)
// WS 发送...
await new Promise((resolve, reject) => {
pendingCallbacks.set(sessionId.value!, { resolve, reject })
})
sending.value = false
// durationMs 计算
sessionStore.fetchSession(sessionId.value)
fetchMessages()
// catch: 移除空 assistant 消息、ElMessage.error
```
#### 设计方案
提取 `executeWithCompletion` 内部函数:
```typescript
async function executeWithCompletion(
action: (eventId: string) => void,
options?: {
errorMessage?: string
onSuccess?: () => void
onError?: (error: any) => void
}
) {
const sid = sessionId.value
if (!sid) return // 调用方应确保 session 已创建
startedAt.value = new Date().toISOString()
await connect()
subscribe(sid)
const eventId = crypto.randomUUID()
try {
action(eventId)
await waitForSessionTurn(sid, eventId)
// 计算 durationMs
if (startedAt.value) {
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.durationMs = Date.now() - new Date(startedAt.value).getTime()
}
startedAt.value = null
}
// fetchSession + fetchMessages 已移至 onTurnSettled 回调(见第 2 节),
// 此处不再重复调用。
options?.onSuccess?.()
} catch (error: any) {
// 移除空 assistant 占位消息
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.role === 'assistant' && !lastMsg.content && !(lastMsg.toolCalls?.length)) {
messages.value.pop()
}
ElMessage.error(options?.errorMessage || error?.message || '执行中断')
options?.onError?.(error)
}
}
```
> **`sessionId` 取值时机**:在函数开头一次性捕获 `sid`,避免 `action` 执行期间 `sessionId.value` 被修改的风险。`sendMessage` 在调用 helper 前已完成 session 创建,`editAndResend` 开头已检查 `sessionId.value`,因此 `!sid` guard 不会触发——保留仅为防御性编程。`fetchSession` + `fetchMessages` 已移至 `onTurnSettled` 回调(第 2 节),helper 内不再重复调用,避免与 turnTracker 的终态处理产生竞态。
`sendMessage` 简化:
```typescript
async function sendMessage(text: string, files?: File[]) {
if ((!text && (!files || files.length === 0)) || sending.value) return
localPendingSessionId.value = sessionId.value || '__creating__'
startedAt.value = new Date().toISOString()
const imageUrls = await uploadImages(files || [])
if (!sessionId.value) {
// ... session creation logic ...
sessionId.value = sessionData.id
}
const sid = sessionId.value!
localPendingSessionId.value = sid
sessionStore.clearTodos(sid)
// ... title update ...
sessionStore.addUserMessage(sid, { id: `msg_${Date.now()}_user`, role: 'user', content: text, ... })
sessionStore.addAssistantMessage(sid, { id: `msg_${Date.now()}_assistant`, role: 'assistant', content: '', ... })
subscribe(sid)
await executeWithCompletion(() => {
wsSendMessage(sid, text || '', crypto.randomUUID(), imageUrls)
})
// fetchSession + fetchMessages 由 onTurnSettled 回调处理,此处无需调用
}
```
`editAndResend` 简化:
```typescript
async function editAndResend(messageId: string, newContent: string, images: string[] = []) {
if (!sessionId.value || sending.value) return
// 校验
const msgs = sessionStore.getMessages(sessionId.value)
const lastUserMsg = [...msgs].reverse().find(m => m.role === 'user')
if (!lastUserMsg || String(lastUserMsg.id) !== String(messageId)) {
ElMessage.warning('只能编辑最后一条用户消息')
return
}
// 乐观更新
sessionStore.truncateMessagesAfter(sessionId.value, messageId)
sessionStore.updateMessageContent(sessionId.value, messageId, newContent, images.length > 0 ? images : undefined)
sessionStore.appendMessage(sessionId.value, {
id: `msg_${Date.now()}_assistant`, role: 'assistant', content: '', createdAt: new Date().toLocaleString(), toolCalls: [], segments: []
})
localPendingSessionId.value = sessionId.value
await executeWithCompletion(() => {
sendEditMessage(sessionId.value!, newContent, messageId, images)
}, {
errorMessage: '编辑重新发送失败'
})
// fetchSession + fetchMessages 由 onTurnSettled 回调处理,此处无需调用
}
```
注意:shared helper 内部已经负责 toast,调用方不要再在 `onError` 中重复 `ElMessage.error()`,避免编辑失败时弹两次。
**效果**:两个函数从共 ~200 行减少到共 ~80 行,共享流程的任何修改只需改一处。
---
### 5. 清理审批组件遗留代码
#### 问题详解
- `ToolApprovalBar.vue` 是旧版审批组件,TaskView.vue 已不使用
- `ApprovalStack.vue` 从 `ToolApprovalBar.vue` import `ApprovalItem` 类型(:50),原接口只有 `requestId, toolName, description, dangerReason?` 四个字段
- `useChat.ts` 定义了自己的 `ApprovalItem` 接口(:18-24),多了 `sessionId?: string` 字段
#### 设计方案
**Step 1**:将 `ApprovalItem` 类型统一到 `types/chat.ts`:
```typescript
// types/chat.ts — 新增
export interface ApprovalItem {
requestId: string
toolName: string
description: string
sessionId?: string
dangerReason?: string
}
```
**Step 2**:删除 `ToolApprovalBar.vue`。
**Step 3**:`ApprovalStack.vue` 和 `useChat.ts` 都从 `types/chat.ts` import `ApprovalItem`:
```typescript
// ApprovalStack.vue
import type { ApprovalItem } from '../../types/chat'
// useChat.ts
import type { ApprovalItem } from '../types/chat'
// 删除 :18-24 的本地定义
```
---
### 6. Store Map 响应式优化
#### 问题详解
当前 9 个 `ref<Map>` 在每次 mutation 时需要手动 spread 触发 Vue 响应式:
```typescript
// 当前模式(每次 appendDelta 都 spread 整个消息数组)
sessionMessages.value.set(sid, [...list])
```
Vue 3 的 `reactive()` 对 Map 有原生支持,`.set()` / `.delete()` 会自动触发响应式更新。
#### 设计方案
将 `ref<Map>` 改为 `reactive(new Map())`:
```typescript
// session.ts — 改前
const sessionMessages = ref<Map<string, ChatMessage[]>>(new Map())
// 改后
const sessionMessages = reactive(new Map<string, ChatMessage[]>())
```
所有 mutation 处移除 spread:
```typescript
// 改前
function appendDelta(sessionId: string, delta: string) {
const list = sessionMessages.value.get(sid)
// ...
sessionMessages.value.set(sid, [...list]) // 手动 spread
}
// 改后
function appendDelta(sessionId: string, delta: string) {
const list = sessionMessages.get(sid)
// ...
// 直接修改 list 内部即可,reactive Map 自动追踪
// 不需要 sessionMessages.set(sid, [...list])
}
```
computed 属性同步更新:
```typescript
// 改前
const activeMessages = computed(() =>
sessionMessages.value.get(activeSessionId.value ?? '') ?? []
)
// 改后
const activeMessages = computed(() =>
sessionMessages.get(activeSessionId.value ?? '') ?? []
)
```
reset 函数更新:
```typescript
function reset() {
// ...
sessionMessages.clear() // 替代 sessionMessages.value = new Map()
sessionTodos.clear()
// ...
}
```
**迁移范围**:每个 Map 从 `ref<Map>` 改为 `reactive(new Map())` 时,需要修改所有 `.value.get()` / `.value.set()` 调用点。以 `sessionMessages` 为例,涉及以下位置(共 18 处):
- computed: `activeMessages`
- actions: `setMessages`, `addUserMessage`, `addAssistantMessage`, `getMessages`, `appendDelta`, `appendThinkingDelta`, `appendToolCallStart`, `updateToolCallResult`, `updateToolCallArgs`, `markMessageComplete`, `clearMessages`, `truncateMessagesAfter`, `updateMessageContent`, `appendMessage`, `updateLastMessageId`
- reset: `reset()`
其他 8 个 Map 同理。建议用 `replace_all` 批量替换 `sessionMessages.value` → `sessionMessages`,然后逐一验证。
**注意**:此步骤需要逐个 Map 迁移并验证,建议每次改一个 Map,确认无回归后再改下一个。先从 `sessionMessages` 开始(使用最频繁,收益最大),最后改不常用的(如 `sessionCompacting`)。
**风险评估**:`reactive(new Map())` 在 Vue 3.4+ 行为稳定。如果项目使用的 Vue 版本低于 3.4,建议先验证 Map 的深层响应式是否正常工作。
**`reset()` 语义变化**:当前 `sessionMessages.value = new Map()` 是整体替换引用,旧 Map 对象被丢弃。改为 `.clear()` 后是清空同一个 Map 对象的内容。对于 Vue 响应式系统两者等价(都会触发更新),但有一个边界差异:如果有异步操作(如 `fetchMessages`)在 `reset()` 之后尝试写入 `sessionMessages.set(sid, data)`,用 `.clear()` 的话数据会写入被清空的 Map 并重新出现;用 `= new Map()` 的话数据写入旧 Map,新 Map 不受影响。
推荐方案:`reset()` 中保持 `= new Map()` 整体替换(不改为 `.clear()`),避免异步写入时序问题。`reactive()` 包装的 Map 在整体替换引用后仍然触发响应式更新(Vue 追踪的是 `reactive` 对象的属性,Map 整体替换会触发更新)。如果必须用 `.clear()`,则在 `reset()` 中加 `disposed` 标志位:
**新增 per-session getter**(第 3 节 `hasLiveAssistantMessage` 依赖):
```typescript
// stores/session.ts — 新增
function isSessionStreaming(sessionId: string): boolean {
return sessionStreaming.get(String(sessionId)) ?? false
}
function isSessionThinking(sessionId: string): boolean {
return sessionThinking.get(String(sessionId)) ?? false
}
```
这两个 getter 在 reactive Map 迁移后访问 `sessionStreaming.get()`(无 `.value`),与迁移保持一致。如果在 reactive Map 迁移之前实现,需要保留 `.value`。
```typescript
let disposed = false
function reset() {
disposed = true
sessions.value = []
activeSessionId.value = null
loading.value = false
sessionMessages.clear()
sessionTodos.clear()
// ... 其他 Map ...
disposed = false // 重置后允许写入
}
// 在所有异步写入前检查
function setMessages(sessionId: string, messages: ChatMessage[]) {
if (disposed) return
sessionMessages.set(String(sessionId), messages)
}
```
---
### 7. 消除常量重复定义
#### 问题
`TASK_TOOL_NAMES` 在两处定义:
- `utils/chatMessage.ts:58`
- `stores/session.ts:273`
#### 设计方案
提取到 `types/chat.ts`:
```typescript
// types/chat.ts
export const TASK_TOOL_NAMES = new Set(['task_create', 'task_update', 'task_delete', 'task_list'])
```
```typescript
// utils/chatMessage.ts — 删除本地定义,改为 import
import { TASK_TOOL_NAMES } from '../types/chat'
// stores/session.ts — 同上
import { TASK_TOOL_NAMES } from '../types/chat'
```
---
## 执行顺序与依赖关系
```
第 1 步(无依赖,可并行):
├─ 7. 消除常量重复定义(改 3 个文件各 1 行)
└─ 5. 清理审批组件(删 1 文件,改 3 文件的 import)
第 2 步(依赖第 1 步的类型统一):
└─ 6. Store Map 响应式优化 + 新增 per-session getter(逐个 Map 迁移)
第 3 步(核心重构,依赖理解前两步的 store 结构):
└─ 2. 引入 turnTracker,移除 pendingCallbacks 双写(改 useStreamWS + useChat)
— 删除 useChat.ts 的 phase watcher 终态处理,统一由 turnTracker + routeEvent 负责
— 保留轻量的队列自动消费 watcher(见第 1 节)
第 4 步(依赖第 3 步的 callback 机制 + 第 2 步的 per-session getter):
├─ 1. 统一 phase 与本地 pending 状态模型
└─ 4. 提取 executeWithCompletion(可与第 4 步并行)
第 5 步(依赖第 4 步的状态模型统一):
└─ 3. 统一会话切换入口
```
推荐每步单独提交,便于 review 和回滚。
### 新增目录结构
第 3 步需要在 `desktop/src/` 下新建 `domain/session/` 目录,用于放置与组件生命周期无关的纯逻辑模块:
```
desktop/src/domain/session/
├── turnTracker.ts # 跨组件的 turn 完成跟踪(第 3 步)
└── phase.ts # phase 常量集合与判断函数(第 4 步)
# EXECUTING_PHASES / ACTIVE_PHASES / TERMINAL_PHASES
# isExecutingPhase() / isActivePhase() / isTerminalPhase()
```
选择 `domain/session/` 而非 `composables/` 的原因:`turnTracker` 和 `phase` 是模块级单例逻辑,不依赖 Vue 组件生命周期(不需要 `ref`、`computed`、`onUnmounted`),放在 `composables/` 中会误导使用者以为它是组件级 hook。`domain/` 目录明确表示这是业务领域逻辑,可被 `useChat`、`useStreamWS`、store 等多方安全导入。
---
## 风险与验证要点
### 逐项风险评估
| 重构项 | 风险 | 缓解措施 |
|--------|------|----------|
| phase/pending 统一 | sendMessage 发起后、RUNNING 返回前存在时序窗口 | 保留 `localPendingSessionId`,不要单靠 phase 禁用输入 |
| sending computed | `WAITING_APPROVAL` 期间 `sending` 为 true 导致无法排队消息 | `sending` 使用 `isExecutingPhase`(排除 WAITING_APPROVAL),`isActive` 独立保留用于队列路由 |
| phase watcher 删除 | 删除终态处理后,队列自动消费(COMPLETED→RUNNING)无对应 Promise 等待者 | 保留轻量 watcher 仅处理队列自动消费:RUNNING 且无 pending turn 时注册新 turn |
| turnTracker 与 phase watcher | 两者同时在终态 resolve pendingCallbacks 导致双重 resolve | 明确删除 phase watcher 的终态处理逻辑,统一由 turnTracker + routeEvent 负责 |
| `stopExecution` | `'__creating__'` 阶段 `sessionId` 为 null,取消请求无法到达服务端,但 `sending` 为 true 导致 UI 锁死 | `stopExecution` 中检测 `sessionId` 为 null 时直接清除 `localPendingSessionId`,不设置 `localCancellingSessionId` |
| turnTracker | 终态事件可能发生在非当前 active session | 在 `useStreamWS.routeEvent` 中按事件 `sessionId` 调 `resolveSessionTurn/rejectSessionTurn` |
| turnTracker | `fetchSession` 调用频率从 active-only 变为 all sessions | 在 `routeEvent` 中加 `sessionId === sessionStore.activeSessionId` 条件,仅 active session 触发 |
| 会话切换统一 | `preserveLiveMessages` 漏判导致流式消息被覆盖 | 用 `hasLiveAssistantMessage(sessionId)` 判断,依赖 store 新增的 `isSessionStreaming`/`isSessionThinking` per-session getter |
| executeWithCompletion | catch 块中移除 assistant 消息的逻辑在两次调用中略有不同 | 统一为"移除最后一条空 assistant",错误 toast 由 helper 或调用方单方负责 |
| reactive Map | Vue 版本兼容性与深层响应式误判 | 确认 Vue >= 3.4;此项可后置,逐个 Map 迁移并手动测试 |
| reactive Map | `reset()` 从 `= new Map()` 改为 `.clear()`,异步操作写入时序变化 | 推荐 `reset()` 保持 `= new Map()` 整体替换;如用 `.clear()` 则加 `disposed` 标志位 |
### 功能验证清单
每个重构步骤完成后,需验证以下场景:
- [ ] **发送消息**:输入文字 → 发送 → 流式显示 → 完成后轮次折叠正常
- [ ] **编辑重发**:编辑最后一条用户消息 → 重新发送 → 正常完成
- [ ] **停止执行**:执行中点击停止 → phase 变为 CANCELLING → UI 显示取消中 → 变为 CANCELLED
- [ ] **会话切换**:执行中切换到其他会话 → 切回来 → 流式消息不丢失
- [ ] **队列消费**:执行中发送多条消息 → 排队 → 依次消费 → 每条都有正确轮次
- [ ] **队列消费 + 会话切换**:执行中发送多条消息 → 切到其他会话 → 切回来 → 队列自动消费正常继续
- [ ] **审批期间排队**:agent 等待审批时 → 用户输入新消息 → 点发送 → 消息进入队列(UI 不锁定)
- [ ] **创建中停止**:首页发送消息后、session 创建完成前快速点击停止 → UI 解锁,不锁死
- [ ] **审批流程**:触发审批 → ApprovalStack 显示 → 点击执行/拒绝 → 正确响应
- [ ] **新会话创建**:从首页发送 → 创建会话 → 路由跳转 → 消息正常
- [ ] **返回首页**:点返回 → 清理状态 → 显示新任务或最新会话
- [ ] **页面刷新**:F5 刷新 → 恢复会话 → phase 同步正确