7 Commity f2a5dd4846 ... adfebc72a7

Autor SHA1 Wiadomość Data
  hizhangling adfebc72a7 Merge remote-tracking branch 'origin/master' 5 dni temu
  hizhangling 010358a8f9 [feat][ai聊天][消息折叠与展开] 5 dni temu
  hizhangling 4950bada33 [feat][ai聊天][消息折叠与展开] 5 dni temu
  hizhangling 3ec60108cf [feat][ai聊天][语音识别] 5 dni temu
  hizhangling 0f364c2605 [feat][ai聊天][语音识别] 5 dni temu
  hizhangling 75ea3be56a [feat][ai聊天][语音识别] 5 dni temu
  hizhangling 791b397639 [feat][ai聊天][语音识别] 6 dni temu

Plik diff jest za duży
+ 1 - 0
public/ai/tts/dist/index.cjs.js


+ 35 - 0
public/ai/tts/dist/index.d.ts

@@ -0,0 +1,35 @@
+type ISaveAudioData = "pcm" | "wav";
+declare class AudioPlayer {
+    constructor(processorPath?: string);
+    private toSampleRate;
+    private resumePlayDuration;
+    private fromSampleRate;
+    private isAudioDataEnded;
+    private playAudioTime?;
+    private status;
+    private audioContext?;
+    private bufferSource?;
+    private audioDatas;
+    private pcmAudioDatas;
+    private audioDataOffset;
+    private processor;
+    postMessage({ type, data, isLastData }: {
+        type: "base64" | "string" | "Int16Array" | "Float32Array";
+        data: string | Int16Array | Float32Array;
+        isLastData: boolean;
+    }): void;
+    private onPlay?;
+    private onStop?;
+    private playAudio;
+    reset(): void;
+    start({ autoPlay, sampleRate, resumePlayDuration }?: {
+        autoPlay?: boolean;
+        sampleRate?: number;
+        resumePlayDuration?: number;
+    }): void;
+    play(): void;
+    stop(): void;
+    getAudioDataBlob(type: ISaveAudioData): Blob | undefined;
+}
+
+export { AudioPlayer as default };

Plik diff jest za duży
+ 1 - 0
public/ai/tts/dist/index.esm.js


Plik diff jest za duży
+ 1 - 0
public/ai/tts/dist/index.umd.js


Plik diff jest za duży
+ 1 - 0
public/ai/tts/dist/processor.worker.js


+ 1 - 1
src/api/xjc-integratedmachine/common/aiChat.js

@@ -14,7 +14,7 @@ export function aiChatRecordAdd(data) {
     return request({
         url: '/ai/chat/record/add',
         method: 'post',
-        params : data
+        data : data
     })
 }
 

+ 61 - 0
src/utils/scroll.js

@@ -0,0 +1,61 @@
+/**
+ * 设置滚动位置
+ * @param {Object} recordWrapperTarget - 滚动容器ref对象
+ * @param {string} behavior - 滚动行为,默认为"smooth"
+ * @param {boolean} autoScroll - 是否自动滚动到底部,默认为true
+ */
+export const setScroll = (recordWrapperTarget, behavior = "smooth", autoScroll = true) => {
+    if (!recordWrapperTarget || !recordWrapperTarget.value) return;
+
+    const container = recordWrapperTarget.value;
+    const scrollOptions = { behavior };
+
+    // 初始化滚动状态
+    if (!container._scrollState) {
+        container._scrollState = {
+            autoScrollEnabled: true,
+            lastScrollTime: 0,
+            timeout: null
+        };
+
+        container.addEventListener('scroll', () => {
+            const { scrollTop, scrollHeight, clientHeight } = container;
+            const now = Date.now();
+            const distanceToBottom = scrollHeight - (scrollTop + clientHeight);
+
+            // 检测向上滚动(scrollTop减小)
+            if (scrollTop < container._scrollState.lastScrollTop) {
+                container._scrollState.autoScrollEnabled = false;
+            }
+
+            // 滚动到底部时恢复自动滚动
+            if (distanceToBottom < 10) {
+                container._scrollState.autoScrollEnabled = true;
+            }
+
+            // 记录当前滚动位置
+            container._scrollState.lastScrollTop = scrollTop;
+
+            // 自动滚动保护期
+            if (container._scrollState.isAutoScrolling) {
+                setTimeout(() => {
+                    container._scrollState.isAutoScrolling = false;
+                }, 100);
+            }
+        });
+    }
+
+    // 自动滚动逻辑
+    if (autoScroll && container._scrollState.autoScrollEnabled) {
+        scrollOptions.top = container.scrollHeight;
+        container._scrollState.lastScrollTime = Date.now();
+
+        // 清除之前的超时
+        clearTimeout(container._scrollState.timeout);
+
+        // 设置新的滚动
+        container._scrollState.timeout = setTimeout(() => {
+            container.scrollTo(scrollOptions);
+        }, 50);
+    }
+};

