import { ref, toRaw, isRef, unref } from 'vue' import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile, MessageSegment } from './interface' import { useUserStore } from '@/stores/user' import { useDiscreteApi } from '@/composable/useDiscreteApi' import { chatDB } from './chatDB' import { getAiSessionList, getCurrentAiSession, createAiSession, switchAiSession, deleteAiSession, cancelAiChat, resolveAiPermission, uploadAiFile, updateAiSessionTitle, } from '@/service/aiChat' // SSE done 事件数据 export interface SessionLoadDoneInfo { totalMessages: number totalBytes: number suggestNewSession: boolean } // 文件下载标记:$$FILE_DOWNLOAD:{...}$$(模块作用域,避免每次调用重新编译) const FILE_MARKER_RE = /\$\$FILE_DOWNLOAD:([\s\S]*?)\$\$/g // 全局消息提示(模块作用域,避免每次调用重建 discrete api 实例) const { message } = useDiscreteApi() // 解析出的行内文件片段(用于内联下载按钮) interface ParsedFileChunk { kind: 'file' file: { key: string, fileName: string, size: number } } // 解析出的文本片段 interface ParsedTextChunk { kind: 'text' content: string } type ParsedChunk = ParsedTextChunk | ParsedFileChunk // 深度普通化:把 Vue 响应式对象(含所有嵌套层级)递归还原为纯 JS 对象, // 使其可被 structured clone(IndexedDB.put)。相比 toRaw 只解一层、 // 或 JSON 往返会丢失共享引用,这里用 WeakMap 记录已拷贝对象, // 保证如 tool 在 toolCalls 与 segments 中的“共享同一引用”在拷贝后依然成立, // 从而读取会话、Vue 重新响应式化后,tool 状态同步语义得以保留。 // 同时兼容循环引用。 function deepPlain(input: T, seen: WeakMap = new WeakMap()): T { const raw: any = toRaw(isRef(input) ? unref(input) : input) if (raw === null || typeof raw !== 'object') return raw as T if (seen.has(raw)) return seen.get(raw) const out: any = Array.isArray(raw) ? [] : {} seen.set(raw, out) // 先登记再递归,命中共享/循环引用时复用同一份拷贝 for (const key of Object.keys(raw)) { out[key] = deepPlain(raw[key], seen) } return out as T } // 按 $$FILE_DOWNLOAD$$ 标记把文本切分为「文本 / 文件」有序片段。 // 非法/残缺标记会被丢弃(既不产生文件片段,也不保留标记文本)。 function splitByFileMarker(text: string): ParsedChunk[] { const chunks: ParsedChunk[] = [] let lastIndex = 0 for (const m of text.matchAll(FILE_MARKER_RE)) { const idx = m.index ?? 0 const before = text.slice(lastIndex, idx) if (before) chunks.push({ kind: 'text', content: before }) try { const fileData = JSON.parse(m[1]) chunks.push({ kind: 'file', file: { key: fileData.key, fileName: fileData.fileName, size: fileData.size } }) } catch { /* ignore malformed / partial marker */ } lastIndex = idx + m[0].length } const rest = text.slice(lastIndex) if (rest) chunks.push({ kind: 'text', content: rest }) return chunks } export function useAiChat() { const messages = ref([]) const isStreaming = ref(false) const currentSessionId = ref('') const welcomeMessage = ref('') const sessionList = ref([]) const pendingFiles = ref([]) const isUploading = ref(false) const isLoadingSession = ref(false) const sessionLoadDoneInfo = ref(null) // 实际生效的 agent / model 与上下文用量(百分比 0~100) const currentAgent = ref('') const currentModel = ref('') const contextUsage = ref(0) const turnDuration = ref(0) // 上一轮耗时(毫秒) const userStore = useUserStore() function getBaseUrl(): string { const base = import.meta.env.BASE_URL || '/' return `${base}api` } // ============ 会话列表管理 ============ // 获取会话列表 async function fetchSessionList() { try { const data = await getAiSessionList() if (data) { sessionList.value = data // 清理本地缓存中不存在于后端列表的会话 const serverIds = new Set(data.map((s: any) => s.sessionId)) const localIds = await chatDB.getAllSessionIds() for (const localId of localIds) { if (!serverIds.has(localId)) { await chatDB.deleteSession(localId) } } } } catch { // 忽略 } } // 获取当前会话信息(首次打开时调用) async function fetchSession() { isLoadingSession.value = true try { // 先查询会话列表 const list = await getAiSessionList() if (list) { sessionList.value = list } // 如果存在已激活会话,直接使用 const activeSession = list?.find(s => s.isActive) if (activeSession) { currentSessionId.value = activeSession.sessionId // 从 IndexedDB 恢复本地消息 await loadLocalMessages(activeSession.sessionId) // 获取当前会话的 agent/model/用量(首屏展示) try { const info = await getCurrentAiSession() currentAgent.value = info?.agentId || '' currentModel.value = info?.modelId || '' contextUsage.value = info?.contextUsagePercent || 0 turnDuration.value = info?.turnDurationMs || 0 } catch { /* 忽略 */ } return } // 没有激活会话但列表中有历史会话:加载最近的一个 if (list && list.length > 0) { const latest = list[0] currentSessionId.value = latest.sessionId await loadLocalMessages(latest.sessionId) // 后台恢复 Kiro 上下文 const data = await switchAiSession(latest.sessionId) currentAgent.value = data?.agentId || '' currentModel.value = data?.modelId || '' await fetchSessionList() return } // 列表完全为空:创建新会话 await createNewSession() } catch { // 忽略 } finally { isLoadingSession.value = false } } // 创建新会话 async function createNewSession() { isLoadingSession.value = true try { const data = await createAiSession() // 后端未返回有效 sessionId:视为创建失败,明确提示,不静默停留在旧会话 if (!data?.sessionId) { message.error('创建新会话失败,请稍后重试') return } // 保存当前会话消息到 IndexedDB if (currentSessionId.value && messages.value.length > 0) { await saveLocalMessages() } // 切换到新会话(先落地本地状态,保证 UI 一定切过去) currentSessionId.value = data.sessionId messages.value = [] welcomeMessage.value = data.welcomeMessage || '欢迎使用AI助手' currentAgent.value = data.agentId || '' currentModel.value = data.modelId || '' contextUsage.value = 0 turnDuration.value = 0 // 刷新列表:新会话作为活跃会话由后端返回(IsActive=true),需要重新拉取才能在侧边栏正确展示/高亮。 // 列表刷新失败不影响已完成的切换,仅退化为侧边栏暂不显示,下次进入会自动纠正。 try { await fetchSessionList() // 校验新会话确实出现在列表中,未出现给出提示(便于定位后端归档/列表问题) if (!sessionList.value.some(s => s.sessionId === data.sessionId)) { message.warning('新会话已创建,但暂未出现在历史列表中') } } catch { message.warning('新会话已创建,历史列表刷新失败') } } catch (err: any) { message.error(`创建新会话失败:${err?.message || '未知错误'}`) } finally { isLoadingSession.value = false } } // 加载历史会话 async function loadSession(sessionId: string) { if (sessionId === currentSessionId.value) return isLoadingSession.value = true sessionLoadDoneInfo.value = null try { // 保存当前会话消息到 IndexedDB if (currentSessionId.value && messages.value.length > 0) { await saveLocalMessages() } // 切换会话前重置上下文用量 contextUsage.value = 0 turnDuration.value = 0 // 先尝试从 IndexedDB 缓存加载 const cached = await chatDB.getSession(sessionId) if (cached && cached.messages.length > 0) { currentSessionId.value = sessionId messages.value = cached.messages welcomeMessage.value = '' // 调用 switchAiSession 恢复 Kiro 上下文 const data = await switchAiSession(sessionId) currentAgent.value = data?.agentId || '' currentModel.value = data?.modelId || '' await fetchSessionList() } else { // 无缓存:先切换会话恢复上下文 const data = await switchAiSession(sessionId) if (data?.sessionId) { currentSessionId.value = data.sessionId welcomeMessage.value = '' currentAgent.value = data.agentId || '' currentModel.value = data.modelId || '' messages.value = [] // 通过 SSE 流式加载回放消息 await streamLoadReplayMessages(sessionId) await fetchSessionList() } } } catch { // 忽略 } finally { isLoadingSession.value = false } } // SSE 流式加载回放消息 async function streamLoadReplayMessages(sessionId: string): Promise { const url = `${getBaseUrl()}/ai/sessions/${sessionId}/load` const response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${userStore.token}`, }, }) if (!response.ok) { throw new Error(`SSE 加载失败: ${response.status}`) } const reader = response.body?.getReader() if (!reader) { throw new Error('无法读取响应流') } const decoder = new TextDecoder() let buffer = '' let messageIndex = 0 while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // 按 \n\n 分割 SSE 事件 const parts = buffer.split('\n\n') buffer = parts.pop() || '' for (const part of parts) { if (!part.trim()) continue const lines = part.split('\n') let eventType = '' let data = '' for (const line of lines) { if (line.startsWith('event: ')) { eventType = line.slice(7) } else if (line.startsWith('data: ')) { data = line.slice(6) } } if (!data) continue try { const payload = JSON.parse(data) if (eventType === 'message') { // 逐条添加回放消息 const chatMsg: ChatMessage = { id: payload.messageId || `replay_${messageIndex}`, role: payload.role, content: stripContextPrefix(payload.content, payload.role), timestamp: Date.now() - 1000 + messageIndex, } // 解析历史消息中的下载标记,生成内联下载按钮 hydrateReplayMessage(chatMsg) messages.value.push(chatMsg) messageIndex++ } else if (eventType === 'done') { // done 事件:保存统计信息,用于弹窗判断 sessionLoadDoneInfo.value = { totalMessages: payload.totalMessages, totalBytes: payload.totalBytes, suggestNewSession: payload.suggestNewSession, } // 保存到 IndexedDB 缓存 if (messages.value.length > 0) { await saveLocalMessages() } } } catch { // 非 JSON 数据忽略 } } } // 处理剩余 buffer if (buffer.trim()) { const lines = buffer.split('\n') let eventType = '' let data = '' for (const line of lines) { if (line.startsWith('event: ')) { eventType = line.slice(7) } else if (line.startsWith('data: ')) { data = line.slice(6) } } if (data) { try { const payload = JSON.parse(data) if (eventType === 'message') { const chatMsg: ChatMessage = { id: payload.messageId || `replay_${messageIndex}`, role: payload.role, content: stripContextPrefix(payload.content, payload.role), timestamp: Date.now() - 1000 + messageIndex, } hydrateReplayMessage(chatMsg) messages.value.push(chatMsg) } else if (eventType === 'done') { sessionLoadDoneInfo.value = { totalMessages: payload.totalMessages, totalBytes: payload.totalBytes, suggestNewSession: payload.suggestNewSession, } if (messages.value.length > 0) { await saveLocalMessages() } } } catch { // ignore } } } } // 删除会话 async function deleteSession(sessionId: string) { try { await deleteAiSession(sessionId) // 清除 IndexedDB 中的消息 await chatDB.deleteSession(sessionId) // 如果删的是当前会话,清空消息 if (sessionId === currentSessionId.value) { messages.value = [] currentSessionId.value = '' } // 刷新列表 await fetchSessionList() // 如果没有任何会话了,自动创建新会话 if (sessionList.value.length === 0) { await createNewSession() } } catch { // 忽略 } } // 重命名会话 async function renameSession(sessionId: string, title: string) { try { await updateAiSessionTitle(sessionId, title) // 更新本地列表中的标题 const item = sessionList.value.find(s => s.sessionId === sessionId) if (item) { item.title = title } } catch { // 忽略 } } // ============ IndexedDB 本地消息管理 ============ async function saveLocalMessages() { if (!currentSessionId.value || messages.value.length === 0) return const title = getSessionTitle() // 深度普通化:递归解响应式并保留共享引用,避免 IndexedDB 结构化克隆失败 await chatDB.saveSession(currentSessionId.value, title, deepPlain(messages.value)) } async function loadLocalMessages(sessionId: string) { const session = await chatDB.getSession(sessionId) if (session) { messages.value = session.messages } else { messages.value = [] } } function getSessionTitle(): string { const firstUserMsg = messages.value.find((m) => m.role === 'user') if (firstUserMsg) { const text = firstUserMsg.content return text.length > 30 ? `${text.slice(0, 30)}...` : text } return '新对话' } // ============ 文件上传 ============ async function handleUploadFile(file: File) { // 限制数量 if (pendingFiles.value.length >= 5) { return } // 限制大小 if (file.size > 2 * 1024 * 1024) { return } isUploading.value = true try { const result = await uploadAiFile(file) if (result) { pendingFiles.value.push({ key: result.key, fileName: result.fileName, path: result.path, size: result.size, }) } } catch { // 忽略 } finally { isUploading.value = false } } function removePendingFile(index: number) { pendingFiles.value.splice(index, 1) } // ============ 消息发送 ============ function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}` } async function sendMessage(content: string) { if (!content.trim() || isStreaming.value) return // 构建实际发送的消息(包含文件上下文) let actualMessage = content.trim() // 记录本次随消息发送的附件,用于在用户气泡中展示 const attachedFiles = pendingFiles.value.map(f => ({ key: f.key, fileName: f.fileName, size: f.size, })) if (pendingFiles.value.length > 0) { const filePaths = pendingFiles.value.map(f => f.path).join(', ') actualMessage = `[用户上传了文件: ${filePaths},请使用 readTextFile 工具读取文件内容后进行分析]\n\n${actualMessage}` // 清空待发送文件 pendingFiles.value = [] } // 添加用户消息(显示原始内容,不含注入的文件提示;附带展示上传的附件) messages.value.push({ id: generateId(), role: 'user', content: content.trim(), files: attachedFiles.length > 0 ? attachedFiles : undefined, timestamp: Date.now(), }) // 添加 AI 消息占位 messages.value.push({ id: generateId(), role: 'assistant', content: '', toolCalls: [], segments: [], timestamp: Date.now(), }) const aiMessage: ChatMessage = messages.value.at(-1)! isStreaming.value = true try { const response = await fetch(`${getBaseUrl()}/ai/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userStore.token}`, }, body: JSON.stringify({ message: actualMessage, sessionId: currentSessionId.value, }), }) if (!response.ok) { aiMessage.content = `错误: ${response.status} ${response.statusText}` isStreaming.value = false return } const reader = response.body?.getReader() if (!reader) { aiMessage.content = '错误: 无法读取响应流' isStreaming.value = false return } const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // 按 \n\n 分割 SSE 事件 const parts = buffer.split('\n\n') buffer = parts.pop() || '' for (const part of parts) { if (!part.trim()) continue parseSSEEvent(part, aiMessage) } } // 处理剩余 buffer if (buffer.trim()) { parseSSEEvent(buffer, aiMessage) } } catch (err: any) { const errText = `\n\n[连接错误: ${err.message || '未知错误'}]` aiMessage.content += errText // 同步写入 segments,确保 segments 渲染分支下也能显示错误 if (!aiMessage.segments) aiMessage.segments = [] const last = aiMessage.segments.at(-1) if (last && last.kind === 'text') { last.content += errText } else { aiMessage.segments.push({ kind: 'text', content: errText }) } } finally { isStreaming.value = false // 每次消息完成后保存到 IndexedDB await saveLocalMessages() } } // 将一段文本追加到 segments:末尾是文本片段则合并,否则新建文本片段。 // file/tool 片段会“打断”文本合并,从而让后续文本另起片段,保持先后顺序。 function appendTextSegment(aiMessage: ChatMessage, text: string) { if (!text) return if (!aiMessage.segments) aiMessage.segments = [] const last = aiMessage.segments.at(-1) if (last && last.kind === 'text') { last.content += text } else { aiMessage.segments.push({ kind: 'text', content: text }) } } function parseSSEEvent(raw: string, aiMessage: ChatMessage) { const lines = raw.split('\n') let eventType = '' let data = '' for (const line of lines) { if (line.startsWith('event: ')) { eventType = line.slice(7) } else if (line.startsWith('data: ')) { data = line.slice(6) } } if (!data) return try { const payload = JSON.parse(data) switch (eventType || payload.type) { case 'text': { const text = payload.content || '' // 按 $$FILE_DOWNLOAD:{...}$$ 标记位置切分文本,保持「文本 / 文件按钮」的先后顺序 if (!aiMessage.segments) aiMessage.segments = [] let cleanText = '' for (const chunk of splitByFileMarker(text)) { if (chunk.kind === 'text') { cleanText += chunk.content appendTextSegment(aiMessage, chunk.content) } else { // 内联插入 file segment,同时写入 files 供旧渲染回退(按 key 去重) if (!aiMessage.files) aiMessage.files = [] if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file) aiMessage.segments.push({ kind: 'file', file: chunk.file }) } } aiMessage.content += cleanText break } case 'tool_call': if (!aiMessage.toolCalls) aiMessage.toolCalls = [] { // 工具对象在 toolCalls 与 segments 中共享同一引用, // 以便 tool_update / done 更新状态时两处同步生效 const tool: ToolCallInfo = { id: payload.toolCallId || '', description: payload.description || '调用工具', title: payload.name || '', status: 'running', } aiMessage.toolCalls.push(tool) if (!aiMessage.segments) aiMessage.segments = [] aiMessage.segments.push({ kind: 'tool', tool }) } break case 'tool_update': { // 按 toolCallId 关联对应的工具调用 const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.id === payload.toolCallId) if (tc) { if (payload.status) tc.status = payload.status // update 携带新 title/description 时一并更新 if (payload.description) tc.description = payload.description if (payload.name) tc.title = payload.name } break } case 'permission': { const permData = typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content aiMessage.permission = { requestId: permData.requestId, title: permData.title, options: permData.options, resolved: false, } break } case 'done': if (payload.sessionId) { currentSessionId.value = payload.sessionId } // turn 结束:将所有仍在 running 的 tool call 标记为完成 if (aiMessage.toolCalls) { for (const tc of aiMessage.toolCalls) { if (tc.status === 'running') { tc.status = 'completed' } } } // 最终扫描:处理跨 chunk 的标记 extractFileDownloads(aiMessage) break case 'welcome': welcomeMessage.value = payload.content || '' break case 'session_info': { // 实际生效的 agent / model try { const info = typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content if (info?.agentId) currentAgent.value = info.agentId if (info?.modelId) currentModel.value = info.modelId } catch { /* ignore */ } break } case 'metadata': { // 上下文用量(content 是嵌套 JSON 字符串) try { const meta = typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content if (typeof meta?.contextUsagePercent === 'number') { contextUsage.value = meta.contextUsagePercent } if (typeof meta?.turnDurationMs === 'number' && meta.turnDurationMs > 0) { turnDuration.value = meta.turnDurationMs } } catch { /* ignore */ } break } case 'error': { const errText = `\n\n[错误: ${payload.content || '未知'}]` aiMessage.content += errText // 同步写入 segments:当本轮已产生 tool/text/file 片段时,渲染走 segments 分支, // 若不同步写入,错误信息将不会显示(只存在于 content 中)。 appendTextSegment(aiMessage, errText) break } } } catch { // 非 JSON 数据忽略 } } // 去除回放消息中注入的 [context] 前缀 function stripContextPrefix(content: string, role: string): string { if (role !== 'user') return content // 匹配 "[context] userId=xxx, sessionId=xxx\n\n" 前缀 return content.replace(/^\[context\] userId=.*?, sessionId=.*?\n\n/, '') } // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记。 // 1) 从完整 content 提取所有文件,去重补进 files(保证按钮至少能显示); // 2) 归一化 segments:合并相邻文本片段后按标记切分,重建内联的 file 片段, // 同时保留 tool / 已有 file 片段的原始顺序作为锚点。 function extractFileDownloads(aiMessage: ChatMessage) { // —— 1) content 层:提取 + 去重补进 files,并清除标记文本 —— for (const chunk of splitByFileMarker(aiMessage.content)) { if (chunk.kind !== 'file') continue if (!aiMessage.files) aiMessage.files = [] if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file) } aiMessage.content = aiMessage.content.replace(FILE_MARKER_RE, '').trim() // —— 2) segments 层:归一化重建 —— if (!aiMessage.segments || aiMessage.segments.length === 0) return const rebuilt: MessageSegment[] = [] let textBuf = '' // 把累积的文本按标记切分后刷入 rebuilt(文本、内联 file 交错) const flushText = () => { if (!textBuf) return for (const chunk of splitByFileMarker(textBuf)) { rebuilt.push(chunk) } textBuf = '' } for (const seg of aiMessage.segments) { if (seg.kind === 'text') { // 累积相邻文本,使跨片段的标记残片能拼接成完整标记 textBuf += seg.content } else { // tool / 已有 file 片段:作为锚点,刷出前面的文本后原样保留 flushText() rebuilt.push(seg) } } flushText() // 去除重复的 file 片段(同一 key 只保留首次出现) const seenKeys = new Set() aiMessage.segments = rebuilt.filter((seg) => { if (seg.kind !== 'file') return true if (seenKeys.has(seg.file.key)) return false seenKeys.add(seg.file.key) return true }) } // 会话回放:为一条 assistant 历史消息解析 $$FILE_DOWNLOAD$$ 标记, // 生成内联 file 片段(复用 extractFileDownloads 的归一化逻辑)。 // 历史消息文本一次到位,先整体作为一个文本片段,再交给归一化切分。 function hydrateReplayMessage(chatMsg: ChatMessage) { if (chatMsg.role !== 'assistant' || !chatMsg.content) return chatMsg.segments = [{ kind: 'text', content: chatMsg.content }] extractFileDownloads(chatMsg) } async function resolvePermission(optionId: string) { try { // 记住当前要 resolve 的 requestId,防止 await 期间被新 permission 覆盖后误标记 const lastMsg = messages.value[messages.value.length - 1] const targetRequestId = lastMsg?.permission?.requestId await resolveAiPermission(optionId) // await 回来后检查 permission 是否仍是同一个(未被新事件覆盖) if (lastMsg?.permission && lastMsg.permission.requestId === targetRequestId) { lastMsg.permission.resolved = true } } catch { // 忽略 } } async function cancelChat() { try { await cancelAiChat() } catch { // 忽略 } } function clearMessages() { messages.value = [] if (currentSessionId.value) { chatDB.deleteSession(currentSessionId.value) } } // 清除弹窗信息(用户做出选择后) function dismissSessionLoadInfo() { sessionLoadDoneInfo.value = null } return { messages, isStreaming, currentSessionId, welcomeMessage, sessionList, isLoadingSession, pendingFiles, isUploading, sessionLoadDoneInfo, currentAgent, currentModel, contextUsage, turnDuration, sendMessage, cancelChat, clearMessages, resolvePermission, fetchSession, fetchSessionList, createNewSession, loadSession, deleteSession, handleUploadFile, removePendingFile, renameSession, dismissSessionLoadInfo, } }