Browse Source

feat: 调试对话记录保存于查询接口,完善历史记录功能;新增对话终止功能;思维链折叠展示;

hotchicken1996 1 month ago
parent
commit
608e27e499

+ 11 - 6
client/src/hooks/web/useSSE.ts

@@ -17,7 +17,7 @@ export interface AResultRecord {
 export const useSSE = (body: UseSSEQueryBody) => {
   const { mutationFn, onMessage, onError, onSuccess, onOpen } = body
   const data = reactive<AResultRecord>({ content: '', result: {} })
-  const loading = ref<boolean>(false)
+  const isLoading = ref<boolean>(false)
   const ctrlAbout = new AbortController()
 
   // 思考过程更新
@@ -32,12 +32,17 @@ export const useSSE = (body: UseSSEQueryBody) => {
 
   // 对话结束回调
   const answerDone = () => {
-    loading.value = false
+    isLoading.value = false
     onSuccess?.(data)
   }
 
+  const stop = (): void => {
+    ctrlAbout.abort()
+    isLoading.value = false
+  }
+
   const mutate = (paramBody: any) => {
-    loading.value = true
+    isLoading.value = true
     data.content = ''
     data.result = {}
     mutationFn?.({
@@ -48,7 +53,7 @@ export const useSSE = (body: UseSSEQueryBody) => {
       },
       onError: (err) => {
         console.log('error: ', err)
-        ctrlAbout.abort()
+        stop()
         onError?.(err)
       },
       contentChange,
@@ -59,8 +64,8 @@ export const useSSE = (body: UseSSEQueryBody) => {
   }
 
   onBeforeUnmount(() => {
-    ctrlAbout.abort()
+    stop()
   })
 
-  return { data, ctrlAbout, loading, mutate }
+  return { data, isLoading, mutate, stop }
 }

+ 7 - 10
client/src/service/aiService.ts

@@ -63,9 +63,7 @@ export const kbChat = (body: SSEApi): void => {
 }
 
 export const getChatHistories = async (params?: any) => {
-  const data = await request.get({ url: '/ai/chat/list', params }, '/business')
-  console.log('data: ', data)
-  return data
+  return await request.get({ url: '/ai/chat/list', params }, '/business')
 }
 
 export const createChatHistory = async (title: string): Promise<string> => {
@@ -73,22 +71,21 @@ export const createChatHistory = async (title: string): Promise<string> => {
 }
 
 export const deleteChatHistory = async (id: string) => {
-  const result = await request.get({ url: '/ai/chat/delete', params: { id } }, '/business')
-  return result
+  return await request.get({ url: '/ai/chat/delete', params: { id } }, '/business')
 }
 
 export const collectChatHistory = async (data?: any) => {
-  const result = await request.post({ url: '/ai/question/collect', data }, '/business')
-  return result
+  return await request.post({ url: '/ai/question/collect', data }, '/business')
 }
 
-export const getChatHistory = async (chatId: string) => {
-  const result = await request.get(
+export const getChatHistory = async (
+  chatId: string
+): Promise<{ question: string; answer: string }[]> => {
+  return await request.get(
     {
       url: '/ai/question/getList-by-chatId',
       params: { chatId }
     },
     '/business'
   )
-  return result
 }

+ 9 - 17
client/src/views/OaSystem/aiQA/components/AResult.vue

@@ -1,8 +1,7 @@
 <script setup lang="ts">
-import { computed } from 'vue'
 import { AResultRecord } from '@/hooks/web/useSSE'
-import MarkdownView from '@/components/Markdown/MarkdownView.vue'
 import PDFLink from '@/views/OaSystem/aiQA/components/PDFLink.vue'
+import ResultMessageView from '@/views/OaSystem/aiQA/components/ResultMessageView.vue'
 
 interface AResultProps {
   data: AResultRecord
@@ -10,19 +9,12 @@ interface AResultProps {
 
 const props = defineProps<AResultProps>()
 const { data } = toRefs(props)
-const realResultContent = computed(() => {
-  const target = data.value?.result?.['3']?.choices?.[0]?.delta?.content
-  const targetIndex = target?.indexOf('###')
-  return target.substring(targetIndex)
-})
 </script>
 
 <template>
   <div class="a-result-body">
-    <div class="result-text">
-      <MarkdownView :text="realResultContent" />
-    </div>
-    <div class="result-files">
+    <ResultMessageView :data="data?.result?.['3']?.choices?.[0]?.delta?.content" />
+    <div class="result-files" v-if="(data?.result?.['0']?.docs?.length ?? 0) > 0">
       <div class="label">【来源文件】:</div>
       <div class="files-container">
         <div class="file" v-for="file in data?.result?.['0']?.docs" :key="file">
@@ -35,17 +27,12 @@ const realResultContent = computed(() => {
 
 <style scoped lang="scss">
 .a-result-body {
-  .result-text {
-    padding: 15px 20px;
-    background: #ffffff;
-    border-radius: 8px 8px 8px 8px;
-  }
-
   .result-files {
     margin-top: 15px;
     display: flex;
 
     .label {
+      margin-top: 10px;
       flex-shrink: 0;
     }
 
@@ -53,6 +40,11 @@ const realResultContent = computed(() => {
       flex-grow: 1;
 
       .file {
+        padding: 10px;
+        color: #888;
+        margin-bottom: 10px;
+        background: #f1f9ff;
+        border-radius: 8px;
       }
     }
   }

+ 39 - 19
client/src/views/OaSystem/aiQA/components/ChatContent.vue

@@ -8,6 +8,8 @@ import QChat from '@/views/OaSystem/aiQA/components/QChat.vue'
 import { useSSE, type AResultRecord } from '@/hooks/web/useSSE'
 import { collectChatHistory, createChatHistory, getChatHistory, kbChat } from '@/service/aiService'
 import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import ResultMessageView from '@/views/OaSystem/aiQA/components/ResultMessageView.vue'
+import { cloneDeep } from 'lodash-es'
 
 interface ChatContentProps {
   currentQuestion: { text: string; id?: string }
@@ -25,14 +27,21 @@ const currentQuestionId = ref('')
 // 对话
 const {
   data,
+  isLoading,
   mutate: startChat,
-  loading
+  stop
 } = useSSE({
   mutationFn: kbChat,
   onSuccess: (res) => {
     const question = Object.keys(contents)?.at(-1)
-    contents[question] = res
-    //  todo 保存当前历史
+    contents[question] = cloneDeep(res)
+    // 保存当前历史
+    const queryBody = {
+      chatId: currentQuestionId.value,
+      question,
+      answer: res?.result?.['3']?.choices?.[0]?.delta?.content
+    }
+    collectHistory(queryBody)
   }
 })
 
@@ -47,22 +56,22 @@ const { mutate: createHistory } = useMutation({
 })
 
 // 更新历史
-const { mutate: collectHistory } = useMutation({
-  mutationFn: collectChatHistory,
-  onSuccess: (res) => {
-    console.log('collectHistory success:', res)
-  }
-})
+const { mutate: collectHistory } = useMutation({ mutationFn: collectChatHistory })
 
 // 获取历史详情
-useQuery({
+const { isLoading: historyLoading } = useQuery({
   queryKey: ['getChatHistory', currentQuestion],
   queryFn: async () => {
     if ((currentQuestion.value?.id ?? '') === '') return null
     return await getChatHistory(currentQuestion.value.id)
   },
   onSuccess: (res) => {
-    console.log('getChatHistory success:', res)
+    if ((res?.length ?? 0) > 0) {
+      currentQuestionId.value = currentQuestion.value?.id
+      res?.forEach(({ question, answer }) => {
+        contents[question] = { result: { '3': { choices: [{ delta: { content: answer } }] } } }
+      })
+    }
   }
 })
 
@@ -87,18 +96,16 @@ const handleSend = (text): void => {
 watch(
   currentQuestion,
   (newVal) => {
-    const { text, id } = newVal
+    const { text } = newVal
     if ((text ?? '') === '') {
       //重置对话记录
       Object.keys(contents).forEach((key) => {
         delete contents[key]
       })
+      currentQuestionId.value = ''
     } else {
       handleSend(text)
     }
-    if ((id ?? '') !== '') {
-      // 加载历史记录
-    }
   },
   { deep: true }
 )
@@ -106,12 +113,14 @@ watch(
 
 <template>
   <div class="chat-content">
-    <div class="message-body">
+    <div class="message-body" v-loading="historyLoading">
       <template v-if="(Object.keys(contents)?.length ?? 0) > 0">
         <template v-for="(value, key) in contents" :key="key">
           <QChat>{{ key }}</QChat>
-          <!--   思维链    -->
-          <AChat v-if="value == null"> {{ data.content }}...</AChat>
+          <AChat v-if="value == null">
+            <!--   思维链过程    -->
+            <ResultMessageView isLoading defaultOpen :data="data?.content ?? ''" />
+          </AChat>
           <AChat v-else>
             <!--     回答结果     -->
             <AResult :data="value" />
@@ -138,7 +147,8 @@ watch(
         placeholder="请输入问题,可以通过回车换行"
       />
       <div class="q-btn" @click="handleSend(textarea)">
-        <img :src="sendImg" alt="发送" />
+        <div v-if="isLoading" class="stop-btn" @click="stop"></div>
+        <img v-else :src="sendImg" alt="发送" />
       </div>
     </div>
   </div>
@@ -201,12 +211,22 @@ watch(
       overflow: hidden;
       cursor: pointer;
       align-self: flex-end;
+      display: flex;
+      justify-content: center;
+      align-items: center;
 
       img {
         width: 100%;
         height: 100%;
       }
 
+      .stop-btn {
+        width: 20px;
+        height: 20px;
+        background: #fff;
+        border-radius: 2px;
+      }
+
       &:hover {
         opacity: 0.9;
       }

+ 6 - 2
client/src/views/OaSystem/aiQA/components/PDFLink.vue

@@ -18,8 +18,12 @@ const pdfUrl = computed(() => {
 <template>
   <div>
     <el-tooltip content="点击下载" placement="top-end">
-      <a :href="pdfUrl">{{ pdfName }}</a>
+      <a class="pdf-link" :href="pdfUrl">{{ pdfName }}</a>
     </el-tooltip>
   </div>
 </template>
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+.pdf-link {
+  color: #446ae7;
+}
+</style>

+ 71 - 0
client/src/views/OaSystem/aiQA/components/ResultMessageView.vue

@@ -0,0 +1,71 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import MarkdownView from '@/components/Markdown/MarkdownView.vue'
+
+interface ResultMessageProps {
+  data: string
+  isLoading?: boolean
+  defaultOpen?: boolean
+}
+
+const props = defineProps<ResultMessageProps>()
+const { data, isLoading } = toRefs(props)
+const open = ref(props.defaultOpen ? '1' : '')
+
+const thinkContent = computed(() => {
+  const thinkIndex = data.value?.indexOf('</think>')
+  if (thinkIndex > 0) {
+    return data.value?.substring(0, thinkIndex)
+  }
+  const targetIndex = data.value?.indexOf('###')
+  return targetIndex > 0 ? data.value?.substring(0, targetIndex) : data.value
+})
+const realResultContent = computed(() => {
+  const thinkIndex = data.value?.indexOf('</think>')
+  if (thinkIndex > 0) {
+    return data.value?.split('</think>')?.[1]
+  }
+  const targetIndex = data.value?.indexOf('###')
+  return targetIndex > 0 ? data.value?.substring(targetIndex) : ''
+})
+</script>
+
+<template>
+  <div class="result-message">
+    <el-collapse class="think-content" :expand-icon-position="'left'" v-model="open">
+      <el-collapse-item :title="isLoading ? '思考中...' : '已深度思考'" name="1">
+        {{ thinkContent }}
+      </el-collapse-item>
+    </el-collapse>
+    <div class="real-result" v-if="(realResultContent ?? '') !== ''">
+      <MarkdownView :text="realResultContent" />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.result-message {
+  .think-content {
+    --el-collapse-border-color: none;
+    --el-collapse-header-bg-color: none;
+    --el-collapse-content-bg-color: none;
+
+    :deep(.el-collapse-item__header) {
+      position: relative;
+      padding: 0 0 0 20px;
+
+      .el-collapse-item__arrow {
+        position: absolute;
+        left: 0;
+      }
+    }
+  }
+
+  .real-result {
+    margin-top: 10px;
+    padding: 15px 20px;
+    background: #ffffff;
+    border-radius: 8px 8px 8px 8px;
+  }
+}
+</style>

+ 9 - 4
client/src/views/OaSystem/aiQA/components/historyChat.vue

@@ -3,7 +3,7 @@ import newChatIcon from '@/assets/imgs/ai/new_chat_icon.png'
 import msgIcon from '@/assets/imgs/ai/msg-icon.png'
 import deleteIcon from '@/assets/imgs/ai/delete-icon.png'
 import { useMutation, useQuery } from '@tanstack/vue-query'
-import { createChatHistory, deleteChatHistory, getChatHistories } from '@/service/aiService'
+import { deleteChatHistory, getChatHistories } from '@/service/aiService'
 
 interface HistoryChatProps {
   changeQuestion: (q: string, id?: string) => void
@@ -43,10 +43,15 @@ const { mutate: deleteHistory } = useMutation({
       <el-input prefix-icon="Search" type="text" v-model="text" placeholder="搜素历史记录" />
     </div>
     <div class="histories-content">
-      <div class="history-body">
+      <div
+        class="history-body"
+        v-for="{ id, title } in data"
+        :key="id"
+        @click="changeQuestion('', id)"
+      >
         <img class="icon" :src="msgIcon" alt="" />
-        <span class="text">村庄规划的编制要点是什么?</span>
-        <!--        <img class="delete-btn" :src="deleteIcon" alt="" />-->
+        <span class="text">{{ title }}</span>
+        <img class="delete-btn" :src="deleteIcon" alt="删除" @click.stop="deleteHistory(id)" />
       </div>
     </div>
     <div class="footer">