Quellcode durchsuchen

Merge branch 'wanzi' of http://59.110.91.129:3000/zhangmengying/AIClass into wanzi

丸子 vor 8 Monaten
Ursprung
Commit
e41498d188

+ 1 - 0
src/api/questions.js

@@ -17,6 +17,7 @@ export function CreateDialogue (data){
 export async function sendChatMessageStream (
     conversationId,
     content,
+    contentAnswer = undefined,
     ctrl,
     enableContext,
     onMessage,

+ 103 - 0
src/api/tts/useAudioPlayer.js

@@ -0,0 +1,103 @@
+export function useAudioPlayer() {
+    let audioContext = null;
+    let audioQueue = [];
+    let isPlaying = false;
+    let currentTime = 0; // 当前播放时间(用于连续播放)
+    const SAMPLE_RATE = 16000; // 匹配后端采样率
+    const CHANNELS = 1; // 单声道
+    const BIT_DEPTH = 16; // 16位深
+
+    // 初始化AudioContext
+    const initAudioContext = () => {
+        if (!audioContext) {
+            audioContext = new (window.AudioContext || window.webkitAudioContext)({
+                sampleRate: SAMPLE_RATE
+            });
+            currentTime = 0; // 重置播放时间
+        }
+    };
+
+    // 播放音频块(支持流式PCM)
+    const playAudioChunk = async (base64Audio) => {
+        initAudioContext();
+
+        // 解码Base64音频数据
+        const audioBytes = Uint8Array.from(atob(base64Audio), c => c.charCodeAt(0));
+        audioQueue.push(audioBytes);
+
+        if (!isPlaying) {
+            processAudioQueue();
+        }
+    };
+
+    // 处理音频队列(核心流式播放逻辑)
+    const processAudioQueue = async () => {
+        if (audioQueue.length === 0) {
+            isPlaying = false;
+            return;
+        }
+
+        isPlaying = true;
+        const audioData = audioQueue.shift();
+
+        try {
+            // 1. 处理首个WAV分片(带文件头)
+            if (currentTime === 0) {
+                // 解码完整WAV文件(仅首次)
+                const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
+                playBuffer(audioBuffer);
+                currentTime += audioBuffer.duration; // 更新播放时间
+            }
+            // 2. 处理后续PCM分片(无文件头)
+            else {
+                // 将16位PCM字节转换为Float32Array(AudioContext要求格式)
+                const float32Data = convertPCMToFloat32(audioData);
+                // 创建音频缓冲区
+                const audioBuffer = audioContext.createBuffer(CHANNELS, float32Data.length, SAMPLE_RATE);
+                audioBuffer.copyToChannel(float32Data, 0); // 复制到音频通道
+                playBuffer(audioBuffer);
+                currentTime += audioBuffer.duration; // 更新播放时间
+            }
+        } catch (error) {
+            console.error('音频处理失败:', error);
+            isPlaying = false;
+        }
+    };
+
+    // 播放音频缓冲区并调度下一个分片
+    const playBuffer = (audioBuffer) => {
+        const source = audioContext.createBufferSource();
+        source.buffer = audioBuffer;
+        source.connect(audioContext.destination);
+        source.start(currentTime); // 从当前时间点开始播放
+        // 播放结束后继续处理队列
+        source.onended = processAudioQueue;
+    };
+
+    // 将16位PCM字节转换为Float32Array([-1.0, 1.0]范围)
+    const convertPCMToFloat32 = (bytes) => {
+        const int16Array = new Int16Array(bytes.buffer);
+        const float32Array = new Float32Array(int16Array.length);
+        for (let i = 0; i < int16Array.length; i++) {
+            float32Array[i] = int16Array[i] / 32768; // 16位PCM最大值为32767
+        }
+        return float32Array;
+    };
+
+    // 停止播放并清理
+    const stopPlayback = () => {
+        if (audioContext) {
+            audioContext.close().then(() => {
+                audioContext = null;
+                currentTime = 0; // 重置播放时间
+            });
+        }
+        audioQueue = [];
+        isPlaying = false;
+    };
+
+    return {
+        playAudioChunk,
+        stopPlayback
+    };
+}

+ 96 - 35
src/components/videopage/DialogComponents.vue

@@ -76,7 +76,7 @@
           >
         </div>
         <div class="ai-dialog-content">
-          <div class="ai-message-history">
+          <div class="ai-message-history" ref="messageContainer" @scroll="handleScroll">
             <div
               v-for="(message, index) in messageList"
               :key="index"
@@ -124,7 +124,7 @@
 </template>
 
 <script setup>
-import { ref, defineProps, defineEmits, onMounted,watch } from 'vue'
+import {ref, defineProps, defineEmits, onMounted, watch, nextTick} from 'vue'
 import { ElMessage } from 'element-plus'
 import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
 import { teacherList } from '@/api/teachers.js'
@@ -152,7 +152,9 @@ const showAIDialog = ref(false)
 const selectedOption = ref(null)
 const messageList = ref([])
 const prompt = ref('')
+const messageContainer = ref(null)
 const aiQuestionCount = ref(0)
+const userScrolled = ref(false)//是否用户手动滚动
 const xZAiData = ref({})
 const activeConversationId = ref(null)
 const conversationInProgress = ref(false)
@@ -162,6 +164,10 @@ const isComposing = ref(false)
 const inputTimeout = ref()
 const enableContext = ref(true)
 
+//tts
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+const { playAudioChunk } = useAudioPlayer();
+
 // 处理选择的默认消息
 const handleSelectMessage = message => {
   prompt.value = message
@@ -184,18 +190,27 @@ const handleSubmitAnswer = () => {
 }
 
 // 处理 AI 助手点击事件
-const handleAIClick = () => {
+const handleAIClick = async () => {
   // 清空输入框
   messageList.value = []
-  if (props.currentQuestion.ccQuestContent) {
-    prompt.value = props.currentQuestion.ccQuestContent
-    sendMessage()
-    prompt.value = ''
-  }
   showAIDialog.value = true
 
   //创建对话
-  createAiChart()
+  await createAiChart()
+
+  if (props.currentQuestion.ccQuestContent) {
+    // prompt.value = props.currentQuestion.ccQuestContent
+    // sendMessage()
+
+    prompt.value = ''
+    // 执行发送
+    await doSendMessageStream({
+      conversationId: activeConversationId.value,
+      content: props.currentQuestion.ccQuestContent,
+      contentAnswer: props.currentQuestion.ccAiAnswer,
+    })
+
+  }
 }
 
 // 数字人接口
@@ -230,8 +245,9 @@ const createAiChart = async () => {
   await getXzAi()
 
   // 智能问答
-  CreateDialogue({ roleId: xZAiData.value.id })
+  await CreateDialogue({ roleId: xZAiData.value.id })
     .then(res => {
+      console.log("创建会话:", res);
       activeConversationId.value = res.data
     })
     .catch(error => {
@@ -262,12 +278,12 @@ const sendMessage = async () => {
       console.error('保存AI问答次数失败:', error)
     }
 
-    // 模拟 AI 回复
-    const aiResponse = await simulateAIResponse(prompt.value)
-    messageList.value.push({
-      type: 'ai',
-      content: aiResponse
-    })
+    // // 模拟 AI 回复
+    // const aiResponse = await simulateAIResponse(prompt.value)
+    // messageList.value.push({
+    //   type: 'ai',
+    //   content: aiResponse
+    // })
 
     // 清空输入框
     prompt.value = ''
@@ -336,7 +352,8 @@ const doSendMessage = async content => {
   // 执行发送
   await doSendMessageStream({
     conversationId: activeConversationId.value,
-    content: content
+    content: content,
+    contentAnswer: null,
   })
 }
 
@@ -368,10 +385,11 @@ const doSendMessageStream = async userMessage => {
 
     // 2. 发送 event stream
     let isFirstChunk = true // 是否是第一个 chunk 消息段
-
+    console.log("userMessage", userMessage)
     await sendChatMessageStream(
       userMessage.conversationId,
       userMessage.content,
+      userMessage.contentAnswer,
       conversationInAbortController.value,
       enableContext.value,
       async res => {
@@ -380,25 +398,39 @@ const doSendMessageStream = async userMessage => {
           console.log(`对话异常! ${msg}`)
           return
         }
-        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
-        // 首次返回需要添加一个 message 到页面,后面的都是更新
-        if (isFirstChunk) {
-          isFirstChunk = false
-          // 弹出两个假数据
-          messageList.value.pop()
-          messageList.value.pop()
-          // 更新返回的数据
-          messageList.value.push(data.send)
-          messageList.value.push(data.receive)
-        } else {
-          // 更新最后一条消息
-          if (messageList.value.length > 0) {
-            const lastMessage = messageList.value[messageList.value.length - 1]
-            if (lastMessage.id === data.receive.id) {
-              lastMessage.content = receiveMessageFullText.value
+
+        if (data.eventType === 'TEXT') {
+
+          // 如果内容为空,就不处理。
+          if (data.receive?.content === '') {
+            return
+          }
+          receiveMessageFullText.value += data.receive.content
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false
+            // 弹出两个假数据
+            messageList.value.pop()
+            messageList.value.pop()
+            // 更新返回的数据
+            messageList.value.push(data.send)
+            messageList.value.push(data.receive)
+          } else {
+            //更新最后一条消息
+            if (messageList.value.length > 0) {
+              const lastMessage = messageList.value[messageList.value.length - 1]
+              if (lastMessage.id === data.receive.id) {
+                lastMessage.content = receiveMessageFullText.value
+              }
             }
           }
+        } else if (data.eventType === 'AUDIO') {
+          // 处理音频消息
+          await playAudioChunk(data.audioData);
         }
+
+        // 添加此行确保触发滚动
+        scrollToBottom()
       },
       error => {
         console.log(`对话异常! ${error}`)
@@ -463,7 +495,36 @@ watch(() => props.questionDialogVisible, (newVal) => {
     selectedOption.value = null
   }
 })
-
+// 监听消息列表变化,自动滚动到底部
+watch(messageList, () => {
+  scrollToBottom()
+}, { deep: true })
+
+//处理滚动事件,判断用户是否手动滚动
+const handleScroll = () => {
+  if (messageContainer.value) {
+    const { scrollTop, scrollHeight, clientHeight } = messageContainer.value
+    // 当用户滚动距离底部超过50px时,认为是手动滚动
+    userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
+  }
+}
+// 单独的滚动到底部函数
+const scrollToBottom = () => {
+
+  // 如果用户手动滚动过,不自动滚动
+  if (userScrolled.value) return
+
+  nextTick(() => {
+    if (messageContainer.value) {
+      // 强制重排以确保获取最新高度
+      messageContainer.value.scrollTop = messageContainer.value.scrollHeight
+      // 双重保险:使用requestAnimationFrame确保在浏览器重绘后执行
+      requestAnimationFrame(() => {
+        messageContainer.value.scrollTop = messageContainer.value.scrollHeight
+      })
+    }
+  })
+}
 onMounted(() => {
   // 初始化
 })

+ 8 - 8
src/views/AIDevelop.vue

@@ -450,14 +450,14 @@ onMounted(async () => {
         }
 
         // 手动修改第一个课程为image类型用于测试
-        if (index === 0) {
-          courseTemp.courseContentType = 'ppt';
-          courseTemp.pptPath = 'http://59.110.91.129:8088/admin-api/infra/file/29/get/20250820/ppt_1755654972861.pptx';
-          // courseTemp.courseContentType = 'image';
-          // courseTemp.courseImagePath = 'http://59.110.91.129:8088/admin-api/infra/file/4/get/20250715/one_1752549934393.png,http://59.110.91.129:8088/admin-api/infra/file/29/get/20250722/666_1753151547130.png';
-          // // 可选:修改课程名称以便识别
-          courseTemp.courseName = '测试';
-        }
+        // if (index === 0) {
+        //   courseTemp.courseContentType = 'ppt';
+        //   courseTemp.pptPath = 'http://59.110.91.129:8088/admin-api/infra/file/29/get/20250820/ppt_1755654972861.pptx';
+        //   // courseTemp.courseContentType = 'image';
+        //   // courseTemp.courseImagePath = 'http://59.110.91.129:8088/admin-api/infra/file/4/get/20250715/one_1752549934393.png,http://59.110.91.129:8088/admin-api/infra/file/29/get/20250722/666_1753151547130.png';
+        //   // // 可选:修改课程名称以便识别
+        //   courseTemp.courseName = '测试';
+        // }
 
         if (topName === courseTemp.courseLabel) {
           let topMenu = menuItems.value[menuItems.value.length - 1]

+ 33 - 5
src/views/AIQuestions.vue

@@ -41,7 +41,7 @@
           <!-- AI对话框 -->
           <div class="chat-dialog">
             <!-- 对话消息列表 -->
-            <div class="message-list" ref="messageListRef">
+            <div class="message-list" ref="messageListRef" @scroll="handleScroll">
               <div v-for="(item, index) in messageList" :key="index">
                 <!-- AI消息 -->
                 <div class="ai-message" v-if="item.type !== 'user'">
@@ -215,6 +215,7 @@ const enableContext = ref(true); // 是否开启上下文
 const receiveMessageFullText = ref("");
 const receiveMessageDisplayedText = ref("");
 const messageListRef = ref(null);
+const userScrolled = ref(false)//是否用户手动滚动
 
 // =========== 【聊天对话】相关 ===========
 
@@ -419,7 +420,8 @@ const doSendMessage = async (content) => {
   });
 };
 
-import { useAudioPlayer } from "@/components/TTS/useAudioPlayer";
+
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
 
 const { playAudioChunk } = useAudioPlayer();
 
@@ -449,7 +451,7 @@ const doSendMessageStream = async (userMessage) => {
       createTime: new Date(),
     });
 
-    // 1.3 开始滚动
+    // 1.2 开始滚动
     textRoll();
 
     // 2. 发送 event stream
@@ -457,7 +459,7 @@ const doSendMessageStream = async (userMessage) => {
 
     await sendChatMessageStream(
       userMessage.conversationId,
-      userMessage.content,
+      userMessage.content, null,
       conversationInAbortController.value,
       enableContext.value,
       async (res) => {
@@ -540,16 +542,31 @@ const messageList = computed(() => {
 
 // ============== 【消息滚动】相关 =============
 
+//处理滚动事件,判断用户是否手动滚动
+const handleScroll = () => {
+  if (messageListRef.value) {
+    const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
+    // 当用户滚动距离底部超过50px时,认为是手动滚动
+    userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
+  }
+}
+
 /** 滚动到 message 底部 */
 const scrollToBottom = async (isIgnore = false) => {
+  // 如果用户手动滚动过,不自动滚动
+  if (userScrolled.value) return
+
   await nextTick();
   if (messageListRef.value) {
-    messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
+    requestAnimationFrame(() => {
+      messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
+    });
   }
 };
 
 /** 自提滚动效果 */
 const textRoll = async () => {
+
   let index = 0;
   try {
     // 只能执行一次
@@ -588,6 +605,7 @@ const textRoll = async () => {
         const lastMessage =
           activeMessageList.value[activeMessageList.value.length - 1];
         lastMessage.content = receiveMessageDisplayedText.value;
+
         // 滚动到住下面
         await scrollToBottom();
         // 重新设置任务
@@ -607,6 +625,16 @@ const textRoll = async () => {
   } catch {}
 };
 
+
+// 监听消息列表变化,自动滚动到底部
+watch(
+    () => messageList.value,
+    () => {
+      scrollToBottom();
+    },
+    { deep: true }
+);
+
 /** 初始化 **/
 onMounted(async () => {
   if (personId.value) {