Bläddra i källkod

图片文本一起发送

xsh_1997 1 månad sedan
förälder
incheckning
4293e29243
1 ändrade filer med 212 tillägg och 27 borttagningar
  1. 212 27
      ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue

+ 212 - 27
ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue

@@ -109,6 +109,20 @@
109 109
                         :class="{ 'is-streaming': m.streaming }"
110 110
                         v-html="renderChatMarkdown(m.content)"
111 111
                       />
112
+                      <div
113
+                        v-else-if="m.msgType === 1 && isUserMessage(m)"
114
+                        class="chat-bubble__user-body"
115
+                      >
116
+                        <el-image
117
+                          v-for="(img, ii) in userMessageImages(m)"
118
+                          :key="'uimg-' + ii"
119
+                          :src="mediaUrl(img)"
120
+                          :preview-src-list="userMessageImages(m).map((u) => mediaUrl(u))"
121
+                          fit="cover"
122
+                          class="chat-bubble__img chat-bubble__img--inline"
123
+                        />
124
+                        <div v-if="userMessageText(m)" class="chat-bubble__text">{{ userMessageText(m) }}</div>
125
+                      </div>
112 126
                       <div v-else-if="m.msgType === 1" class="chat-bubble__text">{{ m.content }}</div>
113 127
                       <el-image
114 128
                         v-else-if="m.msgType === 2"
@@ -175,7 +189,7 @@
175 189
                 :show-file-list="false"
176 190
                 accept=".jpg,.jpeg,.png,.gif"
177 191
                 :before-upload="(f) => beforeMediaUpload(f, 'image')"
178
-                :on-success="(res, file) => onMediaUploadSuccess(res, file, 2)"
192
+                :on-success="(res, file) => onMediaUploadSuccess(res, file)"
179 193
                 :on-error="onUploadError"
180 194
               >
181 195
                 <el-button
@@ -256,17 +270,29 @@
256 270
                 </button>
257 271
               </el-popover>
258 272
             </div>
259
-            <div v-if="pendingAttachments.length" class="composer-attachments">
260
-              <el-tag
273
+            <div v-if="pendingAttachments.length" class="composer-pending">
274
+              <div
261 275
                 v-for="(a, i) in pendingAttachments"
262
-                :key="i"
263
-                closable
264
-                size="small"
265
-                class="composer-attach-tag"
266
-                @close="removeAttachment(i)"
276
+                :key="a.id || i"
277
+                class="composer-pending__item"
267 278
               >
268
-                {{ a.kind === "image" ? dtT("uploadImage") : dtT("uploadVideo") }} · {{ a.name }}
269
-              </el-tag>
279
+                <el-image
280
+                  v-if="a.kind === 'image'"
281
+                  class="composer-pending__thumb"
282
+                  :src="a.previewUrl || mediaUrl(a.url)"
283
+                  fit="cover"
284
+                />
285
+                <div v-if="a.uploading" class="composer-pending__loading">
286
+                  <i class="el-icon-loading" />
287
+                </div>
288
+                <el-button
289
+                  v-if="!a.uploading"
290
+                  type="text"
291
+                  icon="el-icon-close"
292
+                  class="composer-pending__remove"
293
+                  @click="removeAttachment(i)"
294
+                />
295
+              </div>
270 296
             </div>
271 297
             <div class="composer-input-row">
272 298
               <el-input
@@ -417,7 +443,14 @@ export default {
417 443
     },
418 444
     sendDisabled() {
419 445
       const t = (this.draft || "").trim()
420
-      return this.sending || (!t && !this.pendingAttachments.length)
446
+      if (this.sending) {
447
+        return true
448
+      }
449
+      if (this.pendingAttachments.some((a) => a.uploading)) {
450
+        return true
451
+      }
452
+      const hasReadyAttach = this.pendingAttachments.some((a) => a.url)
453
+      return !t && !hasReadyAttach
421 454
     },