+ 111 - 35
src/views/xjc-integratedmachine/common/ai/chat.vue

@@ -13,40 +13,53 @@
             </div>
         </div>
         <div class="main-content">
-            <div class="chat-container">
+            <div v-loading="loadingHistoryRecord" class="chat-container" ref="chatContainerRef">
                 <div class="message-list">
                     <div v-for="(item, index) in chatRecordList" :key="item.id">
-                        <!-- 会话图标 -->
-                        <!--<div :class="item.isUser == 1? 'user-image' : 'system-image' "></div>-->
                         <div v-if="item.isUser == 1" class="user-message">
-                            <img src="@/assets/images/common/ai/user.png" alt="icon"/>
+                            <img src="@/assets/images/common/ai/user.png" alt="icon" style="margin-left: 4px"/>
                             <span class="user-message-content">{{item.content}}</span>
                         </div>
-                        <div v-else class="message bot-message">
-                            <img src="@/assets/images/common/ai/system.png" alt="icon"/>
-                            <div v-show="!expandIndexList.includes(index)" class="bot-message-content" v-html="item.content.substring(0, 80)"></div>
-                            <div v-show="expandIndexList.includes(index)" class="bot-message-content" v-html="item.content"></div>
-                            <el-button v-if="item.content.length>80" @click="foldOrExpandMessage(index, expandIndexList.includes(index))">{{expandIndexList.includes(index)?'折叠':'展开'}}</el-button>
+                        <div v-else class="bot-message">
+                            <img src="@/assets/images/common/ai/system.png" alt="icon" style="margin-left: 4px"/>
+                            <div style="margin-left: 4px;">
+                                <div class="bot-message-content">
+                                    <div v-if="index === chatRecordList.length-1">
+                                        <div v-if="item.isTyping">
+                                            <div v-html="item.content"></div>
+                                            <span class="loading-dots" style="display: flex;justify-content: center">
+                                                <span class="dot"></span>
+                                                <span class="dot"></span>
+                                            </span>
+                                        </div>
+                                        <div v-else>
+                                            <div v-html="item.content"></div>
+                                        </div>
+                                    </div>
+                                    <div v-else>
+                                        <div v-show="!expandIndexList.includes(index)" v-html="item.content.substring(0, 80)"></div>
+                                        <div v-show="expandIndexList.includes(index)" v-html="item.content"></div>
+                                    </div>
+                                </div>
+                                <div style="margin: 4px">
+                                    <el-text>{{item.createTime}}</el-text>
+                                </div>
+                            </div>
+                            <div v-if="index !== chatRecordList.length-1">
+                                <el-button v-if="item.content.length>80" style="margin-left: 4px"
+                                           @click="foldOrExpandMessage(index, expandIndexList.includes(index))">
+                                    {{expandIndexList.includes(index)?'折叠':'展开'}}
+                                </el-button>
+                            </div>
                             <el-button>播放</el-button>
                         </div>
                     </div>
                 </div>
-                <div class="message-box">
-                    <div class="typing-message">
-                        <span>
-                            <span v-html="streamHtmlData"></span>
-                            <span class="loading-dots">
-                                <span class="dot"></span>
-                                <span class="dot"></span>
-                            </span>
-                        </span>
-                    </div>
-                </div>
-                <el-button @click="stopMessage">停止回答</el-button>
             </div>
             <div class="input-container">
                 <el-input
                         v-model="inputMessage"
+                        type="textarea"
                         placeholder="请输入消息"
                         @keyup.enter="sendMessage"
                 ></el-input>
@@ -59,10 +72,13 @@
                     <el-button style="width:140px">退出</el-button>
                 </div>
                 <div class="control-button">
-                    <el-button style="width:140px" @click="startVoice">静音</el-button>
+                    <el-button style="width:140px" @click="startVoice">开始</el-button>
+                </div>
+                <div class="control-button">
+                    <el-button style="width:140px" @click="closeVoiceOpen">结束</el-button>
                 </div>
                 <div class="control-button">
-                    <el-button style="width:140px">新会话</el-button>
+                    <el-button style="width:140px" @click="stopMessage">停止回答</el-button>
                 </div>
             </div>
             <div class="session-list-box">
@@ -73,15 +89,18 @@
 </template>
 
 <script setup>
-    import {nextTick, onMounted} from 'vue'
-    import {aiChatRecordList} from '@/api/xjc-integratedmachine/common/aiChat.js'
+    import {nextTick, onMounted, useTemplateRef} from 'vue'
+    import {aiChatRecordList, aiChatRecordAdd} from '@/api/xjc-integratedmachine/common/aiChat.js'
     import { getToken } from '@/utils/auth'
+    import { setScroll } from '@/utils/scroll.js'
     // md转换为html
     import { marked } from 'marked'
     // 语音识别
     import CryptoJS from 'crypto-js';
     import * as RecorderManager from "/public/ai/iat/dist/index.umd.js"
