useAiChat.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  1. import { ref, toRaw, isRef, unref } from 'vue'
  2. import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile, MessageSegment } from './interface'
  3. import { useUserStore } from '@/stores/user'
  4. import { useDiscreteApi } from '@/composable/useDiscreteApi'
  5. import { chatDB } from './chatDB'
  6. import {
  7. getAiSessionList,
  8. getCurrentAiSession,
  9. createAiSession,
  10. switchAiSession,
  11. deleteAiSession,
  12. cancelAiChat,
  13. resolveAiPermission,
  14. uploadAiFile,
  15. updateAiSessionTitle,
  16. } from '@/service/aiChat'
  17. // SSE done 事件数据
  18. export interface SessionLoadDoneInfo {
  19. totalMessages: number
  20. totalBytes: number
  21. suggestNewSession: boolean
  22. }
  23. // 文件下载标记:$$FILE_DOWNLOAD:{...}$$(模块作用域,避免每次调用重新编译)
  24. const FILE_MARKER_RE = /\$\$FILE_DOWNLOAD:([\s\S]*?)\$\$/g
  25. // 全局消息提示(模块作用域,避免每次调用重建 discrete api 实例)
  26. const { message } = useDiscreteApi()
  27. // 解析出的行内文件片段(用于内联下载按钮)
  28. interface ParsedFileChunk {
  29. kind: 'file'
  30. file: { key: string, fileName: string, size: number }
  31. }
  32. // 解析出的文本片段
  33. interface ParsedTextChunk {
  34. kind: 'text'
  35. content: string
  36. }
  37. type ParsedChunk = ParsedTextChunk | ParsedFileChunk
  38. // 深度普通化:把 Vue 响应式对象(含所有嵌套层级)递归还原为纯 JS 对象,
  39. // 使其可被 structured clone(IndexedDB.put)。相比 toRaw 只解一层、
  40. // 或 JSON 往返会丢失共享引用,这里用 WeakMap 记录已拷贝对象,
  41. // 保证如 tool 在 toolCalls 与 segments 中的“共享同一引用”在拷贝后依然成立,
  42. // 从而读取会话、Vue 重新响应式化后,tool 状态同步语义得以保留。
  43. // 同时兼容循环引用。
  44. function deepPlain<T>(input: T, seen: WeakMap<object, any> = new WeakMap()): T {
  45. const raw: any = toRaw(isRef(input) ? unref(input) : input)
  46. if (raw === null || typeof raw !== 'object') return raw as T
  47. if (seen.has(raw)) return seen.get(raw)
  48. const out: any = Array.isArray(raw) ? [] : {}
  49. seen.set(raw, out) // 先登记再递归,命中共享/循环引用时复用同一份拷贝
  50. for (const key of Object.keys(raw)) {
  51. out[key] = deepPlain(raw[key], seen)
  52. }
  53. return out as T
  54. }
  55. // 按 $$FILE_DOWNLOAD$$ 标记把文本切分为「文本 / 文件」有序片段。
  56. // 非法/残缺标记会被丢弃(既不产生文件片段,也不保留标记文本)。
  57. function splitByFileMarker(text: string): ParsedChunk[] {
  58. const chunks: ParsedChunk[] = []
  59. let lastIndex = 0
  60. for (const m of text.matchAll(FILE_MARKER_RE)) {
  61. const idx = m.index ?? 0
  62. const before = text.slice(lastIndex, idx)
  63. if (before) chunks.push({ kind: 'text', content: before })
  64. try {
  65. const fileData = JSON.parse(m[1])
  66. chunks.push({ kind: 'file', file: { key: fileData.key, fileName: fileData.fileName, size: fileData.size } })
  67. }
  68. catch { /* ignore malformed / partial marker */ }
  69. lastIndex = idx + m[0].length
  70. }
  71. const rest = text.slice(lastIndex)
  72. if (rest) chunks.push({ kind: 'text', content: rest })
  73. return chunks
  74. }
  75. export function useAiChat() {
  76. const messages = ref<ChatMessage[]>([])
  77. const isStreaming = ref(false)
  78. const currentSessionId = ref('')
  79. const welcomeMessage = ref('')
  80. const sessionList = ref<SessionListItem[]>([])
  81. const pendingFiles = ref<UploadedFile[]>([])
  82. const isUploading = ref(false)
  83. const isLoadingSession = ref(false)
  84. const sessionLoadDoneInfo = ref<SessionLoadDoneInfo | null>(null)
  85. // 实际生效的 agent / model 与上下文用量(百分比 0~100)
  86. const currentAgent = ref('')
  87. const currentModel = ref('')
  88. const contextUsage = ref(0)
  89. const turnDuration = ref(0) // 上一轮耗时(毫秒)
  90. const userStore = useUserStore()
  91. function getBaseUrl(): string {
  92. const base = import.meta.env.BASE_URL || '/'
  93. return `${base}api`
  94. }
  95. // ============ 会话列表管理 ============
  96. // 获取会话列表
  97. async function fetchSessionList() {
  98. try {
  99. const data = await getAiSessionList()
  100. if (data) {
  101. sessionList.value = data
  102. // 清理本地缓存中不存在于后端列表的会话
  103. const serverIds = new Set(data.map((s: any) => s.sessionId))
  104. const localIds = await chatDB.getAllSessionIds()
  105. for (const localId of localIds) {
  106. if (!serverIds.has(localId)) {
  107. await chatDB.deleteSession(localId)
  108. }
  109. }
  110. }
  111. }
  112. catch {
  113. // 忽略
  114. }
  115. }
  116. // 获取当前会话信息(首次打开时调用)
  117. async function fetchSession() {
  118. isLoadingSession.value = true
  119. try {
  120. // 先查询会话列表
  121. const list = await getAiSessionList()
  122. if (list) {
  123. sessionList.value = list
  124. }
  125. // 如果存在已激活会话,直接使用
  126. const activeSession = list?.find(s => s.isActive)
  127. if (activeSession) {
  128. currentSessionId.value = activeSession.sessionId
  129. // 从 IndexedDB 恢复本地消息
  130. await loadLocalMessages(activeSession.sessionId)
  131. // 获取当前会话的 agent/model/用量(首屏展示)
  132. try {
  133. const info = await getCurrentAiSession()
  134. currentAgent.value = info?.agentId || ''
  135. currentModel.value = info?.modelId || ''
  136. contextUsage.value = info?.contextUsagePercent || 0
  137. turnDuration.value = info?.turnDurationMs || 0
  138. }
  139. catch { /* 忽略 */ }
  140. return
  141. }
  142. // 没有激活会话但列表中有历史会话:加载最近的一个
  143. if (list && list.length > 0) {
  144. const latest = list[0]
  145. currentSessionId.value = latest.sessionId
  146. await loadLocalMessages(latest.sessionId)
  147. // 后台恢复 Kiro 上下文
  148. const data = await switchAiSession(latest.sessionId)
  149. currentAgent.value = data?.agentId || ''
  150. currentModel.value = data?.modelId || ''
  151. await fetchSessionList()
  152. return
  153. }
  154. // 列表完全为空:创建新会话
  155. await createNewSession()
  156. }
  157. catch {
  158. // 忽略
  159. }
  160. finally {
  161. isLoadingSession.value = false
  162. }
  163. }
  164. // 创建新会话
  165. async function createNewSession() {
  166. isLoadingSession.value = true
  167. try {
  168. const data = await createAiSession()
  169. // 后端未返回有效 sessionId:视为创建失败,明确提示,不静默停留在旧会话
  170. if (!data?.sessionId) {
  171. message.error('创建新会话失败,请稍后重试')
  172. return
  173. }
  174. // 保存当前会话消息到 IndexedDB
  175. if (currentSessionId.value && messages.value.length > 0) {
  176. await saveLocalMessages()
  177. }
  178. // 切换到新会话(先落地本地状态,保证 UI 一定切过去)
  179. currentSessionId.value = data.sessionId
  180. messages.value = []
  181. welcomeMessage.value = data.welcomeMessage || '欢迎使用AI助手'
  182. currentAgent.value = data.agentId || ''
  183. currentModel.value = data.modelId || ''
  184. contextUsage.value = 0
  185. turnDuration.value = 0
  186. // 刷新列表:新会话作为活跃会话由后端返回(IsActive=true),需要重新拉取才能在侧边栏正确展示/高亮。
  187. // 列表刷新失败不影响已完成的切换,仅退化为侧边栏暂不显示,下次进入会自动纠正。
  188. try {
  189. await fetchSessionList()
  190. // 校验新会话确实出现在列表中,未出现给出提示(便于定位后端归档/列表问题)
  191. if (!sessionList.value.some(s => s.sessionId === data.sessionId)) {
  192. message.warning('新会话已创建,但暂未出现在历史列表中')
  193. }
  194. }
  195. catch {
  196. message.warning('新会话已创建,历史列表刷新失败')
  197. }
  198. }
  199. catch (err: any) {
  200. message.error(`创建新会话失败:${err?.message || '未知错误'}`)
  201. }
  202. finally {
  203. isLoadingSession.value = false
  204. }
  205. }
  206. // 加载历史会话
  207. async function loadSession(sessionId: string) {
  208. if (sessionId === currentSessionId.value) return
  209. isLoadingSession.value = true
  210. sessionLoadDoneInfo.value = null
  211. try {
  212. // 保存当前会话消息到 IndexedDB
  213. if (currentSessionId.value && messages.value.length > 0) {
  214. await saveLocalMessages()
  215. }
  216. // 切换会话前重置上下文用量
  217. contextUsage.value = 0
  218. turnDuration.value = 0
  219. // 先尝试从 IndexedDB 缓存加载
  220. const cached = await chatDB.getSession(sessionId)
  221. if (cached && cached.messages.length > 0) {
  222. currentSessionId.value = sessionId
  223. messages.value = cached.messages
  224. welcomeMessage.value = ''
  225. // 调用 switchAiSession 恢复 Kiro 上下文
  226. const data = await switchAiSession(sessionId)
  227. currentAgent.value = data?.agentId || ''
  228. currentModel.value = data?.modelId || ''
  229. await fetchSessionList()
  230. }
  231. else {
  232. // 无缓存:先切换会话恢复上下文
  233. const data = await switchAiSession(sessionId)
  234. if (data?.sessionId) {
  235. currentSessionId.value = data.sessionId
  236. welcomeMessage.value = ''
  237. currentAgent.value = data.agentId || ''
  238. currentModel.value = data.modelId || ''
  239. messages.value = []
  240. // 通过 SSE 流式加载回放消息
  241. await streamLoadReplayMessages(sessionId)
  242. await fetchSessionList()
  243. }
  244. }
  245. }
  246. catch {
  247. // 忽略
  248. }
  249. finally {
  250. isLoadingSession.value = false
  251. }
  252. }
  253. // SSE 流式加载回放消息
  254. async function streamLoadReplayMessages(sessionId: string): Promise<void> {
  255. const url = `${getBaseUrl()}/ai/sessions/${sessionId}/load`
  256. const response = await fetch(url, {
  257. method: 'GET',
  258. headers: {
  259. Authorization: `Bearer ${userStore.token}`,
  260. },
  261. })
  262. if (!response.ok) {
  263. throw new Error(`SSE 加载失败: ${response.status}`)
  264. }
  265. const reader = response.body?.getReader()
  266. if (!reader) {
  267. throw new Error('无法读取响应流')
  268. }
  269. const decoder = new TextDecoder()
  270. let buffer = ''
  271. let messageIndex = 0
  272. while (true) {
  273. const { done, value } = await reader.read()
  274. if (done) break
  275. buffer += decoder.decode(value, { stream: true })
  276. // 按 \n\n 分割 SSE 事件
  277. const parts = buffer.split('\n\n')
  278. buffer = parts.pop() || ''
  279. for (const part of parts) {
  280. if (!part.trim()) continue
  281. const lines = part.split('\n')
  282. let eventType = ''
  283. let data = ''
  284. for (const line of lines) {
  285. if (line.startsWith('event: ')) {
  286. eventType = line.slice(7)
  287. } else if (line.startsWith('data: ')) {
  288. data = line.slice(6)
  289. }
  290. }
  291. if (!data) continue
  292. try {
  293. const payload = JSON.parse(data)
  294. if (eventType === 'message') {
  295. // 逐条添加回放消息
  296. const chatMsg: ChatMessage = {
  297. id: payload.messageId || `replay_${messageIndex}`,
  298. role: payload.role,
  299. content: stripContextPrefix(payload.content, payload.role),
  300. timestamp: Date.now() - 1000 + messageIndex,
  301. }
  302. // 解析历史消息中的下载标记,生成内联下载按钮
  303. hydrateReplayMessage(chatMsg)
  304. messages.value.push(chatMsg)
  305. messageIndex++
  306. }
  307. else if (eventType === 'done') {
  308. // done 事件:保存统计信息,用于弹窗判断
  309. sessionLoadDoneInfo.value = {
  310. totalMessages: payload.totalMessages,
  311. totalBytes: payload.totalBytes,
  312. suggestNewSession: payload.suggestNewSession,
  313. }
  314. // 保存到 IndexedDB 缓存
  315. if (messages.value.length > 0) {
  316. await saveLocalMessages()
  317. }
  318. }
  319. }
  320. catch {
  321. // 非 JSON 数据忽略
  322. }
  323. }
  324. }
  325. // 处理剩余 buffer
  326. if (buffer.trim()) {
  327. const lines = buffer.split('\n')
  328. let eventType = ''
  329. let data = ''
  330. for (const line of lines) {
  331. if (line.startsWith('event: ')) {
  332. eventType = line.slice(7)
  333. } else if (line.startsWith('data: ')) {
  334. data = line.slice(6)
  335. }
  336. }
  337. if (data) {
  338. try {
  339. const payload = JSON.parse(data)
  340. if (eventType === 'message') {
  341. const chatMsg: ChatMessage = {
  342. id: payload.messageId || `replay_${messageIndex}`,
  343. role: payload.role,
  344. content: stripContextPrefix(payload.content, payload.role),
  345. timestamp: Date.now() - 1000 + messageIndex,
  346. }
  347. hydrateReplayMessage(chatMsg)
  348. messages.value.push(chatMsg)
  349. }
  350. else if (eventType === 'done') {
  351. sessionLoadDoneInfo.value = {
  352. totalMessages: payload.totalMessages,
  353. totalBytes: payload.totalBytes,
  354. suggestNewSession: payload.suggestNewSession,
  355. }
  356. if (messages.value.length > 0) {
  357. await saveLocalMessages()
  358. }
  359. }
  360. }
  361. catch {
  362. // ignore
  363. }
  364. }
  365. }
  366. }
  367. // 删除会话
  368. async function deleteSession(sessionId: string) {
  369. try {
  370. await deleteAiSession(sessionId)
  371. // 清除 IndexedDB 中的消息
  372. await chatDB.deleteSession(sessionId)
  373. // 如果删的是当前会话,清空消息
  374. if (sessionId === currentSessionId.value) {
  375. messages.value = []
  376. currentSessionId.value = ''
  377. }
  378. // 刷新列表
  379. await fetchSessionList()
  380. // 如果没有任何会话了,自动创建新会话
  381. if (sessionList.value.length === 0) {
  382. await createNewSession()
  383. }
  384. }
  385. catch {
  386. // 忽略
  387. }
  388. }
  389. // 重命名会话
  390. async function renameSession(sessionId: string, title: string) {
  391. try {
  392. await updateAiSessionTitle(sessionId, title)
  393. // 更新本地列表中的标题
  394. const item = sessionList.value.find(s => s.sessionId === sessionId)
  395. if (item) {
  396. item.title = title
  397. }
  398. }
  399. catch {
  400. // 忽略
  401. }
  402. }
  403. // ============ IndexedDB 本地消息管理 ============
  404. async function saveLocalMessages() {
  405. if (!currentSessionId.value || messages.value.length === 0) return
  406. const title = getSessionTitle()
  407. // 深度普通化:递归解响应式并保留共享引用,避免 IndexedDB 结构化克隆失败
  408. await chatDB.saveSession(currentSessionId.value, title, deepPlain(messages.value))
  409. }
  410. async function loadLocalMessages(sessionId: string) {
  411. const session = await chatDB.getSession(sessionId)
  412. if (session) {
  413. messages.value = session.messages
  414. } else {
  415. messages.value = []
  416. }
  417. }
  418. function getSessionTitle(): string {
  419. const firstUserMsg = messages.value.find((m) => m.role === 'user')
  420. if (firstUserMsg) {
  421. const text = firstUserMsg.content
  422. return text.length > 30 ? `${text.slice(0, 30)}...` : text
  423. }
  424. return '新对话'
  425. }
  426. // ============ 文件上传 ============
  427. async function handleUploadFile(file: File) {
  428. // 限制数量
  429. if (pendingFiles.value.length >= 5) {
  430. return
  431. }
  432. // 限制大小
  433. if (file.size > 2 * 1024 * 1024) {
  434. return
  435. }
  436. isUploading.value = true
  437. try {
  438. const result = await uploadAiFile(file)
  439. if (result) {
  440. pendingFiles.value.push({
  441. key: result.key,
  442. fileName: result.fileName,
  443. path: result.path,
  444. size: result.size,
  445. })
  446. }
  447. }
  448. catch {
  449. // 忽略
  450. }
  451. finally {
  452. isUploading.value = false
  453. }
  454. }
  455. function removePendingFile(index: number) {
  456. pendingFiles.value.splice(index, 1)
  457. }
  458. // ============ 消息发送 ============
  459. function generateId(): string {
  460. return `${Date.now()}-${Math.random().toString(36).slice(2)}`
  461. }
  462. async function sendMessage(content: string) {
  463. if (!content.trim() || isStreaming.value) return
  464. // 构建实际发送的消息(包含文件上下文)
  465. let actualMessage = content.trim()
  466. // 记录本次随消息发送的附件,用于在用户气泡中展示
  467. const attachedFiles = pendingFiles.value.map(f => ({
  468. key: f.key,
  469. fileName: f.fileName,
  470. size: f.size,
  471. }))
  472. if (pendingFiles.value.length > 0) {
  473. const filePaths = pendingFiles.value.map(f => f.path).join(', ')
  474. actualMessage = `[用户上传了文件: ${filePaths},请使用 readTextFile 工具读取文件内容后进行分析]\n\n${actualMessage}`
  475. // 清空待发送文件
  476. pendingFiles.value = []
  477. }
  478. // 添加用户消息(显示原始内容,不含注入的文件提示;附带展示上传的附件)
  479. messages.value.push({
  480. id: generateId(),
  481. role: 'user',
  482. content: content.trim(),
  483. files: attachedFiles.length > 0 ? attachedFiles : undefined,
  484. timestamp: Date.now(),
  485. })
  486. // 添加 AI 消息占位
  487. messages.value.push({
  488. id: generateId(),
  489. role: 'assistant',
  490. content: '',
  491. toolCalls: [],
  492. segments: [],
  493. timestamp: Date.now(),
  494. })
  495. const aiMessage: ChatMessage = messages.value.at(-1)!
  496. isStreaming.value = true
  497. try {
  498. const response = await fetch(`${getBaseUrl()}/ai/chat`, {
  499. method: 'POST',
  500. headers: {
  501. 'Content-Type': 'application/json',
  502. Authorization: `Bearer ${userStore.token}`,
  503. },
  504. body: JSON.stringify({
  505. message: actualMessage,
  506. sessionId: currentSessionId.value,
  507. }),
  508. })
  509. if (!response.ok) {
  510. aiMessage.content = `错误: ${response.status} ${response.statusText}`
  511. isStreaming.value = false
  512. return
  513. }
  514. const reader = response.body?.getReader()
  515. if (!reader) {
  516. aiMessage.content = '错误: 无法读取响应流'
  517. isStreaming.value = false
  518. return
  519. }
  520. const decoder = new TextDecoder()
  521. let buffer = ''
  522. while (true) {
  523. const { done, value } = await reader.read()
  524. if (done) break
  525. buffer += decoder.decode(value, { stream: true })
  526. // 按 \n\n 分割 SSE 事件
  527. const parts = buffer.split('\n\n')
  528. buffer = parts.pop() || ''
  529. for (const part of parts) {
  530. if (!part.trim()) continue
  531. parseSSEEvent(part, aiMessage)
  532. }
  533. }
  534. // 处理剩余 buffer
  535. if (buffer.trim()) {
  536. parseSSEEvent(buffer, aiMessage)
  537. }
  538. } catch (err: any) {
  539. const errText = `\n\n[连接错误: ${err.message || '未知错误'}]`
  540. aiMessage.content += errText
  541. // 同步写入 segments,确保 segments 渲染分支下也能显示错误
  542. if (!aiMessage.segments) aiMessage.segments = []
  543. const last = aiMessage.segments.at(-1)
  544. if (last && last.kind === 'text') {
  545. last.content += errText
  546. }
  547. else {
  548. aiMessage.segments.push({ kind: 'text', content: errText })
  549. }
  550. } finally {
  551. isStreaming.value = false
  552. // 每次消息完成后保存到 IndexedDB
  553. await saveLocalMessages()
  554. }
  555. }
  556. // 将一段文本追加到 segments:末尾是文本片段则合并,否则新建文本片段。
  557. // file/tool 片段会“打断”文本合并,从而让后续文本另起片段,保持先后顺序。
  558. function appendTextSegment(aiMessage: ChatMessage, text: string) {
  559. if (!text) return
  560. if (!aiMessage.segments) aiMessage.segments = []
  561. const last = aiMessage.segments.at(-1)
  562. if (last && last.kind === 'text') {
  563. last.content += text
  564. }
  565. else {
  566. aiMessage.segments.push({ kind: 'text', content: text })
  567. }
  568. }
  569. function parseSSEEvent(raw: string, aiMessage: ChatMessage) {
  570. const lines = raw.split('\n')
  571. let eventType = ''
  572. let data = ''
  573. for (const line of lines) {
  574. if (line.startsWith('event: ')) {
  575. eventType = line.slice(7)
  576. } else if (line.startsWith('data: ')) {
  577. data = line.slice(6)
  578. }
  579. }
  580. if (!data) return
  581. try {
  582. const payload = JSON.parse(data)
  583. switch (eventType || payload.type) {
  584. case 'text': {
  585. const text = payload.content || ''
  586. // 按 $$FILE_DOWNLOAD:{...}$$ 标记位置切分文本,保持「文本 / 文件按钮」的先后顺序
  587. if (!aiMessage.segments) aiMessage.segments = []
  588. let cleanText = ''
  589. for (const chunk of splitByFileMarker(text)) {
  590. if (chunk.kind === 'text') {
  591. cleanText += chunk.content
  592. appendTextSegment(aiMessage, chunk.content)
  593. }
  594. else {
  595. // 内联插入 file segment,同时写入 files 供旧渲染回退(按 key 去重)
  596. if (!aiMessage.files) aiMessage.files = []
  597. if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
  598. aiMessage.segments.push({ kind: 'file', file: chunk.file })
  599. }
  600. }
  601. aiMessage.content += cleanText
  602. break
  603. }
  604. case 'tool_call':
  605. if (!aiMessage.toolCalls) aiMessage.toolCalls = []
  606. {
  607. // 工具对象在 toolCalls 与 segments 中共享同一引用,
  608. // 以便 tool_update / done 更新状态时两处同步生效
  609. const tool: ToolCallInfo = {
  610. id: payload.toolCallId || '',
  611. description: payload.description || '调用工具',
  612. title: payload.name || '',
  613. status: 'running',
  614. }
  615. aiMessage.toolCalls.push(tool)
  616. if (!aiMessage.segments) aiMessage.segments = []
  617. aiMessage.segments.push({ kind: 'tool', tool })
  618. }
  619. break
  620. case 'tool_update': {
  621. // 按 toolCallId 关联对应的工具调用
  622. const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.id === payload.toolCallId)
  623. if (tc) {
  624. if (payload.status) tc.status = payload.status
  625. // update 携带新 title/description 时一并更新
  626. if (payload.description) tc.description = payload.description
  627. if (payload.name) tc.title = payload.name
  628. }
  629. break
  630. }
  631. case 'permission': {
  632. const permData =
  633. typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
  634. aiMessage.permission = {
  635. requestId: permData.requestId,
  636. title: permData.title,
  637. options: permData.options,
  638. resolved: false,
  639. }
  640. break
  641. }
  642. case 'done':
  643. if (payload.sessionId) {
  644. currentSessionId.value = payload.sessionId
  645. }
  646. // turn 结束:将所有仍在 running 的 tool call 标记为完成
  647. if (aiMessage.toolCalls) {
  648. for (const tc of aiMessage.toolCalls) {
  649. if (tc.status === 'running') {
  650. tc.status = 'completed'
  651. }
  652. }
  653. }
  654. // 最终扫描:处理跨 chunk 的标记
  655. extractFileDownloads(aiMessage)
  656. break
  657. case 'welcome':
  658. welcomeMessage.value = payload.content || ''
  659. break
  660. case 'session_info': {
  661. // 实际生效的 agent / model
  662. try {
  663. const info =
  664. typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
  665. if (info?.agentId) currentAgent.value = info.agentId
  666. if (info?.modelId) currentModel.value = info.modelId
  667. }
  668. catch { /* ignore */ }
  669. break
  670. }
  671. case 'metadata': {
  672. // 上下文用量(content 是嵌套 JSON 字符串)
  673. try {
  674. const meta =
  675. typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
  676. if (typeof meta?.contextUsagePercent === 'number') {
  677. contextUsage.value = meta.contextUsagePercent
  678. }
  679. if (typeof meta?.turnDurationMs === 'number' && meta.turnDurationMs > 0) {
  680. turnDuration.value = meta.turnDurationMs
  681. }
  682. }
  683. catch { /* ignore */ }
  684. break
  685. }
  686. case 'error': {
  687. const errText = `\n\n[错误: ${payload.content || '未知'}]`
  688. aiMessage.content += errText
  689. // 同步写入 segments:当本轮已产生 tool/text/file 片段时,渲染走 segments 分支,
  690. // 若不同步写入,错误信息将不会显示(只存在于 content 中)。
  691. appendTextSegment(aiMessage, errText)
  692. break
  693. }
  694. }
  695. } catch {
  696. // 非 JSON 数据忽略
  697. }
  698. }
  699. // 去除回放消息中注入的 [context] 前缀
  700. function stripContextPrefix(content: string, role: string): string {
  701. if (role !== 'user') return content
  702. // 匹配 "[context] userId=xxx, sessionId=xxx\n\n" 前缀
  703. return content.replace(/^\[context\] userId=.*?, sessionId=.*?\n\n/, '')
  704. }
  705. // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记。
  706. // 1) 从完整 content 提取所有文件,去重补进 files(保证按钮至少能显示);
  707. // 2) 归一化 segments:合并相邻文本片段后按标记切分,重建内联的 file 片段,
  708. // 同时保留 tool / 已有 file 片段的原始顺序作为锚点。
  709. function extractFileDownloads(aiMessage: ChatMessage) {
  710. // —— 1) content 层:提取 + 去重补进 files,并清除标记文本 ——
  711. for (const chunk of splitByFileMarker(aiMessage.content)) {
  712. if (chunk.kind !== 'file') continue
  713. if (!aiMessage.files) aiMessage.files = []
  714. if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
  715. }
  716. aiMessage.content = aiMessage.content.replace(FILE_MARKER_RE, '').trim()
  717. // —— 2) segments 层:归一化重建 ——
  718. if (!aiMessage.segments || aiMessage.segments.length === 0) return
  719. const rebuilt: MessageSegment[] = []
  720. let textBuf = ''
  721. // 把累积的文本按标记切分后刷入 rebuilt(文本、内联 file 交错)
  722. const flushText = () => {
  723. if (!textBuf) return
  724. for (const chunk of splitByFileMarker(textBuf)) {
  725. rebuilt.push(chunk)
  726. }
  727. textBuf = ''
  728. }
  729. for (const seg of aiMessage.segments) {
  730. if (seg.kind === 'text') {
  731. // 累积相邻文本,使跨片段的标记残片能拼接成完整标记
  732. textBuf += seg.content
  733. }
  734. else {
  735. // tool / 已有 file 片段:作为锚点,刷出前面的文本后原样保留
  736. flushText()
  737. rebuilt.push(seg)
  738. }
  739. }
  740. flushText()
  741. // 去除重复的 file 片段(同一 key 只保留首次出现)
  742. const seenKeys = new Set<string>()
  743. aiMessage.segments = rebuilt.filter((seg) => {
  744. if (seg.kind !== 'file') return true
  745. if (seenKeys.has(seg.file.key)) return false
  746. seenKeys.add(seg.file.key)
  747. return true
  748. })
  749. }
  750. // 会话回放:为一条 assistant 历史消息解析 $$FILE_DOWNLOAD$$ 标记,
  751. // 生成内联 file 片段(复用 extractFileDownloads 的归一化逻辑)。
  752. // 历史消息文本一次到位,先整体作为一个文本片段,再交给归一化切分。
  753. function hydrateReplayMessage(chatMsg: ChatMessage) {
  754. if (chatMsg.role !== 'assistant' || !chatMsg.content) return
  755. chatMsg.segments = [{ kind: 'text', content: chatMsg.content }]
  756. extractFileDownloads(chatMsg)
  757. }
  758. async function resolvePermission(optionId: string) {
  759. try {
  760. // 记住当前要 resolve 的 requestId,防止 await 期间被新 permission 覆盖后误标记
  761. const lastMsg = messages.value[messages.value.length - 1]
  762. const targetRequestId = lastMsg?.permission?.requestId
  763. await resolveAiPermission(optionId)
  764. // await 回来后检查 permission 是否仍是同一个(未被新事件覆盖)
  765. if (lastMsg?.permission && lastMsg.permission.requestId === targetRequestId) {
  766. lastMsg.permission.resolved = true
  767. }
  768. }
  769. catch {
  770. // 忽略
  771. }
  772. }
  773. async function cancelChat() {
  774. try {
  775. await cancelAiChat()
  776. }
  777. catch {
  778. // 忽略
  779. }
  780. }
  781. function clearMessages() {
  782. messages.value = []
  783. if (currentSessionId.value) {
  784. chatDB.deleteSession(currentSessionId.value)
  785. }
  786. }
  787. // 清除弹窗信息(用户做出选择后)
  788. function dismissSessionLoadInfo() {
  789. sessionLoadDoneInfo.value = null
  790. }
  791. return {
  792. messages,
  793. isStreaming,
  794. currentSessionId,
  795. welcomeMessage,
  796. sessionList,
  797. isLoadingSession,
  798. pendingFiles,
  799. isUploading,
  800. sessionLoadDoneInfo,
  801. currentAgent,
  802. currentModel,
  803. contextUsage,
  804. turnDuration,
  805. sendMessage,
  806. cancelChat,
  807. clearMessages,
  808. resolvePermission,
  809. fetchSession,
  810. fetchSessionList,
  811. createNewSession,
  812. loadSession,
  813. deleteSession,
  814. handleUploadFile,
  815. removePendingFile,
  816. renameSession,
  817. dismissSessionLoadInfo,
  818. }
  819. }