Chat.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <!-- Chat.vue -->
  2. <template>
  3. <div class="chat-container">
  4. <div v-if="!messages.length" class="message-list">
  5. <pageMask />
  6. </div>
  7. <!-- 消息列表 -->
  8. <div class="message-list" v-else>
  9. <el-scrollbar ref="scrollbar" @scroll="handleScroll">
  10. <!-- 加载更多指示器 -->
  11. <div v-if="isLoadingMore" class="loading-more-indicator">
  12. <div class="loading-spinner"></div>
  13. <span>加载更多消息...</span>
  14. </div>
  15. // 添加一个 ref 用于获取消息列表容器
  16. const messagesContainer = ref(null)
  17. // 修改模板中的消息列表 div,添加 ref
  18. <div class="messages" ref="messagesContainer">
  19. <div v-for="(message, index) in messages" :key="index"
  20. :class="['message-item', message.role === 'user' ? 'self' : 'other']">
  21. <el-avatar :size="32" :src="message.role === 'user' ? userAvatar : avatar" />
  22. <div class="message-content">
  23. <div class="content" v-if="message.role ==='system'"
  24. :class="{ 'loading-content': message.content === '' }">
  25. <formTable v-if="message.type === 'form'" :content="message.rawContent" />
  26. <span v-else v-html="message.content"></span>
  27. <span class="loading-indicator" v-if="sendLoading && index === messages.length - 1">
  28. <span class="dot"></span>
  29. <span class="dot"></span>
  30. <span class="dot"></span>
  31. </span>
  32. </div>
  33. <document v-else-if="message.type === 'document' && message.role === 'user'" :content="message.content"
  34. :rawContent="message.rawContent" />
  35. <div v-else class="content">
  36. <span v-if="message.type === ''">{{ message.content }}</span>
  37. <span class="loading-indicator" v-if="sendLoading && index === messages.length - 1">
  38. <span class="dot"></span>
  39. <span class="dot"></span>
  40. <span class="dot"></span>
  41. </span>
  42. </div>
  43. <div class="timestamp ">{{ moment(message.sortKey).format('MM-DD HH:mm') }}
  44. <span v-if="message.add" style="cursor: pointer;" @click="handleInput">填充</span>
  45. </div>
  46. </div>
  47. </div>
  48. </div>
  49. </el-scrollbar>
  50. <ScrollToBottom :target="scrollbar" ref="scrollToBottomRef" />
  51. </div>
  52. <Tools @read-click="readClick" @upload-file="handleUpload" @handle-capture="handleCapture" @his-records="hisRecords"
  53. @add-new-dialogue="addNewDialogue" @handle-current-change="handleCurrentChange"
  54. @handel-intelligent-filling-click="handelIntelligentFillingClick" />
  55. <div>
  56. <!-- 输入区域 -->
  57. <div class="input-area">
  58. <div v-show="isShowPage" style="border-bottom: 1px solid #F0F0F0;">
  59. <div class="card_list">
  60. <div v-for="(v, i) in pageInfoList" :key="i"
  61. :class="`card-content ${pageInfoList.length > 1 ? 'card_width' : ''}`">
  62. <img :src="v?.favIconUrl" style="width: 24px;display: block" />
  63. <div class="title-wrapper">
  64. <span class="els title-scroller">{{ v?.title }}</span>
  65. <span class="els url-scroller">{{ v?.url }}</span>
  66. </div>
  67. <el-icon class="closeIcon" size="16px" color="#909399" @click="deletePageInfo(i)">
  68. <CircleClose />
  69. </el-icon>
  70. </div>
  71. </div>
  72. <div v-show="type !== FunctionList.Intelligent_Form_filling" class="card-btn">
  73. <el-tooltip content="总结当前页面" placement="top">
  74. <el-button round @click="handleSummary">总结</el-button>
  75. </el-tooltip>
  76. </div>
  77. </div>
  78. <el-input ref="textareaRef" v-model="inputMessage" type="textarea" :rows="3" placeholder="输入消息..."
  79. @keyup.enter="handleAsk" />
  80. <div class="chat_area_op">
  81. <el-button
  82. :style="`background-color:${inputMessage.trim() ? '#4d6bfe' : '#d6dee8'};border-color:${inputMessage.trim() ? '#4d6bfe' : '#d6dee8'}`"
  83. v-if="inputMessage.trim() || !sendLoading" type="primary" circle @click="handleAsk"
  84. :disabled="!inputMessage.trim() || sendLoading">
  85. <svg-icon icon-class="send" color="#000000" />
  86. </el-button>
  87. <el-button style="background-color:#ffffff;border:2px solid rgb(134 143 153);" v-else circle
  88. @click="handleStopAsk">
  89. <svg-icon icon-class="stop" color="red" />
  90. </el-button>
  91. </div>
  92. </div>
  93. </div>
  94. <!-- 历史记录 -->
  95. <historyComponent :msgUuid="msgUuid" ref="historyComponentRef" @currentData="(e) => handleCurrentData(e)" />
  96. </div>
  97. </template>
  98. <script setup>
  99. import { ref, onMounted, nextTick, inject, useTemplateRef, reactive } from 'vue'
  100. import { ElScrollbar, ElAvatar, ElInput, ElButton } from 'element-plus'
  101. import moment from 'moment'
  102. import fileLogo from '@/assets/svg/file.svg'
  103. import {
  104. buildExcelUnderstandingPrompt,
  105. getSummaryPrompt,
  106. getFileContent,
  107. buildObjPrompt,
  108. modelFileUpload,
  109. controllerList
  110. } from '@/entrypoints/sidepanel/utils/ai-service.js'
  111. import { storeToRefs } from 'pinia'
  112. import { ElMessage } from 'element-plus'
  113. import { useMsg } from '@/entrypoints/sidepanel/hook/useMsg.ts'
  114. import Tools from '@/entrypoints/sidepanel/component/tools.vue'
  115. import historyComponent from '@/entrypoints/sidepanel/component/historyComponent.vue'
  116. import document from '@/entrypoints/sidepanel/component/document.vue'
  117. import pageMask from '@/entrypoints/sidepanel/component/pageMask.vue'
  118. import ScrollToBottom from '@/entrypoints/sidepanel/component/ScrollToBottom.vue'
  119. import formTable from '@/entrypoints/sidepanel/component/formTable.vue'
  120. import userAvatar from '@/assets/images/user.png'
  121. import avatar from '@/public/icon/32.png'
  122. import { mockData, startMsg, mockData2, options, FunctionList } from '@/entrypoints/sidepanel/mock'
  123. import { useAutoResizeTextarea } from '@/entrypoints/sidepanel/hook/useAutoResizeTextarea.ts'
  124. import { getPageInfo, getXlsxValue, handleInput } from './utils/index.js'
  125. import { useMsgStore } from '@/store/modules/msg.ts'
  126. import { useUserStore } from '@/store/modules/user'
  127. import { putChat } from "@/api/index.js";
  128. import {debounce} from 'lodash'
  129. const userStore = useUserStore()
  130. import { getChatDetail } from '@/api/index.js'
  131. // 在其他状态变量附近添加
  132. const isLoadingMore = ref(false)
  133. import { v4 as uuidv4 } from 'uuid';
  134. // 滚动条引用
  135. const scrollbar = ref(null)
  136. const scrollToBottomRef = ref(null)
  137. const xlsxData = ref({})
  138. const isShowPage = ref(false)
  139. const tareRef = useTemplateRef('textareaRef')
  140. const drawerRef = useTemplateRef('historyComponentRef')
  141. // 获取父组件提供的 Hook 实例
  142. const msgStore = useMsgStore()
  143. const { pageInfoList, messages, msgUuid, AIModel,page,hasNext } = storeToRefs(useMsgStore())
  144. const formInfo = ref('')
  145. const {
  146. taklToHtml,
  147. sendLoading,
  148. type,
  149. streamRes,
  150. getFormKeyAndValue,
  151. requestFlowFn,
  152. fetchRes
  153. } = useMsg(scrollbar)
  154. const inputMessage = ref('')
  155. const pageInfo = ref('')
  156. function handleStopAsk() {
  157. if (type.value === FunctionList.Intelligent_Form_filling) {
  158. inputMessage.value = ''
  159. pageInfoList.value = []
  160. isShowPage.value = false
  161. taklToHtml.value = false
  162. type.value = FunctionList.File_Operation
  163. }
  164. controllerList.value[0].abort()
  165. controllerList.value = []
  166. sendLoading.value = false
  167. }
  168. function handleScroll(a) {
  169. const { wrapRef } = scrollbar.value
  170. const { scrollHeight, clientHeight } = wrapRef
  171. scrollToBottomRef.value.showButton = scrollHeight - a.scrollTop - clientHeight >= 350
  172. // 检测是否滚动到顶部
  173. if (a.scrollTop <= 10 && hasNext.value) {
  174. handleScrollToTop()
  175. }
  176. }
  177. const messagesContainer = ref(null)
  178. let innerText = ''
  179. // 处理滚动到顶部的事件
  180. const handleScrollToTop = debounce(async function () {
  181. console.log('滚动到顶部,可以加载更多历史消息')
  182. if (!sendLoading.value && !isLoadingMore.value) {
  183. isLoadingMore.value = true
  184. try {
  185. // 记录当前第一条消息的位置
  186. const firstMessage = messagesContainer.value?.firstElementChild.querySelector('.timestamp')
  187. innerText = firstMessage.innerText
  188. const oldHeight = firstMessage?.offsetTop || 0
  189. await msgStore.changePage()
  190. setTimeout(() => {
  191. if (firstMessage) {
  192. console.log([...messagesContainer.value.querySelectorAll('.timestamp')].find(_ => _.innerText === innerText).offsetTop, innerText, 778);
  193. const newHeight = [...messagesContainer.value.querySelectorAll('.timestamp')].find(_ => _.innerText === innerText).offsetTop
  194. const scrollOffset = newHeight - oldHeight
  195. scrollbar.value?.setScrollTop(scrollOffset)
  196. }
  197. }, 400);
  198. // 恢复滚动位置
  199. } finally {
  200. isLoadingMore.value = false
  201. }
  202. }
  203. }, 500, { leading: false, trailing: true })
  204. /**
  205. * @param {string} msg
  206. * @param {string} raw
  207. * @param {string} type 发送的类型 document 、text
  208. * **/
  209. async function addMessage(msg, raw, type) {
  210. console.log(msg);
  211. const newMessage = reactive({
  212. id:+new Date(),
  213. type: type || '',
  214. rawContent: raw ?? msg,
  215. senderId: userStore.token,
  216. receiverId:-1, //大模型
  217. content: msg,
  218. sortKey: moment().valueOf(),
  219. role: 'user',
  220. conversationId:msgUuid.value,
  221. addToHistory: `${!taklToHtml.value}`
  222. })
  223. if (type === 'document') {
  224. newMessage.content = msg
  225. newMessage.rawContent = raw
  226. }
  227. if (!msg) return
  228. messages.value.push(newMessage)
  229. await nextTick(() => {
  230. scrollbar.value?.setScrollTop(99999)
  231. })
  232. return newMessage
  233. }
  234. const handleSummary = async () => {
  235. let params = []
  236. if (AIModel.value.file === true) {
  237. pageInfoList.value.forEach((pageInfo) => {
  238. params.push({ role: 'system', content: `fileid://${pageInfo.fileId}` })
  239. })
  240. params.push({
  241. role: 'user', content: `
  242. 请根据这几个文件的文本内容综合分析总结
  243. 要求:
  244. 1.抽取文件的文本内容
  245. 2.对每个文件中的文本内容进行分别总结
  246. 3.对步骤二总结的内容进行综合总结`
  247. })
  248. } else {
  249. let tempStr = ''
  250. pageInfoList.value.forEach((pageInfo, index) => {
  251. pageInfo.handleText = getSummaryPrompt(pageInfo.content)
  252. tempStr += `第${index + 1}段:${pageInfo.handleText}\n`
  253. })
  254. params = [{
  255. role: 'user',
  256. content: `请根据以下这几个内容综合总结出结果:
  257. ${tempStr}要求:
  258. 1. 用简洁清晰的语言提取所有的核心要点
  259. 2. 保持客观中立的语气
  260. 3. 按重要性排序
  261. 4. 返回内容做好换行,以及展示样式
  262. 5. 请以"以下是对该文件内容的总结:"开头,然后用要点的形式列出主要内容。
  263. 6. 对这几个内容进行综合分析及联想`
  264. }]
  265. }
  266. const msg = await addMessage(JSON.stringify(pageInfoList.value), JSON.stringify(params.map(_ => _.content)), 'document')
  267. putChat(msg)
  268. if (requestFlowFn) {
  269. isShowPage.value = false
  270. taklToHtml.value = false
  271. pageInfoList.value = []
  272. const res = await requestFlowFn(params)
  273. }
  274. }
  275. async function handelIntelligentFillingClick() {
  276. isShowPage.value = true
  277. taklToHtml.value = true
  278. const tempPageInfo = await getPageInfo()
  279. pageInfo.value = tempPageInfo
  280. pageInfoList.value = [tempPageInfo]
  281. inputMessage.value = '/智能填表 '
  282. type.value = FunctionList.Intelligent_Form_filling
  283. }
  284. function handleCurrentChange(e) {
  285. options.forEach((item) => {
  286. item.options.forEach((item2) => {
  287. if (item2.value === e) {
  288. msgStore.updateAIModel(item2)
  289. }
  290. })
  291. })
  292. if (AIModel.value.file === true) {
  293. isShowPage.value = false
  294. taklToHtml.value = false
  295. pageInfoList.value = []
  296. }
  297. }
  298. async function handleCurrentData(e) {
  299. drawerRef.value.drawer = false
  300. if (!e) {
  301. addNewDialogue()
  302. return
  303. }
  304. messages.value = []
  305. page.value = 1
  306. msgUuid.value = e
  307. chrome.storage.local.set({ msgUuid: msgUuid.value })
  308. await msgStore.initMsg()
  309. scrollbar.value?.setScrollTop(99999)
  310. }
  311. async function readClick() {
  312. if (type.value === FunctionList.Intelligent_Form_filling) {
  313. pageInfoList.value = []
  314. type.value = FunctionList.File_Operation
  315. }
  316. isShowPage.value = true
  317. taklToHtml.value = true
  318. if (pageInfoList.value.length >= Number(import.meta.env.VITE_MAX_FILE_NUMBER)) {
  319. ElMessage.warning(`最多添加${import.meta.env.VITE_MAX_FILE_NUMBER}个文件`)
  320. return
  321. }
  322. const tempPageInfo = await getPageInfo()
  323. if (AIModel.value.file === true) {
  324. const blob = new Blob([tempPageInfo.content.mainContent], { type: 'text/plain' })
  325. const file = new File([blob], tempPageInfo.title + '.txt', { type: 'text/plain' })
  326. tempPageInfo.fileId = await modelFileUpload(file)
  327. }
  328. pageInfoList.value.unshift(tempPageInfo)
  329. }
  330. function deletePageInfo(i) {
  331. pageInfoList.value.splice(i, 1)
  332. if (pageInfoList.value.length === 0) {
  333. isShowPage.value = false
  334. taklToHtml.value = false
  335. if (type.value === FunctionList.Intelligent_Form_filling) inputMessage.value = ''
  336. type.value = FunctionList.File_Operation
  337. }
  338. }
  339. function addNewDialogue() {
  340. if (messages.value.length === 0) {
  341. ElMessage.warning('已经是新对话')
  342. return
  343. }
  344. isShowPage.value = false
  345. taklToHtml.value = false
  346. messages.value = []
  347. page.value = 1
  348. pageInfoList.value = []
  349. msgUuid.value = uuidv4()
  350. chrome.storage.local.set({ msgUuid: msgUuid.value })
  351. }
  352. async function handleAsk() {
  353. const str = inputMessage.value.trim()
  354. inputMessage.value = ''
  355. if (sendLoading.value) return
  356. const msg = await addMessage(str)
  357. putChat(msg)
  358. if (type.value === FunctionList.Intelligent_Form_filling) {
  359. const res = await fetchRes(str)
  360. if (res.status === 'ok') {
  361. formInfo.value = res.data
  362. console.log(res, 55558)
  363. } else {
  364. type.value = FunctionList.File_Operation
  365. isShowPage.value = false
  366. taklToHtml.value = false
  367. pageInfoList.value = []
  368. }
  369. } else streamRes(taklToHtml.value)
  370. }
  371. function handleCapture() {
  372. ElMessage({
  373. message: '开发中...',
  374. grouping: true,
  375. showClose: true
  376. })
  377. }
  378. function hisRecords() {
  379. drawerRef.value.drawer = true
  380. }
  381. const handleUpload = async (file) => {
  382. if (AIModel.value.file === true) {
  383. const id = await modelFileUpload(file)
  384. pageInfoList.value.unshift({
  385. fileId: id,
  386. title: file.name,
  387. url: 'File',
  388. favIconUrl: fileLogo
  389. })
  390. isShowPage.value = true
  391. taklToHtml.value = true
  392. return
  393. }
  394. if (type.value === FunctionList.Intelligent_Form_filling) {
  395. const fileExtension = file.name.split('.').pop().toLowerCase()
  396. if (fileExtension === 'xlsx') {
  397. const readData = await getXlsxValue(file)
  398. readData[0].forEach((header, i) => {
  399. // if (!xlsxData.value[header]) xlsxData.value[header] = []
  400. xlsxData.value[header] = readData[1][i]
  401. })
  402. const msg = addMessage(`已上传文件:${file.name}`, buildExcelUnderstandingPrompt(readData[0], file?.name, formInfo.value))
  403. putChat(msg)
  404. const { rawContent, status } = await streamRes()
  405. if (status === 'ok') {
  406. let form = []
  407. if (rawContent.includes('json')) form = JSON.parse(rawContent.split('json')[1].split('```')[0])
  408. else form = JSON.parse(rawContent)
  409. handleInput(xlsxData.value, form)
  410. }
  411. } else {
  412. try {
  413. sendLoading.value = true
  414. const msg = await addMessage(`文件上传中`)
  415. putChat(msg)
  416. const data = await getFileValue(file)
  417. msg.content = `已上传文件:${file.name}`
  418. msg.rawContent = data
  419. const res2 = await getFormKeyAndValue(data, formInfo.value)
  420. xlsxData.value = res2.data
  421. msg.rawContent = buildObjPrompt(res2.data, formInfo.value)
  422. const { rawContent, status } = await streamRes()
  423. if (status === 'ok') {
  424. let form = []
  425. if (rawContent.includes('json')) form = JSON.parse(rawContent.split('json')[1].split('```')[0])
  426. else form = JSON.parse(rawContent)
  427. handleInput(xlsxData.value, form)
  428. }
  429. } catch (error) {
  430. msg.content = '文件解析出错'
  431. } finally {
  432. sendLoading.value = false
  433. // putChat({
  434. // id: msgUuid.value,
  435. // msg: msg
  436. // })
  437. }
  438. }
  439. type.value = FunctionList.File_Operation
  440. isShowPage.value = false
  441. taklToHtml.value = false
  442. pageInfoList.value = []
  443. return
  444. }
  445. if (type.value === FunctionList.File_Operation) {
  446. let formData = new FormData()
  447. formData.append('file', file)
  448. const res = await getFileContent(formData)
  449. pageInfoList.value.unshift({
  450. title: file.name,
  451. url: 'File',
  452. favIconUrl: fileLogo,
  453. content: {
  454. mainContent: res.data
  455. }
  456. })
  457. isShowPage.value = true
  458. }
  459. }
  460. async function getFileValue(file) {
  461. let formData = new FormData()
  462. formData.append('file', file)
  463. const res = await getFileContent(formData)
  464. return res.data
  465. }
  466. let a = null
  467. // 组件挂载时滚动到底部
  468. onMounted(async () => {
  469. msgStore.updateAIModel(options[0].options[0])
  470. await msgStore.initMsg()
  471. useAutoResizeTextarea(tareRef, inputMessage)
  472. chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  473. if (message.type === 'TO_SIDE_PANEL_PAGE_INFO') {
  474. pageInfo.value = message.data
  475. // 转发到 content.js
  476. chrome.tabs.sendMessage(sender.tab.id, {
  477. type: 'TO_CONTENT_SCRIPT',
  478. data: message.data
  479. })
  480. }
  481. if (message.type === 'TO_SIDE_PANEL_PAGE_CHANGE') {
  482. pageInfo.value = message.data
  483. }
  484. })
  485. nextTick(() => {
  486. scrollbar.value?.setScrollTop(99999)
  487. })
  488. })
  489. </script>
  490. <style lang="scss" scoped>
  491. @use '@/entrypoints/sidepanel/css/chat.scss';
  492. .loading-more-indicator {
  493. display: flex;
  494. align-items: center;
  495. justify-content: center;
  496. padding: 10px 0;
  497. color: #909399;
  498. font-size: 14px;
  499. .loading-spinner {
  500. width: 20px;
  501. height: 20px;
  502. margin-right: 8px;
  503. border: 2px solid #e6e6e6;
  504. border-top-color: #4d6bfe;
  505. border-radius: 50%;
  506. animation: spin 1s linear infinite;
  507. }
  508. }
  509. @keyframes spin {
  510. to {
  511. transform: rotate(360deg);
  512. }
  513. }
  514. </style>