Просмотр исходного кода

feat(AI聊天): 添加错误处理和重试功能

- 在AI聊天组件中添加错误处理和重试按钮
- 新增网络错误提示多语言支持
- 添加刷新图标用于重试功能
- 优化消息内容换行显示
- 增加请求超时处理机制
lsc 1 неделя назад
Родитель
Сommit
445ff4dafb

+ 2 - 0
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -735,6 +735,8 @@ onMounted(() => {
 }
 
 .message-content {
+    word-break: break-word;
+
     &.ai-message {
         align-self: flex-start;
         background: #fafbfc;

+ 2 - 0
src/plugins/icon.ts

@@ -130,6 +130,7 @@ import {
   More,
   LoadingFour, // 引入loadingIcon
   UpTwo,
+  Refresh,
 } from '@icon-park/vue-next'
 
 export interface Icons {
@@ -265,6 +266,7 @@ export const icons: Icons = {
   IconMore: More,
   IconLoading: LoadingFour, // 添加loadingIcon
   UpTwo: UpTwo,
+  IconRefresh: Refresh,
 }
 
 export default {

+ 10 - 7
src/tools/aiChat.ts

@@ -159,6 +159,10 @@ export const chat_stream = async (
       throw err
     },
   }).catch(err => {
+    onMessage({
+      type: 'error',
+      data: err || 'Unknown error'
+    })
     console.log('err', err)
   })
   
@@ -359,6 +363,7 @@ export interface InsertChatParams {
   latestMessage?: any;
   agentHeadUrl?: string;
   agentAssistantName?: string;
+  jsonData?: any;
 }
 
 export interface InsertChatResult {
@@ -376,18 +381,16 @@ export const insertChat = async (params: InsertChatParams): Promise<InsertChatRe
     fileId,
     latestMessage,
     agentHeadUrl,
-    agentAssistantName
+    agentAssistantName,
+    jsonData
   } = params
 
-  const jsonData: any = {
+  const jsonData2: any = {
     headUrl: agentHeadUrl || '',
-    assistantName: agentAssistantName || ''
+    assistantName: agentAssistantName || '',
+    ...jsonData
   }
 
-  // 如果存在sourceArray,则添加到jsonData中
-  if (latestMessage && latestMessage.jsonData && latestMessage.jsonData.sourceArray) {
-    jsonData.sourceArray = latestMessage.jsonData.sourceArray
-  }
 
   try {
     const response = await axios.post('https://gpt4.cocorobo.cn/insert_chat', {

+ 113 - 25
src/views/Student/components/aiChat.vue

@@ -30,9 +30,9 @@
             </div>
           </div>
         </div>
-        <div class="message-content ai-message chat" v-if="message.aiContent || message.loading">
+        <div class="message-content ai-message chat" v-if="message.aiContent || message.loading || message?.jsonData?.error || !message.aiContent">
           <div v-if="message.aiContent" v-html="message.aiContent"></div>
-          <svg v-else xmlns="http://www.w3.org/2000/svg" width="32" height="32"
+          <svg v-else-if="message.loading" xmlns="http://www.w3.org/2000/svg" width="32" height="32"
             viewBox="0 0 24 24"><!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
             <circle cx="4" cy="12" r="3" fill="currentColor">
               <animate id="svgSpinners3DotsBounce0" attributeName="cy" begin="0;svgSpinners3DotsBounce1.end+0.25s"
@@ -47,6 +47,12 @@
                 calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
             </circle>
           </svg>
+          <div v-else-if="message?.jsonData?.error || !message.aiContent" class="error-message">
+            <div class="error-text">{{ message?.jsonData?.errorMessage || lang.ssRetryMessage }}</div>
+            <button class="retry-btn" @click="retryMessage(index)">
+              <IconRefresh class="retry-icon"/>
+            </button>
+          </div>
         </div>
       </div>
     </div>
@@ -141,6 +147,9 @@ interface ChatMessage {
     url?: string
   }>
   jsonData?: {
+    error?: boolean
+    errorMessage?: string
+    retryable?: boolean
     gType?: string
     isGenerate?: boolean
     headUrl?: string
@@ -328,6 +337,9 @@ const sendMessage = () => {
       title: file.title,
       id: file.id
     }))
+    if (!messages.value.at(-1).jsonData) {
+      messages.value.at(-1).jsonData = {}
+    }
     chatLoading.value = true
     sendAction(inputText.value)
     inputText.value = ''
@@ -424,6 +436,14 @@ const sendQuickAction = (action: string) => {
   sendMessage()
 }
 
+const retryMessage = (index: number) => {
+  const message = messages.value[index]
+  if (message && message?.jsonData?.retryable) {
+    inputText.value = message.content || ''
+    sendMessage()
+  }
+}
+
 import { v4 as uuidv4 } from 'uuid'
 const session_name = ref('')
 const slidesStore = useSlidesStore()
@@ -490,34 +510,66 @@ const sendAction = async (action: string) => {
   ${promptText}
   query:${action}
   `
-  chat_stream(prompt, agentid2.value, props.userid || '', lang.lang, (event) => {
-    if (event.type === 'message') {
-      messages.value.at(-1).aiContent = md.render(event.data)
-
-      messages.value.at(-1).loading = false
-      prevChatResult()
-    }
-    else if (event.type === 'messageEnd') {
-      messages.value.at(-1).aiContent = md.render(event.data)
-      messages.value.at(-1).chatloading = false
-      chatLoading.value = false
-      prevChatResult()
-      insertChat({
-        answer: messages.value.at(-1).aiContent,
-        problem: messages.value.at(-1).content,
-        type: 'chat',
-        alltext: messages.value.at(-1).aiContent,
-        assistant_id: props.workJson.id
-      })
-    }
-  }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
+  
+  // 设置超时
+  const timeoutPromise = new Promise<never>((_, reject) => {
+    setTimeout(() => {
+      reject(new Error('请求超时'))
+    }, 30000) // 30秒超时
+  })
+  
+  try {
+    const controller = await Promise.race([
+      chat_stream(prompt, agentid2.value, props.userid || '', lang.lang, (event) => {
+        if (event.type === 'message') {
+          messages.value.at(-1).aiContent = md.render(event.data)
+          messages.value.at(-1).loading = false
+          prevChatResult()
+        }
+        else if (event.type === 'messageEnd') {
+          messages.value.at(-1).aiContent = md.render(event.data)
+          messages.value.at(-1).chatloading = false
+          chatLoading.value = false
+          prevChatResult()
+          addChat()
+        }
+        else if (event.type === 'error') {
+          errorSet()
+        }
+      }, session_name.value, messages.value.at(-1)?.sourceFiles?.map(file => file.id).filter(Boolean)),
+      timeoutPromise
+    ])
+    
     streamController.value = controller
-  }).catch(err => {
-    chatLoading.value = false
+  }
+  catch (err) {
     console.log('err', err)
+    errorSet()
     stopMessage()
+  }
+}
+
+const errorSet = () => {
+  chatLoading.value = false
+  messages.value.at(-1).chatloading = false
+  messages.value.at(-1).loading = false
+  messages.value.at(-1).jsonData.error = true
+  messages.value.at(-1).jsonData.errorMessage = lang.ssRetryMessage
+  messages.value.at(-1).jsonData.retryable = true
+  addChat()
+}
+
+const addChat = () => {
+  insertChat({
+    answer: messages.value.at(-1).aiContent,
+    problem: messages.value.at(-1).content,
+    type: 'chat',
+    alltext: messages.value.at(-1).aiContent,
+    assistant_id: props.workJson.id,
+    jsonData: messages.value.at(-1).jsonData
   })
 }
+
 import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 const { createSlide } = useSlideHandler()
@@ -895,6 +947,8 @@ onMounted(() => {
 }
 
 .message-content {
+  word-break: break-word;
+
   &.ai-message {
     align-self: flex-start;
     background: #fff;
@@ -917,6 +971,40 @@ onMounted(() => {
   }
 }
 
+.error-message {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #ef4444;
+  font-size: 14px;
+
+  .error-text {
+    flex: 1;
+  }
+
+  .retry-btn {
+    background: none;
+    border: none;
+    cursor: pointer;
+    color: #ff9300;
+    padding: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+
+    .retry-icon {
+      width: 16px;
+      height: 16px;
+    }
+
+    &:hover {
+      color: #e68a00;
+      transform: rotate(180deg);
+    }
+  }
+}
+
 .initial-state {
   display: flex;
   flex-direction: column;

+ 37 - 36
src/views/Student/index.vue

@@ -88,13 +88,13 @@
               <div class="homework-check-box-item-title">{{ lang.ssAnswer }}</div>
             </div>
           </div>
-          <div class="aiBtn" ref="aiBtnRef" v-if="isQuestionFrame && hasWork && false" 
+          <div class="aiBtn" ref="aiBtnRef" v-if="isQuestionFrame && hasWork && props.type == '2'" 
             :style="{ right: aiBtnPosition.x + 'px', bottom: aiBtnPosition.y + 'px' }" @click="openAiChat">
             <IconComment class="aiBtn-icon" />
             <span>AI对话</span>
           </div>
           <aiChat v-show="visibleAIChat && props.cid" :position="aiBtnPosition" @close="visibleAIChat = false" :userid="props.userid" :workJson="myWork" :visible="visibleAIChat" :cid="props.cid"/>
-         <!-- && props.type == '2' -->
+         <!--  -->
           <div class="viewport" v-if="false">
             <div class="background" :style="backgroundStyle"></div>
 
@@ -1646,48 +1646,49 @@ const processIframeLinks = async () => {
                   }
                   catch (error) {
                     console.log(`iframe ${iframeSrc} 无法获取contentWindow,使用HTML方式:`, error)
-                  }
                   
-                  // 如果无法获取contentWindow,使用HTML方式
-                  let html = null
-                  try {
-                    console.log(`getFile2 失败,尝试使用 getHTML:`, error2)
-                    try {
-                      html = await api.getHTML(iframeSrc)
-                      console.log('getHTML 成功获取内容:', html)
-                    }
-                    catch (htmlError) {
-                      console.error('getHTML 也失败:', htmlError)
-                      console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
-                      // throw new Error(`无法获取内容: getFile、getFile2 和 getHTML 都失败了`)
-                    }
-                  }
-                  catch (error) {
-                    console.log(`getFile 失败,尝试使用 getFile2:`, error)
+                    // 如果无法获取contentWindow,使用HTML方式
+                    let html = null
                     try {
-                      const fileData = await getFile(iframeSrc)
-                      if (fileData && fileData.data) {
-                        const uint8Array = new Uint8Array(fileData.data)
-                        html = new TextDecoder('utf-8').decode(uint8Array)
-                        console.log('getFile 成功获取内容:', html)
+                      console.log(`getFile2 失败,尝试使用 getHTML:`, error2)
+                      try {
+                        html = await api.getHTML(iframeSrc)
+                        console.log('getHTML 成功获取内容:', html)
+                      }
+                      catch (htmlError) {
+                        console.error('getHTML 也失败:', htmlError)
+                        console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
+                        // throw new Error(`无法获取内容: getFile、getFile2 和 getHTML 都失败了`)
                       }
                     }
-                    catch (error2) {
-                      const fileData2 = await getFile2(iframeSrc)
-                      if (fileData2 && fileData2.data) {
-                        const uint8Array = new Uint8Array(fileData2.data)
-                        html = new TextDecoder('utf-8').decode(uint8Array)
-                        console.log('getFile2 成功获取内容:', html)
+                    catch (error) {
+                      console.log(`getFile 失败,尝试使用 getFile2:`, error)
+                      try {
+                        const fileData = await getFile(iframeSrc)
+                        if (fileData && fileData.data) {
+                          const uint8Array = new Uint8Array(fileData.data)
+                          html = new TextDecoder('utf-8').decode(uint8Array)
+                          console.log('getFile 成功获取内容:', html)
+                        }
+                      }
+                      catch (error2) {
+                        const fileData2 = await getFile2(iframeSrc)
+                        if (fileData2 && fileData2.data) {
+                          const uint8Array = new Uint8Array(fileData2.data)
+                          html = new TextDecoder('utf-8').decode(uint8Array)
+                          console.log('getFile2 成功获取内容:', html)
+                        }
                       }
                     }
-                  }
-                  console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
-                  return {
-                    ...element,
-                    isHTML: true,  
-                    url: html
+                    console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
+                    return {
+                      ...element,
+                      isHTML: true,  
+                      url: html
+                    }
                   }
                 }
+
               }
 
               // 不是iframe元素或不需要处理,直接返回

+ 2 - 1
src/views/lang/cn.json

@@ -737,5 +737,6 @@
   "ssAiChatWaitUpload": "请等待文件上传完成后再发送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超过10MB",
   "ssAiChatUploadFailed": "文件上传失败:",
-  "ssClassroomAiAssistant": "课堂AI助手"
+  "ssClassroomAiAssistant": "课堂AI助手",
+  "ssRetryMessage": "网络有点慢,请稍后重试"
 }

+ 2 - 1
src/views/lang/en.json

@@ -737,5 +737,6 @@
   "ssAiChatWaitUpload": "Please wait for the file upload to complete before sending a message",
   "ssAiChatFileSizeLimit": "File size cannot exceed 10MB",
   "ssAiChatUploadFailed": "File upload failed:",
-  "ssClassroomAiAssistant": "Classroom AI Assistant"
+  "ssClassroomAiAssistant": "Classroom AI Assistant",
+  "ssRetryMessage": "Network is slow, please try again later"
 }

+ 2 - 1
src/views/lang/hk.json

@@ -737,5 +737,6 @@
   "ssAiChatWaitUpload": "請等待文件上傳完成後再發送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超過10MB",
   "ssAiChatUploadFailed": "文件上傳失敗:",
-  "ssClassroomAiAssistant": "課堂AI助手"
+  "ssClassroomAiAssistant": "課堂AI助手",
+  "ssRetryMessage": "網絡稍慢,請稍後重試"
 }