Chat.vue 17 KB

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