useAiChat.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { ref, reactive } from 'vue'
  2. import type { ChatMessage, ToolCallInfo, SessionListItem } from './interface'
  3. import { useUserStore } from '@/stores/user'
  4. import { chatDB } from './chatDB'
  5. export function useAiChat() {
  6. const messages = ref<ChatMessage[]>([])
  7. const isStreaming = ref(false)
  8. const currentSessionId = ref('')
  9. const welcomeMessage = ref('')
  10. const sessionList = ref<SessionListItem[]>([])
  11. const isLoadingSession = ref(false)
  12. const userStore = useUserStore()
  13. function getBaseUrl(): string {
  14. const base = import.meta.env.BASE_URL || '/'
  15. return `${base}api`
  16. }
  17. // ============ 会话列表管理 ============
  18. // 获取会话列表
  19. async function fetchSessionList() {
  20. try {
  21. const resp = await fetch(`${getBaseUrl()}/ai/sessions/list`, {
  22. headers: { Authorization: `Bearer ${userStore.token}` },
  23. })
  24. if (resp.ok) {
  25. const json = await resp.json()
  26. if (json.data) {
  27. sessionList.value = json.data
  28. // 清理本地缓存中不存在于后端列表的会话
  29. const serverIds = new Set(json.data.map((s: any) => s.sessionId))
  30. const localIds = await chatDB.getAllSessionIds()
  31. for (const localId of localIds) {
  32. if (!serverIds.has(localId)) {
  33. await chatDB.deleteSession(localId)
  34. }
  35. }
  36. }
  37. }
  38. } catch {
  39. // 忽略
  40. }
  41. }
  42. // 获取当前会话信息
  43. async function fetchSession() {
  44. isLoadingSession.value = true
  45. try {
  46. const resp = await fetch(`${getBaseUrl()}/ai/sessions`, {
  47. headers: { Authorization: `Bearer ${userStore.token}` },
  48. })
  49. if (resp.ok) {
  50. const json = await resp.json()
  51. if (json.data?.welcomeMessage) {
  52. welcomeMessage.value = json.data.welcomeMessage
  53. }
  54. if (json.data?.sessionId) {
  55. currentSessionId.value = json.data.sessionId
  56. // 从 IndexedDB 恢复本地消息
  57. await loadLocalMessages(json.data.sessionId)
  58. }
  59. }
  60. }
  61. catch {
  62. // 忽略
  63. }
  64. finally {
  65. isLoadingSession.value = false
  66. }
  67. // 同时获取会话列表
  68. await fetchSessionList()
  69. }
  70. // 创建新会话
  71. async function createNewSession() {
  72. isLoadingSession.value = true
  73. try {
  74. const resp = await fetch(`${getBaseUrl()}/ai/sessions/new`, {
  75. method: 'POST',
  76. headers: {
  77. 'Content-Type': 'application/json',
  78. Authorization: `Bearer ${userStore.token}`,
  79. },
  80. })
  81. if (resp.ok) {
  82. const json = await resp.json()
  83. if (json.data?.sessionId) {
  84. // 保存当前会话消息到 IndexedDB
  85. if (currentSessionId.value && messages.value.length > 0) {
  86. await saveLocalMessages()
  87. }
  88. // 切换到新会话
  89. currentSessionId.value = json.data.sessionId
  90. messages.value = []
  91. welcomeMessage.value = json.data.welcomeMessage || '欢迎使用AI助手'
  92. // 刷新列表
  93. await fetchSessionList()
  94. }
  95. }
  96. }
  97. catch {
  98. // 忽略
  99. }
  100. finally {
  101. isLoadingSession.value = false
  102. }
  103. }
  104. // 加载历史会话
  105. async function loadSession(sessionId: string) {
  106. if (sessionId === currentSessionId.value) return
  107. isLoadingSession.value = true
  108. try {
  109. // 保存当前会话消息到 IndexedDB
  110. if (currentSessionId.value && messages.value.length > 0) {
  111. await saveLocalMessages()
  112. }
  113. // 先尝试从 IndexedDB 缓存加载
  114. const cached = await chatDB.getSession(sessionId)
  115. if (cached && cached.messages.length > 0) {
  116. currentSessionId.value = sessionId
  117. messages.value = cached.messages
  118. welcomeMessage.value = ''
  119. // 后台静默调用 session/load 恢复 Kiro 上下文(不等待回放结果)
  120. fetch(`${getBaseUrl()}/ai/sessions/${sessionId}/load`, {
  121. method: 'POST',
  122. headers: {
  123. 'Content-Type': 'application/json',
  124. Authorization: `Bearer ${userStore.token}`,
  125. },
  126. })
  127. await fetchSessionList()
  128. } else {
  129. // 无缓存:调用 session/load 获取回放消息
  130. const resp = await fetch(`${getBaseUrl()}/ai/sessions/${sessionId}/load`, {
  131. method: 'POST',
  132. headers: {
  133. 'Content-Type': 'application/json',
  134. Authorization: `Bearer ${userStore.token}`,
  135. },
  136. })
  137. if (resp.ok) {
  138. const json = await resp.json()
  139. if (json.data?.sessionId) {
  140. currentSessionId.value = json.data.sessionId
  141. welcomeMessage.value = ''
  142. // 将回放消息转为 ChatMessage 格式
  143. if (json.data.replayMessages && json.data.replayMessages.length > 0) {
  144. messages.value = json.data.replayMessages.map((m: any, i: number) => ({
  145. id: m.messageId || `replay_${i}`,
  146. role: m.role,
  147. content: m.content,
  148. timestamp: Date.now() - (json.data.replayMessages.length - i) * 1000,
  149. }))
  150. // 保存到缓存
  151. await saveLocalMessages()
  152. } else {
  153. messages.value = []
  154. }
  155. await fetchSessionList()
  156. }
  157. }
  158. }
  159. } catch {
  160. // 忽略
  161. } finally {
  162. isLoadingSession.value = false
  163. }
  164. }
  165. // 删除会话
  166. async function deleteSession(sessionId: string) {
  167. try {
  168. const resp = await fetch(`${getBaseUrl()}/ai/sessions/${sessionId}`, {
  169. method: 'DELETE',
  170. headers: { Authorization: `Bearer ${userStore.token}` },
  171. })
  172. if (resp.ok) {
  173. // 清除 IndexedDB 中的消息
  174. await chatDB.deleteSession(sessionId)
  175. // 如果删的是当前会话,清空消息
  176. if (sessionId === currentSessionId.value) {
  177. messages.value = []
  178. currentSessionId.value = ''
  179. }
  180. // 刷新列表
  181. await fetchSessionList()
  182. // 如果没有任何会话了,自动创建新会话
  183. if (sessionList.value.length === 0) {
  184. await createNewSession()
  185. }
  186. }
  187. }
  188. catch {
  189. // 忽略
  190. }
  191. }
  192. // ============ IndexedDB 本地消息管理 ============
  193. async function saveLocalMessages() {
  194. if (!currentSessionId.value || messages.value.length === 0) return
  195. const title = getSessionTitle()
  196. await chatDB.saveSession(currentSessionId.value, title, toRaw(messages.value))
  197. }
  198. async function loadLocalMessages(sessionId: string) {
  199. const session = await chatDB.getSession(sessionId)
  200. if (session) {
  201. messages.value = session.messages
  202. } else {
  203. messages.value = []
  204. }
  205. }
  206. function getSessionTitle(): string {
  207. const firstUserMsg = messages.value.find((m) => m.role === 'user')
  208. if (firstUserMsg) {
  209. const text = firstUserMsg.content
  210. return text.length > 30 ? `${text.slice(0, 30)}...` : text
  211. }
  212. return '新对话'
  213. }
  214. // ============ 消息发送 ============
  215. function generateId(): string {
  216. return `${Date.now()}-${Math.random().toString(36).slice(2)}`
  217. }
  218. async function sendMessage(content: string) {
  219. if (!content.trim() || isStreaming.value) return
  220. // 添加用户消息
  221. messages.value.push({
  222. id: generateId(),
  223. role: 'user',
  224. content: content.trim(),
  225. timestamp: Date.now(),
  226. })
  227. // 添加 AI 消息占位
  228. messages.value.push({
  229. id: generateId(),
  230. role: 'assistant',
  231. content: '',
  232. toolCalls: [],
  233. timestamp: Date.now(),
  234. })
  235. const aiMessage: ChatMessage = messages.value.at(-1)!
  236. isStreaming.value = true
  237. try {
  238. const response = await fetch(`${getBaseUrl()}/ai/chat`, {
  239. method: 'POST',
  240. headers: {
  241. 'Content-Type': 'application/json',
  242. Authorization: `Bearer ${userStore.token}`,
  243. },
  244. body: JSON.stringify({
  245. message: content.trim(),
  246. sessionId: currentSessionId.value,
  247. }),
  248. })
  249. if (!response.ok) {
  250. aiMessage.content = `错误: ${response.status} ${response.statusText}`
  251. isStreaming.value = false
  252. return
  253. }
  254. const reader = response.body?.getReader()
  255. if (!reader) {
  256. aiMessage.content = '错误: 无法读取响应流'
  257. isStreaming.value = false
  258. return
  259. }
  260. const decoder = new TextDecoder()
  261. let buffer = ''
  262. while (true) {
  263. const { done, value } = await reader.read()
  264. if (done) break
  265. buffer += decoder.decode(value, { stream: true })
  266. // 按 \n\n 分割 SSE 事件
  267. const parts = buffer.split('\n\n')
  268. buffer = parts.pop() || ''
  269. for (const part of parts) {
  270. if (!part.trim()) continue
  271. parseSSEEvent(part, aiMessage)
  272. }
  273. }
  274. // 处理剩余 buffer
  275. if (buffer.trim()) {
  276. parseSSEEvent(buffer, aiMessage)
  277. }
  278. } catch (err: any) {
  279. aiMessage.content += `\n\n[连接错误: ${err.message || '未知错误'}]`
  280. } finally {
  281. isStreaming.value = false
  282. // 每次消息完成后保存到 IndexedDB
  283. await saveLocalMessages()
  284. }
  285. }
  286. function parseSSEEvent(raw: string, aiMessage: ChatMessage) {
  287. const lines = raw.split('\n')
  288. let eventType = ''
  289. let data = ''
  290. for (const line of lines) {
  291. if (line.startsWith('event: ')) {
  292. eventType = line.slice(7)
  293. } else if (line.startsWith('data: ')) {
  294. data = line.slice(6)
  295. }
  296. }
  297. if (!data) return
  298. try {
  299. const payload = JSON.parse(data)
  300. switch (eventType || payload.type) {
  301. case 'text':
  302. aiMessage.content += payload.content || ''
  303. break
  304. case 'tool_call':
  305. if (!aiMessage.toolCalls) aiMessage.toolCalls = []
  306. aiMessage.toolCalls.push({
  307. name: payload.name || '',
  308. status: 'running',
  309. })
  310. break
  311. case 'tool_update': {
  312. const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.name === payload.name)
  313. if (tc) tc.status = payload.status || 'completed'
  314. break
  315. }
  316. case 'permission': {
  317. const permData =
  318. typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
  319. aiMessage.permission = {
  320. requestId: permData.requestId,
  321. title: permData.title,
  322. options: permData.options,
  323. resolved: false,
  324. }
  325. break
  326. }
  327. case 'file_created': {
  328. const fileData =
  329. typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
  330. if (!aiMessage.files) aiMessage.files = []
  331. aiMessage.files.push({
  332. key: fileData.key,
  333. fileName: fileData.fileName,
  334. size: fileData.size,
  335. })
  336. break
  337. }
  338. case 'done':
  339. if (payload.sessionId) {
  340. currentSessionId.value = payload.sessionId
  341. }
  342. break
  343. case 'welcome':
  344. welcomeMessage.value = payload.content || ''
  345. break
  346. case 'error':
  347. aiMessage.content += `\n\n[错误: ${payload.content || '未知'}]`
  348. break
  349. }
  350. } catch {
  351. // 非 JSON 数据忽略
  352. }
  353. }
  354. async function resolvePermission(optionId: string) {
  355. try {
  356. await fetch(`${getBaseUrl()}/ai/permission`, {
  357. method: 'POST',
  358. headers: {
  359. 'Content-Type': 'application/json',
  360. Authorization: `Bearer ${userStore.token}`,
  361. },
  362. body: JSON.stringify({ optionId }),
  363. })
  364. const lastMsg = messages.value[messages.value.length - 1]
  365. if (lastMsg?.permission) {
  366. lastMsg.permission.resolved = true
  367. }
  368. } catch {
  369. // 忽略
  370. }
  371. }
  372. async function cancelChat() {
  373. try {
  374. await fetch(`${getBaseUrl()}/ai/cancel`, {
  375. method: 'POST',
  376. headers: { Authorization: `Bearer ${userStore.token}` },
  377. })
  378. } catch {
  379. // 忽略
  380. }
  381. }
  382. function clearMessages() {
  383. messages.value = []
  384. if (currentSessionId.value) {
  385. chatDB.deleteSession(currentSessionId.value)
  386. }
  387. }
  388. return {
  389. messages,
  390. isStreaming,
  391. currentSessionId,
  392. welcomeMessage,
  393. sessionList,
  394. isLoadingSession,
  395. sendMessage,
  396. cancelChat,
  397. clearMessages,
  398. resolvePermission,
  399. fetchSession,
  400. fetchSessionList,
  401. createNewSession,
  402. loadSession,
  403. deleteSession,
  404. }
  405. }