Pārlūkot izejas kodu

小智官方web端测试对话功能代码转移uniapp上并针对最新对话页面进行适配修改;功能已完成

htc 3 dienas atpakaļ
vecāks
revīzija
f0706d584a

+ 104 - 0
common/BlockingQueue.js

@@ -0,0 +1,104 @@
+// /common/BlockingQueue.js
+
+/**
+ * 一个异步阻塞队列,适用于uniapp和小程序环境。
+ * 核心逻辑与原版完全相同,仅将私有字段 '#' 替换为下划线 '_' 以增强兼容性。
+ */
+export default class BlockingQueue {
+    // 使用下划线 '_' 代替 '#' 作为私有属性的约定
+    _items   = [];
+    _waiters = [];          // {resolve, reject, min, timer, onTimeout}
+
+    /* 空队列一次性闸门 */
+    _emptyPromise = null;
+    _emptyResolve = null;
+
+    /* 生产者:把数据塞进去 */
+    enqueue(item, ...restItems) {
+        if (restItems.length === 0) {
+            this._items.push(item);
+        }
+        // 如果有额外参数,批量处理所有项
+        else {
+            const items = [item, ...restItems].filter(i => i);
+            if (items.length === 0) return;
+            this._items.push(...items);
+        }
+        // 若有空队列闸门,一次性放行所有等待者
+        if (this._emptyResolve) {
+            this._emptyResolve();
+            this._emptyResolve = null;
+            this._emptyPromise = null;
+        }
+
+        // 唤醒所有正在等的 waiter
+        this._wakeWaiters();
+    }
+
+    /* 消费者:min 条或 timeout ms 先到谁 */
+    async dequeue(min = 1, timeout = Infinity, onTimeout = null) {
+        // 1. 若空,等第一次数据到达(所有调用共享同一个 promise)
+        if (this._items.length === 0) {
+            await this._waitForFirstItem();
+        }
+
+        // 立即满足
+        if (this._items.length >= min) {
+            return this._flush();
+        }
+
+        // 需要等待
+        return new Promise((resolve, reject) => {
+            let timer = null;
+            const waiter = { resolve, reject, min, onTimeout, timer };
+
+            // 超时逻辑
+            if (Number.isFinite(timeout)) {
+                waiter.timer = setTimeout(() => {
+                    this._removeWaiter(waiter);
+                    if (onTimeout) onTimeout(this._items.length);
+                    resolve(this._flush());
+                }, timeout);
+            }
+
+            this._waiters.push(waiter);
+        });
+    }
+
+    /* 内部方法:同样使用 '_' 前缀 */
+    _waitForFirstItem() {
+        if (!this._emptyPromise) {
+            this._emptyPromise = new Promise(r => (this._emptyResolve = r));
+        }
+        return this._emptyPromise;
+    }
+
+    _wakeWaiters() {
+        for (let i = this._waiters.length - 1; i >= 0; i--) {
+            const w = this._waiters[i];
+            if (this._items.length >= w.min) {
+                this._removeWaiter(w);
+                w.resolve(this._flush());
+            }
+        }
+    }
+
+    _removeWaiter(waiter) {
+        const idx = this._waiters.indexOf(waiter);
+        if (idx !== -1) {
+            this._waiters.splice(idx, 1);
+            if (waiter.timer) clearTimeout(waiter.timer);
+        }
+    }
+
+    _flush() {
+        const snapshot = [...this._items];
+        this._items.length = 0;
+        return snapshot;
+    }
+
+    /* 当前缓存长度(不含等待者) */
+    get length() {
+        return this._items.length;
+    }
+}

+ 209 - 0
common/StreamingContext.js

