瀏覽代碼

feat(sidepanel): 实现历史记录功能并优化聊天组件

- 新增 historyComponent 组件用于显示历史记录
- 在 Chat 组件中集成历史记录功能
- 添加新对话创建功能
- 优化消息发送和加载逻辑
- 引入 IndexedDB 用于持久化存储聊天记录
wzg 5 月之前
父節點
當前提交
62a7daeec1

文件差異過大導致無法顯示
+ 31 - 15
src/entrypoints/sidepanel/App.vue


+ 81 - 30
src/entrypoints/sidepanel/Chat.vue

@@ -5,8 +5,8 @@
     <el-scrollbar class="message-list" ref="scrollbar">
       <div class="messages">
         <div v-for="(message, index) in messages" :key="index"
-          :class="['message-item', message.isSelf ? 'self' : 'other']">
-          <el-avatar :size="32" :src="message.avatar" />
+             :class="['message-item', message.isSelf ? 'self' : 'other']">
+          <el-avatar :size="32" :src="message.avatar"/>
           <div class="message-content">
             <div class="content" v-if="!message.isSelf" :class="{ 'loading-content': message.content === '' }">
               <span v-html="message.content"></span>
@@ -25,21 +25,25 @@
       </div>
     </el-scrollbar>
 
-    <Tools @read-click="readClick" @upload-file="handleUpload" @handle-capture="handleCapture" />
+    <Tools @read-click="readClick"
+           @upload-file="handleUpload"
+           @handle-capture="handleCapture"
+           @his-records="hisRecords"
+           @add-new-dialogue="addNewDialogue"/>
 
     <div>
       <!-- 输入区域 -->
       <div class="input-area">
         <div v-show="isShowPage" style="border-bottom: 1px solid #F0F0F0;">
           <div class="card-content">
-            <img :src="pageInfo?.favIconUrl" style="width: 24px;" />
+            <img :src="pageInfo?.favIconUrl" style="width: 24px;"/>
             <div class="title-wrapper">
               <div class="title-scroller" :class="{ 'scroll': isHoveringTitle && titleScroll }">
                 {{ pageInfo?.title }}
               </div>
             </div>
             <el-icon size="16px" @click="isShowPage = false; taklToHtml = false">
-              <CircleClose />
+              <CircleClose/>
             </el-icon>
           </div>
           <div class="card-btn">
@@ -53,41 +57,50 @@
           </div>
         </div>
 
-        <el-input ref="textareaRef" v-model="inputMessage" type="textarea" :rows="3" placeholder="输入消息..." @keyup.enter="() => {
-          addMessage(inputMessage.trim(), true)
-          inputMessage = ''
-        }" />
+        <el-input ref="textareaRef" v-model="inputMessage" type="textarea" :rows="3" placeholder="输入消息..."
+                  @keyup.enter="handleAsk"/>
         <div class="chat_area_op">
           <el-button type="primary" link @click="handleAsk" :disabled="!inputMessage.trim() || sendLoading">
             <el-icon size="18" :color="inputMessage.trim() ? 'black' : 'gray'">
-              <Promotion />
+              <Promotion/>
             </el-icon>
           </el-button>
         </div>
       </div>
 
     </div>
+
+
+    <!--  历史记录  -->
+    <historyComponent :msgUuid="msgUuid" ref="historyComponentRef" @currentData="(e)=>handleCurrentData(e)"/>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick, inject, useTemplateRef } from 'vue'
-import { ElScrollbar, ElAvatar, ElInput, ElButton } from 'element-plus'
-import { buildExcelUnderstandingPrompt } from '@/utils/ai-service.js'
+import {ref, onMounted, nextTick, inject, useTemplateRef} from 'vue'
+import {ElScrollbar, ElAvatar, ElInput, ElButton} from 'element-plus'
+import moment from "moment";
+import {buildExcelUnderstandingPrompt} from '@/utils/ai-service.js'
 import * as XLSX from "xlsx";
-import { ElMessage } from 'element-plus';
-import { useMsg } from '@/entrypoints/sidepanel/hook/useMsg.ts';
+import {ElMessage} from 'element-plus';
+import {useMsg} from '@/entrypoints/sidepanel/hook/useMsg.ts';
 import Tools from "@/entrypoints/sidepanel/component/tools.vue";
-import { useSummary } from '@/entrypoints/sidepanel/hook/useSummary.ts'
-import { mockData } from "@/entrypoints/sidepanel/mock"
-import { useAutoResizeTextarea } from '@/entrypoints/sidepanel/hook/useAutoResizeTextarea.ts';
+import historyComponent from '@/entrypoints/sidepanel/component/historyComponent.vue';
+import {useSummary} from '@/entrypoints/sidepanel/hook/useSummary.ts'
+import {mockData, startMsg} from "@/entrypoints/sidepanel/mock"
+import {useAutoResizeTextarea} from '@/entrypoints/sidepanel/hook/useAutoResizeTextarea.ts';
+
 
 // 滚动条引用
 const scrollbar = ref(null);
 const xlsxData = ref({});
 const isShowPage = ref(false);
 const tareRef = useTemplateRef("textareaRef");
