ai.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /**
  2. * AI 模块 API 封装
  3. * SSE 流式对话 — 支持 intent / status / content / meta 多事件
  4. */
  5. const BASE_URL = 'https://your-api-domain.com/api/v1'
  6. // 本地调试: const BASE_URL = 'http://localhost:8000/api/v1'
  7. function getToken() {
  8. return uni.getStorageSync('access_token') || ''
  9. }
  10. function request(url, options = {}) {
  11. return new Promise((resolve, reject) => {
  12. uni.request({
  13. url: BASE_URL + url,
  14. method: options.method || 'GET',
  15. data: options.data,
  16. header: {
  17. 'Content-Type': 'application/json',
  18. 'Authorization': 'Bearer ' + getToken()
  19. },
  20. success: (res) => {
  21. if (res.statusCode === 200) resolve(res.data)
  22. else if (res.statusCode === 401) {
  23. uni.navigateTo({ url: '/pages/login/index' })
  24. reject(new Error('Unauthorized'))
  25. } else if (res.statusCode === 429) {
  26. // 限流:优先读取服务器返回的具体提示信息
  27. const msg = res.data?.detail || res.data?.message || res.data?.error || '请求过于频繁,请稍后再试'
  28. reject(new Error(msg))
  29. } else reject(new Error(res.data?.detail || res.data?.message || res.data?.error || 'HTTP ' + res.statusCode))
  30. },
  31. fail: (err) => reject(err)
  32. })
  33. })
  34. }
  35. /**
  36. * SSE 流式对话(支持多事件类型)
  37. * callbacks: { onIntent, onStatus, onToken, onMeta, onDone, onError }
  38. */
  39. export function chatStream(data, callbacks) {
  40. const { onToken, onDone, onError, onIntent, onStatus, onMeta } = callbacks
  41. let buffer = ''
  42. let currentEvent = ''
  43. // 自动选择端点:有媒体附件走 stream-multimodal,纯文本走 stream
  44. const hasMedia = !!(data.media_base64 && data.media_type)
  45. const endpoint = hasMedia ? '/chat/stream-multimodal' : '/chat/stream'
  46. const reqData = {
  47. message: data.message || '',
  48. conversation_id: data.conversation_id || ''
  49. }
  50. if (hasMedia) {
  51. reqData.media_type = data.media_type
  52. reqData.media_base64 = data.media_base64
  53. reqData.media_mime = data.media_mime || ''
  54. }
  55. let aborted = false
  56. const task = uni.request({
  57. url: BASE_URL + endpoint,
  58. method: 'POST',
  59. data: reqData,
  60. header: {
  61. 'Content-Type': 'application/json',
  62. 'Authorization': 'Bearer ' + getToken()
  63. },
  64. enableChunked: true,
  65. responseType: 'text',
  66. success: () => {},
  67. fail: (err) => {
  68. if (aborted) return
  69. if (onError) onError(err)
  70. }
  71. })
  72. task.onHeadersReceived((res) => {
  73. // 检测 HTTP 状态码:429 或非 2xx 时立即中断
  74. if (res.statusCode === 429) {
  75. aborted = true
  76. task.abort()
  77. if (onError) onError(new Error('请求过于频繁,请稍后再试'))
  78. return
  79. }
  80. if (res.statusCode >= 400) {
  81. aborted = true
  82. task.abort()
  83. if (onError) onError(new Error('服务异常,请稍后再试'))
  84. return
  85. }
  86. })
  87. task.onChunkReceived((res) => {
  88. try {
  89. const chunk = typeof res.data === 'string'
  90. ? res.data
  91. : new TextDecoder('utf-8').decode(res.data)
  92. buffer += chunk
  93. const lines = buffer.split('\n')
  94. buffer = lines.pop() || ''
  95. for (const line of lines) {
  96. if (line.startsWith('event: ')) {
  97. currentEvent = line.slice(7).trim()
  98. continue
  99. }
  100. if (!line.startsWith('data: ')) continue
  101. const d = line.slice(6)
  102. if (d === '[DONE]') continue
  103. if (currentEvent === 'intent' && onIntent) {
  104. onIntent(d)
  105. } else if (currentEvent === 'status' && onStatus) {
  106. onStatus(d)
  107. } else if (currentEvent === 'meta' && onMeta) {
  108. try { onMeta(JSON.parse(d)) } catch(e) {}
  109. } else if (currentEvent === 'content' || !currentEvent) {
  110. if (onToken) onToken(d)
  111. }
  112. currentEvent = ''
  113. }
  114. } catch (e) {
  115. console.error('SSE parse error:', e)
  116. }
  117. })
  118. return task
  119. }
  120. /**
  121. * 非流式对话(获取来源)
  122. */
  123. export function chatAsk(data) {
  124. return request('/chat/ask', {
  125. method: 'POST',
  126. data: {
  127. message: data.message,
  128. conversation_id: data.conversation_id || ''
  129. }
  130. })
  131. }
  132. /**
  133. * 对话历史
  134. */
  135. export function getHistory(page = 1, pageSize = 20) {
  136. return request('/chat/history?page=' + page + '&page_size=' + pageSize)
  137. }
  138. /**
  139. * 对话详情
  140. */
  141. export function getConversation(cid) {
  142. return request('/chat/history/' + cid)
  143. }
  144. /**
  145. * 提交反馈
  146. */
  147. export function submitFeedback(conversationId, messageId, feedback) {
  148. return request('/chat/feedback', {
  149. method: 'POST',
  150. data: { conversation_id: conversationId, message_id: messageId, feedback }
  151. })
  152. }
  153. /**
  154. * 药品搜索
  155. */
  156. export function searchDrug(keyword, options = {}) {
  157. const { category, page = 1, pageSize = 20 } = options
  158. let url = '/drug/search?keyword=' + encodeURIComponent(keyword) +
  159. '&page=' + page + '&page_size=' + pageSize
  160. if (category) url += '&category=' + encodeURIComponent(category)
  161. return request(url)
  162. }
  163. export function getDrugDetail(drugId) { return request('/drug/' + drugId) }
  164. export function getCategoryTree() { return request('/drug/category/tree') }