@@ -0,0 +1,209 @@
+// /common/StreamingContext.js
+
+import BlockingQueue from './BlockingQueue.js'; // 使用我们之前适配过的版本
+
+/**
+ * 适配uniapp小程序环境的音频流处理上下文。
+ * 它负责解码Opus流,将其缓冲为PCM数据,并在需要时转换为WAV文件进行播放。
+ */
+export class StreamingContext {
+    /**
+     * @param {object} opusDecoder - 已初始化的Opus解码器实例
+     * @param {InnerAudioContext} innerAudioContext - uniapp创建的音频播放器实例
+     * @param {number} sampleRate - 采样率 (e.g., 16000)
+     * @param {number} channels - 声道数 (e.g., 1)
+     */
+    constructor(opusDecoder, innerAudioContext, sampleRate, channels) {
+        this.opusDecoder = opusDecoder;
+        this.innerAudioContext = innerAudioContext;
+        this.sampleRate = sampleRate;
+        this.channels = channels;
+
+        // 输入队列,用于接收来自WebSocket的原始Opus数据包
+        this.inputQueue = new BlockingQueue();
+        
+        // 内部PCM缓冲,存储解码后的Int16数据块
+        this._pcmBuffer = [];
+        this._totalPcmSamples = 0;
+
+        // 状态标记
+        this._isDecoding = false;
+        this._isStopped = false;
+    }
+
+    /**
+     * 外部调用:向队列中添加待解码的原始Opus数据。
+     * @param {Uint8Array} opusFrame - 从WebSocket收到的Opus数据帧
+     */
+    pushOpusFrame(opusFrame) {
+        if (opusFrame && opusFrame.length > 0) {
+            this.inputQueue.enqueue(opusFrame);
+        }
+    }
+
+    /**
+     * 启动解码循环。这个循环会持续运行,直到被stop()。
+     * 它会阻塞地等待`inputQueue`中的数据。
+     */
+    async startDecodingLoop() {
+        if (this._isDecoding) return; // 防止重复启动
+        if (!this.opusDecoder) {
+            console.log('Opus解码器未初始化,无法启动解码循环', 'error');
+            return;
+        }
+
+        this._isDecoding = true;
+        this._isStopped = false;
+        console.log('Opus解码循环已启动', 'info');
+
+        while (!this._isStopped) {
+            try {
+                // 阻塞式地等待数据帧,可以一次性取出多个
+                const framesToDecode = await this.inputQueue.dequeue(1, Infinity);
+                
+                if (this._isStopped) break; // 检查在等待后是否被停止
+
+                for (const frame of framesToDecode) {
+                    const pcmFrame = this.opusDecoder.decode(frame); // 假设返回 Int16Array
+                    if (pcmFrame && pcmFrame.length > 0) {
+                        this._pcmBuffer.push(pcmFrame);
+                        this._totalPcmSamples += pcmFrame.length;
+                    }
+                }
+            } catch (error) {
+                console.log(`解码循环中出错: ${error.message}`, 'error');
+            }
+        }
+        
+        this._isDecoding = false;
+        console.log('Opus解码循环已停止', 'info');
+    }
+
+    /**
+     * 外部调用:将当前所有缓冲的PCM数据转换成WAV文件并播放。
+     */
+    async playBufferedAudio() {
+        if (this.innerAudioContext.paused === false) {
+             console.log('播放器正在播放,本次播放请求被忽略', 'warning');
+             return;
+        }
+       
+        if (this._pcmBuffer.length === 0) {
+            console.log('PCM缓冲区为空,无需播放', 'info');
+            return;
+        }
+
+        // 1. 合并所有PCM数据块
+        const totalSamples = this._totalPcmSamples;
+        const fullPcmData = new Int16Array(totalSamples);
+        let offset = 0;
+        for (const pcmChunk of this._pcmBuffer) {
+            fullPcmData.set(pcmChunk, offset);
+            offset += pcmChunk.length;
+        }
+        
+        // 清空缓冲区以便下一次会话
+        this.reset();
+        
+        console.log(`准备播放,总样本数: ${totalSamples}`, 'info');
+
+        // 2. 转换为WAV格式 (ArrayBuffer)
+        const wavData = this._pcmToWav(fullPcmData, this.sampleRate, this.channels);
+        
+        // 3. 写入临时文件并播放
+        const fs = wx.getFileSystemManager();
+        const tempFilePath = `${wx.env.USER_DATA_PATH}/temp_audio_${Date.now()}.wav`;
+
+        fs.writeFile({
+            filePath: tempFilePath,
+            data: wavData,
+            encoding: 'binary',
+            success: () => {
+                console.log(`WAV文件写入成功: ${tempFilePath}`, 'success');
+                this.innerAudioContext.src = tempFilePath;
+                this.innerAudioContext.play();
+            },
+            fail: (err) => {
+                console.log(`WAV文件写入失败: ${JSON.stringify(err)}`, 'error');
+            }
+        });
+    }
+
+    /**
+     * 重置上下文状态,清空所有缓冲区,为下一次语音会话做准备。
+     */
+    reset() {
+        this._pcmBuffer = [];
+        this._totalPcmSamples = 0;
+        // 注意:不要重置 inputQueue,因为它可能有预读的数据
+        console.log('StreamingContext已重置', 'info');
+    }
+    
+    /**
+     * 停止解码循环并清理资源。
+     */
+    stop() {
+      this._isStopped = true;
+      // 通过入队一个null值来唤醒可能在dequeue上阻塞的循环,使其能检查到_isStopped标志
+      this.inputQueue.enqueue(null); 
+    }
+
+    /**
+     * [核心辅助函数] 将Int16 PCM数据转换为WAV文件格式的ArrayBuffer
+     * @param {Int16Array} pcmData - 原始PCM数据
+     * @param {number} sampleRate - 采样率
+     * @param {number} channels - 声道数
+     * @returns {ArrayBuffer}
+     */
+    _pcmToWav(pcmData, sampleRate, channels) {
+        const bitsPerSample = 16;
+        const dataSize = pcmData.length * (bitsPerSample / 8);
+        const fileSize = 44 + dataSize;
+
+        const buffer = new ArrayBuffer(fileSize);
+        const view = new DataView(buffer);
+
+        // 写入WAV文件头
+        // RIFF chunk descriptor
+        this._writeString(view, 0, 'RIFF');
+        view.setUint32(4, fileSize - 8, true); // fileSize
+        this._writeString(view, 8, 'WAVE');
+        // "fmt " sub-chunk
+        this._writeString(view, 12, 'fmt ');
+        view.setUint32(16, 16, true); // chunkSize
+        view.setUint16(20, 1, true); // audioFormat (1 for PCM)
+        view.setUint16(22, channels, true); // numChannels
+        view.setUint32(24, sampleRate, true); // sampleRate
+        view.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true); // byteRate
+        view.setUint16(32, channels * (bitsPerSample / 8), true); // blockAlign
+        view.setUint16(34, bitsPerSample, true); // bitsPerSample
+        // "data" sub-chunk
+        this._writeString(view, 36, 'data');
+        view.setUint32(40, dataSize, true);
+
+        // 写入PCM数据
+        for (let i = 0; i < pcmData.length; i++) {
+            view.setInt16(44 + i * 2, pcmData[i], true);
+        }
+
+        return buffer;
+    }
+    
+    _writeString(view, offset, string) {
+        for (let i = 0; i < string.length; i++) {
+            view.setUint8(offset + i, string.charCodeAt(i));
+        }
+    }
+}
+
+/**
+ * 创建StreamingContext实例的工厂函数
+ * @param {object} opusDecoder 
+ * @param {InnerAudioContext} innerAudioContext 
+ * @param {number} sampleRate 
+ * @param {number} channels 
+ * @returns {StreamingContext}
+ */
+export function createStreamingContext(opusDecoder, innerAudioContext, sampleRate, channels) {
+    return new StreamingContext(opusDecoder, innerAudioContext, sampleRate, channels);
+}

