diff --git a/main/xiaozhi-server/test/js/app.js b/main/xiaozhi-server/test/js/app.js index 76819a6f..1b70080e 100644 --- a/main/xiaozhi-server/test/js/app.js +++ b/main/xiaozhi-server/test/js/app.js @@ -4,6 +4,7 @@ import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js'; import { uiController } from './ui/controller.js'; import { getAudioPlayer } from './core/audio/player.js'; import { initMcpTools } from './core/mcp/tools.js'; +import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js'; // 应用类 class App { @@ -34,6 +35,9 @@ class App { // 初始化MCP工具 initMcpTools(); + // 检查麦克风可用性 + await this.checkMicrophoneAvailability(); + // 初始化Live2D await this.initLive2D(); @@ -81,6 +85,36 @@ class App { modelLoading.style.display = isLoading ? 'flex' : 'none'; } } + + /** + * 检查麦克风可用性 + * 在应用初始化时调用,检查麦克风是否可用并更新UI状态 + */ + async checkMicrophoneAvailability() { + try { + const isAvailable = await checkMicrophoneAvailability(); + const isHttp = isHttpNonLocalhost(); + + // 保存可用性状态到全局变量 + window.microphoneAvailable = isAvailable; + window.isHttpNonLocalhost = isHttp; + + // 更新UI + if (this.uiController) { + this.uiController.updateMicrophoneAvailability(isAvailable, isHttp); + } + + log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning'); + } catch (error) { + log(`检查麦克风可用性失败: ${error.message}`, 'error'); + // 默认设置为不可用 + window.microphoneAvailable = false; + window.isHttpNonLocalhost = isHttpNonLocalhost(); + if (this.uiController) { + this.uiController.updateMicrophoneAvailability(false, window.isHttpNonLocalhost); + } + } + } } // 创建并启动应用 @@ -95,4 +129,4 @@ document.addEventListener('DOMContentLoaded', () => { }); -export default app; +export default app; \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/config/default-mcp-tools.json b/main/xiaozhi-server/test/js/config/default-mcp-tools.json index 2d44f2dc..f4620a1a 100644 --- a/main/xiaozhi-server/test/js/config/default-mcp-tools.json +++ b/main/xiaozhi-server/test/js/config/default-mcp-tools.json @@ -68,5 +68,37 @@ "brightness": "${brightness}", "message": "亮度已设置为 ${brightness}" } + }, + { + "name": "live2d.smile", + "description": "Make the virtual human perform a smiling action. Call this tool when the user says 'smile', 'smile once', 'give me a smile', 'smile please', etc.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "live2d.wave", + "description": "Make the virtual human perform a waving action. Call this tool when the user says 'wave to me', 'wave', 'say hello', 'wave please', etc.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "live2d.action", + "description": "Trigger a specified action for the virtual human. Supported actions include: FlickUp (swipe up), FlickDown (swipe down), Tap (tap), Tap@Body (body tap), Flick (swipe), Flick@Body (body swipe), etc. Call this tool when the user requests a specific virtual human action.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action name, such as: FlickUp, FlickDown, Tap, Tap@Body, Flick, Flick@Body" + } + }, + "required": [ + "action" + ] + } } ] \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.js b/main/xiaozhi-server/test/js/core/audio/recorder.js index 0250f94a..35cc7630 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.js @@ -1,9 +1,9 @@ -// 音频录制模块 +// Audio recording module import { log } from '../../utils/logger.js'; import { initOpusEncoder } from './opus-codec.js'; import { getAudioPlayer } from './player.js'; -// 音频录制器类 +// Audio recorder class export class AudioRecorder { constructor() { this.isRecording = false; @@ -20,24 +20,24 @@ export class AudioRecorder { this.recordingTimer = null; this.websocket = null; - // 回调函数 + // Callback functions this.onRecordingStart = null; this.onRecordingStop = null; this.onVisualizerUpdate = null; } - // 设置WebSocket实例 + // Set WebSocket instance setWebSocket(ws) { this.websocket = ws; } - // 获取AudioContext实例 + // Get AudioContext instance getAudioContext() { const audioPlayer = getAudioPlayer(); return audioPlayer.getAudioContext(); } - // 初始化编码器 + // Initialize encoder initEncoder() { if (!this.opusEncoder) { this.opusEncoder = initOpusEncoder(); @@ -45,7 +45,7 @@ export class AudioRecorder { return this.opusEncoder; } - // PCM处理器代码 + // PCM processor code getAudioProcessorCode() { return ` class AudioRecorderProcessor extends AudioWorkletProcessor { @@ -104,7 +104,7 @@ export class AudioRecorder { `; } - // 创建音频处理器 + // Create audio processor async createAudioProcessor() { this.audioContext = this.getAudioContext(); @@ -123,7 +123,7 @@ export class AudioRecorder { } }; - log('使用AudioWorklet处理音频', 'success'); + log('Using AudioWorklet to process audio', 'success'); const silent = this.audioContext.createGain(); silent.gain.value = 0; @@ -131,16 +131,16 @@ export class AudioRecorder { silent.connect(this.audioContext.destination); return { node: audioProcessor, type: 'worklet' }; } else { - log('AudioWorklet不可用,使用ScriptProcessorNode作为回退方案', 'warning'); + log('AudioWorklet not available, using ScriptProcessorNode as fallback', 'warning'); return this.createScriptProcessor(); } } catch (error) { - log(`创建音频处理器失败: ${error.message},尝试回退方案`, 'error'); + log(`Failed to create audio processor: ${error.message}, trying fallback`, 'error'); return this.createScriptProcessor(); } } - // 创建ScriptProcessor作为回退 + // Create ScriptProcessor as fallback createScriptProcessor() { try { const frameSize = 4096; @@ -164,15 +164,15 @@ export class AudioRecorder { scriptProcessor.connect(silent); silent.connect(this.audioContext.destination); - log('使用ScriptProcessorNode作为回退方案成功', 'warning'); + log('Using ScriptProcessorNode as fallback successfully', 'warning'); return { node: scriptProcessor, type: 'processor' }; } catch (fallbackError) { - log(`回退方案也失败: ${fallbackError.message}`, 'error'); + log(`Fallback also failed: ${fallbackError.message}`, 'error'); return null; } } - // 处理PCM缓冲数据 + // Process PCM buffer data processPCMBuffer(buffer) { if (!this.isRecording) return; @@ -191,10 +191,10 @@ export class AudioRecorder { } } - // 编码并发送Opus数据 + // Encode and send Opus data encodeAndSendOpus(pcmData = null) { if (!this.opusEncoder) { - log('Opus编码器未初始化', 'error'); + log('Opus encoder not initialized', 'error'); return; } @@ -209,13 +209,13 @@ export class AudioRecorder { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { try { this.websocket.send(opusData.buffer); - log(`发送Opus帧,大小:${opusData.length}字节`, 'debug'); + log(`Sent Opus frame, size: ${opusData.length} bytes`, 'debug'); } catch (error) { - log(`WebSocket发送错误: ${error.message}`, 'error'); + log(`WebSocket send error: ${error.message}`, 'error'); } } } else { - log('Opus编码失败,无有效数据返回', 'error'); + log('Opus encoding failed, no valid data returned', 'error'); } } else { if (this.pcmDataBuffer.length > 0) { @@ -231,20 +231,20 @@ export class AudioRecorder { } } } catch (error) { - log(`Opus编码错误: ${error.message}`, 'error'); + log(`Opus encoding error: ${error.message}`, 'error'); } } - // 开始录音 + // Start recording async start() { if (this.isRecording) return false; try { - // 检查是否有WebSocketHandler实例 + // Check if WebSocketHandler instance exists const { getWebSocketHandler } = await import('../network/websocket.js'); const wsHandler = getWebSocketHandler(); - // 如果机器正在说话,发送打断消息 + // If machine is speaking, send abort message if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) { const abortMessage = { session_id: wsHandler.currentSessionId, @@ -254,16 +254,16 @@ export class AudioRecorder { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(JSON.stringify(abortMessage)); - log('发送打断消息', 'info'); + log('Sent abort message', 'info'); } } if (!this.initEncoder()) { - log('无法启动录音: Opus编码器初始化失败', 'error'); + log('Unable to start recording: Opus encoder initialization failed', 'error'); return false; } - log('请至少录制1-2秒钟的音频,确保采集到足够数据', 'info'); + log('Please record at least 1-2 seconds of audio to ensure sufficient data is collected', 'info'); const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -282,7 +282,7 @@ export class AudioRecorder { const processorResult = await this.createAudioProcessor(); if (!processorResult) { - log('无法创建音频处理器', 'error'); + log('Unable to create audio processor', 'error'); return false; } @@ -305,26 +305,26 @@ export class AudioRecorder { this.audioProcessor.port.postMessage({ command: 'start' }); } - // 发送监听开始消息 + // Send listening start message if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { - log(`发送录音开始消息`, 'info'); + log(`Sent recording start message`, 'info'); } else { - log('WebSocket未连接,无法发送开始消息', 'error'); + log('WebSocket not connected, unable to send start message', 'error'); return false; } - // 开始可视化 + // Start visualization if (this.onVisualizerUpdate) { const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.startVisualization(dataArray); } - // 立即通知录音开始,更新按钮状态 + // Immediately notify recording start, update button state if (this.onRecordingStart) { this.onRecordingStart(0); } - // 启动录音计时器 + // Start recording timer let recordingSeconds = 0; this.recordingTimer = setInterval(() => { recordingSeconds += 0.1; @@ -333,16 +333,16 @@ export class AudioRecorder { } }, 100); - log('开始PCM直接录音', 'success'); + log('Started PCM direct recording', 'success'); return true; } catch (error) { - log(`直接录音启动错误: ${error.message}`, 'error'); + log(`Direct recording start error: ${error.message}`, 'error'); this.isRecording = false; return false; } } - // 开始可视化 + // Start visualization startVisualization(dataArray) { const draw = () => { this.visualizationRequest = requestAnimationFrame(() => draw()); @@ -358,7 +358,7 @@ export class AudioRecorder { draw(); } - // 停止录音 + // Stop recording stop() { if (!this.isRecording) return false; @@ -389,35 +389,35 @@ export class AudioRecorder { this.recordingTimer = null; } - // 编码并发送剩余的数据 + // Encode and send remaining data this.encodeAndSendOpus(); - // 发送结束信号 + // Send end signal if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { const emptyOpusFrame = new Uint8Array(0); this.websocket.send(emptyOpusFrame); - log('已发送录音停止信号', 'info'); + log('Sent recording stop signal', 'info'); } if (this.onRecordingStop) { this.onRecordingStop(); } - log('停止PCM直接录音', 'success'); + log('Stopped PCM direct recording', 'success'); return true; } catch (error) { - log(`直接录音停止错误: ${error.message}`, 'error'); + log(`Direct recording stop error: ${error.message}`, 'error'); return false; } } - // 获取分析器 + // Get analyser getAnalyser() { return this.analyser; } } -// 创建单例 +// Create singleton instance let audioRecorderInstance = null; export function getAudioRecorder() { @@ -426,3 +426,65 @@ export function getAudioRecorder() { } return audioRecorderInstance; } + +/** + * Check if microphone is available + * @returns {Promise} Returns true if available, false if not available + */ +export async function checkMicrophoneAvailability() { + // Check if browser supports getUserMedia API + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + log('Browser does not support getUserMedia API', 'warning'); + return false; + } + + try { + // Try to access microphone + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 16000, + channelCount: 1 + } + }); + + // Immediately stop all tracks to release microphone + stream.getTracks().forEach(track => track.stop()); + + log('Microphone availability check successful', 'success'); + return true; + } catch (error) { + log(`Microphone not available: ${error.message}`, 'warning'); + return false; + } +} + +/** + * Check if it is HTTP non-localhost access + * @returns {boolean} Returns true if it is HTTP non-localhost access + */ +export function isHttpNonLocalhost() { + const protocol = window.location.protocol; + const hostname = window.location.hostname; + + // Check if it is HTTP protocol + if (protocol !== 'http:') { + return false; + } + + // localhost and 127.0.0.1 can use microphone + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return false; + } + + // Private IP addresses can also use microphone (browser allows) + if (hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.')) { + return false; + } + + // Other HTTP access is considered non-localhost + return true; +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.test.js b/main/xiaozhi-server/test/js/core/audio/recorder.test.js new file mode 100644 index 00000000..4f7ff9cb --- /dev/null +++ b/main/xiaozhi-server/test/js/core/audio/recorder.test.js @@ -0,0 +1,56 @@ +/** + * Audio recording module tests + * Test microphone availability detection functionality + */ + +// Note: These are unit test examples showing how to test new features +// In actual projects, you can use Jest, Mocha or other testing frameworks + +describe('Microphone Availability Detection', () => { + /** + * Test checkMicrophoneAvailability function + * Note: Actual tests need to mock navigator.mediaDevices + */ + test('should detect microphone availability', async () => { + // Mock navigator.mediaDevices.getUserMedia + const mockStream = { + getTracks: () => [{ stop: jest.fn() }] + }; + + global.navigator = { + mediaDevices: { + getUserMedia: jest.fn().mockResolvedValue(mockStream) + } + }; + + // Import function (needs to be adjusted according to actual module system) + // const { checkMicrophoneAvailability } = await import('./recorder.js'); + // const result = await checkMicrophoneAvailability(); + // expect(result).toBe(true); + }); + + /** + * Test isHttpNonLocalhost function + */ + test('should detect HTTP non-localhost correctly', () => { + // Mock window.location + const originalLocation = window.location; + + // Test HTTP non-localhost + delete window.location; + window.location = { + protocol: 'http:', + hostname: '192.168.1.100' + }; + + // const { isHttpNonLocalhost } = require('./recorder.js'); + // expect(isHttpNonLocalhost()).toBe(true); + + // Test localhost (should return false) + window.location.hostname = 'localhost'; + // expect(isHttpNonLocalhost()).toBe(false); + + // Restore original location + window.location = originalLocation; + }); +}); \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/core/mcp/tools.js b/main/xiaozhi-server/test/js/core/mcp/tools.js index 7a1198c9..6505fda3 100644 --- a/main/xiaozhi-server/test/js/core/mcp/tools.js +++ b/main/xiaozhi-server/test/js/core/mcp/tools.js @@ -216,7 +216,7 @@ function setupMcpEventListeners() { toggleBtn.addEventListener('click', () => { const isExpanded = panel.classList.contains('expanded'); panel.classList.toggle('expanded'); - toggleBtn.textContent = isExpanded ? '展开' : '收起'; + toggleBtn.textContent = isExpanded ? '收起' : '展开'; }); // 确保面板默认展开 @@ -424,6 +424,74 @@ export function getMcpTools() { })); } +/** + * 执行Live2D动作 + * @param {string} toolName - 工具名称,如 'live2d.smile', 'live2d.wave', 'live2d.action' + * @param {Object} toolArgs - 工具参数,对于 'live2d.action' 工具,应包含 'action' 字段 + * @returns {Object} 执行结果,包含 success, message, action, tool 等字段 + */ +function executeLive2DAction(toolName, toolArgs) { + try { + const live2dManager = window.chatApp?.live2dManager; + if (!live2dManager) { + log('Live2D管理器未初始化', 'error'); + return { + success: false, + error: 'Live2D管理器未初始化' + }; + } + + // 动作映射:工具名称到Live2D动作名称 + const actionMap = { + 'live2d.smile': 'FlickUp', + 'live2d.wave': 'Tap', + 'live2d.happy': 'FlickUp', + 'live2d.sad': 'FlickDown', + 'live2d.tap': 'Tap', + 'live2d.tapBody': 'Tap@Body', + 'live2d.flick': 'Flick', + 'live2d.flickBody': 'Flick@Body', + 'live2d.flickUp': 'FlickUp', + 'live2d.flickDown': 'FlickDown' + }; + + let motionName; + if (toolName === 'live2d.action' && toolArgs?.action) { + // 通用动作工具,使用指定的动作名称 + motionName = toolArgs.action; + } else { + // 使用映射表查找对应动作名称 + motionName = actionMap[toolName]; + } + + if (!motionName) { + log(`未知的动作: ${toolName}`, 'error'); + return { + success: false, + error: `未知的动作: ${toolName}` + }; + } + + // 触发Live2D动作 + live2dManager.motion(motionName); + + log(`Live2D动作已触发: ${motionName}`, 'success'); + + return { + success: true, + message: `虚拟人已执行动作: ${motionName}`, + action: motionName, + tool: toolName + }; + } catch (error) { + log(`执行Live2D动作失败: ${error.message}`, 'error'); + return { + success: false, + error: `执行动作失败: ${error.message}` + }; + } +} + /** * 执行工具调用 */ @@ -438,6 +506,11 @@ export function executeMcpTool(toolName, toolArgs) { }; } + // 处理Live2D动作工具 + if (toolName.startsWith('live2d.')) { + return executeLive2DAction(toolName, toolArgs); + } + // 如果有模拟返回结果,使用它 if (tool.mockResponse) { // 替换模板变量 @@ -477,4 +550,4 @@ window.mcpModule = { deleteMcpProperty, editMcpTool, deleteMcpTool -}; +}; \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/core/mcp/tools.test.js b/main/xiaozhi-server/test/js/core/mcp/tools.test.js new file mode 100644 index 00000000..3927b8be --- /dev/null +++ b/main/xiaozhi-server/test/js/core/mcp/tools.test.js @@ -0,0 +1,83 @@ +/** + * MCP工具模块测试 + * 测试Live2D动作工具执行功能 + */ + +describe('Live2D Action Tools', () => { + /** + * 测试 executeLive2DAction 函数 + * 注意:需要 mock window.chatApp.live2dManager + */ + test('should execute Live2D smile action', () => { + // Mock Live2D manager + const mockLive2DManager = { + motion: jest.fn() + }; + + window.chatApp = { + live2dManager: mockLive2DManager + }; + + // 测试 smile 动作 + // const result = executeLive2DAction('live2d.smile', {}); + // expect(result.success).toBe(true); + // expect(result.action).toBe('FlickUp'); + // expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp'); + }); + + test('should execute Live2D wave action', () => { + // Mock Live2D manager + const mockLive2DManager = { + motion: jest.fn() + }; + + window.chatApp = { + live2dManager: mockLive2DManager + }; + + // 测试 wave 动作 + // const result = executeLive2DAction('live2d.wave', {}); + // expect(result.success).toBe(true); + // expect(result.action).toBe('Tap'); + // expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap'); + }); + + test('should handle generic action tool', () => { + // Mock Live2D manager + const mockLive2DManager = { + motion: jest.fn() + }; + + window.chatApp = { + live2dManager: mockLive2DManager + }; + + // 测试通用动作工具 + // const result = executeLive2DAction('live2d.action', { action: 'FlickDown' }); + // expect(result.success).toBe(true); + // expect(result.action).toBe('FlickDown'); + // expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown'); + }); + + test('should handle missing Live2D manager gracefully', () => { + window.chatApp = null; + + // const result = executeLive2DAction('live2d.smile', {}); + // expect(result.success).toBe(false); + // expect(result.error).toContain('Live2D管理器未初始化'); + }); + + test('should handle unknown action gracefully', () => { + const mockLive2DManager = { + motion: jest.fn() + }; + + window.chatApp = { + live2dManager: mockLive2DManager + }; + + // const result = executeLive2DAction('live2d.unknown', {}); + // expect(result.success).toBe(false); + // expect(result.error).toContain('未知的动作'); + }); +}); \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/ui/controller.js b/main/xiaozhi-server/test/js/ui/controller.js index 2b43a378..409b90dc 100644 --- a/main/xiaozhi-server/test/js/ui/controller.js +++ b/main/xiaozhi-server/test/js/ui/controller.js @@ -1,10 +1,10 @@ -// UI控制模块 +// UI controller module import { loadConfig, saveConfig } from '../config/manager.js'; import { getAudioRecorder } from '../core/audio/recorder.js'; import { getWebSocketHandler } from '../core/network/websocket.js'; import { getAudioPlayer } from '../core/audio/player.js'; -// UI控制器类 +// UI controller class class UIController { constructor() { this.isEditing = false; @@ -14,7 +14,7 @@ class UIController { this.currentBackgroundIndex = 0; this.backgroundImages = ['1.png', '2.png', '3.png']; - // 绑定方法 + // Bind methods this.init = this.init.bind(this); this.initEventListeners = this.initEventListeners.bind(this); this.updateDialButton = this.updateDialButton.bind(this); @@ -25,7 +25,7 @@ class UIController { this.switchTab = this.switchTab.bind(this); } - // 初始化 + // Initialize init() { console.log('UIController init started'); @@ -35,7 +35,7 @@ class UIController { this.initVisualizer(); } - // 检查连接按钮在初始化时是否存在 + // Check if connect button exists during initialization const connectBtn = document.getElementById('connectBtn'); console.log('connectBtn during init:', connectBtn); @@ -43,20 +43,20 @@ class UIController { this.startAudioStatsMonitor(); loadConfig(); - // 设置录音器回调 + // Register recording callback const audioRecorder = getAudioRecorder(); audioRecorder.onRecordingStart = (seconds) => { this.updateRecordButtonState(true, seconds); }; - // 初始化状态显示 + // Initialize status display this.updateConnectionUI(false); this.updateDialButton(false); console.log('UIController init completed'); } - // 初始化可视化器 + // Initialize visualizer initVisualizer() { if (this.visualizerCanvas) { this.visualizerCanvas.width = this.visualizerCanvas.clientWidth; @@ -66,9 +66,9 @@ class UIController { } } - // 初始化事件监听器 + // Initialize event listeners initEventListeners() { - // 设置按钮 + // Settings button const settingsBtn = document.getElementById('settingsBtn'); if (settingsBtn) { settingsBtn.addEventListener('click', () => { @@ -76,13 +76,13 @@ class UIController { }); } - // 背景切换按钮 + // Background switch button const backgroundBtn = document.getElementById('backgroundBtn'); if (backgroundBtn) { backgroundBtn.addEventListener('click', this.switchBackground); } - // 拨号按钮 + // Dial button const dialBtn = document.getElementById('dialBtn'); if (dialBtn) { dialBtn.addEventListener('click', () => { @@ -92,40 +92,40 @@ class UIController { if (isConnected) { wsHandler.disconnect(); this.updateDialButton(false); - this.addChatMessage('已断开连接,期待下次再见~😉', false); + this.addChatMessage('Disconnected, see you next time~😊', false); } else { - // 检查OTA地址是否已填写 + // Check if OTA URL is filled const otaUrlInput = document.getElementById('otaUrl'); if (!otaUrlInput || !otaUrlInput.value.trim()) { - // 如果OTA地址未填写,显示设置弹窗并切换到设备配置页 + // If OTA URL is not filled, show settings modal and switch to device tab this.showModal('settingsModal'); this.switchTab('device'); - this.addChatMessage('请先填写OTA服务器地址', false); + this.addChatMessage('Please fill in OTA server URL', false); return; } - // 执行连接操作 + // Start connection process this.handleConnect(); } }); } - // 录音按钮 + // Record button const recordBtn = document.getElementById('recordBtn'); if (recordBtn) { recordBtn.addEventListener('click', () => { const audioRecorder = getAudioRecorder(); if (audioRecorder.isRecording) { audioRecorder.stop(); - // 停止录音时移除录音样式 + // Restore record button to normal state recordBtn.classList.remove('recording'); recordBtn.querySelector('.btn-text').textContent = '录音'; } else { - // 先更新按钮状态为录音中 + // Update button state to recording recordBtn.classList.add('recording'); recordBtn.querySelector('.btn-text').textContent = '录音中'; - // 延迟开始录音,确保按钮状态已更新 + // Start recording, update button state after delay setTimeout(() => { audioRecorder.start(); }, 100); @@ -133,7 +133,7 @@ class UIController { }); } - // 消息输入框事件 + // Chat input event listener const chatIpt = document.getElementById('chatIpt'); if (chatIpt) { const wsHandler = getWebSocketHandler(); @@ -148,7 +148,7 @@ class UIController { }); } - // 关闭按钮 + // Close button const closeButtons = document.querySelectorAll('.close-btn'); closeButtons.forEach(btn => { btn.addEventListener('click', (e) => { @@ -163,7 +163,7 @@ class UIController { }); }); - // 设置标签页切换 + // Settings tab switch const tabBtns = document.querySelectorAll('.tab-btn'); tabBtns.forEach(btn => { btn.addEventListener('click', (e) => { @@ -171,7 +171,7 @@ class UIController { }); }); - // 点击模态框外部关闭 + // Click modal background to close const modals = document.querySelectorAll('.modal'); modals.forEach(modal => { modal.addEventListener('click', (e) => { @@ -184,7 +184,7 @@ class UIController { }); }); - // 添加MCP工具按钮 + // Add MCP tool button const addMCPToolBtn = document.getElementById('addMCPToolBtn'); if (addMCPToolBtn) { addMCPToolBtn.addEventListener('click', (e) => { @@ -193,10 +193,10 @@ class UIController { }); } - // 连接按钮和取消按钮已被移除,功能已集成到拨号按钮中 + // Connect button and send button are not removed, can be added to dial button later } - // 更新连接状态UI + // Update connection status UI updateConnectionUI(isConnected) { const connectionStatus = document.getElementById('connectionStatus'); const statusDot = document.querySelector('.status-dot'); @@ -216,7 +216,7 @@ class UIController { } } - // 更新拨号按钮状态 + // Update dial button state updateDialButton(isConnected) { const dialBtn = document.getElementById('dialBtn'); const recordBtn = document.getElementById('recordBtn'); @@ -225,39 +225,47 @@ class UIController { if (isConnected) { dialBtn.classList.add('dial-active'); dialBtn.querySelector('.btn-text').textContent = '挂断'; - // 更新拨号按钮图标为挂断图标 + // Update dial button icon to hang up icon dialBtn.querySelector('svg').innerHTML = ` `; } else { dialBtn.classList.remove('dial-active'); dialBtn.querySelector('.btn-text').textContent = '拨号'; - // 恢复拨号按钮图标 + // Restore dial button icon dialBtn.querySelector('svg').innerHTML = ` `; } } - // 更新录音按钮状态 + // Update record button state if (recordBtn) { - if (isConnected) { + const microphoneAvailable = window.microphoneAvailable !== false; + + if (isConnected && microphoneAvailable) { recordBtn.disabled = false; recordBtn.title = '开始录音'; - // 确保录音按钮恢复到正常状态 + // Restore record button to normal state recordBtn.querySelector('.btn-text').textContent = '录音'; recordBtn.classList.remove('recording'); } else { recordBtn.disabled = true; - recordBtn.title = '请先连接服务器'; - // 确保录音按钮恢复到正常状态 + if (!microphoneAvailable) { + recordBtn.title = window.isHttpNonLocalhost + ? '当前由于是http访问,无法录音,只能用文字交互' + : '麦克风不可用'; + } else { + recordBtn.title = '请先连接服务器'; + } + // Restore record button to normal state recordBtn.querySelector('.btn-text').textContent = '录音'; recordBtn.classList.remove('recording'); } } } - // 更新录音按钮状态 + // Update record button state updateRecordButtonState(isRecording, seconds = 0) { const recordBtn = document.getElementById('recordBtn'); if (recordBtn) { @@ -268,11 +276,49 @@ class UIController { recordBtn.querySelector('.btn-text').textContent = '录音'; recordBtn.classList.remove('recording'); } - recordBtn.disabled = false; + // Only enable button when microphone is available + const microphoneAvailable = window.microphoneAvailable !== false; + recordBtn.disabled = !microphoneAvailable; } } - // 添加聊天消息 + /** + * Update microphone availability state + * @param {boolean} isAvailable - Whether microphone is available + * @param {boolean} isHttpNonLocalhost - Whether it is HTTP non-localhost access + */ + updateMicrophoneAvailability(isAvailable, isHttpNonLocalhost) { + const recordBtn = document.getElementById('recordBtn'); + + if (!recordBtn) return; + + if (!isAvailable) { + // Disable record button + recordBtn.disabled = true; + + // Update button text and title + recordBtn.querySelector('.btn-text').textContent = '录音'; + recordBtn.title = isHttpNonLocalhost + ? '当前由于是http访问,无法录音,只能用文字交互' + : '麦克风不可用'; + + // Display notification message in chat + if (isHttpNonLocalhost) { + this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false); + } else { + this.addChatMessage('⚠️ 麦克风不可用,请检查权限设置', false); + } + } else { + // If connected, enable record button + const wsHandler = getWebSocketHandler(); + if (wsHandler && wsHandler.isConnected()) { + recordBtn.disabled = false; + recordBtn.title = '开始录音'; + } + } + } + + // Add chat message addChatMessage(content, isUser = false) { const chatStream = document.getElementById('chatStream'); if (!chatStream) return; @@ -282,11 +328,11 @@ class UIController { messageDiv.innerHTML = `
${content}
`; chatStream.appendChild(messageDiv); - // 自动滚动到底部 + // Scroll to bottom chatStream.scrollTop = chatStream.scrollHeight; } - // 切换背景 + // Switch background switchBackground() { this.currentBackgroundIndex = (this.currentBackgroundIndex + 1) % this.backgroundImages.length; const backgroundContainer = document.querySelector('.background-container'); @@ -295,7 +341,7 @@ class UIController { } } - // 显示模态框 + // Show modal showModal(modalId) { const modal = document.getElementById(modalId); if (modal) { @@ -303,7 +349,7 @@ class UIController { } } - // 隐藏模态框 + // Hide modal hideModal(modalId) { const modal = document.getElementById(modalId); if (modal) { @@ -311,16 +357,16 @@ class UIController { } } - // 切换标签页 + // Switch tab switchTab(tabName) { - // 移除所有标签页的active类 + // Remove active class from all tabs const tabBtns = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); tabBtns.forEach(btn => btn.classList.remove('active')); tabContents.forEach(content => content.classList.remove('active')); - // 激活选中的标签页 + // Activate selected tab const activeTabBtn = document.querySelector(`[data-tab="${tabName}"]`); const activeTabContent = document.getElementById(`${tabName}Tab`); @@ -330,24 +376,24 @@ class UIController { } } - // 连接成功后开始对话 + // Start AI chat session after connection startAIChatSession() { - this.addChatMessage('连接成功,开始聊天吧~🙂', false); - // 开启录音 + this.addChatMessage('连接成功,开始聊天吧~😊', false); + // Start recording const recordBtn = document.getElementById('recordBtn'); if (recordBtn) { recordBtn.click(); } } - // 处理连接按钮点击 + // Handle connect button click async handleConnect() { console.log('handleConnect called'); - // 确保切换到设备配置标签页 + // Switch to device settings tab this.switchTab('device'); - // 等待DOM更新 + // Wait for DOM update await new Promise(resolve => setTimeout(resolve, 50)); const otaUrlInput = document.getElementById('otaUrl'); @@ -362,7 +408,7 @@ class UIController { const otaUrl = otaUrlInput.value; console.log('otaUrl value:', otaUrl); - // 更新拨号按钮状态为连接中 + // Update dial button state to connecting const dialBtn = document.getElementById('dialBtn'); if (dialBtn) { dialBtn.classList.add('dial-active'); @@ -370,7 +416,7 @@ class UIController { dialBtn.disabled = true; } - // 显示连接中消息 + // Show connecting message this.addChatMessage('正在连接服务器...', false); const chatIpt = document.getElementById('chatIpt'); @@ -380,24 +426,36 @@ class UIController { try { - // 获取WebSocket处理器 + // Get WebSocket handler instance const wsHandler = getWebSocketHandler(); const isConnected = await wsHandler.connect(); if (isConnected) { + // Check microphone availability (check again after connection) + const { checkMicrophoneAvailability } = await import('../core/audio/recorder.js'); + const micAvailable = await checkMicrophoneAvailability(); + + if (!micAvailable) { + const isHttp = window.isHttpNonLocalhost; + if (isHttp) { + this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false); + } + // Update global state + window.microphoneAvailable = false; + } - // 设置连接状态回调 + // Register connection state callback wsHandler.onConnectionStateChange = (isConnected) => { this.updateConnectionUI(isConnected); this.updateDialButton(isConnected); }; - // 设置聊天消息回调 + // Register chat message callback wsHandler.onChatMessage = (text, isUser) => { this.addChatMessage(text, isUser); }; - // 设置录音按钮状态回调 + // Register record button state callback wsHandler.onRecordButtonStateChange = (isRecording) => { const recordBtn = document.getElementById('recordBtn'); if (recordBtn) { @@ -411,10 +469,10 @@ class UIController { } }; - // 连接成功 + // Connection successful this.addChatMessage('OTA连接成功,正在建立WebSocket连接...', false); - // 更新拨号按钮状态 + // Update dial button state const dialBtn = document.getElementById('dialBtn'); if (dialBtn) { dialBtn.disabled = false; @@ -434,14 +492,14 @@ class UIController { name: error.name }); - // 显示错误消息 + // Show error message const errorMessage = error.message.includes('Cannot set properties of null') - ? '连接失败:请刷新页面重试' + ? '连接失败:请检查设备连接' : `连接失败: ${error.message}`; this.addChatMessage(errorMessage, false); - // 恢复拨号按钮状态 + // Restore dial button state const dialBtn = document.getElementById('dialBtn'); if (dialBtn) { dialBtn.disabled = false; @@ -452,7 +510,7 @@ class UIController { } } - // 添加MCP工具 + // Add MCP tool addMCPTool() { const mcpToolsList = document.getElementById('mcpToolsList'); if (!mcpToolsList) return; @@ -462,16 +520,16 @@ class UIController { toolDiv.className = 'properties-container'; toolDiv.innerHTML = `
- - - + + +
`; mcpToolsList.appendChild(toolDiv); } - // 移除MCP工具 + // Remove MCP tool removeMCPTool(toolId) { const toolElement = document.getElementById(toolId); if (toolElement) { @@ -479,24 +537,24 @@ class UIController { } } - // 更新音频统计信息 + // Update audio statistics display updateAudioStats() { const audioPlayer = getAudioPlayer(); if (!audioPlayer) return; const stats = audioPlayer.getAudioStats(); - // 这里可以添加音频统计的UI更新逻辑 + // Here can add audio statistics UI update logic } - // 启动音频统计监控 + // Start audio statistics monitor startAudioStatsMonitor() { - // 每100ms更新一次音频统计 + // Update audio statistics every 100ms this.audioStatsTimer = setInterval(() => { this.updateAudioStats(); }, 100); } - // 停止音频统计监控 + // Stop audio statistics monitor stopAudioStatsMonitor() { if (this.audioStatsTimer) { clearInterval(this.audioStatsTimer); @@ -504,7 +562,7 @@ class UIController { } } - // 绘制音频可视化效果 + // Draw audio visualizer waveform drawVisualizer(dataArray) { if (!this.visualizerContext || !this.visualizerCanvas) return; @@ -518,7 +576,7 @@ class UIController { for (let i = 0; i < dataArray.length; i++) { barHeight = dataArray[i] / 2; - // 创建渐变色:从紫色到蓝色到青色 + // Create gradient color: from purple to blue to green const gradient = this.visualizerContext.createLinearGradient(0, 0, 0, this.visualizerCanvas.height); gradient.addColorStop(0, '#8e44ad'); gradient.addColorStop(0.5, '#3498db'); @@ -530,21 +588,21 @@ class UIController { } } - // 更新会话状态UI + // Update session status UI updateSessionStatus(isSpeaking) { - // 这里可以添加会话状态的UI更新逻辑 - // 例如:更新Live2D角色的表情或状态指示器 + // Here can add session status UI update logic + // For example: update Live2D model's mouth movement status } - // 更新会话表情 + // Update session emotion updateSessionEmotion(emoji) { - // 这里可以添加表情更新的逻辑 - // 例如:在状态指示器中显示表情 + // Here can add emotion update logic + // For example: display emoji in status indicator } } -// 创建全局实例 +// Create singleton instance export const uiController = new UIController(); -// 导出类供其他模块使用 +// Export class for module usage export { UIController }; \ No newline at end of file