index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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">发送</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. h = h.replace(/【(.+?)】/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:16rpx 0 8rpx">【$1】</p>')
  111. // URL 自动转可点击链接
  112. h = h.replace(/(https?:\/\/[^\s<>"')、。,;《》]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
  113. return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
  114. },
  115. // ============================================================
  116. // 媒体选择(图片/视频)
  117. // ============================================================
  118. pickImage() {
  119. uni.chooseImage({
  120. count: 1, sizeType: ['compressed'],
  121. success: (res) => {
  122. const path = res.tempFilePaths[0]
  123. this.fileToBase64(path, 'image', (b64, info) => {
  124. this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
  125. })
  126. }
  127. })
  128. },
  129. pickVideo() {
  130. uni.chooseVideo({
  131. maxDuration: 60, compressed: true,
  132. success: (res) => {
  133. const path = res.tempFilePath
  134. this.fileToBase64(path, 'video', (b64, info) => {
  135. this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
  136. })
  137. }
  138. })
  139. },
  140. fileToBase64(path, type, cb) {
  141. // uni-app 文件转 base64
  142. const fs = uni.getFileSystemManager()
  143. try {
  144. const data = fs.readFileSync(path, 'base64')
  145. const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
  146. const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
  147. cb(data, { mime, name })
  148. } catch (e) {
  149. uni.showToast({ title: '读取文件失败', icon: 'none' })
  150. }
  151. },
  152. clearMedia() {
  153. this.pendingMedia = null
  154. },
  155. quickAsk(text) {
  156. this.inputText = text
  157. this.sendMessage()
  158. },
  159. sendMessage() {
  160. const text = this.inputText.trim()
  161. const hasMedia = !!this.pendingMedia
  162. if (!text && !hasMedia) return
  163. this.inputText = ''
  164. const payload = { text, media: this.pendingMedia }
  165. this.pendingMedia = null
  166. this.messageQueue.push(payload)
  167. this.queueCount = this.messageQueue.length
  168. if (!this.streaming) this.processQueue()
  169. },
  170. stopCurrent() {
  171. this.abortFlag = true
  172. this.messageQueue = []
  173. this.queueCount = 0
  174. // 最后一个 AI 消息追加停止标记
  175. if (this.messages.length) {
  176. const last = this.messages[this.messages.length - 1]
  177. if (last.role === 'assistant') {
  178. last.content += ' ⏹ 已停止'
  179. last.thinking = ''
  180. }
  181. }
  182. this.streaming = false
  183. },
  184. async processQueue() {
  185. if (this.streaming || this.messageQueue.length === 0) return
  186. this.streaming = true
  187. this.abortFlag = false
  188. const payload = this.messageQueue.shift()
  189. this.queueCount = this.messageQueue.length
  190. const text = typeof payload === 'string' ? payload : (payload.text || '')
  191. const media = (typeof payload === 'string') ? null : payload.media
  192. // 构造用户消息显示
  193. let userDisplay = text
  194. if (media) {
  195. const icon = media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] '
  196. userDisplay = icon + (text || ('请分析此' + (media.type === 'video' ? '视频' : '图片')))
  197. }
  198. this.messages.push({ role: 'user', content: userDisplay })
  199. const aiIdx = this.messages.length
  200. this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
  201. this.scrollToBottom()
  202. // 构建请求体(支持多媒体)
  203. const reqBody = { message: text, conversation_id: this.conversationId }
  204. if (media) {
  205. reqBody.media_type = media.type
  206. reqBody.media_base64 = media.base64
  207. reqBody.media_mime = media.mime
  208. }
  209. await chatStream(
  210. reqBody,
  211. {
  212. onIntent: (d) => {
  213. if (this.abortFlag) return
  214. this.currentIntent = d
  215. this.messages[aiIdx].intent = d
  216. },
  217. onStatus: (d) => {
  218. if (this.abortFlag) return
  219. this.messages[aiIdx].thinking = d
  220. this.scrollToBottom()
  221. },
  222. onToken: (d) => {
  223. if (this.abortFlag) return
  224. this.messages[aiIdx].thinking = ''
  225. this.messages[aiIdx].content += d
  226. this.scrollToBottom()
  227. },
  228. onMeta: (meta) => {
  229. if (meta.cid) this.conversationId = meta.cid
  230. if (meta.intent) this.messages[aiIdx].intent = meta.intent
  231. if (meta.sources) this.messages[aiIdx].sources = meta.sources
  232. },
  233. onDone: () => {
  234. this.messages[aiIdx].thinking = ''
  235. this.scrollToBottom()
  236. },
  237. onError: (err) => {
  238. if (!this.abortFlag) this.messages[aiIdx].content = '请求失败:' + (err.message || '网络异常')
  239. }
  240. }
  241. )
  242. this.streaming = false
  243. this.scrollToBottom()
  244. if (this.messageQueue.length > 0 && !this.abortFlag) {
  245. setTimeout(() => this.processQueue(), 300)
  246. }
  247. },
  248. scrollToBottom() {
  249. this.$nextTick(() => {
  250. this.scrollTop = 999999
  251. })
  252. }
  253. }
  254. }
  255. </script>
  256. <style scoped>
  257. .ai-page { display: flex; flex-direction: column; height: 100vh; background: #F5F6FA; }
  258. .header { padding: 24rpx 32rpx 16rpx; background: linear-gradient(135deg, #002FA7, #1a3fbf); color: #fff; text-align: center; position: relative; overflow: hidden; }
  259. .header::after { content: ''; position: absolute; top: -40rpx; right: -30rpx; width: 120rpx; height: 120rpx; background: #C41E3A; border-radius: 50%; opacity: .15; }
  260. .title { font-size: 36rpx; font-weight: 700; position: relative; z-index: 1; }
  261. .subtitle { font-size: 24rpx; opacity: .85; margin-top: 8rpx; position: relative; z-index: 1; display: block; }
  262. .chat-list { flex: 1; padding: 16rpx 24rpx; }
  263. .msg-wrapper { margin-bottom: 24rpx; }
  264. .msg-user { display: flex; justify-content: flex-end; }
  265. .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; }
  266. .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); }
  267. .msg-tag { display: inline-block; font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 10rpx; margin-bottom: 8rpx; font-weight: 600; }
  268. .tag-drug { background: #E8EDF8; color: #002FA7; }
  269. .tag-usage { background: #FDEAEE; color: #C41E3A; }
  270. .tag-symptom { background: #fff3e0; color: #e65100; }
  271. .tag-regulation { background: #f3e5f5; color: #6a1b9a; }
  272. .tag-exam { background: #e0f2f1; color: #00695c; }
  273. .thinking { color: #888; font-size: 26rpx; padding: 4rpx 0; display: flex; align-items: center; }
  274. .think-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #002FA7; margin-right: 12rpx; animation: pulse 1s infinite; }
  275. @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
  276. .msg-body { font-size: 28rpx; line-height: 1.8; color: #1a1a2e; word-break: break-all; }
  277. .msg-body h3 { font-size: 30rpx; color: #002FA7; margin: 16rpx 0 8rpx; }
  278. .msg-body strong { color: #111; }
  279. .msg-body p { margin: 0 0 12rpx 0; }
  280. .msg-sources { margin-top: 16rpx; padding-top: 12rpx; border-top: 1rpx solid #eee; font-size: 22rpx; color: #aaa; }
  281. .src-item { color: #888; }
  282. .queue-hint { text-align: center; padding: 8rpx; font-size: 24rpx; color: #888; background: #fafafa; }
  283. .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
  284. .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
  285. .media-preview { display: flex; align-items: center; padding: 12rpx 24rpx; background: #fafafa; border-top: 1rpx solid #eee; gap: 12rpx; }
  286. .preview-thumb { width: 80rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
  287. .preview-vid { width: 120rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
  288. .preview-name { font-size: 24rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  289. .preview-remove { font-size: 28rpx; color: #C41E3A; padding: 8rpx; }
  290. .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
  291. .media-btn { font-size: 36rpx; padding: 8rpx; min-width: 56rpx; text-align: center; }
  292. .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
  293. .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
  294. .send-btn[disabled] { opacity: .4; }
  295. .stop-btn { background: #999; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; }
  296. .welcome { text-align: center; color: #888; padding: 80rpx 40rpx; }
  297. .welcome-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
  298. </style>