+ 167 - 0
common/xiaoZhiConnect.js

@@ -0,0 +1,167 @@
+import qs from 'qs';
+// 导入我们之前改写的日志模块
+/**
+ * 验证WebSocket服务器URL格式
+ * @param {string} wsUrl 
+ * @returns {boolean}
+ */
+function validateWsUrl(wsUrl) {
+    if (!wsUrl) {
+        console.log('WebSocket服务器地址不能为空', 'error');
+        return false;
+    }
+    if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
+        console.log('URL格式错误,必须以ws://或wss://开头', 'error');
+        return false;
+    }
+    return true;
+}
+
+/**
+ * 验证设备配置
+ * @param {object} config 
+ * @returns {boolean}
+ */
+function validateConfig(config) {
+    if (!config.deviceMac) {
+        console.log('设备MAC地址不能为空', 'error');
+        return false;
+    }
+    if (!config.clientId) {
+        console.log('客户端ID不能为空', 'error');
+        return false;
+    }
+    return true;
+}
+
+/**
+ * 发送OTA请求,验证设备状态
+ * @param {string} otaUrl 
+ * @param {object} config 
+ * @returns {Promise<boolean>} - 返回一个Promise,resolve为true表示成功,false表示失败
+ */
+async function sendOTA(otaUrl, config) {
+    console.log('正在进行OTA状态验证...', 'info');
+    try {
+        // 【修正】uni.request 的 await 返回的是单个 res 对象
+        const res = await uni.request({
+            url: otaUrl,
+            method: 'POST',
+            header: {
+                'Content-Type': 'application/json',
+                'Device-Id': config.deviceMac,
+                'Client-Id': config.clientId
+            },
+            data: {
+                version: 0,
+                uuid: '',
+                application: {
+                    name: 'xiaozhi-uniapp-test',
+                    version: '1.0.0',
+                    compile_time: '2025-04-16 10:00:00',
+                    idf_version: '4.4.3',
+                    elf_sha256: '1234567890abcdef1234567890abcdef1234567890abcdef'
+                },
+                ota: { label: 'xiaozhi-uniapp-test' },
+                board: {
+                    type: 'xiaozhi-uniapp-test',
+                    ssid: 'xiaozhi-uniapp-test',
+                    rssi: 0,
+                    channel: 0,
+                    ip: '192.168.1.1',
+                    mac: config.deviceMac
+                },
+                flash_size: 0,
+                minimum_free_heap_size: 0,
+                mac_address: config.deviceMac,
+                chip_model_name: '',
+                chip_info: { model: 0, cores: 0, revision: 0, features: 0 },
+                partition_table: [{ label: '', type: 0, subtype: 0, address: 0, size: 0 }]
+            }
+        });
+		
+		console.log(res,'res');
+		let _res = res[1]||null;
+		if(!_res) return false;
+		
+        if (_res.statusCode >= 200 && _res.statusCode < 300) {
+			// 【新增判断】检查返回的数据体中是否包含error字段
+			if (_res.data && _res.data.error) {
+				// 如果有error字段,说明业务逻辑失败了
+				throw new Error(`OTA业务错误: ${_res.data.error}`);
+			}
+			
+			// 只有当状态码正确且没有业务错误时,才算真正成功
+			console.log(`OTA验证成功: ${JSON.stringify(_res.data)}`, 'success');
+			return true;
+		} else {
+			throw new Error(`HTTP错误: ${_res.statusCode}`);
+		}
+
+    } catch (err) {
+        const errorMessage = err.errMsg || err.message || '未知网络错误';
+		console.log(`OTA验证失败: ${errorMessage}`, 'error');
+		return false;
+    }
+}
+
+/**
+ * 【修正版 - 使用qs库】构建带参数的URL
+ * @param {string} baseUrl 
+ * @param {object} params 
+ * @returns {string}
+ */
+function buildUrlWithParams(baseUrl, params) {
+    const paramString = qs.stringify(params, { addQueryPrefix: true }); // addQueryPrefix 会自动加上 '?'
+    return baseUrl + paramString;
+}
+
+
+/**
+ * 执行OTA检查并连接WebSocket服务器
+ * @param {string} otaUrl - OTA服务器地址
+ * @param {string} wsUrl - WebSocket服务器地址
+ * @param {object} config - 设备配置对象
+ * @returns {Promise<SocketTask|null>} - 返回一个Promise。
+ *   - 如果连接前置步骤成功,resolve为uniapp的SocketTask对象。
+ *   - 如果任何步骤失败,resolve为null。
+ */
+export async function webSocketConnect(otaUrl, wsUrl, config) {
+    if (!validateWsUrl(wsUrl) || !validateConfig(config)) {
+        return null; // 验证失败
+    }
+
+    // 1. 执行OTA检查
+	console.log(otaUrl, config);
+    const otaOk = await sendOTA(otaUrl, config);
+	console.log(otaOk);
+    if (!otaOk) {
+        // OTA失败,直接返回,由调用方处理UI状态
+        return null;
+    }
+
+    // 2. 构建连接URL
+    // 小程序环境没有内置URL对象,需要手动拼接
+    const params = {
+        'device-id': config.deviceMac, 
+        'client-id': config.clientId
+    };
+    const finalUrl = buildUrlWithParams(wsUrl, params);
+
+    console.log(`准备连接WebSocket: ${finalUrl}`, 'info');
+
+    // 3. 使用 uni.connectSocket 替换 new WebSocket()
+    // 它会立即返回一个 SocketTask 对象,连接过程是异步的
+    const socketTask = uni.connectSocket({
+        url: finalUrl,
+        // success/fail回调只代表API调用是否成功,不代表连接是否成功
+        success: () => {}, 
+        fail: (err) => {
+            console.log(`uni.connectSocket API调用失败: ${JSON.stringify(err)}`, 'error');
+        }
+    });
+
+    // 4. 返回 SocketTask 对象
+    // 调用此函数的组件需要负责监听 onOpen, onMessage, onError, onClose 事件
+    return socketTask;
+}

