Ver código fonte

feat: 新增markdown展示通用组件;重构对话数据格式,支持多个对话同时存在

hotchicken1996 1 mês atrás
pai
commit
591ef8bc11

+ 2 - 0
client/package.json

@@ -64,6 +64,7 @@
     "js-cookie": "^2.2.1",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
+    "markdown-it": "^14.1.0",
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "moment": "^2.29.4",
@@ -94,6 +95,7 @@
     "@purge-icons/generated": "^0.9.0",
     "@types/intro.js": "^5.1.1",
     "@types/lodash-es": "^4.17.9",
+    "@types/markdown-it": "^14.1.2",
     "@types/node": "^20.6.0",
     "@types/nprogress": "^0.2.0",
     "@types/qrcode": "^1.5.2",

+ 23 - 0
client/src/components/Markdown/MarkdownView.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import MarkdownIt from 'markdown-it'
+
+const MD = new MarkdownIt()
+
+interface MarkdownViewProps {
+  text: string
+}
+
+const props = defineProps<MarkdownViewProps>()
+const { text } = toRefs(props)
+const html = computed(() => MD.render(text.value))
+</script>
+
+<template>
+  <div class="markdown-view" v-html="html"></div>
+</template>
+
+<style scoped lang="scss">
+.markdown-view {
+}
+</style>

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

@@ -5,16 +5,17 @@ interface UseSSEQueryBody {
   mutationFn: (body: SSEApi) => void
   onMessage?: (data: any) => void
   onError?: (err: any) => void
+  onSuccess?: (data: AResultRecord) => void
 }
 