+    import * as AudioPlayer from "/public/ai/tts/dist/index.umd.js"
 
+    const loadingHistoryRecord = ref(false)
     // 聊天记录
     let chatRecordList = ref([])
     // md流式数据
@@ -106,15 +125,27 @@
     // 查看所有聊天记录
     list();
     function list() {
+        loadingHistoryRecord.value = true
         let queryForm = {
-
+            pageNum: 1,
+            pageSize: 10000
         }
         aiChatRecordList(queryForm).then(resp =>{
-            console.log(resp)
             chatRecordList.value = resp.rows;
+            loadingHistoryRecord.value = false
         })
     }
 
+    function addRecord(content) {
+        let queryForm = {
+            content: content,
+            isUser: 0
+        }
+        aiChatRecordAdd(queryForm).then(resp =>{
+            console.log(resp)
+
+        })
+    }
     const sendMessage = () => {
         if (inputMessage.value.trim()) {
             sendRequest(inputMessage.value.trim())
@@ -130,7 +161,16 @@
             isTyping: false
         }
         // 消息加入聊天记录
-        // chatRecordList.value.push(userMessage)
+        chatRecordList.value.push(userMessage)
+        const botMsg = {
+            isUser: false,
+            content: '', // 增量填充
+            isTyping: true, // 显示加载动画
+        }
+        chatRecordList.value.push(botMsg)
+        const lastMsg = chatRecordList.value[chatRecordList.value.length - 1]
+
+
         try{
             isLoading.value = true;
             streamMarkdownData.value = ''; // 清空之前的数据
@@ -166,16 +206,22 @@
                 chunk = chunk.replace(/\n\n/g, '').replace(/data:/g, '')
                 streamMarkdownData.value += chunk;
                 streamHtmlData.value = marked(streamMarkdownData.value)
+                lastMsg.content = marked(streamMarkdownData.value)
             }
         }catch (error) {
             // 如果是手动中止,不显示错误
             if (error.name !== 'AbortError') {
                 console.error('流式读取失败:', error);
-                streamMarkdownData.value = 'Error: ' + error.message;
+                streamHtmlData.value = 'Error: ' + error.message;
             }
         } finally {
-            streamHtmlData.value = marked(streamMarkdownData.value)
             isLoading.value = false;
+            let htmlData = marked(streamMarkdownData.value);
+            addRecord(htmlData);
+            streamHtmlData.value = htmlData;
+            lastMsg.content = marked(streamMarkdownData.value)
+            lastMsg.isTyping = false
+            // list()
         }
     }
 
@@ -211,6 +257,7 @@
     let startVoiceStatus = false
     // 识别中状态
     let identifyStatus = false
+    // 录音对象
     let recorder = null
     let transcription = ''
     let btnStatus = ''
@@ -227,8 +274,10 @@
         APIKey: '8b1a53486bec887eb817b4410aa743ed',
     }
     // 初始化语音识别
-    function initRecognize(){
+    function initSpeechRecognition(){
+        // 初始化录音
         recorder = new window.RecorderManager('/ai/iat/dist');
+        // 开始录音
         recorder.onStart = () => {
             changeBtnStatus('OPEN');
         };
@@ -323,8 +372,8 @@
             } else {
                 resultText += str;
             }
-            transcription = resultTextTemp || resultText || '';
-            console.log("识别:"+transcription);
+            inputMessage.value = resultTextTemp || resultText || '';
+            console.log("识别:"+inputMessage.value);
         }
         if (jsonData.code === 0 && jsonData.data.status === 2) {
             iatWS.close();
@@ -413,7 +462,7 @@
     }
 
     function countdown() {
-        let seconds = 10;
+        let seconds = 60;
         console.log(`录音中(${seconds}s)`);
         countdownInterval = setInterval(() => {
             seconds -= 1;
@@ -460,10 +509,31 @@
     }
     /* 语音识别 ↑*/
 
+    /*语音合成 ↓*/
+
+
+    function initSpeechsynthesis(){
+
+    }
+
+    /*语音合成 ↑*/
+
+    let chatContainerRef = ref(null)
+    let chatContainerRefObj = useTemplateRef(chatContainerRef)
+
+
+    const scrollToBottom = () => {
+        nextTick(() => {
+            chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight;
+        });
+    };
 
     onMounted(()=>{
         nextTick(()=>{
-            initRecognize();
+            // 组件挂载后自动滚动到底部
+            scrollToBottom();
+            initSpeechRecognition();
+            initSpeechsynthesis();
             sendMessage()
         })
 
@@ -556,6 +626,11 @@
         /* align-items: center; */
     }
 
+    .typing {
+        display: flex;
+        justify-content: center;
+    }
+
     .user-message {
         display: flex;
         align-self: flex-end;
@@ -643,6 +718,7 @@
     /*输入框*/
     .input-container {
         display: flex;
+        align-items: center;
     }
 
     .input-container .el-input {