index.vue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <template>
  2. <view class="ai-chat-panel" v-if="visible">
  3. <view class="panel-header">
  4. <text class="panel-title">AI 药典助手</text>
  5. <button class="close-btn" size="mini" @click="close">✕</button>
  6. </view>
  7. <scroll-view class="panel-body" scroll-y :scroll-top="scrollTop">
  8. <view v-for="(msg, idx) in messages" :key="idx" class="chat-item">
  9. <view :class="msg.role === 'user' ? 'chat-user' : 'chat-ai'">
  10. <view v-if="msg.thinking" class="thinking">
  11. <view class="think-dot"></view>
  12. <text>{{ msg.thinking }}</text>
  13. </view>
  14. <rich-text :nodes="renderMarkdown(msg.content)"></rich-text>
  15. </view>
  16. </view>
  17. </scroll-view>
  18. <!-- 媒体预览 -->
  19. <view v-if="pendingMedia" class="media-preview">
  20. <image v-if="pendingMedia.type==='image'" :src="pendingMedia.preview" mode="aspectFit" class="preview-thumb"/>
  21. <text class="preview-name">{{ pendingMedia.type==='video'?'🎬':'📷' }} {{ pendingMedia.name }}</text>
  22. <text class="preview-remove" @click="pendingMedia=null">✕</text>
  23. </view>
  24. <view class="panel-input">
  25. <text class="mini-media-btn" @click="pickImage">📷</text>
  26. <text class="mini-media-btn" @click="pickVideo">🎬</text>
  27. <input
  28. v-model="input"
  29. class="mini-input"
  30. :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,输入问题...':'图片已就绪,输入问题...'):'问关于这个药品...'"
  31. @confirm="send"
  32. />
  33. </view>
  34. </view>
  35. <view v-else class="ai-trigger" @click="visible = true">
  36. <text class="trigger-icon">💊</text>
  37. <text class="trigger-text">问 AI</text>
  38. </view>
  39. </template>
  40. <script>
  41. import { chatStream } from '@/api/ai.js'
  42. export default {
  43. name: 'AiChatPanel',
  44. props: {
  45. drugName: { type: String, default: '' }
  46. },
  47. data() {
  48. return {
  49. visible: false,
  50. messages: [],
  51. input: '',
  52. streaming: false,
  53. conversationId: '',
  54. scrollTop: 0,
  55. pendingMedia: null
  56. }
  57. },
  58. watch: {
  59. visible(val) {
  60. if (val && this.drugName && this.messages.length === 0) {
  61. this.input = '请介绍' + this.drugName + '的适应症、用法用量和注意事项'
  62. }
  63. }
  64. },
  65. methods: {
  66. close() { this.visible = false },
  67. pickImage() {
  68. uni.chooseImage({
  69. count: 1, sizeType: ['compressed'],
  70. success: (res) => {
  71. const path = res.tempFilePaths[0]
  72. this.fileToBase64(path, 'image', (b64, info) => {
  73. this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
  74. })
  75. }
  76. })
  77. },
  78. pickVideo() {
  79. uni.chooseVideo({
  80. maxDuration: 60, compressed: true,
  81. success: (res) => {
  82. const path = res.tempFilePath
  83. this.fileToBase64(path, 'video', (b64, info) => {
  84. this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
  85. })
  86. }
  87. })
  88. },
  89. fileToBase64(path, type, cb) {
  90. const fs = uni.getFileSystemManager()
  91. try {
  92. const data = fs.readFileSync(path, 'base64')
  93. const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
  94. const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
  95. cb(data, { mime, name })
  96. } catch (e) {
  97. uni.showToast({ title: '读取文件失败', icon: 'none' })
  98. }
  99. },
  100. renderMarkdown(content) {
  101. if (!content) return ''
  102. let h = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  103. h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  104. var secs = '结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|禁忌|不良反应|副作用|药理|贮藏|特殊人群|安全提醒|通用药学知识|来源汇总|AI 声明|AI声明|来源说明|免责声明'
  105. h = h.replace(new RegExp('(?<![【])】\\s*(' + secs + ')', 'g'), '【$1】')
  106. h = h.replace(new RegExp('(?<![【])(' + secs + ')】', 'g'), '【$1】')
  107. h = h.replace(/\n\n+/g, '</p><p>')
  108. h = h.replace(/\n/g, '<br>')
  109. // 段落标题:吞掉紧跟的 <br>
  110. h = h.replace(/【(.+?)】(?:<br>)?/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:14rpx 0 2rpx">【$1】</p>')
  111. // 去掉标题 <p> 前后的 <br>
  112. h = h.replace(/<br><p style="font-size:30rpx/g, '<p style="font-size:30rpx')
  113. h = h.replace(/<\/p><br>/g, '</p>')
  114. // markdown 链接 [文字](url) → <a>
  115. h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s<>)]+)\)/g, '<a href="$2" style="color:#002FA7;text-decoration:underline">$1</a>')
  116. // 纯 URL 自动转可点击链接
  117. h = h.replace(/(?<!href=")(?<!href=')(https?:\/\/[^\s<>]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
  118. return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
  119. },
  120. async send() {
  121. const text = this.input.trim()
  122. const media = this.pendingMedia
  123. if (!text && !media) return
  124. if (this.streaming) return
  125. this.input = ''
  126. this.pendingMedia = null
  127. this.streaming = true
  128. let userDisplay = text
  129. if (media) {
  130. userDisplay = (media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] ') + (text || '请分析')
  131. }
  132. this.messages.push({ role: 'user', content: userDisplay })
  133. const idx = this.messages.length
  134. this.messages.push({ role: 'assistant', content: '', thinking: '正在分析...' })
  135. this.scrollBottom()
  136. const reqBody = { message: text, conversation_id: this.conversationId }
  137. if (media) {
  138. reqBody.media_type = media.type
  139. reqBody.media_base64 = media.base64
  140. reqBody.media_mime = media.mime
  141. }
  142. await chatStream(
  143. reqBody,
  144. {
  145. onIntent: (d) => { this.messages[idx].intent = d },
  146. onStatus: (d) => { this.messages[idx].thinking = d; this.scrollBottom() },
  147. onToken: (d) => { this.messages[idx].thinking = ''; this.messages[idx].content += d; this.scrollBottom() },
  148. onMeta: (meta) => { if (meta.cid) this.conversationId = meta.cid },
  149. onDone: () => { this.messages[idx].thinking = '' },
  150. onError: (err) => { this.messages[idx].content = err.message || '服务暂不可用' }
  151. }
  152. )
  153. this.streaming = false
  154. },
  155. scrollBottom() {
  156. this.$nextTick(() => { this.scrollTop = 999999 })
  157. }
  158. }
  159. }
  160. </script>
  161. <style scoped>
  162. .ai-chat-panel {
  163. position: fixed; right: 0; bottom: 0; width: 90vw; max-width: 500rpx; height: 60vh;
  164. background: #fff; border-radius: 16rpx 16rpx 0 0; box-shadow: 0 -4rpx 20rpx rgba(0,0,0,.12);
  165. display: flex; flex-direction: column; z-index: 999;
  166. }
  167. .panel-header {
  168. display: flex; justify-content: space-between; align-items: center;
  169. padding: 16rpx 24rpx; background: #002FA7; color: #fff; border-radius: 16rpx 16rpx 0 0;
  170. }
  171. .panel-title { font-size: 28rpx; font-weight: 600; }
  172. .close-btn { background: rgba(255,255,255,.2); border: none; color: #fff; border-radius: 8rpx; }
  173. .panel-body { flex: 1; padding: 16rpx; overflow-y: auto; }
  174. .chat-item { margin-bottom: 16rpx; }
  175. .chat-user { text-align: right; color: #002FA7; background: #E8EDF8; padding: 10rpx 16rpx; border-radius: 12rpx 12rpx 4rpx 12rpx; font-size: 26rpx; display: inline-block; max-width: 80%; }
  176. .chat-ai { text-align: left; background: #F5F6FA; padding: 10rpx 16rpx; border-radius: 4rpx 12rpx 12rpx 12rpx; font-size: 26rpx; color: #1a1a2e; border-left: 4rpx solid #002FA7; word-break: break-all; }
  177. .thinking { color: #888; font-size: 22rpx; display: flex; align-items: center; margin-bottom: 6rpx; }
  178. .think-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #002FA7; margin-right: 8rpx; animation: pulse 1s infinite; }
  179. @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
  180. .media-preview { display: flex; align-items: center; padding: 8rpx 16rpx; background: #fafafa; gap: 8rpx; }
  181. .preview-thumb { width: 60rpx; height: 60rpx; border-radius: 6rpx; flex-shrink: 0; }
  182. .preview-name { font-size: 22rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  183. .preview-remove { font-size: 24rpx; color: #C41E3A; padding: 4rpx; }
  184. .panel-input { display: flex; align-items: center; padding: 12rpx 16rpx; border-top: 1rpx solid #eee; gap: 6rpx; }
  185. .mini-media-btn { font-size: 32rpx; padding: 4rpx; min-width: 44rpx; text-align: center; flex-shrink: 0; }
  186. .mini-input { flex: 1; height: 64rpx; background: #F5F6FA; border-radius: 32rpx; padding: 0 20rpx; font-size: 26rpx; }
  187. .ai-trigger {
  188. position: fixed; right: 24rpx; bottom: 120rpx; width: 96rpx; height: 96rpx;
  189. background: linear-gradient(135deg, #002FA7, #1a3fbf); border-radius: 50%;
  190. display: flex; flex-direction: column; align-items: center; justify-content: center;
  191. box-shadow: 0 4rpx 16rpx rgba(0,47,167,.35); z-index: 99;
  192. }
  193. .trigger-icon { font-size: 36rpx; }
  194. .trigger-text { font-size: 20rpx; color: #fff; margin-top: 2rpx; }
  195. </style>