Browse Source

Merge branch 'master' of http://114.55.67.98:8070/Natural_p1/zjugis_OA

zhangjq 3 tuần trước cách đây
mục cha
commit
8f256a227c

BIN
client/src/assets/imgs/ai/robot.png


+ 85 - 0
client/src/components/PDF/PDFView.vue

@@ -0,0 +1,85 @@
+<script setup lang="ts">
+import { ref, toRefs } from 'vue'
+import VuePdfEmbed from 'vue-pdf-embed'
+import 'vue-pdf-embed/dist/styles/annotationLayer.css'
+import 'vue-pdf-embed/dist/styles/textLayer.css'
+import { debounce } from 'lodash'
+
+/**
+ * pdf预览组件
+ * 支持高亮指定内容
+ */
+interface PDFViewProps {
+  url: string // 文件地址
+  highLightContent?: string // 需要高亮的内容
+}
+
+const props = defineProps<PDFViewProps>()
+const { url, highLightContent } = toRefs(props)
+const pdfViewerRef = ref<any>(null)
+
+const handleScrollToView = debounce(
+  (element: Element) => {
+    element?.scrollIntoView({
+      behavior: 'smooth',
+      block: 'center'
+    })
+  },
+  1000,
+  { leading: true, trailing: false }
+)
+const handlePdfLoaded = (): void => {
+  // 加载完成后可操作PDF实例
+  if (highLightContent.value) {
+    const container = document.getElementById('pdfViewerRef')
+    if (container == null) return
+    // 获取所有span元素
+    const spans = container.getElementsByTagName('span')
+    Array.from(spans).forEach((span) => {
+      const text = span?.textContent?.trim() ?? ''
+      // 检查内容是否在highLightContent数组中
+      if (
+        (text?.length ?? 0) > 4 &&
+        /[a-zA-Z\u4e00-\u9fa5]/.test(text) &&
+        highLightContent.value?.includes(text)
+      ) {
+        try {
+          handleScrollToView(span)
+          span.style.backgroundColor = '#ff06' // 设置高亮背景色
+        } catch (e) {
+          console.log('handleScrollToView error: ', e)
+        }
+      }
+    })
+  }
+}
+
+const onProcess = (e) => {
+  // console.log('onProcess: ', e)
+}
+
+const onLoaded = (doc: any) => {
+  console.log('onLoaded: ', doc)
+}
+</script>
+
+<template>
+  <VuePdfEmbed
+    id="pdfViewerRef"
+    ref="pdfViewerRef"
+    class="pdf-view"
+    annotation-layer
+    text-layer
+    :source="url"
+    @progress="onProcess"
+    @loaded="onLoaded"
+    @rendered="handlePdfLoaded"
+  />
+</template>
+
+<style scoped lang="scss">
+.pdf-view {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 81 - 7
client/src/views/OaSystem/aiQA/components/AResult.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { AResultRecord } from '@/hooks/web/useSSE'
-import PDFLink from '@/views/OaSystem/aiQA/components/PDFLink.vue'
 import ResultMessageView from '@/views/OaSystem/aiQA/components/ResultMessageView.vue'
+import PDFView from '@/components/PDF/PDFView.vue'
 
 interface AResultProps {
   data: AResultRecord
@@ -9,19 +9,82 @@ interface AResultProps {
 
 const props = defineProps<AResultProps>()
 const { data } = toRefs(props)
+// 格式化处理pdf文件
+const pdfFiles = computed(() => {
+  return (data?.value?.result?.['0']?.docs ?? [])?.map((text) => {
+    // 提取索引号(假设索引号是第一个方括号中的数字)
+    const indexMatch = text.match(/\[\[(\d+)\]\]/)
+    const index = indexMatch != null ? indexMatch[1] : null
+    // 提取名称和链接(Markdown格式的链接)
+    const linkMatch = text.match(/\[(.*?)\]\((.*?)\)/)
+    const name = linkMatch != null ? `[${linkMatch[1]}]` : null
+    const link = linkMatch != null ? linkMatch[2] : null
+    // 提取内容(链接之后的所有文本)
+    const contentStartIndex = text.indexOf('](') + 2 // 链接结束的位置
+    let content = text.slice(contentStartIndex).trim()
+    // 清理内容中的Markdown链接残留(如果有)
+    content = content.replace(/\]\(.*?\)/g, '').trim()
+    return {
+      index,
+      name,
+      link,
+      content
+    }
+  })
+})
+const dialogVisible = ref(false)
+const pdfIndex = ref<string>()
+const targetViewPdf = computed(() =>
+  pdfFiles.value?.find?.(({ index }) => String(index) === String(pdfIndex.value))
+)
+
+const handlePdfView = (fileIndex: string): void => {
+  if (pdfFiles.value?.some(({ index }) => String(index) === String(fileIndex))) {
+    pdfIndex.value = fileIndex
+    dialogVisible.value = true
+  } else {
+    console.log('未找到文件')
+  }
+}
+const handleClose = (): void => {
+  pdfIndex.value = undefined
+}
 </script>
 
 <template>
   <div class="a-result-body">
-    <ResultMessageView :data="data?.result?.['3']?.choices?.[0]?.delta?.content" />
-    <div class="result-files" v-if="(data?.result?.['0']?.docs?.length ?? 0) > 0">
+    <ResultMessageView
+      :data="data?.result?.['3']?.choices?.[0]?.delta?.content"
+      :handlePDFView="handlePdfView"
+    />
+    <div class="result-files" v-if="(pdfFiles?.length ?? 0) > 0">
       <div class="label">【来源文件】:</div>
       <div class="files-container">
-        <div class="file" v-for="file in data?.result?.['0']?.docs" :key="file">
-          <PDFLink :data="file" />
-        </div>
+        <template v-for="file in pdfFiles" :key="file?.index">
+          <div v-if="file" class="file" @click="handlePdfView(file.index)">
+            <span>{{ file?.name ?? '' }}</span>
+          </div>
+        </template>
       </div>
     </div>
+    <!--    PDF预览弹窗    -->
+    <el-dialog
+      :title="targetViewPdf?.name ?? ''"
+      v-model="dialogVisible"
+      width="80%"
+      @close="handleClose"
+      append-to-body
+      destroy-on-close
+    >
+      <div class="pdf-view-body">
+        <PDFView
+          v-if="targetViewPdf?.link"
+          :url="targetViewPdf?.link"
+          :highLightContent="targetViewPdf?.content"
+        />
+      </div>
+      <template #footer></template>
+    </el-dialog>
   </div>
 </template>
 
@@ -40,13 +103,24 @@ const { data } = toRefs(props)
       flex-grow: 1;
 
       .file {
+        cursor: pointer;
         padding: 10px;
-        color: #888;
+        color: #666;
         margin-bottom: 10px;
         background: #f1f9ff;
         border-radius: 8px;
+
+        &:hover {
+          text-decoration: underline;
+        }
       }
     }
   }
 }