+ 1 - 1
components/deviceBox/index.vue

@@ -83,7 +83,7 @@ export default {
 		},
 		toDialog(item){
 			uni.navigateTo({
-				url:'/pages/dialog?deviceMac='+item.id
+				url:'/pages/dialog?deviceMac='+(item?.id||'fc:01:2c:c8:da:5a')
 			})
 		},
 		unbindDevice(item) {

+ 170 - 137
pages/dialog.vue

@@ -1,6 +1,6 @@
 <template>
 	<view class="page" :style="{'min-height':h+'px', 'padding-top':mt+'px'}">
-		<u-navbar title="对话" bgColor="transparent" :titleStyle="{'font-size':'32rpx','font-weight':'bold'}">
+		<u-navbar title="对话" bgColor="#F6F6F8" :titleStyle="{'font-size':'32rpx','font-weight':'bold'}">
 			<view class="u-nav-slot" slot="left" style="display: flex;background-color: transparent;">
 				<image src="https://transcend.ringzle.com/xiaozhi-app/profile/2025/09/10/2f7cc90a-848c-4369-b573-4448125591a9.png" style="width: 45rpx;height: 45rpx;" @tap="toBack"></image>
 				<!-- <image src="https://transcend.ringzle.com/xiaozhi-app/profile/2025/09/10/3580b8d2-8ddb-4385-8516-fb6f47ce5fea.png" style="width: 42rpx;height: 42rpx;margin-left: 40rpx;" @tap="startNewDialog"></image> -->
@@ -40,7 +40,7 @@
 								</view>
 							</template>
 							<template v-else>
-								<u-parse :content="item.answer"></u-parse>
+								<u-parse :content="item.answer" style="font-size: 28rpx;line-height: 54rpx;color: #252525;"></u-parse>
 							</template>
 							<div class="dc_btns adfacjb" v-if="item.answer">
 								<div class="db_l">
@@ -49,7 +49,7 @@
 									<image :src="item.comment?require('@/static/comment_active.png'):require('@/static/comment.png')" @tap="toComment(item,index)"></image>
 								</div>
 								<div class="db_r">
-								<!-- 	<image :src="item.share?require('@/static/share_active.png'):require('@/static/share.png')" @tap="toShare(item,index)"></image> -->
+									<image :src="item.share?require('@/static/share_active.png'):require('@/static/share.png')" @tap="toShare(item,index)"></image>
 								</div>
 							</div>
 						</div>
@@ -81,166 +81,198 @@
 	let requestTask = null;
 	import { BaseApi } from '../http/baseApi.js'
 	import * as TextEncoding from "text-encoding-shim";
+	import { webSocketConnect } from '@/common/xiaoZhiConnect.js';
 	export default {
 		data(){
 			return {
-				isDialog:false,
-				retryCount: 3, // 最大重试次数
-				currentRetry: 0, // 当前重试次数
-				isRequesting: false, // 请求状态锁
 				question:'',
-				streamingResponse:'',
-				receivedData:'',
 				dialogList:[],
-				windex:0,
 				commentShow:false,
 				content:'',
 				cindex:'',
 				chzq:false,
 				lwss:false,
-				fixBottom: 0
+				fixBottom: 0,
+				// 对话
+				config: {
+					deviceMac: '',
+					deviceName: '小智AI设备',
+					clientId: 'uniapp_xiaozhi_client',
+					token: 'xiaozhi-ai',
+					otaUrl: 'http://i.ringzle.com:8002/xiaozhi/ota/',
+					serverUrl: 'ws://i.ringzle.com:8000/xiaozhi/v1/'
+				},
+				isConnected: false,
+				textMessage: '',
+				socketTask: null,
 			}
+		},
+		computed: {
+			displayMac() {
+				return this.config.deviceMac;
+			},
+			displayClient() {
+				return this.config.clientId;
+			}
 		},
 		onReady() {
 		    uni.onKeyboardHeightChange(res => {
 		      this.fixBottom = res.height||0;
 		    });
 		},
+		onLoad(option) {
+			this.config.deviceMac = option?.deviceMac;
+			this.connectToServer();
+		},
 		onUnload() {
+			this.disconnectFromServer();
 		    uni.offKeyboardHeightChange();
 			this.fixBottom = 0;
 		},
 		methods:{
-			toBack(){
-				uni.navigateBack();
-			},
-			startNewDialog(){
-				clearInterval(timer)
-				this.dialogList = [];
-				this.question = '';
-				this.streamingResponse = '';
-			},
-			// 封装带重试机制的请求方法
-			async sendRequestWithRetry() {
-				if (this.isRequesting) return;
-				this.isRequesting = true;
-				this.currentRetry = 0;
-				try {
-					await this._executeRequest();
-				} catch (error) {
-					  this.$showToast('请求失败,请稍后重试')
-				} finally {
-					this.isRequesting = false;
+			async connectToServer() {
+				if (this.socketTask) return;
+				console.log('开始连接流程...');
+				const task = await webSocketConnect(
+					this.config.otaUrl,
+					this.config.serverUrl,
+					this.config
+				);
+			
+				if(task) {
+					this.socketTask = task;
+					this.setupSocketListeners();
 				}
 			},
-			_executeRequest2(){
-				return new Promise((resolve, reject) => {
-					this.$api.post('/core/chat/sendChatMessageStream',{query:this.question},false).then(res=>{
-						if(res.data.code!==0) return this.$showToast(res.data.msg)
-						let answer = this.dialogList[this.dialogList.length-1].answer+res.data.data;
-						this.$set(this.dialogList[this.dialogList.length-1],'answer',answer);
-						this.$set(this.dialogList[this.dialogList.length-1],'think',false);
-						setTimeout(()=>{
-							this.scrollToBottom();
-						},100)
-						resolve()
-					})
-				})
+			setupSocketListeners() {
+				this.socketTask.onOpen(() => {
+					this.isConnected = true;
+					console.log('WebSocket连接已打开', 'success');
+					this.sendHelloMessage();
+				});
+			
+				this.socketTask.onClose(() => {
+					this.isConnected = false;
+					console.log('WebSocket连接已关闭', 'info');
+					this.socketTask = null;
+				});
+			
+				this.socketTask.onError((err) => {
+					this.isConnected = false;
+					console.log(`WebSocket错误: ${JSON.stringify(err)}`, 'error');
+				});
+			
+				this.socketTask.onMessage((res) => {
+					if (typeof res.data === 'string') {
+						this.handleTextMessage(res.data);
+					} else {
+						this.handleBinaryMessage(res.data);
+					}
+				});
 			},
-			// 实际执行请求的方法
-			_executeRequest() {
-				return new Promise((resolve, reject) => {
-					requestTask = uni.request({
-						url: `${BaseApi}/core/chat/sendChatMessageStream`,
-						method: 'POST',
-						timeout: 10000,
-						data:{ 
-							query: this.question,
-							identity:'被教练者',
-						},
-						header: {
-						  'Content-Type': 'application/json',
-						  'token': uni.getStorageSync('token') || ''
-						},
-						enableChunked: true, // 启用流式接收
-						responseType:'text',
-						success: (res) => {
-							if (res.statusCode === 200) {
-								this._handleSuccess(res.data);
-								resolve();
-							} else {
-								this._handleError(`状态码异常: ${res.statusCode}`, resolve, reject);
-							}
-						},
-						fail: (err) => {
-							this._handleError(err.errMsg, resolve, reject);
-						},
-						complete: (com) => {
-							console.log('请求完成',com)
-						}
-					});
-					
-					requestTask.onChunkReceived(async (res) => {
-						const uint8Array = new Uint8Array(res.data);
-						const decoder = new TextEncoding.TextDecoder("utf-8");
-						const decodedString = decoder.decode(uint8Array);
-						try {
-							let newtext = decodedString.replaceAll('data:','').replaceAll(':keepAlive','');
-							let ntArr = newtext.split('\n\n');
-							if(ntArr.length){
-								ntArr.forEach(n=>{
-									if(!n.trim()) return
-									let nj = JSON.parse(n);
-									if(nj.event=='message'){
-										let answer = this.dialogList[this.dialogList.length-1].answer+nj.answer?.replace(/(\r\n|\n|\r)+/g, '<br>');
-										this.$set(this.dialogList[this.dialogList.length-1],'answer',answer);
-										this.$set(this.dialogList[this.dialogList.length-1],'id',nj.id);
-										this.$set(this.dialogList[this.dialogList.length-1],'task_id',nj.task_id);
-										this.$set(this.dialogList[this.dialogList.length-1],'message_id',nj.message_id);
-										this.$set(this.dialogList[this.dialogList.length-1],'conversation_id',nj.conversation_id);
-										this.$set(this.dialogList[this.dialogList.length-1],'think',false);
-									}
-								})
-								setTimeout(()=>{
-									this.scrollToBottom();
-								},100)
-							}
-						} catch (e) {
-							console.error('解析失败', e, '原始数据:', decodedString);
-						}
+			disconnectFromServer() {
+				if (this.socketTask) {
+					this.socketTask.close({
+						code: 1000,
+						reason: 'manual disconnection'
 					});
+					console.log('手动断开连接', 'info');
+				}
+			},
+			sendSocketMessage(data) {
+				if (!this.isConnected || !this.socketTask) {
+					console.log('无法发送消息,连接未建立', 'error');
+					return;
+				}
+				uni.sendSocketMessage({
+					data: data,
+					fail: (err) => {
+						console.log(`发送消息失败: ${JSON.stringify(err)}`, 'error');
+					}
 				});
 			},
-			// 成功处理
-			_handleSuccess(data) {
-				if (data) {
-				  this.streamingResponse += data;
+			sendHelloMessage() {
+				const helloMessage = {
+					type: 'hello',
+					device_id: this.config.deviceMac,
+					device_name: this.config.deviceName,
+					device_mac: this.config.deviceMac,
+					token: this.config.token,
+					features: { mcp: true }
+				};
+				console.log('发送hello握手消息', 'info');
+				this.sendSocketMessage(JSON.stringify(helloMessage));
+			},
+			addConversationMessage(text, isUser = false) {
+				console.log(text,isUser);
+				text = text+'<br>'
+				if(!isUser){
+					let answer = this.dialogList[this.dialogList.length-1].answer+text?.replace(/(\r\n|\n|\r)+/g, '<br>');
+					this.$set(this.dialogList[this.dialogList.length-1],'answer',answer);
+					this.$set(this.dialogList[this.dialogList.length-1],'think',false);
+					setTimeout(()=>{
+						this.scrollToBottom();
+					},100)
 				}
-				this.currentRetry = 0; // 重置重试计数器
 			},
-			// 错误处理
-			_handleError(errorMsg, resolve, reject) {
-				if (this._shouldRetry(errorMsg)) {
-					this.currentRetry++;
-					setTimeout(() => {
-						this._executeRequest().then(resolve).catch(reject);
-					}, this._getRetryDelay());
-				} else {
-					reject(errorMsg);
+			sendTextMessage() {
+				const message = this.question.trim();
+				if (!message) return;
+				const listenMessage = {
+					type: 'listen',
+					mode: 'manual',
+					state: 'detect',
+					text: message
+				};
+				this.sendSocketMessage(JSON.stringify(listenMessage));
+				this.addConversationMessage(message, true);
+				console.log(`发送文本: ${message}`, 'info');
+			},
+			handleTextMessage(jsonString) {
+				try {
+					const message = JSON.parse(jsonString);
+					console.log(`收到文本消息: ${message.type}`, 'debug');
+					switch (message.type) {
+						case 'hello':
+							console.log(`服务器回应: Session ID ${message.session_id}`, 'success');
+							break;
+						case 'tts':
+							if(message.state === 'sentence_start' && message.text) {
+								this.addConversationMessage(message.text);
+							}
+							break;
+						case 'llm':
+							if(message.text && message.text !== '😊') {
+								this.addConversationMessage(message.text);
+							}
+							break;
+						default:
+							console.log(`收到消息: ${jsonString}`, 'info');
+					}
+				} catch (e) {
+					console.log(`解析JSON失败: ${e.message}`, 'error');
+					console.log(`原始数据: ${jsonString}`, 'debug');
 				}
 			},
-			// 判断是否需要重试
-			_shouldRetry(errorMsg) {
-				const retryableErrors = [
-					'timeout', 
-					'request:fail', 
-					'Network Error'
-				];
-				return this.currentRetry < this.retryCount && retryableErrors.some(e => errorMsg.includes(e));
+			handleBinaryMessage(arrayBuffer) {
+				console.log(`收到二进制数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
 			},
-			// 获取指数退避延迟时间
-			_getRetryDelay() {
-				return Math.min(1000 * Math.pow(2, this.currentRetry), 10000);
+			
+			toBack(){
+				uni.navigateBack();
+			},
+			startNewDialog(){
+				uni.showModal({
+					title:'温馨提示',
+					content:'是否开启新的一轮对话?',
+					success: (res) => {
+						if(res.confirm){
+							clearInterval(timer)
+							this.dialogList = [];
+							this.question = '';
+						}
+					}
+				})
 			},
 			sendQuestion(){
 				if(!this.question) return this.$showToast('请输入您的问题');
@@ -257,7 +289,7 @@
 				this.dialogList = [...this.dialogList,...[qa]];
 				this.$nextTick(()=>{
 					this.scrollToBottom();
-					this.sendRequestWithRetry();
+					this.sendTextMessage();
 					this.question = '';
 				})
 			},
@@ -321,7 +353,7 @@
 	}
 	
 	.page{
-		background: linear-gradient( 227deg, #EEEFF8 0%, #F6ECF4 100%, #F6ECF4 100%);
+		background: #F6F6F8;
 		padding: 0 30rpx 200rpx;
 		box-sizing: border-box;
 		
@@ -356,6 +388,7 @@
 			padding-top: 34rpx;
 			box-sizing: border-box;
 			overflow-y: auto;
+			padding-bottom: 50rpx;
 			.d_answer{
 				margin-top: 40rpx;
 				&.init{
@@ -409,13 +442,13 @@
 				display: flex;
 				justify-content: flex-end;
 				.dq_text{
-					background: #833478;
+					background: #D9F159;
 					border-radius: 24rpx 4rpx 24rpx 24rpx;
 					font-family: PingFangSC, PingFang SC;
 					font-weight: 400;
-					font-size: 30rpx;
-					color: #FFFFFF;
-					line-height: 48rpx;
+					font-size: 28rpx;
+					color: #252525;
+					line-height: 40rpx;
 					// text-align: right;
 					padding: 26rpx 30rpx;
 				}
@@ -424,7 +457,7 @@
 		.ask_box{
 			width: 100%;
 			min-height: 176rpx;
-			background: linear-gradient( 227deg, #EEEFF8 0%, #F6ECF4 100%, #F6ECF4 100%);
+			background: #F6F6F8;
 			padding: 0 30rpx 60rpx;
 			position: fixed;
 			left: 0;

+ 1 - 0
pages/home.vue

@@ -66,6 +66,7 @@
 					this.list.forEach(l=>{
 						l.roleModelName = this.modelMap.get(l?.agent?.systemPrompt)?.agentName;
 					})
+					this.list.push({})
 				})
 			},
 			addDevice(){