-interface AResult {
+export interface AResultRecord {
   content: string // 思考过程
   result: object // 最终结果(含有多个对象,通过状态为key区分)
 }
 
-const useSSE = (body: UseSSEQueryBody) => {
-  const { mutationFn, onMessage, onError } = body
-  const data = reactive<AResult>({ content: '', result: {} })
+export const useSSE = (body: UseSSEQueryBody) => {
+  const { mutationFn, onMessage, onError, onSuccess } = body
+  const data = reactive<AResultRecord>({ content: '', result: {} })
   const loading = ref<boolean>(false)
   const ctrlAbout = new AbortController()
 
@@ -31,10 +32,13 @@ const useSSE = (body: UseSSEQueryBody) => {
   // 对话结束回调
   const answerDone = () => {
     loading.value = false
+    onSuccess?.(data)
   }
 
   const mutation = (paramBody: any) => {
     loading.value = true
+    data.content = ''
+    data.result = {}
     mutationFn?.({
       paramBody,
       signal: ctrlAbout.signal,
@@ -58,5 +62,3 @@ const useSSE = (body: UseSSEQueryBody) => {
 
   return { data, ctrlAbout, loading, mutation }
 }
-
-export default useSSE

+ 0 - 2
client/src/interface/ai.ts

@@ -1,5 +1,3 @@
-import { AbortSignal } from 'node/globals'
-
 export interface SSEApi {
   paramBody: KbChatParam
   signal: AbortSignal

+ 59 - 0
client/src/views/OaSystem/aiQA/components/AResult.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { AResultRecord } from '@/hooks/web/useSSE'
+import MarkdownView from '@/components/Markdown/MarkdownView.vue'
+
+interface AResultProps {
+  data: AResultRecord
+}
+
+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">
+      <div class="label"></div>
+      <div class="files-container">
+        <div class="file" v-for="file in data?.result?.['0']?.docs" :key="file">
+          &lt;!&ndash;    todo 从字符串中解析数据展示     &ndash;&gt;
+          <a :href="file" target="_blank">{{ file }}</a>
+        </div>
+      </div>
+    </div>-->
+  </div>
+</template>
+
+<style scoped lang="scss">
+.a-result-body {
+  .result-text {
+    padding: 15px 20px;
+    background: #ffffff;
+    border-radius: 8px 8px 8px 8px;
+  }
+
+  .result-files {
+    display: flex;
+
+    .label {
+      flex-shrink: 0;
+    }
+
+    .files-container {
+      flex-grow: 1;
+
+      .file {
+      }
+    }
+  }
+}
+</style>

+ 43 - 17
client/src/views/OaSystem/aiQA/components/ChatContent.vue

@@ -1,53 +1,79 @@
 <script setup lang="ts">
 import { watch } from 'vue'
 import AChat from '@/views/OaSystem/aiQA/components/AChat.vue'
+import AResult from '@/views/OaSystem/aiQA/components/AResult.vue'
 import ExampleChat from '@/views/OaSystem/aiQA/components/ExampleChat.vue'
 import sendImg from '@/assets/imgs/ai/send.png'
 import QChat from '@/views/OaSystem/aiQA/components/QChat.vue'
-import useSSE from '@/hooks/web/useSSE'
+import { useSSE, type AResultRecord } from '@/hooks/web/useSSE'
 import { kbChat } from '@/service/aiService'
 
 interface ChatContentProps {
-  currentQuestion: string
-  changeQuestion: (q: string) => void
+  currentQuestion: string[]
+  changeQuestion: (q: string, init?: boolean) => void
 }
 
 const props = defineProps<ChatContentProps>()
 const { currentQuestion } = toRefs(props)
 const { changeQuestion } = props
 const textarea = ref('')
+const contents = reactive<{ string: AResultRecord }>({}) //对话记录,以问题为key保存
 
-const { data, mutation: startChat } = useSSE({ mutationFn: kbChat })
+const { data, mutation: startChat } = useSSE({
+  mutationFn: kbChat,
+  onSuccess: (res) => {
+    const question = currentQuestion.value[currentQuestion.value.length - 1]
+    contents[question] = res
+  }
+})
 
-const handleClear = () => {
-  changeQuestion('')
+const handleClear = (): void => {
+  changeQuestion('', true)
 }
 
-const handleSend = () => {
+const handleSend = (): void => {
   changeQuestion(textarea.value)
   textarea.value = ''
 }
 
 /*监控问题变化,开启问答*/
-watch(currentQuestion, (newVal) => {
-  if (newVal) {
-    startChat({ query: newVal })
-  }
-})
+watch(
+  currentQuestion,
+  (newVal) => {
+    const length = newVal?.length ?? 0
+    const qText = newVal[length - 1]
+    if (qText) {
+      startChat({ query: qText })
+    }
+    if (length <= 1) {
+      //首条对话, 重置对话记录
+      Object.keys(contents).forEach((key) => {
+        delete contents[key]
+      })
+    }
+  },
+  { deep: true }
+)
 </script>
 
 <template>
   <div class="chat-content">
     <div class="message-body">
-      <template v-if="currentQuestion">
-        <QChat>{{ currentQuestion }}</QChat>
-        <AChat v-if="data?.result?.['3'] == null"> {{ data.content }}</AChat>
-        <AChat v-else> {{ data?.result?.['3']?.choices?.[0]?.delta?.content }}</AChat>
+      <template v-if="(currentQuestion?.length ?? 0) > 0">
+        <template v-for="question in currentQuestion" :key="question">
+          <QChat>{{ question }}</QChat>
+          <!--   思维链    -->
+          <AChat v-if="contents?.[question] == null"> {{ data.content }}...</AChat>
+          <AChat v-else>
+            <!--     回答结果     -->
+            <AResult :data="contents[question]" />
+          </AChat>
+        </template>
       </template>
       <template v-else>
         <!--   引导对话   -->
         <AChat>
-          <ExampleChat />
+          <ExampleChat :changeQuestion="changeQuestion" />
         </AChat>
       </template>
     </div>

+ 12 - 1
client/src/views/OaSystem/aiQA/components/ExampleChat.vue

@@ -2,6 +2,12 @@
 import fireIcon from '@/assets/imgs/ai/fire.png'
 import refreshIcon from '@/assets/imgs/ai/refresh.png'
 
+interface ExampleChatProps {
+  changeQuestion: (q: string, init?: boolean) => void
+}
+
+const props = defineProps<ExampleChatProps>()
+const { changeQuestion } = props
 const entries = [
   '村庄规划的编制要点是什么?',
   '耕地占补平衡方案写作案例?',
@@ -27,7 +33,12 @@ const entries = [
       <a class="a-btn"> <img class="icon" :src="refreshIcon" alt="" />换一批</a>
     </div>
     <div class="example-entries">
-      <div class="example-entry" v-for="entry in entries" :key="entry">
+      <div
+        class="example-entry"
+        v-for="entry in entries"
+        :key="entry"
+        @click="changeQuestion(entry, true)"
+      >
         <img class="icon" :src="fireIcon" alt="" />
         <span>{{ entry }}</span>
       </div>

+ 8 - 3
client/src/views/OaSystem/aiQA/index.vue

@@ -4,10 +4,15 @@ import ChatContent from '@/views/OaSystem/aiQA/components/ChatContent.vue'
 
 defineOptions({ name: 'AIQA' })
 
-const currentQuestion = ref('')
+const currentQuestion = ref([])
 
-const changeQuestion = (q: string): void => {
-  currentQuestion.value = q
+const changeQuestion = (q: string, init?: boolean): void => {
+  if (init) {
+    currentQuestion.value = []
+  }
+  if ((q ?? '') !== '') {
+    currentQuestion.value.push(q)
+  }
 }
 </script>