ChatMessages.vue 81 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446
  1. <template>
  2. <div class="chat-messages-container">
  3. <!-- 左侧对话缩略导航 -->
  4. <div
  5. v-if="qaPairs.length > 0"
  6. class="conversation-nav"
  7. ref="navRef"
  8. @mouseenter="onNavMove"
  9. @mousemove="onNavMove"
  10. @mouseleave="onNavLeave"
  11. @scroll="onNavScroll"
  12. >
  13. <div
  14. v-for="(pair, pIdx) in qaPairs"
  15. :key="pIdx"
  16. :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
  17. :style="{ width: `${navLineWidth(pIdx)}px` }"
  18. @click="scrollToPair(pair)"
  19. ></div>
  20. </div>
  21. <Transition name="tooltip-fade">
  22. <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
  23. <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
  24. <div v-if="hoveredPair.question" class="tooltip-row">
  25. <span class="tooltip-label tooltip-label-q">问</span>
  26. <span class="tooltip-text">{{ hoveredPair.question }}</span>
  27. </div>
  28. <div v-if="hoveredPair.answer" class="tooltip-row">
  29. <span class="tooltip-label tooltip-label-a">答</span>
  30. <span class="tooltip-text">{{ hoveredPair.answer }}</span>
  31. </div>
  32. </div>
  33. </Transition>
  34. <div class="chat-messages" ref="messagesRef">
  35. <!-- 新对话缺省图 -->
  36. <div v-if="messages.length === 0" class="welcome-empty">
  37. <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
  38. </div>
  39. <div
  40. v-for="(message, index) in messages"
  41. :key="index"
  42. :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
  43. @mouseleave="copiedMessageIndex = -1"
  44. >
  45. <!-- AI消息 -->
  46. <div v-if="message.type === 'ai'" class="ai-message">
  47. <div class="message-body">
  48. <!-- 思考过程折叠卡片 -->
  49. <div v-if="message.thinkingSteps && message.thinkingSteps.length > 0" class="thinking-card">
  50. <div class="thinking-duration-bar" @click="toggleThinkingCard(index)">
  51. <span v-if="getDurationText(message)" class="thinking-duration">{{ getDurationText(message) }}</span>
  52. <div :class="['thinking-arrow', { expanded: !collapsedThinking[index] }]">
  53. <img :src="getCssUrl('--img-chat-arrow-icon')" width="12" height="12" />
  54. </div>
  55. </div>
  56. <Transition name="collapse">
  57. <div v-show="!collapsedThinking[index]" class="thinking-collapse-wrap">
  58. <div class="thinking-header" @click="toggleThinkingContent(index)">
  59. <div class="thinking-header-left">
  60. <div class="thinking-icon"></div>
  61. <span class="thinking-title">思考过程</span>
  62. </div>
  63. <div v-if="message.thinkingContent" :class="['thinking-arrow', { expanded: !collapsedThinkingContent[index] }]">
  64. <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
  65. </div>
  66. </div>
  67. <!-- thinking_token 思考内容 -->
  68. <Transition name="collapse">
  69. <div v-if="message.thinkingContent && !collapsedThinkingContent[index]" class="thinking-content-body">
  70. {{ message.thinkingContent }}
  71. </div>
  72. </Transition>
  73. <div class="thinking-body">
  74. <div
  75. v-for="(group, groupIdx) in groupStepsByAgent(message.thinkingSteps)"
  76. :key="groupIdx"
  77. class="agent-group"
  78. @click="handleAgentGroupClick(index, group)"
  79. >
  80. <div class="agent-group-header">
  81. <div class="agent-group-header-left">
  82. <div class="agent-group-icon"></div>
  83. <span class="agent-group-name">{{ group.agent }}</span>
  84. </div>
  85. <span v-if="getLatestStepMessage(group)" class="agent-group-latest">
  86. {{ getLatestStepMessage(group) }}
  87. </span>
  88. <div class="thinking-arrow agent-arrow">
  89. <img :src="getCssUrl('--img-chat-next-icon')" width="10" height="10" />
  90. </div>
  91. </div>
  92. </div>
  93. </div>
  94. </div>
  95. </Transition>
  96. </div>
  97. <div
  98. v-if="message.content"
  99. class="message-text"
  100. v-html="
  101. renderMarkdown(
  102. message.content,
  103. tableIcons,
  104. message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined
  105. )
  106. "
  107. @click="handleFootnoteClick($event, index)"
  108. ></div>
  109. <!-- 数据来源卡片 citations -->
  110. <div v-if="message.citations" class="citations-card">
  111. <div v-if="message.citations.model_only" class="citations-header citations-header-simple">
  112. <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
  113. </div>
  114. <template v-else-if="message.citations.items?.length">
  115. <div class="citations-header" @click="toggleCitations(index)">
  116. <div class="citations-header-left">
  117. <span class="citations-title">数据来源</span>
  118. <span class="citations-count">{{ message.citations.items.length }}</span>
  119. </div>
  120. <div :class="['thinking-arrow', { expanded: !collapsedCitations[index] }]">
  121. <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
  122. </div>
  123. </div>
  124. <div v-show="!collapsedCitations[index]" class="citations-body">
  125. <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
  126. <div class="citation-item-row">
  127. <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
  128. <span class="citation-title-text">{{ item.title }}</span>
  129. <span v-if="item.search_url" class="citation-link">知识库检索</span>
  130. <span v-if="item.url" class="citation-link">链接</span>
  131. </div>
  132. <div class="citation-item-attrs">
  133. <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
  134. <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
  135. </div>
  136. <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
  137. </div>
  138. </div>
  139. </template>
  140. </div>
  141. <div v-if="message.wordDownloadUrl" class="word-download-area">
  142. <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
  143. </div>
  144. <!-- 文件操作卡片 -->
  145. <div v-if="message.downloadFiles?.length" class="download-file-cards">
  146. <div v-for="(file, fIdx) in message.downloadFiles" :key="fIdx" class="download-file-card" @click="handlePreviewFile(file)">
  147. <div class="file-card-info">
  148. <div class="file-card-icon" :style="{ backgroundImage: getFileIconVar(file.filename) }"></div>
  149. <div class="file-card-text">
  150. <span class="file-card-name">{{ file.filename }}</span>
  151. <span class="file-card-hint">点击预览文件</span>
  152. </div>
  153. </div>
  154. <div class="file-card-actions">
  155. <button class="file-action-btn download-btn" @click.stop="handleDownloadFile(file.url, file.filename)">
  156. <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
  157. <span>下载</span>
  158. </button>
  159. <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click.stop="handleEditFile(file.url, file.filename)">
  160. <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
  161. <span>编辑</span>
  162. </button>
  163. </div>
  164. </div>
  165. </div>
  166. <div v-show="message.isLoading" class="loading-dots">
  167. <span class="dot"></span>
  168. <span class="dot"></span>
  169. <span class="dot"></span>
  170. </div>
  171. <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
  172. <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length" class="approval-card">
  173. <div class="approval-icon"></div>
  174. <div class="approval-content">
  175. <div class="approval-title">{{ message.pendingApprovalData?.message }}</div>
  176. <!-- 计划内容 -->
  177. <div v-if="message.pendingApprovalData?.plan" class="approval-plan">
  178. <div class="approval-plan-label">执行计划</div>
  179. <div class="approval-plan-content">{{ message.pendingApprovalData.plan }}</div>
  180. </div>
  181. <!-- 步骤指示器 -->
  182. <div v-if="message.pendingApprovalData.ask_user.questions.length > 1" class="ask-user-stepper">
  183. <div
  184. v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
  185. :key="sIdx"
  186. :class="[
  187. 'ask-user-step-dot',
  188. {
  189. active: sIdx === (askUserCurrentStep[index] ?? 0),
  190. completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim()),
  191. },
  192. ]"
  193. @click="askUserCurrentStep[index] = sIdx"
  194. ></div>
  195. <span class="ask-user-step-text"
  196. >{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span
  197. >
  198. </div>
  199. <!-- 当前问题 -->
  200. <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]" class="ask-user-question">
  201. <div class="ask-user-question-text">
  202. {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
  203. <span
  204. v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false"
  205. class="ask-user-required"
  206. >*</span
  207. >
  208. </div>
  209. <!-- multiple_choice 类型 -->
  210. <div
  211. v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'"
  212. class="ask-user-choices"
  213. >
  214. <div
  215. v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
  216. :key="cIdx"
  217. :class="[
  218. 'ask-user-choice',
  219. {
  220. selected:
  221. askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) &&
  222. !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0],
  223. },
  224. ]"
  225. @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
  226. >
  227. <span>{{ getChoiceValue(choice) }}</span>
  228. </div>
  229. <!-- Other 选项 -->
  230. <div
  231. :class="['ask-user-choice', { selected: askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
  232. @click="selectOther(index, askUserCurrentStep[index] ?? 0)"
  233. >
  234. <span>其他</span>
  235. </div>
  236. <input
  237. v-if="askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0]"
  238. class="ask-user-other-input"
  239. placeholder="请输入..."
  240. :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
  241. @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLInputElement).value"
  242. />
  243. </div>
  244. <!-- text 类型 -->
  245. <div
  246. v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'"
  247. class="ask-user-text"
  248. >
  249. <textarea
  250. class="ask-user-textarea"
  251. placeholder="请输入..."
  252. :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
  253. @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLTextAreaElement).value"
  254. ></textarea>
  255. </div>
  256. </div>
  257. <!-- 操作按钮 -->
  258. <div class="approval-actions">
  259. <button v-if="(askUserCurrentStep[index] ?? 0) > 0" class="approval-btn nav-btn" @click="goPrevStep(index)">上一步</button>
  260. <button
  261. v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
  262. class="approval-btn approve-btn"
  263. :disabled="
  264. !isCurrentStepAnswered(
  265. index,
  266. askUserCurrentStep[index] ?? 0,
  267. message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
  268. )
  269. "
  270. @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
  271. >下一步</button
  272. >
  273. <button
  274. v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
  275. class="approval-btn approve-btn"
  276. :disabled="
  277. !isCurrentStepAnswered(
  278. index,
  279. askUserCurrentStep[index] ?? 0,
  280. message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
  281. )
  282. "
  283. @click="handleSubmitAskUser(index)"
  284. >提交</button
  285. >
  286. <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
  287. </div>
  288. </div>
  289. </div>
  290. </div>
  291. <!-- AI消息操作栏 -->
  292. <div class="message-actions">
  293. <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
  294. <img :src="copyIconUrl(index)" width="14" height="14" />
  295. </div>
  296. <div v-if="canRegenerate(index, message)" class="action-btn" @click="emit('regenerate')" title="重新生成">
  297. <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
  298. </div>
  299. <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
  300. <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
  301. </div>
  302. <div v-if="showAnswerSwitcher(index)" class="answer-switcher">
  303. <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrevAnswer(index) }" @click="prevAnswer(index)" title="上一个回答">
  304. <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
  305. </div>
  306. <span class="answer-switcher-text">{{ answerPositionText(index) }}</span>
  307. <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNextAnswer(index) }" @click="nextAnswer(index)" title="下一个回答">
  308. <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
  309. </div>
  310. </div>
  311. <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
  312. </div>
  313. </div>
  314. <!-- 用户消息 -->
  315. <div v-else class="user-message">
  316. <div class="message-body">
  317. <template v-if="editingIndex === index">
  318. <textarea
  319. v-model="editDraft"
  320. class="message-edit-textarea"
  321. spellcheck="false"
  322. @keydown.enter.exact.prevent="submitEdit(index)"
  323. @keydown.escape.prevent="cancelEdit"
  324. ></textarea>
  325. <div class="message-edit-actions">
  326. <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
  327. <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
  328. </div>
  329. </template>
  330. <template v-else>
  331. <div class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
  332. <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
  333. <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
  334. <div class="file-info-card">
  335. <div class="file-name-card">{{ message.attachedFile.name }}</div>
  336. <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
  337. </div>
  338. </div>
  339. </template>
  340. </div>
  341. <!-- 用户消息操作栏 -->
  342. <div class="message-actions">
  343. <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
  344. <img :src="copyIconUrl(index)" width="14" height="14" />
  345. </div>
  346. <div v-if="canEdit(index, message)" class="action-btn" @click="startEdit(index)" title="编辑">
  347. <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
  348. </div>
  349. <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
  350. <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
  351. </div>
  352. <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
  353. </div>
  354. </div>
  355. </div>
  356. </div>
  357. <!-- 回到底部按钮 -->
  358. <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
  359. <div class="scroll-to-bottom-icon"></div>
  360. </div>
  361. </div>
  362. </template>
  363. <script setup lang="ts">
  364. import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
  365. import dayjs from 'dayjs';
  366. import {
  367. renderMarkdown,
  368. formatFileSize,
  369. tableHtmlToMarkdown,
  370. tableHtmlToCsv,
  371. isMdFile,
  372. isTextFile,
  373. getFileIconVar,
  374. fetchPreviewFile,
  375. groupStepsByAgent,
  376. } from './utils';
  377. import type { AgentGroup } from './utils';
  378. import { getToken } from '/@/utils/auth';
  379. import { message } from 'ant-design-vue';
  380. import type { Message, AttachedFile } from './types';
  381. // import katex from 'katex';
  382. // import 'katex/dist/katex.min.css';
  383. // Cache CSS variable lookups to avoid repeated getComputedStyle calls on every render
  384. const cssUrlCache = new Map<string, string>();
  385. const getCssUrl = (varName: string) => {
  386. if (!cssUrlCache.has(varName)) {
  387. const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
  388. cssUrlCache.set(varName, val.replace(/^url\(["']?/, '').replace(/["']?\)$/, ''));
  389. }
  390. return cssUrlCache.get(varName)!;
  391. };
  392. // Clear cache when theme changes (observed via MutationObserver on html class)
  393. const themeObserver = new MutationObserver(() => cssUrlCache.clear());
  394. themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
  395. onBeforeUnmount(() => themeObserver.disconnect());
  396. const tableIcons = {
  397. copy: getCssUrl('--img-chat-copy-icon'),
  398. download: getCssUrl('--img-chat-download-icon-btn'),
  399. preview: getCssUrl('--img-chat-preview-icon'),
  400. copySuccess: getCssUrl('--img-chat-copy-success-icon'),
  401. };
  402. const copyIconUrl = computed(() => {
  403. const icon = getCssUrl('--img-chat-copy-icon');
  404. const successIcon = getCssUrl('--img-chat-copy-success-icon');
  405. return (index: number) => (copiedMessageIndex.value === index ? successIcon : icon);
  406. });
  407. // 全局计时器 - 每秒刷新一次,用于实时显示思考耗时
  408. const nowTs = ref(Date.now());
  409. let timerInterval: ReturnType<typeof setInterval> | null = null;
  410. const getDurationText = (message: Message): string => {
  411. // 优先使用后端返回的 duration_ms(已包含 baseDurationMs 累加)
  412. if (message.durationMs) {
  413. const totalSec = Math.round(message.durationMs / 1000);
  414. const min = Math.floor(totalSec / 60);
  415. const sec = totalSec % 60;
  416. return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
  417. }
  418. // 审批等待中 - 暂停显示中断前的累计时长
  419. if (message.isPendingApproval && message.pendingApprovalData?.elapsedMs) {
  420. const totalSec = Math.round(message.pendingApprovalData.elapsedMs / 1000);
  421. const min = Math.floor(totalSec / 60);
  422. const sec = totalSec % 60;
  423. return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
  424. }
  425. // 前端实时计时(含中断前累计时长)
  426. if (message.generateStartTime && message.isLoading) {
  427. const baseMs = message.baseDurationMs || 0;
  428. const elapsed = Math.floor((nowTs.value - message.generateStartTime + baseMs) / 1000);
  429. const min = Math.floor(elapsed / 60);
  430. const sec = elapsed % 60;
  431. return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
  432. }
  433. return '';
  434. };
  435. const props = defineProps<{
  436. messages: Message[];
  437. }>();
  438. export interface AgentDetailData {
  439. messageIndex: number;
  440. agent: string;
  441. agentId?: string;
  442. }
  443. const emit = defineEmits<{
  444. (e: 'file-preview', file: AttachedFile): void;
  445. (e: 'download-word', url: string): void;
  446. (e: 'open-file-preview', file: AttachedFile): void;
  447. (e: 'ask-user-submit', answers: string[]): void;
  448. (e: 'ask-user-cancel'): void;
  449. (e: 'agent-detail-click', data: AgentDetailData): void;
  450. (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
  451. (e: 'delete-message', message: Message): void;
  452. (e: 'regenerate'): void;
  453. (e: 'edit-resend', index: number, newContent: string): void;
  454. }>();
  455. // 用户消息内联编辑状态
  456. const editingIndex = ref(-1);
  457. const editDraft = ref('');
  458. // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
  459. const stripInjectedHtml = (content: string): string =>
  460. (content || '')
  461. .replace(/\[<img[^>]*>[^\]]*\]/g, '')
  462. .replace(/<img[^>]*>/g, '')
  463. .trim();
  464. // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
  465. interface AnswerGroup {
  466. indices: number[];
  467. }
  468. // 将连续的 AI 消息划分为若干“回答组”
  469. const answerGroups = computed<AnswerGroup[]>(() => {
  470. const groups: AnswerGroup[] = [];
  471. let current: AnswerGroup | null = null;
  472. props.messages.forEach((msg, index) => {
  473. if (msg.type === 'ai') {
  474. if (!current) {
  475. current = { indices: [] };
  476. groups.push(current);
  477. }
  478. current.indices.push(index);
  479. } else {
  480. current = null;
  481. }
  482. });
  483. return groups;
  484. });
  485. // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
  486. const aiGroupMeta = computed(() => {
  487. const meta = new Map<number, { groupKey: number; offset: number; groupLen: number }>();
  488. for (const group of answerGroups.value) {
  489. const groupKey = group.indices[0];
  490. const groupLen = group.indices.length;
  491. group.indices.forEach((idx, offset) => {
  492. meta.set(idx, { groupKey, offset, groupLen });
  493. });
  494. }
  495. return meta;
  496. });
  497. // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
  498. const activeAnswerOffset = reactive<Record<number, number>>({});
  499. const currentAnswerOffset = (groupKey: number, groupLen: number): number => {
  500. const val = activeAnswerOffset[groupKey];
  501. if (val == null) return groupLen - 1;
  502. return Math.min(Math.max(val, 0), groupLen - 1);
  503. };
  504. // 末尾回答组内是否存在正在生成(isLoading)的消息
  505. const lastAnswerGroupHasLoading = computed(() => {
  506. const msgs = props.messages;
  507. if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
  508. let start = msgs.length - 1;
  509. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  510. for (let i = start; i < msgs.length; i++) {
  511. if (msgs[i].isLoading) return true;
  512. }
  513. return false;
  514. });
  515. // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
  516. watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
  517. if (loading && !wasLoading) {
  518. const msgs = props.messages;
  519. let start = msgs.length - 1;
  520. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  521. delete activeAnswerOffset[start];
  522. }
  523. });
  524. // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
  525. const isHiddenAnswer = (index: number): boolean => {
  526. const meta = aiGroupMeta.value.get(index);
  527. if (!meta || meta.groupLen <= 1) return false;
  528. return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
  529. };
  530. // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
  531. const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
  532. // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
  533. const showAnswerSwitcher = (index: number): boolean => {
  534. const meta = aiGroupMeta.value.get(index);
  535. if (!meta || meta.groupLen <= 1) return false;
  536. return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
  537. };
  538. const answerPositionText = (index: number): string => {
  539. const meta = aiGroupMeta.value.get(index);
  540. if (!meta) return '';
  541. return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
  542. };
  543. const canPrevAnswer = (index: number): boolean => {
  544. const meta = aiGroupMeta.value.get(index);
  545. if (!meta) return false;
  546. return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
  547. };
  548. const canNextAnswer = (index: number): boolean => {
  549. const meta = aiGroupMeta.value.get(index);
  550. if (!meta) return false;
  551. return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
  552. };
  553. const prevAnswer = (index: number) => {
  554. const meta = aiGroupMeta.value.get(index);
  555. if (!meta) return;
  556. const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
  557. if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
  558. };
  559. const nextAnswer = (index: number) => {
  560. const meta = aiGroupMeta.value.get(index);
  561. if (!meta) return;
  562. const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
  563. if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
  564. };
  565. // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
  566. const isInLastAnswerGroup = (index: number): boolean => {
  567. const msgs = props.messages;
  568. if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
  569. if (msgs[index]?.type !== 'ai') return false;
  570. let start = msgs.length - 1;
  571. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  572. return index >= start;
  573. };
  574. const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
  575. // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
  576. const lastUserMessageIndex = computed(() => {
  577. for (let i = props.messages.length - 1; i >= 0; i--) {
  578. if (props.messages[i].type === 'user') return i;
  579. }
  580. return -1;
  581. });
  582. const canEdit = (index: number, message: Message): boolean =>
  583. message.type === 'user' && index === lastUserMessageIndex.value && editingIndex.value !== index;
  584. const startEdit = (index: number) => {
  585. const msg = props.messages[index];
  586. editingIndex.value = index;
  587. editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
  588. };
  589. const cancelEdit = () => {
  590. editingIndex.value = -1;
  591. editDraft.value = '';
  592. };
  593. const submitEdit = (index: number) => {
  594. const content = editDraft.value;
  595. editingIndex.value = -1;
  596. editDraft.value = '';
  597. emit('edit-resend', index, content);
  598. };
  599. const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
  600. const handleDownloadFile = (url: string, filename: string) => {
  601. if (!url) return;
  602. const a = document.createElement('a');
  603. a.href = url;
  604. a.download = filename;
  605. document.body.appendChild(a);
  606. a.click();
  607. document.body.removeChild(a);
  608. };
  609. const handleEditFile = async (url: string, filename: string) => {
  610. try {
  611. const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
  612. if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  613. const content = await resp.text();
  614. const ext = filename.split('.').pop()?.toLowerCase() || '';
  615. const mimeMap: Record<string, string> = {
  616. md: 'text/markdown',
  617. txt: 'text/plain',
  618. json: 'application/json',
  619. xml: 'text/xml',
  620. yaml: 'text/yaml',
  621. yml: 'text/yaml',
  622. csv: 'text/csv',
  623. };
  624. const file: AttachedFile = {
  625. id: `report-${url}`,
  626. name: filename,
  627. size: content.length,
  628. type: mimeMap[ext] || 'text/plain',
  629. content,
  630. uploadTime: new Date().toISOString(),
  631. };
  632. emit('open-file-preview', file);
  633. } catch (err) {
  634. console.error('获取文件内容失败:', err);
  635. message.error('获取文件内容失败');
  636. }
  637. };
  638. // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
  639. const handlePreviewFile = async (file: { url: string; filename: string }) => {
  640. if (!file.url) return;
  641. try {
  642. const previewFile = await fetchPreviewFile(file.url, file.filename);
  643. if (!previewFile) {
  644. // 不支持的格式回退为直接下载
  645. handleDownloadFile(file.url, file.filename);
  646. return;
  647. }
  648. emit('file-preview', previewFile);
  649. } catch (err) {
  650. console.error('获取文件内容失败:', err);
  651. message.error('文件预览失败,请尝试下载查看');
  652. }
  653. };
  654. // ask_user 答案状态(按消息索引存储)
  655. const askUserAnswers = reactive<Record<number, string[]>>({});
  656. const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
  657. const askUserCurrentStep = reactive<Record<number, number>>({});
  658. // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
  659. const getChoiceValue = (choice: any): string => {
  660. if (typeof choice === 'string') return choice;
  661. if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
  662. return String(choice);
  663. };
  664. const initAskUserAnswers = (index: number, questions: { question: string; type: string; choices?: any[] }[]) => {
  665. // Always reinitialize when called - handles repeated interrupts from backend
  666. askUserAnswers[index] = questions.map(() => '');
  667. askUserOtherSelected[index] = questions.map(() => false);
  668. askUserCurrentStep[index] = 0;
  669. };
  670. // Track the last pendingApprovalData reference to detect new interrupts
  671. const lastApprovalDataRef = new Map<number, any>();
  672. // 自动初始化 ask_user 答案状态。
  673. // 必须注册在 initAskUserAnswers 等 const 声明之后:immediate watcher 在 setup 阶段同步执行,
  674. // 若带审批消息重新挂载(关闭弹框后再打开),回调会访问尚未初始化的变量(TDZ 报错导致整个弹框挂载失败)
  675. watch(
  676. () => props.messages,
  677. (msgs) => {
  678. msgs.forEach((msg, idx) => {
  679. if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
  680. // Only reinitialize if pendingApprovalData object reference changed (new interrupt)
  681. const lastRef = lastApprovalDataRef.get(idx);
  682. if (lastRef !== msg.pendingApprovalData) {
  683. lastApprovalDataRef.set(idx, msg.pendingApprovalData);
  684. initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
  685. }
  686. } else {
  687. lastApprovalDataRef.delete(idx);
  688. }
  689. });
  690. },
  691. { immediate: true, deep: true }
  692. );
  693. const isCurrentStepAnswered = (msgIndex: number, qIndex: number, question: { required?: boolean; type: string }) => {
  694. const answers = askUserAnswers[msgIndex];
  695. if (!answers) return false;
  696. if (question.required === false && !answers[qIndex]) return true;
  697. return !!(answers[qIndex] && answers[qIndex].trim() !== '');
  698. };
  699. const goNextStep = (msgIndex: number, totalQuestions: number) => {
  700. if (askUserCurrentStep[msgIndex] < totalQuestions - 1) {
  701. askUserCurrentStep[msgIndex]++;
  702. }
  703. };
  704. const goPrevStep = (msgIndex: number) => {
  705. if (askUserCurrentStep[msgIndex] > 0) {
  706. askUserCurrentStep[msgIndex]--;
  707. }
  708. };
  709. const selectChoice = (msgIndex: number, qIndex: number, choice: any, totalQuestions: number) => {
  710. if (!askUserAnswers[msgIndex]) return;
  711. askUserAnswers[msgIndex][qIndex] = getChoiceValue(choice);
  712. askUserOtherSelected[msgIndex][qIndex] = false;
  713. // 单选自动跳到下一题(非最后一题时)
  714. if (qIndex < totalQuestions - 1) {
  715. goNextStep(msgIndex, totalQuestions);
  716. }
  717. };
  718. const selectOther = (msgIndex: number, qIndex: number) => {
  719. if (!askUserOtherSelected[msgIndex]) return;
  720. askUserOtherSelected[msgIndex][qIndex] = true;
  721. askUserAnswers[msgIndex][qIndex] = '';
  722. };
  723. const handleSubmitAskUser = (index: number) => {
  724. // 防御性初始化:如果答案数组未初始化,从 pendingApprovalData 回退
  725. if (!askUserAnswers[index]) {
  726. const msg = props.messages[index];
  727. if (msg?.pendingApprovalData?.ask_user?.questions) {
  728. initAskUserAnswers(index, msg.pendingApprovalData.ask_user.questions);
  729. } else {
  730. return;
  731. }
  732. }
  733. const answers = askUserAnswers[index];
  734. if (!answers) return;
  735. emit('ask-user-submit', [...answers]);
  736. };
  737. const handleCancelAskUser = () => {
  738. emit('ask-user-cancel');
  739. };
  740. const messagesRef = ref<HTMLElement>();
  741. const collapsedThinking = reactive<Record<number, boolean>>({});
  742. const collapsedThinkingContent = reactive<Record<number, boolean>>({});
  743. const collapsedCitations = reactive<Record<number, boolean>>({});
  744. const isAtBottom = ref(true);
  745. const copiedMessageIndex = ref(-1);
  746. const handleTableAction = (e: Event) => {
  747. const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
  748. if (!target) return;
  749. const action = target.dataset.action;
  750. const encodedTable = target.dataset.table;
  751. if (!action || !encodedTable) return;
  752. const tableHtml = decodeURIComponent(encodedTable);
  753. switch (action) {
  754. case 'copy': {
  755. const markdown = tableHtmlToMarkdown(tableHtml);
  756. const doCopy = async () => {
  757. try {
  758. await navigator.clipboard.writeText(markdown);
  759. } catch {
  760. const ta = document.createElement('textarea');
  761. ta.value = markdown;
  762. ta.style.position = 'fixed';
  763. ta.style.opacity = '0';
  764. document.body.appendChild(ta);
  765. ta.select();
  766. document.execCommand('copy');
  767. document.body.removeChild(ta);
  768. }
  769. };
  770. doCopy().then(() => {
  771. const btn = target;
  772. const img = btn.querySelector('img');
  773. const successIcon = btn.dataset.iconSuccess;
  774. const originalIcon = btn.dataset.icon;
  775. if (img && successIcon) {
  776. img.src = successIcon;
  777. btn.title = '复制成功';
  778. const reset = () => {
  779. img.src = originalIcon!;
  780. btn.title = '复制Markdown';
  781. wrapper.removeEventListener('mouseleave', reset);
  782. };
  783. const wrapper = btn.closest('.markdown-table-wrapper')!;
  784. wrapper.addEventListener('mouseleave', reset);
  785. }
  786. });
  787. break;
  788. }
  789. case 'csv': {
  790. const csv = tableHtmlToCsv(tableHtml);
  791. const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  792. const url = URL.createObjectURL(blob);
  793. const a = document.createElement('a');
  794. a.href = url;
  795. a.download = `table_${Date.now()}.csv`;
  796. a.click();
  797. URL.revokeObjectURL(url);
  798. break;
  799. }
  800. case 'preview': {
  801. // 在右侧面板新建标签页展示表格渲染效果
  802. emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
  803. break;
  804. }
  805. }
  806. };
  807. const checkScrollBottom = () => {
  808. const el = messagesRef.value;
  809. if (!el) return;
  810. const threshold = 50;
  811. isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
  812. };
  813. const scrollToBottom = () => {
  814. const el = messagesRef.value;
  815. if (el) {
  816. el.scrollTop = el.scrollHeight - el.clientHeight;
  817. }
  818. };
  819. const forceScrollToBottom = () => {
  820. const el = messagesRef.value;
  821. if (el) {
  822. el.scrollTop = el.scrollHeight - el.clientHeight;
  823. // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
  824. requestAnimationFrame(() => {
  825. el.scrollTop = el.scrollHeight - el.clientHeight;
  826. });
  827. }
  828. isAtBottom.value = true;
  829. };
  830. onMounted(() => {
  831. messagesRef.value?.addEventListener('click', handleTableAction);
  832. messagesRef.value?.addEventListener('scroll', checkScrollBottom);
  833. timerInterval = setInterval(() => {
  834. nowTs.value = Date.now();
  835. }, 1000);
  836. });
  837. onBeforeUnmount(() => {
  838. messagesRef.value?.removeEventListener('click', handleTableAction);
  839. messagesRef.value?.removeEventListener('scroll', checkScrollBottom);
  840. if (timerInterval) {
  841. clearInterval(timerInterval);
  842. timerInterval = null;
  843. }
  844. });
  845. const toggleThinkingCard = (index: number) => {
  846. collapsedThinking[index] = !collapsedThinking[index];
  847. };
  848. const toggleThinkingContent = (index: number) => {
  849. collapsedThinkingContent[index] = !collapsedThinkingContent[index];
  850. };
  851. const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
  852. emit('agent-detail-click', {
  853. messageIndex,
  854. agent: group.agent,
  855. agentId: group.agentId,
  856. });
  857. };
  858. const toggleCitations = (index: number) => {
  859. collapsedCitations[index] = !collapsedCitations[index];
  860. };
  861. const handleFootnoteClick = (e: Event, msgIndex: number) => {
  862. const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
  863. if (!target) return;
  864. const n = target.dataset.n;
  865. if (!n) return;
  866. const el = document.getElementById(`citation-${msgIndex}-${n}`);
  867. if (el) {
  868. scrollMessagesTo(el, 'center');
  869. el.classList.add('citation-highlight');
  870. setTimeout(() => el.classList.remove('citation-highlight'), 1500);
  871. }
  872. };
  873. const getLatestStepMessage = (group: AgentGroup): string => {
  874. if (group.steps.length === 0) return '';
  875. const last = group.steps[group.steps.length - 1];
  876. if (last.type === 'executing') return last.message || '正在执行...';
  877. if (last.type === 'tool_call') return last.message || last.tool || '';
  878. if (last.type === 'tool_result') return last.message || '';
  879. if (last.type === 'agent_start') return last.message || '';
  880. if (last.type === 'agent_done') return last.message || '';
  881. if (last.type === 'updated_todo_list') return '任务列表已更新';
  882. return '';
  883. };
  884. const formatFullTime = (createdAt: string) => {
  885. return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
  886. };
  887. const copyMessageContent = async (content: string, index: number) => {
  888. if (!content) return;
  889. try {
  890. await navigator.clipboard.writeText(content);
  891. copiedMessageIndex.value = index;
  892. } catch {
  893. const textarea = document.createElement('textarea');
  894. textarea.value = content;
  895. textarea.style.position = 'fixed';
  896. textarea.style.opacity = '0';
  897. document.body.appendChild(textarea);
  898. textarea.select();
  899. document.execCommand('copy');
  900. document.body.removeChild(textarea);
  901. copiedMessageIndex.value = index;
  902. }
  903. };
  904. // ===== 左侧对话缩略导航 =====
  905. interface QaPair {
  906. startIndex: number;
  907. question: string;
  908. answer: string;
  909. }
  910. const toPlainText = (md: string): string =>
  911. md
  912. .replace(/```[\s\S]*?```/g, ' [代码] ')
  913. .replace(/`[^`]*`/g, ' ')
  914. .replace(/<[^>]+>/g, '')
  915. .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
  916. .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
  917. .replace(/^#{1,6}\s*/gm, '')
  918. .replace(/[*_~>|]/g, '')
  919. .replace(/\s+/g, ' ')
  920. .trim();
  921. const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
  922. // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
  923. const qaPairs = computed<QaPair[]>(() => {
  924. const pairs: QaPair[] = [];
  925. let current: QaPair | null = null;
  926. props.messages.forEach((msg, index) => {
  927. if (msg.type === 'user') {
  928. if (current) pairs.push(current);
  929. current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
  930. } else {
  931. if (!current) current = { startIndex: index, question: '', answer: '' };
  932. const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
  933. if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
  934. }
  935. });
  936. if (current) pairs.push(current);
  937. return pairs;
  938. });
  939. const navRef = ref<HTMLElement>();
  940. const tooltipRef = ref<HTMLElement>();
  941. const hoveredPair = ref<QaPair | null>(null);
  942. const tooltipTop = ref(0);
  943. const tooltipArrowTop = ref(0);
  944. const activeMessage = ref<Message | null>(null);
  945. // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
  946. const navMouseY = ref<number | null>(null);
  947. // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
  948. const lineCenters = ref<number[]>([]);
  949. const updateLineCenters = () => {
  950. const nav = navRef.value;
  951. if (!nav) {
  952. lineCenters.value = [];
  953. return;
  954. }
  955. const centers: number[] = [];
  956. nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
  957. lineCenters.value = centers;
  958. };
  959. // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形
  960. const navLineWidth = (pIdx: number): number => {
  961. const mouseY = navMouseY.value;
  962. if (mouseY === null) return 10;
  963. const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
  964. return 10 + 8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
  965. };
  966. const nearestLineIndex = computed(() => {
  967. const mouseY = navMouseY.value;
  968. if (mouseY === null) return -1;
  969. let best = -1;
  970. let bestDist = Infinity;
  971. lineCenters.value.forEach((center, i) => {
  972. const dist = Math.abs(center - mouseY);
  973. if (dist < bestDist) {
  974. bestDist = dist;
  975. best = i;
  976. }
  977. });
  978. return best;
  979. });
  980. let lastTooltipIndex = -1;
  981. const applyTooltip = async (pIdx: number) => {
  982. const pair = qaPairs.value[pIdx];
  983. if (!pair) {
  984. hoveredPair.value = null;
  985. return;
  986. }
  987. hoveredPair.value = pair;
  988. await nextTick();
  989. const tooltip = tooltipRef.value;
  990. const nav = navRef.value;
  991. const container = nav?.parentElement as HTMLElement | null;
  992. if (!tooltip || !nav || !container) return;
  993. const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
  994. const tooltipH = tooltip.offsetHeight;
  995. const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
  996. tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
  997. tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
  998. };
  999. const onNavMove = (e: MouseEvent) => {
  1000. const nav = navRef.value;
  1001. if (!nav) return;
  1002. // 横线数量变化(新增问答/首次悬浮)时重建坐标
  1003. if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
  1004. // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
  1005. // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
  1006. const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
  1007. navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
  1008. const nearest = nearestLineIndex.value;
  1009. if (nearest >= 0 && nearest !== lastTooltipIndex) {
  1010. lastTooltipIndex = nearest;
  1011. applyTooltip(nearest);
  1012. }
  1013. };
  1014. const onNavLeave = () => {
  1015. navMouseY.value = null;
  1016. hoveredPair.value = null;
  1017. lastTooltipIndex = -1;
  1018. };
  1019. const onNavScroll = () => {
  1020. updateLineCenters();
  1021. if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
  1022. };
  1023. // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
  1024. const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
  1025. const container = messagesRef.value;
  1026. if (!container) return;
  1027. const top = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
  1028. const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
  1029. container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
  1030. };
  1031. const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
  1032. const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
  1033. let el = wrappers?.[index] as HTMLElement | undefined;
  1034. // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
  1035. if (el && el.offsetParent === null) {
  1036. el = undefined;
  1037. for (let i = index - 1; i >= 0; i--) {
  1038. const w = wrappers?.[i] as HTMLElement | undefined;
  1039. if (w && w.offsetParent !== null) {
  1040. el = w;
  1041. break;
  1042. }
  1043. }
  1044. }
  1045. if (el) scrollMessagesTo(el, block);
  1046. };
  1047. const scrollToPair = (pair: QaPair) => {
  1048. activeMessage.value = props.messages[pair.startIndex];
  1049. scrollToMessage(pair.startIndex, 'start');
  1050. const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
  1051. const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
  1052. el?.classList.add('nav-located-highlight');
  1053. setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
  1054. };
  1055. defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
  1056. </script>
  1057. <style scoped lang="less">
  1058. .chat-messages-container {
  1059. flex: 1;
  1060. position: relative;
  1061. display: flex;
  1062. flex-direction: row;
  1063. overflow: hidden;
  1064. }
  1065. .conversation-nav {
  1066. position: relative;
  1067. width: 28px;
  1068. flex-shrink: 0;
  1069. display: flex;
  1070. flex-direction: column;
  1071. align-items: center;
  1072. overflow-y: auto;
  1073. padding: 6px 0;
  1074. scrollbar-width: none;
  1075. &::-webkit-scrollbar {
  1076. display: none;
  1077. }
  1078. .nav-line {
  1079. width: 10px;
  1080. height: 3px;
  1081. border-radius: 2px;
  1082. background: rgba(139, 148, 158, 0.4);
  1083. margin: 5px 0;
  1084. flex-shrink: 0;
  1085. cursor: pointer;
  1086. transition:
  1087. width 0.15s ease-out,
  1088. background 0.2s;
  1089. &:first-child {
  1090. margin-top: auto;
  1091. }
  1092. &:last-child {
  1093. margin-bottom: auto;
  1094. }
  1095. &.nav-line-hot {
  1096. background: #0a84ff;
  1097. }
  1098. &.active {
  1099. background: #0a84ff;
  1100. }
  1101. }
  1102. }
  1103. .conversation-tooltip {
  1104. position: absolute;
  1105. left: 36px;
  1106. width: 260px;
  1107. background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
  1108. border: 1px solid #1a4a6f;
  1109. border-radius: 8px;
  1110. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  1111. padding: 10px 12px;
  1112. z-index: 20;
  1113. pointer-events: none;
  1114. .tooltip-arrow {
  1115. position: absolute;
  1116. left: -6px;
  1117. width: 10px;
  1118. height: 10px;
  1119. transform: rotate(45deg);
  1120. background: #0d2137;
  1121. border-left: 1px solid #1a4a6f;
  1122. border-bottom: 1px solid #1a4a6f;
  1123. }
  1124. .tooltip-row {
  1125. display: flex;
  1126. align-items: flex-start;
  1127. gap: 8px;
  1128. font-size: 12px;
  1129. line-height: 1.5;
  1130. & + .tooltip-row {
  1131. margin-top: 8px;
  1132. padding-top: 8px;
  1133. border-top: 1px solid rgba(63, 80, 106, 0.3);
  1134. }
  1135. .tooltip-label {
  1136. flex-shrink: 0;
  1137. width: 16px;
  1138. height: 16px;
  1139. line-height: 16px;
  1140. margin-top: 1px;
  1141. text-align: center;
  1142. border-radius: 3px;
  1143. font-size: 11px;
  1144. &-q {
  1145. color: #79c0ff;
  1146. background: rgba(10, 132, 255, 0.15);
  1147. }
  1148. &-a {
  1149. color: #8b949e;
  1150. background: rgba(139, 148, 158, 0.15);
  1151. }
  1152. }
  1153. .tooltip-text {
  1154. color: #c9d1d9;
  1155. word-break: break-word;
  1156. }
  1157. }
  1158. }
  1159. .tooltip-fade-enter-active,
  1160. .tooltip-fade-leave-active {
  1161. transition: opacity 0.15s ease;
  1162. }
  1163. .tooltip-fade-enter-from,
  1164. .tooltip-fade-leave-to {
  1165. opacity: 0;
  1166. }
  1167. .chat-messages {
  1168. flex: 1;
  1169. overflow-y: auto;
  1170. padding: 20px;
  1171. position: relative;
  1172. .welcome-empty {
  1173. position: absolute;
  1174. top: 50%;
  1175. left: 50%;
  1176. transform: translate(-50%, -50%);
  1177. display: flex;
  1178. align-items: center;
  1179. justify-content: center;
  1180. .welcome-img {
  1181. max-width: 300px;
  1182. max-height: 300px;
  1183. object-fit: contain;
  1184. }
  1185. }
  1186. .message-wrapper {
  1187. // margin-bottom: 20px;
  1188. &.answer-hidden {
  1189. display: none;
  1190. }
  1191. &.nav-located-highlight {
  1192. background: rgba(10, 132, 255, 0.08);
  1193. border-radius: 6px;
  1194. }
  1195. .ai-message {
  1196. display: flex;
  1197. flex-wrap: wrap;
  1198. .message-agent {
  1199. display: flex;
  1200. margin-bottom: 8px;
  1201. opacity: 0;
  1202. }
  1203. .message-body {
  1204. padding: 12px 12px 4px 12px;
  1205. border-radius: 6px;
  1206. width: 100%;
  1207. min-width: 0;
  1208. overflow: hidden;
  1209. /* 思考过程卡片 */
  1210. .thinking-card {
  1211. margin-bottom: 12px;
  1212. overflow: hidden;
  1213. .thinking-duration-bar {
  1214. display: flex;
  1215. align-items: center;
  1216. justify-content: space-between;
  1217. padding: 8px 14px;
  1218. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  1219. cursor: pointer;
  1220. user-select: none;
  1221. transition: background 0.2s;
  1222. &:hover {
  1223. background: rgba(10, 132, 255, 0.12);
  1224. }
  1225. .thinking-duration {
  1226. font-size: 16px;
  1227. color: #8b949e;
  1228. }
  1229. .thinking-arrow {
  1230. color: #8b949e;
  1231. display: flex;
  1232. align-items: center;
  1233. transition: transform 0.2s;
  1234. &.expanded {
  1235. transform: rotate(180deg);
  1236. }
  1237. }
  1238. }
  1239. .thinking-header {
  1240. display: flex;
  1241. align-items: center;
  1242. justify-content: space-between;
  1243. padding: 10px 14px;
  1244. cursor: pointer;
  1245. user-select: none;
  1246. transition: background 0.2s;
  1247. &:hover {
  1248. background: rgba(10, 132, 255, 0.05);
  1249. }
  1250. .thinking-header-left {
  1251. display: flex;
  1252. align-items: center;
  1253. gap: 8px;
  1254. }
  1255. .thinking-arrow {
  1256. display: flex;
  1257. align-items: center;
  1258. transition: transform 0.2s;
  1259. &.expanded {
  1260. transform: rotate(180deg);
  1261. }
  1262. }
  1263. .thinking-icon {
  1264. width: 16px;
  1265. height: 16px;
  1266. background-image: var(--img-chat-thinking-icon);
  1267. background-repeat: no-repeat;
  1268. background-size: 100% 100%;
  1269. }
  1270. .thinking-title {
  1271. font-size: 13px;
  1272. font-weight: 500;
  1273. color: #8b949e;
  1274. }
  1275. .thinking-count {
  1276. font-size: 12px;
  1277. color: #8b949e;
  1278. background: rgba(10, 132, 255, 0.1);
  1279. padding: 1px 6px;
  1280. border-radius: 10px;
  1281. }
  1282. }
  1283. .thinking-content-body {
  1284. margin: 0 14px 8px;
  1285. padding: 8px 12px;
  1286. max-height: 200px;
  1287. overflow-y: auto;
  1288. font-size: 13px;
  1289. line-height: 1.6;
  1290. color: #8b949e;
  1291. white-space: pre-wrap;
  1292. word-break: break-word;
  1293. background: rgba(10, 13, 23, 0.3);
  1294. border-radius: 6px;
  1295. }
  1296. .thinking-body {
  1297. padding: 8px 14px;
  1298. .agent-group {
  1299. margin-bottom: 8px;
  1300. border: 1px solid transparent;
  1301. border-radius: 6px;
  1302. overflow: hidden;
  1303. transition: border-color 0.2s;
  1304. cursor: pointer;
  1305. &:hover {
  1306. border-color: rgba(63, 80, 106, 0.25);
  1307. background: rgba(10, 132, 255, 0.05);
  1308. }
  1309. &:last-child {
  1310. margin-bottom: 0;
  1311. }
  1312. .agent-group-header {
  1313. display: flex;
  1314. align-items: center;
  1315. justify-content: space-between;
  1316. padding: 8px 12px;
  1317. user-select: none;
  1318. .agent-group-header-left {
  1319. display: flex;
  1320. align-items: center;
  1321. gap: 8px;
  1322. }
  1323. .agent-group-icon {
  1324. width: 14px;
  1325. height: 14px;
  1326. background-image: var(--img-chat-agent-icon);
  1327. background-repeat: no-repeat;
  1328. background-size: 100% 100%;
  1329. flex-shrink: 0;
  1330. }
  1331. .agent-group-name {
  1332. font-size: 13px;
  1333. font-weight: 500;
  1334. color: #fff;
  1335. }
  1336. .agent-group-latest {
  1337. font-size: 12px;
  1338. color: #8b949e;
  1339. margin-left: 8px;
  1340. overflow: hidden;
  1341. text-overflow: ellipsis;
  1342. white-space: nowrap;
  1343. flex: 1;
  1344. min-width: 0;
  1345. }
  1346. .agent-arrow {
  1347. margin-left: 8px;
  1348. flex-shrink: 0;
  1349. display: flex;
  1350. align-items: center;
  1351. }
  1352. }
  1353. }
  1354. }
  1355. }
  1356. .collapse-enter-active,
  1357. .collapse-leave-active {
  1358. transition: all 0.25s ease;
  1359. overflow: hidden;
  1360. }
  1361. .collapse-enter-from,
  1362. .collapse-leave-to {
  1363. opacity: 0;
  1364. max-height: 0;
  1365. padding-top: 0;
  1366. padding-bottom: 0;
  1367. margin-top: 0;
  1368. margin-bottom: 0;
  1369. }
  1370. .collapse-enter-to,
  1371. .collapse-leave-from {
  1372. opacity: 1;
  1373. max-height: 500px;
  1374. }
  1375. .message-text {
  1376. font-size: 14px;
  1377. font-weight: 400;
  1378. line-height: 1.6;
  1379. color: #e0e6ed;
  1380. word-break: break-word;
  1381. overflow-wrap: break-word;
  1382. :deep(.session-ref-icon) {
  1383. width: 14px;
  1384. height: 14px;
  1385. vertical-align: -2px;
  1386. }
  1387. :deep(h1),
  1388. :deep(h2),
  1389. :deep(h3),
  1390. :deep(h4),
  1391. :deep(h5),
  1392. :deep(h6) {
  1393. color: #fff;
  1394. }
  1395. :deep(p) {
  1396. margin: 0;
  1397. }
  1398. :deep(code) {
  1399. background: rgba(10, 132, 255, 0.1);
  1400. padding: 1px 4px;
  1401. border-radius: 3px;
  1402. font-size: 12px;
  1403. }
  1404. :deep(pre) {
  1405. background: rgba(10, 132, 255, 0.1);
  1406. padding: 8px;
  1407. border-radius: 4px;
  1408. overflow-x: auto;
  1409. margin: 6px 0;
  1410. white-space: pre-wrap;
  1411. }
  1412. :deep(pre code) {
  1413. background: transparent;
  1414. padding: 0;
  1415. }
  1416. :deep(ul),
  1417. :deep(ol) {
  1418. padding-left: 18px;
  1419. margin: 4px 0;
  1420. }
  1421. :deep(blockquote) {
  1422. border-left: 3px solid rgba(10, 132, 255, 0.3);
  1423. padding-left: 10px;
  1424. margin: 6px 0;
  1425. color: #8b949e;
  1426. }
  1427. :deep(table) {
  1428. border-collapse: collapse;
  1429. margin: 6px 0;
  1430. }
  1431. :deep(th),
  1432. :deep(td) {
  1433. border: 1px solid rgba(63, 80, 106, 0.5);
  1434. padding: 4px 8px;
  1435. }
  1436. :deep(th) {
  1437. background: rgba(10, 132, 255, 0.1);
  1438. }
  1439. :deep(strong) {
  1440. font-weight: 600;
  1441. }
  1442. :deep(.markdown-table-wrapper) {
  1443. position: relative;
  1444. margin: 6px 0;
  1445. max-width: 100%;
  1446. overflow-x: auto;
  1447. .table-actions {
  1448. position: absolute;
  1449. top: 4px;
  1450. right: 4px;
  1451. display: flex;
  1452. gap: 2px;
  1453. opacity: 0;
  1454. transition: opacity 0.2s;
  1455. z-index: 1;
  1456. }
  1457. &:hover .table-actions {
  1458. opacity: 1;
  1459. }
  1460. .table-action-btn {
  1461. width: 26px;
  1462. height: 26px;
  1463. display: flex;
  1464. align-items: center;
  1465. justify-content: center;
  1466. border-radius: 4px;
  1467. color: #8b949e;
  1468. cursor: pointer;
  1469. background: rgba(22, 27, 34, 0.8);
  1470. backdrop-filter: blur(4px);
  1471. transition: all 0.2s;
  1472. &:hover {
  1473. color: #e0e6ed;
  1474. background: rgba(10, 132, 255, 0.3);
  1475. }
  1476. }
  1477. }
  1478. }
  1479. }
  1480. .loading-dots {
  1481. display: flex;
  1482. align-items: center;
  1483. gap: 6px;
  1484. padding: 4px 0;
  1485. min-height: 16px;
  1486. .dot {
  1487. width: 8px;
  1488. height: 8px;
  1489. border-radius: 50%;
  1490. background: rgba(10, 132, 255, 0.4);
  1491. animation: dot-bounce 1.4s ease-in-out infinite both;
  1492. &:nth-child(1) {
  1493. animation-delay: 0s;
  1494. }
  1495. &:nth-child(2) {
  1496. animation-delay: 0.2s;
  1497. }
  1498. &:nth-child(3) {
  1499. animation-delay: 0.4s;
  1500. }
  1501. }
  1502. }
  1503. @keyframes dot-bounce {
  1504. 0%,
  1505. 80%,
  1506. 100% {
  1507. transform: scale(0.6);
  1508. opacity: 0.4;
  1509. }
  1510. 40% {
  1511. transform: scale(1);
  1512. opacity: 1;
  1513. }
  1514. }
  1515. .approval-card {
  1516. display: flex;
  1517. align-items: flex-start;
  1518. gap: 12px;
  1519. padding: 14px 16px;
  1520. margin-top: 12px;
  1521. background: #002f4a;
  1522. border: 1px solid #005482;
  1523. border-radius: 8px;
  1524. .approval-icon {
  1525. width: 36px;
  1526. height: 36px;
  1527. background-image: var(--img-chat-session-approve-icon);
  1528. background-repeat: no-repeat;
  1529. background-size: 100% 100%;
  1530. flex-shrink: 0;
  1531. }
  1532. .approval-content {
  1533. flex: 1;
  1534. .approval-title {
  1535. font-size: 14px;
  1536. font-weight: 600;
  1537. color: #e0e6ed;
  1538. margin-bottom: 6px;
  1539. }
  1540. .approval-plan {
  1541. margin-bottom: 14px;
  1542. padding: 10px 12px;
  1543. background: rgba(10, 132, 255, 0.08);
  1544. border: 1px solid rgba(10, 132, 255, 0.2);
  1545. border-radius: 6px;
  1546. .approval-plan-label {
  1547. font-size: 12px;
  1548. color: #0a84ff;
  1549. font-weight: 600;
  1550. margin-bottom: 6px;
  1551. }
  1552. .approval-plan-content {
  1553. font-size: 13px;
  1554. color: #c9d1d9;
  1555. line-height: 1.6;
  1556. white-space: pre-wrap;
  1557. }
  1558. }
  1559. .ask-user-question {
  1560. margin-bottom: 14px;
  1561. .ask-user-question-text {
  1562. font-size: 13px;
  1563. color: #c9d1d9;
  1564. margin-bottom: 8px;
  1565. line-height: 1.5;
  1566. .ask-user-required {
  1567. color: #f85149;
  1568. margin-left: 2px;
  1569. }
  1570. }
  1571. .ask-user-choices {
  1572. display: flex;
  1573. flex-direction: column;
  1574. gap: 6px;
  1575. .ask-user-choice {
  1576. display: flex;
  1577. align-items: center;
  1578. padding: 8px 12px;
  1579. background: rgba(255, 255, 255, 0.04);
  1580. border: 1px solid rgba(63, 80, 106, 0.4);
  1581. border-radius: 6px;
  1582. cursor: pointer;
  1583. font-size: 13px;
  1584. color: #c9d1d9;
  1585. transition: all 0.2s;
  1586. &:hover {
  1587. background: rgba(10, 132, 255, 0.1);
  1588. border-color: rgba(10, 132, 255, 0.4);
  1589. }
  1590. &.selected {
  1591. background: rgba(10, 132, 255, 0.15);
  1592. border-color: #0a84ff;
  1593. color: #fff;
  1594. }
  1595. }
  1596. .ask-user-other-input {
  1597. margin-top: 4px;
  1598. padding: 8px 12px;
  1599. font-size: 13px;
  1600. color: #e0e6ed;
  1601. background: rgba(255, 255, 255, 0.06);
  1602. border: 1px solid rgba(63, 80, 106, 0.5);
  1603. border-radius: 6px;
  1604. outline: none;
  1605. width: 100%;
  1606. &:focus {
  1607. border-color: #0a84ff;
  1608. background: rgba(10, 132, 255, 0.08);
  1609. }
  1610. &::placeholder {
  1611. color: #6e7681;
  1612. }
  1613. }
  1614. }
  1615. .ask-user-text {
  1616. .ask-user-textarea {
  1617. width: 100%;
  1618. min-height: 60px;
  1619. padding: 8px 12px;
  1620. font-size: 13px;
  1621. color: #e0e6ed;
  1622. background: rgba(255, 255, 255, 0.06);
  1623. border: 1px solid rgba(63, 80, 106, 0.5);
  1624. border-radius: 6px;
  1625. outline: none;
  1626. resize: vertical;
  1627. font-family: inherit;
  1628. &:focus {
  1629. border-color: #0a84ff;
  1630. background: rgba(10, 132, 255, 0.08);
  1631. }
  1632. &::placeholder {
  1633. color: #6e7681;
  1634. }
  1635. }
  1636. }
  1637. }
  1638. .ask-user-stepper {
  1639. display: flex;
  1640. align-items: center;
  1641. gap: 6px;
  1642. margin-bottom: 14px;
  1643. .ask-user-step-dot {
  1644. width: 8px;
  1645. height: 8px;
  1646. border-radius: 50%;
  1647. background: rgba(63, 80, 106, 0.5);
  1648. cursor: pointer;
  1649. transition: all 0.2s;
  1650. &.active {
  1651. background: #0a84ff;
  1652. width: 20px;
  1653. border-radius: 4px;
  1654. }
  1655. &.completed {
  1656. background: rgba(10, 132, 255, 0.5);
  1657. }
  1658. }
  1659. .ask-user-step-text {
  1660. margin-left: 6px;
  1661. font-size: 12px;
  1662. color: #6e7681;
  1663. }
  1664. }
  1665. .approval-actions {
  1666. display: flex;
  1667. gap: 10px;
  1668. .approval-btn {
  1669. padding: 6px 20px;
  1670. border-radius: 6px;
  1671. font-size: 13px;
  1672. font-weight: 500;
  1673. cursor: pointer;
  1674. border: none;
  1675. transition: all 0.2s;
  1676. &.approve-btn {
  1677. background: #0a84ff;
  1678. color: #fff;
  1679. &:hover:not(:disabled) {
  1680. background: #3a9bfd;
  1681. }
  1682. &:disabled {
  1683. opacity: 0.4;
  1684. cursor: not-allowed;
  1685. }
  1686. }
  1687. &.nav-btn {
  1688. background: rgba(255, 255, 255, 0.08);
  1689. color: #c9d1d9;
  1690. border: 1px solid rgba(63, 80, 106, 0.5);
  1691. &:hover {
  1692. background: rgba(255, 255, 255, 0.15);
  1693. }
  1694. }
  1695. &.reject-btn {
  1696. background: rgba(255, 255, 255, 0.08);
  1697. color: #e0e6ed;
  1698. border: 1px solid rgba(63, 80, 106, 0.5);
  1699. &:hover {
  1700. background: rgba(255, 255, 255, 0.15);
  1701. }
  1702. }
  1703. }
  1704. }
  1705. }
  1706. }
  1707. .citations-card {
  1708. overflow: hidden;
  1709. .citations-header {
  1710. display: flex;
  1711. align-items: center;
  1712. justify-content: space-between;
  1713. padding: 8px 14px;
  1714. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  1715. cursor: pointer;
  1716. user-select: none;
  1717. transition: background 0.2s;
  1718. &:hover {
  1719. background: rgba(10, 132, 255, 0.08);
  1720. }
  1721. &.citations-header-simple {
  1722. cursor: default;
  1723. &:hover {
  1724. background: transparent;
  1725. }
  1726. }
  1727. .citations-header-left {
  1728. display: flex;
  1729. align-items: center;
  1730. gap: 8px;
  1731. }
  1732. .citations-title {
  1733. font-size: 13px;
  1734. font-weight: 500;
  1735. color: #8b949e;
  1736. }
  1737. .citations-count {
  1738. font-size: 12px;
  1739. color: #8b949e;
  1740. background: rgba(10, 132, 255, 0.1);
  1741. padding: 1px 6px;
  1742. border-radius: 10px;
  1743. }
  1744. .thinking-arrow {
  1745. display: flex;
  1746. align-items: center;
  1747. transition: transform 0.2s;
  1748. &.expanded {
  1749. transform: rotate(180deg);
  1750. }
  1751. }
  1752. }
  1753. .citations-body {
  1754. padding: 4px 14px;
  1755. .citation-item {
  1756. padding: 6px 0;
  1757. border-bottom: 1px solid rgba(63, 80, 106, 0.15);
  1758. &:last-child {
  1759. border-bottom: none;
  1760. }
  1761. &.citation-highlight {
  1762. background: rgba(10, 132, 255, 0.08);
  1763. border-radius: 4px;
  1764. padding: 6px 8px;
  1765. margin: 0 -8px;
  1766. }
  1767. .citation-item-row {
  1768. display: flex;
  1769. align-items: center;
  1770. gap: 8px;
  1771. line-height: 1.5;
  1772. .citation-badge {
  1773. font-size: 12px;
  1774. color: #79c0ff;
  1775. font-family: monospace;
  1776. background: rgba(10, 132, 255, 0.1);
  1777. padding: 1px 5px;
  1778. border-radius: 3px;
  1779. white-space: nowrap;
  1780. flex-shrink: 0;
  1781. }
  1782. .citation-badge-knowledge_base {
  1783. color: #3fb950;
  1784. background: rgba(46, 160, 67, 0.1);
  1785. }
  1786. .citation-badge-web {
  1787. color: #d29922;
  1788. background: rgba(210, 153, 34, 0.1);
  1789. }
  1790. .citation-badge-calc {
  1791. color: #a371f7;
  1792. background: rgba(163, 113, 247, 0.1);
  1793. }
  1794. .citation-title-text {
  1795. font-size: 13px;
  1796. color: #b0b8c4;
  1797. overflow: hidden;
  1798. text-overflow: ellipsis;
  1799. white-space: nowrap;
  1800. min-width: 0;
  1801. }
  1802. .citation-link {
  1803. font-size: 12px;
  1804. color: #0a84ff;
  1805. text-decoration: none;
  1806. white-space: nowrap;
  1807. flex-shrink: 0;
  1808. }
  1809. }
  1810. .citation-item-attrs {
  1811. display: flex;
  1812. align-items: center;
  1813. flex-wrap: wrap;
  1814. gap: 4px 10px;
  1815. padding-left: 0;
  1816. margin-top: 2px;
  1817. .citation-attr {
  1818. font-size: 12px;
  1819. color: #8b949e;
  1820. white-space: nowrap;
  1821. }
  1822. .citation-low-score {
  1823. color: #d29922;
  1824. }
  1825. }
  1826. .citation-snippet {
  1827. margin-top: 4px;
  1828. font-size: 12px;
  1829. color: #8b949e;
  1830. line-height: 1.5;
  1831. overflow: hidden;
  1832. text-overflow: ellipsis;
  1833. white-space: nowrap;
  1834. }
  1835. }
  1836. }
  1837. }
  1838. .download-file-cards {
  1839. display: flex;
  1840. flex-direction: column;
  1841. gap: 8px;
  1842. margin-top: 12px;
  1843. .download-file-card {
  1844. display: flex;
  1845. align-items: center;
  1846. justify-content: space-between;
  1847. padding: 12px 16px;
  1848. background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
  1849. border: 1px solid rgba(0, 84, 130, 0.6);
  1850. border-radius: 10px;
  1851. cursor: pointer;
  1852. transition: all 0.2s ease;
  1853. &:hover {
  1854. border-color: rgba(10, 132, 255, 0.4);
  1855. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  1856. }
  1857. .file-card-info {
  1858. display: flex;
  1859. align-items: center;
  1860. gap: 12px;
  1861. min-width: 0;
  1862. flex: 1;
  1863. .file-card-icon {
  1864. width: 36px;
  1865. height: 36px;
  1866. background-image: var(--img-chat-download-icon);
  1867. background-repeat: no-repeat;
  1868. background-size: 100% 100%;
  1869. flex-shrink: 0;
  1870. opacity: 0.9;
  1871. }
  1872. .file-card-text {
  1873. display: flex;
  1874. flex-direction: column;
  1875. gap: 2px;
  1876. min-width: 0;
  1877. .file-card-name {
  1878. font-size: 13px;
  1879. font-weight: 500;
  1880. color: #e0e6ed;
  1881. overflow: hidden;
  1882. text-overflow: ellipsis;
  1883. white-space: nowrap;
  1884. }
  1885. .file-card-hint {
  1886. font-size: 11px;
  1887. color: #6e7681;
  1888. }
  1889. }
  1890. }
  1891. .file-card-actions {
  1892. display: flex;
  1893. gap: 8px;
  1894. flex-shrink: 0;
  1895. .file-action-btn {
  1896. display: inline-flex;
  1897. align-items: center;
  1898. gap: 5px;
  1899. padding: 6px 14px;
  1900. border-radius: 7px;
  1901. font-size: 12px;
  1902. font-weight: 500;
  1903. cursor: pointer;
  1904. border: none;
  1905. transition: all 0.2s ease;
  1906. white-space: nowrap;
  1907. &.download-btn {
  1908. background: rgba(255, 255, 255, 0.08);
  1909. color: #c9d1d9;
  1910. border: 1px solid rgba(63, 80, 106, 0.5);
  1911. &:hover {
  1912. background: rgba(255, 255, 255, 0.14);
  1913. color: #e0e6ed;
  1914. border-color: rgba(63, 80, 106, 0.8);
  1915. }
  1916. }
  1917. &.edit-btn {
  1918. background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
  1919. color: #fff;
  1920. border: 1px solid rgba(10, 132, 255, 0.5);
  1921. box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
  1922. &:hover {
  1923. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  1924. box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
  1925. }
  1926. }
  1927. }
  1928. }
  1929. }
  1930. }
  1931. }
  1932. .user-message {
  1933. display: flex;
  1934. flex-wrap: wrap;
  1935. justify-content: flex-end;
  1936. align-items: flex-start;
  1937. .message-agent {
  1938. display: flex;
  1939. margin-bottom: 8px;
  1940. margin-left: 5px;
  1941. }
  1942. .message-body {
  1943. background: rgba(10, 132, 255, 0.15);
  1944. padding: 12px;
  1945. border-radius: 6px 0 6px 6px;
  1946. max-width: 700px;
  1947. min-width: 0;
  1948. overflow: hidden;
  1949. .message-text {
  1950. font-size: 14px;
  1951. font-weight: 400;
  1952. line-height: 1.6;
  1953. color: #e0e6ed;
  1954. word-break: break-word;
  1955. overflow-wrap: break-word;
  1956. :deep(.session-ref-icon) {
  1957. width: 14px;
  1958. height: 14px;
  1959. vertical-align: -2px;
  1960. }
  1961. :deep(h1, h2, h3, h4, h5, h6) {
  1962. color: #fff;
  1963. }
  1964. :deep(p) {
  1965. margin: 0;
  1966. }
  1967. :deep(code) {
  1968. background: rgba(0, 0, 0, 0.2);
  1969. padding: 1px 4px;
  1970. border-radius: 3px;
  1971. font-size: 12px;
  1972. }
  1973. :deep(pre) {
  1974. background: rgba(0, 0, 0, 0.2);
  1975. padding: 8px;
  1976. border-radius: 4px;
  1977. overflow-x: auto;
  1978. margin: 6px 0;
  1979. white-space: pre-wrap;
  1980. }
  1981. :deep(pre code) {
  1982. background: transparent;
  1983. padding: 0;
  1984. }
  1985. }
  1986. .attached-file-card {
  1987. margin-top: 10px;
  1988. background: rgba(255, 255, 255, 0.08);
  1989. border-radius: 6px;
  1990. display: flex;
  1991. align-items: center;
  1992. gap: 10px;
  1993. padding: 8px;
  1994. cursor: pointer;
  1995. transition: all 0.3s;
  1996. &:hover {
  1997. background: rgba(255, 255, 255, 0.15);
  1998. }
  1999. .file-icon-card {
  2000. width: 32px;
  2001. height: 32px;
  2002. background-image: var(--img-chat-attach-icon);
  2003. background-repeat: no-repeat;
  2004. background-size: 100% 100%;
  2005. flex-shrink: 0;
  2006. }
  2007. .file-info-card {
  2008. flex: 1;
  2009. min-width: 0;
  2010. .file-name-card {
  2011. font-size: 12px;
  2012. font-weight: 400;
  2013. line-height: 1.4;
  2014. color: #e0e6ed;
  2015. overflow: hidden;
  2016. text-overflow: ellipsis;
  2017. white-space: nowrap;
  2018. margin-bottom: 2px;
  2019. }
  2020. .file-size-card {
  2021. font-size: 11px;
  2022. font-weight: 400;
  2023. line-height: 1.3;
  2024. color: #8b949e;
  2025. }
  2026. }
  2027. }
  2028. }
  2029. }
  2030. .ai-message,
  2031. .user-message {
  2032. .message-actions {
  2033. display: flex;
  2034. align-items: center;
  2035. gap: 8px;
  2036. width: 100%;
  2037. opacity: 0;
  2038. transition: opacity 0.2s;
  2039. .action-btn {
  2040. width: 26px;
  2041. height: 26px;
  2042. display: flex;
  2043. align-items: center;
  2044. justify-content: center;
  2045. border-radius: 4px;
  2046. color: #8b949e;
  2047. cursor: pointer;
  2048. transition: all 0.2s;
  2049. &:hover {
  2050. color: #e0e6ed;
  2051. background: rgba(10, 132, 255, 0.15);
  2052. }
  2053. }
  2054. .action-time {
  2055. font-size: 12px;
  2056. color: #8b949e;
  2057. }
  2058. .answer-switcher {
  2059. display: flex;
  2060. align-items: center;
  2061. gap: 2px;
  2062. padding: 0 4px;
  2063. border-radius: 4px;
  2064. background: rgba(255, 255, 255, 0.04);
  2065. .answer-nav-btn {
  2066. width: 22px;
  2067. height: 22px;
  2068. &.is-disabled {
  2069. opacity: 0.35;
  2070. cursor: not-allowed;
  2071. &:hover {
  2072. background: transparent;
  2073. color: #8b949e;
  2074. }
  2075. }
  2076. }
  2077. .answer-switcher-text {
  2078. font-size: 12px;
  2079. color: #8b949e;
  2080. min-width: 32px;
  2081. text-align: center;
  2082. user-select: none;
  2083. }
  2084. }
  2085. }
  2086. &:hover .message-actions {
  2087. opacity: 1;
  2088. }
  2089. }
  2090. .ai-message .message-actions {
  2091. padding: 0 0 0 10px;
  2092. }
  2093. .user-message .message-actions {
  2094. padding: 4px 0;
  2095. justify-content: flex-end;
  2096. }
  2097. }
  2098. }
  2099. .message-edit-textarea {
  2100. width: 100%;
  2101. min-width: 500px;
  2102. min-height: 80px;
  2103. padding: 8px 12px;
  2104. font-size: 14px;
  2105. line-height: 1.6;
  2106. color: #e0e6ed;
  2107. background: rgba(0, 0, 0, 0.2);
  2108. border: 1px solid rgba(10, 132, 255, 0.5);
  2109. border-radius: 6px;
  2110. outline: none;
  2111. resize: vertical;
  2112. font-family: inherit;
  2113. &:focus {
  2114. border-color: #0a84ff;
  2115. background: rgba(0, 0, 0, 0.3);
  2116. }
  2117. }
  2118. .message-edit-actions {
  2119. display: flex;
  2120. gap: 8px;
  2121. margin-top: 8px;
  2122. justify-content: flex-end;
  2123. .message-edit-btn {
  2124. padding: 3px 14px;
  2125. font-size: 12px;
  2126. border-radius: 4px;
  2127. cursor: pointer;
  2128. border: 1px solid transparent;
  2129. transition: all 0.2s;
  2130. &.confirm {
  2131. color: #fff;
  2132. background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
  2133. border-color: rgba(10, 132, 255, 0.5);
  2134. &:hover {
  2135. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  2136. }
  2137. }
  2138. &.cancel {
  2139. color: #8b949e;
  2140. background: rgba(255, 255, 255, 0.06);
  2141. border-color: rgba(63, 80, 106, 0.4);
  2142. &:hover {
  2143. color: #c9d1d9;
  2144. background: rgba(255, 255, 255, 0.1);
  2145. }
  2146. }
  2147. }
  2148. }
  2149. .scroll-to-bottom {
  2150. position: absolute;
  2151. bottom: 20px;
  2152. left: 50%;
  2153. transform: translateX(-50%);
  2154. z-index: 10;
  2155. .scroll-to-bottom-icon {
  2156. width: 40px;
  2157. height: 40px;
  2158. border-radius: 50%;
  2159. background: rgba(255, 255, 255, 0.15);
  2160. backdrop-filter: blur(4px);
  2161. background-image: var(--img-chat-scroll-bottom);
  2162. background-repeat: no-repeat;
  2163. background-size: 20px 20px;
  2164. background-position: center;
  2165. cursor: pointer;
  2166. transition: all 0.2s;
  2167. &:hover {
  2168. background-color: rgba(255, 255, 255, 0.25);
  2169. }
  2170. }
  2171. }
  2172. </style>