index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. <template>
  2. <view class="ai-page">
  3. <view class="header">
  4. <text class="title">💊 AI 药典助手</text>
  5. <text class="subtitle">基于《中华人民共和国药典》& Qwen 大模型</text>
  6. </view>
  7. <scroll-view class="chat-list" scroll-y :scroll-top="scrollTop" :scroll-with-animation="true">
  8. <view v-if="messages.length === 0" class="welcome">
  9. <text class="welcome-icon">🔬</text>
  10. <text>输入药品名称、成分或用药问题</text>
  11. </view>
  12. <view v-for="(msg, idx) in messages" :key="idx" class="msg-wrapper">
  13. <!-- 用户消息 -->
  14. <view v-if="msg.role === 'user'" class="msg-user">
  15. <text class="msg-content">{{ msg.content }}</text>
  16. </view>
  17. <!-- AI 消息 -->
  18. <view v-else class="msg-ai">
  19. <view class="msg-tag" :class="tagClass(msg.intent)">
  20. {{ msg.intent ? intentLabel(msg.intent) : 'AI 药典助手' }}
  21. </view>
  22. <!-- 思考中 -->
  23. <view v-if="msg.thinking" class="thinking">
  24. <view class="think-dot"></view>
  25. <text>{{ msg.thinking }}</text>
  26. </view>
  27. <!-- 正文 -->
  28. <rich-text class="msg-body" :nodes="renderMarkdown(msg.content)"></rich-text>
  29. <!-- 来源 -->
  30. <view v-if="msg.sources && msg.sources.length" class="msg-sources">
  31. <text>📚 </text>
  32. <text v-for="(s, si) in msg.sources.slice(0,3)" :key="si" class="src-item">
  33. {{ s.section || s.name || '' }} ({{ (s.score || 0).toFixed(2) }})
  34. <text v-if="si < Math.min(2, msg.sources.length-1)"> · </text>
  35. </text>
  36. </view>
  37. </view>
  38. </view>
  39. </scroll-view>
  40. <!-- 队列提示 -->
  41. <view v-if="queueCount > 0" class="queue-hint">⏳ 还有 {{ queueCount }} 个问题等待回答</view>
  42. <!-- 媒体预览 -->
  43. <view v-if="pendingMedia" class="media-preview">
  44. <image v-if="pendingMedia.type==='image'" :src="pendingMedia.preview" mode="aspectFit" class="preview-thumb"/>
  45. <video v-if="pendingMedia.type==='video'" :src="pendingMedia.preview" class="preview-vid"/>
  46. <text class="preview-name">{{ pendingMedia.type==='video'?'🎬':'📷' }} {{ pendingMedia.name }}</text>
  47. <text class="preview-remove" @click="clearMedia">✕</text>
  48. </view>
  49. <view class="tags">
  50. <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
  51. <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
  52. <text class="tag-btn" @click="quickAsk('布洛芬用法用量')">布洛芬用法用量</text>
  53. <text class="tag-btn" @click="quickAsk('感冒发烧吃什么药')">感冒发烧吃什么药</text>
  54. </view>
  55. <view class="input-area">
  56. <text class="media-btn" @click="pickImage">📷</text>
  57. <text class="media-btn" @click="pickVideo">🎬</text>
  58. <input
  59. v-model="inputText"
  60. class="chat-input"
  61. :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,可输入补充问题...':'图片已就绪,可输入补充问题...'):'输入药品问题...'"
  62. :disabled="streaming"
  63. @confirm="sendMessage"
  64. confirm-type="send"
  65. />
  66. <button class="send-btn" :disabled="!inputText.trim() && !pendingMedia" @click="sendMessage">{{pendingMedia?'发送(含附件)':'发送'}}</button>
  67. <button v-if="streaming" class="stop-btn" @click="stopCurrent">停止</button>
  68. </view>
  69. </view>
  70. </template>
  71. <script>
  72. import { chatStream } from '@/api/ai.js'
  73. export default {
  74. data() {
  75. return {
  76. messages: [],
  77. inputText: '',
  78. conversationId: '',
  79. streaming: false,
  80. queueCount: 0,
  81. messageQueue: [],
  82. scrollTop: 0,
  83. currentIntent: '',
  84. abortFlag: false,
  85. pendingMedia: null // {type:'image'|'video', base64:'...', mime:'...', name:'...', preview:'...'}
  86. }
  87. },
  88. methods: {
  89. intentLabel(i) {
  90. const m = { drug_query:'药品查询', usage_guide:'用法用量', symptom_advice:'用药建议',
  91. regulation:'法规条款', exam_tutor:'考试辅导' }
  92. return m[i] || '综合查询'
  93. },
  94. tagClass(i) {
  95. const m = { drug_query:'tag-drug', usage_guide:'tag-usage', symptom_advice:'tag-symptom',
  96. regulation:'tag-regulation', exam_tutor:'tag-exam' }
  97. return m[i] || ''
  98. },
  99. renderMarkdown(content) {
  100. if (!content) return ''
  101. let h = content
  102. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  103. h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  104. // 修复残缺括号
  105. var secs='(?:结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|AI 声明|AI声明|来源说明|免责声明)';
  106. h = h.replace(new RegExp('(?<![【])】\\s*('+secs+')','g'),'【$1】');
  107. h = h.replace(new RegExp('(?<![【])('+secs+')】','g'),'【$1】');
  108. h = h.replace(/\n\n+/g, '</p><p>')
  109. h = h.replace(/\n/g, '<br>')
  110. // 段落标题:吞掉紧跟的 <br>
  111. h = h.replace(/【(.+?)】(?:<br>)?/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:14rpx 0 2rpx">【$1】</p>')
  112. // 去掉标题 <p> 前后的 <br>(避免标题自带 margin + <br> = 多余空行)
  113. h = h.replace(/<br><p style="font-size:30rpx/g, '<p style="font-size:30rpx')
  114. h = h.replace(/<\/p><br>/g, '</p>')
  115. // markdown 链接 [文字](url) → <a>
  116. h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s<>)]+)\)/g, '<a href="$2" style="color:#002FA7;text-decoration:underline">$1</a>')
  117. // 纯 URL 自动转可点击链接
  118. h = h.replace(/(?<!href=")(?<!href=')(https?:\/\/[^\s<>]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
  119. return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
  120. },
  121. // ============================================================
  122. // 媒体选择(图片/视频)
  123. // ============================================================
  124. pickImage() {
  125. uni.chooseImage({
  126. count: 1, sizeType: ['compressed'],
  127. success: (res) => {
  128. const path = res.tempFilePaths[0]
  129. this.fileToBase64(path, 'image', (b64, info) => {
  130. this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
  131. })
  132. }
  133. })
  134. },
  135. pickVideo() {
  136. uni.chooseVideo({
  137. maxDuration: 60, compressed: true,
  138. success: (res) => {
  139. const path = res.tempFilePath
  140. this.fileToBase64(path, 'video', (b64, info) => {
  141. this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
  142. })
  143. }
  144. })
  145. },
  146. fileToBase64(path, type, cb) {
  147. // uni-app 文件转 base64
  148. const fs = uni.getFileSystemManager()
  149. try {
  150. const data = fs.readFileSync(path, 'base64')
  151. const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
  152. const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
  153. cb(data, { mime, name })
  154. } catch (e) {
  155. uni.showToast({ title: '读取文件失败', icon: 'none' })
  156. }
  157. },
  158. clearMedia() {
  159. this.pendingMedia = null
  160. },
  161. quickAsk(text) {
  162. this.inputText = text
  163. this.sendMessage()
  164. },
  165. sendMessage() {
  166. const text = this.inputText.trim()
  167. const hasMedia = !!this.pendingMedia
  168. if (!text && !hasMedia) return
  169. this.inputText = ''
  170. const payload = { text, media: this.pendingMedia }
  171. this.pendingMedia = null
  172. this.messageQueue.push(payload)
  173. this.queueCount = this.messageQueue.length
  174. if (!this.streaming) this.processQueue()
  175. },
  176. stopCurrent() {
  177. this.abortFlag = true
  178. this.messageQueue = []
  179. this.queueCount = 0
  180. // 最后一个 AI 消息追加停止标记
  181. if (this.messages.length) {
  182. const last = this.messages[this.messages.length - 1]
  183. if (last.role === 'assistant') {
  184. last.content += ' ⏹ 已停止'
  185. last.thinking = ''
  186. }
  187. }
  188. this.streaming = false
  189. },
  190. async processQueue() {
  191. if (this.streaming || this.messageQueue.length === 0) return
  192. this.streaming = true
  193. this.abortFlag = false
  194. const payload = this.messageQueue.shift()
  195. this.queueCount = this.messageQueue.length
  196. const text = typeof payload === 'string' ? payload : (payload.text || '')
  197. const media = (typeof payload === 'string') ? null : payload.media
  198. // 构造用户消息显示
  199. let userDisplay = text
  200. if (media) {
  201. const icon = media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] '
  202. userDisplay = icon + (text || ('请分析此' + (media.type === 'video' ? '视频' : '图片')))
  203. }
  204. this.messages.push({ role: 'user', content: userDisplay })
  205. const aiIdx = this.messages.length
  206. this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
  207. this.scrollToBottom()
  208. // 构建请求体(支持多媒体)
  209. const reqBody = { message: text, conversation_id: this.conversationId }
  210. if (media) {
  211. reqBody.media_type = media.type
  212. reqBody.media_base64 = media.base64
  213. reqBody.media_mime = media.mime
  214. }
  215. await chatStream(
  216. reqBody,
  217. {
  218. onIntent: (d) => {
  219. if (this.abortFlag) return
  220. this.currentIntent = d
  221. this.messages[aiIdx].intent = d
  222. },
  223. onStatus: (d) => {
  224. if (this.abortFlag) return
  225. this.messages[aiIdx].thinking = d
  226. this.scrollToBottom()
  227. },
  228. onToken: (d) => {
  229. if (this.abortFlag) return
  230. this.messages[aiIdx].thinking = ''
  231. this.messages[aiIdx].content += d
  232. this.scrollToBottom()
  233. },
  234. onMeta: (meta) => {
  235. if (meta.cid) this.conversationId = meta.cid
  236. if (meta.intent) this.messages[aiIdx].intent = meta.intent
  237. if (meta.sources) this.messages[aiIdx].sources = meta.sources
  238. },
  239. onDone: () => {
  240. this.messages[aiIdx].thinking = ''
  241. this.scrollToBottom()
  242. },
  243. onError: (err) => {
  244. if (!this.abortFlag) this.messages[aiIdx].content = '请求失败:' + (err.message || '网络异常')
  245. }
  246. }
  247. )
  248. this.streaming = false
  249. this.scrollToBottom()
  250. if (this.messageQueue.length > 0 && !this.abortFlag) {
  251. setTimeout(() => this.processQueue(), 300)
  252. }
  253. },
  254. scrollToBottom() {
  255. this.$nextTick(() => {
  256. this.scrollTop = 999999
  257. })
  258. }
  259. }
  260. }
  261. </script>
  262. <style scoped>
  263. .ai-page { display: flex; flex-direction: column; height: 100vh; background: #F5F6FA; }
  264. .header { padding: 24rpx 32rpx 16rpx; background: linear-gradient(135deg, #002FA7, #1a3fbf); color: #fff; text-align: center; position: relative; overflow: hidden; }
  265. .header::after { content: ''; position: absolute; top: -40rpx; right: -30rpx; width: 120rpx; height: 120rpx; background: #C41E3A; border-radius: 50%; opacity: .15; }
  266. .title { font-size: 36rpx; font-weight: 700; position: relative; z-index: 1; }
  267. .subtitle { font-size: 24rpx; opacity: .85; margin-top: 8rpx; position: relative; z-index: 1; display: block; }
  268. .chat-list { flex: 1; padding: 16rpx 24rpx; }
  269. .msg-wrapper { margin-bottom: 24rpx; }
  270. .msg-user { display: flex; justify-content: flex-end; }
  271. .msg-user .msg-content { background: #002FA7; color: #fff; padding: 16rpx 24rpx; border-radius: 16rpx 16rpx 4rpx 16rpx; max-width: 80%; font-size: 28rpx; line-height: 1.6; }
  272. .msg-ai { background: #fff; padding: 20rpx 24rpx; border-radius: 0 16rpx 16rpx 16rpx; border-left: 6rpx solid #002FA7; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
  273. .msg-tag { display: inline-block; font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 10rpx; margin-bottom: 8rpx; font-weight: 600; }
  274. .tag-drug { background: #E8EDF8; color: #002FA7; }
  275. .tag-usage { background: #FDEAEE; color: #C41E3A; }
  276. .tag-symptom { background: #fff3e0; color: #e65100; }
  277. .tag-regulation { background: #f3e5f5; color: #6a1b9a; }
  278. .tag-exam { background: #e0f2f1; color: #00695c; }
  279. .thinking { color: #888; font-size: 26rpx; padding: 4rpx 0; display: flex; align-items: center; }
  280. .think-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #002FA7; margin-right: 12rpx; animation: pulse 1s infinite; }
  281. @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
  282. .msg-body { font-size: 28rpx; line-height: 1.8; color: #1a1a2e; word-break: break-all; }
  283. .msg-body h3 { font-size: 30rpx; color: #002FA7; margin: 16rpx 0 8rpx; }
  284. .msg-body strong { color: #111; }
  285. .msg-body p { margin: 0 0 12rpx 0; }
  286. .msg-sources { margin-top: 16rpx; padding-top: 12rpx; border-top: 1rpx solid #eee; font-size: 22rpx; color: #aaa; }
  287. .src-item { color: #888; }
  288. .queue-hint { text-align: center; padding: 8rpx; font-size: 24rpx; color: #888; background: #fafafa; }
  289. .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
  290. .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
  291. .media-preview { display: flex; align-items: center; padding: 12rpx 24rpx; background: #fafafa; border-top: 1rpx solid #eee; gap: 12rpx; }
  292. .preview-thumb { width: 80rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
  293. .preview-vid { width: 120rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
  294. .preview-name { font-size: 24rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  295. .preview-remove { font-size: 28rpx; color: #C41E3A; padding: 8rpx; }
  296. .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
  297. .media-btn { font-size: 36rpx; padding: 8rpx; min-width: 56rpx; text-align: center; }
  298. .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
  299. .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
  300. .send-btn[disabled] { opacity: .4; }
  301. .stop-btn { background: #999; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; }
  302. .welcome { text-align: center; color: #888; padding: 80rpx 40rpx; }
  303. .welcome-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
  304. </style>