422 455
     showThinking() {
423 456
       if (!this.sending) {
@@ -498,7 +531,32 @@ export default {
498 531
       this.model = value
499 532
       this.modelPickerVisible = false
500 533
     },
534
+    revokeAttachmentPreview(a) {
535
+      if (a && a.objectUrl) {
536
+        try {
537
+          URL.revokeObjectURL(a.objectUrl)
538
+        } catch (e) {
539
+          /* ignore */
540
+        }
541
+      }
542
+    },
543
+    setPendingAttachment(item) {
544
+      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
545
+      this.pendingAttachments = [
546
+        {
547
+          id: genLocalId(),
548
+          kind: item.kind,
549
+          name: item.name,
550
+          previewUrl: item.previewUrl,
551
+          objectUrl: item.objectUrl || "",
552
+          uploading: !!item.uploading,
553
+          url: item.url || ""
554
+        }
555
+      ]
556
+    },
501 557
     removeAttachment(i) {
558
+      const a = this.pendingAttachments[i]
559
+      this.revokeAttachmentPreview(a)
502 560
       this.pendingAttachments.splice(i, 1)
503 561
     },
504 562
     isUserMessage(m) {
@@ -662,7 +720,7 @@ export default {
662 720
       this.hydrateLlmSessionId(session)
663 721
       this.noMoreOlder = false
664 722
       this.messages = []
665
-      this.draft = ""
723
+      this.clearComposer()
666 724
       this.loadMessages(false)
667 725
       this.startPolling()
668 726
     },
@@ -881,6 +939,16 @@ export default {
881 939
       let content = m.content
882 940
       if (m.msgType === 2 && content) {
883 941
         content = [{ type: "image_url", image_url: { url: this.mediaUrl(content) } }]
942
+      } else if (m.msgType === 1 && this.userMessageImages(m).length) {
943
+        const parts = []
944
+        for (const u of this.userMessageImages(m)) {
945
+          parts.push({ type: "image_url", image_url: { url: this.mediaUrl(u) } })
946
+        }
947
+        const text = this.userMessageText(m)
948
+        if (text) {
949
+          parts.push({ type: "text", text })
950
+        }
951
+        content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
884 952
       }
885 953
       return { role, content: this.contentForLlm(content) }
886 954
     },
@@ -909,9 +977,44 @@ export default {
909 977
       }
910 978
     },
911 979
     clearComposer() {
980
+      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
912 981
       this.draft = ""
913 982
       this.pendingAttachments = []
914 983
     },
984
+    userMessageText(m) {
985
+      if (!m) {
986
+        return ""
987
+      }
988
+      const c = m.content
989
+      if (c && typeof c === "object" && !Array.isArray(c)) {
990
+        return c.text || ""
991
+      }
992
+      if (typeof c === "string") {
993
+        return c
994
+      }
995
+      return ""
996
+    },
997
+    userMessageImages(m) {
998
+      if (!m) {
999
+        return []
1000
+      }
1001
+      const c = m.content
1002
+      if (c && typeof c === "object" && !Array.isArray(c) && Array.isArray(c.imageUrls)) {
1003
+        return c.imageUrls.filter(Boolean)
1004
+      }
1005
+      return []
1006
+    },
1007
+    resolveUserDisplayMessage(draftText, pendingSnapshot) {
1008
+      const text = (draftText || "").trim()
1009
+      const imgs = pendingSnapshot.filter((a) => a.kind === "image" && a.url).map((a) => a.url)
1010
+      if (imgs.length && text) {
1011
+        return { msgType: 1, content: { text, imageUrls: imgs } }
1012
+      }
1013
+      if (imgs.length) {
1014
+        return { msgType: 2, content: imgs[0] }
1015
+      }
1016
+      return { msgType: 1, content: text }
1017
+    },
915 1018
     resolvePayloadUserContent(payload) {
916 1019
       if (payload.msgType === 1) {
917 1020
         return payload.content
@@ -1186,7 +1289,11 @@ export default {
1186 1289
     },
1187 1290
     sendText() {
1188 1291
       const text = (this.draft || "").trim()
1189
-      if (!text && !this.pendingAttachments.length) {
1292
+      if (this.pendingAttachments.some((a) => a.uploading)) {
1293
+        this.$modal.msgWarning(this.dtT("uploading"))
1294
+        return
1295
+      }
1296
+      if (!text && !this.pendingAttachments.some((a) => a.url)) {
1190 1297
         this.$modal.msgWarning(this.dtT("inputOrAttach"))
1191 1298
         return
1192 1299
       }
@@ -1214,9 +1321,9 @@ export default {
1214 1321
       const msgType = (payload && payload.msgType) || 1
1215 1322
       const draftText = (this.draft || "").trim()
1216 1323
       const pendingSnapshot = this.pendingAttachments.slice()
1217
-      const displayContent = msgType !== 1 ? payload.content : userContent
1218 1324
       const persistBody = this.buildPersistSendBody(msgType, payload, draftText, pendingSnapshot)
1219
-      this.appendLocalMessage(SENDER_ROLE_USER, msgType, displayContent)
1325
+      const displayMsg = this.resolveUserDisplayMessage(draftText, pendingSnapshot)
1326
+      this.appendLocalMessage(SENDER_ROLE_USER, displayMsg.msgType, displayMsg.content)
1220 1327
       this.clearComposer()
1221 1328
       this.$nextTick(() => this.scrollChatBottom())
1222 1329
 
@@ -1325,22 +1432,46 @@ export default {
1325 1432
         this.$modal.msgError(this.dtT(rule.errMb))
1326 1433
         return false
1327 1434
       }
1328
-      this.$modal.loading(this.dtT("uploading"))
1435
+      const objectUrl = URL.createObjectURL(file)
1436
+      this.setPendingAttachment({
1437
+        kind,
1438
+        name: file.name,
1439
+        previewUrl: objectUrl,
1440
+        objectUrl,
1441
+        uploading: true,
1442
+        url: ""
1443
+      })
1329 1444
       return true
1330 1445
     },
1331
-    onMediaUploadSuccess(res, file, msgType) {
1332
-      this.$modal.closeLoading()
1446
+    onMediaUploadSuccess(res, file) {
1333 1447
       if (res.code === 200 && (res.url || res.fileName)) {
1334
-        this.sendViaLlm({
1335
-          msgType,
1336
-          content: res.url || res.fileName
1337
-        })
1448
+        const url = res.url || res.fileName
1449
+        const pending = this.pendingAttachments[0]
1450
+        if (pending && pending.uploading) {
1451
+          this.$set(pending, "url", url)
1452
+          this.$set(pending, "uploading", false)
1453
+          this.$set(pending, "previewUrl", this.mediaUrl(url))
1454
+          if (pending.objectUrl) {
1455
+            this.revokeAttachmentPreview(pending)
1456
+            this.$set(pending, "objectUrl", "")
1457
+          }
1458
+        } else {
1459
+          this.setPendingAttachment({
1460
+            kind: "image",
1461
+            name: file.name,
1462
+            previewUrl: this.mediaUrl(url),
1463
+            url
1464
+          })
1465
+        }
1338 1466
       } else {
1467
+        this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
1468
+        this.pendingAttachments = []
1339 1469
         this.$modal.msgError(res.msg || this.dtT("uploadFail"))
1340 1470
       }
1341 1471
     },
1342 1472
     onUploadError() {
1343
-      this.$modal.closeLoading()
1473
+      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
1474
+      this.pendingAttachments = []
1344 1475
       this.$modal.msgError(this.dtT("uploadFail"))
1345 1476
     },
1346 1477
     stopVoice() {
@@ -1786,6 +1917,17 @@ export default {
1786 1917
   border-radius: 6px;
1787 1918
 }
1788 1919
 
1920
+.chat-bubble__user-body {
1921
+  display: flex;
1922
+  flex-direction: column;
1923
+  gap: 8px;
1924
+  min-width: 0;
1925
+}
1926
+
1927
+.chat-bubble__img--inline {
1928
+  display: block;
1929
+}
1930
+
1789 1931
 .chat-bubble__video {
1790 1932
   max-width: 240px;
1791 1933
   max-height: 180px;
@@ -1954,12 +2096,55 @@ export default {
1954 2096
   transition: transform 0.2s;
1955 2097
 }
1956 2098
 
1957
-.composer-attachments {
1958
-  margin-bottom: 8px;
2099
+.composer-pending {
2100
+  display: flex;
2101
+  flex-wrap: wrap;
2102
+  gap: 10px;
2103
+  margin-bottom: 10px;
2104
+}
2105
+
2106
+.composer-pending__item {
2107
+  position: relative;
2108
+  width: 72px;
2109
+  height: 72px;
2110
+  border-radius: 8px;
2111
+  overflow: hidden;
2112
+  background: #f0f2f5;
2113
+  flex-shrink: 0;
2114
+}
2115
+
2116
+.composer-pending__thumb {
2117
+  width: 100%;
2118
+  height: 100%;
2119
+  display: block;
2120
+}
2121
+
2122
+.composer-pending__loading {
2123
+  position: absolute;
2124
+  inset: 0;
2125
+  display: flex;
2126
+  align-items: center;
2127
+  justify-content: center;
2128
+  background: rgba(0, 0, 0, 0.35);
2129
+  color: #fff;
2130
+  font-size: 22px;
1959 2131
 }
1960 2132
 
1961
-.composer-attach-tag {
1962
-  margin-right: 6px;
2133
+.composer-pending__remove {
2134
+  position: absolute;
2135
+  top: 2px;
2136
+  right: 2px;
2137
+  padding: 0;
2138
+  width: 22px;
2139
+  height: 22px;
2140
+  min-height: 22px;
2141
+  border-radius: 50%;
2142
+  background: rgba(0, 0, 0, 0.45);
2143
+  color: #fff;
2144
+
2145
+  ::v-deep i {
2146
+    color: #fff;
2147
+  }
1963 2148
 }
1964 2149
 
1965 2150
 .composer-upload {