+
+.pdf-view-body {
+  width: 100%;
+  height: 70vh;
+  overflow-y: auto;
+}
 </style>

+ 27 - 5
client/src/views/OaSystem/aiQA/components/ChatContent.vue

@@ -39,7 +39,8 @@ const {
     const queryBody = {
       chatId: currentQuestionId.value,
       question,
-      answer: res?.result?.['3']?.choices?.[0]?.delta?.content
+      answer: res?.result?.['3']?.choices?.[0]?.delta?.content,
+      answerSources: JSON.stringify(res?.result?.['0']?.docs)
     }
     collectHistory(queryBody)
   }
@@ -68,8 +69,18 @@ const { isLoading: historyLoading } = useQuery({
   onSuccess: (res) => {
     if ((res?.length ?? 0) > 0) {
       currentQuestionId.value = currentQuestion.value?.id
-      res?.forEach(({ question, answer }) => {
-        contents[question] = { result: { '3': { choices: [{ delta: { content: answer } }] } } }
+      res?.forEach(({ question, answer, answerSources }) => {
+        try {
+          const docs = JSON.parse(answerSources ?? '[]')
+          contents[question] = {
+            result: {
+              '3': { choices: [{ delta: { content: answer } }] },
+              '0': { docs }
+            }
+          }
+        } catch (e) {
+          console.log('no docs')
+        }
       })
     }
   }
@@ -79,7 +90,7 @@ const handleClear = (): void => {
   changeQuestion('')
 }
 
-const handleSend = (text): void => {
+const continueChat = (text): void => {
   if ((text ?? '') === '') {
     return
   }
@@ -92,6 +103,17 @@ const handleSend = (text): void => {
   contents[text] = undefined
 }
 
+// 开始对话
+const handleSend = (text: string): void => {
+  if ((currentQuestionId.value ?? '') === '') {
+    // 创建对话
+    changeQuestion(text)
+  } else {
+    // 继续对话
+    continueChat(text)
+  }
+}
+
 /*监控问题变化,开启问答*/
 watch(
   currentQuestion,
@@ -104,7 +126,7 @@ watch(
       })
       currentQuestionId.value = ''
     } else {
-      handleSend(text)
+      continueChat(text)
     }
   },
   { deep: true }

+ 30 - 2
client/src/views/OaSystem/aiQA/components/ResultMessageView.vue

@@ -1,17 +1,22 @@
 <script setup lang="ts">
 import { computed } from 'vue'
-import MarkdownView from '@/components/Markdown/MarkdownView.vue'
+import MarkdownIt from 'markdown-it'
+
+const MD = new MarkdownIt()
 
 interface ResultMessageProps {
   data: string
   isLoading?: boolean
   defaultOpen?: boolean
+  handlePDFView?: (pdfIndex: string) => void
 }
 
 const props = defineProps<ResultMessageProps>()
 const { data, isLoading } = toRefs(props)
+const { handlePDFView } = props
 const open = ref(props.defaultOpen ? '1' : '')
 
+// 思考过程
 const thinkContent = computed(() => {
   const thinkIndex = data.value?.indexOf('</think>')
   if (thinkIndex > 0) {
@@ -20,6 +25,7 @@ const thinkContent = computed(() => {
   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) {
@@ -28,6 +34,22 @@ const realResultContent = computed(() => {
   const targetIndex = data.value?.indexOf('###')
   return targetIndex > 0 ? data.value?.substring(targetIndex) : ''
 })
+// 回答内容格式化
+const realContentHtml = computed(() => {
+  // 使用正则表达式匹配 [[index]] 格式,并替换为 <b>index</b>
+  // 同时添加点击事件
+  return MD.render(realResultContent?.value)?.replace(/\[\[(\d+)\]\]/g, (match, index) => {
+    return `<b class="clickable-index" data-index="${index}">[[${index}]]</b>`
+  })
+})
+
+const handleClick = (e: Event): void => {
+  // 检查点击的是否是带有 clickable-index 类的元素
+  if (e?.target?.classList?.contains('clickable-index')) {
+    const index = e?.target?.getAttribute('data-index')
+    handlePDFView?.(index)
+  }
+}
 </script>
 
 <template>
@@ -38,7 +60,7 @@ const realResultContent = computed(() => {
       </el-collapse-item>
     </el-collapse>
     <div class="real-result" v-if="(realResultContent ?? '') !== ''">
-      <MarkdownView :text="realResultContent" />
+      <div v-html="realContentHtml" @click="handleClick"></div>
     </div>
   </div>
 </template>
@@ -66,6 +88,12 @@ const realResultContent = computed(() => {
     padding: 15px 20px;
     background: #ffffff;
     border-radius: 8px 8px 8px 8px;
+
+    :deep(.clickable-index) {
+      //color: #446ae7;
+      font-weight: 700;
+      cursor: pointer;
+    }
   }
 }
 </style>

+ 16 - 0
client/src/views/OaSystem/oaLayout/menus.vue

@@ -45,6 +45,7 @@
           <MenusActive :menuData="menuData" v-if="mouseenterIndex == index" />
         </div>
       </el-menu>
+      <img :src="AIBtn" alt="" class="ai-btn" @click="handleGoToAi" />
     </div>
   </div>
 </template>
@@ -62,6 +63,7 @@ import { getAttendCount } from '@/api/oa/index'
 import subscribe from '@/utils/Subscribe'
 import request from '@/config/axios'
 import { useUserStoreWithOut } from '@/store/modules/user'
+import AIBtn from '@/assets/imgs/ai/robot.png'
 
 defineOptions({ name: 'Header' })
 const { t } = useI18n()
@@ -123,6 +125,10 @@ const loginOut = () => {
     })
     .catch(() => {})
 }
+const handleGoToAi = (): void => {
+  push('/OaSystem/aiQA')
+}
+
 const userImgClick = () => {
   push('/oaSystem/mineCenter')
 }
@@ -331,6 +337,16 @@ onMounted(() => {
   .menus-tabs {
     width: 100%;
     height: calc(100% - 250px);
+    position: relative;
+
+    .ai-btn {
+      position: absolute;
+      bottom: 30px;
+      left: 50%;
+      transform: translateX(-50%);
+      z-index: 9;
+      cursor: pointer;
+    }
 
     .menuDiv {
       position: relative;