修复: aichat 切换/新建对话缺陷(队列串会话/回切丢消息/审批误终态化/删除复活/切换链路)
This commit is contained in:
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
||||
|
||||
@@ -63,42 +63,60 @@ async function loadConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return,
|
||||
// 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。
|
||||
let _newConvLock = false
|
||||
|
||||
/** 新建空对话并切过去 */
|
||||
async function newConversation() {
|
||||
// G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
|
||||
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
|
||||
const result = await withConvOp(
|
||||
() => {},
|
||||
() => aiApi.createConversation(),
|
||||
() => {},
|
||||
'newConvFail',
|
||||
)
|
||||
if (!result) return // 失败已推气泡,保持当前视图
|
||||
state.activeConversationId = result.id
|
||||
void appSettings.set('df-ai-active-conv', result.id)
|
||||
// G3.5:新对话无历史,load_more 游标复位
|
||||
loadMoreCursor.hasMore = false
|
||||
loadMoreCursor.earliestSeq = null
|
||||
loadMoreCursor.convId = result.id
|
||||
loadMoreCursor.loading = false
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
setStreaming(false, { reason: 'newConversation' })
|
||||
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||||
state.agentRound = 0
|
||||
state.searchQuery = ''
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
// 新建会话清空所有审批计时器(对齐切换/删除会话——旧 conv 的审批超时到点会误拒后台
|
||||
// 挂起审批 + 错误气泡进新会话视图)。
|
||||
clearAllApprovalTimers()
|
||||
// 防抖(300ms 内重复调用直接 return)
|
||||
if (_newConvLock) return
|
||||
_newConvLock = true
|
||||
try {
|
||||
// G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
|
||||
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
|
||||
const result = await withConvOp(
|
||||
() => {},
|
||||
() => aiApi.createConversation(),
|
||||
() => {},
|
||||
'newConvFail',
|
||||
)
|
||||
if (!result) return // 失败已推气泡,保持当前视图
|
||||
state.activeConversationId = result.id
|
||||
void appSettings.set('df-ai-active-conv', result.id)
|
||||
// G3.5:新对话无历史,load_more 游标复位
|
||||
loadMoreCursor.hasMore = false
|
||||
loadMoreCursor.earliestSeq = null
|
||||
loadMoreCursor.convId = result.id
|
||||
loadMoreCursor.loading = false
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
setStreaming(false, { reason: 'newConversation' })
|
||||
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||||
state.agentRound = 0
|
||||
state.searchQuery = ''
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
} finally {
|
||||
// 防抖释放:300ms 后允许再次新建
|
||||
setTimeout(() => { _newConvLock = false }, 300)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
|
||||
let _latestSwitchId = 0
|
||||
// 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃,
|
||||
// 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。
|
||||
let _switchBufferedText = ''
|
||||
|
||||
// ============================================================
|
||||
// G3.5 load_more 分页(滚顶加载更早历史)
|
||||
@@ -232,18 +250,27 @@ async function loadMoreHistory(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||||
export async function switchConversation(id: string) {
|
||||
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
|
||||
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
|
||||
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */
|
||||
export async function switchConversation(id: string, force = false) {
|
||||
// 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
|
||||
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
|
||||
// in-flight 流式文本。程序化刷新(force=true)不受影响。
|
||||
if (!force && id === state.activeConversationId) return
|
||||
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
|
||||
clearAllApprovalTimers()
|
||||
const mySwitchId = ++_latestSwitchId
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
let detail: AiConversationDetail
|
||||
try {
|
||||
// 标记目标会话切换中(await 往返期间),useAiEvents 对非当前会话的 AiTextDelta
|
||||
// 若命中此集合则累积到其 per-conv 流式态而非丢弃(治切换窗口丢 token)。
|
||||
switchingConvs.add(id)
|
||||
detail = await aiApi.switchConversation(id)
|
||||
} catch (e) {
|
||||
// G3.3:区分 Err 形态——仅"对话不存在"(已删除/未落库的虚 ID)才 create-new 兜底;
|
||||
// 区分错误形态:仅"对话不存在"(已删除/未落库的虚 ID)才新建会话兜底;
|
||||
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
if (errMsg.includes('对话不存在')) {
|
||||
@@ -253,7 +280,7 @@ export async function switchConversation(id: string) {
|
||||
void appSettings.set('df-ai-active-conv', created.id)
|
||||
// 用新对话 id 重走后续逻辑
|
||||
detail = { id: created.id as ConvId, title: null, messages: '[]' }
|
||||
// G3.5:新建对话无历史,load_more 游标复位
|
||||
// 新建对话无历史,load_more 游标复位
|
||||
loadMoreCursor.hasMore = false
|
||||
loadMoreCursor.earliestSeq = null
|
||||
loadMoreCursor.convId = created.id
|
||||
@@ -263,46 +290,54 @@ export async function switchConversation(id: string) {
|
||||
void loadConversations()
|
||||
return
|
||||
}
|
||||
// 瞬态失败:保留当前视图 + 错误气泡(复用 M30 pushConvOpFail 模式);过期响应丢弃。
|
||||
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
|
||||
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
|
||||
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
pushConvOpFail('switchConvFail')
|
||||
void loadConversations()
|
||||
return
|
||||
} finally {
|
||||
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
|
||||
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
|
||||
// 防陈旧文本在后续切换被误恢复造成内容重复。
|
||||
switchingConvs.delete(id)
|
||||
_switchBufferedText = getConvStreamState(id)?.currentText ?? ''
|
||||
setConvCurrentText(id, '')
|
||||
}
|
||||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
state.activeConversationId = id
|
||||
// G3.5:透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
||||
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
||||
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
|
||||
const switchDetailAny = detail as any
|
||||
loadMoreCursor.hasMore = switchDetailAny.has_more ?? false
|
||||
loadMoreCursor.earliestSeq = switchDetailAny.earliest_seq ?? null
|
||||
loadMoreCursor.convId = id
|
||||
loadMoreCursor.loading = false
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
|
||||
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
|
||||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
||||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
// streaming 是全局单值,切到非生成会话需按目标会话生成态重算,否则残留停止按钮
|
||||
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
|
||||
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
|
||||
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
|
||||
const targetCs = getConvState(id)
|
||||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
|
||||
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
|
||||
// messages 仍是旧视图 + 错误气泡 → active 与 messages 错配(视图显示旧会话却以新会话高亮)。
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
// G3.5:消息映射收敛进 parseConvMessages(switch + load_more 共用,过滤/映射语义一致)。
|
||||
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
|
||||
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
|
||||
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
|
||||
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
||||
// parse 成功才置 active(保证 active 与 messages 一致)
|
||||
state.activeConversationId = id
|
||||
} catch (e) {
|
||||
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
||||
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
||||
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
||||
// parse 失败不切换(active/messages 保持原状),仅推错误气泡,视图不显错配。
|
||||
console.error('[AI] 切换对话历史消息解析失败:', e)
|
||||
state.messages.push({
|
||||
id: `switch-conv-fail-${nextMsgId()}`,
|
||||
@@ -311,7 +346,11 @@ export async function switchConversation(id: string) {
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
return
|
||||
}
|
||||
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
@@ -327,16 +366,18 @@ export async function switchConversation(id: string) {
|
||||
state.lastTokenUsage = null
|
||||
|
||||
state.currentText = ''
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
|
||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||
// 阶段4:按 IPC 返的 kind 渲染——'path' 类显 once/always/deny(tc.kind='path' + 推 path/dir 文案),
|
||||
// 'risk' 类显 approve/reject(tc.kind='risk'/缺省)。对齐阶段3b 统一审批模型(两 kind 都可恢复)。
|
||||
// 恢复切换窗口缓冲的流式文本(切换中累积到该会话 per-conv 态的后端快照之后增量),
|
||||
// 由消息列表续显,避免切换完成时丢可见内容(回复缺前缀)。空缓冲则无操作。
|
||||
if (_switchBufferedText) state.currentText = _switchBufferedText
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
|
||||
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
|
||||
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
@@ -363,8 +404,7 @@ export async function switchConversation(id: string) {
|
||||
path: tc.path,
|
||||
dir: tc.dir,
|
||||
reason: tc.reason,
|
||||
// A2-B10 conv-scoped 审批收尾:恢复的历史挂起带目标会话 id,
|
||||
// cleanupTerminatedConversation 按此仅清本会话的待审批项(不连累并发会话)。
|
||||
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
|
||||
conversationId: id,
|
||||
})
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||
|
||||
Reference in New Issue
Block a user