Selaa lähdekoodia

feat:优化AI对话流式体验与Markdown渲染

将写作模块AI对话切换为流式响应并降低前端逐块渲染开销,提升对话体感速度与稳定性。补充Markdown安全渲染及样式支持,同时更新Sass编译配置以消除legacy-js-api警告。

Made-with: Cursor
lalala 4 kuukautta sitten
vanhempi
commit
0ed64c5b54

+ 30 - 0
package-lock.json

@@ -16,9 +16,11 @@
         "crypto-js": "^4.2.0",
         "dayjs": "^1.11.13",
         "docx-preview": "^0.3.2",
+        "dompurify": "^3.4.0",
         "echarts": "^5.6.0",
         "element-plus": "^2.9.7",
         "js-audio-recorder": "^1.0.7",
+        "marked": "^18.0.2",
         "mitt": "^3.0.1",
         "pinia": "^2.1.7",
         "pinia-plugin-persistedstate": "^3.2.1",
@@ -1208,6 +1210,13 @@
         "undici-types": "~6.19.2"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.16",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@@ -2145,6 +2154,15 @@
         "jszip": ">=3.0.0"
       }
     },
+    "node_modules/dompurify": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.0.tgz",
+      "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3183,6 +3201,18 @@
         "@jridgewell/sourcemap-codec": "^1.5.0"
       }
     },
