| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912 |
- 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<T>(input: T, seen: WeakMap<object, any> = 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<ChatMessage[]>([])
- const isStreaming = ref(false)
- const currentSessionId = ref('')
- const welcomeMessage = ref('')
- const sessionList = ref<SessionListItem[]>([])
- const pendingFiles = ref<UploadedFile[]>([])
- const isUploading = ref(false)
- const isLoadingSession = ref(false)
- const sessionLoadDoneInfo = ref<SessionLoadDoneInfo | null>(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<void> {
- 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<string>()
- 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,
- }
- }
|