ChatMessages.vue 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316
  1. <template>
  2. <div class="chat-messages-container">
  3. <!-- 左侧对话缩略导航(悬浮提示与滚动跟随内聚在组件内部) -->
  4. <ConversationNav
  5. ref="conversationNavRef"
  6. :messages="messages"
  7. :is-at-bottom="isAtBottom"
  8. :get-container="() => messagesRef"
  9. :scroll-to-message="scrollToMessage"
  10. />
  11. <div class="chat-messages" ref="messagesRef">
  12. <!-- 新对话缺省图 -->
  13. <div v-if="messages.length === 0" class="welcome-empty">
  14. <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
  15. </div>
  16. <div
  17. v-for="(message, index) in messages"
  18. :key="getMessageKey(message)"
  19. :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
  20. @mouseleave="copiedMessageKey = ''"
  21. >
  22. <!-- AI消息 -->
  23. <div v-if="message.type === 'ai'" class="ai-message">
  24. <div class="message-body">
  25. <ThinkingCard
  26. v-if="message.thinkingSteps && message.thinkingSteps.length > 0"
  27. :message="message"
  28. :collapsed="!!collapsedThinking[getMessageKey(message)]"
  29. :content-collapsed="!!collapsedThinkingContent[getMessageKey(message)]"
  30. @update:collapsed="collapsedThinking[getMessageKey(message)] = $event"
  31. @update:content-collapsed="collapsedThinkingContent[getMessageKey(message)] = $event"
  32. @agent-click="handleAgentGroupClick(index, $event)"
  33. />
  34. <div
  35. v-if="message.content"
  36. class="message-text"
  37. v-html="
  38. renderMarkdownCached(
  39. message.content,
  40. tableIcons,
  41. message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined,
  42. !message.isLoading
  43. )
  44. "
  45. @click="handleFootnoteClick($event, index)"
  46. ></div>
  47. <!-- 数据来源卡片 -->
  48. <CitationsCard
  49. v-if="message.citations"
  50. :message="message"
  51. :index="index"
  52. :collapsed="collapsedCitations[getMessageKey(message)] ?? true"
  53. @update:collapsed="collapsedCitations[getMessageKey(message)] = $event"
  54. />
  55. <div v-if="message.wordDownloadUrl" class="word-download-area">
  56. <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
  57. </div>
  58. <!-- 文件操作卡片 -->
  59. <DownloadFiles
  60. v-if="message.downloadFiles?.length"
  61. :files="message.downloadFiles"
  62. @file-preview="emit('file-preview', $event)"
  63. @open-file-preview="emit('open-file-preview', $event)"
  64. />
  65. <div v-show="message.isLoading" class="loading-dots">
  66. <span class="dot"></span>
  67. <span class="dot"></span>
  68. <span class="dot"></span>
  69. </div>
  70. <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
  71. <AskUserCard
  72. v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length"
  73. :ref="(el) => setAskCardRef(message, el)"
  74. :data="message.pendingApprovalData"
  75. @submit="emit('ask-user-submit', $event)"
  76. @cancel="emit('ask-user-cancel')"
  77. />
  78. </div>
  79. <!-- AI消息操作栏 -->
  80. <MessageActions
  81. :message="message"
  82. :copied="copiedMessageKey === getMessageKey(message)"
  83. :can-regenerate="canRegenerate(index, message)"
  84. :show-answer-switcher="showAnswerSwitcher(index)"
  85. :can-prev="canPrevAnswer(index)"
  86. :can-next="canNextAnswer(index)"
  87. :answer-position="answerPositionText(index)"
  88. @copy="copyMessageContent(message.content, getMessageKey(message))"
  89. @delete="emit('delete-message', message)"
  90. @regenerate="emit('regenerate')"
  91. @prev-answer="prevAnswer(index)"
  92. @next-answer="nextAnswer(index)"
  93. />
  94. </div>
  95. <!-- 用户消息 -->
  96. <div v-else class="user-message">
  97. <div class="message-body">
  98. <template v-if="editingKey === getMessageKey(message)">
  99. <textarea
  100. v-model="editDraft"
  101. class="message-edit-textarea"
  102. spellcheck="false"
  103. @keydown.enter.exact.prevent="submitEdit(index)"
  104. @keydown.escape.prevent="cancelEdit"
  105. ></textarea>
  106. <div class="message-edit-actions">
  107. <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
  108. <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
  109. </div>
  110. </template>
  111. <template v-else>
  112. <div class="message-text" v-html="renderMarkdownCached(message.content, tableIcons)"></div>
  113. <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
  114. <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
  115. <div class="file-info-card">
  116. <div class="file-name-card">{{ message.attachedFile.name }}</div>
  117. <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
  118. </div>
  119. </div>
  120. </template>
  121. </div>
  122. <!-- 用户消息操作栏 -->
  123. <MessageActions
  124. :message="message"
  125. :copied="copiedMessageKey === getMessageKey(message)"
  126. :can-edit="canEdit(index, message)"
  127. @copy="copyMessageContent(message.content, getMessageKey(message))"
  128. @delete="emit('delete-message', message)"
  129. @edit="startEdit(index)"
  130. />
  131. </div>
  132. </div>
  133. </div>
  134. <!-- 回到底部按钮 -->
  135. <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
  136. <div class="scroll-to-bottom-icon"></div>
  137. </div>
  138. </div>
  139. </template>
  140. <script setup lang="ts">
  141. import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
  142. import ConversationNav from './ConversationNav.vue';
  143. import CitationsCard from './CitationsCard.vue';
  144. import AskUserCard from './AskUserCard.vue';
  145. import DownloadFiles from './DownloadFiles.vue';
  146. import MessageActions from './MessageActions.vue';
  147. import ThinkingCard from './ThinkingCard.vue';
  148. import {
  149. renderMarkdownCached,
  150. formatFileSize,
  151. tableHtmlToMarkdown,
  152. tableHtmlToCsv,
  153. getFileIconVar,
  154. getCssUrl,
  155. getMessageKey,
  156. } from '/@/views/ventAI/manageAssistent/lib/index';
  157. import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
  158. import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
  159. export type { AgentDetailData };
  160. // import katex from 'katex';
  161. // import 'katex/dist/katex.min.css';
  162. const tableIcons = {
  163. copy: getCssUrl('--img-chat-copy-icon'),
  164. download: getCssUrl('--img-chat-download-icon-btn'),
  165. preview: getCssUrl('--img-chat-preview-icon'),
  166. copySuccess: getCssUrl('--img-chat-copy-success-icon'),
  167. };
  168. const props = defineProps<{
  169. messages: Message[];
  170. }>();
  171. const emit = defineEmits<{
  172. (e: 'file-preview', file: AttachedFile): void;
  173. (e: 'download-word', url: string): void;
  174. (e: 'open-file-preview', file: AttachedFile): void;
  175. (e: 'ask-user-submit', answers: string[]): void;
  176. (e: 'ask-user-cancel'): void;
  177. (e: 'agent-detail-click', data: AgentDetailData): void;
  178. (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
  179. (e: 'delete-message', message: Message): void;
  180. (e: 'regenerate'): void;
  181. (e: 'edit-resend', index: number, newContent: string): void;
  182. }>();
  183. // 用户消息内联编辑状态
  184. const editingKey = ref('');
  185. const editDraft = ref('');
  186. // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
  187. const stripInjectedHtml = (content: string): string =>
  188. (content || '')
  189. .replace(/\[<img[^>]*>[^\]]*\]/g, '')
  190. .replace(/<img[^>]*>/g, '')
  191. .trim();
  192. // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
  193. interface AnswerGroup {
  194. indices: number[];
  195. }
  196. // 将连续的 AI 消息划分为若干“回答组”
  197. const answerGroups = computed<AnswerGroup[]>(() => {
  198. const groups: AnswerGroup[] = [];
  199. let current: AnswerGroup | null = null;
  200. props.messages.forEach((msg, index) => {
  201. if (msg.type === 'ai') {
  202. if (!current) {
  203. current = { indices: [] };
  204. groups.push(current);
  205. }
  206. current.indices.push(index);
  207. } else {
  208. current = null;
  209. }
  210. });
  211. return groups;
  212. });
  213. // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
  214. const aiGroupMeta = computed(() => {
  215. const meta = new Map<number, { groupKey: string; offset: number; groupLen: number }>();
  216. for (const group of answerGroups.value) {
  217. const groupKey = getMessageKey(props.messages[group.indices[0]]);
  218. const groupLen = group.indices.length;
  219. group.indices.forEach((idx, offset) => {
  220. meta.set(idx, { groupKey, offset, groupLen });
  221. });
  222. }
  223. return meta;
  224. });
  225. // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
  226. const activeAnswerOffset = reactive<Record<string, number>>({});
  227. const currentAnswerOffset = (groupKey: string, groupLen: number): number => {
  228. const val = activeAnswerOffset[groupKey];
  229. if (val == null) return groupLen - 1;
  230. return Math.min(Math.max(val, 0), groupLen - 1);
  231. };
  232. // 末尾回答组内是否存在正在生成(isLoading)的消息
  233. const lastAnswerGroupHasLoading = computed(() => {
  234. const msgs = props.messages;
  235. if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
  236. let start = msgs.length - 1;
  237. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  238. for (let i = start; i < msgs.length; i++) {
  239. if (msgs[i].isLoading) return true;
  240. }
  241. return false;
  242. });
  243. // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
  244. watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
  245. if (loading && !wasLoading) {
  246. const msgs = props.messages;
  247. let start = msgs.length - 1;
  248. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  249. delete activeAnswerOffset[getMessageKey(msgs[start])];
  250. }
  251. });
  252. // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
  253. const isHiddenAnswer = (index: number): boolean => {
  254. const meta = aiGroupMeta.value.get(index);
  255. if (!meta || meta.groupLen <= 1) return false;
  256. return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
  257. };
  258. // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
  259. const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
  260. // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
  261. const showAnswerSwitcher = (index: number): boolean => {
  262. const meta = aiGroupMeta.value.get(index);
  263. if (!meta || meta.groupLen <= 1) return false;
  264. return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
  265. };
  266. const answerPositionText = (index: number): string => {
  267. const meta = aiGroupMeta.value.get(index);
  268. if (!meta) return '';
  269. return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
  270. };
  271. const canPrevAnswer = (index: number): boolean => {
  272. const meta = aiGroupMeta.value.get(index);
  273. if (!meta) return false;
  274. return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
  275. };
  276. const canNextAnswer = (index: number): boolean => {
  277. const meta = aiGroupMeta.value.get(index);
  278. if (!meta) return false;
  279. return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
  280. };
  281. const prevAnswer = (index: number) => {
  282. const meta = aiGroupMeta.value.get(index);
  283. if (!meta) return;
  284. const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
  285. if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
  286. };
  287. const nextAnswer = (index: number) => {
  288. const meta = aiGroupMeta.value.get(index);
  289. if (!meta) return;
  290. const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
  291. if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
  292. };
  293. // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
  294. const isInLastAnswerGroup = (index: number): boolean => {
  295. const msgs = props.messages;
  296. if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
  297. if (msgs[index]?.type !== 'ai') return false;
  298. let start = msgs.length - 1;
  299. while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
  300. return index >= start;
  301. };
  302. const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
  303. // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
  304. const lastUserMessageIndex = computed(() => {
  305. for (let i = props.messages.length - 1; i >= 0; i--) {
  306. if (props.messages[i].type === 'user') return i;
  307. }
  308. return -1;
  309. });
  310. const canEdit = (index: number, message: Message): boolean =>
  311. message.type === 'user' && index === lastUserMessageIndex.value && editingKey.value !== getMessageKey(message);
  312. const startEdit = (index: number) => {
  313. const msg = props.messages[index];
  314. editingKey.value = getMessageKey(msg);
  315. editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
  316. };
  317. const cancelEdit = () => {
  318. editingKey.value = '';
  319. editDraft.value = '';
  320. };
  321. const submitEdit = (index: number) => {
  322. const content = editDraft.value;
  323. editingKey.value = '';
  324. editDraft.value = '';
  325. emit('edit-resend', index, content);
  326. };
  327. // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
  328. // ask_user 答案状态(按消息索引存储)
  329. // 审批卡实例按消息对象索引:SSE 侧拿到消息下标后据此路由到对应卡片(卡片自身也会在 data 变化时重建答案)
  330. const askCardRefs = new Map<Message, InstanceType<typeof AskUserCard>>();
  331. const setAskCardRef = (msg: Message, el: unknown) => {
  332. if (el) askCardRefs.set(msg, el as InstanceType<typeof AskUserCard>);
  333. else askCardRefs.delete(msg);
  334. };
  335. const initAskUserAnswers = (index: number, questions: AskUserQuestion[]) => {
  336. const msg = props.messages[index];
  337. const card = msg ? askCardRefs.get(msg) : undefined;
  338. card?.init(questions);
  339. };
  340. const messagesRef = ref<HTMLElement>();
  341. const conversationNavRef = ref<InstanceType<typeof ConversationNav>>();
  342. const collapsedThinking = reactive<Record<string, boolean>>({});
  343. const collapsedThinkingContent = reactive<Record<string, boolean>>({});
  344. const collapsedCitations = reactive<Record<string, boolean>>({});
  345. const isAtBottom = ref(true);
  346. const copiedMessageKey = ref('');
  347. const handleTableAction = (e: Event) => {
  348. const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
  349. if (!target) return;
  350. const action = target.dataset.action;
  351. const encodedTable = target.dataset.table;
  352. if (!action || !encodedTable) return;
  353. const tableHtml = decodeURIComponent(encodedTable);
  354. switch (action) {
  355. case 'copy': {
  356. const markdown = tableHtmlToMarkdown(tableHtml);
  357. const doCopy = async () => {
  358. try {
  359. await navigator.clipboard.writeText(markdown);
  360. } catch {
  361. const ta = document.createElement('textarea');
  362. ta.value = markdown;
  363. ta.style.position = 'fixed';
  364. ta.style.opacity = '0';
  365. document.body.appendChild(ta);
  366. ta.select();
  367. document.execCommand('copy');
  368. document.body.removeChild(ta);
  369. }
  370. };
  371. doCopy().then(() => {
  372. const btn = target;
  373. const img = btn.querySelector('img');
  374. const successIcon = btn.dataset.iconSuccess;
  375. const originalIcon = btn.dataset.icon;
  376. if (img && successIcon) {
  377. img.src = successIcon;
  378. btn.title = '复制成功';
  379. const reset = () => {
  380. img.src = originalIcon!;
  381. btn.title = '复制Markdown';
  382. wrapper.removeEventListener('mouseleave', reset);
  383. };
  384. const wrapper = btn.closest('.markdown-table-wrapper')!;
  385. wrapper.addEventListener('mouseleave', reset);
  386. }
  387. });
  388. break;
  389. }
  390. case 'csv': {
  391. const csv = tableHtmlToCsv(tableHtml);
  392. const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  393. const url = URL.createObjectURL(blob);
  394. const a = document.createElement('a');
  395. a.href = url;
  396. a.download = `table_${Date.now()}.csv`;
  397. a.click();
  398. URL.revokeObjectURL(url);
  399. break;
  400. }
  401. case 'preview': {
  402. // 在右侧面板新建标签页展示表格渲染效果
  403. emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
  404. break;
  405. }
  406. }
  407. };
  408. const checkScrollBottom = () => {
  409. const el = messagesRef.value;
  410. if (!el) return;
  411. const threshold = 50;
  412. isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
  413. };
  414. const scrollToBottom = () => {
  415. const el = messagesRef.value;
  416. if (el) {
  417. el.scrollTop = el.scrollHeight - el.clientHeight;
  418. }
  419. };
  420. const forceScrollToBottom = () => {
  421. const el = messagesRef.value;
  422. if (el) {
  423. el.scrollTop = el.scrollHeight - el.clientHeight;
  424. // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
  425. requestAnimationFrame(() => {
  426. el.scrollTop = el.scrollHeight - el.clientHeight;
  427. });
  428. }
  429. isAtBottom.value = true;
  430. };
  431. onMounted(() => {
  432. messagesRef.value?.addEventListener('click', handleTableAction);
  433. messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
  434. });
  435. onBeforeUnmount(() => {
  436. messagesRef.value?.removeEventListener('click', handleTableAction);
  437. messagesRef.value?.removeEventListener('scroll', handleMessagesScroll);
  438. });
  439. const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
  440. emit('agent-detail-click', {
  441. messageIndex,
  442. agent: group.agent,
  443. agentId: group.agentId,
  444. });
  445. };
  446. const handleFootnoteClick = (e: Event, msgIndex: number) => {
  447. const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
  448. if (!target) return;
  449. const n = target.dataset.n;
  450. if (!n) return;
  451. const el = document.getElementById(`citation-${msgIndex}-${n}`);
  452. if (el) {
  453. scrollMessagesTo(el, 'center');
  454. el.classList.add('citation-highlight');
  455. setTimeout(() => el.classList.remove('citation-highlight'), 1500);
  456. }
  457. };
  458. const copyMessageContent = async (content: string, messageKey: string) => {
  459. if (!content) return;
  460. try {
  461. await navigator.clipboard.writeText(content);
  462. copiedMessageKey.value = messageKey;
  463. } catch {
  464. const textarea = document.createElement('textarea');
  465. textarea.value = content;
  466. textarea.style.position = 'fixed';
  467. textarea.style.opacity = '0';
  468. document.body.appendChild(textarea);
  469. textarea.select();
  470. document.execCommand('copy');
  471. document.body.removeChild(textarea);
  472. copiedMessageKey.value = messageKey;
  473. }
  474. };
  475. // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
  476. const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
  477. const container = messagesRef.value;
  478. if (!container) return;
  479. // 容器处于自适应缩放环境:getBoundingClientRect 是缩放后的视口坐标,scrollTop 是未缩放的布局坐标,
  480. // 直接混算在缩放比非 1(打包部署后的其他分辨率)时定位偏差,需除以纵向缩放比换算回布局坐标
  481. //(与左侧对话缩略导航 tooltip 的换算方式一致)
  482. const rect = container.getBoundingClientRect();
  483. const scale = container.offsetHeight ? rect.height / container.offsetHeight : 1;
  484. const safeScale = scale > 0 && Number.isFinite(scale) ? scale : 1;
  485. const top = (el.getBoundingClientRect().top - rect.top) / safeScale + container.scrollTop;
  486. const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
  487. container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
  488. };
  489. const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
  490. const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
  491. let el = wrappers?.[index] as HTMLElement | undefined;
  492. // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
  493. if (el && el.offsetParent === null) {
  494. el = undefined;
  495. for (let i = index - 1; i >= 0; i--) {
  496. const w = wrappers?.[i] as HTMLElement | undefined;
  497. if (w && w.offsetParent !== null) {
  498. el = w;
  499. break;
  500. }
  501. }
  502. }
  503. if (el) scrollMessagesTo(el, block);
  504. };
  505. const handleMessagesScroll = () => {
  506. checkScrollBottom();
  507. conversationNavRef.value?.onContainerScroll();
  508. };
  509. defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
  510. </script>
  511. <style scoped lang="less">
  512. .chat-messages-container {
  513. flex: 1;
  514. position: relative;
  515. display: flex;
  516. flex-direction: row;
  517. overflow: hidden;
  518. }
  519. .chat-messages {
  520. flex: 1;
  521. overflow-y: auto;
  522. padding: 20px;
  523. position: relative;
  524. .welcome-empty {
  525. position: absolute;
  526. top: 50%;
  527. left: 50%;
  528. transform: translate(-50%, -50%);
  529. display: flex;
  530. align-items: center;
  531. justify-content: center;
  532. .welcome-img {
  533. max-width: 300px;
  534. max-height: 300px;
  535. object-fit: contain;
  536. }
  537. }
  538. .message-wrapper {
  539. // margin-bottom: 20px;
  540. &.answer-hidden {
  541. display: none;
  542. }
  543. &.nav-located-highlight {
  544. background: rgba(10, 132, 255, 0.08);
  545. border-radius: 6px;
  546. }
  547. .ai-message {
  548. display: flex;
  549. flex-wrap: wrap;
  550. .message-agent {
  551. display: flex;
  552. margin-bottom: 8px;
  553. opacity: 0;
  554. }
  555. .message-body {
  556. padding: 12px 12px 4px 12px;
  557. border-radius: 6px;
  558. width: 100%;
  559. min-width: 0;
  560. overflow: hidden;
  561. .message-text {
  562. font-size: 14px;
  563. font-weight: 400;
  564. line-height: 1.6;
  565. color: #e0e6ed;
  566. word-break: break-word;
  567. overflow-wrap: break-word;
  568. :deep(.session-ref-icon) {
  569. width: 14px;
  570. height: 14px;
  571. vertical-align: -2px;
  572. }
  573. :deep(h1),
  574. :deep(h2),
  575. :deep(h3),
  576. :deep(h4),
  577. :deep(h5),
  578. :deep(h6) {
  579. color: #fff;
  580. }
  581. :deep(p) {
  582. margin: 0;
  583. }
  584. :deep(code) {
  585. background: rgba(10, 132, 255, 0.1);
  586. padding: 1px 4px;
  587. border-radius: 3px;
  588. font-size: 12px;
  589. }
  590. :deep(pre) {
  591. background: rgba(10, 132, 255, 0.1);
  592. padding: 8px;
  593. border-radius: 4px;
  594. overflow-x: auto;
  595. margin: 6px 0;
  596. white-space: pre-wrap;
  597. }
  598. :deep(pre code) {
  599. background: transparent;
  600. padding: 0;
  601. }
  602. :deep(ul),
  603. :deep(ol) {
  604. padding-left: 18px;
  605. margin: 4px 0;
  606. }
  607. :deep(blockquote) {
  608. border-left: 3px solid rgba(10, 132, 255, 0.3);
  609. padding-left: 10px;
  610. margin: 6px 0;
  611. color: #8b949e;
  612. }
  613. :deep(table) {
  614. border-collapse: collapse;
  615. margin: 6px 0;
  616. }
  617. :deep(th),
  618. :deep(td) {
  619. border: 1px solid rgba(63, 80, 106, 0.5);
  620. padding: 4px 8px;
  621. }
  622. :deep(th) {
  623. background: rgba(10, 132, 255, 0.1);
  624. }
  625. :deep(strong) {
  626. font-weight: 600;
  627. }
  628. :deep(.markdown-table-wrapper) {
  629. position: relative;
  630. margin: 6px 0;
  631. max-width: 100%;
  632. overflow-x: auto;
  633. .table-actions {
  634. position: absolute;
  635. top: 4px;
  636. right: 4px;
  637. display: flex;
  638. gap: 2px;
  639. opacity: 0;
  640. transition: opacity 0.2s;
  641. z-index: 1;
  642. }
  643. &:hover .table-actions {
  644. opacity: 1;
  645. }
  646. .table-action-btn {
  647. width: 26px;
  648. height: 26px;
  649. display: flex;
  650. align-items: center;
  651. justify-content: center;
  652. border-radius: 4px;
  653. color: #8b949e;
  654. cursor: pointer;
  655. background: rgba(22, 27, 34, 0.8);
  656. backdrop-filter: blur(4px);
  657. transition: all 0.2s;
  658. &:hover {
  659. color: #e0e6ed;
  660. background: rgba(10, 132, 255, 0.3);
  661. }
  662. }
  663. }
  664. }
  665. }
  666. .loading-dots {
  667. display: flex;
  668. align-items: center;
  669. gap: 6px;
  670. padding: 4px 0;
  671. min-height: 16px;
  672. .dot {
  673. width: 8px;
  674. height: 8px;
  675. border-radius: 50%;
  676. background: rgba(10, 132, 255, 0.4);
  677. animation: dot-bounce 1.4s ease-in-out infinite both;
  678. &:nth-child(1) {
  679. animation-delay: 0s;
  680. }
  681. &:nth-child(2) {
  682. animation-delay: 0.2s;
  683. }
  684. &:nth-child(3) {
  685. animation-delay: 0.4s;
  686. }
  687. }
  688. }
  689. @keyframes dot-bounce {
  690. 0%,
  691. 80%,
  692. 100% {
  693. transform: scale(0.6);
  694. opacity: 0.4;
  695. }
  696. 40% {
  697. transform: scale(1);
  698. opacity: 1;
  699. }
  700. }
  701. .citations-card {
  702. overflow: hidden;
  703. .citations-header {
  704. display: flex;
  705. align-items: center;
  706. justify-content: space-between;
  707. padding: 8px 14px;
  708. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  709. cursor: pointer;
  710. user-select: none;
  711. transition: background 0.2s;
  712. &:hover {
  713. background: rgba(10, 132, 255, 0.08);
  714. }
  715. &.citations-header-simple {
  716. cursor: default;
  717. &:hover {
  718. background: transparent;
  719. }
  720. }
  721. .citations-header-left {
  722. display: flex;
  723. align-items: center;
  724. gap: 8px;
  725. }
  726. .citations-title {
  727. font-size: 13px;
  728. font-weight: 500;
  729. color: #8b949e;
  730. }
  731. .citations-count {
  732. font-size: 12px;
  733. color: #8b949e;
  734. background: rgba(10, 132, 255, 0.1);
  735. padding: 1px 6px;
  736. border-radius: 10px;
  737. }
  738. .thinking-arrow {
  739. display: flex;
  740. align-items: center;
  741. transition: transform 0.2s;
  742. &.expanded {
  743. transform: rotate(180deg);
  744. }
  745. }
  746. }
  747. .citations-body {
  748. padding: 4px 14px;
  749. .citation-item {
  750. padding: 6px 0;
  751. border-bottom: 1px solid rgba(63, 80, 106, 0.15);
  752. &:last-child {
  753. border-bottom: none;
  754. }
  755. &.citation-highlight {
  756. background: rgba(10, 132, 255, 0.08);
  757. border-radius: 4px;
  758. padding: 6px 8px;
  759. margin: 0 -8px;
  760. }
  761. .citation-item-row {
  762. display: flex;
  763. align-items: center;
  764. gap: 8px;
  765. line-height: 1.5;
  766. .citation-badge {
  767. font-size: 12px;
  768. color: #79c0ff;
  769. font-family: monospace;
  770. background: rgba(10, 132, 255, 0.1);
  771. padding: 1px 5px;
  772. border-radius: 3px;
  773. white-space: nowrap;
  774. flex-shrink: 0;
  775. }
  776. .citation-badge-knowledge_base {
  777. color: #3fb950;
  778. background: rgba(46, 160, 67, 0.1);
  779. }
  780. .citation-badge-web {
  781. color: #d29922;
  782. background: rgba(210, 153, 34, 0.1);
  783. }
  784. .citation-badge-calc {
  785. color: #a371f7;
  786. background: rgba(163, 113, 247, 0.1);
  787. }
  788. .citation-title-text {
  789. font-size: 13px;
  790. color: #b0b8c4;
  791. overflow: hidden;
  792. text-overflow: ellipsis;
  793. white-space: nowrap;
  794. min-width: 0;
  795. }
  796. .citation-link {
  797. font-size: 12px;
  798. color: #0a84ff;
  799. text-decoration: none;
  800. white-space: nowrap;
  801. flex-shrink: 0;
  802. }
  803. }
  804. .citation-item-attrs {
  805. display: flex;
  806. align-items: center;
  807. flex-wrap: wrap;
  808. gap: 4px 10px;
  809. padding-left: 0;
  810. margin-top: 2px;
  811. .citation-attr {
  812. font-size: 12px;
  813. color: #8b949e;
  814. white-space: nowrap;
  815. }
  816. .citation-low-score {
  817. color: #d29922;
  818. }
  819. }
  820. .citation-snippet {
  821. margin-top: 4px;
  822. font-size: 12px;
  823. color: #8b949e;
  824. line-height: 1.5;
  825. overflow: hidden;
  826. text-overflow: ellipsis;
  827. white-space: nowrap;
  828. }
  829. }
  830. }
  831. }
  832. .download-file-cards {
  833. display: flex;
  834. flex-direction: column;
  835. gap: 8px;
  836. margin-top: 12px;
  837. .download-file-card {
  838. display: flex;
  839. align-items: center;
  840. justify-content: space-between;
  841. padding: 12px 16px;
  842. background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
  843. border: 1px solid rgba(0, 84, 130, 0.6);
  844. border-radius: 10px;
  845. cursor: pointer;
  846. transition: all 0.2s ease;
  847. &:hover {
  848. border-color: rgba(10, 132, 255, 0.4);
  849. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  850. }
  851. .file-card-info {
  852. display: flex;
  853. align-items: center;
  854. gap: 12px;
  855. min-width: 0;
  856. flex: 1;
  857. .file-card-icon {
  858. width: 36px;
  859. height: 36px;
  860. background-image: var(--img-chat-download-icon);
  861. background-repeat: no-repeat;
  862. background-size: 100% 100%;
  863. flex-shrink: 0;
  864. opacity: 0.9;
  865. }
  866. .file-card-text {
  867. display: flex;
  868. flex-direction: column;
  869. gap: 2px;
  870. min-width: 0;
  871. .file-card-name {
  872. font-size: 13px;
  873. font-weight: 500;
  874. color: #e0e6ed;
  875. overflow: hidden;
  876. text-overflow: ellipsis;
  877. white-space: nowrap;
  878. }
  879. .file-card-hint {
  880. font-size: 11px;
  881. color: #6e7681;
  882. }
  883. }
  884. }
  885. .file-card-actions {
  886. display: flex;
  887. gap: 8px;
  888. flex-shrink: 0;
  889. .file-action-btn {
  890. display: inline-flex;
  891. align-items: center;
  892. gap: 5px;
  893. padding: 6px 14px;
  894. border-radius: 7px;
  895. font-size: 12px;
  896. font-weight: 500;
  897. cursor: pointer;
  898. border: none;
  899. transition: all 0.2s ease;
  900. white-space: nowrap;
  901. &.download-btn {
  902. background: rgba(255, 255, 255, 0.08);
  903. color: #c9d1d9;
  904. border: 1px solid rgba(63, 80, 106, 0.5);
  905. &:hover {
  906. background: rgba(255, 255, 255, 0.14);
  907. color: #e0e6ed;
  908. border-color: rgba(63, 80, 106, 0.8);
  909. }
  910. }
  911. &.edit-btn {
  912. background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
  913. color: #fff;
  914. border: 1px solid rgba(10, 132, 255, 0.5);
  915. box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
  916. &:hover {
  917. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  918. box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
  919. }
  920. }
  921. }
  922. }
  923. }
  924. }
  925. }
  926. .user-message {
  927. display: flex;
  928. flex-wrap: wrap;
  929. justify-content: flex-end;
  930. align-items: flex-start;
  931. .message-agent {
  932. display: flex;
  933. margin-bottom: 8px;
  934. margin-left: 5px;
  935. }
  936. .message-body {
  937. background: rgba(10, 132, 255, 0.15);
  938. padding: 12px;
  939. border-radius: 6px 0 6px 6px;
  940. max-width: 700px;
  941. min-width: 0;
  942. overflow: hidden;
  943. .message-text {
  944. font-size: 14px;
  945. font-weight: 400;
  946. line-height: 1.6;
  947. color: #e0e6ed;
  948. word-break: break-word;
  949. overflow-wrap: break-word;
  950. :deep(.session-ref-icon) {
  951. width: 14px;
  952. height: 14px;
  953. vertical-align: -2px;
  954. }
  955. :deep(h1, h2, h3, h4, h5, h6) {
  956. color: #fff;
  957. }
  958. :deep(p) {
  959. margin: 0;
  960. }
  961. :deep(code) {
  962. background: rgba(0, 0, 0, 0.2);
  963. padding: 1px 4px;
  964. border-radius: 3px;
  965. font-size: 12px;
  966. }
  967. :deep(pre) {
  968. background: rgba(0, 0, 0, 0.2);
  969. padding: 8px;
  970. border-radius: 4px;
  971. overflow-x: auto;
  972. margin: 6px 0;
  973. white-space: pre-wrap;
  974. }
  975. :deep(pre code) {
  976. background: transparent;
  977. padding: 0;
  978. }
  979. }
  980. .attached-file-card {
  981. margin-top: 10px;
  982. background: rgba(255, 255, 255, 0.08);
  983. border-radius: 6px;
  984. display: flex;
  985. align-items: center;
  986. gap: 10px;
  987. padding: 8px;
  988. cursor: pointer;
  989. transition: all 0.3s;
  990. &:hover {
  991. background: rgba(255, 255, 255, 0.15);
  992. }
  993. .file-icon-card {
  994. width: 32px;
  995. height: 32px;
  996. background-image: var(--img-chat-attach-icon);
  997. background-repeat: no-repeat;
  998. background-size: 100% 100%;
  999. flex-shrink: 0;
  1000. }
  1001. .file-info-card {
  1002. flex: 1;
  1003. min-width: 0;
  1004. .file-name-card {
  1005. font-size: 12px;
  1006. font-weight: 400;
  1007. line-height: 1.4;
  1008. color: #e0e6ed;
  1009. overflow: hidden;
  1010. text-overflow: ellipsis;
  1011. white-space: nowrap;
  1012. margin-bottom: 2px;
  1013. }
  1014. .file-size-card {
  1015. font-size: 11px;
  1016. font-weight: 400;
  1017. line-height: 1.3;
  1018. color: #8b949e;
  1019. }
  1020. }
  1021. }
  1022. }
  1023. }
  1024. .ai-message,
  1025. .user-message {
  1026. .message-actions {
  1027. display: flex;
  1028. align-items: center;
  1029. gap: 8px;
  1030. width: 100%;
  1031. opacity: 0;
  1032. transition: opacity 0.2s;
  1033. .action-btn {
  1034. width: 26px;
  1035. height: 26px;
  1036. display: flex;
  1037. align-items: center;
  1038. justify-content: center;
  1039. border-radius: 4px;
  1040. color: #8b949e;
  1041. cursor: pointer;
  1042. transition: all 0.2s;
  1043. &:hover {
  1044. color: #e0e6ed;
  1045. background: rgba(10, 132, 255, 0.15);
  1046. }
  1047. }
  1048. .action-time {
  1049. font-size: 12px;
  1050. color: #8b949e;
  1051. }
  1052. .answer-switcher {
  1053. display: flex;
  1054. align-items: center;
  1055. gap: 2px;
  1056. padding: 0 4px;
  1057. border-radius: 4px;
  1058. background: rgba(255, 255, 255, 0.04);
  1059. .answer-nav-btn {
  1060. width: 22px;
  1061. height: 22px;
  1062. &.is-disabled {
  1063. opacity: 0.35;
  1064. cursor: not-allowed;
  1065. &:hover {
  1066. background: transparent;
  1067. color: #8b949e;
  1068. }
  1069. }
  1070. }
  1071. .answer-switcher-text {
  1072. font-size: 12px;
  1073. color: #8b949e;
  1074. min-width: 32px;
  1075. text-align: center;
  1076. user-select: none;
  1077. }
  1078. }
  1079. }
  1080. &:hover .message-actions {
  1081. opacity: 1;
  1082. }
  1083. }
  1084. .ai-message .message-actions {
  1085. padding: 0 0 0 10px;
  1086. }
  1087. .user-message .message-actions {
  1088. padding: 4px 0;
  1089. justify-content: flex-end;
  1090. }
  1091. }
  1092. }
  1093. .message-edit-textarea {
  1094. width: 100%;
  1095. min-width: 500px;
  1096. min-height: 80px;
  1097. padding: 8px 12px;
  1098. font-size: 14px;
  1099. line-height: 1.6;
  1100. color: #e0e6ed;
  1101. background: rgba(0, 0, 0, 0.2);
  1102. border: 1px solid rgba(10, 132, 255, 0.5);
  1103. border-radius: 6px;
  1104. outline: none;
  1105. resize: vertical;
  1106. font-family: inherit;
  1107. &:focus {
  1108. border-color: #0a84ff;
  1109. background: rgba(0, 0, 0, 0.3);
  1110. }
  1111. }
  1112. .message-edit-actions {
  1113. display: flex;
  1114. gap: 8px;
  1115. margin-top: 8px;
  1116. justify-content: flex-end;
  1117. .message-edit-btn {
  1118. padding: 3px 14px;
  1119. font-size: 12px;
  1120. border-radius: 4px;
  1121. cursor: pointer;
  1122. border: 1px solid transparent;
  1123. transition: all 0.2s;
  1124. &.confirm {
  1125. color: #fff;
  1126. background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
  1127. border-color: rgba(10, 132, 255, 0.5);
  1128. &:hover {
  1129. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  1130. }
  1131. }
  1132. &.cancel {
  1133. color: #8b949e;
  1134. background: rgba(255, 255, 255, 0.06);
  1135. border-color: rgba(63, 80, 106, 0.4);
  1136. &:hover {
  1137. color: #c9d1d9;
  1138. background: rgba(255, 255, 255, 0.1);
  1139. }
  1140. }
  1141. }
  1142. }
  1143. .scroll-to-bottom {
  1144. position: absolute;
  1145. bottom: 20px;
  1146. left: 50%;
  1147. transform: translateX(-50%);
  1148. z-index: 10;
  1149. .scroll-to-bottom-icon {
  1150. width: 40px;
  1151. height: 40px;
  1152. border-radius: 50%;
  1153. background: rgba(255, 255, 255, 0.15);
  1154. backdrop-filter: blur(4px);
  1155. background-image: var(--img-chat-scroll-bottom);
  1156. background-repeat: no-repeat;
  1157. background-size: 20px 20px;
  1158. background-position: center;
  1159. cursor: pointer;
  1160. transition: all 0.2s;
  1161. &:hover {
  1162. background-color: rgba(255, 255, 255, 0.25);
  1163. }
  1164. }
  1165. }
  1166. </style>