//! 对话管理 — 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 { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiConversationDetail, AiMessage, AiToolCallInfo } 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, 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() } } } /** 新建空对话并切过去 */ async function newConversation() { const result = await aiApi.createConversation() state.activeConversationId = result.id void appSettings.set('df-ai-active-conv', result.id) 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 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。 // queue 清空防旧会话排队消息带进新会话 drain;agentRound 复位 0;searchQuery 清空防新会话 // 侧栏被旧搜索过滤。此三项均为新 conv 本地视图复位(非跨 conv 跟踪),保留。 state.queue = [] state.agentRound = 0 state.searchQuery = '' await loadConversations() notifyConversationChanged() } // 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1) let _latestSwitchId = 0 /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */ export async function switchConversation(id: string) { const mySwitchId = ++_latestSwitchId // 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图 let detail: AiConversationDetail try { detail = await aiApi.switchConversation(id) } catch { // 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代 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, title: null, messages: '[]' } state.messages = [] notifyConversationChanged() void loadConversations() return } // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) if (mySwitchId !== _latestSwitchId) return state.activeConversationId = id 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}(非终止三态)。 const targetCs = getConvState(id) const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed' setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) try { const rawMsgs = typeof detail.messages === 'string' ? JSON.parse(detail.messages) : detail.messages // 构建 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 || '') } } state.messages = 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: `loaded-${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(), // F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[] // 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段, // 此处原样透传供 AiChat.vue 用户气泡渲染 (base64 模式持久化层已替换占位 Text 片, // 故历史 parts 通常仅含 url 模式 Image 或纯 Text)。 parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined, // F-15 阶段2: 透传 status(archived_segment/compressed/null|active), // 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单), // 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。 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), } }), })) } catch (e) { // UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。 // 改为推错误气泡提示 + 控制台日志(保留 state.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) } // 加载历史对话 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 = '' // 恢复该对话积压的待审批:重启后后端从审计表重建了 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 都可恢复)。 try { const pending = await aiApi.pendingToolCalls(id) // 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals if (mySwitchId !== _latestSwitchId) return if (pending.length) { // kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染 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, }) // 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝, // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) startApprovalTimer(tc.id, tc.name, kind) } } } state.pendingApprovals = restored } else { state.pendingApprovals = [] } } catch { state.pendingApprovals = [] } } /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */ async function deleteConversation(id: string) { await aiApi.deleteConversation(id) // F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长 // (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。 clearConvStreamState(id) if (state.activeConversationId === id) { state.activeConversationId = null void appSettings.remove('df-ai-active-conv') state.messages = [] } await loadConversations() notifyConversationChanged() } /** 重命名会话(后端 + 本地侧栏摘要同步) */ async function renameConversation(id: string, title: string) { await aiApi.renameConversation(id, title) // 本地同步更新侧栏摘要标题 const conv = state.conversations.find(c => c.id === id) if (conv) conv.title = title notifyConversationChanged() } /** 归档/取消归档(后端 + 本地侧栏分组同步) */ async function archiveConversation(id: string, archived: boolean) { await aiApi.archiveConversation(id, archived) // 本地同步归档态(侧栏立即移入/移出归档分组) const conv = state.conversations.find(c => c.id === id) if (conv) conv.archived = archived notifyConversationChanged() } /** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */ async function setPinnedConversation(id: string, pinned: boolean) { await aiApi.setPinnedConversation(id, pinned) // 本地同步置顶态(排序 computed 读 pinned,立即重排) const conv = state.conversations.find(c => c.id === id) if (conv) conv.pinned = pinned notifyConversationChanged() } /** 折叠/展开归档分组 */ 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, newConversation, switchConversation, deleteConversation, renameConversation, archiveConversation, setPinnedConversation, toggleArchivedFold, toggleGroupFold, toggleSidebar, } } export { loadConversations }