BusinessRecord.vue 77 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959
  1. <template>
  2. <div
  3. ref="recordRootRef"
  4. :style="recordRootStyle"
  5. :class="{ 'text-left': record.isAI, 'text-right': record.isUser, 'text-center': record.isTips }"
  6. >
  7. <transition name="slow-fade">
  8. <QuestionSource
  9. v-if="record.questionSource && record.questionSource.length"
  10. :questionFrom="record.questionSource"
  11. />
  12. </transition>
  13. <div
  14. v-if="businessScopeOutsideRow"
  15. ref="businessScopeOutsideRef"
  16. class="business-scope-content-outside"
  17. >
  18. <ScopeContent
  19. :content="businessScopeOutsideRow.content"
  20. :forcePending="isScopeForcePending"
  21. :exiting="businessScopeOutsideRow.exiting"
  22. scope-source="outside"
  23. @auto-exit="handleScopeAutoExit(businessScopeOutsideRow)"
  24. />
  25. </div>
  26. <Transition name="business-record-body-fade" appear>
  27. <div
  28. v-if="shouldRenderBusinessRecordBody"
  29. class="business-record-body"
  30. :style="businessRecordBodyStyle"
  31. :class="{
  32. 'is-scope-exiting': isScopeExitCompensating,
  33. 'is-scope-exit-transition-disabled': isScopeExitBodyTransitionDisabled,
  34. }"
  35. >
  36. <div
  37. v-if="showUserAttachments"
  38. class="ba-message-attachments"
  39. :class="{ desktop: !isMobileDevice }"
  40. >
  41. <div
  42. v-for="attachment in userAttachments"
  43. :key="attachment.id || attachment.url"
  44. class="ba-message-attachment"
  45. :class="{ 'is-image': attachment.isImage, 'is-file': !attachment.isImage }"
  46. >
  47. <ImageContent
  48. v-if="attachment.isImage && attachment.url"
  49. :image-urls="previewableUserAttachmentUrls"
  50. :preview-index="getUserAttachmentPreviewIndex(attachment)"
  51. :compact="true"
  52. :activeReveal="false"
  53. :showDownloadButton="false"
  54. />
  55. <template v-else>
  56. <div class="ba-message-attachment-icon">{{
  57. getAttachmentExtension(attachment.name)
  58. }}</div>
  59. <div class="ba-message-attachment-info">
  60. <div class="ba-message-attachment-name">{{ attachment.name }}</div>
  61. <div class="ba-message-attachment-size">{{
  62. formatAttachmentSize(attachment.size)
  63. }}</div>
  64. </div>
  65. </template>
  66. </div>
  67. </div>
  68. <div
  69. v-if="shouldRenderRecordLine"
  70. :class="{
  71. 'ba-record-me': record.isUser,
  72. 'ba-record-ai': record.isAI,
  73. 'is-loading': isBusinessLoading,
  74. 'is-interrupted': showInterruptedState,
  75. 'chat-record-tips': record.isTips,
  76. 'with-feedback-margin': true,
  77. 'with-feedback-margin-rated':
  78. localFeedback === FeedbackType.Like || localFeedback === FeedbackType.Dislike,
  79. 'full-mode': type == 'full',
  80. desktop: !isMobileDevice,
  81. }"
  82. class="chat-record-line record--business business-record"
  83. ref="elRef"
  84. >
  85. <div class="tips-title" v-if="record.isTips">
  86. <div class="tips-deco" />
  87. <div class="tips-deco mirror" />
  88. 温馨提示
  89. </div>
  90. <ReasoningWidget
  91. v-if="record.isAI && record.reasoningContent !== undefined"
  92. :reasoningContent="record.reasoningContent"
  93. :status="reasoningStatus"
  94. />
  95. <div class="content" :class="{ isTip: record.isTips }" ref="contentRef">
  96. <span v-if="showInterruptedState" class="request-interrupted-text">请求已取消</span>
  97. <DotsAnim v-else-if="showLoadingDots" />
  98. <template v-else>
  99. <template
  100. v-for="(row, index) in visibleInlineDisplayRecordRows"
  101. :key="row.id || index"
  102. >
  103. <TextContent
  104. :content="row.content"
  105. v-if="shouldRenderTextContentRow(row)"
  106. :instant="shouldRenderTextInstantly"
  107. :blockMarkdown="true"
  108. :activeTypewriter="shouldDriveTypewriter"
  109. :paused="false"
  110. :scopeExitCompensating="isScopeExitCompensating"
  111. :enableRevealFade="false"
  112. :initialMinWidth="scopeExitWidth"
  113. @typingFinished="handleTypingFinished(row.id)"
  114. @typingStateChange="handleTypingStateChange(row.id, $event)"
  115. />
  116. <FormContent :content="row.content" v-if="row.type === RecordRowTypes.Form" />
  117. <LinkURLContent :content="row.content" v-if="row.type === RecordRowTypes.LinkURL" />
  118. <ImageContent
  119. :content="row.content"
  120. v-if="row.type === RecordRowTypes.ImageContent"
  121. :activeReveal="shouldDriveTypewriter && !isImageRowFullyRevealed(row)"
  122. @revealFinished="handleImageRevealFinished(row.id)"
  123. @revealStateChange="handleImageRevealStateChange(row.id, $event)"
  124. />
  125. <OfficeURLContent
  126. :content="row.content"
  127. v-if="row.type === RecordRowTypes.OfficeURL"
  128. />
  129. <div
  130. v-if="row.type === RecordRowTypes.Scope"
  131. class="scope-content-shell"
  132. :ref="(el) => setScopeRowRef(row, el)"
  133. >
  134. <ScopeContent
  135. :content="row.content"
  136. :forcePending="isScopeForcePending"
  137. :exiting="row.exiting"
  138. scope-source="inline"
  139. @auto-exit="handleScopeAutoExit(row)"
  140. />
  141. </div>
  142. <PolicyMatch
  143. v-if="row.type === RecordRowTypes.PolicyTable"
  144. :policyContent="row.content"
  145. :recordMode="true"
  146. :sessionId="policyMatchSessionId"
  147. />
  148. <PolicyListUpdatedMatch
  149. v-if="row.type === RecordRowTypes.PolicyListUpdated"
  150. :content="row.content"
  151. :recordMode="true"
  152. :sessionId="policyMatchSessionId"
  153. />
  154. <QuestionCard
  155. v-if="row.type === RecordRowTypes.QuestionCards"
  156. :content="row.content"
  157. :recordMode="true"
  158. :sessionId="policyMatchSessionId"
  159. />
  160. <div
  161. v-if="row.type === RecordRowTypes.RefLinks && getRefLinkItems(row.content).length"
  162. class="ba-ref-links-summary"
  163. :class="{ desktop: !isMobileDevice }"
  164. >
  165. <div class="ba-ref-links-summary-title">参考资料</div>
  166. <div class="ba-ref-links-row">
  167. <div class="ba-ref-links-chip-list">
  168. <a
  169. v-for="(item, refIndex) in getRefLinkItems(row.content)"
  170. :key="`${item.title}-${refIndex}`"
  171. class="ba-ref-links-chip"
  172. :href="item.linkUrl"
  173. target="_blank"
  174. rel="noopener noreferrer"
  175. @click="handleRefLinkChipClick($event, item)"
  176. >
  177. <span class="ba-ref-links-chip-index">[{{ refIndex + 1 }}]</span>
  178. <span class="ba-ref-links-chip-title">{{
  179. item.title || item.type || "参考资料"
  180. }}</span>
  181. </a>
  182. </div>
  183. <button
  184. type="button"
  185. class="ba-ref-links-more"
  186. @click.stop="openRefLinksPanel(row.content)"
  187. >
  188. 更多
  189. </button>
  190. </div>
  191. </div>
  192. </template>
  193. <!-- media display holder -->
  194. <div
  195. class="media-row"
  196. :class="{ 'mt-1': visibleInlineDisplayRecordRows.length === 0 }"
  197. v-if="headMedia.type"
  198. >
  199. <div
  200. class="media-placeholder head-media"
  201. @click="
  202. (e) => {
  203. openPreview(headMedia, 0, e);
  204. }
  205. "
  206. >
  207. <img
  208. :src="headMedia.type === 'image' ? headMedia.url : headMedia.preview"
  209. @error="handleError"
  210. />
  211. <div v-if="headMedia.type === 'video'" class="play-button"></div>
  212. </div>
  213. </div>
  214. <div
  215. class="rest-media-wrapper"
  216. :class="{ 'pa-0': restMedia.length == 1 }"
  217. v-if="restMedia.length"
  218. >
  219. <div
  220. class="media-placeholder"
  221. :class="{
  222. several: restMedia.length > 1 && restMedia.length <= 4,
  223. multi: restMedia.length > 4,
  224. }"
  225. v-for="(each, index) in restMedia"
  226. :key="index"
  227. @click="
  228. (e) => {
  229. openPreview(each, index + 1, e);
  230. }
  231. "
  232. >
  233. <img
  234. :src="each.type === 'image' ? each.url : each.preview"
  235. @error="handleError"
  236. />
  237. <div v-if="each.type === 'video'" class="play-button"></div>
  238. </div>
  239. </div>
  240. <!-- media display holder end -->
  241. </template>
  242. </div>
  243. <div class="tips-wrap" v-if="record.isTips">
  244. <div class="tip" v-for="(item, index) in tips.tipItems" :key="index">
  245. <div class="title">
  246. <span class="order">{{ index + 1 }}</span> {{ item.title }}
  247. </div>
  248. <div class="content">{{ item.content }}</div>
  249. </div>
  250. <div class="tip-footer">{{ tips.tipFooter }}</div>
  251. </div>
  252. <BarAnim v-if="state === MessageState.Update" />
  253. <transition name="slow-fade">
  254. <References
  255. v-if="showReferences && record.references && record.references.length"
  256. :questions="record.references"
  257. />
  258. </transition>
  259. <transition name="slow-fade">
  260. <RelatedQuestions
  261. v-if="
  262. showRelatedQuestions && record.relatedQuestions && record.relatedQuestions.length
  263. "
  264. :questions="record.relatedQuestions"
  265. />
  266. </transition>
  267. <div v-if="showSessionReadableLogButton" class="session-readable-log-row">
  268. <button type="button" class="session-readable-log-btn" @click="openSessionReadableLog">
  269. 打开日志
  270. </button>
  271. </div>
  272. </div>
  273. <transition name="slow-fade">
  274. <FeedbackWidget
  275. v-if="shouldRenderFeedback"
  276. :id="record.id"
  277. :feedback="localFeedback"
  278. :content="copyableContent"
  279. @change="handleFeedbackChange"
  280. />
  281. </transition>
  282. </div>
  283. </Transition>
  284. </div>
  285. <Teleport to="body">
  286. <div v-if="refLinksPanelVisible" class="ref-links-viewer-modal">
  287. <transition name="ref-links-panel">
  288. <div v-if="refLinksPanelVisible" class="ref-links-viewer-content">
  289. <div class="ref-links-viewer-header">
  290. <div class="ref-links-viewer-title">参考资料</div>
  291. <button type="button" class="ref-links-viewer-close" @click.stop="closeRefLinksPanel">
  292. <svg
  293. width="14"
  294. height="14"
  295. viewBox="0 0 14 14"
  296. fill="none"
  297. xmlns="http://www.w3.org/2000/svg"
  298. >
  299. <path
  300. d="M1 1L13 13M13 1L1 13"
  301. stroke="currentColor"
  302. stroke-width="1.5"
  303. stroke-linecap="round"
  304. stroke-linejoin="round"
  305. />
  306. </svg>
  307. </button>
  308. </div>
  309. <div class="ref-links-viewer-list">
  310. <div
  311. v-for="(item, index) in activeRefLinks"
  312. :key="`${item.title}-${index}`"
  313. class="ref-links-card"
  314. >
  315. <div class="ref-links-card-head">
  316. <div class="ref-links-card-title">{{ item.title || "参考资料" }}</div>
  317. <div v-if="item.type" class="ref-links-card-tag">{{ item.type }}</div>
  318. </div>
  319. <!-- 来源元信息:发布机构 / 发布或更新时间(见接口文档 §9 source 统一字段) -->
  320. <div v-if="getRefLinkMeta(item)" class="ref-links-card-content">
  321. {{ getRefLinkMeta(item) }}
  322. </div>
  323. <div v-if="item.content" class="ref-links-card-content">{{ item.content }}</div>
  324. <a
  325. v-if="item.linkUrl"
  326. class="ref-links-card-link"
  327. :href="item.linkUrl"
  328. target="_blank"
  329. rel="noopener noreferrer"
  330. >
  331. 查看详情
  332. <svg
  333. width="12"
  334. height="12"
  335. viewBox="0 0 12 12"
  336. fill="none"
  337. xmlns="http://www.w3.org/2000/svg"
  338. >
  339. <path
  340. d="M1.33333 12C0.966667 12 0.652778 11.8694 0.391667 11.6083C0.130556 11.3472 0 11.0333 0 10.6667V1.33333C0 0.966667 0.130556 0.652778 0.391667 0.391667C0.652778 0.130556 0.966667 0 1.33333 0H6V1.33333H1.33333V10.6667H10.6667V6H12V10.6667C12 11.0333 11.8694 11.3472 11.6083 11.6083C11.3472 11.8694 11.0333 12 10.6667 12H1.33333ZM4.46667 8.46667L3.53333 7.53333L9.73333 1.33333H7.33333V0H12V4.66667H10.6667V2.26667L4.46667 8.46667Z"
  341. fill="#3525CD"
  342. />
  343. </svg>
  344. </a>
  345. </div>
  346. </div>
  347. </div>
  348. </transition>
  349. </div>
  350. </Teleport>
  351. <DislikeDialog v-model:visible="showDislikeDialog" @confirm="handleDislikeConfirm" />
  352. <Toast :message="toastMessage" />
  353. </template>
  354. <script setup lang="ts">
  355. import {
  356. computed,
  357. inject,
  358. onBeforeUnmount,
  359. onMounted,
  360. nextTick,
  361. provide,
  362. ref,
  363. watch,
  364. getCurrentInstance,
  365. } from "vue";
  366. import { MessageState } from "../stream-message-coordinator";
  367. import { FeedbackType, From } from "@/types/record";
  368. import { writeDmsFeedback } from "@/network/api/dms/chat-sessions-dms";
  369. import DotsAnim from "../Common/DotsAnim.vue";
  370. import BarAnim from "../Common/BarAnim.vue";
  371. import FeedbackWidget from "../Common/FeedbackWidget.vue";
  372. import DislikeDialog from "../Common/DislikeDialog.vue";
  373. import Toast from "../Common/Toast.vue";
  374. import QuestionSource from "../Common/QuestionSourceWidget.vue";
  375. import defaultImage from "@/assets/img_break.svg";
  376. import { computedOriginContentPosition, getQueryObj, sleep } from "../utils";
  377. import { isMobile } from "@/utils";
  378. import ReasoningWidget from "../Common/ReasoningWidget.vue";
  379. import RelatedQuestions from "../Common/RelatedQuestionsWidget.vue";
  380. import References from "../Common/References.vue";
  381. import TextContent from "./TextContent.vue";
  382. import FormContent from "./FormContent.vue";
  383. import LinkURLContent from "./LinkURLContent.vue";
  384. import ImageContent from "./ImageContent.vue";
  385. import OfficeURLContent from "./OfficeURLContent.vue";
  386. import ScopeContent from "./ScopeContent.vue";
  387. import PolicyMatch from "./PolicyMatch.vue";
  388. import PolicyListUpdatedMatch from "./PolicyListUpdatedMatch.vue";
  389. import QuestionCard from "./QuestionCard.vue";
  390. import {
  391. parseRefLinksContent,
  392. RecordRowTypes,
  393. StreamXMLFilter,
  394. type RefLinkItem,
  395. } from "@/utils/stream-xml-filter";
  396. import { jsonrepair } from "jsonrepair";
  397. import { isInterruptedEmptyScopeMessage } from "@/utils/interrupted-message";
  398. import {
  399. splitStreamingTextContentRows,
  400. trimDisplayTextContentEnd,
  401. trimDisplayTextContentStart,
  402. } from "./text-content-utils";
  403. import {
  404. isEmptyScopeRow,
  405. isRemovableEmptyScopeStatusRow,
  406. isRemovableScopePreludeTextRow,
  407. mergeScopeRecordRow,
  408. resolveBusinessScopeOutsideRow,
  409. shouldForcePendingScope,
  410. } from "@/utils/scope-record-rows";
  411. import { createModalMutex, MODAL_MUTEX_KEY } from "../business-assistant/modal-mutex";
  412. enum RecordRowStates {
  413. Pending = "pending",
  414. Displaying = "displaying",
  415. Displayed = "displayed",
  416. }
  417. // 定义 displayRecordRow 类型
  418. type DisplayRecordRow = {
  419. id: number;
  420. type: RecordRowTypes;
  421. state: RecordRowStates;
  422. content: string;
  423. [key: string]: any;
  424. };
  425. interface Props {
  426. aspect?: string;
  427. record?: any;
  428. content?: string;
  429. options?: { option: string }[];
  430. theme?: any;
  431. state?: MessageState | string | number;
  432. rect?: {
  433. top: number;
  434. height: number;
  435. };
  436. type?: string;
  437. human?: any;
  438. isLastRecord?: boolean;
  439. sessionViewedBefore?: boolean;
  440. sessionId?: string;
  441. enableFeedback?: boolean;
  442. }
  443. const props = withDefaults(defineProps<Props>(), {
  444. aspect: "16/9",
  445. rect: () => ({
  446. top: 0,
  447. height: 0,
  448. }),
  449. type: "default",
  450. });
  451. const emit = defineEmits([
  452. "changeIdleState",
  453. "feedback",
  454. "displayContentChange",
  455. "displayMediaChange",
  456. "refreshScroll",
  457. "scopeExitStateChange",
  458. "scopeHeightLockChange",
  459. "typewriterStateChange",
  460. ]);
  461. const policyMatchSessionId = computed(
  462. () => props.record?.session || props.sessionId || globalThis.currentSession || ""
  463. );
  464. const recordRootRef = ref<HTMLElement | null>(null);
  465. const businessScopeOutsideRef = ref<HTMLElement | null>(null);
  466. const textAnimWrapper = ref();
  467. const showTextAnim = ref(false);
  468. const localFeedback = ref<FeedbackType>(FeedbackType.None);
  469. const showDislikeDialog = ref(false);
  470. const toastMessage = ref("");
  471. let pendingDislikeRecordId: string | number | null = null;
  472. let toastTimer: ReturnType<typeof setTimeout> | undefined;
  473. const isFormQueryMode = getQueryObj("mode") === "form";
  474. const showRelatedQuestions = ref(false);
  475. const showReferences = ref(false);
  476. const refLinksPanelVisible = ref(false);
  477. const activeRefLinks = ref<RefLinkItem[]>([]);
  478. // 互斥面板管理
  479. const businessRecordInstanceId = `BR-${Math.random().toString(36).slice(2, 7)}`;
  480. const refLinksModalId = Symbol(`${businessRecordInstanceId}:ref-links`);
  481. const modalMutex = inject(MODAL_MUTEX_KEY, null) ?? createModalMutex();
  482. provide(MODAL_MUTEX_KEY, modalMutex);
  483. let unregisterRefLinksModal: (() => void) | undefined;
  484. function showToast(message: string) {
  485. toastMessage.value = message;
  486. if (toastTimer) clearTimeout(toastTimer);
  487. toastTimer = setTimeout(() => {
  488. toastMessage.value = "";
  489. }, 3000);
  490. }
  491. function openDislikeDialog(recordId: string | number) {
  492. pendingDislikeRecordId = recordId;
  493. showDislikeDialog.value = true;
  494. }
  495. // 反馈写入 DMS 的问答记录(栏目 1889 的 c_feedback_*),按 c_record_id 定位那一行。
  496. // recordId 就是 AI 消息 id,与写入时的 c_record_id 是同一个值(见 chat-sessions-dms.ts)
  497. function submitLikeFeedback(recordId: string | number) {
  498. void writeDmsFeedback({
  499. recordId: String(recordId),
  500. sessionId: policyMatchSessionId.value,
  501. status: FeedbackType.Like,
  502. });
  503. }
  504. function cancelFeedback(recordId: string | number) {
  505. void writeDmsFeedback({
  506. recordId: String(recordId),
  507. sessionId: policyMatchSessionId.value,
  508. status: FeedbackType.None,
  509. });
  510. }
  511. function handleDislikeConfirm(option: string, remark: string) {
  512. if (pendingDislikeRecordId === null) return;
  513. void writeDmsFeedback({
  514. recordId: String(pendingDislikeRecordId),
  515. sessionId: policyMatchSessionId.value,
  516. status: FeedbackType.Dislike,
  517. option,
  518. remark,
  519. });
  520. localFeedback.value = FeedbackType.Dislike;
  521. pendingDislikeRecordId = null;
  522. }
  523. provide("feedbackControl", { openDislikeDialog, submitLikeFeedback, cancelFeedback });
  524. provide("dislikeDialogControl", { openDislikeDialog });
  525. provide("toastControl", { showToast });
  526. const { setFormOptions } = inject("formOptionsControl") as any;
  527. const instance = getCurrentInstance();
  528. const $mediaViewer = instance?.proxy?.$mediaViewer as any;
  529. const record = computed(() => {
  530. let _record = props.record;
  531. if (!_record) return {};
  532. _record.isUser = _record.role === "user" || _record.from === From.User;
  533. _record.isAI = _record.role === "ai" || _record.from === From.AI;
  534. _record.isTips = _record.from === From.Tips;
  535. return _record;
  536. });
  537. const xmlFilter = new StreamXMLFilter({
  538. customProcessor: new Map([
  539. [
  540. RecordRowTypes.UIForm,
  541. (payload: { schema: string; attrs: Record<string, string> }) => {
  542. setFormOptions({
  543. visible: true,
  544. enable: true,
  545. mode: "form",
  546. schema: JSON.parse(jsonrepair(payload.schema)) || [],
  547. title: payload.attrs.title,
  548. });
  549. },
  550. ],
  551. ]),
  552. });
  553. const { stopSpeech, handleEndSpeech } = inject("speechControl") as any;
  554. let isEffectHumanState: any;
  555. const mediaList = computed(() => props.record?.mediaList);
  556. const userAttachments = computed(() => (record.value.isUser ? record.value.attachments || [] : []));
  557. const previewableUserAttachmentUrls = computed(() =>
  558. userAttachments.value
  559. .filter((attachment) => attachment.isImage && attachment.url)
  560. .map((attachment) => String(attachment.url || "").trim())
  561. .filter(Boolean)
  562. );
  563. const showUserAttachments = computed(() => userAttachments.value.length > 0);
  564. const displayMediaList = ref<any[]>([]);
  565. const isMobileDevice = isMobile();
  566. enum SchedulerTaskState {
  567. Idle = "idle",
  568. }
  569. const getRefLinkItems = (content: string) => parseRefLinksContent(content);
  570. // 复制按钮只应复制可读正文:去掉 <scope>、<!-- POLICY_TABLE -->、<ref_links>、
  571. // <question-cards>、<image-scope>(企业微信二维码)等协议标记,
  572. // 避免复制出一大段 JSON 或图片地址。
  573. //
  574. // 注意不能用 getStreamPlainText():它对整段内容做一次性解析,而过滤器在遇到
  575. // <!-- POLICY_TABLE 时会把同批 buffer 里位于它之前的内容直接当纯文本输出,
  576. // 导致 scope 标记被当成正文保留下来。这里直接剥离标记,结果与内容分片方式无关。
  577. const copyableContent = computed(() => {
  578. const raw = String(record.value.content || props.content || "");
  579. return raw
  580. .replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, "")
  581. .replace(/<ref_links\b[^>]*>[\s\S]*?<\/ref_links>\s*/gi, "")
  582. .replace(/<question-cards\b[^>]*>[\s\S]*?<\/question-cards>\s*/gi, "")
  583. .replace(/<image-scope\b[^>]*>[\s\S]*?<\/image-scope>\s*/gi, "")
  584. .replace(/<!--\s*POLICY_TABLE[\s\S]*?POLICY_TABLE\s*-->\s*/gi, "")
  585. .replace(/<\/?silence\b[^>]*>/gi, "")
  586. .replace(/<\/?scope\b[^>]*>/gi, "")
  587. .replace(/\n{3,}/g, "\n\n")
  588. .trim();
  589. });
  590. // 参考资料卡片的元信息行:发布机构 + 发布/更新时间。
  591. // 字段来自接口文档 §9「原始 source 对象中的统一字段」,经 originalData 透传。
  592. const getRefLinkMeta = (item: any) => {
  593. const data = item?.originalData || {};
  594. const parts: string[] = [];
  595. if (data.publisher) {
  596. parts.push(String(data.publisher));
  597. }
  598. const published = data.published_text || data.updated_text;
  599. if (published) {
  600. parts.push(/^\d{8}$/.test(String(published))
  601. ? `${String(published).replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3")} 更新`
  602. : String(published));
  603. }
  604. return parts.join(" · ");
  605. };
  606. const handleRefLinkChipClick = (event: MouseEvent, item: RefLinkItem) => {
  607. if (item.linkUrl) {
  608. return;
  609. }
  610. event.preventDefault();
  611. };
  612. const openRefLinksPanel = async (content: string) => {
  613. const items = getRefLinkItems(content);
  614. if (!items.length) {
  615. return;
  616. }
  617. const openTicket = modalMutex.requestOpen(refLinksModalId);
  618. await nextTick();
  619. if (!modalMutex.isCurrentRequest(refLinksModalId, openTicket)) return;
  620. activeRefLinks.value = items;
  621. refLinksPanelVisible.value = true;
  622. emitRefLinksPanelWidth();
  623. };
  624. const closeRefLinksPanel = () => {
  625. refLinksPanelVisible.value = false;
  626. modalMutex.release(refLinksModalId);
  627. emitRefLinksPanelWidth();
  628. };
  629. const emitRefLinksPanelWidth = () => {
  630. document.dispatchEvent(
  631. new CustomEvent("refLinksPanelWidthChange", {
  632. detail: { width: refLinksPanelVisible.value ? 480 : 0 },
  633. })
  634. );
  635. };
  636. watch(
  637. () => mediaList.value,
  638. (val) => {
  639. if (!val || val.length === 0 || timer) {
  640. return;
  641. }
  642. displayMediaList.value = val;
  643. }
  644. );
  645. const headMedia = computed(() => {
  646. if (!displayMediaList.value || displayMediaList.value.length === 0) {
  647. return {};
  648. }
  649. return displayMediaList.value[0];
  650. });
  651. const restMedia = computed(() => {
  652. if (!displayMediaList.value || displayMediaList.value.length === 0) {
  653. return [];
  654. }
  655. return displayMediaList.value.slice(1);
  656. });
  657. const getAttachmentExtension = (name: string) => name.split(".").pop()?.toUpperCase() || "FILE";
  658. const formatAttachmentSize = (size: number) => {
  659. if (!Number.isFinite(size) || size <= 0) {
  660. return "";
  661. }
  662. if (size >= 1024 * 1024) {
  663. return `${(size / 1024 / 1024).toFixed(2)} MB`;
  664. }
  665. return `${Math.max(1, Math.round(size / 1024))} KB`;
  666. };
  667. const reasoningStatus = computed(() => {
  668. let status = "";
  669. if (
  670. (record.value.state === MessageState.Sending || record.value.state === MessageState.Success) &&
  671. !record.value.content
  672. ) {
  673. status = "thinking";
  674. } else {
  675. status = "certain";
  676. }
  677. return status;
  678. });
  679. const showInterruptedState = computed(() =>
  680. isInterruptedEmptyScopeMessage({
  681. ...record.value,
  682. state: props.state ?? record.value.state,
  683. })
  684. );
  685. const displayContent = ref("");
  686. const originContent = ref(props.content || "");
  687. const count = ref(0);
  688. const mediaCount = ref(0);
  689. const hasVisibleAnswerContent = ref(false);
  690. const textRowTypingFinished = ref<Record<number, boolean>>({});
  691. const textRowTypingActive = ref<Record<number, boolean>>({});
  692. const imageRowRevealFinished = ref<Record<number, boolean>>({});
  693. const imageRowRevealActive = ref<Record<number, boolean>>({});
  694. // displayRecordRows 类型增强
  695. const displayRecordRows = ref<DisplayRecordRow[]>([]);
  696. const updateRecordRowIndex = ref(0);
  697. const activeRecordState = computed(() => props.state ?? record.value.state);
  698. const isForcePendingState = computed(
  699. () =>
  700. String(activeRecordState.value || "")
  701. .trim()
  702. .toLowerCase() === "forcepending"
  703. );
  704. const hasRecordTextContent = computed(
  705. () => String(record.value.content || props.content || "").trim().length > 0
  706. );
  707. const shouldRenderTextInstantly = computed(() => !record.value.isAI || !!record.value.history);
  708. const canAnimateAiText = computed(
  709. () =>
  710. record.value.isAI &&
  711. !record.value.history &&
  712. !!props.isLastRecord &&
  713. activeRecordState.value !== MessageState.Stop
  714. );
  715. const isScopeForcePending = computed(() =>
  716. shouldForcePendingScope(
  717. activeRecordState.value,
  718. hasVisibleAnswerContent.value,
  719. MessageState.Finish,
  720. MessageState.Stop
  721. )
  722. );
  723. const isBusinessLoading = computed(() => {
  724. return (
  725. record.value.isAI &&
  726. isForcePendingState.value &&
  727. !hasRecordTextContent.value &&
  728. !hasVisibleAnswerContent.value
  729. );
  730. });
  731. const showLoadingDots = computed(() => {
  732. const hasNoDisplayedContent =
  733. displayRecordRows.value.length === 0 &&
  734. !showTextAnim.value &&
  735. displayMediaList.value.length === 0;
  736. return hasNoDisplayedContent && isBusinessLoading.value;
  737. });
  738. let timer: any;
  739. let mediaTimer: any;
  740. let lastRecordResizeObserver: ResizeObserver | null = null;
  741. let lastObservedRecordHeight = 0;
  742. const gapTime = 30;
  743. const scopeExitFadeDurationMs = 300;
  744. const scopeExitCollapseDurationMs = scopeExitFadeDurationMs;
  745. let scopeExitTimer: number | null = null;
  746. let scopeExitRevision = 0;
  747. const scopeExitDebugStartedAt = performance.now();
  748. const scopeRowElementMap = new WeakMap<DisplayRecordRow, HTMLElement>();
  749. const recordHeightLock = ref<number | null>(null);
  750. const scopeExitHeight = ref(0);
  751. const scopeExitWidth = ref(0);
  752. const isScopeExitCompensating = computed(() => scopeExitHeight.value > 0);
  753. const isScopeExitBodyTransitionDisabled = ref(false);
  754. const isLastAiStreamingRecord = computed(
  755. () =>
  756. props.isLastRecord &&
  757. record.value.isAI &&
  758. activeRecordState.value !== MessageState.Finish &&
  759. activeRecordState.value !== MessageState.Stop
  760. );
  761. const scopeExitBodyGapPx = 8;
  762. const businessRecordBodyStyle = computed(() => ({
  763. "--scope-exit-offset": `${scopeExitHeight.value + (isScopeExitCompensating.value ? scopeExitBodyGapPx : 0)}px`,
  764. }));
  765. const recordRootStyle = computed(() => ({
  766. width: "100%",
  767. minHeight: recordHeightLock.value !== null ? `${recordHeightLock.value}px` : undefined,
  768. overflow: recordHeightLock.value !== null ? "hidden" : undefined,
  769. }));
  770. let changeFlag = 1;
  771. const customFormType = ref("");
  772. const businessScopeOutsideRow = computed(() => {
  773. const scopeRow = resolveBusinessScopeOutsideRow({
  774. variant: "business",
  775. record: record.value,
  776. displayRecordRows: displayRecordRows.value,
  777. hasVisibleAnswerContent: hasVisibleAnswerContent.value,
  778. activeRecordState: activeRecordState.value,
  779. finishState: MessageState.Finish,
  780. stopState: MessageState.Stop,
  781. allowFallbackPendingScope: !record.value.history,
  782. });
  783. if (
  784. scopeRow &&
  785. scopeRow.type === RecordRowTypes.Scope &&
  786. !scopeRow.exiting &&
  787. (activeRecordState.value === MessageState.Finish ||
  788. activeRecordState.value === MessageState.Stop)
  789. ) {
  790. return null;
  791. }
  792. if (record.value.history && hasHistoricalNonScopeContent.value) {
  793. return null;
  794. }
  795. return scopeRow;
  796. });
  797. const isOutsideScopeExiting = computed(() => Boolean(businessScopeOutsideRow.value?.exiting));
  798. const shouldRenderBusinessRecordBody = computed(() => true);
  799. const handleScopeAutoExit = (scopeRow: DisplayRecordRow | null) => {
  800. beginScopeExit(scopeRow);
  801. };
  802. const hasPolicyTableRow = computed(() =>
  803. displayRecordRows.value.some(
  804. (row) =>
  805. row.type === RecordRowTypes.PolicyTable || row.type === RecordRowTypes.PolicyListUpdated
  806. )
  807. );
  808. const hasHistoricalNonScopeContent = computed(() => {
  809. if (!record.value.history) {
  810. return false;
  811. }
  812. const rawContent = String(record.value.content || props.content || "");
  813. const contentWithoutScopes = rawContent
  814. .replace(/<\/?silence\b[^>]*>/gi, "")
  815. .replace(/<scope\b[^>]*>[\s\S]*?<\/scope>/gi, "");
  816. return contentWithoutScopes.trim().length > 0;
  817. });
  818. const hasRenderableNonScopeContent = computed(
  819. () =>
  820. hasHistoricalNonScopeContent.value ||
  821. hasInlineRenderableContent.value ||
  822. showLoadingDots.value ||
  823. showInterruptedState.value ||
  824. !!headMedia.value.type ||
  825. restMedia.value.length > 0 ||
  826. record.value.isTips ||
  827. activeRecordState.value === MessageState.Update ||
  828. showReferences.value ||
  829. showRelatedQuestions.value ||
  830. hasPolicyTableRow.value
  831. );
  832. const shouldRenderScopeRow = (row: DisplayRecordRow | null | undefined) => {
  833. if (!row || row.type !== RecordRowTypes.Scope) {
  834. return false;
  835. }
  836. if (record.value.history && hasRenderableNonScopeContent.value) {
  837. return false;
  838. }
  839. if (
  840. isEmptyScopeRow(row) &&
  841. (activeRecordState.value === MessageState.Finish ||
  842. activeRecordState.value === MessageState.Stop) &&
  843. !row.exiting
  844. ) {
  845. return false;
  846. }
  847. return (
  848. row.exiting ||
  849. (activeRecordState.value !== MessageState.Finish &&
  850. activeRecordState.value !== MessageState.Stop)
  851. );
  852. };
  853. const shouldRenderTextContentRow = (row: DisplayRecordRow) =>
  854. row.type === RecordRowTypes.Text && !isScopeExitCompensating.value;
  855. const visibleDisplayRecordRows = computed(() =>
  856. displayRecordRows.value.filter(
  857. (row) => row.type !== RecordRowTypes.Scope || shouldRenderScopeRow(row)
  858. )
  859. );
  860. const inlineDisplayRecordRows = computed(() => {
  861. const outsideRow = businessScopeOutsideRow.value;
  862. if (!outsideRow) {
  863. return visibleDisplayRecordRows.value;
  864. }
  865. return visibleDisplayRecordRows.value.filter((row) => row !== outsideRow);
  866. });
  867. const isTextRowFullyCompleted = (row: DisplayRecordRow) =>
  868. // 文本行完成条件只认打字机动画结束事件(textRowTypingFinished[row.id])。
  869. // 不能依赖 row.state === Displayed:段落边界(breakAfter)会把上一行提前标记为
  870. // Displayed,但此时打字机可能还没播完最后几个字,会导致下一行提前开始渲染。
  871. row.type !== RecordRowTypes.Text || textRowTypingFinished.value[row.id] === true;
  872. const isImageRowFullyRevealed = (row: DisplayRecordRow) =>
  873. row.type !== RecordRowTypes.ImageContent || imageRowRevealFinished.value[row.id];
  874. const hasUnfinishedRevealRows = computed(() =>
  875. inlineDisplayRecordRows.value.some(
  876. (row) =>
  877. (row.type === RecordRowTypes.Text && !isTextRowFullyCompleted(row)) ||
  878. (row.type === RecordRowTypes.ImageContent && !isImageRowFullyRevealed(row))
  879. )
  880. );
  881. const shouldDriveTypewriter = computed(
  882. () =>
  883. canAnimateAiText.value &&
  884. // F073:正文本身是真实增量流式到达的,再逐字重放等于凭空多等一遍 —— 关掉打字机
  885. // (TextContent 的 `instant || !activeTypewriter` 分支会转成即时渲染)
  886. !record.value.contentStreaming &&
  887. (activeRecordState.value !== MessageState.Finish || hasUnfinishedRevealRows.value)
  888. );
  889. const isSessionReadableLogButtonEnabled =
  890. import.meta.env.MODE === "production" || import.meta.env.MODE === "development";
  891. const showSessionReadableLogButton = computed(
  892. () =>
  893. isSessionReadableLogButtonEnabled &&
  894. globalThis.policyMode !== "zhaoshang" &&
  895. record.value.isAI &&
  896. activeRecordState.value === MessageState.Finish
  897. );
  898. const shouldWaitForTypewriterCompletion = computed(
  899. () => canAnimateAiText.value && hasUnfinishedRevealRows.value
  900. );
  901. // 反馈组件显示策略:
  902. // - 非 AI 消息:永不显示
  903. // - 已评价(赞/踩/未选中):始终显示
  904. // - 历史会话(sessionViewedBefore)的 AI 消息:始终显示(不需要等动画)
  905. // - 新消息:等 SSE 流结束 + 打字机/媒体动画都结束才显示
  906. const shouldRenderFeedback = computed(() => {
  907. if (!record.value.isAI) return false;
  908. if (localFeedback.value !== FeedbackType.None) return true;
  909. if (props.sessionViewedBefore) return true;
  910. if (activeRecordState.value !== MessageState.Finish) return false;
  911. // 打字机 / 图片 reveal 还没结束:不显示
  912. if (shouldWaitForTypewriterCompletion.value) return false;
  913. // 媒体列表(图片/视频)还在渐进式加载:不显示
  914. if ((mediaList.value?.length || 0) > displayMediaList.value.length) return false;
  915. return true;
  916. });
  917. const emitTypewriterStateChange = (isTypingActive: boolean) => {
  918. console.log("[BusinessRecord][typewriter-state]", {
  919. recordId: record.value.id,
  920. isLastRecord: props.isLastRecord,
  921. isAI: record.value.isAI,
  922. isTypingActive,
  923. textRowTypingActive: { ...textRowTypingActive.value },
  924. imageRowRevealActive: { ...imageRowRevealActive.value },
  925. });
  926. emit("typewriterStateChange", {
  927. recordId: record.value.id,
  928. isTypingActive,
  929. });
  930. };
  931. const syncTypewriterState = () => {
  932. const isTypingActive = Boolean(
  933. props.isLastRecord &&
  934. record.value.isAI &&
  935. (Object.values(textRowTypingActive.value).some(Boolean) ||
  936. Object.values(imageRowRevealActive.value).some(Boolean))
  937. );
  938. console.log("[BusinessRecord][typewriter-sync]", {
  939. recordId: record.value.id,
  940. isLastRecord: props.isLastRecord,
  941. isAI: record.value.isAI,
  942. isTypingActive,
  943. });
  944. emitTypewriterStateChange(isTypingActive);
  945. };
  946. const visibleInlineDisplayRecordRows = computed(() => {
  947. if (!shouldDriveTypewriter.value && !hasUnfinishedRevealRows.value) {
  948. return inlineDisplayRecordRows.value;
  949. }
  950. const visibleRows: DisplayRecordRow[] = [];
  951. let revealBlocked = false;
  952. for (const row of inlineDisplayRecordRows.value) {
  953. if (revealBlocked) {
  954. continue;
  955. }
  956. if (row.type === RecordRowTypes.Text) {
  957. visibleRows.push(row);
  958. if (!isTextRowFullyCompleted(row)) {
  959. revealBlocked = true;
  960. }
  961. continue;
  962. }
  963. visibleRows.push(row);
  964. if (row.type === RecordRowTypes.ImageContent && !isImageRowFullyRevealed(row)) {
  965. revealBlocked = true;
  966. }
  967. }
  968. return visibleRows;
  969. });
  970. const hasInlineRenderableContent = computed(() =>
  971. inlineDisplayRecordRows.value.some((row) => {
  972. if (row.type === RecordRowTypes.Text) {
  973. return String(row.content || "").trim().length > 0;
  974. }
  975. return row.type !== RecordRowTypes.Scope && !!row.content;
  976. })
  977. );
  978. const shouldRenderRecordLine = computed(() => {
  979. if (showInterruptedState.value) {
  980. return true;
  981. }
  982. if (
  983. record.value.isAI &&
  984. !hasInlineRenderableContent.value &&
  985. !businessScopeOutsideRow.value &&
  986. !hasPolicyTableRow.value
  987. ) {
  988. return false;
  989. }
  990. if (!businessScopeOutsideRow.value) {
  991. return true;
  992. }
  993. return (
  994. inlineDisplayRecordRows.value.length > 0 ||
  995. showLoadingDots.value ||
  996. showInterruptedState.value ||
  997. !!headMedia.value.type ||
  998. restMedia.value.length > 0 ||
  999. record.value.isTips ||
  1000. activeRecordState.value === MessageState.Update ||
  1001. showReferences.value ||
  1002. showRelatedQuestions.value
  1003. );
  1004. });
  1005. const tips = ref({
  1006. tipItems: [
  1007. {
  1008. title: "携带资料",
  1009. content: "就诊前请准备好身份证件、就诊卡(如有)、以往的病历、检查报告和正在使用的药物清单。",
  1010. },
  1011. {
  1012. title: "体格检查",
  1013. content: "请您明天穿着宽松舒适、无金属的衣物,以便进行体格检查。",
  1014. },
  1015. {
  1016. title: "陪同人员",
  1017. content: "建议您有家人或朋友陪同前来,以确保检查过程的便利和舒适。",
  1018. },
  1019. ],
  1020. tipFooter: "如果您有任何紧急情况或疑问,请联系我们0592-5050505(科室导诊台电话)",
  1021. });
  1022. const clearExistingTimer = () => {
  1023. if (timer) {
  1024. clearInterval(timer);
  1025. timer = null;
  1026. }
  1027. };
  1028. const stripSilenceTags = (input: string) => {
  1029. return String(input || "").replace(/<\/?silence\b[^>]*>/gi, "");
  1030. };
  1031. const processTextSections = (input: string) => {
  1032. recordParser(input);
  1033. };
  1034. const isQuestionAnswersContent = (content: string) => {
  1035. const trimmed = String(content || "").trim();
  1036. if (!trimmed.startsWith("{")) {
  1037. return false;
  1038. }
  1039. try {
  1040. const parsed = JSON.parse(trimmed);
  1041. return !!parsed && Array.isArray(parsed.answers);
  1042. } catch {
  1043. return false;
  1044. }
  1045. };
  1046. const processRecordSections = (input: string) => {
  1047. input = String(input || "");
  1048. if (!input) {
  1049. return;
  1050. }
  1051. if (record.value.isUser && isQuestionAnswersContent(input)) {
  1052. updateLastRecordRow(input, RecordRowTypes.QuestionCards, RecordRowStates.Displayed);
  1053. return;
  1054. }
  1055. const policyStartToken = "<!-- POLICY_TABLE";
  1056. const policyEndToken = "POLICY_TABLE -->";
  1057. let rest = input;
  1058. while (rest.length > 0) {
  1059. const policyStart = rest.indexOf(policyStartToken);
  1060. if (policyStart === -1) {
  1061. processTextSections(rest);
  1062. return;
  1063. }
  1064. const normalText = rest.slice(0, policyStart);
  1065. if (normalText) {
  1066. processTextSections(normalText);
  1067. }
  1068. const policyEnd = rest.indexOf(policyEndToken, policyStart);
  1069. if (policyEnd === -1) {
  1070. recordParser(rest.slice(policyStart));
  1071. return;
  1072. }
  1073. recordParser(rest.slice(policyStart, policyEnd + policyEndToken.length));
  1074. rest = rest.slice(policyEnd + policyEndToken.length);
  1075. }
  1076. };
  1077. const resetDisplayContent = () => {
  1078. if (textAnimWrapper.value) {
  1079. textAnimWrapper.value.innerHTML = "";
  1080. }
  1081. displayContent.value = "";
  1082. displayRecordRows.value = [];
  1083. textRowTypingFinished.value = {};
  1084. textRowTypingActive.value = {};
  1085. imageRowRevealFinished.value = {};
  1086. imageRowRevealActive.value = {};
  1087. hasVisibleAnswerContent.value = false;
  1088. // ⚠️ scope 退场补偿态也要清:`isScopeExitCompensating` 为真时**正文行是隐藏的**
  1089. // (见 shouldRenderTextContentRow),残留会让重写后的正文整段不显示
  1090. clearScopeExitTimer();
  1091. scopeExitHeight.value = 0;
  1092. scopeExitWidth.value = 0;
  1093. recordHeightLock.value = null;
  1094. count.value = 0;
  1095. };
  1096. const clearScopeExitTimer = () => {
  1097. if (scopeExitTimer === null) {
  1098. return;
  1099. }
  1100. window.clearTimeout(scopeExitTimer);
  1101. scopeExitTimer = null;
  1102. };
  1103. const getScopeExitSource = (scopeRow: DisplayRecordRow) =>
  1104. scopeRow === businessScopeOutsideRow.value ? "outside" : "inline";
  1105. const getScopeRowElement = (scopeRow: DisplayRecordRow | null) => {
  1106. if (!scopeRow) {
  1107. return null;
  1108. }
  1109. if (scopeRow === businessScopeOutsideRow.value) {
  1110. return businessScopeOutsideRef.value;
  1111. }
  1112. return scopeRowElementMap.get(scopeRow) || null;
  1113. };
  1114. const getScopeContentWidth = (scopeEl: HTMLElement | null) => {
  1115. const scopeContentEl = scopeEl?.querySelector<HTMLElement>(".scope-content-wrap") || scopeEl;
  1116. const width = scopeContentEl ? Math.ceil(scopeContentEl.getBoundingClientRect().width) : 0;
  1117. return Number.isFinite(width) && width > 0 ? width : 0;
  1118. };
  1119. const setScopeRowRef = (scopeRow: DisplayRecordRow, el: HTMLElement | null) => {
  1120. if (el) {
  1121. scopeRowElementMap.set(scopeRow, el);
  1122. } else {
  1123. scopeRowElementMap.delete(scopeRow);
  1124. }
  1125. };
  1126. const finalizeScopeExit = async (
  1127. exitingRow: DisplayRecordRow,
  1128. scopeSource: string,
  1129. exitHeight: number,
  1130. shouldLockRecordHeight: boolean,
  1131. exitRevision: number
  1132. ) => {
  1133. if (exitRevision !== scopeExitRevision) {
  1134. return;
  1135. }
  1136. const rowIndex = displayRecordRows.value.indexOf(exitingRow);
  1137. if (rowIndex !== -1 && displayRecordRows.value[rowIndex]?.exiting) {
  1138. displayRecordRows.value.splice(rowIndex, 1);
  1139. }
  1140. scopeExitHeight.value = 0;
  1141. scopeExitWidth.value = 0;
  1142. emit("scopeExitStateChange", {
  1143. recordId: record.value.id,
  1144. active: false,
  1145. });
  1146. await nextTick();
  1147. window.requestAnimationFrame(() => {
  1148. if (exitRevision !== scopeExitRevision) {
  1149. return;
  1150. }
  1151. isScopeExitBodyTransitionDisabled.value = false;
  1152. });
  1153. };
  1154. const beginScopeExit = (scopeRow: DisplayRecordRow | null) => {
  1155. if (!scopeRow || scopeRow.type !== RecordRowTypes.Scope || scopeRow.exiting) {
  1156. return;
  1157. }
  1158. if (!String(scopeRow.content || "").includes("</scope>")) {
  1159. return;
  1160. }
  1161. const scopeEl = getScopeRowElement(scopeRow);
  1162. const exitHeight = scopeEl ? Math.ceil(scopeEl.getBoundingClientRect().height) : 0;
  1163. const exitWidth = getScopeContentWidth(scopeEl);
  1164. const shouldLockRecordHeight = props.isLastRecord && exitHeight > 0;
  1165. const scopeSource = getScopeExitSource(scopeRow);
  1166. if (shouldLockRecordHeight && recordHeightLock.value === null) {
  1167. const recordEl = recordRootRef.value;
  1168. recordHeightLock.value = recordEl ? Math.ceil(recordEl.getBoundingClientRect().height) : null;
  1169. }
  1170. scopeRow.exiting = true;
  1171. scopeExitHeight.value = exitHeight;
  1172. scopeExitWidth.value = exitWidth;
  1173. isScopeExitBodyTransitionDisabled.value = true;
  1174. emit("scopeHeightLockChange", {
  1175. recordId: record.value.id,
  1176. active: shouldLockRecordHeight && exitHeight > 0,
  1177. height: exitHeight,
  1178. });
  1179. emit("scopeExitStateChange", {
  1180. recordId: record.value.id,
  1181. active: true,
  1182. });
  1183. const exitingRow = scopeRow;
  1184. const exitRevision = ++scopeExitRevision;
  1185. clearScopeExitTimer();
  1186. scopeExitTimer = window.setTimeout(() => {
  1187. scopeExitTimer = null;
  1188. if (exitRevision !== scopeExitRevision) {
  1189. return;
  1190. }
  1191. void finalizeScopeExit(
  1192. exitingRow,
  1193. scopeSource,
  1194. exitHeight,
  1195. shouldLockRecordHeight,
  1196. exitRevision
  1197. );
  1198. }, scopeExitCollapseDurationMs);
  1199. };
  1200. const updateDisplayContent = (newVal: string) => {
  1201. for (let i = 0; i < displayContent.value.length; i++) {
  1202. if (displayContent.value[i] !== newVal[i]) {
  1203. displayContent.value =
  1204. displayContent.value.substring(0, i) + newVal[i] + displayContent.value.substring(i + 1);
  1205. }
  1206. }
  1207. };
  1208. const syncRecordHeightLock = () => {
  1209. if (!isLastAiStreamingRecord.value) {
  1210. return;
  1211. }
  1212. const recordEl = recordRootRef.value;
  1213. if (!recordEl) {
  1214. return;
  1215. }
  1216. const nextHeight = Math.ceil(recordEl.getBoundingClientRect().height);
  1217. if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
  1218. return;
  1219. }
  1220. recordHeightLock.value = Math.max(recordHeightLock.value ?? 0, nextHeight);
  1221. };
  1222. const scopeTitleReg = /title\s*=\s*(['"]?)([^'"\s>]+)\1/i;
  1223. const updateLastRecordRow = (
  1224. text: string,
  1225. type = RecordRowTypes.Text,
  1226. state = RecordRowStates.Displaying
  1227. ) => {
  1228. let lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1229. if (type === RecordRowTypes.Text) {
  1230. text = stripSilenceTags(text);
  1231. if (!lastRow || lastRow.state === RecordRowStates.Displayed || lastRow.type !== type) {
  1232. text = trimDisplayTextContentStart(text);
  1233. }
  1234. if (text.length === 0) {
  1235. return;
  1236. }
  1237. }
  1238. const removeTrailingScopePreludeTextRows = () => {
  1239. while (displayRecordRows.value.length > 0) {
  1240. const trailingRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1241. if (!isRemovableScopePreludeTextRow(trailingRow)) {
  1242. break;
  1243. }
  1244. displayRecordRows.value.pop();
  1245. }
  1246. lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1247. };
  1248. const removeTrailingEmptyScopeStatusRows = () => {
  1249. while (displayRecordRows.value.length > 0) {
  1250. const trailingRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1251. if (!isRemovableEmptyScopeStatusRow(trailingRow)) {
  1252. break;
  1253. }
  1254. if (trailingRow.type === RecordRowTypes.Scope && !trailingRow.exiting) {
  1255. beginScopeExit(trailingRow);
  1256. break;
  1257. }
  1258. displayRecordRows.value.pop();
  1259. }
  1260. lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1261. };
  1262. if (type === RecordRowTypes.Scope) {
  1263. removeTrailingScopePreludeTextRows();
  1264. }
  1265. if (
  1266. type === RecordRowTypes.Scope &&
  1267. mergeScopeRecordRow(displayRecordRows.value, { type, content: text, state })
  1268. ) {
  1269. return;
  1270. }
  1271. // 已经有一个空的 scope 但还在流式更新时,允许直接复用它并替换标题。
  1272. if (
  1273. type === RecordRowTypes.Scope &&
  1274. lastRow &&
  1275. lastRow.type === RecordRowTypes.Scope &&
  1276. isEmptyScopeRow(lastRow)
  1277. ) {
  1278. lastRow.content = text;
  1279. lastRow.state = state;
  1280. return;
  1281. }
  1282. const hasVisibleContent =
  1283. type === RecordRowTypes.Text ? String(text || "").trim().length > 0 : !!text;
  1284. if (type !== RecordRowTypes.Scope && hasVisibleContent) {
  1285. hasVisibleAnswerContent.value = true;
  1286. removeTrailingEmptyScopeStatusRows();
  1287. }
  1288. if (type === RecordRowTypes.Text) {
  1289. const currentTextContent =
  1290. lastRow && lastRow.type === RecordRowTypes.Text && lastRow.state !== RecordRowStates.Displayed
  1291. ? lastRow.content
  1292. : "";
  1293. const segments = splitStreamingTextContentRows(currentTextContent, text);
  1294. if (segments.length === 0) {
  1295. return;
  1296. }
  1297. if (lastRow && lastRow.type === RecordRowTypes.Scope && !lastRow.exiting) {
  1298. beginScopeExit(lastRow);
  1299. }
  1300. for (const segment of segments) {
  1301. lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1302. if (!lastRow || lastRow.state === RecordRowStates.Displayed || lastRow.type !== type) {
  1303. if (lastRow && lastRow.type !== type) {
  1304. if (lastRow.type === RecordRowTypes.Text) {
  1305. lastRow.content = trimDisplayTextContentEnd(lastRow.content);
  1306. }
  1307. lastRow.state = RecordRowStates.Displayed;
  1308. }
  1309. displayRecordRows.value.push({
  1310. id: updateRecordRowIndex.value++,
  1311. type: type,
  1312. content: "",
  1313. state: RecordRowStates.Pending,
  1314. });
  1315. lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1316. if (type === RecordRowTypes.Text) {
  1317. textRowTypingFinished.value[lastRow.id] = true;
  1318. }
  1319. }
  1320. const segmentContent =
  1321. lastRow.content.length === 0
  1322. ? trimDisplayTextContentStart(segment.content)
  1323. : segment.content;
  1324. if (segmentContent.length > 0) {
  1325. lastRow.content += segmentContent;
  1326. textRowTypingFinished.value[lastRow.id] = false;
  1327. }
  1328. lastRow.state = state;
  1329. if (segment.breakAfter) {
  1330. lastRow.content = trimDisplayTextContentEnd(lastRow.content);
  1331. lastRow.state = RecordRowStates.Displayed;
  1332. }
  1333. }
  1334. return;
  1335. }
  1336. if (!lastRow || lastRow.state === RecordRowStates.Displayed || lastRow.type !== type) {
  1337. if (lastRow && lastRow.type !== type) {
  1338. if (lastRow.type === RecordRowTypes.Text) {
  1339. lastRow.content = trimDisplayTextContentEnd(lastRow.content);
  1340. }
  1341. lastRow.state = RecordRowStates.Displayed;
  1342. }
  1343. displayRecordRows.value.push({
  1344. id: updateRecordRowIndex.value++,
  1345. type: type,
  1346. content: "",
  1347. state: RecordRowStates.Pending,
  1348. });
  1349. lastRow = displayRecordRows.value[displayRecordRows.value.length - 1];
  1350. if (type === RecordRowTypes.Text) {
  1351. textRowTypingFinished.value[lastRow.id] = true;
  1352. } else if (type === RecordRowTypes.ImageContent) {
  1353. imageRowRevealFinished.value[lastRow.id] = !shouldDriveTypewriter.value;
  1354. }
  1355. }
  1356. if (type === RecordRowTypes.Text && text.length > 0) {
  1357. textRowTypingFinished.value[lastRow.id] = false;
  1358. }
  1359. lastRow.content = text;
  1360. lastRow.state = state;
  1361. };
  1362. const getLastRecordRow = () => {
  1363. if (displayRecordRows.value.length === 0) {
  1364. return null;
  1365. }
  1366. return displayRecordRows.value[displayRecordRows.value.length - 1];
  1367. };
  1368. // 优化和修正逻辑,确保 <form> 区间处理更严谨
  1369. const handleTimerTick = (val: string) => {
  1370. if (count.value >= val.length) {
  1371. clearExistingTimer();
  1372. if (record.value.state === MessageState.Finish) {
  1373. handleFinishEvent();
  1374. }
  1375. return;
  1376. }
  1377. updateDisplayContent(val);
  1378. // 判断当前字符是否存在,防止越界
  1379. if (typeof val[count.value] !== "undefined") {
  1380. showTextAnim.value = false;
  1381. // 添加当前字符到动画显示区
  1382. addText(val[count.value]);
  1383. // 通知外部内容有变化
  1384. emit("displayContentChange");
  1385. // 计数器自增,准备下一个字符
  1386. count.value++;
  1387. }
  1388. };
  1389. watch(
  1390. () => record.value.content,
  1391. (val) => {
  1392. clearExistingTimer();
  1393. if (!val || showInterruptedState.value) {
  1394. changeFlag++;
  1395. resetDisplayContent();
  1396. return;
  1397. }
  1398. /**
  1399. * ⚠️ 内容**不是纯追加**时必须整体重置再全量解析。
  1400. *
  1401. * 这里的增量解析只吃「比上次多出来的那一段」(`val.slice(count)`),
  1402. * 所以消息内容被**整块重写**(不是往后追加)时,切出来的会是新内容的**中段**——
  1403. * 那段中段会被当正文渲染出来(踩过:参考资料 JSON 的中段变成一坨文本显示在界面上)。
  1404. *
  1405. * 会整块重写的场景(F073 起):
  1406. * - 流式收尾的**卡片重排**(把政策卡片挪到正文之上)
  1407. * - `answer_end` 与增量拼接不一致时的整块重建
  1408. * - 撤草稿(内容被截短回基线)
  1409. * 纯追加(流式 delta / answer_end 的尾缀补齐)仍走增量路径,不重置、不闪。
  1410. */
  1411. if (!val.startsWith(originContent.value)) {
  1412. changeFlag++;
  1413. resetDisplayContent();
  1414. xmlFilter.reset(); // 解析器也是有状态的(buffer / 未闭合标签),一并清
  1415. }
  1416. const diffText = val.slice(count.value);
  1417. processRecordSections(diffText);
  1418. originContent.value = val;
  1419. count.value = val.length;
  1420. timer = setInterval(() => handleTimerTick(val), gapTime);
  1421. }
  1422. );
  1423. // 反馈组件显示由 shouldRenderFeedback 控制:历史会话立即展示,
  1424. // 新消息等打字机/媒体动画结束(triggerMediaAndFeedback 内把
  1425. // localFeedback 切到 Unselected)后才展示。
  1426. watch(
  1427. () => record.value.state,
  1428. async (val) => {
  1429. if (val === MessageState.Stop) {
  1430. changeFlag++;
  1431. if (timer) {
  1432. clearInterval(timer);
  1433. timer = null;
  1434. }
  1435. if (mediaTimer) {
  1436. clearInterval(mediaTimer);
  1437. }
  1438. await sleep(200);
  1439. let rawContent = computedOriginContentPosition(
  1440. record.value.originContent,
  1441. displayContent.value
  1442. );
  1443. stopSpeech({ answer: rawContent, record_id: record.value.id });
  1444. } else if (!timer && val === MessageState.Finish) {
  1445. handleFinishEvent();
  1446. }
  1447. }
  1448. );
  1449. const addMediaList = (localMediaList: any[], successCallback: Function) => {
  1450. if (!localMediaList || localMediaList.length === 0) {
  1451. successCallback();
  1452. return;
  1453. }
  1454. if (mediaTimer) {
  1455. clearInterval(mediaTimer);
  1456. }
  1457. mediaTimer = setInterval(() => {
  1458. if (mediaCount.value >= localMediaList.length) {
  1459. clearInterval(mediaTimer);
  1460. successCallback();
  1461. return;
  1462. }
  1463. mediaCount.value++;
  1464. displayMediaList.value = localMediaList.slice(0, mediaCount.value);
  1465. }, gapTime);
  1466. };
  1467. const addText = (text: string) => {
  1468. if (record.value.state === MessageState.Stop) {
  1469. return;
  1470. }
  1471. if (isMobileDevice) {
  1472. displayContent.value += text;
  1473. updateLastRecordRow(text);
  1474. return;
  1475. }
  1476. if (!textAnimWrapper.value) {
  1477. return;
  1478. }
  1479. let textSpan = document.createElement("span");
  1480. let regex = /^[a-zA-Z0-9 ]+$/;
  1481. if (regex.test(text)) {
  1482. textSpan.className = "entry-anim quick";
  1483. } else {
  1484. textSpan.className = "entry-anim";
  1485. }
  1486. textSpan.innerHTML = text;
  1487. textAnimWrapper.value.appendChild(textSpan);
  1488. let currentFlag = changeFlag;
  1489. const onAnimationEnd = () => {
  1490. if (currentFlag !== changeFlag) {
  1491. return;
  1492. }
  1493. textSpan.remove();
  1494. displayContent.value += text;
  1495. updateLastRecordRow(text);
  1496. if (textAnimWrapper.value.innerHTML === "") {
  1497. showTextAnim.value = false;
  1498. }
  1499. };
  1500. setTimeout(onAnimationEnd, 200);
  1501. };
  1502. // Handle TextContent typing finished
  1503. const handleTypingFinished = (rowId: number) => {
  1504. textRowTypingFinished.value[rowId] = true;
  1505. textRowTypingActive.value[rowId] = false;
  1506. console.log("[BusinessRecord][typing-finished]", {
  1507. recordId: record.value.id,
  1508. rowId,
  1509. isLastRecord: props.isLastRecord,
  1510. });
  1511. syncTypewriterState();
  1512. emit("refreshScroll");
  1513. };
  1514. const handleTypingStateChange = (rowId: number, isTypingActive: boolean) => {
  1515. textRowTypingActive.value[rowId] = isTypingActive;
  1516. if (isTypingActive) {
  1517. textRowTypingFinished.value[rowId] = false;
  1518. }
  1519. console.log("[BusinessRecord][typing-state-change]", {
  1520. recordId: record.value.id,
  1521. rowId,
  1522. isTypingActive,
  1523. isLastRecord: props.isLastRecord,
  1524. });
  1525. syncTypewriterState();
  1526. };
  1527. const handleImageRevealFinished = (rowId: number) => {
  1528. imageRowRevealFinished.value[rowId] = true;
  1529. imageRowRevealActive.value[rowId] = false;
  1530. console.log("[BusinessRecord][image-reveal-finished]", {
  1531. recordId: record.value.id,
  1532. rowId,
  1533. isLastRecord: props.isLastRecord,
  1534. });
  1535. syncTypewriterState();
  1536. emit("refreshScroll");
  1537. };
  1538. const handleImageRevealStateChange = (rowId: number, isRevealActive: boolean) => {
  1539. imageRowRevealActive.value[rowId] = isRevealActive;
  1540. if (isRevealActive) {
  1541. imageRowRevealFinished.value[rowId] = false;
  1542. }
  1543. console.log("[BusinessRecord][image-reveal-state-change]", {
  1544. recordId: record.value.id,
  1545. rowId,
  1546. isRevealActive,
  1547. isLastRecord: props.isLastRecord,
  1548. });
  1549. syncTypewriterState();
  1550. };
  1551. const openSessionReadableLog = () => {
  1552. const sessionId = localStorage.getItem("ba_current_session_id");
  1553. if (!sessionId) {
  1554. return;
  1555. }
  1556. const date = new Date().toLocaleDateString("en-CA");
  1557. window.open(
  1558. `https://qingpu-data-api.metamaker.cn/llms/qingpu/debug/session-readable-log?date=${date}&session_id=${sessionId}`
  1559. );
  1560. };
  1561. const observeLastRecordHeight = () => {
  1562. lastRecordResizeObserver?.disconnect();
  1563. lastRecordResizeObserver = null;
  1564. lastObservedRecordHeight = 0;
  1565. const target = recordRootRef.value;
  1566. if (!target || typeof ResizeObserver === "undefined") {
  1567. return;
  1568. }
  1569. lastObservedRecordHeight = target.getBoundingClientRect().height;
  1570. syncRecordHeightLock();
  1571. lastRecordResizeObserver = new ResizeObserver((entries) => {
  1572. const nextHeight = entries[0]?.contentRect.height ?? target.getBoundingClientRect().height;
  1573. if (!props.isLastRecord) {
  1574. lastObservedRecordHeight = nextHeight;
  1575. return;
  1576. }
  1577. syncRecordHeightLock();
  1578. const heightGrowth = nextHeight - lastObservedRecordHeight;
  1579. if (lastObservedRecordHeight > 0 && heightGrowth > 0) {
  1580. emit("refreshScroll", { heightGrowth });
  1581. }
  1582. lastObservedRecordHeight = nextHeight;
  1583. });
  1584. lastRecordResizeObserver.observe(target);
  1585. };
  1586. const handleFinishEvent = () => {
  1587. if (showInterruptedState.value) {
  1588. return;
  1589. }
  1590. handleEndSpeech({
  1591. answer: record.value.originContent,
  1592. record_id: record.value.id,
  1593. });
  1594. const triggerMediaAndFeedback = async () => {
  1595. addMediaList(mediaList.value, async () => {
  1596. if (props.enableFeedback && !isFormQueryMode && localFeedback.value === FeedbackType.None) {
  1597. localFeedback.value = FeedbackType.Unselected;
  1598. }
  1599. if (record.value.references && record.value.references.length) {
  1600. showReferences.value = true;
  1601. }
  1602. if (record.value.relatedQuestions && record.value.relatedQuestions.length) {
  1603. await sleep(500);
  1604. showRelatedQuestions.value = true;
  1605. }
  1606. });
  1607. emit("displayMediaChange");
  1608. };
  1609. const waitAllTypingFinished = () => {
  1610. if (!shouldWaitForTypewriterCompletion.value) {
  1611. triggerMediaAndFeedback();
  1612. return;
  1613. }
  1614. setTimeout(waitAllTypingFinished, 100);
  1615. };
  1616. waitAllTypingFinished();
  1617. };
  1618. watch(
  1619. isLastAiStreamingRecord,
  1620. (isStreaming) => {
  1621. if (!isStreaming) {
  1622. recordHeightLock.value = null;
  1623. return;
  1624. }
  1625. syncRecordHeightLock();
  1626. },
  1627. { immediate: true }
  1628. );
  1629. onMounted(() => {
  1630. unregisterRefLinksModal = modalMutex.register(refLinksModalId, {
  1631. close: closeRefLinksPanel,
  1632. isOpen: () => refLinksPanelVisible.value,
  1633. });
  1634. observeLastRecordHeight();
  1635. const initialContent = props.content || "";
  1636. if (initialContent && !showInterruptedState.value) {
  1637. processRecordSections(initialContent);
  1638. originContent.value = initialContent;
  1639. count.value = initialContent.length;
  1640. }
  1641. });
  1642. onBeforeUnmount(() => {
  1643. closeRefLinksPanel();
  1644. unregisterRefLinksModal?.();
  1645. emitTypewriterStateChange(false);
  1646. lastRecordResizeObserver?.disconnect();
  1647. lastRecordResizeObserver = null;
  1648. clearScopeExitTimer();
  1649. scopeExitRevision += 1;
  1650. if (!isEffectHumanState || !props.human) {
  1651. return;
  1652. }
  1653. if (isEffectHumanState.getTaskState == SchedulerTaskState.Idle) {
  1654. return;
  1655. }
  1656. if (isEffectHumanState != props.human.getCurrentState) {
  1657. return;
  1658. }
  1659. if (timer) {
  1660. clearInterval(timer);
  1661. timer = null;
  1662. }
  1663. props.human.setMainAnimationNameImmediateEffective(
  1664. props.human.getIdlePoses[0],
  1665. SchedulerTaskState.Idle
  1666. );
  1667. });
  1668. let formBuffer = "";
  1669. function recordParser(input: string) {
  1670. const resultRows = xmlFilter.processChunk(input);
  1671. resultRows.forEach((row) => {
  1672. updateLastRecordRow(row.content, row.type, RecordRowStates.Displaying);
  1673. });
  1674. }
  1675. // 流式处理文本,自动分段
  1676. // 处理流式和完整输入,支持断续和完整的 val
  1677. function openPreview(media: any, index: number, e: any) {
  1678. emit("changeIdleState", "interruptSpeech");
  1679. let rect = e.currentTarget?.getBoundingClientRect?.() || e.target.getBoundingClientRect();
  1680. $mediaViewer.openModal({
  1681. index,
  1682. media: { ...media, rect },
  1683. list: record.value.mediaList,
  1684. });
  1685. }
  1686. const getUserAttachmentPreviewIndex = (attachment: any) => {
  1687. const url = String(attachment?.url || "").trim();
  1688. if (!url) {
  1689. return 0;
  1690. }
  1691. const index = previewableUserAttachmentUrls.value.findIndex((item) => item === url);
  1692. return index >= 0 ? index : 0;
  1693. };
  1694. function handleError(e: any) {
  1695. e.target.style.objectFit = "none";
  1696. e.target.src = defaultImage;
  1697. }
  1698. async function handleFeedbackChange(feedback: FeedbackType) {
  1699. localFeedback.value = feedback;
  1700. // [已移除] 反馈统计埋点:统计接口(老接口)已按用户要求删除(2026-09-18)。
  1701. // 反馈本身仍写入 DMS(见 submitLikeFeedback / cancelFeedback / handleDislikeConfirm)
  1702. emit("feedback", feedback, record.value.id);
  1703. }
  1704. </script>
  1705. <style lang="scss">
  1706. @use "./record-theme.scss";
  1707. .record-modal-wrap {
  1708. position: fixed;
  1709. background: rgba(0, 0, 0, 0.7);
  1710. top: 0;
  1711. left: 0;
  1712. width: 100vw;
  1713. height: 100vh;
  1714. z-index: 200;
  1715. }
  1716. .chat-record-line {
  1717. font-size: 15px;
  1718. pointer-events: auto;
  1719. transition: all 0.3s ease-in-out;
  1720. animation: slide-up-fade 0.3s forwards cubic-bezier(0.22, 1, 0.36, 1);
  1721. &.with-feedback-margin {
  1722. padding-bottom: 8px;
  1723. }
  1724. &.with-feedback-margin-rated {
  1725. padding-bottom: 8px;
  1726. }
  1727. }
  1728. .chat-record-me {
  1729. background: rgba(176, 198, 253, 0.18);
  1730. color: #333333;
  1731. text-align: center;
  1732. /* title/h5_regular */
  1733. font-family: PingFang SC;
  1734. font-style: normal;
  1735. font-weight: 400;
  1736. line-height: 24px;
  1737. padding: 12px 15px 12px 15px;
  1738. border-radius: 16px 16px 4px 16px;
  1739. margin-bottom: 12px;
  1740. backdrop-filter: blur(4px);
  1741. -webkit-backdrop-filter: blur(4px);
  1742. display: inline-block;
  1743. margin-left: 10px;
  1744. align-self: flex-end;
  1745. flex-shrink: 0;
  1746. text-align: left;
  1747. position: relative;
  1748. transform-origin: right bottom;
  1749. span {
  1750. &::selection {
  1751. background: rgba(24, 144, 255, 0.24);
  1752. }
  1753. }
  1754. &.full-mode {
  1755. // background-color: rgba(176, 198, 253, 0.65);
  1756. // color: #303133;
  1757. }
  1758. &.desktop {
  1759. color: #000;
  1760. box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.04);
  1761. background: linear-gradient(180deg, rgba(222, 231, 255, 0.1) 0%, rgba(222, 231, 255, 0.2) 100%);
  1762. position: relative;
  1763. &:before {
  1764. content: "";
  1765. position: absolute;
  1766. inset: 0;
  1767. border-radius: inherit;
  1768. padding: 1px;
  1769. /* 控制描边厚度 */
  1770. background: radial-gradient(
  1771. 70.66% 143.6% at 37.72% 0%,
  1772. #ffffff 0%,
  1773. rgba(255, 255, 255, 0) 100%
  1774. );
  1775. /* 关键:中间镂空,仅保留“描边” */
  1776. -webkit-mask:
  1777. linear-gradient(#000 0 0) content-box,
  1778. linear-gradient(#000 0 0);
  1779. -webkit-mask-composite: xor;
  1780. mask:
  1781. linear-gradient(#000 0 0) content-box,
  1782. linear-gradient(#000 0 0);
  1783. mask-composite: exclude;
  1784. pointer-events: none;
  1785. /* 不挡交互 */
  1786. z-index: 1;
  1787. /* 在内容之上 */
  1788. }
  1789. }
  1790. }
  1791. .chat-record-ai {
  1792. min-width: 45px;
  1793. // background: rgba(255, 255, 255, 0.75);
  1794. position: relative;
  1795. // box-shadow: inset 1px 1px 44px rgba(255, 255, 255, 0.17), inset 0px -10px 25px rgba(255, 255, 255, 0.2), inset 0px 4px 19px rgba(255, 255, 255, 0.36);
  1796. border-radius: 0px 10px 10px 10px;
  1797. color: rgba(48, 49, 51, 1);
  1798. /* title/h5_regular */
  1799. font-family: PingFang SC;
  1800. font-style: normal;
  1801. font-weight: 400;
  1802. line-height: 24px;
  1803. // padding: 12px 15px 12px 15px;
  1804. margin-bottom: 12px;
  1805. // backdrop-filter: blur(8px);
  1806. // -webkit-backdrop-filter: blur(8px);
  1807. margin-right: 10px;
  1808. display: inline-block;
  1809. text-align: left;
  1810. flex-shrink: 0;
  1811. transform-origin: left bottom;
  1812. span {
  1813. &::selection {
  1814. background: rgb(200, 204, 207);
  1815. }
  1816. }
  1817. &.full-mode {
  1818. background-color: rgba(255, 255, 255, 0.5);
  1819. }
  1820. }
  1821. .record--business.ba-record-ai.is-loading {
  1822. background: transparent;
  1823. box-shadow: none;
  1824. padding: 0;
  1825. }
  1826. .business-scope-content-outside {
  1827. display: block;
  1828. margin-bottom: 8px;
  1829. text-align: left;
  1830. }
  1831. .business-record-body {
  1832. min-width: 0;
  1833. max-width: 100%;
  1834. transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
  1835. will-change: transform;
  1836. }
  1837. .business-record-body.is-scope-exit-transition-disabled {
  1838. transition: none !important;
  1839. }
  1840. .business-record-body.is-scope-exiting {
  1841. transform: translateY(calc(var(--scope-exit-offset, 0px) * -1));
  1842. }
  1843. .session-readable-log-row {
  1844. display: flex;
  1845. margin-top: 8px;
  1846. }
  1847. .session-readable-log-btn {
  1848. border: 0;
  1849. border-radius: 999px;
  1850. padding: 6px 12px;
  1851. background: rgba(53, 37, 205, 0.08);
  1852. color: #3525cd;
  1853. font-size: 12px;
  1854. line-height: 1.4;
  1855. cursor: pointer;
  1856. }
  1857. .session-readable-log-btn:hover {
  1858. background: rgba(53, 37, 205, 0.14);
  1859. }
  1860. .ba-ref-links-summary {
  1861. width: 100%;
  1862. margin-top: 12px;
  1863. padding-top: 12px;
  1864. border-top: 1px solid rgba(199, 196, 216, 0.2);
  1865. color: #141b2b;
  1866. }
  1867. .ba-ref-links-summary-title {
  1868. margin-bottom: 8px;
  1869. color: rgba(70, 69, 85, 0.6);
  1870. font-size: 12px;
  1871. font-weight: 500;
  1872. line-height: 1.4;
  1873. }
  1874. .ba-ref-links-row {
  1875. display: flex;
  1876. align-items: center;
  1877. gap: 8px;
  1878. max-width: 100%;
  1879. min-width: 0;
  1880. }
  1881. .ba-ref-links-chip-list {
  1882. display: flex;
  1883. align-items: center;
  1884. gap: 8px;
  1885. max-width: 100%;
  1886. min-width: 0;
  1887. overflow-x: hidden;
  1888. overflow-y: hidden;
  1889. }
  1890. .ba-ref-links-chip {
  1891. display: inline-flex;
  1892. align-items: center;
  1893. gap: 4px;
  1894. min-width: 0;
  1895. max-width: 198px;
  1896. height: 25px;
  1897. padding: 3px 8px;
  1898. border-radius: 4px;
  1899. background: rgb(225, 232, 252);
  1900. color: #3525cd;
  1901. font-size: 12px;
  1902. font-weight: 500;
  1903. line-height: 18px;
  1904. white-space: nowrap;
  1905. text-decoration: none;
  1906. flex-shrink: 0;
  1907. }
  1908. .ba-ref-links-chip-index {
  1909. color: #3525cd;
  1910. font-weight: 500;
  1911. flex-shrink: 0;
  1912. }
  1913. .ba-ref-links-chip-title {
  1914. min-width: 0;
  1915. overflow: hidden;
  1916. text-overflow: ellipsis;
  1917. max-width: 198px;
  1918. }
  1919. .ba-ref-links-more {
  1920. height: 25px;
  1921. padding: 3px 10px;
  1922. border-radius: 4px;
  1923. outline: none;
  1924. border: none;
  1925. background: rgb(225, 232, 252);
  1926. color: #3525cd;
  1927. font-size: 12px;
  1928. font-weight: 700;
  1929. line-height: 18px;
  1930. cursor: pointer;
  1931. flex-shrink: 0;
  1932. }
  1933. .ba-ref-links-more:hover {
  1934. background: #e2dfff;
  1935. }
  1936. .ref-links-viewer-modal {
  1937. position: fixed;
  1938. inset: 0;
  1939. z-index: 220;
  1940. pointer-events: none;
  1941. }
  1942. .ref-links-viewer-content {
  1943. position: absolute;
  1944. top: 0;
  1945. right: 0;
  1946. bottom: 0;
  1947. width: 480px;
  1948. max-width: 100vw;
  1949. background: #ffffff;
  1950. box-shadow: -12px 0 32px rgba(20, 27, 43, 0.12);
  1951. display: flex;
  1952. flex-direction: column;
  1953. overflow: hidden;
  1954. pointer-events: auto;
  1955. }
  1956. .ref-links-viewer-header {
  1957. display: flex;
  1958. align-items: center;
  1959. justify-content: space-between;
  1960. gap: 16px;
  1961. padding: 24px 24px 12px;
  1962. flex-shrink: 0;
  1963. }
  1964. .ref-links-viewer-title {
  1965. color: #141b2b;
  1966. font-size: 24px;
  1967. font-weight: 500;
  1968. line-height: 1.4;
  1969. }
  1970. .ref-links-viewer-close {
  1971. width: 40px;
  1972. height: 40px;
  1973. border: 0;
  1974. border-radius: 999px;
  1975. background: transparent;
  1976. color: #464555;
  1977. display: inline-flex;
  1978. align-items: center;
  1979. justify-content: center;
  1980. cursor: pointer;
  1981. }
  1982. .ref-links-viewer-close:hover {
  1983. background: rgba(53, 37, 205, 0.06);
  1984. }
  1985. .ref-links-viewer-list {
  1986. display: flex;
  1987. flex-direction: column;
  1988. gap: 16px;
  1989. padding: 0 24px 32px;
  1990. overflow-y: auto;
  1991. min-height: 0;
  1992. }
  1993. .ref-links-card {
  1994. display: flex;
  1995. flex-direction: column;
  1996. gap: 8px;
  1997. padding: 24px;
  1998. border: 1px solid #e9edff;
  1999. border-radius: 12px;
  2000. background: #ffffff;
  2001. box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
  2002. }
  2003. .ref-links-card-head {
  2004. display: flex;
  2005. align-items: flex-start;
  2006. justify-content: space-between;
  2007. gap: 12px;
  2008. }
  2009. .ref-links-card-title {
  2010. min-width: 0;
  2011. color: #141b2b;
  2012. font-size: 16px;
  2013. font-weight: 500;
  2014. line-height: 1.6;
  2015. -webkit-line-clamp: 1;
  2016. -webkit-box-orient: vertical;
  2017. display: -webkit-box;
  2018. overflow: hidden;
  2019. text-overflow: ellipsis;
  2020. white-space: nowrap;
  2021. }
  2022. .ref-links-card-tag {
  2023. max-width: 92px;
  2024. padding: 2px 8px;
  2025. border-radius: 4px;
  2026. background: rgba(225, 232, 253, 1);
  2027. color: #3525cd;
  2028. font-size: 12px;
  2029. font-weight: 500;
  2030. line-height: 1.4;
  2031. overflow: hidden;
  2032. text-overflow: ellipsis;
  2033. white-space: nowrap;
  2034. flex-shrink: 0;
  2035. }
  2036. .ref-links-card-content {
  2037. color: #464555;
  2038. font-size: 12px;
  2039. font-weight: 500;
  2040. line-height: 1.6;
  2041. -webkit-line-clamp: 2;
  2042. -webkit-box-orient: vertical;
  2043. display: -webkit-box;
  2044. overflow: hidden;
  2045. text-overflow: ellipsis;
  2046. }
  2047. .ref-links-card-link {
  2048. display: inline-flex;
  2049. align-items: center;
  2050. gap: 4px;
  2051. width: max-content;
  2052. color: #3525cd;
  2053. font-size: 12px;
  2054. font-weight: 700;
  2055. line-height: 1.4;
  2056. margin-top: 8px;
  2057. text-decoration: none;
  2058. }
  2059. .ref-links-card-link:hover {
  2060. text-decoration: underline;
  2061. text-underline-offset: 4px;
  2062. }
  2063. .ref-links-panel-enter-active,
  2064. .ref-links-panel-leave-active {
  2065. transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
  2066. }
  2067. .ref-links-panel-enter-from,
  2068. .ref-links-panel-leave-to {
  2069. transform: translateX(100%);
  2070. }
  2071. .chat-record-me .session-readable-log-row {
  2072. justify-content: flex-end;
  2073. }
  2074. .chat-record-ai .session-readable-log-row {
  2075. justify-content: flex-start;
  2076. }
  2077. .scope-content-shell {
  2078. display: block;
  2079. width: 100%;
  2080. min-width: 0;
  2081. }
  2082. .business-record-body-fade-enter-active,
  2083. .business-record-body-fade-leave-active {
  2084. transition:
  2085. opacity 300ms cubic-bezier(0.22, 1, 0.36, 1),
  2086. transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
  2087. }
  2088. .business-record-body-fade-enter-from,
  2089. .business-record-body-fade-leave-to {
  2090. opacity: 0;
  2091. }
  2092. .business-record-body-fade-enter-to,
  2093. .business-record-body-fade-leave-from {
  2094. opacity: 1;
  2095. }
  2096. .ba-message-attachments {
  2097. display: flex;
  2098. flex-wrap: wrap;
  2099. justify-content: flex-end;
  2100. align-self: flex-end;
  2101. gap: 8px;
  2102. width: 100%;
  2103. margin-bottom: 12px;
  2104. pointer-events: auto;
  2105. clear: both;
  2106. &.desktop {
  2107. max-width: min(420px, 60vw);
  2108. margin-left: auto;
  2109. }
  2110. }
  2111. .ba-message-attachment {
  2112. position: relative;
  2113. overflow: hidden;
  2114. border: 1px solid rgba(199, 196, 216, 0.3);
  2115. border-radius: 12px;
  2116. background: #ffffff;
  2117. box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
  2118. }
  2119. .ba-message-attachment.is-image {
  2120. width: 68px;
  2121. height: 68px;
  2122. padding: 0;
  2123. border: 0;
  2124. background: transparent;
  2125. box-shadow: none;
  2126. }
  2127. .ba-message-attachment.is-image :deep(.image-content-wrap) {
  2128. width: 100%;
  2129. height: 100%;
  2130. margin: 0;
  2131. }
  2132. .ba-message-attachment.is-file {
  2133. min-width: 180px;
  2134. max-width: 240px;
  2135. min-height: 68px;
  2136. display: flex;
  2137. align-items: center;
  2138. gap: 10px;
  2139. padding: 10px;
  2140. }
  2141. .ba-message-attachment-icon {
  2142. width: 40px;
  2143. height: 40px;
  2144. border-radius: 8px;
  2145. background: rgba(53, 37, 205, 0.1);
  2146. color: #3525cd;
  2147. display: flex;
  2148. align-items: center;
  2149. justify-content: center;
  2150. flex-shrink: 0;
  2151. font-size: 10px;
  2152. font-weight: 600;
  2153. }
  2154. .ba-message-attachment-info {
  2155. min-width: 0;
  2156. flex: 1;
  2157. }
  2158. .ba-message-attachment-name {
  2159. overflow: hidden;
  2160. color: #141b2b;
  2161. font-size: 13px;
  2162. font-weight: 500;
  2163. line-height: 1.4;
  2164. text-overflow: ellipsis;
  2165. white-space: nowrap;
  2166. }
  2167. .ba-message-attachment-size {
  2168. margin-top: 3px;
  2169. color: #464555;
  2170. font-size: 12px;
  2171. line-height: 1.4;
  2172. }
  2173. .chat-record-tips {
  2174. min-width: 45px;
  2175. background: linear-gradient(180deg, #e0e7ff 0%, #f4f7ff 100%);
  2176. border: 3px solid #ffffff;
  2177. border-radius: 16px;
  2178. position: relative;
  2179. // box-shadow: inset 1px 1px 44px rgba(255, 255, 255, 0.17), inset 0px -10px 25px rgba(255, 255, 255, 0.2), inset 0px 4px 19px rgba(255, 255, 255, 0.36);
  2180. color: #0a0b0d;
  2181. /* title/h5_regular */
  2182. font-family: PingFang SC;
  2183. font-size: 15px;
  2184. font-style: normal;
  2185. font-weight: 400;
  2186. line-height: 24px;
  2187. padding: 22px 15px 12px 15px;
  2188. margin-top: 40px;
  2189. margin-bottom: 12px;
  2190. backdrop-filter: blur(8px);
  2191. -webkit-backdrop-filter: blur(8px);
  2192. margin-right: 30px;
  2193. margin-left: 30px;
  2194. display: inline-block;
  2195. text-align: left;
  2196. flex-shrink: 0;
  2197. transform-origin: center bottom;
  2198. span {
  2199. &::selection {
  2200. background: rgb(200, 204, 207);
  2201. }
  2202. }
  2203. &.full-mode {
  2204. background-color: rgba(255, 255, 255, 0.5);
  2205. }
  2206. .tips-title {
  2207. position: absolute;
  2208. font-size: 14px;
  2209. line-height: 24px;
  2210. top: -15px;
  2211. padding: 5px 60px;
  2212. color: #fff;
  2213. left: 50%;
  2214. transform: translateX(-50%);
  2215. border-radius: 0 0 10px 10px;
  2216. margin-bottom: 8px;
  2217. font-weight: bold;
  2218. word-break: keep-all;
  2219. .tips-deco {
  2220. position: absolute;
  2221. width: 90%;
  2222. height: 100%;
  2223. left: 0;
  2224. top: 0;
  2225. z-index: -1;
  2226. &.mirror {
  2227. transform: scaleX(-100%);
  2228. left: auto;
  2229. right: 0;
  2230. }
  2231. &:before {
  2232. position: absolute;
  2233. content: "";
  2234. width: 0;
  2235. height: 0;
  2236. left: -9px;
  2237. border-top: 0 solid transparent;
  2238. border-left: 6px solid transparent;
  2239. border-right: 6px solid transparent;
  2240. border-bottom: 13px solid #0013c3;
  2241. }
  2242. &:after {
  2243. position: absolute;
  2244. content: "";
  2245. width: 100%;
  2246. height: 100%;
  2247. left: 0;
  2248. top: 0;
  2249. background: linear-gradient(180deg, #485bff 0%, #3e51f5 100%);
  2250. transform: skewX(10deg);
  2251. border-radius: 0 0 0 8px;
  2252. }
  2253. }
  2254. }
  2255. }
  2256. .business-record .content {
  2257. line-height: 24px;
  2258. text-align: left;
  2259. flex-shrink: 0;
  2260. display: block;
  2261. width: 100%;
  2262. max-width: 100%;
  2263. min-width: 0;
  2264. box-sizing: border-box;
  2265. word-break: break-word;
  2266. user-select: text;
  2267. &.isTip {
  2268. display: none;
  2269. }
  2270. a {
  2271. color: rgba(255, 255, 255, 1);
  2272. }
  2273. p:last-child {
  2274. margin-bottom: 0;
  2275. }
  2276. }
  2277. .request-interrupted-status {
  2278. display: inline-flex;
  2279. align-items: center;
  2280. min-height: 28px;
  2281. padding: 2px 10px;
  2282. border-radius: 6px;
  2283. border: 1px solid rgba(148, 163, 184, 0.28);
  2284. background: rgba(248, 250, 252, 0.9);
  2285. color: #64748b;
  2286. font-size: 13px;
  2287. line-height: 22px;
  2288. user-select: text;
  2289. }
  2290. .tips-wrap {
  2291. padding-top: 5px;
  2292. .tip {
  2293. .title {
  2294. display: flex;
  2295. font-size: 14px;
  2296. line-height: 24px;
  2297. color: #333;
  2298. font-weight: bold;
  2299. margin-bottom: 6px;
  2300. .order {
  2301. display: flex;
  2302. justify-content: center;
  2303. align-items: center;
  2304. width: 22px;
  2305. height: 22px;
  2306. border-radius: 3px;
  2307. color: #fff;
  2308. background: linear-gradient(180deg, #485bff 0%, #3e51f5 100%);
  2309. margin-right: 8px;
  2310. font-family: PingFang SC;
  2311. }
  2312. }
  2313. & + .tip {
  2314. margin-top: 16px;
  2315. }
  2316. }
  2317. .tip-footer {
  2318. margin-top: 16px;
  2319. font-weight: bold;
  2320. }
  2321. }
  2322. @keyframes word-disappear {
  2323. 0% {
  2324. opacity: 1;
  2325. transform: scale(1);
  2326. }
  2327. 100% {
  2328. opacity: 0;
  2329. transform: scale(0.5);
  2330. }
  2331. }
  2332. .entry-anim {
  2333. height: 14px;
  2334. line-height: 14px;
  2335. animation: text-fading 0.2s linear forwards;
  2336. &.quick {
  2337. animation: text-fading 0.1s linear forwards;
  2338. }
  2339. &.block {
  2340. height: 0;
  2341. display: block;
  2342. }
  2343. &.block + .block {
  2344. margin-top: 14px;
  2345. }
  2346. }
  2347. .recognizing-anim {
  2348. margin-left: -4px;
  2349. }
  2350. @media screen and (width > 768px) {
  2351. .desktop {
  2352. .business-record .content {
  2353. color: rgba(255, 255, 255, 1);
  2354. a {
  2355. color: rgba(255, 255, 255, 1);
  2356. }
  2357. }
  2358. }
  2359. }
  2360. @media (max-width: 767px) {
  2361. .ba-ref-links-summary {
  2362. max-width: 100%;
  2363. }
  2364. .ref-links-viewer-content {
  2365. left: 0;
  2366. width: 100vw;
  2367. height: 100dvh;
  2368. box-shadow: none;
  2369. }
  2370. .ref-links-viewer-header {
  2371. padding: 32px 16px 12px;
  2372. }
  2373. .ref-links-viewer-title {
  2374. font-size: 24px;
  2375. }
  2376. .ref-links-viewer-close {
  2377. width: 40px;
  2378. height: 40px;
  2379. }
  2380. .ref-links-viewer-list {
  2381. gap: 12px;
  2382. padding: 0 16px 32px;
  2383. }
  2384. .ref-links-card {
  2385. padding: 16px;
  2386. box-shadow: none;
  2387. }
  2388. .ref-links-card-head {
  2389. align-items: flex-start;
  2390. }
  2391. .ref-links-card-title {
  2392. font-size: 14px;
  2393. }
  2394. }
  2395. @keyframes text-fading {
  2396. 0% {
  2397. opacity: 0;
  2398. // filter: blur(40px);
  2399. }
  2400. 100% {
  2401. opacity: 1;
  2402. // filter: blur(0);
  2403. }
  2404. }
  2405. .thinking-anim {
  2406. display: inline-flex;
  2407. height: 21px;
  2408. vertical-align: top;
  2409. align-items: center;
  2410. .random-text {
  2411. margin-left: -4px;
  2412. min-width: 14px;
  2413. height: 14px;
  2414. line-height: 14px;
  2415. background-color: rgba(0, 0, 0, 0.6);
  2416. color: white;
  2417. text-align: left;
  2418. vertical-align: top;
  2419. }
  2420. .insert-block {
  2421. width: 14px;
  2422. height: 14px;
  2423. background-color: rgba(0, 0, 0, 0.6);
  2424. }
  2425. .lds-ellipsis {
  2426. display: inline-block;
  2427. position: relative;
  2428. width: 80px;
  2429. height: 80px;
  2430. transform: scale(0.4);
  2431. }
  2432. .lds-ellipsis div {
  2433. position: absolute;
  2434. top: 33px;
  2435. width: 13px;
  2436. height: 13px;
  2437. border-radius: 50%;
  2438. background: rgba(0, 0, 0, 0.6);
  2439. animation-timing-function: cubic-bezier(0, 1, 1, 0);
  2440. }
  2441. .lds-ellipsis div:nth-child(1) {
  2442. left: 8px;
  2443. background: gray;
  2444. animation: lds-ellipsis1 0.8s infinite;
  2445. }
  2446. .lds-ellipsis div:nth-child(2) {
  2447. left: 8px;
  2448. animation: lds-ellipsis2 0.8s infinite;
  2449. }
  2450. .lds-ellipsis div:nth-child(3) {
  2451. left: 32px;
  2452. animation: lds-ellipsis3 0.8s infinite;
  2453. }
  2454. .lds-ellipsis div:nth-child(4) {
  2455. left: 56px;
  2456. animation: lds-ellipsis4 0.8s infinite;
  2457. }
  2458. @keyframes lds-ellipsis1 {
  2459. 0% {
  2460. transform: scale(2);
  2461. border-radius: 50%;
  2462. }
  2463. 75% {
  2464. transform: scale(2.5);
  2465. border-radius: 4px;
  2466. }
  2467. 100% {
  2468. transform: scale(2);
  2469. border-radius: 50%;
  2470. }
  2471. }
  2472. @keyframes lds-ellipsis2 {
  2473. 0% {
  2474. opacity: 0.5;
  2475. transform: translate(24px, 0) scale(1.5);
  2476. }
  2477. 75% {
  2478. transform: translate(0, 0) scale(2);
  2479. }
  2480. 100% {
  2481. opacity: 1;
  2482. transform: translate(0, 0) scale(1);
  2483. }
  2484. }
  2485. @keyframes lds-ellipsis3 {
  2486. 0% {
  2487. opacity: 0.25;
  2488. transform: translate(24px, 0) scale(1);
  2489. }
  2490. 100% {
  2491. opacity: 0.5;
  2492. transform: translate(0, 0) scale(1.5);
  2493. }
  2494. }
  2495. @keyframes lds-ellipsis4 {
  2496. 0% {
  2497. opacity: 0;
  2498. transform: scale(0);
  2499. }
  2500. 100% {
  2501. opacity: 0.25;
  2502. transform: scale(1);
  2503. }
  2504. }
  2505. }
  2506. .media-row {
  2507. display: block;
  2508. margin-top: 8px;
  2509. margin-bottom: 8px;
  2510. }
  2511. .rest-media-wrapper {
  2512. width: 140px;
  2513. margin-bottom: 4px;
  2514. border-radius: 8px;
  2515. background-color: white;
  2516. display: flex;
  2517. flex-wrap: wrap;
  2518. padding: 4px;
  2519. box-sizing: border-box;
  2520. }
  2521. .media-placeholder {
  2522. width: 140px;
  2523. height: 140px;
  2524. animation: zoom-in 0.3s forwards ease-in-out;
  2525. border-radius: 8px;
  2526. overflow: hidden;
  2527. position: relative;
  2528. background: white;
  2529. .play-button {
  2530. position: absolute;
  2531. top: 50%;
  2532. left: 50%;
  2533. transform: translate(-50%, -50%);
  2534. width: 40px;
  2535. height: 40px;
  2536. border-radius: 50%;
  2537. background-color: rgba(0, 0, 0, 0.6);
  2538. background-image: url(@/assets/play-button.svg);
  2539. background-size: 100% 100%;
  2540. display: flex;
  2541. align-items: center;
  2542. justify-content: center;
  2543. cursor: pointer;
  2544. z-index: 10;
  2545. }
  2546. &.head-media {
  2547. width: 140px;
  2548. height: 140px;
  2549. }
  2550. &.several {
  2551. width: 64.5px;
  2552. height: 64.5px;
  2553. margin-top: 3px;
  2554. margin-right: 3px;
  2555. &:nth-child(1),
  2556. &:nth-child(2) {
  2557. margin-top: 0;
  2558. }
  2559. &:nth-child(2n) {
  2560. margin-right: 0;
  2561. }
  2562. .play-button {
  2563. width: 36px;
  2564. height: 36px;
  2565. }
  2566. }
  2567. &.multi {
  2568. width: 42px;
  2569. height: 42px;
  2570. margin-top: 3px;
  2571. margin-right: 3px;
  2572. &:nth-child(1),
  2573. &:nth-child(2),
  2574. &:nth-child(3) {
  2575. margin-top: 0;
  2576. }
  2577. &:nth-child(3n) {
  2578. margin-right: 0;
  2579. }
  2580. .play-button {
  2581. width: 24px;
  2582. height: 24px;
  2583. }
  2584. }
  2585. img {
  2586. width: 100%;
  2587. height: 100%;
  2588. object-fit: cover;
  2589. }
  2590. video {
  2591. width: 100%;
  2592. height: 100%;
  2593. object-fit: cover;
  2594. }
  2595. }
  2596. @keyframes zoom-in {
  2597. 0% {
  2598. transform: scale(0.5);
  2599. opacity: 0;
  2600. }
  2601. 100% {
  2602. transform: scale(1);
  2603. opacity: 1;
  2604. }
  2605. }
  2606. @keyframes slide-up-fade {
  2607. 0% {
  2608. transform: translateY(12px);
  2609. opacity: 0;
  2610. }
  2611. 100% {
  2612. transform: translateY(0);
  2613. opacity: 1;
  2614. }
  2615. }
  2616. </style>