ChatMessages.vue 40 KB

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