+    "node_modules/marked": {
+      "version": "18.0.2",
+      "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.2.tgz",
+      "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 20"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

+ 2 - 0
package.json

@@ -20,9 +20,11 @@
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
+    "dompurify": "^3.4.0",
     "echarts": "^5.6.0",
     "element-plus": "^2.9.7",
     "js-audio-recorder": "^1.0.7",
+    "marked": "^18.0.2",
     "mitt": "^3.0.1",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",

+ 113 - 0
src/apis/ai.ts

@@ -3,6 +3,73 @@ import type {IDialogue} from "@/types/dialogue";
 
 const AI_PREFIX = "/api/ai"
 
+interface StreamHandlers {
+    onChunk?: (chunk: string) => void;
+    onComplete?: () => void;
+}
+
+const extractStreamContent = (payload: unknown): string => {
+    if (typeof payload === 'string') {
+        return payload
+    }
+
+    if (!payload || typeof payload !== 'object') {
+        return ''
+    }
+
+    const data = payload as Record<string, any>
+
+    if (typeof data.content === 'string') {
+        return data.content
+    }
+
+    if (typeof data.data?.content === 'string') {
+        return data.data.content
+    }
+
+    const choice = data.choices?.[0]
+    if (typeof choice?.delta?.content === 'string') {
+        return choice.delta.content
+    }
+
+    if (typeof choice?.message?.content === 'string') {
+        return choice.message.content
+    }
+
+    return ''
+}
+
+const handleSSEBlock = (block: string, onChunk?: (chunk: string) => void) => {
+    const lines = block
+        .split('\n')
+        .map((line) => line.trim())
+        .filter(Boolean)
+
+    const dataLines = lines
+        .filter((line) => line.startsWith('data:'))
+        .map((line) => line.slice(5).trim())
+
+    if (dataLines.length === 0) {
+        return
+    }
+
+    const payload = dataLines.join('\n')
+    if (!payload || payload === '[DONE]') {
+        return
+    }
+
+    try {
+        const parsed = JSON.parse(payload)
+        const content = extractStreamContent(parsed)
+        if (content) {
+            onChunk?.(content)
+        }
+        return
+    } catch (_error) {
+        onChunk?.(payload)
+    }
+}
+
 const aiApis = {
     getAIDialogues(assignmentId: number, userId: number, page: number=0, size: number=20) {
         return axiosInstance.get(`${AI_PREFIX}`, {
@@ -34,6 +101,52 @@ const aiApis = {
             timeoutErrorMessage: '请求超时,请稍后重试'
         })
     },
+    async requestAIStream(dialogueId: string, model: string, messages: IDialogue[], handlers: StreamHandlers = {}) {
+        let processedLength = 0
+        let streamBuffer = ''
+
+        const response = await axiosInstance.put(`${AI_PREFIX}/stream`, {
+            model,
+            messages
+        }, {
+            params: {
+                dialogueId
+            },
+            headers: {
+                Accept: 'text/event-stream'
+            },
+            timeout: 0,
+            responseType: 'text',
+            onDownloadProgress: (progressEvent: any) => {
+                const responseText = progressEvent?.event?.target?.responseText
+                if (typeof responseText !== 'string') {
+                    return
+                }
+
+                const newText = responseText.slice(processedLength)
+                if (!newText) {
+                    return
+                }
+
+                processedLength = responseText.length
+                streamBuffer += newText
+
+                const blocks = streamBuffer.split('\n\n')
+                streamBuffer = blocks.pop() ?? ''
+                blocks.forEach((block) => handleSSEBlock(block, handlers.onChunk))
+            }
+        })
+
+        if (typeof response.data === 'string' && response.data.length > processedLength) {
+            streamBuffer += response.data.slice(processedLength)
+        }
+
+        if (streamBuffer.trim()) {
+            handleSSEBlock(streamBuffer, handlers.onChunk)
+        }
+
+        handlers.onComplete?.()
+    },
     rewrite(assignmentId: number, userId: number) {
         return axiosInstance.post(`${AI_PREFIX}/rewrite`, {}, {
             params: {

+ 122 - 0
src/components/AIChatter/AIChatter.module.scss

@@ -117,6 +117,67 @@
         background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
         color: #333;
         border-radius: 20px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.65);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.7);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
       }
 
       .message-avatar {
@@ -271,6 +332,67 @@
         background-color: #f0f2f5;
         color: #333;
         border-bottom-left-radius: 4px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.9);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.95);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
       }
 
       .message-avatar {

+ 90 - 42
src/components/AIChatter/AIChatter.vue

@@ -16,7 +16,7 @@
           </span>
             <template #dropdown>
               <el-dropdown-menu>
-                <el-dropdown-item v-for="model in modelOptions" :command="model">{{ model }}</el-dropdown-item>
+                <el-dropdown-item v-for="model in modelOptions" :key="model" :command="model">{{ model }}</el-dropdown-item>
               </el-dropdown-menu>
             </template>
           </el-dropdown>
@@ -28,7 +28,7 @@
     <div :class="$style['message-box']" ref="containerTopRef">
       <div
           v-for="(item, index) in messageList"
-          :key="index"
+          :key="`${item?.role}-${index}`"
           :class="$style['message-group']"
       >
         <!-- 用户消息 -->
@@ -51,8 +51,10 @@
           </div>
           <div :class="$style['message-info']">
             <div :class="$style['ai-name']">{{ data.model }}</div>
-            <div :class="[$style['message-bubble'], $style['ai-bubble']]">
-              {{ item?.content }}
+            <div
+              :class="[$style['message-bubble'], $style['ai-bubble']]"
+              v-html="renderMarkdown(item?.content || '')"
+            >
             </div>
           </div>
         </div>
@@ -106,14 +108,16 @@
 
 <script setup lang="ts">
   import "./AIChatter.module.scss";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import type {IDialogue} from "@/types/dialogue";
   import {useStore} from "@/store";
   import useApis from "@/apis";
-  import {onMounted, reactive, ref, defineProps, watch, onUnmounted, nextTick} from "vue";
+  import {onMounted, reactive, ref, watch, onUnmounted, nextTick} from "vue";
   import {ArrowDown, Top} from '@element-plus/icons-vue'
   import {ElMessage} from "element-plus";
   import emitter from "@/utils/emitter";
-  import dayjs from "dayjs";
+  import * as dayjsModule from "dayjs";
+  import { marked } from "marked";
+  import * as DOMPurifyModule from "dompurify";
 
   // const svg = `
   //       <path class="path" d="
@@ -146,20 +150,33 @@
 </svg>
 `;
 //update: 加入isTeacher属性,用于判断是否是教师批改界面
-  interface IProps {
-    dialogueId: string;
-    messages: IDialogue[]
-    isTeacher: boolean
-  }
-
-  const props = defineProps<{
+  const props = withDefaults(defineProps<{
     dialogueId: string,
     messages: IDialogue[]
     isTeacher: boolean
-  }>()
+  }>(), {
+    isTeacher: false
+  })
 
   const apis = useApis()
   const store = useStore()
+  const dayjs = (
+    (dayjsModule as unknown as { default?: (date?: Date) => { format: (template: string) => string } }).default
+    ?? dayjsModule
+  ) as (date?: Date) => { format: (template: string) => string }
+  const sanitizeHtml = (dirty: string) => {
+    const purifierModule = DOMPurifyModule as unknown as {
+      default?: { sanitize: (input: string) => string },
+      sanitize?: (input: string) => string
+    }
+    const purifier = purifierModule.default ?? purifierModule
+    return purifier.sanitize(dirty)
+  }
+
+  marked.setOptions({
+    gfm: true,
+    breaks: true
+  })
   //UPDATE: 目前只有ChatGLM4
   const modelOptions = ["ChatGLM4"]
   const questionTemplates = [
@@ -175,7 +192,7 @@
   const containerTopRef = ref(null);
 
   // 用于处理ai对话展示异常问题
-  let messageList:IDialogue[] = []
+  const messageList = ref<IDialogue[]>([])
 
   let data = reactive<{
     model: string;
@@ -196,15 +213,14 @@
   // 加载聊天记录,并滚动至最后
   onMounted(() => {
     if (props.messages) data.messages = props.messages;
-    // console.log(props.messages)
+    handleMessageList()
     nextTick(scrollToEnd);
   });
 
   const scrollToEnd = () => {
-    // messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' });
-    let container = containerTopRef.value
-    container.scrollTo({ top: container.scrollHeight-1, behavior: 'smooth' });
-
+    const container = containerTopRef.value as HTMLDivElement | null
+    if (!container) return
+    container.scrollTo({ top: container.scrollHeight - 1, behavior: 'smooth' });
   };
 
   watch(() => props.messages, (newMessages:any) => {
@@ -240,7 +256,9 @@
   });
 
   // 发送问题
-  const handleSendQuestion = () => {
+  const handleSendQuestion = async () => {
+    if (data.loading || !data.questionContent.trim()) return
+
     // 触发行为记录
     emitter.emit("click-ai-send-button", {
       insert: data.questionContent,
@@ -258,32 +276,60 @@
     }
     data.loading = true;
 
-    let requestBody:IDialogue[] = data.messages
-    requestBody.push({
+    const questionContent = data.questionContent.trim()
+    const userMessage: IDialogue = {
       role: 'user',
-      content: data.questionContent
-    })
+      content: questionContent
+    }
+    const assistantMessage: IDialogue = {
+      role: 'assistant',
+      content: ''
+    }
+    const requestBody: IDialogue[] = [...data.messages, userMessage]
+
+    data.messages.push(userMessage)
+    data.messages.push(assistantMessage)
+    data.questionContent = ''
+    handleMessageList()
+    nextTick(scrollToEnd)
 
-    apis.requestAI(props.dialogueId, data.model, requestBody)
-        .then((_res:any) => {
-          const resDialogue = _res.data.data;
-          data.messages.push(resDialogue);
+    try {
+      await apis.requestAIStream(props.dialogueId, data.model, requestBody, {
+        onChunk: (chunk: string) => {
+          assistantMessage.content += chunk
           handleMessageList()
-        })
-        .catch((_err:any) => {
-          console.log(_err);
-        })
-        .finally(() => {
-          data.questionContent = '';
-          data.loading = false;
-          nextTick(scrollToEnd);
-        });
+          nextTick(scrollToEnd)
+        }
+      })
+    } catch (_err:any) {
+      console.log(_err);
+      if (!assistantMessage.content) {
+        data.messages.pop()
+      } else {
+        assistantMessage.content += '\n\n[请求中断]'
+      }
+      ElMessage.error('AI 请求失败,请稍后重试')
+      handleMessageList()
+    } finally {
+      data.loading = false;
+      nextTick(scrollToEnd);
+    }
   };
 
   const handleCommand = (command:string) => {
     data.model = command
   }
 
+  const normalizeMarkdown = (content: string) => {
+    return content.replace(/^(#{1,6})(\S)/gm, '$1 $2')
+  }
+
+  const renderMarkdown = (content: string) => {
+    const normalizedContent = normalizeMarkdown(content ?? '')
+    const html = marked.parse(normalizedContent)
+    return sanitizeHtml(typeof html === 'string' ? html : '')
+  }
+
   // 选择模板问题
   const handleMenuClick = (key:any) => {
     data.questionContent = questionTemplates[key];
@@ -292,6 +338,7 @@
 
   // 处理ai对话显示异常问题
   const handleMessageList = () => {
+    const nextMessageList: IDialogue[] = []
     // console.log(data.messages)
     let count1 = 0
     let count2 = 1
@@ -312,7 +359,7 @@
     if(userNum > AssistantNum) count2 = count2 + 2 * (userNum - AssistantNum)
     for(let message of data.messages){
       if(message.role == "user"){
-        messageList[count1] = {
+        nextMessageList[count1] = {
           role: "user",
           content: message.content
         }
@@ -320,13 +367,13 @@
       }
       if(message.role == "assistant"){
         if(userNum == AssistantNum){
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
           count2+=2
         } else {
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
@@ -334,6 +381,7 @@
         }
       }
     }
+    messageList.value = nextMessageList
     // console.log(messageList)
   }
 

+ 1 - 17
src/views/EditPage/EditPage.vue

@@ -38,7 +38,7 @@
   import router from "@/router"
   import {onMounted, onUnmounted, reactive, computed, ref, watch} from "vue";
   import type {engagementVO, IAssignment, ExamTimeVO} from "@/types/vo";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import type {IDialogue} from "@/types/dialogue";
   import Edit from "@/views/Home/component/Edit/index.vue";
   import Slider from '@/views/Home/component/Silder/index.vue'
   import "./index.scss"
@@ -275,21 +275,6 @@
         })
   }
 
-  const saveAIDialogue = async () => {
-    if (dataForm.dialogueId && dataForm.messages.length > 0) {
-      try {
-        await apis.requestAI(dataForm.dialogueId, 'ChatGLM4', dataForm.messages)
-        console.log('AI对话内容已保存')
-      } catch (error) {
-        console.error('保存AI对话失败:', error)
-      }
-    }
-  }
-
-  emitter.on('save-ai-dialogue-before-submit', async () => {
-    await saveAIDialogue()
-  })
-
   emitter.on('exam-submitted', () => {
     dataForm.engagement.submitted = true;
     examTimeInfo.value = examTimeInfo.value ? { ...examTimeInfo.value, submitted: true } : null;
@@ -320,7 +305,6 @@
   onUnmounted(() => {
     stopCountdown();
     removeAllEventListeners();
-    emitter.off('save-ai-dialogue-before-submit');
     emitter.emit('exam-mode-change', false);
   })
 

+ 8 - 0
vite.config.js

@@ -6,6 +6,14 @@ export default defineConfig({
     plugins: [
         vue(),
     ],
+    css: {
+        preprocessorOptions: {
+            scss: {
+                // Use Sass modern JS API to avoid legacy API warnings.
+                api: 'modern-compiler',
+            },
+        },
+    },
     resolve: {
         alias: {
             '@': fileURLToPath(new URL('./src', import.meta.url))

+ 8 - 0
vite.config.ts

@@ -8,6 +8,14 @@ export default defineConfig({
   plugins: [
     vue(),
   ],
+  css: {
+    preprocessorOptions: {
+      scss: {
+        // Use Sass modern JS API to avoid legacy API warnings.
+        api: 'modern-compiler',
+      }
+    }
+  },
   resolve: {
     alias: {
       '@': fileURLToPath(new URL('./src', import.meta.url))