大语言模型
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322
  1. <!--
  2. * @Author: ysh
  3. * @Date: 2025-04-07 14:14:05
  4. * @LastEditors: wrh
  5. * @LastEditTime: 2025-07-29 17:12:19
  6. -->
  7. <template>
  8. <div class="app-container">
  9. <!-- 最外层页面于窗口同宽,使聊天面板居中 -->
  10. <div class="home-view">
  11. <!-- 整个聊天面板 -->
  12. <div class="chat-panel">
  13. <!-- 左侧的会话列表 -->
  14. <div class="session-panel">
  15. <div class="session-header">
  16. <el-button :icon="Plus" type="primary" class="new-chat-btn" @click="createNewChat">
  17. 开启新对话
  18. </el-button>
  19. </div>
  20. <el-divider />
  21. <el-scrollbar class="session-scrollbar">
  22. <div id="recent-containers">
  23. <div v-if="classifiedRecent.today.length > 0">
  24. <div class="category-title">今天</div>
  25. <div v-for="item in classifiedRecent.today" :key="item.topicId" class="record"
  26. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  27. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  28. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  29. size="small" />
  30. <div class="record-actions" v-if="!item.isEditing">
  31. <el-dropdown trigger="click" @command="handleAction">
  32. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  33. <template #dropdown>
  34. <el-dropdown-menu>
  35. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  36. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  37. </el-dropdown-menu>
  38. </template>
  39. </el-dropdown>
  40. </div>
  41. </div>
  42. </div>
  43. <div class="spacer" v-if="classifiedRecent.today.length > 0"></div>
  44. <div v-if="classifiedRecent.yesterday.length > 0">
  45. <div class="category-title">昨天</div>
  46. <div v-for="item in classifiedRecent.yesterday" :key="item.topicId" class="record"
  47. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  48. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  49. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  50. size="small" />
  51. <div class="record-actions" v-if="!item.isEditing">
  52. <el-dropdown trigger="click" @command="handleAction">
  53. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  54. <template #dropdown>
  55. <el-dropdown-menu>
  56. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  57. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  58. </el-dropdown-menu>
  59. </template>
  60. </el-dropdown>
  61. </div>
  62. </div>
  63. </div>
  64. <div class="spacer" v-if="classifiedRecent.yesterday.length > 0"></div>
  65. <div v-if="classifiedRecent.dayBeforeYesterday.length > 0">
  66. <div class="category-title">前天</div>
  67. <div v-for="item in classifiedRecent.dayBeforeYesterday" :key="item.topicId" class="record"
  68. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  69. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  70. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  71. size="small" />
  72. <div class="record-actions" v-if="!item.isEditing">
  73. <el-dropdown trigger="click" @command="handleAction">
  74. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  75. <template #dropdown>
  76. <el-dropdown-menu>
  77. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  78. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  79. </el-dropdown-menu>
  80. </template>
  81. </el-dropdown>
  82. </div>
  83. </div>
  84. </div>
  85. <div class="spacer" v-if="classifiedRecent.dayBeforeYesterday.length > 0"></div>
  86. <div v-if="classifiedRecent.within7Days.length > 0">
  87. <div class="category-title">7天内</div>
  88. <div v-for="item in classifiedRecent.within7Days" :key="item.topicId" class="record"
  89. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  90. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  91. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  92. size="small" />
  93. <div class="record-actions" v-if="!item.isEditing">
  94. <el-dropdown trigger="click" @command="handleAction">
  95. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  96. <template #dropdown>
  97. <el-dropdown-menu>
  98. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  99. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  100. </el-dropdown-menu>
  101. </template>
  102. </el-dropdown>
  103. </div>
  104. </div>
  105. </div>
  106. <div class="spacer" v-if="classifiedRecent.within7Days.length > 0"></div>
  107. <div v-if="classifiedRecent.within30Days.length > 0">
  108. <div class="category-title">30天内</div>
  109. <div v-for="item in classifiedRecent.within30Days" :key="item.topicId" class="record"
  110. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  111. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  112. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  113. size="small" />
  114. <div class="record-actions" v-if="!item.isEditing">
  115. <el-dropdown trigger="click" @command="handleAction">
  116. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  117. <template #dropdown>
  118. <el-dropdown-menu>
  119. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  120. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  121. </el-dropdown-menu>
  122. </template>
  123. </el-dropdown>
  124. </div>
  125. </div>
  126. </div>
  127. <div class="spacer" v-if="classifiedRecent.within30Days.length > 0"></div>
  128. </div>
  129. <!-- 按月记录容器 -->
  130. <div v-if="sortedMonthlyKeys.length > 0">
  131. <div v-for="monthKey in sortedMonthlyKeys" :key="monthKey">
  132. <div class="category-title">{{ formatMonth(monthKey) }}</div>
  133. <div v-for="item in classifiedMonthly[monthKey]" :key="item.topicId" class="record"
  134. :class="{ active: currentTopicId === item.topicId }" @click="selectTopic(item)">
  135. <span v-if="!item.isEditing" class="topic-text">{{ item.topic }}</span>
  136. <el-input v-else v-model="item.topic" @blur="disableEdit(item)" @keyup.enter="disableEdit(item)"
  137. size="small" />
  138. <div class="record-actions" v-if="!item.isEditing">
  139. <el-dropdown trigger="click" @command="handleAction">
  140. <el-button class="action-icon" :icon="MoreFilled" style="border: none;" />
  141. <template #dropdown>
  142. <el-dropdown-menu>
  143. <el-dropdown-item :command="{ action: 'rename', item }">重命名</el-dropdown-item>
  144. <el-dropdown-item :command="{ action: 'delete', item }" divided>删除</el-dropdown-item>
  145. </el-dropdown-menu>
  146. </template>
  147. </el-dropdown>
  148. </div>
  149. </div>
  150. </div>
  151. <div class="spacer"></div>
  152. </div>
  153. <!-- 空状态 -->
  154. <div v-if="!hasRecords" class="empty">
  155. 暂无记录
  156. </div>
  157. </el-scrollbar>
  158. </div>
  159. <!-- 右侧的消息记录 -->
  160. <div class="message-panel">
  161. <!-- 消息列表区域 -->
  162. <div class="message-container">
  163. <el-scrollbar ref="messageScrollbar" class="message-scrollbar">
  164. <!-- 欢迎消息:初次进入 -->
  165. <div v-if="!currentTopicId && chatMessages.length === 0" class="welcome-message">
  166. <div class="welcome-content">
  167. <div class="welcome-icon">🤖</div>
  168. <h2>欢迎使用 AI 助手</h2>
  169. <p>我是您的智能助手,可以帮您解答问题、编写代码、分析数据等。</p>
  170. <p>请开始您的对话吧!</p>
  171. </div>
  172. </div>
  173. <!-- 新建对话欢迎消息 -->
  174. <div v-if="showNewChatWelcome && flatMessages.length === 0 && currentTopicId" class="welcome-message">
  175. <div class="welcome-content">
  176. <div class="welcome-icon">🤖</div>
  177. <h2>我是您的AI助手,很高兴见到你!</h2>
  178. <p>我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~</p>
  179. </div>
  180. </div>
  181. <!-- 消息列表 -->
  182. <div v-for="(msg, idx) in flatMessages" :key="msg.id || idx" class="message-item" :class="msg.type">
  183. <div class="message-avatar">
  184. <div v-if="msg.type === 'user'" class="user-avatar"></div>
  185. <div v-else class="ai-avatar">
  186. <img :src="logoImg" alt="AI" class="ai-logo" />
  187. </div>
  188. </div>
  189. <div class="message-content">
  190. <div class="message-bubble" :class="msg.type">
  191. <div class="message-text" v-html="formatMessage(msg.text)"></div>
  192. <div class="message-time">{{ formatMessageTime(msg.time) }}</div>
  193. </div>
  194. <!-- 用户消息的文件列表 -->
  195. <div v-if="msg.type === 'user' && msg.chatId && msg.fileList && msg.fileList.length > 0"
  196. class="message-files">
  197. <div class="files-header">
  198. <el-icon>
  199. <Document />
  200. </el-icon>
  201. <span>附件 ({{ msg.fileList.length }})</span>
  202. </div>
  203. <div class="files-list">
  204. <div v-for="file in msg.fileList" :key="file.documentId" class="file-item-display">
  205. <el-icon class="file-icon">
  206. <Document />
  207. </el-icon>
  208. <span class="file-name">{{ file.path.split("/")[file.path.split("/").length - 1] }}</span>
  209. </div>
  210. </div>
  211. </div>
  212. </div>
  213. </div>
  214. <!-- 加载状态 -->
  215. <div v-if="isLoading" class="message-item ai">
  216. <div class="message-avatar">
  217. <div class="ai-avatar">
  218. <img :src="logoImg" alt="AI" class="ai-logo" />
  219. </div>
  220. </div>
  221. <div class="message-content">
  222. <div class="message-bubble ai">
  223. <div class="loading-dots">
  224. <span></span>
  225. <span></span>
  226. <span></span>
  227. </div>
  228. </div>
  229. </div>
  230. </div>
  231. </el-scrollbar>
  232. </div>
  233. <!-- 输入框区域 -->
  234. <div class="input-container">
  235. <!-- 文件列表显示区域 -->
  236. <div v-if="selectedFiles.length > 0 || isUploading" class="file-list-container">
  237. <div class="file-list-header">
  238. <span v-if="isUploading">正在上传文件...</span>
  239. <span v-else>已上传 {{ selectedFiles.length }} 个文件</span>
  240. <el-icon v-if="isUploading" class="loading-icon">
  241. <Loading />
  242. </el-icon>
  243. </div>
  244. <div class="file-list">
  245. <div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
  246. <div class="file-info">
  247. <el-icon class="file-icon">
  248. <Document />
  249. </el-icon>
  250. <span class="file-name">{{ file.name }}</span>
  251. <span class="file-size">({{ formatFileSize(file.size) }})</span>
  252. </div>
  253. <el-button v-if="!isUploading" size="small" circle :icon="Delete" class="remove-btn"
  254. @click="removeFile(index)" />
  255. </div>
  256. </div>
  257. </div>
  258. <div class="input-wrapper">
  259. <el-input v-model="inputMessage" type="textarea" :rows="1" :autosize="{ minRows: 1, maxRows: 6 }"
  260. placeholder="输入您的问题..." @keydown.enter.prevent="handleEnter" @keydown.ctrl.enter="handleCtrlEnter"
  261. class="message-input" resize="none" />
  262. <div class="input-actions">
  263. <el-button :icon="Paperclip" circle size="small" class="action-btn" @click="handleFileUpload" />
  264. <el-button :icon="Promotion" type="primary" circle size="small" class="send-btn"
  265. :disabled="!inputMessage.trim() || isLoading" @click="sendMessage" />
  266. </div>
  267. </div>
  268. <div class="input-tips">
  269. <span>按 Enter 发送,Ctrl + Enter 换行</span>
  270. </div>
  271. <!-- 隐藏的文件选择input -->
  272. <input ref="fileInput" type="file" multiple style="display: none" @change="handleFileSelect"
  273. accept=".txt,.doc,.docx,.pdf,.xlsx,.xls,.ppt,.pptx,.md" />
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. </template>
  280. <script setup name=''>
  281. import { reactive, toRefs, onBeforeMount, onMounted, nextTick, ref, computed, getCurrentInstance } from 'vue'
  282. import { Plus, Delete, User, ChatDotRound, Paperclip, Promotion, MoreFilled, Document, Loading } from '@element-plus/icons-vue'
  283. import { listTopic, getTopic, delTopic, addTopic, updateTopic } from "@/api/llm/topic";
  284. import { listChat, addChat, updateChat } from "@/api/llm/chat";
  285. import { listDocument, uploadDocument } from "@/api/llm/document";
  286. import { getAnswer, getAnswerWithDocument } from "@/api/llm/session";
  287. import { ElMessage, ElMessageBox } from 'element-plus'
  288. import useUserStore from '@/store/modules/user'
  289. import logoImg from '@/assets/images/logo.png'
  290. const { proxy } = getCurrentInstance();
  291. const userStore = useUserStore()
  292. const topicList = ref([]);
  293. const open = ref(false);
  294. const loading = ref(true);
  295. const showSearch = ref(true);
  296. const ids = ref([]);
  297. const single = ref(true);
  298. const multiple = ref(true);
  299. const total = ref(0);
  300. const title = ref("");
  301. const hasRecords = ref(false);
  302. const currentTopicId = ref(null);
  303. const chatMessages = ref([]);
  304. const inputMessage = ref('');
  305. const isLoading = ref(false);
  306. const messageScrollbar = ref(null);
  307. const showNewChatWelcome = ref(false);
  308. const fileInput = ref(null);
  309. const selectedFiles = ref([]);
  310. const isUploading = ref(false);
  311. const documentChatId = ref('');
  312. const messageFileMap = ref(new Map()); // 存储消息对应的文件列表,key为chartId
  313. const classifiedRecent = ref({
  314. today: [],
  315. yesterday: [],
  316. dayBeforeYesterday: [],
  317. within7Days: [],
  318. within30Days: []
  319. });
  320. const classifiedMonthly = ref({});
  321. const enableEdit = (item) => {
  322. item.isEditing = true;
  323. };
  324. const disableEdit = async (item) => {
  325. item.isEditing = false;
  326. try {
  327. await updateTopic(item);
  328. ElMessage.success('主题更新成功');
  329. } catch (error) {
  330. ElMessage.error('主题更新失败');
  331. }
  332. };
  333. const handleAction = (command) => {
  334. const { action, item } = command;
  335. if (action === 'rename') {
  336. enableEdit(item);
  337. } else if (action === 'delete') {
  338. deleteTopic(item);
  339. }
  340. };
  341. // 删除话题
  342. const deleteTopic = async (item) => {
  343. try {
  344. await ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
  345. confirmButtonText: '确定',
  346. cancelButtonText: '取消',
  347. type: 'warning'
  348. });
  349. await delTopic(item.topicId);
  350. ElMessage.success('删除成功');
  351. getList();
  352. } catch (error) {
  353. if (error !== 'cancel') {
  354. ElMessage.error('删除失败');
  355. }
  356. }
  357. };
  358. const createNewChat = async () => {
  359. // 检查今天是否已有"新对话"
  360. const todayNewChat = classifiedRecent.value.today.find(item => item.topic === '新对话');
  361. if (todayNewChat) {
  362. currentTopicId.value = todayNewChat.topicId;
  363. await loadChatMessages(todayNewChat.topicId);
  364. showNewChatWelcome.value = true;
  365. return;
  366. }
  367. try {
  368. const newTopic = {
  369. topic: '新对话',
  370. agentId: 0,
  371. };
  372. const response = await addTopic(newTopic);
  373. await getList();
  374. // 自动选中新建的对话
  375. if (response && response.data && response.data.topicId) {
  376. currentTopicId.value = response.data.topicId;
  377. await loadChatMessages(response.data.topicId);
  378. } else if (response && response.topicId) {
  379. currentTopicId.value = response.topicId;
  380. await loadChatMessages(response.topicId);
  381. } else {
  382. // fallback: 选中最新一条
  383. if (topicList.value.length > 0) {
  384. currentTopicId.value = topicList.value[0].topicId;
  385. await loadChatMessages(topicList.value[0].topicId);
  386. }
  387. }
  388. showNewChatWelcome.value = true;
  389. } catch (error) {
  390. ElMessage.error('创建新对话失败');
  391. }
  392. };
  393. const selectTopic = async (item) => {
  394. currentTopicId.value = item.topicId;
  395. await loadChatMessages(item.topicId);
  396. };
  397. const loadChatMessages = async (topicId) => {
  398. try {
  399. const response = await listChat({ topicId: topicId, pageSize: 1000 });
  400. chatMessages.value = response.rows || [];
  401. // 同时加载具有文件的对话
  402. await loadMessageFiles();
  403. await nextTick();
  404. scrollToBottom();
  405. } catch (error) {
  406. ElMessage.error('加载消息失败');
  407. }
  408. };
  409. const loadMessageFiles = async () => {
  410. try {
  411. // 清空之前的文件映射
  412. messageFileMap.value.clear();
  413. // 获取所有有chatId的消息
  414. const messagesWithChartId = chatMessages.value.filter(msg => msg.chatId);
  415. // 为每个chartId获取文件列表
  416. const promises = messagesWithChartId.map(async (msg) => {
  417. try {
  418. const fileResponse = await listDocument({ chatId: msg.chatId });
  419. if (fileResponse.rows && fileResponse.rows.length > 0) {
  420. messageFileMap.value.set(msg.chatId, fileResponse.rows);
  421. }
  422. } catch (error) {
  423. console.error(`Failed to load files for chatId ${msg.chatId}:`, error);
  424. }
  425. });
  426. await Promise.all(promises);
  427. } catch (error) {
  428. console.error('Failed to load message files:', error);
  429. }
  430. };
  431. const sendMessage = async () => {
  432. if (!inputMessage.value.trim() || isLoading.value) return;
  433. if (!currentTopicId.value) {
  434. await createNewChat();
  435. }
  436. showNewChatWelcome.value = false;
  437. const userMessage = {
  438. userId: userStore.id,
  439. input: inputMessage.value,
  440. topicId: currentTopicId.value,
  441. inputTime: proxy.parseTime(new Date(), '{y}-{m}-{d}'),
  442. chatId: documentChatId.value || null
  443. };
  444. // 添加用户消息到聊天记录
  445. chatMessages.value.push(userMessage);
  446. const messageToSend = inputMessage.value;
  447. inputMessage.value = '';
  448. // 暂存文件上传ID,用于后续查询
  449. const uploadedFileId = documentChatId.value;
  450. // 清空文档chartId,确保每次上传只对下一条消息有效
  451. documentChatId.value = '';
  452. // 清空已上传的文件列表
  453. selectedFiles.value = [];
  454. await nextTick();
  455. scrollToBottom();
  456. // 自动更改当前topic名称为用户问题
  457. try {
  458. // 只有当当前topic名称为"新对话"时才更新为用户问题
  459. const currentTopic = topicList.value.find(item => item.topicId === currentTopicId.value);
  460. if (currentTopic && currentTopic.topic === '新对话') {
  461. await updateTopic({ topicId: currentTopicId.value, topic: messageToSend });
  462. // 同步本地topicList
  463. const updateLocalTopic = (list) => {
  464. for (const item of list) {
  465. if (item.topicId === currentTopicId.value) {
  466. item.topic = messageToSend;
  467. }
  468. }
  469. };
  470. updateLocalTopic(topicList.value);
  471. Object.values(classifiedRecent.value).forEach(updateLocalTopic);
  472. Object.values(classifiedMonthly.value).forEach(updateLocalTopic);
  473. }
  474. } catch (e) { }
  475. // 发送消息到后端,得到回答,并更新到数据库
  476. try {
  477. isLoading.value = true;
  478. let answer;
  479. // 判断是否存在附件,存在则使用getAnswerWithDocument API
  480. if (uploadedFileId) {
  481. answer = await getAnswerWithDocument({ topicId: currentTopicId.value, chatId: uploadedFileId, question: userMessage.input });
  482. } else {
  483. answer = await getAnswer({ topicId: currentTopicId.value, question: userMessage.input });
  484. }
  485. // 使用Vue的响应式更新方法
  486. const messageIndex = chatMessages.value.length - 1;
  487. if (messageIndex >= 0) {
  488. // 使用Vue.set或直接赋值来确保响应式更新
  489. chatMessages.value[messageIndex] = {
  490. ...chatMessages.value[messageIndex],
  491. output: answer[0].content,
  492. outputTime: proxy.parseTime(new Date(), '{y}-{m}-{d}')
  493. };
  494. }
  495. // 保存到数据库
  496. const savedMessage = await addChat(chatMessages.value[messageIndex]);
  497. // 如果保存成功,更新消息的实际ID
  498. if (savedMessage && savedMessage.chatId) {
  499. chatMessages.value[messageIndex].chatId = savedMessage.chatId;
  500. }
  501. // 如果当前消息包含文件,使用上传时的ID查询文件列表
  502. if (uploadedFileId) {
  503. try {
  504. const fileResponse = await listDocument({ chatId: uploadedFileId });
  505. if (fileResponse.rows && fileResponse.rows.length > 0) {
  506. // 使用消息的chatId作为key存储文件列表
  507. const messageChatId = chatMessages.value[messageIndex].chatId;
  508. const keyToUse = messageChatId || uploadedFileId;
  509. messageFileMap.value.set(keyToUse, fileResponse.rows);
  510. // 确保消息有chatId用于显示
  511. if (!chatMessages.value[messageIndex].chatId) {
  512. chatMessages.value[messageIndex].chatId = uploadedFileId;
  513. }
  514. }
  515. } catch (error) {
  516. console.error('Failed to load files for new message:', error);
  517. }
  518. }
  519. isLoading.value = false;
  520. await nextTick();
  521. scrollToBottom();
  522. } catch (error) {
  523. ElMessage.error('发送消息失败');
  524. isLoading.value = false;
  525. }
  526. };
  527. const handleEnter = () => {
  528. sendMessage();
  529. };
  530. const handleCtrlEnter = () => {
  531. inputMessage.value += '\n';
  532. };
  533. const handleFileUpload = () => {
  534. if (fileInput.value) {
  535. fileInput.value.click();
  536. }
  537. };
  538. const handleFileSelect = async (event) => {
  539. const files = Array.from(event.target.files);
  540. if (files.length > 0) {
  541. // 清空input值,允许选择相同文件
  542. event.target.value = '';
  543. // 立即上传文件
  544. try {
  545. isUploading.value = true;
  546. let res = await uploadDocument(files);
  547. documentChatId.value = res.data.chatId;
  548. // 上传成功后,将文件添加到已上传列表
  549. selectedFiles.value = [...selectedFiles.value, ...files];
  550. ElMessage.success(`成功上传 ${files.length} 个文件`);
  551. } catch (error) {
  552. ElMessage.error('文件上传失败');
  553. console.error('File upload error:', error);
  554. } finally {
  555. isUploading.value = false;
  556. }
  557. }
  558. };
  559. const removeFile = (index) => {
  560. selectedFiles.value.splice(index, 1);
  561. };
  562. const formatFileSize = (bytes) => {
  563. if (bytes === 0) return '0 B';
  564. const k = 1024;
  565. const sizes = ['B', 'KB', 'MB', 'GB'];
  566. const i = Math.floor(Math.log(bytes) / Math.log(k));
  567. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  568. };
  569. const scrollToBottom = () => {
  570. if (messageScrollbar.value) {
  571. const scrollbar = messageScrollbar.value.wrapRef;
  572. scrollbar.scrollTop = scrollbar.scrollHeight;
  573. }
  574. };
  575. const formatMessage = (content) => {
  576. // 简单的消息格式化,可以扩展支持markdown等
  577. return content.replace(/\n/g, '<br>');
  578. };
  579. const formatMessageTime = (time) => {
  580. if (!time) return '';
  581. const date = proxy.parseTime(new Date(time), '{y}-{m}-{d}');
  582. return date;
  583. };
  584. const data = reactive({
  585. form: {},
  586. queryParams: {
  587. pageNum: 1,
  588. pageSize: 99999,
  589. topic: null,
  590. agentId: 0,
  591. },
  592. rules: {
  593. }
  594. });
  595. const { queryParams, form, rules } = toRefs(data);
  596. /** 查询cmc聊天主题列表 */
  597. const getList = async () => {
  598. try {
  599. loading.value = true;
  600. let response = await listTopic(queryParams.value);
  601. if (response.total > 0) {
  602. topicList.value = response.rows;
  603. total.value = response.total;
  604. loading.value = false;
  605. hasRecords.value = true;
  606. // 分类近期记录
  607. const today = new Date()
  608. today.setHours(0, 0, 0, 0)
  609. const yesterday = new Date(today)
  610. yesterday.setDate(yesterday.getDate() - 1)
  611. const dayBeforeYesterday = new Date(today)
  612. dayBeforeYesterday.setDate(dayBeforeYesterday.getDate() - 2)
  613. const sevenDaysAgo = new Date(today)
  614. sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
  615. const thirtyDaysAgo = new Date(today)
  616. thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
  617. // 清空之前的分类
  618. classifiedRecent.value = {
  619. today: [],
  620. yesterday: [],
  621. dayBeforeYesterday: [],
  622. within7Days: [],
  623. within30Days: []
  624. };
  625. classifiedMonthly.value = {};
  626. topicList.value.forEach(record => {
  627. const recordDate = new Date(record.createTime)
  628. recordDate.setHours(0, 0, 0, 0)
  629. if (recordDate.getTime() === today.getTime()) {
  630. classifiedRecent.value.today.push(record)
  631. } else if (recordDate.getTime() === yesterday.getTime()) {
  632. classifiedRecent.value.yesterday.push(record)
  633. } else if (recordDate.getTime() === dayBeforeYesterday.getTime()) {
  634. classifiedRecent.value.dayBeforeYesterday.push(record)
  635. } else if (recordDate > sevenDaysAgo) {
  636. classifiedRecent.value.within7Days.push(record)
  637. } else if (recordDate > thirtyDaysAgo) {
  638. classifiedRecent.value.within30Days.push(record)
  639. } else if (recordDate <= thirtyDaysAgo) {
  640. const monthKey = getMonthKey(recordDate)
  641. if (!classifiedMonthly.value[monthKey]) {
  642. classifiedMonthly.value[monthKey] = []
  643. }
  644. classifiedMonthly.value[monthKey].push(record)
  645. }
  646. })
  647. }
  648. } catch (err) {
  649. console.error('获取数据失败:', err)
  650. } finally {
  651. loading.value = false
  652. }
  653. }
  654. // 获取排序后的月份键
  655. const sortedMonthlyKeys = computed(() => {
  656. return Object.keys(classifiedMonthly.value).sort((a, b) => new Date(b) - new Date(a))
  657. })
  658. // 获取月份键 (yyyy-MM)
  659. const getMonthKey = (date) => {
  660. const year = date.getFullYear()
  661. const month = (date.getMonth() + 1).toString().padStart(2, '0')
  662. return `${year}-${month}`
  663. }
  664. // 格式化月份显示
  665. const formatMonth = (monthKey) => {
  666. const [year, month] = monthKey.split('-')
  667. return `${year}-${month}`
  668. }
  669. // 生成扁平化消息列表
  670. const flatMessages = computed(() => {
  671. const arr = []
  672. for (const msg of chatMessages.value) {
  673. // 处理用户消息
  674. if (msg.input && msg.input.trim()) {
  675. const fileList = msg.chatId ? messageFileMap.value.get(msg.chatId) || [] : [];
  676. const userMessage = {
  677. type: 'user',
  678. text: msg.input,
  679. time: msg.inputTime,
  680. id: msg.chatId || (msg.topicId + '_input_' + msg.inputTime),
  681. chatId: msg.chatId,
  682. fileList: fileList
  683. };
  684. arr.push(userMessage);
  685. }
  686. // 处理AI回答消息
  687. if (msg.output && msg.output.trim()) {
  688. arr.push({
  689. type: 'ai',
  690. text: msg.output,
  691. time: msg.outputTime,
  692. id: msg.chatId ? msg.chatId + '_ai' : (msg.topicId + '_output_' + msg.outputTime),
  693. })
  694. }
  695. }
  696. return arr
  697. })
  698. onMounted(() => {
  699. getList();
  700. })
  701. </script>
  702. <style lang="scss" scoped>
  703. .home-view {
  704. display: flex;
  705. width: 100%;
  706. height: calc(100vh - 127px);
  707. background-color: #f5f5f5;
  708. .chat-panel {
  709. display: flex;
  710. width: 100%;
  711. height: 100%;
  712. background-color: white;
  713. border-radius: 8px;
  714. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  715. margin: 8px;
  716. overflow: hidden;
  717. /* 左侧聊天会话面板*/
  718. .session-panel {
  719. background-color: #f8f9fa;
  720. width: 280px;
  721. border-right: 1px solid #e9ecef;
  722. display: flex;
  723. flex-direction: column;
  724. .session-header {
  725. padding: 16px;
  726. border-bottom: 1px solid #e9ecef;
  727. .new-chat-btn {
  728. width: 100%;
  729. height: 40px;
  730. border-radius: 8px;
  731. font-weight: 500;
  732. }
  733. }
  734. .session-scrollbar {
  735. flex: 1;
  736. padding: 8px 0;
  737. }
  738. }
  739. /* 右侧消息记录面板*/
  740. .message-panel {
  741. flex: 1;
  742. display: flex;
  743. flex-direction: column;
  744. background-color: white;
  745. .message-container {
  746. flex: 1;
  747. position: relative;
  748. overflow: hidden;
  749. .message-scrollbar {
  750. height: 100%;
  751. padding: 20px;
  752. overflow-y: auto;
  753. &::-webkit-scrollbar {
  754. width: 6px;
  755. }
  756. &::-webkit-scrollbar-track {
  757. background: #f1f1f1;
  758. border-radius: 3px;
  759. }
  760. &::-webkit-scrollbar-thumb {
  761. background: #c1c1c1;
  762. border-radius: 3px;
  763. &:hover {
  764. background: #a8a8a8;
  765. }
  766. }
  767. }
  768. .welcome-message {
  769. display: flex;
  770. justify-content: center;
  771. align-items: center;
  772. height: 100%;
  773. .welcome-content {
  774. text-align: center;
  775. max-width: 400px;
  776. .welcome-icon {
  777. font-size: 48px;
  778. margin-bottom: 16px;
  779. }
  780. h2 {
  781. color: #333;
  782. margin-bottom: 12px;
  783. font-weight: 600;
  784. }
  785. p {
  786. color: #666;
  787. line-height: 1.6;
  788. margin-bottom: 8px;
  789. }
  790. }
  791. }
  792. .message-item {
  793. display: flex;
  794. margin-bottom: 24px;
  795. align-items: flex-start;
  796. &.user {
  797. flex-direction: row-reverse;
  798. .message-content {
  799. margin-left: 0;
  800. margin-right: 12px;
  801. }
  802. .message-bubble {
  803. background-color: #eff6ff;
  804. color: rgb(0, 0, 0);
  805. border-radius: 18px;
  806. }
  807. }
  808. &.ai {
  809. .message-bubble {
  810. background-color: #f8f9fa;
  811. color: #333;
  812. border-radius: 18px 18px 18px 4px;
  813. }
  814. }
  815. .message-avatar {
  816. flex-shrink: 0;
  817. width: 32px;
  818. height: 32px;
  819. border-radius: 50%;
  820. display: flex;
  821. align-items: center;
  822. justify-content: center;
  823. font-size: 16px;
  824. .user-avatar {
  825. background-color: #007bff;
  826. color: white;
  827. }
  828. .ai-avatar {
  829. background-color: #fff;
  830. display: flex;
  831. align-items: center;
  832. justify-content: center;
  833. width: 32px;
  834. height: 32px;
  835. border-radius: 50%;
  836. overflow: hidden;
  837. border: 1px solid #e9ecef;
  838. .ai-logo {
  839. width: 100%;
  840. height: 100%;
  841. object-fit: cover;
  842. border-radius: 50%;
  843. }
  844. }
  845. }
  846. .message-content {
  847. flex: 1;
  848. margin-left: 12px;
  849. max-width: 70%;
  850. min-width: 0;
  851. .message-bubble {
  852. padding: 12px 16px;
  853. line-height: 1.5;
  854. word-wrap: break-word;
  855. word-break: break-word;
  856. max-width: 100%;
  857. .message-text {
  858. margin-bottom: 4px;
  859. white-space: pre-wrap;
  860. overflow-wrap: break-word;
  861. }
  862. .message-time {
  863. font-size: 12px;
  864. opacity: 0.7;
  865. text-align: right;
  866. }
  867. }
  868. .message-files {
  869. margin-top: 8px;
  870. border: 1px solid #e9ecef;
  871. border-radius: 8px;
  872. background-color: #f8f9fa;
  873. padding: 8px;
  874. .files-header {
  875. display: flex;
  876. align-items: center;
  877. gap: 6px;
  878. font-size: 12px;
  879. color: #666;
  880. margin-bottom: 6px;
  881. font-weight: 500;
  882. .el-icon {
  883. font-size: 14px;
  884. }
  885. }
  886. .files-list {
  887. .file-item-display {
  888. display: flex;
  889. align-items: center;
  890. gap: 6px;
  891. padding: 4px 8px;
  892. background-color: white;
  893. border-radius: 4px;
  894. margin-bottom: 4px;
  895. border: 1px solid #e9ecef;
  896. &:last-child {
  897. margin-bottom: 0;
  898. }
  899. .file-icon {
  900. color: #666;
  901. font-size: 14px;
  902. }
  903. .file-name {
  904. flex: 1;
  905. font-size: 12px;
  906. color: #333;
  907. white-space: nowrap;
  908. overflow: hidden;
  909. text-overflow: ellipsis;
  910. }
  911. .file-size {
  912. font-size: 11px;
  913. color: #999;
  914. white-space: nowrap;
  915. }
  916. }
  917. }
  918. }
  919. }
  920. }
  921. .loading-dots {
  922. display: flex;
  923. gap: 4px;
  924. padding: 8px 0;
  925. span {
  926. width: 8px;
  927. height: 8px;
  928. border-radius: 50%;
  929. background-color: #ccc;
  930. animation: loading 1.4s infinite ease-in-out;
  931. &:nth-child(1) {
  932. animation-delay: -0.32s;
  933. }
  934. &:nth-child(2) {
  935. animation-delay: -0.16s;
  936. }
  937. }
  938. }
  939. }
  940. .input-container {
  941. border-top: 1px solid #e9ecef;
  942. padding: 16px;
  943. background-color: white;
  944. .file-list-container {
  945. margin-bottom: 12px;
  946. border: 1px solid #e9ecef;
  947. border-radius: 8px;
  948. background-color: #f8f9fa;
  949. .file-list-header {
  950. display: flex;
  951. justify-content: space-between;
  952. align-items: center;
  953. padding: 8px 12px;
  954. border-bottom: 1px solid #e9ecef;
  955. font-size: 14px;
  956. font-weight: 500;
  957. color: #333;
  958. .loading-icon {
  959. animation: rotate 1s linear infinite;
  960. color: #007bff;
  961. font-size: 16px;
  962. }
  963. }
  964. .file-list {
  965. max-height: 200px;
  966. overflow-y: auto;
  967. padding: 8px;
  968. .file-item {
  969. display: flex;
  970. justify-content: space-between;
  971. align-items: center;
  972. padding: 8px 12px;
  973. margin-bottom: 4px;
  974. background-color: white;
  975. border-radius: 6px;
  976. border: 1px solid #e9ecef;
  977. transition: all 0.2s ease;
  978. &:hover {
  979. border-color: #007bff;
  980. box-shadow: 0 2px 4px rgba(0, 123, 255, 0.1);
  981. }
  982. &:last-child {
  983. margin-bottom: 0;
  984. }
  985. .file-info {
  986. display: flex;
  987. align-items: center;
  988. flex: 1;
  989. min-width: 0;
  990. .file-icon {
  991. margin-right: 8px;
  992. color: #666;
  993. font-size: 16px;
  994. }
  995. .file-name {
  996. font-size: 14px;
  997. color: #333;
  998. white-space: nowrap;
  999. overflow: hidden;
  1000. text-overflow: ellipsis;
  1001. margin-right: 8px;
  1002. }
  1003. .file-size {
  1004. font-size: 12px;
  1005. color: #999;
  1006. white-space: nowrap;
  1007. }
  1008. }
  1009. .remove-btn {
  1010. color: #dc3545;
  1011. border: none;
  1012. background: transparent;
  1013. padding: 4px;
  1014. min-height: auto;
  1015. &:hover {
  1016. background-color: rgba(220, 53, 69, 0.1);
  1017. color: #dc3545;
  1018. }
  1019. }
  1020. }
  1021. }
  1022. }
  1023. .input-wrapper {
  1024. position: relative;
  1025. border: 1px solid #e9ecef;
  1026. border-radius: 12px;
  1027. padding: 8px;
  1028. background-color: #f8f9fa;
  1029. .message-input {
  1030. border: none;
  1031. background: transparent;
  1032. resize: none;
  1033. :deep(.el-textarea__inner) {
  1034. border: none;
  1035. background: transparent;
  1036. box-shadow: none;
  1037. padding: 8px 60px 8px 8px;
  1038. min-height: 40px;
  1039. max-height: 120px;
  1040. }
  1041. }
  1042. .input-actions {
  1043. position: absolute;
  1044. right: 8px;
  1045. top: 50%;
  1046. transform: translateY(-50%);
  1047. display: flex;
  1048. gap: 8px;
  1049. .action-btn {
  1050. background-color: transparent;
  1051. border: none;
  1052. color: #666;
  1053. &:hover {
  1054. background-color: #e9ecef;
  1055. color: #333;
  1056. }
  1057. }
  1058. .send-btn {
  1059. background-color: #007bff;
  1060. border: none;
  1061. &:hover:not(:disabled) {
  1062. background-color: #0056b3;
  1063. }
  1064. &:disabled {
  1065. background-color: #ccc;
  1066. cursor: not-allowed;
  1067. }
  1068. }
  1069. }
  1070. }
  1071. .input-tips {
  1072. margin-top: 8px;
  1073. text-align: center;
  1074. font-size: 12px;
  1075. color: #999;
  1076. }
  1077. }
  1078. }
  1079. }
  1080. }
  1081. .category-title {
  1082. padding: 8px 16px;
  1083. font-size: 12px;
  1084. font-weight: 600;
  1085. color: #666;
  1086. text-transform: uppercase;
  1087. letter-spacing: 0.5px;
  1088. }
  1089. .record {
  1090. margin: 2px 8px;
  1091. padding: 8px 12px;
  1092. border-radius: 8px;
  1093. cursor: pointer;
  1094. transition: all 0.2s ease;
  1095. position: relative;
  1096. display: flex;
  1097. align-items: center;
  1098. justify-content: space-between;
  1099. &:hover {
  1100. background-color: #e9ecef;
  1101. .record-actions {
  1102. opacity: 1;
  1103. }
  1104. }
  1105. &.active {
  1106. background-color: #007bff;
  1107. color: white;
  1108. .topic-text {
  1109. color: white;
  1110. }
  1111. }
  1112. .topic-text {
  1113. flex: 1;
  1114. font-size: 14px;
  1115. color: #333;
  1116. overflow: hidden;
  1117. text-overflow: ellipsis;
  1118. white-space: nowrap;
  1119. margin-right: 8px;
  1120. }
  1121. .record-actions {
  1122. opacity: 0;
  1123. transition: opacity 0.2s ease;
  1124. .action-icon {
  1125. width: 20px;
  1126. height: 20px;
  1127. padding: 0;
  1128. border: none;
  1129. background: transparent;
  1130. color: #666;
  1131. font-size: 14px;
  1132. &:hover {
  1133. background-color: rgba(255, 255, 255, 0.8);
  1134. color: #333;
  1135. }
  1136. }
  1137. }
  1138. }
  1139. .spacer {
  1140. height: 16px;
  1141. }
  1142. .empty {
  1143. padding: 20px;
  1144. text-align: center;
  1145. color: #999;
  1146. font-size: 14px;
  1147. }
  1148. @keyframes loading {
  1149. 0%,
  1150. 80%,
  1151. 100% {
  1152. transform: scale(0);
  1153. }
  1154. 40% {
  1155. transform: scale(1);
  1156. }
  1157. }
  1158. @keyframes rotate {
  1159. from {
  1160. transform: rotate(0deg);
  1161. }
  1162. to {
  1163. transform: rotate(360deg);
  1164. }
  1165. }
  1166. </style>