西藏巴青项目

index.vue 56KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041
  1. <template>
  2. <div class="app-container ai-diagnosis">
  3. <el-card shadow="never" class="ai-diagnosis__toolbar">
  4. <el-form :inline="true" size="small" @submit.native.prevent>
  5. <el-form-item :label="dtT('queryContent')">
  6. <el-input
  7. v-model="filterContent"
  8. :placeholder="dtT('queryContentPh')"
  9. clearable
  10. style="width: 280px"
  11. @keyup.enter.native="handleSearch"
  12. />
  13. </el-form-item>
  14. <el-form-item>
  15. <el-button type="primary" icon="el-icon-search" @click="handleSearch">{{ dtCommon("search") }}</el-button>
  16. <el-button icon="el-icon-refresh" @click="handleReset">{{ dtCommon("reset") }}</el-button>
  17. <el-button
  18. v-hasPermi="['diseaseTreatment:aiOnlineConsult:add']"
  19. type="primary"
  20. icon="el-icon-plus"
  21. @click="handleNewSession"
  22. >{{ dtT("newSessionBtn") }}</el-button>
  23. </el-form-item>
  24. </el-form>
  25. <el-alert
  26. v-if="disclaimer"
  27. class="ai-diagnosis__disclaimer"
  28. type="warning"
  29. :closable="false"
  30. show-icon
  31. :title="dtT('disclaimerTitle')"
  32. >
  33. <span class="ai-diagnosis__disclaimer-text">{{ disclaimer }}</span>
  34. </el-alert>
  35. </el-card>
  36. <el-card shadow="never" class="ai-diagnosis__main">
  37. <div class="ai-diagnosis__layout">
  38. <aside class="ai-diagnosis__sessions">
  39. <div class="ai-diagnosis__sessions-head">
  40. <div class="ai-diagnosis__sessions-title">{{ dtT("listTitle") }}</div>
  41. <div class="ai-diagnosis__sessions-sub">{{ dtT("listSubDefault") }}</div>
  42. </div>
  43. <div v-loading="listLoading" class="ai-diagnosis__sessions-scroll">
  44. <div
  45. v-for="s in sessions"
  46. :key="resolveSessionId(s)"
  47. class="session-item"
  48. :class="{ 'is-active': resolveSessionId(s) === activeSessionId }"
  49. @click="openSession(s)"
  50. >
  51. <el-avatar :size="40" icon="el-icon-chat-dot-round" class="session-item__avatar" />
  52. <div class="session-item__text">
  53. <div class="session-item__row">
  54. <span class="session-item__name">{{ sessionTitle(s) }}</span>
  55. <span class="session-item__date">{{ formatListDate(s.lastMessageTime) }}</span>
  56. </div>
  57. <div class="session-item__preview">{{ s.lastMessagePreview || dash }}</div>
  58. </div>
  59. <el-button
  60. v-hasPermi="['diseaseTreatment:aiOnlineConsult:remove']"
  61. type="text"
  62. icon="el-icon-delete"
  63. class="session-item__delete"
  64. :title="dtT('deleteSession')"
  65. @click.stop="handleDeleteSession(s)"
  66. />
  67. </div>
  68. <el-empty
  69. v-if="!listLoading && !sessions.length"
  70. :description="listEmptyText"
  71. :image-size="72"
  72. />
  73. </div>
  74. </aside>
  75. <section class="ai-diagnosis__chat">
  76. <header class="ai-diagnosis__chat-head">
  77. {{ activeSession ? sessionTitle(activeSession) : dtT("selectSession") }}
  78. </header>
  79. <div
  80. ref="chatScroll"
  81. v-loading="messagesLoading"
  82. class="ai-diagnosis__chat-body"
  83. @scroll.passive="onChatScroll"
  84. >
  85. <div v-if="activeSessionId && loadingOlder" class="chat-load-hint">{{ dtT("loadingMore") }}</div>
  86. <div v-if="activeSessionId && noMoreOlder && messages.length" class="chat-load-hint">{{ dtT("noMoreHistory") }}</div>
  87. <div class="chat-messages">
  88. <template v-if="activeSessionId && messages.length">
  89. <template v-for="(group, gi) in messageGroups">
  90. <div :key="'d-' + gi" class="chat-date-divider">{{ group.dateLabel }}</div>
  91. <div
  92. v-for="m in group.items"
  93. :key="m.id"
  94. class="chat-row"
  95. :class="isUserMessage(m) ? 'is-user' : 'is-ai'"
  96. >
  97. <el-avatar
  98. v-if="!isUserMessage(m)"
  99. :size="36"
  100. icon="el-icon-s-opportunity"
  101. class="chat-row__avatar"
  102. />
  103. <div class="chat-bubble" :class="isUserMessage(m) ? 'is-user' : 'is-ai'">
  104. <div
  105. v-if="m.msgType === 1 && !isUserMessage(m)"
  106. class="chat-bubble__text chat-bubble__md"
  107. :class="{ 'is-streaming': m.streaming }"
  108. v-html="renderChatMarkdown(m.content)"
  109. />
  110. <div v-else-if="m.msgType === 1" class="chat-bubble__text">{{ m.content }}</div>
  111. <el-image
  112. v-else-if="m.msgType === 2"
  113. :src="mediaUrl(m.content)"
  114. :preview-src-list="[mediaUrl(m.content)]"
  115. fit="cover"
  116. class="chat-bubble__img"
  117. />
  118. <video
  119. v-else-if="m.msgType === 3"
  120. :src="mediaUrl(m.content)"
  121. controls
  122. class="chat-bubble__video"
  123. />
  124. <div
  125. v-else-if="m.msgType === 4"
  126. class="voice-msg"
  127. :class="{ 'is-playing': playingVoiceId === m.id }"
  128. @click="toggleVoice(m)"
  129. >
  130. <span class="voice-msg__dur">{{ formatVoiceDuration(m.mediaDuration) }}</span>
  131. <span class="voice-msg__waves"><i /><i /><i /></span>
  132. </div>
  133. <div v-else class="chat-bubble__text">{{ m.content }}</div>
  134. <div class="chat-bubble__time">{{ formatMsgTime(m.sendTime) }}</div>
  135. </div>
  136. <el-avatar
  137. v-if="isUserMessage(m)"
  138. :size="36"
  139. icon="el-icon-user-solid"
  140. class="chat-row__avatar"
  141. />
  142. </div>
  143. </template>
  144. <div v-if="showThinking" class="chat-row is-ai">
  145. <el-avatar :size="36" icon="el-icon-s-opportunity" class="chat-row__avatar" />
  146. <div class="chat-bubble is-ai chat-bubble--thinking" aria-live="polite">
  147. <span class="thinking-status">
  148. <span class="thinking-status__label">{{ dtT("thinking") }}</span>
  149. <span class="thinking-dots" aria-hidden="true">
  150. <i class="thinking-dots__dot" />
  151. <i class="thinking-dots__dot" />
  152. <i class="thinking-dots__dot" />
  153. </span>
  154. </span>
  155. </div>
  156. </div>
  157. </template>
  158. <el-empty
  159. v-else-if="activeSessionId && !messagesLoading"
  160. :description="dtT('emptyChat')"
  161. :image-size="80"
  162. />
  163. <el-empty v-else-if="!activeSessionId" :description="dtT('selectSession')" :image-size="80" />
  164. </div>
  165. </div>
  166. <footer v-if="activeSessionId" class="ai-diagnosis__composer">
  167. <div class="composer-tools">
  168. <el-upload
  169. class="composer-upload"
  170. :action="uploadAction"
  171. :headers="uploadHeaders"
  172. :show-file-list="false"
  173. accept=".jpg,.jpeg,.png,.gif"
  174. :before-upload="(f) => beforeMediaUpload(f, 'image')"
  175. :on-success="(res, file) => onMediaUploadSuccess(res, file, 2)"
  176. :on-error="onUploadError"
  177. >
  178. <el-button
  179. type="text"
  180. icon="el-icon-picture-outline"
  181. :disabled="sending"
  182. :title="dtT('uploadImage')"
  183. />
  184. </el-upload>
  185. <!-- <el-upload
  186. class="composer-upload"
  187. :action="uploadAction"
  188. :headers="uploadHeaders"
  189. :show-file-list="false"
  190. accept=".mp4,.mov"
  191. :before-upload="(f) => beforeMediaUpload(f, 'video')"
  192. :on-success="(res, file) => onMediaUploadSuccess(res, file, 3)"
  193. :on-error="onUploadError"
  194. >
  195. <el-button
  196. type="text"
  197. icon="el-icon-video-camera"
  198. :disabled="sending"
  199. :title="dtT('uploadVideo')"
  200. />
  201. </el-upload> -->
  202. <!-- <el-upload
  203. class="composer-upload"
  204. :action="uploadAction"
  205. :headers="uploadHeaders"
  206. :show-file-list="false"
  207. accept=".mp3,.m4a,.wav"
  208. :before-upload="(f) => beforeMediaUpload(f, 'voice')"
  209. :on-success="(res, file) => onMediaUploadSuccess(res, file, 4)"
  210. :on-error="onUploadError"
  211. >
  212. <el-button
  213. type="text"
  214. icon="el-icon-microphone"
  215. :disabled="sending"
  216. :title="dtT('uploadVoice')"
  217. />
  218. </el-upload> -->
  219. <el-popover
  220. v-model="modelPickerVisible"
  221. placement="top-end"
  222. width="280"
  223. trigger="click"
  224. popper-class="ai-diagnosis-model-popover"
  225. class="composer-model"
  226. >
  227. <div class="model-picker">
  228. <div
  229. v-for="opt in modelOptions"
  230. :key="opt.value"
  231. class="model-picker__item"
  232. :class="{ 'is-active': model === opt.value }"
  233. @click="pickModel(opt.value)"
  234. >
  235. <span class="model-picker__icon"><i :class="opt.icon" /></span>
  236. <span class="model-picker__body">
  237. <span class="model-picker__name">{{ opt.label }}</span>
  238. <span class="model-picker__desc">{{ opt.desc }}</span>
  239. </span>
  240. <i v-if="model === opt.value" class="el-icon-check model-picker__check" />
  241. </div>
  242. </div>
  243. <button
  244. slot="reference"
  245. type="button"
  246. class="composer-model-trigger"
  247. :class="{ 'is-open': modelPickerVisible }"
  248. :title="currentModelOption.label"
  249. >
  250. <i :class="currentModelOption.icon" class="composer-model-trigger__icon" />
  251. <span class="composer-model-trigger__text">{{ currentModelOption.short }}</span>
  252. <i class="el-icon-arrow-down composer-model-trigger__arrow" />
  253. </button>
  254. </el-popover>
  255. </div>
  256. <div v-if="pendingAttachments.length" class="composer-attachments">
  257. <el-tag
  258. v-for="(a, i) in pendingAttachments"
  259. :key="i"
  260. closable
  261. size="small"
  262. class="composer-attach-tag"
  263. @close="removeAttachment(i)"
  264. >
  265. {{ a.kind === "image" ? dtT("uploadImage") : dtT("uploadVideo") }} · {{ a.name }}
  266. </el-tag>
  267. </div>
  268. <div class="composer-input-row">
  269. <el-input
  270. v-model="draft"
  271. type="textarea"
  272. :rows="3"
  273. resize="none"
  274. :placeholder="dtT('draftPlaceholder')"
  275. :disabled="sending"
  276. @keydown.native="onDraftKeydown"
  277. />
  278. <el-button
  279. type="primary"
  280. class="composer-send"
  281. :loading="sending"
  282. :disabled="sendDisabled"
  283. @click="sendText"
  284. >{{ dtT("send") }}</el-button>
  285. </div>
  286. </footer>
  287. </section>
  288. </div>
  289. </el-card>
  290. </div>
  291. </template>
  292. <script>
  293. import { mapGetters } from "vuex"
  294. import { getToken } from "@/utils/auth"
  295. import diseaseTreatmentLocaleMixin from "@/mixins/diseaseTreatmentLocaleMixin"
  296. import {
  297. listAiConsultSessions,
  298. createAiConsultSession,
  299. listAiConsultMessages,
  300. sendAiConsultMessage,
  301. hideAiConsultSession,
  302. sendChatMessage,
  303. extractLlmSessionIdFromJson,
  304. getModelList
  305. } from "@/api/diseaseTreatment/aiOnlineConsult"
  306. import { renderChatMarkdown } from "@/utils/markdownRender"
  307. const SENDER_ROLE_USER = 1
  308. const SENDER_ROLE_AI = 3
  309. const POLL_MS = 2000
  310. const MESSAGE_PAGE_SIZE = 50
  311. const DEFAULT_SESSION_TITLE = "新会话"
  312. const LLM_SESSION_STORAGE_PREFIX = "ai_consult_llm_session_"
  313. const MODEL_OPTION_DEFS = [
  314. // { value: "auto", labelKey: "modelAuto", shortKey: "modelAutoShort", descKey: "modelAutoDesc", icon: "el-icon-cpu" },
  315. { value: "yak-general", labelKey: "modelGeneral", shortKey: "modelGeneralShort", descKey: "modelGeneralDesc", icon: "el-icon-chat-dot-round" },
  316. { value: "yak-feeding", labelKey: "modelFeeding", shortKey: "modelFeedingShort", descKey: "modelFeedingDesc", icon: "el-icon-s-goods" },
  317. { value: "yak-disease", labelKey: "modelDisease", shortKey: "modelDiseaseShort", descKey: "modelDiseaseDesc", icon: "el-icon-s-flag" },
  318. { value: "yak-growth", labelKey: "modelGrowth", shortKey: "modelGrowth", descKey: "modelGrowthDesc", icon: "el-icon-s-flag" },
  319. ]
  320. function genLocalId() {
  321. return "m_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 9)
  322. }
  323. const MEDIA_RULES = {
  324. image: { exts: ["jpg", "jpeg", "png", "gif"], maxMb: 10, errFmt: "errImageFmt", errMb: "errImageMb" },
  325. video: { exts: ["mp4", "mov"], maxMb: 50, errFmt: "errVideoFmt", errMb: "errVideoMb" },
  326. voice: { exts: ["mp3", "m4a", "wav"], maxMb: 10, errFmt: "errVoiceFmt", errMb: "errVoiceMb" }
  327. }
  328. export default {
  329. name: "AiDiagnosis",
  330. mixins: [diseaseTreatmentLocaleMixin],
  331. data() {
  332. return {
  333. dtNs: "aiOnlineConsult",
  334. listLoading: false,
  335. messagesLoading: false,
  336. loadingOlder: false,
  337. noMoreOlder: false,
  338. sending: false,
  339. searchMode: false,
  340. filterContent: "",
  341. disclaimer: "",
  342. model: "auto",
  343. modelPickerVisible: false,
  344. pendingAttachments: [],
  345. sessions: [],
  346. activeSessionId: null,
  347. messages: [],
  348. draft: "",
  349. pollTimer: null,
  350. playingVoiceId: null,
  351. voiceAudio: null,
  352. listEmptyHint: "",
  353. pendingPersist: null,
  354. streamAbortController: null,
  355. _streamRevealTimer: null
  356. }
  357. },
  358. computed: {
  359. ...mapGetters(["id", "name"]),
  360. dash() {
  361. return this.$t("diseaseTreatment.status.dash")
  362. },
  363. activeSession() {
  364. return this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId) || null
  365. },
  366. modelOptions() {
  367. return MODEL_OPTION_DEFS.map((o) => ({
  368. value: o.value,
  369. icon: o.icon,
  370. label: this.dtT(o.labelKey),
  371. short: this.dtT(o.shortKey),
  372. desc: this.dtT(o.descKey)
  373. }))
  374. },
  375. currentModelOption() {
  376. return this.modelOptions.find((o) => o.value === this.model) || this.modelOptions[0]
  377. },
  378. listEmptyText() {
  379. return this.listEmptyHint || this.dtT("emptySessions")
  380. },
  381. messageGroups() {
  382. const groups = []
  383. let currentKey = null
  384. let currentItems = []
  385. for (const m of this.messages) {
  386. const key = this.dateKey(m.sendTime)
  387. if (key !== currentKey) {
  388. if (currentItems.length) {
  389. groups.push({ dateLabel: this.formatDateDivider(currentKey), items: currentItems })
  390. }
  391. currentKey = key
  392. currentItems = [m]
  393. } else {
  394. currentItems.push(m)
  395. }
  396. }
  397. if (currentItems.length) {
  398. groups.push({ dateLabel: this.formatDateDivider(currentKey), items: currentItems })
  399. }
  400. return groups
  401. },
  402. uploadAction() {
  403. return process.env.VUE_APP_BASE_API + "/common/upload"
  404. },
  405. uploadHeaders() {
  406. return { Authorization: "Bearer " + getToken() }
  407. },
  408. sendDisabled() {
  409. const t = (this.draft || "").trim()
  410. return this.sending || (!t && !this.pendingAttachments.length)
  411. },
  412. showThinking() {
  413. if (!this.sending) {
  414. return false
  415. }
  416. // 仅显示等待动画;首包到达后由 streaming 气泡承接输出
  417. return !this.messages.some((m) => m.senderRole === SENDER_ROLE_AI && m.streaming)
  418. }
  419. },
  420. created() {
  421. this.loadSessions(false)
  422. this.getModelList()
  423. },
  424. beforeRouteLeave(to, from, next) {
  425. this.abortStreamRequest()
  426. this.flushPendingPersist()
  427. .finally(() => {
  428. this.stopPolling()
  429. this.stopVoice()
  430. next()
  431. })
  432. },
  433. beforeDestroy() {
  434. this.abortStreamRequest()
  435. this.stopPolling()
  436. this.stopVoice()
  437. },
  438. methods: {
  439. getModelList() {
  440. getModelList().then((res) => {
  441. })
  442. },
  443. renderChatMarkdown,
  444. /** 若依问诊主键:listAiConsultMessages / sendAiConsultMessage 路径参数(id,非 realSessionId) */
  445. resolveSessionId(session) {
  446. if (!session) {
  447. return null
  448. }
  449. if (typeof session === "string" || typeof session === "number") {
  450. return String(session)
  451. }
  452. if (session.id != null && session.id !== "") {
  453. return String(session.id)
  454. }
  455. if (session.sessionId != null && session.sessionId !== "") {
  456. return String(session.sessionId)
  457. }
  458. if (session.realSessionId != null && session.realSessionId !== "") {
  459. return String(session.realSessionId)
  460. }
  461. return null
  462. },
  463. /** 列表行中的大模型网关 sessionId(real_session_id / llmSessionId) */
  464. resolveLlmSessionIdFromRow(session) {
  465. if (!session) {
  466. return null
  467. }
  468. if (session.llmSessionId != null && session.llmSessionId !== "") {
  469. return String(session.llmSessionId)
  470. }
  471. const consultId = this.resolveSessionId(session)
  472. if (session.realSessionId != null && session.realSessionId !== "") {
  473. const rid = String(session.realSessionId)
  474. if (!consultId || rid !== consultId) {
  475. return rid
  476. }
  477. }
  478. if (session.sessionId != null && session.sessionId !== "") {
  479. const sid = String(session.sessionId)
  480. if (!consultId || sid !== consultId) {
  481. return sid
  482. }
  483. }
  484. return null
  485. },
  486. pickModel(value) {
  487. this.model = value
  488. this.modelPickerVisible = false
  489. },
  490. removeAttachment(i) {
  491. this.pendingAttachments.splice(i, 1)
  492. },
  493. isUserMessage(m) {
  494. return m && m.senderRole === SENDER_ROLE_USER
  495. },
  496. sessionTitle(s) {
  497. if (!s) {
  498. return this.dtT("defaultSessionTitle")
  499. }
  500. const t = s.sessionTitle
  501. if (!t || t === DEFAULT_SESSION_TITLE) {
  502. return this.dtT("defaultSessionTitle")
  503. }
  504. return t
  505. },
  506. mediaUrl(url) {
  507. if (!url) {
  508. return ""
  509. }
  510. if (/^https?:\/\//i.test(url)) {
  511. return url
  512. }
  513. return process.env.VUE_APP_BASE_API + url
  514. },
  515. formatListDate(time) {
  516. if (!time) {
  517. return ""
  518. }
  519. const d = new Date(time)
  520. if (isNaN(d.getTime())) {
  521. return ""
  522. }
  523. return `${d.getMonth() + 1}月${d.getDate()}日`
  524. },
  525. formatMsgTime(time) {
  526. if (!time) {
  527. return ""
  528. }
  529. const d = new Date(time)
  530. if (isNaN(d.getTime())) {
  531. return ""
  532. }
  533. const h = String(d.getHours()).padStart(2, "0")
  534. const min = String(d.getMinutes()).padStart(2, "0")
  535. return `${h}:${min}`
  536. },
  537. dateKey(time) {
  538. if (!time) {
  539. return ""
  540. }
  541. const d = new Date(time)
  542. if (isNaN(d.getTime())) {
  543. return ""
  544. }
  545. return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
  546. },
  547. formatDateDivider(key) {
  548. if (!key) {
  549. return ""
  550. }
  551. const parts = key.split("-")
  552. if (parts.length >= 3) {
  553. return `${Number(parts[1])}月${Number(parts[2])}日`
  554. }
  555. return key
  556. },
  557. formatVoiceDuration(sec) {
  558. const s = Math.max(1, Math.round(Number(sec) || 1))
  559. return s + '"'
  560. },
  561. applyDisclaimerFromResponse(res) {
  562. if (res && res.disclaimer) {
  563. this.disclaimer = res.disclaimer
  564. }
  565. },
  566. loadSessions(searchMode) {
  567. this.listLoading = true
  568. const params = {
  569. pageNum: 1,
  570. pageSize: 100,
  571. contentKeyword: (this.filterContent || "").trim() || undefined,
  572. searchMode: searchMode ? true : undefined
  573. }
  574. listAiConsultSessions(params)
  575. .then((res) => {
  576. this.applyDisclaimerFromResponse(res)
  577. if (searchMode && !(res.rows || []).length) {
  578. this.listEmptyHint = this.dtT("noSearchResult")
  579. } else {
  580. this.listEmptyHint = this.dtT("emptySessions")
  581. }
  582. this.sessions = this.mergeSessionsPreserveLlmId(res.rows || [])
  583. if (this.activeSessionId) {
  584. const active = this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId)
  585. if (active) {
  586. this.hydrateLlmSessionId(active)
  587. }
  588. }
  589. if (this.activeSessionId && !this.sessions.some((s) => this.resolveSessionId(s) === this.activeSessionId)) {
  590. this.activeSessionId = null
  591. this.messages = []
  592. this.stopPolling()
  593. }
  594. })
  595. .catch(() => {
  596. this.sessions = []
  597. })
  598. .finally(() => {
  599. this.listLoading = false
  600. })
  601. },
  602. handleSearch() {
  603. const content = (this.filterContent || "").trim()
  604. if (!content) {
  605. this.$modal.msgWarning(this.dtT("searchNeedFilter"))
  606. return
  607. }
  608. this.searchMode = true
  609. this.loadSessions(true)
  610. },
  611. handleReset() {
  612. this.filterContent = ""
  613. this.searchMode = false
  614. this.loadSessions(false)
  615. },
  616. handleNewSession() {
  617. createAiConsultSession({})
  618. .then((res) => {
  619. const data = res.data || {}
  620. if (data.disclaimer) {
  621. this.disclaimer = data.disclaimer
  622. }
  623. if (data.id == null || data.id === "") {
  624. return
  625. }
  626. const consultId = String(data.id)
  627. this.clearLlmSessionId(consultId)
  628. const row = {
  629. id: consultId,
  630. realSessionId: data.realSessionId || null,
  631. llmSessionId: null,
  632. sessionTitle: data.sessionTitle || DEFAULT_SESSION_TITLE,
  633. lastMessagePreview: data.lastMessagePreview || "",
  634. lastMessageTime: data.lastMessageTime || new Date().toISOString()
  635. }
  636. const exists = this.sessions.some((s) => this.resolveSessionId(s) === consultId)
  637. if (!exists) {
  638. this.sessions.unshift(row)
  639. }
  640. this.openSession(row)
  641. this.loadSessions(this.searchMode)
  642. })
  643. .catch(() => {})
  644. },
  645. openSession(session) {
  646. const sid = this.resolveSessionId(session)
  647. if (!sid) {
  648. return
  649. }
  650. this.activeSessionId = sid
  651. this.hydrateLlmSessionId(session)
  652. this.noMoreOlder = false
  653. this.messages = []
  654. this.draft = ""
  655. this.loadMessages(false)
  656. this.startPolling()
  657. },
  658. handleDeleteSession(session) {
  659. const sid = this.resolveSessionId(session)
  660. if (!sid) {
  661. return
  662. }
  663. const title = this.sessionTitle(session)
  664. this.$modal
  665. .confirm(this.dtT("confirmDeleteSession", { title }))
  666. .then(() => hideAiConsultSession(sid))
  667. .then(() => {
  668. this.$modal.msgSuccess(this.dtCommon("msgDelOk"))
  669. this.clearLlmSessionId(sid)
  670. this.sessions = this.sessions.filter((s) => this.resolveSessionId(s) !== sid)
  671. if (this.pendingPersist && this.pendingPersist.consultSessionId === sid) {
  672. this.pendingPersist = null
  673. }
  674. if (this.activeSessionId === sid) {
  675. this.abortStreamRequest()
  676. this.stopPolling()
  677. this.activeSessionId = ""
  678. this.messages = []
  679. this.draft = ""
  680. this.sending = false
  681. }
  682. })
  683. .catch(() => {})
  684. },
  685. loadMessages(older) {
  686. if (!this.activeSessionId) {
  687. return
  688. }
  689. const beforeId = older && this.messages.length ? this.messages[0].id : undefined
  690. if (older) {
  691. this.loadingOlder = true
  692. } else {
  693. this.messagesLoading = true
  694. }
  695. listAiConsultMessages(this.activeSessionId, {
  696. beforeId,
  697. pageSize: MESSAGE_PAGE_SIZE
  698. })
  699. .then((res) => {
  700. const batch = (res.data || []).map((m) => this.normalizeMessage(m))
  701. if (older) {
  702. if (!batch.length) {
  703. this.noMoreOlder = true
  704. } else {
  705. const el = this.$refs.chatScroll
  706. const prevHeight = el ? el.scrollHeight : 0
  707. this.messages = batch.concat(this.messages)
  708. this.$nextTick(() => {
  709. if (el) {
  710. el.scrollTop = el.scrollHeight - prevHeight
  711. }
  712. })
  713. if (batch.length < MESSAGE_PAGE_SIZE) {
  714. this.noMoreOlder = true
  715. }
  716. }
  717. } else {
  718. this.messages = batch
  719. this.$nextTick(() => this.scrollChatBottom())
  720. if (!batch.length) {
  721. this.noMoreOlder = true
  722. }
  723. }
  724. })
  725. .finally(() => {
  726. this.messagesLoading = false
  727. this.loadingOlder = false
  728. })
  729. },
  730. normalizeMessage(m) {
  731. return {
  732. id: m.id || m.messageId,
  733. sessionId: m.sessionId,
  734. senderRole: m.senderRole,
  735. senderName: m.senderName,
  736. msgType: m.msgType,
  737. content: m.content,
  738. mediaDuration: m.mediaDuration,
  739. sendTime: m.sendTime
  740. }
  741. },
  742. contentForLlm(content) {
  743. if (typeof content === "string") {
  744. return content
  745. }
  746. if (!Array.isArray(content)) {
  747. return ""
  748. }
  749. return content
  750. },
  751. buildUserContentForLlm(text) {
  752. const parts = []
  753. const imgs = this.pendingAttachments.filter((a) => a.kind === "image")
  754. for (const im of imgs) {
  755. parts.push({ type: "image_url", image_url: { url: im.url } })
  756. }
  757. const body = (text || "").trim()
  758. if (body) {
  759. parts.push({ type: "text", text: body })
  760. }
  761. if (!parts.length) {
  762. return ""
  763. }
  764. if (parts.length === 1 && parts[0].type === "text") {
  765. return parts[0].text
  766. }
  767. return parts
  768. },
  769. llmSessionStorageKey(consultSessionId) {
  770. return LLM_SESSION_STORAGE_PREFIX + consultSessionId
  771. },
  772. loadLlmSessionId(consultSessionId) {
  773. if (!consultSessionId) {
  774. return null
  775. }
  776. try {
  777. return sessionStorage.getItem(this.llmSessionStorageKey(consultSessionId)) || null
  778. } catch (e) {
  779. return null
  780. }
  781. },
  782. saveLlmSessionId(consultSessionId, llmSessionId) {
  783. if (!consultSessionId || !llmSessionId) {
  784. return
  785. }
  786. try {
  787. sessionStorage.setItem(this.llmSessionStorageKey(consultSessionId), String(llmSessionId))
  788. } catch (e) {
  789. /* ignore */
  790. }
  791. },
  792. clearLlmSessionId(consultSessionId) {
  793. if (!consultSessionId) {
  794. return
  795. }
  796. try {
  797. sessionStorage.removeItem(this.llmSessionStorageKey(consultSessionId))
  798. } catch (e) {
  799. /* ignore */
  800. }
  801. },
  802. hydrateLlmSessionId(session) {
  803. if (!session) {
  804. return
  805. }
  806. const sid = this.resolveSessionId(session)
  807. const fromList = this.resolveLlmSessionIdFromRow(session)
  808. if (fromList && !session.llmSessionId) {
  809. this.$set(session, "llmSessionId", fromList)
  810. this.saveLlmSessionId(sid, fromList)
  811. }
  812. const stored = this.loadLlmSessionId(sid)
  813. if (stored && !session.llmSessionId) {
  814. this.$set(session, "llmSessionId", stored)
  815. }
  816. },
  817. /** 列表刷新后合并本地已记住的大模型 sessionId */
  818. mergeSessionsPreserveLlmId(rows) {
  819. const prevMap = {}
  820. ;(this.sessions || []).forEach((s) => {
  821. const id = this.resolveSessionId(s)
  822. const llm = this.resolveLlmSessionIdFromRow(s) || s.llmSessionId
  823. if (id && llm) {
  824. prevMap[id] = String(llm)
  825. }
  826. })
  827. return (rows || []).map((row) => {
  828. const consultId =
  829. row.id != null && row.id !== "" ? String(row.id) : this.resolveSessionId(row)
  830. if (!consultId) {
  831. return row
  832. }
  833. const llm =
  834. this.resolveLlmSessionIdFromRow(row) ||
  835. prevMap[consultId] ||
  836. this.loadLlmSessionId(consultId) ||
  837. null
  838. const next = {
  839. ...row,
  840. id: consultId
  841. }
  842. if (llm) {
  843. next.llmSessionId = String(llm)
  844. }
  845. return next
  846. })
  847. },
  848. resolveLlmSessionId(session) {
  849. if (!session) {
  850. return null
  851. }
  852. const sid = this.resolveSessionId(session)
  853. return (
  854. this.resolveLlmSessionIdFromRow(session) ||
  855. (session.llmSessionId && String(session.llmSessionId)) ||
  856. this.loadLlmSessionId(sid) ||
  857. null
  858. )
  859. },
  860. rememberLlmSessionId(session, llmSessionId) {
  861. if (!session || !llmSessionId) {
  862. return
  863. }
  864. const v = String(llmSessionId)
  865. this.$set(session, "llmSessionId", v)
  866. this.saveLlmSessionId(this.resolveSessionId(session), v)
  867. },
  868. messageToLlmItem(m) {
  869. const role = m.senderRole === SENDER_ROLE_AI ? "assistant" : "user"
  870. let content = m.content
  871. if (m.msgType === 2 && content) {
  872. content = [{ type: "image_url", image_url: { url: this.mediaUrl(content) } }]
  873. }
  874. return { role, content: this.contentForLlm(content) }
  875. },
  876. buildMessagesForLlm(llmSessionId) {
  877. if (llmSessionId) {
  878. for (let i = this.messages.length - 1; i >= 0; i--) {
  879. const m = this.messages[i]
  880. if (m.senderRole === SENDER_ROLE_USER) {
  881. return [this.messageToLlmItem(m)]
  882. }
  883. }
  884. return []
  885. }
  886. return this.messages.map((m) => this.messageToLlmItem(m))
  887. },
  888. extractLlmSessionId(data) {
  889. return extractLlmSessionIdFromJson(data)
  890. },
  891. extractAssistantText(data) {
  892. try {
  893. const choice = data.choices && data.choices[0]
  894. const msg = choice && choice.message
  895. return (msg && msg.content) || ""
  896. } catch (e) {
  897. return ""
  898. }
  899. },
  900. clearComposer() {
  901. this.draft = ""
  902. this.pendingAttachments = []
  903. },
  904. resolvePayloadUserContent(payload) {
  905. if (payload.msgType === 1) {
  906. return payload.content
  907. }
  908. if (payload.msgType === 2) {
  909. const parts = [{ type: "image_url", image_url: { url: this.mediaUrl(payload.content) } }]
  910. const text = (this.draft || "").trim()
  911. if (text) {
  912. parts.push({ type: "text", text })
  913. }
  914. return parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
  915. }
  916. return String(payload.content || "")
  917. },
  918. appendLocalMessage(senderRole, msgType, content) {
  919. const msg = {
  920. id: genLocalId(),
  921. senderRole,
  922. msgType: msgType || 1,
  923. content,
  924. sendTime: new Date().toISOString()
  925. }
  926. this.messages.push(msg)
  927. return msg
  928. },
  929. buildPersistSendBody(msgType, payload, draftText, attachments) {
  930. const body = { msgType: msgType || 1 }
  931. if (body.msgType === 1) {
  932. let text = (draftText || "").trim() || (payload && payload.content ? String(payload.content).trim() : "")
  933. if (!text && attachments && attachments.length) {
  934. const img = attachments.find((a) => a.kind === "image")
  935. if (img && img.url) {
  936. body.msgType = 2
  937. body.content = img.url
  938. return body
  939. }
  940. }
  941. body.content = text
  942. } else {
  943. body.content = (payload && payload.content) || ""
  944. if (payload && payload.mediaDuration != null) {
  945. body.mediaDuration = payload.mediaDuration
  946. }
  947. }
  948. return body
  949. },
  950. findLastLocalExchangeIndexes() {
  951. let userIdx = -1
  952. let aiIdx = -1
  953. for (let i = this.messages.length - 1; i >= 0; i--) {
  954. const m = this.messages[i]
  955. if (!m || !String(m.id).startsWith("m_")) {
  956. continue
  957. }
  958. if (aiIdx < 0 && m.senderRole === SENDER_ROLE_AI) {
  959. aiIdx = i
  960. } else if (userIdx < 0 && m.senderRole === SENDER_ROLE_USER) {
  961. userIdx = i
  962. }
  963. if (userIdx >= 0 && aiIdx >= 0) {
  964. break
  965. }
  966. }
  967. return { userIdx, aiIdx }
  968. },
  969. mergePersistResult(data, aiReplyContent) {
  970. if (!data) {
  971. return
  972. }
  973. const { userIdx, aiIdx } = this.findLastLocalExchangeIndexes()
  974. if (data.userMessage && userIdx >= 0) {
  975. const norm = this.normalizeMessage(data.userMessage)
  976. const row = this.messages[userIdx]
  977. this.$set(row, "id", norm.id)
  978. if (norm.sendTime) {
  979. this.$set(row, "sendTime", norm.sendTime)
  980. }
  981. }
  982. if (data.aiMessage && aiIdx >= 0) {
  983. const norm = this.normalizeMessage(data.aiMessage)
  984. const row = this.messages[aiIdx]
  985. this.$set(row, "id", norm.id)
  986. if (norm.sendTime) {
  987. this.$set(row, "sendTime", norm.sendTime)
  988. }
  989. }
  990. const session = this.activeSession
  991. const previewText = (aiReplyContent || "").trim()
  992. if (session && previewText) {
  993. this.$set(session, "lastMessageTime", new Date().toISOString())
  994. this.$set(
  995. session,
  996. "lastMessagePreview",
  997. previewText.length > 80 ? previewText.slice(0, 80) + "…" : previewText
  998. )
  999. const um = data.userMessage
  1000. if (um && um.msgType === 1 && um.content) {
  1001. const title = session.sessionTitle
  1002. if (!title || title === DEFAULT_SESSION_TITLE) {
  1003. const t = um.content.length > 30 ? um.content.slice(0, 30) + "…" : um.content
  1004. this.$set(session, "sessionTitle", t)
  1005. }
  1006. }
  1007. }
  1008. },
  1009. queuePendingPersist(consultSessionId, persistBody, aiReplyContent, llmSessionId, costTime, aiCategory) {
  1010. this.pendingPersist = {
  1011. consultSessionId,
  1012. persistBody: { ...persistBody },
  1013. aiReplyContent,
  1014. llmSessionId: llmSessionId != null ? llmSessionId : null,
  1015. costTime: costTime != null ? costTime : null,
  1016. aiCategory: aiCategory != null && aiCategory !== "" ? aiCategory : null
  1017. }
  1018. },
  1019. async flushPendingPersist() {
  1020. const pending = this.pendingPersist
  1021. if (!pending || !pending.consultSessionId || !pending.persistBody || !pending.aiReplyContent) {
  1022. return
  1023. }
  1024. try {
  1025. await sendAiConsultMessage(pending.consultSessionId, {
  1026. ...pending.persistBody,
  1027. aiReplyContent: pending.aiReplyContent,
  1028. realSessionId: pending.llmSessionId != null ? pending.llmSessionId : null,
  1029. costTime: pending.costTime != null ? pending.costTime : undefined,
  1030. aiCategory: pending.aiCategory != null ? pending.aiCategory : undefined
  1031. })
  1032. this.pendingPersist = null
  1033. } catch (err) {
  1034. // 离开页面时仍失败则保留队列,下次进入同页可再试(仅内存,刷新会丢)
  1035. }
  1036. },
  1037. async persistConsultExchange(persistBody, aiReplyContent, costTime, aiCategory) {
  1038. const reply = (aiReplyContent || "").trim()
  1039. if (!this.activeSessionId || !persistBody || !reply) {
  1040. return true
  1041. }
  1042. const consultSessionId = this.activeSessionId
  1043. const llmSessionId = this.resolveLlmSessionId(this.activeSession)
  1044. const saveBody = {
  1045. ...persistBody,
  1046. aiReplyContent: reply,
  1047. realSessionId: llmSessionId || null,
  1048. costTime: costTime != null ? costTime : undefined,
  1049. aiCategory: aiCategory != null && aiCategory !== "" ? aiCategory : undefined
  1050. }
  1051. try {
  1052. const res = await sendAiConsultMessage(consultSessionId, saveBody)
  1053. this.mergePersistResult(res.data, reply)
  1054. this.refreshSessionListQuiet()
  1055. if (
  1056. this.pendingPersist &&
  1057. this.pendingPersist.consultSessionId === consultSessionId &&
  1058. this.pendingPersist.aiReplyContent === reply
  1059. ) {
  1060. this.pendingPersist = null
  1061. }
  1062. return true
  1063. } catch (err) {
  1064. this.queuePendingPersist(consultSessionId, persistBody, reply, llmSessionId, costTime, aiCategory)
  1065. const msg = (err && err.message) || this.dtT("saveSessionFailed")
  1066. this.$modal.msgWarning(msg)
  1067. return false
  1068. }
  1069. },
  1070. refreshSessionListQuiet() {
  1071. const params = {
  1072. pageNum: 1,
  1073. pageSize: 100,
  1074. contentKeyword: (this.filterContent || "").trim() || undefined,
  1075. searchMode: this.searchMode ? true : undefined
  1076. }
  1077. listAiConsultSessions(params)
  1078. .then((res) => {
  1079. this.applyDisclaimerFromResponse(res)
  1080. this.sessions = this.mergeSessionsPreserveLlmId(res.rows || [])
  1081. if (this.activeSessionId) {
  1082. const active = this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId)
  1083. if (active) {
  1084. this.hydrateLlmSessionId(active)
  1085. }
  1086. }
  1087. })
  1088. .catch(() => {})
  1089. },
  1090. onChatScroll(e) {
  1091. const el = e.target
  1092. if (el.scrollTop < 40 && !this.loadingOlder && !this.noMoreOlder && this.messages.length) {
  1093. this.loadMessages(true)
  1094. }
  1095. },
  1096. startPolling() {
  1097. this.stopPolling()
  1098. this.pollTimer = setInterval(() => {
  1099. this.refreshSessionListQuiet()
  1100. }, POLL_MS)
  1101. },
  1102. stopPolling() {
  1103. if (this.pollTimer) {
  1104. clearInterval(this.pollTimer)
  1105. this.pollTimer = null
  1106. }
  1107. },
  1108. abortStreamRequest() {
  1109. if (this.streamAbortController) {
  1110. this.streamAbortController.abort()
  1111. this.streamAbortController = null
  1112. }
  1113. },
  1114. clearStreamRevealTimer() {
  1115. if (this._streamRevealTimer) {
  1116. clearTimeout(this._streamRevealTimer)
  1117. this._streamRevealTimer = null
  1118. }
  1119. },
  1120. pushStreamText(aiMsg, targetText) {
  1121. if (!aiMsg) {
  1122. return
  1123. }
  1124. const next = targetText == null ? "" : String(targetText)
  1125. const prev = aiMsg.content == null ? "" : String(aiMsg.content)
  1126. if (next === prev) {
  1127. return
  1128. }
  1129. const pin = () => this.$nextTick(() => this.scrollChatBottom())
  1130. if (!next.startsWith(prev)) {
  1131. this.$set(aiMsg, "content", next)
  1132. pin()
  1133. return
  1134. }
  1135. const jump = next.length - prev.length
  1136. if (jump <= 8) {
  1137. this.$set(aiMsg, "content", next)
  1138. pin()
  1139. return
  1140. }
  1141. this.clearStreamRevealTimer()
  1142. let pos = prev.length
  1143. const step = () => {
  1144. if (!aiMsg.streaming) {
  1145. this._streamRevealTimer = null
  1146. this.$set(aiMsg, "content", next)
  1147. pin()
  1148. return
  1149. }
  1150. pos = Math.min(pos + 3, next.length)
  1151. this.$set(aiMsg, "content", next.slice(0, pos))
  1152. pin()
  1153. if (pos < next.length) {
  1154. this._streamRevealTimer = setTimeout(step, 20)
  1155. } else {
  1156. this._streamRevealTimer = null
  1157. }
  1158. }
  1159. step()
  1160. },
  1161. scrollChatBottom() {
  1162. const el = this.$refs.chatScroll
  1163. if (!el) {
  1164. return
  1165. }
  1166. el.scrollTop = el.scrollHeight
  1167. },
  1168. onDraftKeydown(e) {
  1169. if (e.key === "Enter" && !e.shiftKey) {
  1170. e.preventDefault()
  1171. if (!this.sendDisabled) {
  1172. this.sendText()
  1173. }
  1174. }
  1175. },
  1176. sendText() {
  1177. const text = (this.draft || "").trim()
  1178. if (!text && !this.pendingAttachments.length) {
  1179. this.$modal.msgWarning(this.dtT("inputOrAttach"))
  1180. return
  1181. }
  1182. this.sendViaLlm({ msgType: 1, content: text })
  1183. },
  1184. async sendViaLlm(payload) {
  1185. if (!this.activeSessionId || this.sending) {
  1186. return
  1187. }
  1188. const session = this.activeSession
  1189. if (!session) {
  1190. this.$modal.msgWarning(this.dtT("selectSession"))
  1191. return
  1192. }
  1193. let userContent
  1194. if (payload && payload.msgType && payload.msgType !== 1) {
  1195. userContent = this.resolvePayloadUserContent(payload)
  1196. } else {
  1197. userContent = this.buildUserContentForLlm((payload && payload.content) || this.draft)
  1198. }
  1199. if (!userContent) {
  1200. this.$modal.msgWarning(this.dtT("inputOrAttach"))
  1201. return
  1202. }
  1203. const msgType = (payload && payload.msgType) || 1
  1204. const draftText = (this.draft || "").trim()
  1205. const pendingSnapshot = this.pendingAttachments.slice()
  1206. const displayContent = msgType !== 1 ? payload.content : userContent
  1207. const persistBody = this.buildPersistSendBody(msgType, payload, draftText, pendingSnapshot)
  1208. this.appendLocalMessage(SENDER_ROLE_USER, msgType, displayContent)
  1209. this.clearComposer()
  1210. this.$nextTick(() => this.scrollChatBottom())
  1211. const llmSessionId = this.resolveLlmSessionId(session)
  1212. const body = {
  1213. model: this.model,
  1214. messages: this.buildMessagesForLlm(llmSessionId),
  1215. user: this.name ? String(this.name) : String(this.id || "user")
  1216. }
  1217. // 继续对话:带上大模型 sessionId(SSE 的 id);新建会话首次不传
  1218. if (llmSessionId) {
  1219. body.session_id = llmSessionId
  1220. }
  1221. let aiReplyContent = ""
  1222. let aiMsg = null
  1223. let costTime = null
  1224. let aiCategory = null
  1225. this.abortStreamRequest()
  1226. const abortController = new AbortController()
  1227. this.streamAbortController = abortController
  1228. this.sending = true
  1229. try {
  1230. const streamResult = await sendChatMessage(body, {
  1231. signal: abortController.signal,
  1232. onSessionId: (llmSid) => {
  1233. this.rememberLlmSessionId(session, llmSid)
  1234. },
  1235. onModel: (model) => {
  1236. aiCategory = model
  1237. },
  1238. onDelta: (fullText) => {
  1239. aiReplyContent = fullText
  1240. if (!aiMsg) {
  1241. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, fullText)
  1242. this.$set(aiMsg, "streaming", true)
  1243. this.$nextTick(() => this.scrollChatBottom())
  1244. } else {
  1245. this.pushStreamText(aiMsg, fullText)
  1246. }
  1247. }
  1248. })
  1249. const llmSid = streamResult.sessionId || null
  1250. if (llmSid) {
  1251. this.rememberLlmSessionId(session, llmSid)
  1252. }
  1253. costTime = streamResult.durationMs != null ? streamResult.durationMs : null
  1254. if (streamResult.model) {
  1255. aiCategory = streamResult.model
  1256. }
  1257. this.clearStreamRevealTimer()
  1258. aiReplyContent = (streamResult.content || aiReplyContent || "").trim() || this.dtT("noContent")
  1259. if (!aiMsg) {
  1260. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, aiReplyContent)
  1261. } else {
  1262. this.$set(aiMsg, "content", aiReplyContent)
  1263. }
  1264. this.$set(aiMsg, "streaming", false)
  1265. } catch (err) {
  1266. if (err && (err.name === "AbortError" || err.name === "CanceledError")) {
  1267. return
  1268. }
  1269. const msg = err.message || this.dtT("requestFailed")
  1270. this.$modal.msgError(msg)
  1271. aiReplyContent = this.dtT("callFailed", { msg })
  1272. if (!aiMsg) {
  1273. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, aiReplyContent)
  1274. } else {
  1275. this.$set(aiMsg, "content", aiReplyContent)
  1276. this.$set(aiMsg, "streaming", false)
  1277. }
  1278. } finally {
  1279. this.clearStreamRevealTimer()
  1280. if (aiMsg && aiMsg.streaming) {
  1281. this.$set(aiMsg, "streaming", false)
  1282. }
  1283. this.streamAbortController = null
  1284. this.sending = false
  1285. this.$nextTick(() => this.scrollChatBottom())
  1286. }
  1287. await this.persistConsultExchange(
  1288. persistBody,
  1289. (aiReplyContent || "").trim(),
  1290. costTime,
  1291. aiCategory
  1292. )
  1293. },
  1294. extOf(fileName) {
  1295. if (!fileName || fileName.lastIndexOf(".") < 0) {
  1296. return ""
  1297. }
  1298. return fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase()
  1299. },
  1300. beforeMediaUpload(file, kind) {
  1301. const rule = MEDIA_RULES[kind]
  1302. const ext = this.extOf(file.name)
  1303. if (!rule.exts.includes(ext)) {
  1304. this.$modal.msgError(this.dtT(rule.errFmt))
  1305. return false
  1306. }
  1307. if (file.name.includes(",")) {
  1308. this.$modal.msgError(this.dtT("errComma"))
  1309. return false
  1310. }
  1311. if (file.size / 1024 / 1024 >= rule.maxMb) {
  1312. this.$modal.msgError(this.dtT(rule.errMb))
  1313. return false
  1314. }
  1315. this.$modal.loading(this.dtT("uploading"))
  1316. return true
  1317. },
  1318. onMediaUploadSuccess(res, file, msgType) {
  1319. this.$modal.closeLoading()
  1320. if (res.code === 200 && (res.url || res.fileName)) {
  1321. this.sendViaLlm({
  1322. msgType,
  1323. content: res.url || res.fileName
  1324. })
  1325. } else {
  1326. this.$modal.msgError(res.msg || this.dtT("uploadFail"))
  1327. }
  1328. },
  1329. onUploadError() {
  1330. this.$modal.closeLoading()
  1331. this.$modal.msgError(this.dtT("uploadFail"))
  1332. },
  1333. stopVoice() {
  1334. if (this.voiceAudio) {
  1335. this.voiceAudio.pause()
  1336. this.voiceAudio = null
  1337. }
  1338. this.playingVoiceId = null
  1339. },
  1340. toggleVoice(m) {
  1341. if (!m || !m.content) {
  1342. return
  1343. }
  1344. if (this.playingVoiceId === m.id) {
  1345. this.stopVoice()
  1346. return
  1347. }
  1348. this.stopVoice()
  1349. this.playingVoiceId = m.id
  1350. const audio = new Audio(this.mediaUrl(m.content))
  1351. this.voiceAudio = audio
  1352. audio.onended = () => {
  1353. if (this.playingVoiceId === m.id) {
  1354. this.stopVoice()
  1355. }
  1356. }
  1357. audio.onerror = () => {
  1358. this.$modal.msgError(this.dtT("voicePlayFail"))
  1359. this.stopVoice()
  1360. }
  1361. audio.play().catch(() => {
  1362. this.$modal.msgError(this.dtT("voicePlayFail"))
  1363. this.stopVoice()
  1364. })
  1365. }
  1366. }
  1367. }
  1368. </script>
  1369. <style scoped lang="scss">
  1370. .ai-diagnosis {
  1371. display: flex;
  1372. flex-direction: column;
  1373. height: calc(100vh - 84px - 10px);
  1374. max-height: calc(100vh - 84px - 10px);
  1375. overflow: hidden;
  1376. box-sizing: border-box;
  1377. }
  1378. .ai-diagnosis__toolbar {
  1379. flex-shrink: 0;
  1380. margin-bottom: 12px;
  1381. }
  1382. .ai-diagnosis__disclaimer {
  1383. margin-top: 8px;
  1384. }
  1385. .ai-diagnosis__disclaimer-text {
  1386. font-size: 13px;
  1387. line-height: 1.5;
  1388. }
  1389. .ai-diagnosis__main {
  1390. flex: 1;
  1391. min-height: 0;
  1392. display: flex;
  1393. flex-direction: column;
  1394. overflow: hidden;
  1395. }
  1396. .ai-diagnosis__main.el-card ::v-deep .el-card__body {
  1397. padding: 0;
  1398. flex: 1;
  1399. min-height: 0;
  1400. display: flex;
  1401. flex-direction: column;
  1402. }
  1403. .ai-diagnosis__layout {
  1404. display: flex;
  1405. flex: 1;
  1406. min-height: 0;
  1407. overflow: hidden;
  1408. }
  1409. .ai-diagnosis__sessions {
  1410. width: 300px;
  1411. flex-shrink: 0;
  1412. min-height: 0;
  1413. border-right: 1px solid #ebeef5;
  1414. display: flex;
  1415. flex-direction: column;
  1416. background: #fafafa;
  1417. }
  1418. .ai-diagnosis__sessions-head {
  1419. flex-shrink: 0;
  1420. padding: 10px 14px 8px;
  1421. border-bottom: 1px solid #ebeef5;
  1422. }
  1423. .ai-diagnosis__sessions-title {
  1424. font-weight: 600;
  1425. font-size: 15px;
  1426. color: #303133;
  1427. }
  1428. .ai-diagnosis__sessions-sub {
  1429. margin-top: 4px;
  1430. font-size: 12px;
  1431. color: #909399;
  1432. line-height: 1.45;
  1433. }
  1434. .ai-diagnosis__sessions-scroll {
  1435. flex: 1;
  1436. min-height: 0;
  1437. overflow-y: auto;
  1438. }
  1439. .session-item {
  1440. display: flex;
  1441. align-items: flex-start;
  1442. padding: 10px 12px;
  1443. cursor: pointer;
  1444. border-left: 3px solid transparent;
  1445. transition: background 0.15s;
  1446. &:hover {
  1447. background: #f0f2f5;
  1448. }
  1449. &.is-active {
  1450. background: #ecf5ff;
  1451. border-left-color: #409eff;
  1452. }
  1453. }
  1454. .session-item__delete {
  1455. flex-shrink: 0;
  1456. align-self: center;
  1457. padding: 4px;
  1458. margin-left: 4px;
  1459. color: #909399;
  1460. opacity: 0;
  1461. transition: opacity 0.15s, color 0.15s;
  1462. &:hover {
  1463. color: #f56c6c;
  1464. }
  1465. }
  1466. .session-item:hover .session-item__delete,
  1467. .session-item.is-active .session-item__delete {
  1468. opacity: 1;
  1469. }
  1470. .session-item__avatar {
  1471. flex-shrink: 0;
  1472. background: #dcdfe6;
  1473. }
  1474. .session-item__text {
  1475. margin-left: 10px;
  1476. min-width: 0;
  1477. flex: 1;
  1478. }
  1479. .session-item__row {
  1480. display: flex;
  1481. justify-content: space-between;
  1482. align-items: center;
  1483. gap: 8px;
  1484. }
  1485. .session-item__name {
  1486. font-weight: 600;
  1487. font-size: 13px;
  1488. color: #303133;
  1489. white-space: nowrap;
  1490. overflow: hidden;
  1491. text-overflow: ellipsis;
  1492. }
  1493. .session-item__date {
  1494. flex-shrink: 0;
  1495. font-size: 12px;
  1496. color: #909399;
  1497. }
  1498. .session-item__preview {
  1499. margin-top: 4px;
  1500. font-size: 12px;
  1501. color: #909399;
  1502. line-height: 1.4;
  1503. display: -webkit-box;
  1504. -webkit-line-clamp: 2;
  1505. -webkit-box-orient: vertical;
  1506. overflow: hidden;
  1507. }
  1508. .ai-diagnosis__chat {
  1509. flex: 1;
  1510. display: flex;
  1511. flex-direction: column;
  1512. min-width: 0;
  1513. min-height: 0;
  1514. overflow: hidden;
  1515. background: #fff;
  1516. }
  1517. .ai-diagnosis__chat-head {
  1518. flex-shrink: 0;
  1519. text-align: center;
  1520. padding: 14px 16px;
  1521. font-weight: 600;
  1522. font-size: 16px;
  1523. color: #303133;
  1524. border-bottom: 1px solid #ebeef5;
  1525. }
  1526. .ai-diagnosis__chat-body {
  1527. flex: 1;
  1528. min-height: 0;
  1529. overflow-y: auto;
  1530. padding: 12px 16px;
  1531. }
  1532. .chat-load-hint {
  1533. text-align: center;
  1534. font-size: 12px;
  1535. color: #909399;
  1536. padding: 6px 0 10px;
  1537. }
  1538. .chat-date-divider {
  1539. text-align: center;
  1540. font-size: 12px;
  1541. color: #909399;
  1542. margin: 12px 0 8px;
  1543. }
  1544. .chat-messages {
  1545. min-height: 120px;
  1546. }
  1547. .chat-row {
  1548. display: flex;
  1549. align-items: flex-start;
  1550. margin-bottom: 14px;
  1551. gap: 10px;
  1552. &.is-ai {
  1553. justify-content: flex-start;
  1554. }
  1555. &.is-user {
  1556. justify-content: flex-end;
  1557. }
  1558. }
  1559. .chat-row__avatar {
  1560. flex-shrink: 0;
  1561. }
  1562. .chat-row.is-ai .chat-row__avatar {
  1563. background-color: #409eff !important;
  1564. color: #fff !important;
  1565. }
  1566. .chat-row.is-user .chat-row__avatar {
  1567. background-color: #52c41a !important;
  1568. color: #fff !important;
  1569. }
  1570. .chat-bubble {
  1571. box-sizing: border-box;
  1572. padding: 8px 12px;
  1573. border-radius: 10px;
  1574. font-size: 14px;
  1575. line-height: 1.5;
  1576. &.is-ai {
  1577. width: 680px;
  1578. max-width: calc(100% - 46px);
  1579. background: #e8f4ff;
  1580. color: #1d3a5c;
  1581. border: 1px solid #b3d8ff;
  1582. }
  1583. &.is-user {
  1584. width: 360px;
  1585. max-width: calc(100% - 46px);
  1586. background: #52c41a;
  1587. color: #fff;
  1588. }
  1589. &.chat-bubble--thinking {
  1590. width: auto;
  1591. max-width: none;
  1592. min-width: 72px;
  1593. min-height: 40px;
  1594. display: flex;
  1595. align-items: center;
  1596. }
  1597. }
  1598. .chat-bubble__text {
  1599. white-space: pre-wrap;
  1600. word-break: break-word;
  1601. }
  1602. /* AI 回复:Markdown 排版(v-html),流式时同步渲染 */
  1603. .chat-bubble__md {
  1604. white-space: normal;
  1605. word-break: break-word;
  1606. line-height: 1.6;
  1607. &.is-streaming::after {
  1608. content: "";
  1609. display: inline-block;
  1610. width: 2px;
  1611. height: 1em;
  1612. margin-left: 2px;
  1613. vertical-align: text-bottom;
  1614. background: currentColor;
  1615. opacity: 0.7;
  1616. animation: stream-cursor-blink 0.9s step-end infinite;
  1617. }
  1618. ::v-deep p {
  1619. margin: 0 0 8px;
  1620. }
  1621. ::v-deep p:last-child {
  1622. margin-bottom: 0;
  1623. }
  1624. ::v-deep ul,
  1625. ::v-deep ol {
  1626. margin: 0 0 8px;
  1627. padding-left: 1.25em;
  1628. }
  1629. ::v-deep li {
  1630. margin: 4px 0;
  1631. }
  1632. ::v-deep h1,
  1633. ::v-deep h2,
  1634. ::v-deep h3,
  1635. ::v-deep h4 {
  1636. margin: 12px 0 8px;
  1637. font-weight: 600;
  1638. line-height: 1.35;
  1639. }
  1640. ::v-deep h1 {
  1641. font-size: 1.15em;
  1642. }
  1643. ::v-deep h2 {
  1644. font-size: 1.08em;
  1645. }
  1646. ::v-deep blockquote {
  1647. margin: 8px 0;
  1648. padding: 6px 12px;
  1649. border-left: 3px solid #409eff;
  1650. background: rgba(255, 255, 255, 0.55);
  1651. }
  1652. ::v-deep pre {
  1653. margin: 8px 0;
  1654. padding: 10px 12px;
  1655. border-radius: 6px;
  1656. overflow-x: auto;
  1657. background: #f6f8fa !important;
  1658. }
  1659. ::v-deep pre code {
  1660. padding: 0;
  1661. background: transparent;
  1662. }
  1663. ::v-deep code {
  1664. font-family: Consolas, Monaco, monospace;
  1665. font-size: 13px;
  1666. }
  1667. ::v-deep :not(pre) > code {
  1668. padding: 2px 5px;
  1669. border-radius: 4px;
  1670. background: rgba(0, 0, 0, 0.06);
  1671. }
  1672. ::v-deep a {
  1673. color: #409eff;
  1674. text-decoration: underline;
  1675. }
  1676. ::v-deep table {
  1677. border-collapse: collapse;
  1678. margin: 8px 0;
  1679. font-size: 13px;
  1680. max-width: 100%;
  1681. }
  1682. ::v-deep th,
  1683. ::v-deep td {
  1684. border: 1px solid #d0d7de;
  1685. padding: 6px 10px;
  1686. }
  1687. ::v-deep th {
  1688. background: #f0f3f6;
  1689. }
  1690. ::v-deep hr {
  1691. margin: 12px 0;
  1692. border: none;
  1693. border-top: 1px solid #d0d7de;
  1694. }
  1695. }
  1696. @keyframes stream-cursor-blink {
  1697. 50% {
  1698. opacity: 0;
  1699. }
  1700. }
  1701. .chat-bubble__time {
  1702. margin-top: 4px;
  1703. font-size: 11px;
  1704. opacity: 0.75;
  1705. text-align: right;
  1706. }
  1707. .chat-bubble__img {
  1708. max-width: 200px;
  1709. max-height: 160px;
  1710. border-radius: 6px;
  1711. }
  1712. .chat-bubble__video {
  1713. max-width: 240px;
  1714. max-height: 180px;
  1715. border-radius: 6px;
  1716. }
  1717. .thinking-status {
  1718. display: inline-flex;
  1719. align-items: center;
  1720. gap: 8px;
  1721. }
  1722. .thinking-status__label {
  1723. font-size: 14px;
  1724. color: #606266;
  1725. line-height: 1.4;
  1726. }
  1727. .thinking-dots {
  1728. display: inline-flex;
  1729. align-items: center;
  1730. gap: 6px;
  1731. padding: 2px 0;
  1732. }
  1733. .thinking-dots__dot {
  1734. width: 7px;
  1735. height: 7px;
  1736. border-radius: 50%;
  1737. background: #409eff;
  1738. animation: ai-thinking-dot 1.05s ease-in-out infinite both;
  1739. &:nth-child(2) {
  1740. animation-delay: 0.18s;
  1741. }
  1742. &:nth-child(3) {
  1743. animation-delay: 0.36s;
  1744. }
  1745. }
  1746. @keyframes ai-thinking-dot {
  1747. 0%,
  1748. 100% {
  1749. transform: translateY(0);
  1750. opacity: 0.35;
  1751. }
  1752. 40% {
  1753. transform: translateY(-6px);
  1754. opacity: 1;
  1755. }
  1756. }
  1757. .voice-msg {
  1758. display: flex;
  1759. align-items: center;
  1760. gap: 10px;
  1761. min-width: 88px;
  1762. cursor: pointer;
  1763. user-select: none;
  1764. }
  1765. .chat-bubble.is-user .voice-msg {
  1766. color: #fff;
  1767. }
  1768. .voice-msg__dur {
  1769. font-size: 14px;
  1770. font-weight: 500;
  1771. }
  1772. .voice-msg__waves {
  1773. display: flex;
  1774. align-items: flex-end;
  1775. gap: 3px;
  1776. height: 16px;
  1777. i {
  1778. display: block;
  1779. width: 3px;
  1780. border-radius: 2px;
  1781. background: currentColor;
  1782. }
  1783. i:nth-child(1) {
  1784. height: 6px;
  1785. }
  1786. i:nth-child(2) {
  1787. height: 10px;
  1788. }
  1789. i:nth-child(3) {
  1790. height: 14px;
  1791. }
  1792. }
  1793. .voice-msg.is-playing .voice-msg__waves i {
  1794. animation: ai-voice-bar 0.55s ease-in-out infinite alternate;
  1795. &:nth-child(2) {
  1796. animation-delay: 0.12s;
  1797. }
  1798. &:nth-child(3) {
  1799. animation-delay: 0.24s;
  1800. }
  1801. }
  1802. @keyframes ai-voice-bar {
  1803. from {
  1804. transform: scaleY(0.4);
  1805. }
  1806. to {
  1807. transform: scaleY(1);
  1808. }
  1809. }
  1810. .ai-diagnosis__composer {
  1811. flex-shrink: 0;
  1812. border-top: 1px solid #ebeef5;
  1813. padding: 10px 14px 14px;
  1814. background: #fafafa;
  1815. }
  1816. .composer-tools {
  1817. display: flex;
  1818. align-items: center;
  1819. gap: 4px;
  1820. margin-bottom: 6px;
  1821. }
  1822. .composer-model {
  1823. margin-left: auto;
  1824. padding-left: 8px;
  1825. }
  1826. .composer-model-trigger {
  1827. display: inline-flex;
  1828. align-items: center;
  1829. gap: 6px;
  1830. height: 30px;
  1831. padding: 0 12px;
  1832. border: 1px solid #dcdfe6;
  1833. border-radius: 16px;
  1834. background: #fff;
  1835. color: #303133;
  1836. font-size: 13px;
  1837. cursor: pointer;
  1838. &.is-open .composer-model-trigger__arrow {
  1839. transform: rotate(180deg);
  1840. }
  1841. }
  1842. .composer-model-trigger__icon {
  1843. font-size: 15px;
  1844. color: #409eff;
  1845. }
  1846. .composer-model-trigger__arrow {
  1847. font-size: 12px;
  1848. color: #909399;
  1849. transition: transform 0.2s;
  1850. }
  1851. .composer-attachments {
  1852. margin-bottom: 8px;
  1853. }
  1854. .composer-attach-tag {
  1855. margin-right: 6px;
  1856. }
  1857. .composer-upload {
  1858. display: inline-block;
  1859. }
  1860. .composer-input-row {
  1861. display: flex;
  1862. align-items: flex-end;
  1863. gap: 10px;
  1864. ::v-deep .el-textarea__inner {
  1865. background: #fff;
  1866. }
  1867. }
  1868. .composer-send {
  1869. flex-shrink: 0;
  1870. margin-bottom: 4px;
  1871. }
  1872. </style>
  1873. <style lang="scss">
  1874. .ai-diagnosis-model-popover {
  1875. padding: 8px !important;
  1876. }
  1877. .ai-diagnosis-model-popover .model-picker__item {
  1878. display: flex;
  1879. align-items: center;
  1880. gap: 10px;
  1881. padding: 10px 12px;
  1882. border-radius: 8px;
  1883. cursor: pointer;
  1884. &:hover {
  1885. background: #f5f7fa;
  1886. }
  1887. &.is-active {
  1888. background: #ecf5ff;
  1889. }
  1890. }
  1891. .ai-diagnosis-model-popover .model-picker__icon {
  1892. width: 32px;
  1893. height: 32px;
  1894. display: flex;
  1895. align-items: center;
  1896. justify-content: center;
  1897. border-radius: 8px;
  1898. background: #e8f4ff;
  1899. color: #409eff;
  1900. }
  1901. .ai-diagnosis-model-popover .model-picker__body {
  1902. flex: 1;
  1903. min-width: 0;
  1904. display: flex;
  1905. flex-direction: column;
  1906. gap: 2px;
  1907. }
  1908. .ai-diagnosis-model-popover .model-picker__name {
  1909. font-size: 13px;
  1910. font-weight: 600;
  1911. color: #303133;
  1912. }
  1913. .ai-diagnosis-model-popover .model-picker__desc {
  1914. font-size: 12px;
  1915. color: #909399;
  1916. }
  1917. .ai-diagnosis-model-popover .model-picker__check {
  1918. color: #409eff;
  1919. }
  1920. </style>