//! 对话管理 — load/list/new/switch/delete/rename/archive + 侧栏折叠态 //! //! 耦合: //! - 多处调 useAiEvents.notifyConversationChanged //! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types' const appSettings = useAppSettingsStore() /** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */ async function loadConversations() { try { state.conversations = await aiApi.listConversations() // 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。 // 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。 // 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。 // 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。 if (state.activeConversationId && !state.conversations.some(c => c.id === state.activeConversationId)) { const now = String(Date.now()) state.conversations.unshift({ id: state.activeConversationId as ConvId, title: null, provider_id: null, model: null, archived: false, pinned: false, prompt_tokens: 0, completion_tokens: 0, created_at: now, updated_at: now, }) } // 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时) const pending = appSettings.get('df-ai-active-conv', null) if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) { await switchConversation(pending) } } catch (e) { // UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。 // 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。 // 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。 console.error('[AI] 加载会话列表失败:', e) if (state.conversations.length === 0) { state.messages.push({ id: `load-conv-fail-${nextMsgId()}`, role: 'assistant', content: t('ai.loadConvFail'), isError: true, timestamp: Date.now(), } as AiMessage) notifyConversationChanged() } } } // FE1(AC-EFF-F1-1/2/3):会话列表刷新收敛——所有「操作后/事件后」的列表刷新统一走本调度器。 // 约 250ms trailing debounce:合并同一窗口内多次触发(回合收尾的 notify 自触发 + 显式 // loadConversations 曾双拉,switch/new/delete 的本地刷新 + notify)为一次 IPC 列表拉取。 // 跨窗口同步仍靠 notifyConversationChanged 的 emit——分离窗口 listener 也收敛到本调度器, // 各自窗口只拉一次。初始加载(AiChat.vue 挂载)仍直调 loadConversations,不受防抖延迟。 let _refreshTimer: ReturnType | null = null function scheduleConversationsRefresh(): void { if (_refreshTimer) clearTimeout(_refreshTimer) _refreshTimer = setTimeout(() => { _refreshTimer = null void loadConversations() }, 250) } // 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return, // 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。 let _newConvLock = false /** 新建空对话并切过去 */ async function newConversation() { // 新建会话清空所有审批计时器(对齐切换/删除会话——旧 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 = '' // FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。 scheduleConversationsRefresh() 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 分页(滚顶加载更早历史) // ============================================================ // // 后端 ai_conversation_switch 已返 has_more/earliest_seq(最近 50 条之外的更早历史游标), // 本模块持游标;MessageList 滚顶阈值触发 loadMoreHistory → IPC → prepend + 按 id 去重。 // 普通对象(非响应式):仅滚动事件触发时读,无需渲染追踪。 const loadMoreCursor: { hasMore: boolean earliestSeq: number | null loading: boolean convId: string | null } = { hasMore: false, earliestSeq: null, loading: false, convId: null } /** 读 load_more 游标(MessageList 滚动处理器用;返回同一引用,读到的即最新态) */ function getLoadMoreState() { return loadMoreCursor } /** * 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。 * 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、 * token 分项透传、parts 透传、tool_calls 回填 tool_result)。 * id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}` * (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'), * 防两条 id 方案在 prepend 后冲突。 */ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] { // 构建 tool_call_id → tool_result 映射,用于回填工具执行结果 const toolResultMap = new Map() for (const m of rawMsgs) { if (m.role === 'tool' && m.tool_call_id) { toolResultMap.set(m.tool_call_id, m.content || '') } } return rawMsgs // 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated, // 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤) .filter((m: any) => m.role !== 'tool' && m.status !== 'truncated') .map((m: any, i: number) => ({ id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`, role: m.role, content: m.content || '', model: m.model, // 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now() timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(), // 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens // (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供 // MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。 // 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。 // 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。 tokenUsage: m.role === 'assistant' && m.prompt_tokens != null ? { prompt: m.prompt_tokens, completion: m.completion_tokens ?? 0, cache_hit: m.prompt_cache_hit_tokens, cache_miss: m.prompt_cache_miss_tokens, reasoning: m.reasoning_tokens, // 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted is_estimated: m.is_estimated, } : undefined, // 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致) prompt_cache_hit_tokens: m.prompt_cache_hit_tokens, prompt_cache_miss_tokens: m.prompt_cache_miss_tokens, reasoning_tokens: m.reasoning_tokens, // F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[] // 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段, // 此处原样透传供 AiChat.vue 用户气泡渲染 。 parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined, // F-15 阶段2: 透传 status(archived_segment/compressed/null|active), // 供 AiChat.vue 按 status 折叠分组渲染。 status: m.status, toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => { // 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填 let args: unknown = {} const rawArgs = tc.function?.arguments if (typeof rawArgs === 'string') { try { args = rawArgs ? JSON.parse(rawArgs) : {} } catch { args = {} } } else if (rawArgs && typeof rawArgs === 'object') { args = rawArgs } return { id: tc.id, name: tc.function?.name || '', args, status: 'completed' as const, result: toolResultMap.get(tc.id), } }), })) } /** * G3.5:加载更早历史消息并 prepend 到当前视图。 * 游标守卫(hasMore/loading/earliestSeq/convId)+ 切走过期丢弃(convId 与 active 不一致即弃)。 * 返回是否实际插入了新消息(MessageList 据此恢复 scrollTop)。 */ async function loadMoreHistory(): Promise { const cur = loadMoreCursor if (!cur.convId || cur.earliestSeq == null || cur.loading || !cur.hasMore) return false cur.loading = true try { const res = await aiApi.loadMoreMessages(cur.convId, cur.earliestSeq) // 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图 if (cur.convId !== state.activeConversationId) return false const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages const older = parseConvMessages(rawMsgs, 'older') // 按 id 去重(防御:游标边界变化 / 与已加载消息重复) const existing = new Set(state.messages.map(m => m.id)) const fresh = older.filter(m => !existing.has(m.id)) if (fresh.length === 0) { // 无新消息(极端重复):推进游标但不算"插入",防 MessageList 恢复滚动误判 cur.hasMore = res.has_more ?? false if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq return false } state.messages = [...fresh, ...state.messages] cur.hasMore = res.has_more ?? false if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq return true } catch (e) { console.error('[AI] 加载更多历史消息失败:', e) return false } finally { cur.loading = false } } /** 切换到指定会话:加载历史消息(含 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) { // 区分错误形态:仅"对话不存在"(已删除/未落库的虚 ID)才新建会话兜底; // 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。 const errMsg = e instanceof Error ? e.message : String(e) if (errMsg.includes('对话不存在')) { console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id) const created = await aiApi.createConversation() if (mySwitchId !== _latestSwitchId) return void appSettings.set('df-ai-active-conv', created.id) // 用新对话 id 重走后续逻辑 detail = { id: created.id as ConvId, title: null, messages: '[]' } // 新建对话无历史,load_more 游标复位 loadMoreCursor.hasMore = false loadMoreCursor.earliestSeq = null loadMoreCursor.convId = created.id loadMoreCursor.loading = false state.messages = [] notifyConversationChanged() scheduleConversationsRefresh() return } // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。 console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) if (mySwitchId !== _latestSwitchId) return pushConvOpFail('switchConvFail') scheduleConversationsRefresh() return } finally { // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 // 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃, // 防陈旧文本在后续切换被误恢复造成内容重复。 switchingConvs.delete(id) _switchBufferedText = getConvStreamState(id)?.currentText ?? '' setConvCurrentText(id, '') } // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) if (mySwitchId !== _latestSwitchId) return // 透传 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 // streaming 是全局单值,切到非生成会话需按目标会话生成态重算,否则残留停止按钮 // (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的 // 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生, // 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。 const targetCs = getConvState(id) const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed' // 先 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 // 消息映射收敛进 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 // CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 // 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中, // flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText, // 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。 // targetGen(非终止三态 generating/stopping/compressed)在上文已算,直接复用; // round0-pending- 前缀防御判重(force 重刷同会话时 parseConvMessages 已整体重建,不叠加)。 // 字段满足 AiMessage 必填(id/role/content/timestamp),仅 id 因 branded MessageId 逐字段 cast。 const lastMsg = state.messages[state.messages.length - 1] if ( targetGen && lastMsg && lastMsg.role === 'user' && !state.messages.some((m) => m.role === 'assistant' && m.id.startsWith('round0-pending-')) ) { state.messages.push({ id: `round0-pending-${id}` as MessageId, role: 'assistant', content: '', isError: false, timestamp: Date.now(), }) } } catch (e) { // 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。 // 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。 // parse 失败不切换(active/messages 保持原状),仅推错误气泡,视图不显错配。 console.error('[AI] 切换对话历史消息解析失败:', e) state.messages.push({ id: `switch-conv-fail-${nextMsgId()}`, role: 'assistant', content: t('ai.switchConvFail'), 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) if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) { state.convTokenTotal = { prompt: conv.prompt_tokens, completion: conv.completion_tokens, total: conv.prompt_tokens + conv.completion_tokens, } } else { state.convTokenTotal = null } state.lastTokenUsage = null state.currentText = '' // 恢复切换窗口缓冲的流式文本(切换中累积到该会话 per-conv 态的后端快照之后增量), // 由消息列表续显,避免切换完成时丢可见内容(回复缺前缀)。空缓冲则无操作。 if (_switchBufferedText) state.currentText = _switchBufferedText // 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批, // 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。 // 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。 // F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息 // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。 const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0) if (!targetGen && !hasToolCalls) { state.pendingApprovals = [] } else { try { const pending = await aiApi.pendingToolCalls(id) // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals if (mySwitchId !== _latestSwitchId) return if (pending.length) { // 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[] = [] for (const m of state.messages) { for (const tc of (m.toolCalls || [])) { if (pendingIds.has(tc.id)) { tc.status = 'pending_approval' const kind = pendingKindMap.get(tc.id) ?? 'risk' tc.kind = kind // path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path; // 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。 if (kind === 'path') { const p = (tc.args as { path?: string } | null)?.path tc.path = p tc.dir = p ?? undefined tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' }) } restored.push({ id: tc.id, name: tc.name, args: tc.args, status: 'pending_approval', kind, path: tc.path, dir: tc.dir, reason: tc.reason, // 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。 conversationId: id, }) // 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过; // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) startApprovalTimer(tc.id, tc.name, kind) } } } state.pendingApprovals = restored } else { state.pendingApprovals = [] } } catch { state.pendingApprovals = [] } } } /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。 * G3.4:收敛进 withConvOp(乐观移除列表 + 失败回滚 + 错误气泡),原裸 await 失败抛 unhandled rejection。 */ async function deleteConversation(id: string) { // 删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + 错误气泡推错视图) clearAllApprovalTimers() const conv = state.conversations.find(c => c.id === id) const prevIndex = conv ? state.conversations.indexOf(conv) : -1 const wasActive = state.activeConversationId === id const prevActive = state.activeConversationId const prevMessages = state.messages // 删活跃会话前捕获相邻会话(删除后回落,避免空白无引导) const neighbor = wasActive ? (state.conversations[prevIndex + 1] ?? state.conversations[prevIndex - 1]) : null const neighborId = neighbor?.id ?? null const ok = await withConvOp( () => { // 乐观:从列表移除 + 若删的是活跃会话则清空视图 state.conversations = state.conversations.filter(c => c.id !== id) if (wasActive) { state.activeConversationId = null state.messages = [] } }, () => aiApi.deleteConversation(id), () => { // 回滚:恢复列表原位置 + 恢复活跃会话与消息视图(失败保留原视图) if (conv) { const list = [...state.conversations] list.splice(prevIndex === -1 ? list.length : Math.min(prevIndex, list.length), 0, conv) state.conversations = list } if (wasActive) { state.activeConversationId = prevActive state.messages = prevMessages } }, 'deleteConvFail', ) // 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败) if (ok === null) return // 失败已回滚 + 推气泡,保持原视图 // 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长 clearConvStreamState(id) clearLastDelta(id) if (wasActive) { void appSettings.remove('df-ai-active-conv') // 删除活跃会话 → load_more 游标复位(无 active 视图) loadMoreCursor.hasMore = false loadMoreCursor.earliestSeq = null loadMoreCursor.convId = null loadMoreCursor.loading = false } scheduleConversationsRefresh() // 删的是活跃会话:回落相邻会话作为新活跃视图 if (neighborId) await switchConversation(neighborId) notifyConversationChanged({ deletedConvId: id }) } /** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */ type ConvOpFailKey = | 'renameConvFail' | 'archiveConvFail' | 'pinConvFail' | 'newConvFail' | 'deleteConvFail' | 'switchConvFail' /** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈), * 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */ function pushConvOpFail(key: ConvOpFailKey, params?: Record) { state.messages.push({ id: `conv-op-fail-${nextMsgId()}`, role: 'assistant', content: t(`ai.${key}`, params), isError: true, timestamp: Date.now(), } as AiMessage) notifyConversationChanged() } /** 会话操作族统一 helper(G3.4):乐观更新 + 失败回滚 + 错误气泡。 * 边界=会话操作族(rename/archive/pin/new/delete),非全局错误抽象。 * apply:乐观本地变更(同步,幂等);op:后端 IPC;rollback:失败恢复 apply 前本地态; * failKey:错误气泡 i18n key;params:i18n 插值参数。 * 返回后端 op 结果,失败返 null(气泡已推,调用方据此短路后续流程)。 */ async function withConvOp( apply: () => void, op: () => Promise, rollback: () => void, failKey: ConvOpFailKey, params?: Record, ): Promise { apply() notifyConversationChanged() try { return await op() } catch (e) { console.error('[AI] 会话操作失败:', e) rollback() notifyConversationChanged() pushConvOpFail(failKey, params) return null } } /** 重命名会话(后端 + 本地侧栏摘要同步)。 * M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */ async function renameConversation(id: string, title: string) { const conv = state.conversations.find(c => c.id === id) const prevTitle = conv?.title ?? null await withConvOp( () => { if (conv) conv.title = title }, () => aiApi.renameConversation(id, title), () => { if (conv) conv.title = prevTitle }, 'renameConvFail', ) } /** 归档/取消归档(后端 + 本地侧栏分组同步)。 * M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */ async function archiveConversation(id: string, archived: boolean) { const conv = state.conversations.find(c => c.id === id) const prevArchived = conv?.archived ?? false await withConvOp( () => { if (conv) conv.archived = archived }, () => aiApi.archiveConversation(id, archived), () => { if (conv) conv.archived = prevArchived }, 'archiveConvFail', { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') }, ) } /** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17)。 * M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */ async function setPinnedConversation(id: string, pinned: boolean) { const conv = state.conversations.find(c => c.id === id) const prevPinned = conv?.pinned ?? false await withConvOp( () => { if (conv) conv.pinned = pinned }, () => aiApi.setPinnedConversation(id, pinned), () => { if (conv) conv.pinned = prevPinned }, 'pinConvFail', { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') }, ) } /** 折叠/展开归档分组 */ function toggleArchivedFold() { state.archivedCollapsed = !state.archivedCollapsed persistUiState() } /** 折叠/展开时间分组(today/yesterday/earlier) */ function toggleGroupFold(key: string) { state.foldedGroups[key] = !state.foldedGroups[key] persistUiState() } /** 展开/收起侧栏(会话列表) */ function toggleSidebar() { state.sidebarOpen = !state.sidebarOpen persistUiState() } export function useAiConversations() { return { loadConversations, scheduleConversationsRefresh, newConversation, switchConversation, deleteConversation, renameConversation, archiveConversation, setPinnedConversation, loadMoreHistory, toggleArchivedFold, toggleGroupFold, toggleSidebar, } } export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState }