ChatMessages.vue 39 KB

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