+const drawerRef = useTemplateRef("historyComponentRef");
+// 获取父组件提供的 Hook 实例
+const {registerStore, useStore} = inject('indexedDBHook');
 const {
+  msgUuid,
   messages,
   inputMessage,
   indexTemp,
@@ -101,7 +114,7 @@ const {
   streamRes,
   handleInput
 } = useMsg(scrollbar, xlsxData, fetchDataAndProcess);
-const { handleCardButtonClick } = useSummary(addMessage, sendRequese);
+const {handleCardButtonClick} = useSummary(addMessage, sendRequese);
 
 function handelIntelligentFillingClick() {
   if (type.value !== '2') {
@@ -112,13 +125,41 @@ function handelIntelligentFillingClick() {
   }
 }
 
+function handleCurrentData(e) {
+  drawerRef.value.drawer = false;
+  if (!e) {
+    addNewDialogue();
+    return;
+  }
+  // 添加indexDB Store配置
+  msgUuid.value = e;
+  useStore(e).getAll().then((res) => {
+    messages.value = res
+    nextTick(() => {
+      scrollbar.value?.setScrollTop(99999)
+    })
+  })
+}
+
 async function readClick() {
   isShowPage.value = true;
   taklToHtml.value = true;
   await getPageInfo()
 }
 
-function handleAsk() {
+function addNewDialogue() {
+  messages.value = [];
+  msgUuid.value = 'D' + Date.now().toString();
+  registerStore({
+    name: msgUuid.value,
+    keyPath: 'id',
+  })
+  messages.value.push(startMsg);
+  useStore(msgUuid.value).add(startMsg);
+}
+
+async function handleAsk() {
+  if (sendLoading.value) return;
   addMessage(inputMessage.value.trim(), true);
   inputMessage.value = '';
 }
@@ -131,6 +172,10 @@ function handleCapture() {
   });
 }
 
+function hisRecords() {
+  drawerRef.value.drawer = true;
+}
+
 // 计算标题是否需要滚动
 const titleScroll = computed(() => {
   return pageInfo.value?.title?.length > 20 // 当标题超过20个字符时触发滚动
@@ -190,9 +235,9 @@ const handleUpload = (file) => {
 }
 
 async function fetchDataAndProcess(input, obj) {
-  console.log(input);
+  // console.log(input);
 
-  const pageInfo = await getPageInfo();
+  // const pageInfo = await getPageInfo();
 
   // 发起请求获取数据
   // const res = await hepl({
@@ -201,7 +246,7 @@ async function fetchDataAndProcess(input, obj) {
   // })
   const res = await new Promise((resolve, reject) => {
     setTimeout(() => {
-      resolve({ data: mockData[indexTemp.value] })
+      resolve({data: mockData[indexTemp.value]})
     }, 1000)
   })
   await handleClick(res.data, obj);
@@ -224,8 +269,8 @@ async function handleClick(res, msgObj) {
           indexTemp.value++
           fetchDataAndProcess('', msgObj)
         } else {
-          ElMessage({ message: '操作执行完成', type: 'success', duration: 2 * 1000, grouping: true })
-          index = 0
+          ElMessage({message: '操作执行完成', type: 'success', duration: 2 * 1000, grouping: true})
+          // index = 0
         }
       }
       return true
@@ -233,7 +278,7 @@ async function handleClick(res, msgObj) {
   }
   if (res.action === 'show') {
     msgObj.content = `请上传数据`
-    ElMessage({ message: '请上传数据', type: 'success', duration: 4 * 1000, grouping: true })
+    ElMessage({message: '请上传数据', type: 'success', duration: 4 * 1000, grouping: true})
   }
   //     chrome.runtime.sendMessage({
   //          type: 'FROM_SIDE_PANEL_TO_ACTION',
@@ -260,7 +305,7 @@ async function handleClick(res, msgObj) {
 const isHoveringTitle = ref(false)
 
 // 组件挂载时滚动到底部
-onMounted(async () => {
+onMounted(() => {
   useAutoResizeTextarea(tareRef, inputMessage);
   chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
     if (message.type === 'TO_SIDE_PANEL_PAGE_INFO') {
@@ -294,9 +339,17 @@ onMounted(async () => {
         isHoveringTitle.value = false
       })
     }
-
     scrollbar.value?.setScrollTop(99999)
   })
+
+  // 添加indexDB Store配置
+  msgUuid.value = 'D' + Date.now().toString();
+  registerStore({
+    name: msgUuid.value,
+    keyPath: 'id',
+  })
+  messages.value.push(startMsg);
+  useStore(msgUuid.value).add(startMsg);
 })
 </script>
 
@@ -526,8 +579,6 @@ onMounted(async () => {
 }
 
 
-
-
 .input-area {
   padding: 8px 10px;
   color: black;

+ 262 - 0
src/entrypoints/sidepanel/component/historyComponent.vue

@@ -0,0 +1,262 @@
+<script setup lang="ts">
+import {ref, inject} from 'vue';
+import {Search, Delete} from "@element-plus/icons-vue";
+
+const drawer = ref(false);
+const count = ref(0);
+const input = ref('');
+const loading = ref(false);
+const dataList = ref<any[]>([]);
+// 获取父组件提供的 Hook 实例
+const {db, useStore, deleteDB} = inject('indexedDBHook') as any;
+const emit = defineEmits(['currentData']);
+
+const props = defineProps({
+  msgUuid: {
+    type: String,
+    default: ''
+  }
+})
+watch(drawer, (newVal) => {
+  if (newVal) {
+    loading.value = true;
+    getFirstTwoRecordsFromEachStore(db.value).then((data: any) => {
+      dataList.value = data.filter((item: any) => item !== null);
+      count.value = dataList.value.length || 0;
+      loading.value = false;
+      // console.log('First two records from each store:', data);
+    }).catch((error) => {
+      console.error('Error:', error);
+    });
+  }
+})
+
+function handleDeleteDB() {
+  deleteDB().then((res) => {
+    console.log('Database deleted successfully.', res);
+    // // 重新加载页面
+    // window.location.reload();
+  }).catch((error: any) => {
+    console.error('Error deleting database:', error);
+  });
+}
+
+function getFirstTwoRecordsFromObjectStoreWithCursor(db: any, storeName: any) {
+  return new Promise((resolve, reject) => {
+    const transaction = db.transaction([storeName], 'readonly');
+    const objectStore = transaction.objectStore(storeName);
+    const request = objectStore.openCursor();
+    const results: any[] = [];
+    let count = 0;
+
+    request.onerror = (event: any) => {
+      reject(`Failed to get data from object store ${storeName}: ${event.target.error}`);
+    };
+
+    request.onsuccess = (event: any) => {
+      const cursor = event.target.result;
+      if (cursor && count < 2) {
+        results.push(cursor.value);
+        count++;
+        cursor.continue();
+      } else {
+        if (results.length) {
+          resolve({
+            storeName: storeName,
+            data: results
+          });
+        } else {
+          resolve(null)
+        }
+      }
+    };
+  });
+}
+
+async function getFirstTwoRecordsFromEachStore(db: any) {
+  return new Promise((resolve, reject) => {
+    // 获取所有对象存储的名称
+    const storeNames = Array.from(db.objectStoreNames);
+    const result: any[] = [];
+
+    // 遍历每个对象存储并获取前 2 条数据
+    const promises = storeNames.map((storeName) => {
+      return getFirstTwoRecordsFromObjectStoreWithCursor(db, storeName);
+    });
+
+    // 等待所有对象存储的数据读取完成
+    Promise.all(promises).then((dataArrays) => {
+      // 将每个对象存储的数据合并到结果数组中
+      dataArrays.forEach((data: any) => {
+        result.push(data);
+      });
+      resolve(result);
+    }).catch((error) => {
+      reject(error);
+    });
+  })
+}
+
+function handleDeleteStore(e: any, item: any) {
+  e.stopPropagation();
+  if (item === props.msgUuid) {
+    const result = getNextOrPreviousId(dataList.value, item);
+    emit('currentData', result)
+  }
+
+  useStore(item).clearAll().then((res: any) => {
+    loading.value = true;
+    getFirstTwoRecordsFromEachStore(db.value).then((data: any) => {
+      dataList.value = data.filter((item: any) => item !== null);
+      count.value = dataList.value.length || 0;
+      console.log(dataList.value)
+      loading.value = false;
+      // console.log('First two records from each store:', data);
+    }).catch((error) => {
+      console.error('Error:', error);
+    });
+  });
+}
+
+
+function getNextOrPreviousId(array: any, currentId: any) {
+  const currentIndex = array.findIndex((item: any) => item.storeName === currentId);
+
+  if (currentIndex === -1) {
+    throw new Error("当前 storeName 不在数组中");
+  }
+
+  if (currentIndex < array.length - 1) {
+    return array[currentIndex + 1].storeName; // 返回下一个对象的 id
+  } else if (currentIndex > 0) {
+    return array[currentIndex - 1].storeName; // 返回上一个对象的 id
+  }
+
+  return null; // 没有下一个或上一个对象
+}
+
+defineExpose({
+  drawer
+})
+</script>
+
+<template>
+  <el-drawer style="height: 70%" v-model="drawer" direction="btt" :show-close="true"
+             :close-on-click-modal="false" :destroy-on-close="true"
+             :close-on-press-escape="false" class="custom_drawer">
+
+    <template #header>
+      <div class="his_flex"><span class="his_title">历史聊天</span><span class="his_count">({{ count }})</span></div>
+    </template>
+    <div style="height: 100%;overflow: hidden;" v-loading="loading">
+      <div class="his_delete">
+        <el-input style="margin-right: 12px" v-model="input" placeholder="搜索" clearable :prefix-icon="Search"/>
+        <el-button :icon="Delete" circle @click="handleDeleteDB"/>
+      </div>
+      <div class="his_content">
+        <template v-for="item in dataList" :key="item.storeName">
+          <div :class="`his_list ${msgUuid === item.storeName ? 'his_list_change' : '' }`"
+               @click="emit('currentData',item.storeName)">
+            <p class="ellipsis" style="color:#000000;font-weight: 900;">{{ item.data[0]?.content ?? '--' }}</p>
+            <p class="ellipsis" style="color: #888888">{{ item.data[1] ? item.data[1].content : '--' }}</p>
+            <p class="his_list_op">
+              <span>{{ item.data[0]?.timestamp }}</span>
+              <el-button :icon="Delete" link @click="(e:any)=>handleDeleteStore(e,item.storeName)"/>
+            </p>
+          </div>
+        </template>
+      </div>
+    </div>
+  </el-drawer>
+</template>
+
+<style lang="scss">
+.his_flex {
+  display: flex;
+  align-items: center;
+}
+
+.custom_drawer {
+  height: 70vh !important;
+  border-top-left-radius: 15px;
+  border-top-right-radius: 15px;
+
+  .el-drawer__header {
+    padding: 16px;
+    margin-bottom: 0;
+  }
+
+  .el-drawer__close-btn, .el-drawer__body {
+    padding: 0;
+  }
+
+  .el-drawer__body {
+    overflow: hidden;
+    height: 100%;
+  }
+}
+
+.his_title {
+  display: inline-block;
+  color: #000000;
+  font-size: 20px;
+  font-weight: 900;
+  margin-right: 3px;
+  height: 24px;
+  line-height: 24px;
+}
+
+.his_count {
+  display: inline-block;
+  height: 24px;
+  line-height: 24px;
+}
+
+.his_delete {
+  padding: 0 12px 10px;
+  display: flex;
+  position: sticky;
+  align-items: center;
+  justify-content: space-between;
+
+  .is-circle {
+    border-radius: 8px;
+  }
+}
+
+.his_content {
+  height: calc(100% - 40px);
+  overflow: auto;
+  padding: 0 12px;
+
+  .ellipsis {
+    white-space: nowrap; /* 强制文本不换行 */
+    overflow: hidden; /* 隐藏溢出内容 */
+    text-overflow: ellipsis; /* 显示省略号 */
+    width: 100%; /* 设置宽度(必须) */
+  }
+
+  .his_list {
+    font-size: 12px;
+    padding: 5px 10px;
+    border-radius: 8px;
+    margin-bottom: 4px;
+    box-shadow: 0 0 6px rgba(122, 89, 255, .16);
+    cursor: pointer;
+
+    .his_list_op {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+    }
+  }
+
+  .his_list:hover {
+    background-color: rgba(122, 89, 255, .06);
+  }
+
+  .his_list_change {
+    background-color: rgba(122, 89, 255, .2) !important;
+  }
+}
+</style>

+ 36 - 19
src/entrypoints/sidepanel/component/tools.vue

@@ -1,33 +1,44 @@
 <script setup lang="ts">
 import { ref } from "vue";
 import { options } from "@/entrypoints/sidepanel/mock";
-import { Reading, Upload, Paperclip, Scissor } from "@element-plus/icons-vue";
+import { Reading, Upload, Paperclip, Scissor, AlarmClock, CirclePlus } from "@element-plus/icons-vue";
 
 const value = ref(options[0].value);
 
-const emit = defineEmits(['readClick', 'uploadFile','handleCapture'])
+const emit = defineEmits(['readClick', 'uploadFile', 'handleCapture', 'addNewDialogue', 'hisRecords'])
 </script>
 
 <template>
   <div class="tool_content">
-    <el-select v-model="value" placeholder="Select" style="width: 120px">
-      <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
-    </el-select>
-    <span class="separator"></span>
-    <el-tooltip effect="dark" content="阅读此页,开启后将会根据左侧网页中的内容做出回答" placement="top">
-      <el-button :icon="Reading" circle @click="emit('readClick')" />
-    </el-tooltip>
-    <span class="separator"></span>
-    <el-upload style="display:inline-block" :before-upload="(file: any) => emit('uploadFile', file)" :multiple="false"
-      name="file" :show-file-list="false" :accept="'.xlsx'">
-      <el-tooltip effect="dark" content="文件上传" placement="top">
-        <el-button :icon="Paperclip" circle />
+    <div class="tool_content_flex">
+      <el-select v-model="value" placeholder="Select" style="width: 120px">
+        <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
+      </el-select>
+      <span class="separator"></span>
+      <el-tooltip effect="dark" content="阅读此页,开启后将会根据左侧网页中的内容做出回答" placement="top">
+        <el-button style="font-size:14px" :icon="Reading" circle @click="emit('readClick')" />
       </el-tooltip>
-    </el-upload>
-    <span class="separator"></span>
-    <el-tooltip effect="dark" content="截屏" placement="top">
-      <el-button :icon="Scissor" circle  @click="emit('handleCapture')"/>
-    </el-tooltip>
+      <span class="separator"></span>
+      <el-upload style="display:inline-block" :before-upload="(file: any) => emit('uploadFile', file)" :multiple="false"
+        name="file" :show-file-list="false" :accept="'.xlsx'">
+        <el-tooltip effect="dark" content="文件上传" placement="top">
+          <el-button style="font-size:14px" :icon="Paperclip" circle />
+        </el-tooltip>
+      </el-upload>
+      <span class="separator"></span>
+      <el-tooltip effect="dark" content="截屏" placement="top">
+        <el-button style="font-size:14px" :icon="Scissor" circle @click="emit('handleCapture')" />
+      </el-tooltip>
+    </div>
+    <div class="tool_content_flex">
+      <el-tooltip effect="dark" content="历史记录" placement="top">
+        <el-button style="font-size:14px" :icon="AlarmClock" circle @click="emit('hisRecords')" />
+      </el-tooltip>
+      <span class="separator"></span>
+      <el-tooltip effect="dark" content="新对话" placement="top">
+        <el-button style="font-size:14px" :icon="CirclePlus" circle @click="emit('addNewDialogue')" />
+      </el-tooltip>
+    </div>
   </div>
 </template>
 
@@ -36,6 +47,12 @@ const emit = defineEmits(['readClick', 'uploadFile','handleCapture'])
   padding: 8px 12px;
   display: flex;
   align-items: center;
+  justify-content: space-between;
+
+  .tool_content_flex {
+    display: flex;
+    align-items: center;
+  }
 
   .separator {
     margin-right: 8px;

+ 210 - 0
src/entrypoints/sidepanel/hook/useIndexedDB.ts

@@ -0,0 +1,210 @@
+import {ref, readonly} from 'vue'
+
+interface IndexedDBStoreConfig {
+    name: string
+    keyPath: string
+    indexes?: Array<{
+        name: string
+        keyPath: string | string[]
+        options?: IDBIndexParameters
+    }>
+}
+
+interface IndexedDBConfig {
+    dbName: string
+    version?: number
+}
+
+// 全局存储Store配置
+const storeRegistry = new Map<string, IndexedDBStoreConfig>()
+
+export function useIndexedDB(config: IndexedDBConfig) {
+    const dbName = ref<any>(config.dbName);
+    const db = ref<IDBDatabase | null>(null)
+    const currentVersion = ref(config?.version || 1)
+    const isInitialized = ref(false)
+
+    // 注册Store配置(业务模块调用)
+    const registerStore = (storeConfig: IndexedDBStoreConfig) => {
+        if (!storeRegistry.has(storeConfig.name)) {
+            storeRegistry.set(storeConfig.name, storeConfig)
+        }
+    }
+    const deleteDB = () => {
+        return new Promise((resolve, reject) => {
+            const request = indexedDB.deleteDatabase(dbName.value);
+            window.location.reload();
+            request.onsuccess = () => {
+                resolve(true)
+            }
+            request.onerror = () => {
+                reject(request.error)
+            }
+        })
+    }
+    const openDB = (): Promise<IDBDatabase> => {
+        return new Promise((resolve, reject) => {
+            const request = indexedDB.open(dbName.value, currentVersion.value)
+
+            request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
+
+                const db = (event.target as IDBOpenDBRequest).result;
+
+                // 从注册中心获取所有Store配置
+                const stores = Array.from(storeRegistry.values())
+
+                stores.forEach(storeConfig => {
+                    if (!db.objectStoreNames.contains(storeConfig.name)) {
+                        const objectStore = db.createObjectStore(
+                            storeConfig.name,
+                            {keyPath: storeConfig.keyPath}
+                        )
+
+                        storeConfig.indexes?.forEach(index => {
+                            objectStore.createIndex(
+                                index.name,
+                                index.keyPath,
+                                index.options
+                            )
+                        })
+                    }
+                })
+            }
+
+            request.onsuccess = (event: Event) => {
+                db.value = (event.target as IDBOpenDBRequest).result
+                isInitialized.value = true
+                Array.from(db.value.objectStoreNames).forEach((storeName: any) => {
+                    registerStore({
+                        name: storeName,
+                        keyPath: 'id',
+                    })
+                })
+                resolve(db.value)
+            }
+
+            request.onerror = (event: Event) => {
+                reject((event.target as IDBOpenDBRequest).error)
+            }
+        })
+    }
+
+    // 动态升级数据库版本
+    const upgradeVersion = async () => {
+        currentVersion.value += 1
+        localStorage.setItem('dbVersion', currentVersion.value.toString());
+        db.value?.close()
+        return openDB()
+    }
+
+    const useStore = <T>(storeName: string) => {
+        const verifyStore = async () => {
+            if (!storeRegistry.has(storeName)) {
+                throw new Error(`Store ${storeName} not registered`)
+            }
+
+            if (db.value && !db.value.objectStoreNames.contains(storeName)) {
+                await upgradeVersion()
+            }
+        }
+
+        const executeWithTransaction = async <R>(
+            mode: IDBTransactionMode,
+            operation: (store: IDBObjectStore) => Promise<R>
+        ): Promise<R> => {
+            await verifyStore();
+
+            if (!db.value) await openDB();
+
+            return new Promise((resolve, reject) => {
+                db.value;
+                const transaction = db.value!.transaction(storeName, mode);
+                const objectStore = transaction.objectStore(storeName);
+
+                // 事务成功完成时的回调
+                // transaction.oncomplete = () => {
+                //     resolve(); // 事务成功完成,返回结果
+                // };
+
+                // 事务失败时的回调
+                transaction.onerror = (event) => {
+                    reject((event.target as IDBRequest).error);
+                };
+
+                // 执行操作
+                operation(objectStore).then((result) => {
+                    // 操作成功,返回结果
+                    resolve(result);
+                }).catch((error) => {
+                    // 操作失败,拒绝 Promise
+                    reject(error);
+                });
+            });
+        };
+
+
+        return {
+            add: (data: T) => executeWithTransaction(
+                'readwrite',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.add(data)
+                    request.onsuccess = () => resolve(request.result as IDBValidKey)
+                    request.onerror = () => reject(request.error)
+                })
+            ),
+
+            get: (key: IDBValidKey) => executeWithTransaction(
+                'readonly',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.get(key)
+                    request.onsuccess = () => resolve(request.result as T | undefined)
+                    request.onerror = () => reject(request.error)
+                })
+            ),
+
+            update: (data: T) => executeWithTransaction(
+                'readwrite',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.put(data)
+                    request.onsuccess = () => resolve(request.result as IDBValidKey)
+                    request.onerror = () => reject(request.error)
+                })
+            ),
+
+            delete: (key: IDBValidKey) => executeWithTransaction(
+                'readwrite',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.delete(key)
+                    request.onsuccess = () => resolve(undefined)
+                    request.onerror = () => reject(request.error)
+                })
+            ),
+
+            getAll: () => executeWithTransaction(
+                'readonly',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.getAll()
+                    request.onsuccess = () => resolve(request.result as T[])
+                    request.onerror = () => reject(request.error)
+                })
+            ),
+            clearAll: () => executeWithTransaction(
+                'readwrite',
+                (store) => new Promise((resolve, reject) => {
+                    const request = store.clear()
+                    request.onsuccess = () => resolve(true)
+                    request.onerror = () => reject(request.error)
+                })
+            )
+        }
+    }
+
+    return {
+        db,
+        openDB,
+        deleteDB,
+        registerStore,
+        useStore,
+        isInitialized
+    }
+}

