西藏巴青项目

index.vue 58KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058
  1. <template>
  2. <view :key="layoutKey" :class="pageRootClass" class="assistant-page">
  3. <view v-if="disclaimer" class="assistant-disclaimer">
  4. <text class="text-body assistant-disclaimer__tag">{{ $t('aiOnlineConsult.disclaimerTitle') }}</text>
  5. <text class="text-body assistant-disclaimer__txt">{{ disclaimer }}</text>
  6. </view>
  7. <scroll-view
  8. ref="feedScroll"
  9. id="assistant-feed"
  10. class="assistant-feed"
  11. :style="feedScrollStyle"
  12. scroll-y
  13. enable-back-to-top
  14. :scroll-top="feedScrollTopBind"
  15. :scroll-into-view="feedScrollIntoView"
  16. :scroll-with-animation="!sending"
  17. :upper-threshold="80"
  18. @scrolltoupper="onScrollToUpper"
  19. @scroll="onFeedScroll"
  20. >
  21. <view
  22. id="assistant-feed-inner"
  23. class="assistant-feed__inner"
  24. :class="{ 'assistant-feed__inner--pending': pendingAttachments.length }"
  25. >
  26. <view v-if="loadingOlder" class="feed-hint">
  27. <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.loadingMore') }}</text>
  28. </view>
  29. <view v-if="noMoreOlder && messages.length" class="feed-hint">
  30. <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.noMoreHistory') }}</text>
  31. </view>
  32. <view v-if="messagesLoading && !messages.length" class="feed-hint">
  33. <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.creatingSession') }}</text>
  34. </view>
  35. <block v-if="messageGroups.length">
  36. <block v-for="(group, gi) in messageGroups" :key="'g-' + gi">
  37. <view class="chat-date">
  38. <text class="text-body chat-date__txt">{{ group.dateLabel }}</text>
  39. </view>
  40. <view
  41. v-for="m in group.items"
  42. :key="m.id"
  43. :id="'msg-' + m.id"
  44. class="bubble-row"
  45. :class="isUserMessage(m) ? 'bubble-row--user' : 'bubble-row--bot'"
  46. >
  47. <view v-if="!isUserMessage(m)" class="bubble-avatar bubble-avatar--ai">
  48. <up-icon name="star-fill" color="#22C55E" :size="18" />
  49. </view>
  50. <view
  51. class="bubble"
  52. :class="[
  53. isUserMessage(m) ? 'bubble--user' : 'bubble--bot',
  54. m.msgType === 2 ? 'bubble--image' : ''
  55. ]"
  56. >
  57. <text v-if="!isUserMessage(m)" class="text-body bubble__who">{{ $t('aiAssistantPage.assistantBubble') }}</text>
  58. <text v-else class="text-body bubble__who">{{ $t('aiAssistantPage.userBubble') }}</text>
  59. <text
  60. v-if="m.msgType === 1 && isUserMessage(m)"
  61. class="text-body bubble__txt"
  62. >{{ userTextContent(m) }}</text>
  63. <text
  64. v-else-if="m.msgType === 1 && !isUserMessage(m) && m.streaming"
  65. :key="'stream-' + m.id + '-' + (m.streamRenderKey || 0)"
  66. class="text-body bubble__txt bubble__txt--stream"
  67. >{{ plainTextContent(m) }}</text>
  68. <view
  69. v-else-if="m.msgType === 1 && !isUserMessage(m)"
  70. class="bubble__rich"
  71. >
  72. <mp-html
  73. :key="m.id"
  74. :content="aiRichHtml(m)"
  75. :tag-style="mpHtmlTagStyle"
  76. :container-style="mpHtmlContainerStyle"
  77. :scroll-table="true"
  78. :selectable="true"
  79. :preview-img="true"
  80. @load="onBubbleMediaLoad"
  81. />
  82. </view>
  83. <image
  84. v-else-if="m.msgType === 2"
  85. class="bubble__img"
  86. :src="mediaUrl(m.content)"
  87. mode="widthFix"
  88. @load="onBubbleMediaLoad"
  89. @click="previewImage(m.content)"
  90. />
  91. <video
  92. v-else-if="m.msgType === 3"
  93. class="bubble__video"
  94. :src="mediaUrl(m.content)"
  95. controls
  96. />
  97. <view
  98. v-else-if="m.msgType === 4"
  99. class="voice-msg"
  100. :class="{ 'voice-msg--on': playingVoiceId === m.id }"
  101. role="button"
  102. @click="toggleVoice(m)"
  103. >
  104. <text class="text-body voice-msg__dur">{{ formatVoiceDuration(m.mediaDuration) }}</text>
  105. <text class="voice-msg__label">{{ $t('aiOnlineConsult.msgVoice') }}</text>
  106. </view>
  107. <text v-else class="text-body bubble__txt">{{ plainTextContent(m) }}</text>
  108. <text class="text-body bubble__time">{{ formatMsgTime(m.sendTime) }}</text>
  109. </view>
  110. <view v-if="isUserMessage(m)" class="bubble-avatar bubble-avatar--user">
  111. <up-icon name="account-fill" color="#16a34a" :size="18" />
  112. </view>
  113. </view>
  114. </block>
  115. </block>
  116. <view v-else-if="sessionReady && !messagesLoading" class="feed-empty">
  117. <text class="text-body feed-empty__txt">{{ $t('aiOnlineConsult.emptyChat') }}</text>
  118. </view>
  119. <view v-if="showThinking" class="bubble-row bubble-row--bot">
  120. <view class="bubble-avatar bubble-avatar--ai">
  121. <up-icon name="star-fill" color="#22C55E" :size="18" />
  122. </view>
  123. <view class="bubble bubble--bot bubble--thinking">
  124. <text class="text-body bubble__who">{{ $t('aiAssistantPage.assistantBubble') }}</text>
  125. <view class="thinking-status">
  126. <text class="text-body thinking-txt">{{ $t('aiOnlineConsult.thinking') }}</text>
  127. <view class="thinking-dots" aria-hidden="true">
  128. <view class="thinking-dots__dot" />
  129. <view class="thinking-dots__dot" />
  130. <view class="thinking-dots__dot" />
  131. </view>
  132. </view>
  133. </view>
  134. </view>
  135. <view id="msg-tail" class="assistant-feed__tail" />
  136. </view>
  137. </scroll-view>
  138. <view
  139. v-if="sessionReady"
  140. class="assistant-composer"
  141. :class="{ 'assistant-composer--has-pending': pendingAttachments.length }"
  142. >
  143. <!-- 待发送预览(在模型/工具行上方) -->
  144. <scroll-view
  145. v-if="pendingAttachments.length"
  146. class="composer-pending"
  147. scroll-x
  148. :show-scrollbar="false"
  149. >
  150. <view class="composer-pending__track">
  151. <view
  152. v-for="(a, i) in pendingAttachments"
  153. :key="a.id"
  154. class="composer-pending__item"
  155. >
  156. <image
  157. v-if="a.kind === 'image'"
  158. class="composer-pending__thumb composer-pending__thumb--image"
  159. :src="a.localPath"
  160. mode="widthFix"
  161. />
  162. <view v-else-if="a.kind === 'video'" class="composer-pending__thumb composer-pending__thumb--video">
  163. <image
  164. v-if="a.poster"
  165. class="composer-pending__thumb"
  166. :src="a.poster"
  167. mode="aspectFill"
  168. />
  169. <view class="composer-pending__video-mask">
  170. <up-icon name="play-circle" color="#ffffff" :size="28" />
  171. </view>
  172. </view>
  173. <view v-else class="composer-pending__thumb composer-pending__thumb--voice">
  174. <up-icon name="mic" color="#16a34a" :size="28" />
  175. <text class="composer-pending__voice-name">{{ pendingShortName(a.name) }}</text>
  176. </view>
  177. <view class="composer-pending__close" role="button" @click.stop="removeAttachment(i)">
  178. <up-icon name="close" color="#ffffff" :size="12" />
  179. </view>
  180. </view>
  181. </view>
  182. </scroll-view>
  183. <!-- 模型 + 图片 / 视频 / 语音 -->
  184. <view class="composer-tools">
  185. <view class="composer-model" role="button" @click="openModelPicker">
  186. <up-icon :name="currentModelOption.icon" color="#16a34a" :size="20" />
  187. <text class="composer-model__txt">{{ currentModelOption.short }}</text>
  188. <up-icon name="arrow-down" color="#6b7f72" :size="12" />
  189. </view>
  190. <view class="composer-icon-btn" role="button" @click="pickImage">
  191. <up-icon name="photo" color="#22C55E" :size="24" />
  192. </view>
  193. <!-- <view class="composer-icon-btn" role="button" @click="pickVideo">
  194. <up-icon name="play-circle" color="#22C55E" :size="24" />
  195. </view>
  196. <view class="composer-icon-btn" role="button" @click="pickVoice">
  197. <up-icon name="mic" color="#22C55E" :size="24" />
  198. </view> -->
  199. </view>
  200. <view class="composer-input-row">
  201. <view class="composer-input-shell">
  202. <textarea
  203. v-model="draft"
  204. class="composer-textarea"
  205. :placeholder="$t('aiOnlineConsult.draftPlaceholder')"
  206. placeholder-class="composer-textarea-ph"
  207. maxlength="2000"
  208. :disabled="sending"
  209. :cursor-spacing="16"
  210. :show-confirm-bar="false"
  211. auto-height
  212. />
  213. </view>
  214. <view
  215. :class="['composer-send', { 'composer-send--disabled': sendDisabled }]"
  216. role="button"
  217. @click="handleSend"
  218. >
  219. <text class="text-body composer-send__txt">
  220. {{ sending ? $t('aiOnlineConsult.thinking') : $t('aiOnlineConsult.send') }}
  221. </text>
  222. </view>
  223. </view>
  224. </view>
  225. </view>
  226. </template>
  227. <script>
  228. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  229. import tabPage from '@/mixins/tabPage'
  230. import { ensureApiToken } from '@/utils/apiAuth'
  231. import { uploadFile } from '@/utils/upload'
  232. import { useUserStore } from '@/store/user'
  233. import {
  234. MSG_TYPE_TEXT,
  235. MSG_TYPE_IMAGE,
  236. MSG_TYPE_VIDEO,
  237. MSG_TYPE_VOICE,
  238. MESSAGE_PAGE_SIZE,
  239. resolveSessionId,
  240. resolveConsultSessionId,
  241. resolveLlmSessionIdFromRow,
  242. resolveLlmSessionId as resolveLlmSessionIdUtil,
  243. normalizeSessionRow,
  244. isUserMessage,
  245. mediaUrl,
  246. normalizeMessage,
  247. formatMsgTime,
  248. formatVoiceDuration,
  249. buildMessageGroups
  250. } from '@/utils/aiConsult'
  251. import {
  252. MODEL_OPTION_DEFS,
  253. MEDIA_RULES,
  254. SENDER_ROLE_USER,
  255. SENDER_ROLE_AI,
  256. genLocalId,
  257. extOf,
  258. buildUserContentForLlm,
  259. buildMessagesForLlm,
  260. resolvePayloadUserContent,
  261. buildPersistSendBody,
  262. findLastLocalExchangeIndexes
  263. } from '@/utils/aiLlmChat'
  264. import {
  265. createAiConsultSession,
  266. listAiConsultMessages,
  267. sendAiConsultMessage,
  268. sendChatMessage
  269. } from '@/api/diseaseTreatment/aiOnlineConsult'
  270. import {
  271. markdownToHtml,
  272. MP_HTML_TAG_STYLE,
  273. MP_HTML_CONTAINER_STYLE
  274. } from '@/utils/chatMarkdown'
  275. const LLM_SESSION_STORAGE_PREFIX = 'ai_consult_llm_session_'
  276. export default {
  277. components: {
  278. 'up-icon': UIcon
  279. },
  280. mixins: [tabPage],
  281. data() {
  282. return {
  283. navTitleKey: 'aiAssistantPage.title',
  284. sessionId: '',
  285. sessionRow: null,
  286. sessionReady: false,
  287. disclaimer: '',
  288. messages: [],
  289. draft: '',
  290. model: 'auto',
  291. pendingAttachments: [],
  292. pendingPersist: null,
  293. messagesLoading: false,
  294. loadingOlder: false,
  295. noMoreOlder: false,
  296. sending: false,
  297. feedScrollTop: 0,
  298. feedScrollTopFrozen: null,
  299. feedScrollIntoView: '',
  300. _pageAlive: true,
  301. scrollTopNonce: 0,
  302. scrollBottomTimer: null,
  303. feedHeightPx: 0,
  304. _pinScrollRaf: null,
  305. _lastStreamScrollAt: 0,
  306. _streamRevealTimer: null,
  307. stickToBottom: true,
  308. /** 首次滚底完成前,不因 scroll 事件把 stickToBottom 置为 false */
  309. scrollPinReady: false,
  310. playingVoiceId: null,
  311. innerAudio: null,
  312. mpHtmlTagStyle: MP_HTML_TAG_STYLE,
  313. mpHtmlContainerStyle: MP_HTML_CONTAINER_STYLE,
  314. streamRequestTask: null
  315. }
  316. },
  317. computed: {
  318. feedScrollStyle() {
  319. if (this.feedHeightPx > 0) {
  320. return { height: this.feedHeightPx + 'px' }
  321. }
  322. return { height: '100%' }
  323. },
  324. /** 流式期间冻结 scroll-top,避免重渲染把列表拽回顶部 */
  325. feedScrollTopBind() {
  326. if (typeof this.feedScrollTopFrozen === 'number') {
  327. return this.feedScrollTopFrozen
  328. }
  329. return this.feedScrollTop
  330. },
  331. modelOptions() {
  332. return MODEL_OPTION_DEFS.map((o) => ({
  333. value: o.value,
  334. icon: o.icon,
  335. label: this.$t('aiOnlineConsult.' + o.labelKey),
  336. short: this.$t('aiOnlineConsult.' + o.shortKey),
  337. desc: this.$t('aiOnlineConsult.' + o.descKey)
  338. }))
  339. },
  340. currentModelOption() {
  341. return this.modelOptions.find((o) => o.value === this.model) || this.modelOptions[0]
  342. },
  343. messageGroups() {
  344. return buildMessageGroups(this.messages, (k, p) => this.$t(k, p))
  345. },
  346. sendDisabled() {
  347. const t = (this.draft || '').trim()
  348. return this.sending || !this.sessionId || (!t && !this.pendingAttachments.length)
  349. },
  350. showThinking() {
  351. if (!this.sending) {
  352. return false
  353. }
  354. return !this.messages.some((m) => m.senderRole === SENDER_ROLE_AI && m.streaming)
  355. },
  356. llmUserId() {
  357. const store = useUserStore()
  358. return store.state.name ? String(store.state.name) : String(store.state.id || 'user')
  359. }
  360. },
  361. watch: {
  362. sessionReady() {
  363. this.$nextTick(() => this.updateFeedHeight())
  364. },
  365. disclaimer() {
  366. this.$nextTick(() => this.updateFeedHeight())
  367. },
  368. feedHeightPx(val) {
  369. if (val > 0 && !this.sending) {
  370. this.$nextTick(() => this.pinScrollToBottom(true))
  371. }
  372. },
  373. 'messages.length'(len) {
  374. if (len > 0 && this.stickToBottom && !this.loadingOlder && !this.sending) {
  375. this.$nextTick(() => this.scheduleScrollToBottom())
  376. }
  377. }
  378. },
  379. onLoad(query) {
  380. this.stickToBottom = true
  381. this.scrollPinReady = false
  382. if (!ensureApiToken()) return
  383. const raw = query && (query.sessionId || query.id || '')
  384. if (raw) {
  385. try {
  386. this.sessionId = decodeURIComponent(raw)
  387. } catch (e) {
  388. this.sessionId = raw
  389. }
  390. let llmFromQuery = ''
  391. if (query && query.llmSessionId) {
  392. try {
  393. llmFromQuery = decodeURIComponent(query.llmSessionId)
  394. } catch (e) {
  395. llmFromQuery = String(query.llmSessionId)
  396. }
  397. }
  398. this.sessionRow = normalizeSessionRow(
  399. {
  400. id: this.sessionId,
  401. realSessionId: this.sessionId,
  402. llmSessionId: llmFromQuery || null
  403. },
  404. llmFromQuery ? { [this.sessionId]: llmFromQuery } : null
  405. )
  406. if (llmFromQuery) {
  407. this.saveLlmSessionId(this.sessionId, llmFromQuery)
  408. }
  409. this.hydrateLlmSessionId(this.sessionRow)
  410. this.sessionId = this.consultSessionIdForApi() || this.sessionId
  411. this.sessionReady = !!this.sessionId
  412. this.loadMessages(false)
  413. this.$nextTick(() => this.updateFeedHeight())
  414. return
  415. }
  416. this.initNewSession()
  417. },
  418. onShow() {
  419. this.stickToBottom = true
  420. this.updateFeedHeight()
  421. if (this.sessionReady && this.messages.length && !this.messagesLoading && !this.loadingOlder) {
  422. this.scheduleScrollToBottom()
  423. }
  424. },
  425. onReady() {
  426. this.updateFeedHeight()
  427. this.$nextTick(() => {
  428. if (this.messages.length && !this.messagesLoading) {
  429. this.scheduleScrollToBottom()
  430. }
  431. })
  432. },
  433. mounted() {
  434. this.bindH5WheelScroll()
  435. },
  436. // #ifdef VUE3
  437. beforeUnmount() {
  438. this._pageAlive = false
  439. this.unbindH5WheelScroll()
  440. },
  441. // #endif
  442. // #ifndef VUE3
  443. beforeDestroy() {
  444. this._pageAlive = false
  445. this.unbindH5WheelScroll()
  446. },
  447. // #endif
  448. onUnload() {
  449. this._pageAlive = false
  450. if (this.scrollBottomTimer) {
  451. clearTimeout(this.scrollBottomTimer)
  452. this.scrollBottomTimer = null
  453. }
  454. this.clearStreamRevealTimer()
  455. this.abortStreamRequest()
  456. this.flushPendingPersist()
  457. this.stopVoice()
  458. this.unbindH5WheelScroll()
  459. },
  460. methods: {
  461. isUserMessage,
  462. mediaUrl,
  463. formatMsgTime,
  464. formatVoiceDuration,
  465. userTextContent(m) {
  466. if (!m || m.content == null) {
  467. return ''
  468. }
  469. return typeof m.content === 'string' ? m.content : String(m.content)
  470. },
  471. plainTextContent(m) {
  472. if (!m || m.content == null) {
  473. return ''
  474. }
  475. if (typeof m.content === 'string') {
  476. return m.content
  477. }
  478. if (Array.isArray(m.content)) {
  479. const part = m.content.find((p) => p && p.type === 'text' && p.text)
  480. return part ? part.text : ''
  481. }
  482. return String(m.content)
  483. },
  484. aiRichHtml(m) {
  485. const raw = this.plainTextContent(m)
  486. return markdownToHtml(raw)
  487. },
  488. openModelPicker() {
  489. const labels = this.modelOptions.map((o) => o.label)
  490. uni.showActionSheet({
  491. title: this.$t('aiOnlineConsult.pickModel'),
  492. itemList: labels,
  493. success: (res) => {
  494. const opt = this.modelOptions[res.tapIndex]
  495. if (opt) {
  496. this.model = opt.value
  497. }
  498. }
  499. })
  500. },
  501. pendingShortName(name) {
  502. const n = String(name || '')
  503. if (n.length <= 12) return n
  504. return n.slice(-12)
  505. },
  506. revokeAttachmentPreview(a) {
  507. if (a && a.objectUrl && typeof URL !== 'undefined') {
  508. try {
  509. URL.revokeObjectURL(a.objectUrl)
  510. } catch (e) {
  511. /* ignore */
  512. }
  513. }
  514. },
  515. removeAttachment(i) {
  516. const a = this.pendingAttachments[i]
  517. this.revokeAttachmentPreview(a)
  518. this.pendingAttachments.splice(i, 1)
  519. },
  520. setPendingAttachment(item) {
  521. this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
  522. this.pendingAttachments = [
  523. {
  524. id: genLocalId(),
  525. kind: item.kind,
  526. msgType: item.msgType,
  527. name: item.name,
  528. localPath: item.localPath,
  529. poster: item.poster || '',
  530. nativeFile: item.nativeFile || null,
  531. objectUrl: item.objectUrl || '',
  532. mediaDuration: item.mediaDuration,
  533. url: ''
  534. }
  535. ]
  536. },
  537. uploadAllPending() {
  538. const tasks = this.pendingAttachments.map((a) => {
  539. if (a.url) {
  540. return Promise.resolve(a.url)
  541. }
  542. return uploadFile(a.localPath, a.nativeFile).then((url) => {
  543. a.url = url
  544. return url
  545. })
  546. })
  547. return Promise.all(tasks)
  548. },
  549. initNewSession() {
  550. uni.showLoading({ mask: true })
  551. createAiConsultSession({})
  552. .then((res) => {
  553. const data = res.data || {}
  554. this.sessionId = resolveSessionId(data)
  555. this.clearLlmSessionId(this.sessionId)
  556. this.sessionRow = normalizeSessionRow({
  557. ...data,
  558. id: this.sessionId,
  559. realSessionId: this.sessionId,
  560. llmSessionId: null,
  561. sessionTitle: data.sessionTitle
  562. })
  563. this.sessionReady = !!this.sessionId
  564. if (data.disclaimer) {
  565. this.disclaimer = data.disclaimer
  566. }
  567. this.$nextTick(() => this.updateFeedHeight())
  568. })
  569. .catch(() => {
  570. uni.navigateBack()
  571. })
  572. .finally(() => {
  573. uni.hideLoading()
  574. })
  575. },
  576. consultSessionIdForApi() {
  577. return resolveConsultSessionId(this.sessionRow) || this.sessionId || null
  578. },
  579. loadMessages(older) {
  580. const consultSid = this.consultSessionIdForApi()
  581. if (!consultSid) return
  582. const beforeId = older && this.messages.length ? this.messages[0].id : undefined
  583. if (older) {
  584. this.loadingOlder = true
  585. } else {
  586. this.messagesLoading = true
  587. }
  588. listAiConsultMessages(consultSid, {
  589. beforeId,
  590. pageSize: MESSAGE_PAGE_SIZE
  591. })
  592. .then((res) => {
  593. const batch = (res.data || []).map((m) => normalizeMessage(m))
  594. if (older) {
  595. if (!batch.length) {
  596. this.noMoreOlder = true
  597. } else {
  598. const feedEl = this.getH5ScrollableEl()
  599. const prevScrollHeight = feedEl ? feedEl.scrollHeight : 0
  600. const prevScrollTop = feedEl ? feedEl.scrollTop : 0
  601. this.messages = batch.concat(this.messages)
  602. if (batch.length < MESSAGE_PAGE_SIZE) {
  603. this.noMoreOlder = true
  604. }
  605. if (feedEl && prevScrollHeight > 0) {
  606. this.$nextTick(() => {
  607. feedEl.scrollTop = feedEl.scrollHeight - prevScrollHeight + prevScrollTop
  608. })
  609. }
  610. }
  611. } else {
  612. this.messages = batch
  613. if (!batch.length) {
  614. this.noMoreOlder = true
  615. }
  616. this.$nextTick(() => {
  617. this.scheduleScrollToBottom()
  618. })
  619. }
  620. })
  621. .finally(() => {
  622. this.messagesLoading = false
  623. this.loadingOlder = false
  624. if (!older) {
  625. this.$nextTick(() => {
  626. this.scheduleScrollToBottom()
  627. })
  628. }
  629. })
  630. },
  631. onScrollToUpper() {
  632. if (this.loadingOlder || this.noMoreOlder || !this.messages.length) return
  633. this.loadMessages(true)
  634. },
  635. bindH5WheelScroll() {
  636. // #ifdef H5
  637. if (this._h5DocWheel) {
  638. return
  639. }
  640. this._h5DocWheel = (e) => this.handleH5DocumentWheel(e)
  641. document.addEventListener('wheel', this._h5DocWheel, { passive: false, capture: true })
  642. window.addEventListener('resize', this.updateFeedHeight)
  643. // #endif
  644. },
  645. unbindH5WheelScroll() {
  646. // #ifdef H5
  647. if (this._h5DocWheel) {
  648. document.removeEventListener('wheel', this._h5DocWheel, { capture: true })
  649. this._h5DocWheel = null
  650. }
  651. window.removeEventListener('resize', this.updateFeedHeight)
  652. // #endif
  653. },
  654. updateFeedHeight() {
  655. if (!this._pageAlive) {
  656. return
  657. }
  658. uni.getSystemInfo({
  659. success: (sys) => {
  660. if (!this._pageAlive) {
  661. return
  662. }
  663. const winH = sys.windowHeight || sys.screenHeight || 600
  664. const discEstimate = this.disclaimer ? 56 : 0
  665. const compEstimate = this.sessionReady ? 150 : 0
  666. this.feedHeightPx = Math.max(160, Math.floor(winH - discEstimate - compEstimate))
  667. this.$nextTick(() => {
  668. if (!this._pageAlive) {
  669. return
  670. }
  671. try {
  672. const q = uni.createSelectorQuery().in(this)
  673. q.select('.assistant-disclaimer').boundingClientRect()
  674. if (this.sessionReady) {
  675. q.select('.assistant-composer').boundingClientRect()
  676. }
  677. q.exec((rects) => {
  678. if (!this._pageAlive || !rects) {
  679. return
  680. }
  681. const discH = (rects[0] && rects[0].height) || discEstimate
  682. const compH = this.sessionReady ? (rects[1] && rects[1].height) || compEstimate : 0
  683. const next = Math.max(160, Math.floor(winH - discH - compH))
  684. if (next !== this.feedHeightPx) {
  685. this.feedHeightPx = next
  686. if (!this.sending) {
  687. this.$nextTick(() => this.pinScrollToBottom(true))
  688. }
  689. }
  690. })
  691. } catch (e) {
  692. /* H5 节点未挂载时 query 可能抛错,保留估算高度 */
  693. }
  694. })
  695. }
  696. })
  697. },
  698. requestPinScroll() {
  699. if (!this._pageAlive || (!this.stickToBottom && !this.sending)) {
  700. return
  701. }
  702. if (this._pinScrollRaf) {
  703. return
  704. }
  705. const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16)
  706. this._pinScrollRaf = raf(() => {
  707. this._pinScrollRaf = null
  708. if (this.sending) {
  709. this.scrollStreamToBottom()
  710. } else {
  711. this.pinScrollToBottom()
  712. }
  713. })
  714. },
  715. /** 流式输出:仅改原生滚动位置,不动 feedScrollTop,避免与 scroll-view 打架抖动 */
  716. scrollStreamToBottom() {
  717. if (!this._pageAlive) {
  718. return
  719. }
  720. const el = this.getH5ScrollableEl()
  721. if (el) {
  722. const maxTop = this.getH5FeedMaxScrollTop()
  723. const target = maxTop != null ? maxTop : Math.max(0, el.scrollHeight - el.clientHeight)
  724. if (Math.abs(el.scrollTop - target) > 2) {
  725. el.scrollTop = target
  726. }
  727. return
  728. }
  729. const now = Date.now()
  730. if (this._lastStreamScrollAt && now - this._lastStreamScrollAt < 150) {
  731. return
  732. }
  733. this._lastStreamScrollAt = now
  734. this.syncFeedScrollTop()
  735. },
  736. getH5FeedMaxScrollTop() {
  737. const el = this.getH5ScrollableEl()
  738. if (!el) {
  739. return null
  740. }
  741. const root = this.getFeedScrollEl()
  742. const content = root && root.querySelector ? root.querySelector('.uni-scroll-view-content') : null
  743. const scrollHeight = content ? Math.max(content.scrollHeight, el.scrollHeight) : el.scrollHeight
  744. const clientHeight = el.clientHeight || (content && content.clientHeight) || 0
  745. if (clientHeight <= 0) {
  746. return null
  747. }
  748. return Math.max(0, Math.ceil(scrollHeight - clientHeight))
  749. },
  750. applyFeedScrollTop(maxTop, force) {
  751. this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
  752. const nextTop = maxTop + this.scrollTopNonce
  753. if (force || this.feedScrollTop !== nextTop) {
  754. this.feedScrollTop = nextTop
  755. } else {
  756. this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
  757. this.feedScrollTop = maxTop + this.scrollTopNonce
  758. }
  759. if (this.stickToBottom) {
  760. this.scrollPinReady = true
  761. }
  762. },
  763. scrollIntoViewTail() {
  764. if (!this._pageAlive) {
  765. return
  766. }
  767. this.feedScrollIntoView = ''
  768. this.$nextTick(() => {
  769. if (!this._pageAlive) {
  770. return
  771. }
  772. this.feedScrollIntoView = 'msg-tail'
  773. setTimeout(() => {
  774. if (this._pageAlive) {
  775. this.feedScrollIntoView = ''
  776. }
  777. }, 120)
  778. })
  779. },
  780. freezeFeedScrollTopForStream() {
  781. const el = this.getH5ScrollableEl()
  782. if (el) {
  783. this.feedScrollTopFrozen = el.scrollTop
  784. return
  785. }
  786. this.feedScrollTopFrozen = this.feedScrollTop
  787. },
  788. releaseFeedScrollTopFrozen() {
  789. this.feedScrollTopFrozen = null
  790. },
  791. syncFeedScrollTop(force) {
  792. if (!this._pageAlive || typeof this.feedScrollTopFrozen === 'number') {
  793. return
  794. }
  795. const h5Max = this.getH5FeedMaxScrollTop()
  796. if (h5Max != null) {
  797. this.applyFeedScrollTop(h5Max, !!force)
  798. return
  799. }
  800. try {
  801. uni.createSelectorQuery()
  802. .in(this)
  803. .select('#assistant-feed-inner')
  804. .boundingClientRect()
  805. .select('#assistant-feed')
  806. .boundingClientRect()
  807. .exec((res) => {
  808. if (!this._pageAlive || !res) {
  809. return
  810. }
  811. const inner = res[0]
  812. const viewport = res[1]
  813. if (!inner || !viewport || inner.height <= 0 || viewport.height <= 0) {
  814. return
  815. }
  816. const maxTop = Math.max(0, Math.ceil(inner.height - viewport.height))
  817. this.applyFeedScrollTop(maxTop, !!force)
  818. })
  819. } catch (e) {
  820. /* ignore */
  821. }
  822. },
  823. pinScrollToBottom(force) {
  824. if (!this._pageAlive) {
  825. return
  826. }
  827. if (this.sending) {
  828. this.scrollStreamToBottom()
  829. return
  830. }
  831. const run = () => {
  832. if (!this._pageAlive) {
  833. return
  834. }
  835. this.scrollIntoViewTail()
  836. const apply = () => this.syncFeedScrollTop(!!force)
  837. apply()
  838. this.$nextTick(apply)
  839. const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : null
  840. if (raf) {
  841. raf(() => raf(apply))
  842. }
  843. }
  844. if (force) {
  845. run()
  846. return
  847. }
  848. this.$nextTick(run)
  849. },
  850. getFeedScrollEl() {
  851. const ref = this.$refs.feedScroll
  852. if (!ref) {
  853. return null
  854. }
  855. if (ref.$el) {
  856. return ref.$el
  857. }
  858. return ref
  859. },
  860. getH5ScrollableEl() {
  861. const root = this.getFeedScrollEl()
  862. if (!root || typeof document === 'undefined') {
  863. return null
  864. }
  865. const prefer = root.querySelector('.uni-scroll-view') || root.querySelector('.uni-scroll-view-content')
  866. if (prefer) {
  867. return prefer
  868. }
  869. const nodes = root.querySelectorAll('*')
  870. for (let i = 0; i < nodes.length; i++) {
  871. const el = nodes[i]
  872. if (el.scrollHeight > el.clientHeight + 1) {
  873. return el
  874. }
  875. }
  876. return root
  877. },
  878. handleH5DocumentWheel(e) {
  879. if (!e) {
  880. return
  881. }
  882. const root = this.getFeedScrollEl()
  883. if (!root || !e.target || !root.contains(e.target)) {
  884. return
  885. }
  886. const el = this.getH5ScrollableEl()
  887. if (!el || el.scrollHeight <= el.clientHeight + 1) {
  888. return
  889. }
  890. const maxTop = el.scrollHeight - el.clientHeight
  891. const next = Math.max(0, Math.min(maxTop, el.scrollTop + e.deltaY))
  892. if (Math.abs(next - el.scrollTop) < 0.5) {
  893. return
  894. }
  895. el.scrollTop = next
  896. e.preventDefault()
  897. e.stopPropagation()
  898. },
  899. onBubbleMediaLoad() {
  900. if (this.sending || !this.stickToBottom) {
  901. return
  902. }
  903. this.pinScrollToBottom(true)
  904. },
  905. onFeedScroll(e) {
  906. if (e && e.detail && e.detail.scrollTop != null && e.detail.scrollTop < 40) {
  907. this.onScrollToUpper()
  908. }
  909. if (this.sending || this.messagesLoading || this.loadingOlder || !this.scrollPinReady) {
  910. return
  911. }
  912. let scrollTop = e.detail && e.detail.scrollTop
  913. const el = this.getH5ScrollableEl()
  914. if (el && el.scrollHeight > el.clientHeight + 20) {
  915. if (scrollTop == null) {
  916. scrollTop = el.scrollTop
  917. }
  918. const dist = el.scrollHeight - el.clientHeight - (scrollTop || 0)
  919. this.stickToBottom = dist < 100
  920. }
  921. },
  922. scheduleScrollToBottom(immediate = true) {
  923. if (!this._pageAlive) {
  924. return
  925. }
  926. this.stickToBottom = true
  927. if (immediate) {
  928. this.pinScrollToBottom(true)
  929. }
  930. if (this.sending) {
  931. return
  932. }
  933. const delays =
  934. typeof document !== 'undefined' ? [80, 250, 600, 1200, 2000] : [120, 400, 900, 1500]
  935. delays.forEach((ms) => {
  936. setTimeout(() => {
  937. if (!this._pageAlive || this.sending) {
  938. return
  939. }
  940. if (!this.stickToBottom && this.scrollPinReady) {
  941. return
  942. }
  943. this.pinScrollToBottom(true)
  944. }, ms)
  945. })
  946. },
  947. clearComposer() {
  948. this.draft = ''
  949. this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
  950. this.pendingAttachments = []
  951. },
  952. llmSessionStorageKey(consultSessionId) {
  953. return LLM_SESSION_STORAGE_PREFIX + consultSessionId
  954. },
  955. loadLlmSessionId(consultSessionId) {
  956. if (!consultSessionId) {
  957. return null
  958. }
  959. try {
  960. return uni.getStorageSync(this.llmSessionStorageKey(consultSessionId)) || null
  961. } catch (e) {
  962. return null
  963. }
  964. },
  965. saveLlmSessionId(consultSessionId, llmSessionId) {
  966. if (!consultSessionId || !llmSessionId) {
  967. return
  968. }
  969. try {
  970. uni.setStorageSync(this.llmSessionStorageKey(consultSessionId), String(llmSessionId))
  971. } catch (e) {
  972. /* ignore */
  973. }
  974. },
  975. clearLlmSessionId(consultSessionId) {
  976. if (!consultSessionId) {
  977. return
  978. }
  979. try {
  980. uni.removeStorageSync(this.llmSessionStorageKey(consultSessionId))
  981. } catch (e) {
  982. /* ignore */
  983. }
  984. },
  985. hydrateLlmSessionId(sessionRow) {
  986. if (!sessionRow) {
  987. return
  988. }
  989. const sid = resolveConsultSessionId(sessionRow)
  990. const fromList = resolveLlmSessionIdFromRow(sessionRow)
  991. if (fromList && !sessionRow.llmSessionId) {
  992. sessionRow.llmSessionId = fromList
  993. this.saveLlmSessionId(sid, fromList)
  994. }
  995. const stored = this.loadLlmSessionId(sid)
  996. if (stored && !sessionRow.llmSessionId) {
  997. sessionRow.llmSessionId = stored
  998. }
  999. },
  1000. resolveLlmSessionId() {
  1001. return resolveLlmSessionIdUtil(this.sessionRow, (consultId) => this.loadLlmSessionId(consultId))
  1002. },
  1003. rememberLlmSessionId(llmSessionId) {
  1004. if (!llmSessionId || !this.sessionRow) {
  1005. return
  1006. }
  1007. const v = String(llmSessionId)
  1008. this.sessionRow.llmSessionId = v
  1009. this.saveLlmSessionId(resolveConsultSessionId(this.sessionRow), v)
  1010. },
  1011. abortStreamRequest() {
  1012. if (this.streamRequestTask && typeof this.streamRequestTask.abort === 'function') {
  1013. try {
  1014. this.streamRequestTask.abort()
  1015. } catch (e) {
  1016. /* ignore */
  1017. }
  1018. }
  1019. this.streamRequestTask = null
  1020. },
  1021. clearStreamRevealTimer() {
  1022. if (this._streamRevealTimer) {
  1023. clearTimeout(this._streamRevealTimer)
  1024. this._streamRevealTimer = null
  1025. }
  1026. },
  1027. /** 流式正文:小步即时更新;网关缓冲导致一次写入很多字时做打字机展开 */
  1028. pushStreamText(aiMsg, targetText) {
  1029. if (!aiMsg) {
  1030. return
  1031. }
  1032. const next = targetText == null ? '' : String(targetText)
  1033. const prev = aiMsg.content == null ? '' : String(aiMsg.content)
  1034. if (next === prev) {
  1035. return
  1036. }
  1037. if (!next.startsWith(prev)) {
  1038. this.$set(aiMsg, 'content', next)
  1039. this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
  1040. this.requestPinScroll()
  1041. return
  1042. }
  1043. const jump = next.length - prev.length
  1044. if (jump <= 8) {
  1045. this.$set(aiMsg, 'content', next)
  1046. this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
  1047. this.requestPinScroll()
  1048. return
  1049. }
  1050. this.clearStreamRevealTimer()
  1051. let pos = prev.length
  1052. const step = () => {
  1053. if (!this._pageAlive || !aiMsg.streaming) {
  1054. this._streamRevealTimer = null
  1055. this.$set(aiMsg, 'content', next)
  1056. this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
  1057. return
  1058. }
  1059. pos = Math.min(pos + 3, next.length)
  1060. this.$set(aiMsg, 'content', next.slice(0, pos))
  1061. this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
  1062. this.requestPinScroll()
  1063. if (pos < next.length) {
  1064. this._streamRevealTimer = setTimeout(step, 20)
  1065. } else {
  1066. this._streamRevealTimer = null
  1067. }
  1068. }
  1069. step()
  1070. },
  1071. appendLocalMessage(senderRole, msgType, content, extra) {
  1072. const msg = {
  1073. id: genLocalId(),
  1074. senderRole,
  1075. msgType: msgType || MSG_TYPE_TEXT,
  1076. content,
  1077. sendTime: new Date().toISOString(),
  1078. ...(extra || {})
  1079. }
  1080. this.messages.push(msg)
  1081. return msg
  1082. },
  1083. mergePersistResult(data, aiReplyContent) {
  1084. if (!data) return
  1085. const { userIdx, aiIdx } = findLastLocalExchangeIndexes(this.messages)
  1086. if (data.userMessage && userIdx >= 0) {
  1087. const norm = normalizeMessage(data.userMessage)
  1088. const row = this.messages[userIdx]
  1089. row.id = norm.id
  1090. if (norm.sendTime) row.sendTime = norm.sendTime
  1091. }
  1092. if (data.aiMessage && aiIdx >= 0) {
  1093. const norm = normalizeMessage(data.aiMessage)
  1094. const row = this.messages[aiIdx]
  1095. row.id = norm.id
  1096. if (norm.sendTime) row.sendTime = norm.sendTime
  1097. }
  1098. },
  1099. queuePendingPersist(consultSessionId, persistBody, aiReplyContent, llmSessionId, costTime, aiCategory) {
  1100. this.pendingPersist = {
  1101. consultSessionId,
  1102. persistBody: { ...persistBody },
  1103. aiReplyContent,
  1104. llmSessionId: llmSessionId != null ? llmSessionId : null,
  1105. costTime: costTime != null ? costTime : null,
  1106. aiCategory: aiCategory != null && aiCategory !== '' ? aiCategory : null
  1107. }
  1108. },
  1109. flushPendingPersist() {
  1110. const pending = this.pendingPersist
  1111. if (!pending || !pending.consultSessionId || !pending.persistBody || !pending.aiReplyContent) {
  1112. return Promise.resolve()
  1113. }
  1114. return sendAiConsultMessage(pending.consultSessionId, {
  1115. ...pending.persistBody,
  1116. aiReplyContent: pending.aiReplyContent,
  1117. realSessionId: pending.llmSessionId != null ? pending.llmSessionId : null,
  1118. costTime: pending.costTime != null ? pending.costTime : undefined,
  1119. aiCategory: pending.aiCategory != null ? pending.aiCategory : undefined
  1120. })
  1121. .then(() => {
  1122. this.pendingPersist = null
  1123. })
  1124. .catch(() => {})
  1125. },
  1126. persistConsultExchange(persistBody, aiReplyContent, costTime, aiCategory) {
  1127. const reply = (aiReplyContent || '').trim()
  1128. const consultSessionId = this.consultSessionIdForApi()
  1129. if (!consultSessionId || !persistBody || !reply) {
  1130. return Promise.resolve(true)
  1131. }
  1132. const llmSessionId = this.resolveLlmSessionId()
  1133. return sendAiConsultMessage(consultSessionId, {
  1134. ...persistBody,
  1135. aiReplyContent: reply,
  1136. realSessionId: llmSessionId || null,
  1137. costTime: costTime != null ? costTime : undefined,
  1138. aiCategory: aiCategory != null && aiCategory !== '' ? aiCategory : undefined
  1139. })
  1140. .then((res) => {
  1141. this.mergePersistResult(res.data, reply)
  1142. if (
  1143. this.pendingPersist &&
  1144. this.pendingPersist.consultSessionId === consultSessionId &&
  1145. this.pendingPersist.aiReplyContent === reply
  1146. ) {
  1147. this.pendingPersist = null
  1148. }
  1149. return true
  1150. })
  1151. .catch((err) => {
  1152. this.queuePendingPersist(consultSessionId, persistBody, reply, llmSessionId, costTime, aiCategory)
  1153. const msg = (err && err.message) || this.$t('aiOnlineConsult.saveSessionFailed')
  1154. uni.showToast({ title: msg, icon: 'none' })
  1155. return false
  1156. })
  1157. },
  1158. async handleSend() {
  1159. if (this.sendDisabled) return
  1160. const text = (this.draft || '').trim()
  1161. if (!text && !this.pendingAttachments.length) {
  1162. uni.showToast({ title: this.$t('aiOnlineConsult.inputOrAttach'), icon: 'none' })
  1163. return
  1164. }
  1165. if (this.pendingAttachments.some((a) => !a.url)) {
  1166. uni.showLoading({ title: this.$t('aiOnlineConsult.uploading'), mask: true })
  1167. try {
  1168. await this.uploadAllPending()
  1169. } catch (e) {
  1170. uni.showToast({ title: this.$t('aiOnlineConsult.uploadFail'), icon: 'none' })
  1171. return
  1172. } finally {
  1173. uni.hideLoading()
  1174. }
  1175. }
  1176. const atts = this.pendingAttachments
  1177. const img = atts.find((a) => a.kind === 'image' && a.url)
  1178. const video = atts.find((a) => a.kind === 'video' && a.url)
  1179. if (img) {
  1180. await this.sendViaLlm({ msgType: MSG_TYPE_IMAGE, content: img.url })
  1181. return
  1182. }
  1183. if (video) {
  1184. await this.sendViaLlm({
  1185. msgType: MSG_TYPE_VIDEO,
  1186. content: video.url,
  1187. mediaDuration: video.mediaDuration
  1188. })
  1189. return
  1190. }
  1191. await this.sendViaLlm({ msgType: MSG_TYPE_TEXT, content: text })
  1192. },
  1193. async sendViaLlm(payload) {
  1194. if (!this.consultSessionIdForApi() || this.sending) return
  1195. let userContent
  1196. if (payload && payload.msgType && payload.msgType !== MSG_TYPE_TEXT) {
  1197. userContent = resolvePayloadUserContent(payload, this.draft)
  1198. } else {
  1199. userContent = buildUserContentForLlm((payload && payload.content) || this.draft, this.pendingAttachments)
  1200. }
  1201. if (!userContent) {
  1202. uni.showToast({ title: this.$t('aiOnlineConsult.inputOrAttach'), icon: 'none' })
  1203. return
  1204. }
  1205. const msgType = (payload && payload.msgType) || MSG_TYPE_TEXT
  1206. const draftText = (this.draft || '').trim()
  1207. const pendingSnapshot = this.pendingAttachments.slice()
  1208. const displayContent =
  1209. msgType !== MSG_TYPE_TEXT
  1210. ? payload.content
  1211. : typeof userContent === 'string'
  1212. ? userContent
  1213. : pendingSnapshot.find((a) => a.kind === 'image' && a.url)
  1214. ? pendingSnapshot.find((a) => a.kind === 'image').url
  1215. : userContent
  1216. const persistBody = buildPersistSendBody(msgType, payload, draftText, pendingSnapshot)
  1217. this.appendLocalMessage(SENDER_ROLE_USER, msgType, displayContent)
  1218. this.clearComposer()
  1219. this.stickToBottom = true
  1220. this.pinScrollToBottom(true)
  1221. const llmSessionId = this.resolveLlmSessionId()
  1222. const body = {
  1223. model: this.model,
  1224. messages: buildMessagesForLlm(this.messages, llmSessionId),
  1225. user: this.llmUserId
  1226. }
  1227. if (llmSessionId) {
  1228. body.session_id = llmSessionId
  1229. }
  1230. let aiReplyContent = ''
  1231. let aiMsg = null
  1232. let costTime = null
  1233. let aiCategory = null
  1234. this.abortStreamRequest()
  1235. this.freezeFeedScrollTopForStream()
  1236. this.sending = true
  1237. this.stickToBottom = true
  1238. try {
  1239. const streamResult = await sendChatMessage(body, {
  1240. onReadyTask: (task) => {
  1241. this.streamRequestTask = task
  1242. },
  1243. onSessionId: (llmSid) => {
  1244. this.rememberLlmSessionId(llmSid)
  1245. },
  1246. onModel: (model) => {
  1247. aiCategory = model
  1248. },
  1249. onDelta: (fullText) => {
  1250. aiReplyContent = fullText
  1251. if (!aiMsg) {
  1252. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, fullText, {
  1253. streaming: true
  1254. })
  1255. this.requestPinScroll()
  1256. } else {
  1257. this.pushStreamText(aiMsg, fullText)
  1258. }
  1259. }
  1260. })
  1261. const llmSid = streamResult.sessionId || null
  1262. if (llmSid) {
  1263. this.rememberLlmSessionId(llmSid)
  1264. }
  1265. costTime = streamResult.durationMs != null ? streamResult.durationMs : null
  1266. if (streamResult.model) {
  1267. aiCategory = streamResult.model
  1268. }
  1269. this.clearStreamRevealTimer()
  1270. aiReplyContent = (streamResult.content || aiReplyContent || '').trim() || this.$t('aiOnlineConsult.noContent')
  1271. if (!aiMsg) {
  1272. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, aiReplyContent)
  1273. } else {
  1274. this.$set(aiMsg, 'content', aiReplyContent)
  1275. }
  1276. if (aiMsg) {
  1277. this.$set(aiMsg, 'streaming', false)
  1278. }
  1279. } catch (err) {
  1280. let msg = this.$t('aiOnlineConsult.requestFailed')
  1281. if (err && err.message) {
  1282. msg = err.message
  1283. }
  1284. uni.showToast({ title: msg, icon: 'none', duration: 3000 })
  1285. aiReplyContent = this.$t('aiOnlineConsult.callFailed', { msg })
  1286. if (!aiMsg) {
  1287. aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, aiReplyContent)
  1288. } else {
  1289. this.$set(aiMsg, 'content', aiReplyContent)
  1290. this.$set(aiMsg, 'streaming', false)
  1291. }
  1292. } finally {
  1293. this.clearStreamRevealTimer()
  1294. this.streamRequestTask = null
  1295. this.sending = false
  1296. this.releaseFeedScrollTopFrozen()
  1297. if (aiMsg && aiMsg.streaming) {
  1298. this.$set(aiMsg, 'streaming', false)
  1299. }
  1300. this.$nextTick(() => this.scheduleScrollToBottom())
  1301. }
  1302. await this.persistConsultExchange(
  1303. persistBody,
  1304. (aiReplyContent || '').trim(),
  1305. costTime,
  1306. aiCategory
  1307. )
  1308. },
  1309. validateMediaFile(filePath, fileName, kind) {
  1310. const rule = MEDIA_RULES[kind]
  1311. const ext = extOf(fileName || filePath)
  1312. if (!rule.exts.includes(ext)) {
  1313. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errFmt), icon: 'none' })
  1314. return false
  1315. }
  1316. if ((fileName || filePath).includes(',')) {
  1317. uni.showToast({ title: this.$t('aiOnlineConsult.errComma'), icon: 'none' })
  1318. return false
  1319. }
  1320. return true
  1321. },
  1322. checkFileSize(filePath, kind) {
  1323. const rule = MEDIA_RULES[kind]
  1324. return new Promise((resolve) => {
  1325. uni.getFileInfo({
  1326. filePath,
  1327. success: (res) => {
  1328. if (res.size / 1024 / 1024 >= rule.maxMb) {
  1329. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errMb), icon: 'none' })
  1330. resolve(false)
  1331. return
  1332. }
  1333. resolve(true)
  1334. },
  1335. fail: () => resolve(true)
  1336. })
  1337. })
  1338. },
  1339. /** 语音:选完即上传并发送(与 PC 端上传语音一致,不走待发送预览) */
  1340. async sendVoiceImmediately(filePath, fileName, nativeFile, mediaDuration) {
  1341. if (!this.consultSessionIdForApi() || this.sending) return
  1342. uni.showLoading({ title: this.$t('aiOnlineConsult.uploading'), mask: true })
  1343. try {
  1344. const url = await uploadFile(filePath, nativeFile)
  1345. await this.sendViaLlm({
  1346. msgType: MSG_TYPE_VOICE,
  1347. content: url,
  1348. mediaDuration: mediaDuration != null ? mediaDuration : undefined
  1349. })
  1350. } catch (e) {
  1351. uni.showToast({ title: this.$t('aiOnlineConsult.uploadFail'), icon: 'none' })
  1352. } finally {
  1353. uni.hideLoading()
  1354. }
  1355. },
  1356. pickImage() {
  1357. uni.chooseImage({
  1358. count: 1,
  1359. sizeType: ['compressed'],
  1360. sourceType: ['album', 'camera'],
  1361. success: async (res) => {
  1362. const path = res.tempFilePaths && res.tempFilePaths[0]
  1363. if (!path) return
  1364. const name = (res.tempFiles && res.tempFiles[0] && res.tempFiles[0].name) || path
  1365. if (!this.validateMediaFile(path, name, 'image')) return
  1366. if (!(await this.checkFileSize(path, 'image'))) return
  1367. this.setPendingAttachment({
  1368. kind: 'image',
  1369. msgType: MSG_TYPE_IMAGE,
  1370. name,
  1371. localPath: path
  1372. })
  1373. },
  1374. fail: () => {
  1375. uni.showToast({ title: this.$t('aiAssistantPage.imagePickFail'), icon: 'none' })
  1376. }
  1377. })
  1378. },
  1379. pickVideo() {
  1380. uni.chooseVideo({
  1381. sourceType: ['album', 'camera'],
  1382. maxDuration: 60,
  1383. success: async (res) => {
  1384. const path = res.tempFilePath
  1385. if (!path) return
  1386. const name = path
  1387. if (!this.validateMediaFile(path, name, 'video')) return
  1388. if (!(await this.checkFileSize(path, 'video'))) return
  1389. this.setPendingAttachment({
  1390. kind: 'video',
  1391. msgType: MSG_TYPE_VIDEO,
  1392. name,
  1393. localPath: path,
  1394. poster: res.thumbTempFilePath || '',
  1395. mediaDuration: res.duration ? Math.round(res.duration) : undefined
  1396. })
  1397. },
  1398. fail: () => {
  1399. uni.showToast({ title: this.$t('aiAssistantPage.videoPickFail'), icon: 'none' })
  1400. }
  1401. })
  1402. },
  1403. pickVoice() {
  1404. // #ifdef H5
  1405. this.pickVoiceH5()
  1406. // #endif
  1407. // #ifndef H5
  1408. if (typeof uni.chooseFile === 'function') {
  1409. uni.chooseFile({
  1410. count: 1,
  1411. extension: ['.mp3', '.m4a', '.wav'],
  1412. success: async (res) => {
  1413. const f = res.tempFiles && res.tempFiles[0]
  1414. if (!f || !f.path) return
  1415. if (!this.validateMediaFile(f.path, f.name || f.path, 'voice')) return
  1416. if (!(await this.checkFileSize(f.path, 'voice'))) return
  1417. this.sendVoiceImmediately(f.path, f.name || f.path, null, undefined)
  1418. },
  1419. fail: () => {
  1420. uni.showToast({ title: this.$t('aiAssistantPage.recordUnsupported'), icon: 'none' })
  1421. }
  1422. })
  1423. } else {
  1424. uni.showToast({ title: this.$t('aiAssistantPage.recordUnsupported'), icon: 'none' })
  1425. }
  1426. // #endif
  1427. },
  1428. pickVoiceH5() {
  1429. const input = document.createElement('input')
  1430. input.type = 'file'
  1431. input.accept = '.mp3,.m4a,.wav,audio/*'
  1432. input.style.display = 'none'
  1433. input.onchange = async (e) => {
  1434. const file = e.target && e.target.files && e.target.files[0]
  1435. if (!file) return
  1436. if (!this.validateMediaFile(file.name, file.name, 'voice')) return
  1437. if (file.size / 1024 / 1024 >= MEDIA_RULES.voice.maxMb) {
  1438. uni.showToast({ title: this.$t('aiOnlineConsult.errVoiceMb'), icon: 'none' })
  1439. return
  1440. }
  1441. this.sendVoiceImmediately('', file.name, file, undefined)
  1442. }
  1443. document.body.appendChild(input)
  1444. input.click()
  1445. setTimeout(() => {
  1446. if (input.parentNode) {
  1447. input.parentNode.removeChild(input)
  1448. }
  1449. }, 1000)
  1450. },
  1451. previewImage(path) {
  1452. const url = mediaUrl(path)
  1453. if (url) {
  1454. uni.previewImage({ urls: [url] })
  1455. }
  1456. },
  1457. stopVoice() {
  1458. if (this.innerAudio) {
  1459. this.innerAudio.stop()
  1460. this.innerAudio.destroy()
  1461. this.innerAudio = null
  1462. }
  1463. this.playingVoiceId = null
  1464. },
  1465. toggleVoice(m) {
  1466. if (!m || !m.content) return
  1467. if (this.playingVoiceId === m.id) {
  1468. this.stopVoice()
  1469. return
  1470. }
  1471. this.stopVoice()
  1472. const audio = uni.createInnerAudioContext()
  1473. audio.src = mediaUrl(m.content)
  1474. audio.onEnded(() => this.stopVoice())
  1475. audio.onError(() => {
  1476. uni.showToast({ title: this.$t('aiOnlineConsult.voicePlayFail'), icon: 'none' })
  1477. this.stopVoice()
  1478. })
  1479. this.innerAudio = audio
  1480. this.playingVoiceId = m.id
  1481. audio.play()
  1482. }
  1483. }
  1484. }
  1485. </script>
  1486. <style lang="scss" scoped>
  1487. @import '@/styles/morandi.scss';
  1488. @import '@/styles/tab-page.scss';
  1489. .assistant-page {
  1490. display: flex;
  1491. flex-direction: column;
  1492. min-width: 0;
  1493. min-height: 100vh;
  1494. height: 100vh;
  1495. overflow: hidden;
  1496. box-sizing: border-box;
  1497. background: $morandi-bg-page;
  1498. }
  1499. .assistant-disclaimer {
  1500. flex-shrink: 0;
  1501. display: flex;
  1502. flex-direction: column;
  1503. gap: 8rpx;
  1504. padding: 16rpx 20rpx;
  1505. background: #fffbeb;
  1506. border-bottom: 1rpx solid #fde68a;
  1507. }
  1508. .assistant-disclaimer__tag {
  1509. font-size: 24rpx;
  1510. font-weight: 600;
  1511. color: #b45309;
  1512. }
  1513. .assistant-disclaimer__txt {
  1514. font-size: 22rpx;
  1515. color: #92400e;
  1516. line-height: 1.45;
  1517. }
  1518. .assistant-feed {
  1519. flex: 1 1 0;
  1520. min-height: 0;
  1521. min-width: 0;
  1522. width: 100%;
  1523. box-sizing: border-box;
  1524. background: $morandi-feed;
  1525. }
  1526. /* #ifdef H5 */
  1527. .assistant-feed ::v-deep .uni-scroll-view,
  1528. .assistant-feed ::v-deep .uni-scroll-view-content {
  1529. height: 100% !important;
  1530. }
  1531. /* #endif */
  1532. .assistant-feed__inner {
  1533. display: flex;
  1534. flex-direction: column;
  1535. gap: 16rpx;
  1536. min-width: 0;
  1537. padding: 16rpx 16rpx 24rpx;
  1538. padding-bottom: calc(340rpx + env(safe-area-inset-bottom));
  1539. box-sizing: border-box;
  1540. }
  1541. .assistant-feed__inner--pending {
  1542. padding-bottom: calc(460rpx + env(safe-area-inset-bottom));
  1543. }
  1544. .assistant-feed__tail {
  1545. height: 1px;
  1546. width: 100%;
  1547. }
  1548. .feed-hint,
  1549. .feed-empty {
  1550. padding: 16rpx 0;
  1551. display: flex;
  1552. justify-content: center;
  1553. }
  1554. .feed-hint__txt,
  1555. .feed-empty__txt {
  1556. font-size: 24rpx;
  1557. color: $morandi-text-muted;
  1558. }
  1559. .chat-date {
  1560. display: flex;
  1561. justify-content: center;
  1562. padding: 8rpx 0;
  1563. }
  1564. .chat-date__txt {
  1565. font-size: 22rpx;
  1566. color: $morandi-text-soft;
  1567. padding: 6rpx 20rpx;
  1568. border-radius: 999rpx;
  1569. background: rgba(255, 255, 255, 0.6);
  1570. }
  1571. .bubble-row {
  1572. display: flex;
  1573. flex-direction: row;
  1574. align-items: flex-end;
  1575. gap: 12rpx;
  1576. width: 100%;
  1577. min-width: 0;
  1578. }
  1579. .bubble-row--bot {
  1580. justify-content: flex-start;
  1581. }
  1582. .bubble-row--user {
  1583. justify-content: flex-end;
  1584. }
  1585. .bubble-avatar {
  1586. flex-shrink: 0;
  1587. width: 56rpx;
  1588. height: 56rpx;
  1589. border-radius: 50%;
  1590. display: flex;
  1591. align-items: center;
  1592. justify-content: center;
  1593. }
  1594. .bubble-avatar--ai {
  1595. background: rgba(34, 197, 94, 0.15);
  1596. }
  1597. .bubble-avatar--user {
  1598. background: rgba(34, 197, 94, 0.22);
  1599. }
  1600. .bubble {
  1601. display: flex;
  1602. flex-direction: column;
  1603. min-width: 0;
  1604. max-width: 72%;
  1605. padding: 16rpx 18rpx;
  1606. border-radius: 16rpx;
  1607. gap: 8rpx;
  1608. box-sizing: border-box;
  1609. }
  1610. .bubble--bot {
  1611. background: $morandi-bg-card-inner;
  1612. border: 1rpx solid $morandi-border-soft;
  1613. }
  1614. .bubble--user {
  1615. background: #bbf7d0;
  1616. border: 1rpx solid #86efac;
  1617. }
  1618. .bubble--image {
  1619. max-width: 85%;
  1620. }
  1621. .bubble--thinking {
  1622. min-width: 120rpx;
  1623. }
  1624. .bubble__who {
  1625. font-size: 22rpx;
  1626. font-weight: 600;
  1627. color: $morandi-text-muted;
  1628. }
  1629. .bubble--user .bubble__who {
  1630. color: #166534;
  1631. }
  1632. .bubble__txt {
  1633. font-size: 28rpx;
  1634. line-height: 1.5;
  1635. word-break: break-word;
  1636. overflow-wrap: break-word;
  1637. max-width: 100%;
  1638. min-width: 0;
  1639. color: $morandi-text;
  1640. }
  1641. .bubble__rich {
  1642. display: block;
  1643. width: 100%;
  1644. min-width: 0;
  1645. max-width: 100%;
  1646. overflow: visible;
  1647. box-sizing: border-box;
  1648. font-size: 28rpx;
  1649. line-height: 1.55;
  1650. -webkit-text-size-adjust: 100%;
  1651. text-size-adjust: 100%;
  1652. }
  1653. /* iOS rich-text 会放大 h1/h2(2em);兜底压回正文大小 */
  1654. .bubble__rich ::v-deep ._h1,
  1655. .bubble__rich ::v-deep ._h2,
  1656. .bubble__rich ::v-deep ._h3,
  1657. .bubble__rich ::v-deep ._h4,
  1658. .bubble__rich ::v-deep ._h5,
  1659. .bubble__rich ::v-deep ._h6 {
  1660. font-size: 14px !important;
  1661. font-weight: 600;
  1662. line-height: 1.35;
  1663. margin: 8px 0 6px;
  1664. }
  1665. .bubble__rich ::v-deep ._root,
  1666. .bubble__rich ::v-deep ._p,
  1667. .bubble__rich ::v-deep ._div {
  1668. font-size: 14px;
  1669. line-height: 1.55;
  1670. -webkit-text-size-adjust: 100%;
  1671. text-size-adjust: 100%;
  1672. }
  1673. .bubble__rich ::v-deep ._big {
  1674. font-size: 14px !important;
  1675. }
  1676. .bubble--user .bubble__rich {
  1677. color: #166534;
  1678. }
  1679. .bubble--bot .bubble__rich {
  1680. color: $morandi-text;
  1681. }
  1682. .bubble__img {
  1683. display: block;
  1684. width: 200rpx;
  1685. max-width: 100%;
  1686. height: auto;
  1687. border-radius: 8rpx;
  1688. overflow: hidden;
  1689. }
  1690. .bubble__video {
  1691. width: 100%;
  1692. max-width: 360rpx;
  1693. min-height: 200rpx;
  1694. border-radius: 12rpx;
  1695. }
  1696. .bubble__time {
  1697. font-size: 20rpx;
  1698. color: $morandi-text-soft;
  1699. align-self: flex-end;
  1700. }
  1701. .thinking-status {
  1702. display: flex;
  1703. flex-direction: row;
  1704. align-items: center;
  1705. gap: 16rpx;
  1706. margin-top: 8rpx;
  1707. }
  1708. .thinking-txt {
  1709. font-size: 26rpx;
  1710. color: $morandi-text-muted;
  1711. }
  1712. .thinking-dots {
  1713. display: flex;
  1714. flex-direction: row;
  1715. align-items: center;
  1716. gap: 8rpx;
  1717. }
  1718. .thinking-dots__dot {
  1719. width: 12rpx;
  1720. height: 12rpx;
  1721. border-radius: 50%;
  1722. background: #22c55e;
  1723. animation: ai-thinking-dot 1.05s ease-in-out infinite both;
  1724. &:nth-child(2) {
  1725. animation-delay: 0.18s;
  1726. }
  1727. &:nth-child(3) {
  1728. animation-delay: 0.36s;
  1729. }
  1730. }
  1731. @keyframes ai-thinking-dot {
  1732. 0%,
  1733. 80%,
  1734. 100% {
  1735. opacity: 0.35;
  1736. transform: scale(0.85);
  1737. }
  1738. 40% {
  1739. opacity: 1;
  1740. transform: scale(1);
  1741. }
  1742. }
  1743. .bubble__txt--stream {
  1744. white-space: pre-wrap;
  1745. word-break: break-word;
  1746. }
  1747. .voice-msg {
  1748. display: flex;
  1749. flex-direction: row;
  1750. align-items: center;
  1751. gap: 12rpx;
  1752. padding: 12rpx 16rpx;
  1753. border-radius: 12rpx;
  1754. background: $morandi-bg-muted;
  1755. }
  1756. .voice-msg--on {
  1757. background: rgba(34, 197, 94, 0.2);
  1758. }
  1759. .voice-msg__dur {
  1760. font-size: 28rpx;
  1761. font-weight: 600;
  1762. color: $morandi-text;
  1763. }
  1764. .voice-msg__label {
  1765. font-size: 24rpx;
  1766. color: $morandi-text-muted;
  1767. }
  1768. .assistant-composer {
  1769. position: fixed;
  1770. left: 0;
  1771. right: 0;
  1772. bottom: 0;
  1773. z-index: 200;
  1774. display: flex;
  1775. flex-direction: column;
  1776. gap: 12rpx;
  1777. min-width: 0;
  1778. padding: 12rpx 16rpx;
  1779. padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
  1780. background: $morandi-composer;
  1781. border-top: 1rpx solid $morandi-border-soft;
  1782. box-shadow: 0 -8rpx 24rpx rgba(74, 69, 66, 0.06);
  1783. }
  1784. .composer-tools {
  1785. display: flex;
  1786. flex-direction: row;
  1787. flex-wrap: nowrap;
  1788. align-items: center;
  1789. width: 100%;
  1790. min-width: 0;
  1791. gap: 10rpx;
  1792. box-sizing: border-box;
  1793. }
  1794. .composer-model {
  1795. flex: 1;
  1796. min-width: 0;
  1797. height: 64rpx;
  1798. display: flex;
  1799. flex-direction: row;
  1800. align-items: center;
  1801. justify-content: center;
  1802. gap: 6rpx;
  1803. padding: 0 16rpx;
  1804. border-radius: 999rpx;
  1805. background: $morandi-bg-card-inner;
  1806. border: 1rpx solid $morandi-border-soft;
  1807. box-sizing: border-box;
  1808. }
  1809. .composer-model__txt {
  1810. flex: 1;
  1811. min-width: 0;
  1812. font-size: 24rpx;
  1813. line-height: 1.2;
  1814. color: #16a34a;
  1815. font-weight: 600;
  1816. overflow: hidden;
  1817. text-overflow: ellipsis;
  1818. white-space: nowrap;
  1819. }
  1820. .composer-icon-btn {
  1821. flex-shrink: 0;
  1822. width: 64rpx;
  1823. height: 64rpx;
  1824. display: flex;
  1825. align-items: center;
  1826. justify-content: center;
  1827. border-radius: 12rpx;
  1828. background: $morandi-bg-card-inner;
  1829. border: 1rpx solid $morandi-border-soft;
  1830. box-sizing: border-box;
  1831. }
  1832. .assistant-composer--has-pending {
  1833. gap: 10rpx;
  1834. }
  1835. .composer-pending {
  1836. width: 100%;
  1837. white-space: nowrap;
  1838. }
  1839. .composer-pending__track {
  1840. display: inline-flex;
  1841. flex-direction: row;
  1842. align-items: center;
  1843. gap: 12rpx;
  1844. padding: 4rpx 0;
  1845. }
  1846. .composer-pending__item {
  1847. position: relative;
  1848. flex-shrink: 0;
  1849. width: 200rpx;
  1850. border-radius: 12rpx;
  1851. overflow: hidden;
  1852. background: $morandi-bg-muted;
  1853. border: 1rpx solid $morandi-border-soft;
  1854. }
  1855. .composer-pending__thumb {
  1856. display: block;
  1857. }
  1858. .composer-pending__thumb--image {
  1859. width: 200rpx;
  1860. max-width: 100%;
  1861. height: auto;
  1862. }
  1863. .composer-pending__thumb--video {
  1864. position: relative;
  1865. width: 200rpx;
  1866. height: 200rpx;
  1867. }
  1868. .composer-pending__thumb--video > .composer-pending__thumb {
  1869. width: 100%;
  1870. height: 100%;
  1871. }
  1872. .composer-pending__video-mask {
  1873. position: absolute;
  1874. left: 0;
  1875. top: 0;
  1876. right: 0;
  1877. bottom: 0;
  1878. display: flex;
  1879. align-items: center;
  1880. justify-content: center;
  1881. background: rgba(0, 0, 0, 0.35);
  1882. }
  1883. .composer-pending__thumb--voice {
  1884. display: flex;
  1885. flex-direction: column;
  1886. align-items: center;
  1887. justify-content: center;
  1888. gap: 8rpx;
  1889. width: 200rpx;
  1890. height: 200rpx;
  1891. padding: 8rpx;
  1892. box-sizing: border-box;
  1893. }
  1894. .composer-pending__voice-name {
  1895. font-size: 18rpx;
  1896. color: $morandi-text-muted;
  1897. max-width: 100%;
  1898. overflow: hidden;
  1899. text-overflow: ellipsis;
  1900. white-space: nowrap;
  1901. }
  1902. .composer-pending__close {
  1903. position: absolute;
  1904. top: 0;
  1905. right: 0;
  1906. width: 40rpx;
  1907. height: 40rpx;
  1908. display: flex;
  1909. align-items: center;
  1910. justify-content: center;
  1911. background: rgba(0, 0, 0, 0.5);
  1912. border-bottom-left-radius: 12rpx;
  1913. z-index: 2;
  1914. }
  1915. .composer-input-row {
  1916. display: flex;
  1917. flex-direction: row;
  1918. flex-wrap: nowrap;
  1919. align-items: flex-end;
  1920. width: 100%;
  1921. gap: 12rpx;
  1922. min-width: 0;
  1923. box-sizing: border-box;
  1924. }
  1925. .composer-input-shell {
  1926. flex: 1;
  1927. min-width: 0;
  1928. min-height: 72rpx;
  1929. max-height: 200rpx;
  1930. padding: 10rpx 16rpx;
  1931. border-radius: 12rpx;
  1932. background: $morandi-bg-card-inner;
  1933. border: 1rpx solid $morandi-border-soft;
  1934. box-sizing: border-box;
  1935. }
  1936. .composer-textarea {
  1937. width: 100%;
  1938. min-height: 48rpx;
  1939. max-height: 180rpx;
  1940. font-size: 28rpx;
  1941. line-height: 1.45;
  1942. color: $morandi-text;
  1943. }
  1944. .composer-textarea-ph {
  1945. color: $morandi-text-muted;
  1946. font-size: 26rpx;
  1947. }
  1948. .composer-send {
  1949. flex-shrink: 0;
  1950. padding: 18rpx 24rpx;
  1951. border-radius: 12rpx;
  1952. background: linear-gradient(90deg, #16a34a, #22c55e);
  1953. box-sizing: border-box;
  1954. }
  1955. .composer-send--disabled {
  1956. opacity: 0.5;
  1957. }
  1958. .composer-send__txt {
  1959. font-size: 26rpx;
  1960. font-weight: 600;
  1961. color: #fff;
  1962. white-space: nowrap;
  1963. }
  1964. </style>