useAgui.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { reactive, ref } from 'vue'
  2. /**
  3. * AG-UI 客户端(自实现 SSE,容错解析)
  4. *
  5. * 请求:POST 标准 AG-UI RunAgentInput(threadId/runId/messages/tools/context/state)
  6. * 响应:text/event-stream,逐条按 `data:{...}` 解析,根据 event.type 分发渲染。
  7. *
  8. * 不使用 @ag-ui/client 的内置 verifyEvents 严格校验,避免后端事件顺序与其预期
  9. * 不完全一致时整条流被中断、界面无输出。字段做 camelCase/snake_case 双写兼容。
  10. */
  11. export function useAgui() {
  12. // 展示用消息:{ id, role: 'user'|'assistant'|'tool'|'error', content }
  13. const messages = reactive([])
  14. const running = ref(false)
  15. const error = ref('')
  16. // 发给后端的会话历史(仅 user / assistant 文本)
  17. const history = []
  18. // 当前会话 ID(sessionId / threadId);开启新对话时会重新生成
  19. const sessionId = ref(crypto.randomUUID())
  20. const pick = (obj, ...keys) => {
  21. for (const k of keys) if (obj && obj[k] != null) return obj[k]
  22. return undefined
  23. }
  24. function findOrCreate(id, role) {
  25. let m = messages.find((x) => x.id === id)
  26. if (!m) {
  27. m = { id: id || crypto.randomUUID(), role, content: '' }
  28. messages.push(m)
  29. }
  30. return m
  31. }
  32. // 根据一条 AG-UI 事件更新界面
  33. function dispatch(ev) {
  34. const type = (ev.type || ev.event || '').toUpperCase()
  35. switch (type) {
  36. case 'TEXT_MESSAGE_START': {
  37. const id = pick(ev, 'messageId', 'message_id') || crypto.randomUUID()
  38. findOrCreate(id, 'assistant')
  39. break
  40. }
  41. case 'TEXT_MESSAGE_CONTENT': {
  42. const id = pick(ev, 'messageId', 'message_id')
  43. const delta = pick(ev, 'delta', 'content') ?? ''
  44. findOrCreate(id, 'assistant').content += delta
  45. break
  46. }
  47. case 'TEXT_MESSAGE_END': {
  48. const id = pick(ev, 'messageId', 'message_id')
  49. const m = messages.find((x) => x.id === id)
  50. if (m) history.push({ id: m.id, role: 'assistant', content: m.content })
  51. break
  52. }
  53. case 'TOOL_CALL_START': {
  54. const id = pick(ev, 'toolCallId', 'tool_call_id') || crypto.randomUUID()
  55. const name = pick(ev, 'toolCallName', 'tool_call_name') || '未知工具'
  56. findOrCreate(id, 'tool').content = `🔧 调用工具:${name}`
  57. break
  58. }
  59. case 'TOOL_CALL_ARGS': {
  60. const id = pick(ev, 'toolCallId', 'tool_call_id')
  61. const delta = pick(ev, 'delta', 'args') ?? ''
  62. const m = messages.find((x) => x.id === id)
  63. if (m) m.content += delta
  64. break
  65. }
  66. case 'TOOL_CALL_RESULT': {
  67. const id = pick(ev, 'toolCallId', 'tool_call_id')
  68. const content = pick(ev, 'content', 'result') ?? ''
  69. const m = messages.find((x) => x.id === id)
  70. if (m) m.content += `\n↳ 结果:${content}`
  71. break
  72. }
  73. case 'RUN_ERROR': {
  74. error.value = pick(ev, 'message', 'error') || '智能体运行出错'
  75. break
  76. }
  77. // RUN_STARTED / RUN_FINISHED / STATE_* / TOOL_CALL_END 等无需特殊处理
  78. default:
  79. break
  80. }
  81. }
  82. // 解析 SSE 文本块(可能包含多条事件)
  83. function parseChunk(buffer) {
  84. // 以空行分隔事件;返回剩余未完成的尾部
  85. const parts = buffer.split(/\r?\n\r?\n/)
  86. const rest = parts.pop() // 最后一段可能不完整,留到下次
  87. for (const block of parts) {
  88. const dataLines = []
  89. for (const raw of block.split(/\r?\n/)) {
  90. const line = raw.replace(/\r$/, '')
  91. if (!line || line.startsWith(':')) continue // 空行或注释/心跳
  92. if (line.startsWith('data:')) {
  93. dataLines.push(line.slice(5).replace(/^ /, '')) // 去掉 data: 及一个可选空格
  94. }
  95. // event:/id:/retry: 行忽略,type 从 data 的 JSON 里取
  96. }
  97. if (!dataLines.length) continue
  98. const payload = dataLines.join('\n').trim()
  99. if (!payload || payload === '[DONE]') continue
  100. try {
  101. dispatch(JSON.parse(payload))
  102. } catch (e) {
  103. // 单条解析失败不影响后续
  104. console.warn('[AG-UI] 无法解析事件:', payload)
  105. }
  106. }
  107. return rest
  108. }
  109. async function send(url, text, userId) {
  110. const content = (text || '').trim()
  111. if (!content) return
  112. if (!url) {
  113. error.value = '请先选择或输入智能体 URL'
  114. return
  115. }
  116. if (running.value) return
  117. error.value = ''
  118. const userMsg = { id: crypto.randomUUID(), role: 'user', content }
  119. messages.push({ ...userMsg })
  120. history.push(userMsg)
  121. const body = {
  122. threadId: sessionId.value,
  123. runId: crypto.randomUUID(),
  124. state: {},
  125. messages: [userMsg],
  126. tools: [],
  127. context: [],
  128. forwardedProps: {}
  129. }
  130. running.value = true
  131. try {
  132. const headers = {
  133. 'Content-Type': 'application/json',
  134. Accept: 'text/event-stream'
  135. }
  136. if (userId) headers['x-user-id'] = userId
  137. if (sessionId.value) headers['x-session-id'] = sessionId.value
  138. const res = await fetch(url, {
  139. method: 'POST',
  140. headers,
  141. body: JSON.stringify(body)
  142. })
  143. if (!res.ok) {
  144. error.value = `请求失败:HTTP ${res.status} ${res.statusText}`
  145. return
  146. }
  147. if (!res.body) {
  148. error.value = '响应无数据流(body 为空)'
  149. return
  150. }
  151. const reader = res.body.getReader()
  152. const decoder = new TextDecoder('utf-8')
  153. let buffer = ''
  154. // 逐块读取并解析
  155. // eslint-disable-next-line no-constant-condition
  156. while (true) {
  157. const { value, done } = await reader.read()
  158. if (done) break
  159. buffer += decoder.decode(value, { stream: true })
  160. buffer = parseChunk(buffer)
  161. }
  162. // 冲刷尾部(补一个空行确保最后一条事件被处理)
  163. parseChunk(buffer + '\n\n')
  164. } catch (e) {
  165. error.value = e?.message || String(e)
  166. } finally {
  167. running.value = false
  168. }
  169. }
  170. function reset() {
  171. messages.splice(0, messages.length)
  172. history.splice(0, history.length)
  173. error.value = ''
  174. sessionId.value = crypto.randomUUID()
  175. }
  176. // 开启新对话:清空界面与历史,并生成一个新的 sessionId
  177. function newChat() {
  178. reset()
  179. return sessionId.value
  180. }
  181. return { messages, running, error, sessionId, send, reset, newChat }
  182. }