+ 163 - 163
src/entrypoints/sidepanel/hook/useMsg.ts

@@ -1,187 +1,187 @@
 // 消息数组
-import { ref, reactive } from 'vue';
+import {ref, reactive, nextTick, inject} from 'vue';
 import avator from '@/public/icon/32.png';
 import moment from 'moment'
+
 // import { sendMessage } from '@/utils/ai-service';
-export function useMsg(scrollbar: any, xlsxData: any, fetchDataAndProcess: Function) {
-  const inputMessage = ref('');
-  const indexTemp = ref(0);
-  const taklToHtml = ref<any>(false);
-  const sendLoading = ref(false);
-  const pageInfo = ref<any>({});
-  const type = ref('');
-  const formMap = ref([]);
-  const messages = ref([{
-    username: '用户1',
-    content: '你好!有什么我可以帮助你的吗?',
-    rawContent: '你好!有什么我可以帮助你的吗?',
-    timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
-    isSelf: false,
-    avatar: avator
-  }]);
+export function useMsg(scrollbar?: any, xlsxData?: any, fetchDataAndProcess?: Function) {
+    const msgUuid = ref<string>();
+    const inputMessage = ref('');
+    const indexTemp = ref(0);
+    const taklToHtml = ref<any>(false);
+    const sendLoading = ref(false);
+    const pageInfo = ref<any>({});
+    const type = ref('');
+    const formMap = ref([]);
+    const messages = ref<any>([]);
+    // 获取父组件提供的 Hook 实例
+    const {useStore} = inject('indexedDBHook') as any;
+
+    // 发送消息
+    const addMessage = (msg: any, fetch: any) => {
+        if (!msg) return
+        const newMessage: any = {
+            id: messages.value.length + 1,
+            username: '我',
+            content: msg,
+            timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
+            isSelf: true,
+            avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
+            addToHistory: !taklToHtml.value
+        }
+        messages.value.push(newMessage)
+        useStore(msgUuid.value).add(newMessage)
 
-  // 发送消息
-  const addMessage = (msg: any, fetch: any) => {
-    if (!msg) return
-    const newMessage: any = {
-      id: messages.value.length + 1,
-      username: '我',
-      content: msg,
-      timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
-      isSelf: true,
-      avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
-      addToHistory: !taklToHtml.value
+        // 滚动到底部
+        nextTick(() => {
+            scrollbar.value?.setScrollTop(99999);
+            fetch && sendRequese(msg, taklToHtml.value);
+        })
     }
-    messages.value.push(newMessage)
 
-    // 滚动到底部
-    nextTick(() => {
-      scrollbar.value?.setScrollTop(99999);
-      fetch && sendRequese(msg, taklToHtml.value);
-    })
-  }
+    const getPageInfo = () => {
+        return new Promise((res, rej) => {
+            chrome.runtime.sendMessage({
+                type: 'FROM_SIDE_PANEL_TO_GET_PAGE_INFO',
+            }, async (response) => {
+                if (chrome.runtime.lastError) {
+                    console.error("消息发送错误:", chrome.runtime.lastError);
+                    rej(chrome.runtime.lastError)
+                } else {
+                    pageInfo.value = response.data
+                    res(response.data)
+                }
+            });
+        })
+    }
 
-  const getPageInfo = () => {
-    return new Promise((res, rej) => {
-      chrome.runtime.sendMessage({
-        type: 'FROM_SIDE_PANEL_TO_GET_PAGE_INFO',
-      }, async (response) => {
-        if (chrome.runtime.lastError) {
-          console.error("消息发送错误:", chrome.runtime.lastError);
-          rej(chrome.runtime.lastError)
+    const sendRequese = async (msg: any, addHtml = false) => {
+        const res: any = await getPageInfo()
+        if (type.value === '2' && msg.startsWith('/')) {
+            indexTemp.value = 0
+            await fetchRes(msg)
         } else {
-          pageInfo.value = response.data
-          res(response.data)
+            if (!addHtml) msg = getSummaryPrompt(res.content)
+            await streamRes(msg, addHtml)
         }
-      });
-    })
-  }
-
-  const sendRequese = async (msg: any, addHtml = false) => {
-    const res: any = await getPageInfo()
-    if (type.value === '2' && msg.startsWith('/')) {
-      indexTemp.value = 0
-      fetchRes(msg)
-    }
-    else {
-      if (!addHtml) msg = getSummaryPrompt(res.content)
-      streamRes(msg, addHtml)
     }
-  }
 
-  const fetchRes = async (msg: any) => {
-    sendLoading.value = true
-    const obj: any = reactive({
-      id: moment(),
-      username: '用户1',
-      content: '',
-      timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
-      isSelf: false,
-      avatar: avator,
-      addToHistory: !taklToHtml.value
-    })
-    messages.value.push(obj)
-    scrollbar.value?.setScrollTop(99999);
-    if (type.value === '2') {
-      await fetchDataAndProcess(msg, obj)
-      sendLoading.value = false
+    const fetchRes = async (msg: any) => {
+        sendLoading.value = true
+        const obj: any = reactive({
+            id: moment(),
+            username: '用户1',
+            content: '',
+            timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
+            isSelf: false,
+            avatar: avator,
+            addToHistory: !taklToHtml.value
+        })
+        messages.value.push(obj)
+        scrollbar.value?.setScrollTop(99999);
+        if (type.value === '2') {
+            fetchDataAndProcess && await fetchDataAndProcess(msg, obj)
+            sendLoading.value = false
+        }
     }
-  }
 
+    const streamRes = async (msg: any, addHtml: any) => {
+        sendLoading.value = true;
+        const obj = reactive<any>({
+            id:messages.value.length + 1,
+            username: '用户1',
+            content: '',
+            rawContent: '', // 存储原始内容
+            timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
+            isSelf: false,
+            avatar: avator,
+            addToHistory: !taklToHtml.value
+        });
+        let history = []
+        if (taklToHtml.value) {
+            if (addHtml) {
+                history.push({
+                    role: 'user',
+                    content: `页面主要内容${pageInfo.value.content.mainContent}`
+                })
+            }
+            history.push({
+                role: 'user',
+                content: msg
+            })
+        } else {
+            history = messages.value
+                .filter((item: any) => item.addToHistory).slice(-20)
+                .map((item: any) => ({
+                    role: item.isSelf ? 'user' : 'system',
+                    content: item.isSelf ? item.content : item.rawContent
+                }))
+        }
 
-  const streamRes = async (msg: any, addHtml: any) => {
-    sendLoading.value = true;
-    const obj = reactive({
-      username: '用户1',
-      content: '',
-      rawContent: '', // 存储原始内容
-      timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
-      isSelf: false,
-      avatar: avator,
-      addToHistory: !taklToHtml.value
-    });
-    let history = []
-    if (taklToHtml.value) {
-      if (addHtml) {
-        history.push({
-          role: 'user',
-          content: `页面主要内容${pageInfo.value.content.mainContent}`
+        messages.value.push(obj)
+        nextTick(() => {
+            scrollbar.value?.setScrollTop(99999)
         })
-      }
-      history.push({
-        role: 'user',
-        content: msg
-      })
-    } else {
-      history = messages.value
-        .filter((item: any) => item.addToHistory).slice(-20)
-        .map((item: any) => ({
-          role: item.isSelf ? 'user' : 'system',
-          content: item.isSelf ? item.content : item.rawContent
-        }))
-    }
+        const iterator = await sendMessage(history, addHtml)
 
-    messages.value.push(obj)
-    nextTick(() => {
-      scrollbar.value?.setScrollTop(99999)
-    })
-    const iterator = await sendMessage(history, addHtml)
-
-    for await (const chunk of iterator) {
-      if (chunk) {
-        const decodedChunk = chunk.choices[0].delta.content;
-        if (decodedChunk) {
-          // 保存原始内容
-          obj.rawContent += decodedChunk
-          // 实时格式化显示内容
-          obj.content = formatMessage(obj.rawContent)
+        for await (const chunk of iterator) {
+            if (chunk) {
+                const decodedChunk = chunk.choices[0].delta.content;
+                if (decodedChunk) {
+                    // 保存原始内容
+                    obj.rawContent += decodedChunk
+                    // 实时格式化显示内容
+                    obj.content = formatMessage(obj.rawContent)
+                }
+            }
+            scrollbar.value?.setScrollTop(99999)
         }
-      }
-      scrollbar.value?.setScrollTop(99999)
-    }
-    scrollbar.value?.setScrollTop(99999)
+        scrollbar.value?.setScrollTop(99999)
+        //添加到存储历史
+        useStore(msgUuid.value).add({...obj})
 
-    // 处理最终内容
-    if (type.value === '2') {
-      try {
-        formMap.value = JSON.parse(obj.rawContent.split('json')[1].split('```')[0])
+        // 处理最终内容
+        if (type.value === '2') {
+            try {
+                formMap.value = JSON.parse(obj.rawContent.split('json')[1].split('```')[0])
 
-        handleInput()
-        type.value = ''
-      } catch (e) {
-        console.error('解析JSON失败:', e)
-      }
+                handleInput()
+                type.value = ''
+            } catch (e) {
+                console.error('解析JSON失败:', e)
+            }
+        }
+        sendLoading.value = false
+        nextTick(() => {
+            scrollbar.value?.setScrollTop(99999)
+        })
     }
-    sendLoading.value = false
-    nextTick(() => {
-      scrollbar.value?.setScrollTop(99999)
-    })
-  }
 
-  const handleInput = () => {
-    const arr = xlsxData.value;
-    chrome.runtime.sendMessage({
-      type: 'FROM_SIDE_PANEL_TO_INPUT_FORM',
-      data: {
-        excelData: arr,
-        formData: formMap.value
-      }
-    });
-  }
+    const handleInput = () => {
+        const arr = xlsxData.value;
+        chrome.runtime.sendMessage({
+            type: 'FROM_SIDE_PANEL_TO_INPUT_FORM',
+            data: {
+                excelData: arr,
+                formData: formMap.value
+            }
+        });
+    }
 
-  return {
-    messages,
-    inputMessage,
-    indexTemp,
-    taklToHtml,
-    pageInfo,
-    sendLoading,
-    formMap,
-    type,
-    addMessage,
-    sendRequese,
-    getPageInfo,
-    streamRes,
-    handleInput
-  }
+    return {
+        msgUuid,
+        messages,
+        inputMessage,
+        indexTemp,
+        taklToHtml,
+        pageInfo,
+        sendLoading,
+        formMap,
+        type,
+        addMessage,
+        sendRequese,
+        getPageInfo,
+        streamRes,
+        handleInput
+    }
 }

+ 61 - 24
src/entrypoints/sidepanel/mock.ts

@@ -1,28 +1,65 @@
+import moment from "moment/moment";
+import avator from "@/public/icon/32.png";
+
 export const options = [
-  {
-    value: 'DeepSeek-R1',
-    label: 'DeepSeek-R1',
-  },
-  {
-    value: 'DeepSeek-V3',
-    label: 'DeepSeek-V3',
-  },
-  {
-    value: 'GPT-4o mini',
-    label: 'GPT-4o mini',
-  },
-  {
-    value: 'Claude 3.5',
-    label: 'Claude 3.5',
-  },
-  {
-    value: 'DeepSeek-R1 14B',
-    label: 'DeepSeek-R1 14B',
-  },
+    {
+        value: 'DeepSeek-R1',
+        label: 'DeepSeek-R1',
+    },
+    {
+        value: 'DeepSeek-V3',
+        label: 'DeepSeek-V3',
+    },
+    {
+        value: 'GPT-4o mini',
+        label: 'GPT-4o mini',
+    },
+    {
+        value: 'Claude 3.5',
+        label: 'Claude 3.5',
+    },
+    {
+        value: 'DeepSeek-R1 14B',
+        label: 'DeepSeek-R1 14B',
+    },
 ];
 
 export const mockData = [
-  { action: 'click', class: "ant-menu-item", tag: "li", innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>", id: "", text: "项目建档", next: "是" },
-  { action: 'click', class: "ant-menu-item", tag: "button", innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>", id: "", text: "新增", next: "是" },
-  { action: 'show', class: "ant-menu-item", tag: "button", innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>", id: "", text: "请上传数据", next: "是" },
-]
+    {
+        action: 'click',
+        class: "ant-menu-item",
+        tag: "li",
+        innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>",
+        id: "",
+        text: "项目建档",
+        next: "是"
+    },
+    {
+        action: 'click',
+        class: "ant-menu-item",
+        tag: "button",
+        innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>",
+        id: "",
+        text: "新增",
+        next: "是"
+    },
+    {
+        action: 'show',
+        class: "ant-menu-item",
+        tag: "button",
+        innerHTML: "<span class=\"ant-menu-item-icon\"><span role=\"img\" aria-label=\"book\" class=\"anticon anticon-book\"></span></span><span class=\"ant-menu-title-content\"><span>项目建档</span></span>",
+        id: "",
+        text: "请上传数据",
+        next: "是"
+    },
+]
+
+export const startMsg = {
+    id: 1,
+    username: '用户1',
+    content: '你好!有什么我可以帮助你的吗?',
+    rawContent: '你好!有什么我可以帮助你的吗?',
+    timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
+    isSelf: false,
+    avatar: avator
+}

部分文件因文件數量過多而無法顯示