From 3fc33e909513159fda5f7faccd22b425b2432602 Mon Sep 17 00:00:00 2001 From: spider-yamet Date: Mon, 26 Jan 2026 14:48:08 -0800 Subject: [PATCH] Optimize code density: simplify changed files while keeping comments (reduce 364 lines) --- main/xiaozhi-server/test/js/app.js | 22 +- .../test/js/core/audio/recorder.js | 110 +-------- .../js/core/audio/recorder.test.browser.js | 65 +++++- .../test/js/core/audio/recorder.test.js | 81 +------ main/xiaozhi-server/test/js/core/mcp/tools.js | 217 ++---------------- .../test/js/core/mcp/tools.test.browser.js | 40 +++- .../test/js/core/mcp/tools.test.js | 109 +-------- main/xiaozhi-server/test/js/ui/controller.js | 16 +- 8 files changed, 148 insertions(+), 512 deletions(-) diff --git a/main/xiaozhi-server/test/js/app.js b/main/xiaozhi-server/test/js/app.js index 1b70080e..9f71b39a 100644 --- a/main/xiaozhi-server/test/js/app.js +++ b/main/xiaozhi-server/test/js/app.js @@ -17,33 +17,24 @@ class App { // 初始化应用 async init() { log('正在初始化应用...', 'info'); - // 初始化UI控制器 this.uiController = uiController; this.uiController.init(); - // 检查Opus库 checkOpusLoaded(); - // 初始化Opus编码器 initOpusEncoder(); - // 初始化音频播放器 this.audioPlayer = getAudioPlayer(); await this.audioPlayer.start(); - // 初始化MCP工具 initMcpTools(); - // 检查麦克风可用性 await this.checkMicrophoneAvailability(); - // 初始化Live2D await this.initLive2D(); - // 关闭加载loading this.setModelLoadingStatus(false); - log('应用初始化完成', 'success'); } @@ -54,21 +45,17 @@ class App { if (typeof window.Live2DManager === 'undefined') { throw new Error('Live2DManager未加载,请检查脚本引入顺序'); } - this.live2dManager = new window.Live2DManager(); await this.live2dManager.initializeLive2D(); - // 更新UI状态 const live2dStatus = document.getElementById('live2dStatus'); if (live2dStatus) { live2dStatus.textContent = '● 已加载'; live2dStatus.className = 'status loaded'; } - log('Live2D初始化完成', 'success'); } catch (error) { log(`Live2D初始化失败: ${error.message}`, 'error'); - // 更新UI状态 const live2dStatus = document.getElementById('live2dStatus'); if (live2dStatus) { @@ -94,16 +81,13 @@ class App { 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'); @@ -119,14 +103,10 @@ class App { // 创建并启动应用 const app = new App(); - // 将应用实例暴露到全局,供其他模块访问 window.chatApp = app; - document.addEventListener('DOMContentLoaded', () => { // 初始化应用 app.init(); }); - - -export default app; \ No newline at end of file +export default app; diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.js b/main/xiaozhi-server/test/js/core/audio/recorder.js index 35cc7630..b4173807 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.js @@ -19,7 +19,6 @@ export class AudioRecorder { this.visualizationRequest = null; this.recordingTimer = null; this.websocket = null; - // Callback functions this.onRecordingStart = null; this.onRecordingStop = null; @@ -33,8 +32,7 @@ export class AudioRecorder { // Get AudioContext instance getAudioContext() { - const audioPlayer = getAudioPlayer(); - return audioPlayer.getAudioContext(); + return getAudioPlayer().getAudioContext(); } // Initialize encoder @@ -56,50 +54,35 @@ export class AudioRecorder { this.buffer = new Int16Array(this.frameSize); this.bufferIndex = 0; this.isRecording = false; - this.port.onmessage = (event) => { if (event.data.command === 'start') { this.isRecording = true; this.port.postMessage({ type: 'status', status: 'started' }); } else if (event.data.command === 'stop') { this.isRecording = false; - if (this.bufferIndex > 0) { const finalBuffer = this.buffer.slice(0, this.bufferIndex); - this.port.postMessage({ - type: 'buffer', - buffer: finalBuffer - }); + this.port.postMessage({ type: 'buffer', buffer: finalBuffer }); this.bufferIndex = 0; } - this.port.postMessage({ type: 'status', status: 'stopped' }); } }; } - process(inputs, outputs, parameters) { if (!this.isRecording) return true; - const input = inputs[0][0]; if (!input) return true; - for (let i = 0; i < input.length; i++) { if (this.bufferIndex >= this.frameSize) { - this.port.postMessage({ - type: 'buffer', - buffer: this.buffer.slice(0) - }); + this.port.postMessage({ type: 'buffer', buffer: this.buffer.slice(0) }); this.bufferIndex = 0; } - this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } - return true; } } - registerProcessor('audio-recorder-processor', AudioRecorderProcessor); `; } @@ -107,24 +90,19 @@ export class AudioRecorder { // Create audio processor async createAudioProcessor() { this.audioContext = this.getAudioContext(); - try { if (this.audioContext.audioWorklet) { const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' }); const url = URL.createObjectURL(blob); await this.audioContext.audioWorklet.addModule(url); URL.revokeObjectURL(url); - const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor'); - audioProcessor.port.onmessage = (event) => { if (event.data.type === 'buffer') { this.processPCMBuffer(event.data.buffer); } }; - log('Using AudioWorklet to process audio', 'success'); - const silent = this.audioContext.createGain(); silent.gain.value = 0; audioProcessor.connect(silent); @@ -145,25 +123,19 @@ export class AudioRecorder { try { const frameSize = 4096; const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1); - scriptProcessor.onaudioprocess = (event) => { if (!this.isRecording) return; - const input = event.inputBuffer.getChannelData(0); const buffer = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } - this.processPCMBuffer(buffer); }; - const silent = this.audioContext.createGain(); silent.gain.value = 0; scriptProcessor.connect(silent); silent.connect(this.audioContext.destination); - log('Using ScriptProcessorNode as fallback successfully', 'warning'); return { node: scriptProcessor, type: 'processor' }; } catch (fallbackError) { @@ -175,18 +147,14 @@ export class AudioRecorder { // Process PCM buffer data processPCMBuffer(buffer) { if (!this.isRecording) return; - const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length); newBuffer.set(this.pcmDataBuffer); newBuffer.set(buffer, this.pcmDataBuffer.length); this.pcmDataBuffer = newBuffer; - const samplesPerFrame = 960; - while (this.pcmDataBuffer.length >= samplesPerFrame) { const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame); this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame); - this.encodeAndSendOpus(frameData); } } @@ -197,15 +165,12 @@ export class AudioRecorder { log('Opus encoder not initialized', 'error'); return; } - try { if (pcmData) { const opusData = this.opusEncoder.encode(pcmData); - if (opusData && opusData.length > 0) { this.audioBuffers.push(opusData.buffer); this.totalAudioSize += opusData.length; - if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { try { this.websocket.send(opusData.buffer); @@ -238,73 +203,47 @@ export class AudioRecorder { // Start recording async start() { if (this.isRecording) return false; - try { // 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, - type: 'abort', - reason: 'wake_word_detected' - }; - + const abortMessage = { session_id: wsHandler.currentSessionId, type: 'abort', reason: 'wake_word_detected' }; if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(JSON.stringify(abortMessage)); log('Sent abort message', 'info'); } } - if (!this.initEncoder()) { log('Unable to start recording: Opus encoder initialization failed', 'error'); return false; } - log('Please record at least 1-2 seconds of audio to ensure sufficient data is collected', 'info'); - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - sampleRate: 16000, - channelCount: 1 - } - }); - + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); this.audioContext = this.getAudioContext(); - if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } - const processorResult = await this.createAudioProcessor(); if (!processorResult) { log('Unable to create audio processor', 'error'); return false; } - this.audioProcessor = processorResult.node; this.audioProcessorType = processorResult.type; - this.audioSource = this.audioContext.createMediaStreamSource(stream); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 2048; - this.audioSource.connect(this.analyser); this.audioSource.connect(this.audioProcessor); - this.pcmDataBuffer = new Int16Array(); this.audioBuffers = []; this.totalAudioSize = 0; this.isRecording = true; - if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { this.audioProcessor.port.postMessage({ command: 'start' }); } - // Send listening start message if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { log(`Sent recording start message`, 'info'); @@ -312,18 +251,15 @@ export class AudioRecorder { 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(() => { @@ -332,7 +268,6 @@ export class AudioRecorder { this.onRecordingStart(recordingSeconds); } }, 100); - log('Started PCM direct recording', 'success'); return true; } catch (error) { @@ -346,11 +281,8 @@ export class AudioRecorder { startVisualization(dataArray) { const draw = () => { this.visualizationRequest = requestAnimationFrame(() => draw()); - if (!this.isRecording) return; - this.analyser.getByteFrequencyData(dataArray); - if (this.onVisualizerUpdate) { this.onVisualizerUpdate(dataArray); } @@ -361,48 +293,38 @@ export class AudioRecorder { // Stop recording stop() { if (!this.isRecording) return false; - try { this.isRecording = false; - if (this.audioProcessor) { if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { this.audioProcessor.port.postMessage({ command: 'stop' }); } - this.audioProcessor.disconnect(); this.audioProcessor = null; } - if (this.audioSource) { this.audioSource.disconnect(); this.audioSource = null; } - if (this.visualizationRequest) { cancelAnimationFrame(this.visualizationRequest); this.visualizationRequest = null; } - if (this.recordingTimer) { clearInterval(this.recordingTimer); 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('Sent recording stop signal', 'info'); } - if (this.onRecordingStop) { this.onRecordingStop(); } - log('Stopped PCM direct recording', 'success'); return true; } catch (error) { @@ -437,21 +359,11 @@ export async function checkMicrophoneAvailability() { 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 - } - }); - + 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) { @@ -467,24 +379,18 @@ export async function checkMicrophoneAvailability() { 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.')) { + 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.browser.js b/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js index 6d88b5f1..38f5e46e 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js @@ -1,9 +1,23 @@ +/** + * Audio recording module tests - Browser compatible version + * Test microphone availability detection functionality + * + * This version works without Vitest - uses the simple test framework from test-runner.html + */ + import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js'; describe('Microphone Availability Detection', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + }); + /** + * Test checkMicrophoneAvailability function - success case + */ test('should return true when microphone is available', async () => { + // Mock navigator.mediaDevices.getUserMedia to return a successful stream const mockTrack = { stop: vi.fn() }; const mockStream = { getTracks: () => [mockTrack] }; navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); @@ -13,60 +27,105 @@ describe('Microphone Availability Detection', () => { expect(mockTrack.stop).toHaveBeenCalled(); }); + /** + * Test checkMicrophoneAvailability function - failure case + */ test('should return false when microphone is not available', async () => { + // Mock getUserMedia to throw an error navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied')); const result = await checkMicrophoneAvailability(); expect(result).toBe(false); expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); }); + /** + * Test checkMicrophoneAvailability function - browser not supported + */ test('should return false when browser does not support getUserMedia', async () => { + // Mock navigator.mediaDevices.getUserMedia to be undefined const originalGetUserMedia = navigator.mediaDevices.getUserMedia; navigator.mediaDevices.getUserMedia = undefined; const result = await checkMicrophoneAvailability(); expect(result).toBe(false); + // Restore navigator.mediaDevices.getUserMedia = originalGetUserMedia; }); + /** + * Test isHttpNonLocalhost function - HTTP non-localhost + * Note: window.location properties are read-only in browsers, so we test the logic indirectly + */ test('should return true for HTTP non-localhost access', () => { - expect(typeof isHttpNonLocalhost()).toBe('boolean'); + // Since window.location is read-only, we'll test by checking the actual implementation + // This test verifies the function works correctly with the current location + // In a real browser environment, this would test against actual location + const result = isHttpNonLocalhost(); + // Just verify the function runs without error + expect(typeof result).toBe('boolean'); }); + /** + * Test isHttpNonLocalhost function - localhost should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for localhost', () => { + // Test the logic by checking if current location is localhost const result = isHttpNonLocalhost(); + // If we're on localhost, result should be false if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - 127.0.0.1 should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for 127.0.0.1', () => { + // Test the logic by checking if current location is 127.0.0.1 const result = isHttpNonLocalhost(); + // If we're on 127.0.0.1, result should be false if (window.location.hostname === '127.0.0.1') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - private IP should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for private IP addresses', () => { + // Test the logic by checking if current location is a private IP const result = isHttpNonLocalhost(); const hostname = window.location.hostname; const isPrivateIP = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.'); if (isPrivateIP && window.location.protocol === 'http:') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - HTTPS should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for HTTPS protocol', () => { + // Test the logic by checking if current protocol is HTTPS const result = isHttpNonLocalhost(); + // If we're on HTTPS, result should be false if (window.location.protocol === 'https:') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); -}); \ 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 index 8005f12d..c160e5f2 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.test.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.test.js @@ -17,26 +17,12 @@ describe('Microphone Availability Detection', () => { */ test('should return true when microphone is available', async () => { // Mock navigator.mediaDevices.getUserMedia to return a successful stream - const mockTrack = { - stop: vi.fn() - }; - const mockStream = { - getTracks: () => [mockTrack] - }; - + const mockTrack = { stop: vi.fn() }; + const mockStream = { getTracks: () => [mockTrack] }; global.navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); - const result = await checkMicrophoneAvailability(); - expect(result).toBe(true); - expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ - audio: { - echoCancellation: true, - noiseSuppression: true, - sampleRate: 16000, - channelCount: 1 - } - }); + expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); expect(mockTrack.stop).toHaveBeenCalled(); }); @@ -45,11 +31,8 @@ describe('Microphone Availability Detection', () => { */ test('should return false when microphone is not available', async () => { // Mock getUserMedia to throw an error - const mockError = new Error('Permission denied'); - global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(mockError); - + global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied')); const result = await checkMicrophoneAvailability(); - expect(result).toBe(false); expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); }); @@ -61,11 +44,8 @@ describe('Microphone Availability Detection', () => { // Mock navigator without mediaDevices const originalMediaDevices = global.navigator.mediaDevices; delete global.navigator.mediaDevices; - const result = await checkMicrophoneAvailability(); - expect(result).toBe(false); - // Restore global.navigator.mediaDevices = originalMediaDevices; }); @@ -75,15 +55,7 @@ describe('Microphone Availability Detection', () => { */ test('should return true for HTTP non-localhost access', () => { // Mock window.location for HTTP non-localhost - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: 'example.com' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'example.com' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(true); }); @@ -92,15 +64,7 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - localhost should return false */ test('should return false for localhost', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: 'localhost' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'localhost' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -109,15 +73,7 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - 127.0.0.1 should return false */ test('should return false for 127.0.0.1', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: '127.0.0.1' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: '127.0.0.1' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -127,17 +83,8 @@ describe('Microphone Availability Detection', () => { */ test('should return false for private IP addresses', () => { const privateIPs = ['192.168.1.100', '10.0.0.1', '172.16.0.1']; - privateIPs.forEach(ip => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: ip - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: ip }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -147,16 +94,8 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - HTTPS should return false */ test('should return false for HTTPS protocol', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'https:', - hostname: 'example.com' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'https:', hostname: 'example.com' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); -}); \ 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 3fca3001..02a3e09e 100644 --- a/main/xiaozhi-server/test/js/core/mcp/tools.js +++ b/main/xiaozhi-server/test/js/core/mcp/tools.js @@ -24,7 +24,6 @@ export function setWebSocket(ws) { export async function initMcpTools() { // 加载默认工具数据 const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json()); - const savedTools = localStorage.getItem('mcpTools'); if (savedTools) { try { @@ -36,11 +35,10 @@ export async function initMcpTools() { } else { mcpTools = [...defaultMcpTools]; } - renderMcpTools(); // Only setup event listeners if DOM elements exist if (document.getElementById('toggleMcpTools')) { - setupMcpEventListeners(); + setupMcpEventListeners(); } } @@ -50,51 +48,21 @@ export async function initMcpTools() { function renderMcpTools() { const container = document.getElementById('mcpToolsContainer'); const countSpan = document.getElementById('mcpToolsCount'); - if (!container) { return; // Container not found, skip rendering } - if (countSpan) { countSpan.textContent = `${mcpTools.length} 个工具`; } - if (mcpTools.length === 0) { container.innerHTML = '
暂无工具,点击下方按钮添加新工具
'; return; } - container.innerHTML = mcpTools.map((tool, index) => { const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0; const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0; const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0; - - return ` -
-
-
${tool.name}
-
- - -
-
-
${tool.description}
-
-
- 参数数量: - ${paramCount} 个 ${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''} -
-
- 模拟返回: - ${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'} -
-
-
- `; + return `
${tool.name}
${tool.description}
参数数量:${paramCount} 个 ${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''}
模拟返回:${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'}
`; }).join(''); } @@ -103,81 +71,21 @@ function renderMcpTools() { */ function renderMcpProperties() { const container = document.getElementById('mcpPropertiesContainer'); - if (!container) { return; // Container not found, skip rendering } - if (mcpProperties.length === 0) { container.innerHTML = '
暂无参数,点击下方按钮添加参数
'; return; } - - container.innerHTML = mcpProperties.map((prop, index) => ` -
-
- ${prop.name} - -
-
-
- - -
-
- - -
-
- ${(prop.type === 'integer' || prop.type === 'number') ? ` -
-
- - -
-
- - -
-
- ` : ''} -
- - -
- -
- `).join(''); + container.innerHTML = mcpProperties.map((prop, index) => `
${prop.name}
${(prop.type === 'integer' || prop.type === 'number') ? `
` : ''}
`).join(''); } /** * 添加参数 */ function addMcpProperty() { - mcpProperties.push({ - name: `param_${mcpProperties.length + 1}`, - type: 'string', - required: false, - description: '' - }); + mcpProperties.push({ name: `param_${mcpProperties.length + 1}`, type: 'string', required: false, description: '' }); renderMcpProperties(); } @@ -193,9 +101,7 @@ function updateMcpProperty(index, field, value) { return; } } - mcpProperties[index][field] = value; - if (field === 'type' && value !== 'integer' && value !== 'number') { delete mcpProperties[index].minimum; delete mcpProperties[index].maximum; @@ -223,30 +129,24 @@ function setupMcpEventListeners() { const cancelBtn = document.getElementById('cancelMcpBtn'); const form = document.getElementById('mcpToolForm'); const addPropertyBtn = document.getElementById('addMcpPropertyBtn'); - // Return early if required elements don't exist (e.g., in test environment) if (!toggleBtn || !panel || !addBtn || !modal || !closeBtn || !cancelBtn || !form || !addPropertyBtn) { return; } - toggleBtn.addEventListener('click', () => { const isExpanded = panel.classList.contains('expanded'); panel.classList.toggle('expanded'); toggleBtn.textContent = isExpanded ? '收起' : '展开'; }); - // 确保面板默认展开 panel.classList.add('expanded'); - addBtn.addEventListener('click', () => openMcpModal()); closeBtn.addEventListener('click', closeMcpModal); cancelBtn.addEventListener('click', closeMcpModal); addPropertyBtn.addEventListener('click', addMcpProperty); - modal.addEventListener('click', (e) => { if (e.target === modal) closeMcpModal(); }); - form.addEventListener('submit', handleMcpSubmit); } @@ -259,31 +159,21 @@ function openMcpModal(index = null) { alert('WebSocket 已连接,无法编辑工具'); return; } - mcpEditingIndex = index; const errorContainer = document.getElementById('mcpErrorContainer'); errorContainer.innerHTML = ''; - if (index !== null) { document.getElementById('mcpModalTitle').textContent = '编辑工具'; const tool = mcpTools[index]; document.getElementById('mcpToolName').value = tool.name; document.getElementById('mcpToolDescription').value = tool.description; document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : ''; - mcpProperties = []; const schema = tool.inputSchema; if (schema.properties) { Object.keys(schema.properties).forEach(key => { const prop = schema.properties[key]; - mcpProperties.push({ - name: key, - type: prop.type || 'string', - minimum: prop.minimum, - maximum: prop.maximum, - description: prop.description || '', - required: schema.required && schema.required.includes(key) - }); + mcpProperties.push({ name: key, type: prop.type || 'string', minimum: prop.minimum, maximum: prop.maximum, description: prop.description || '', required: schema.required && schema.required.includes(key) }); }); } } else { @@ -291,7 +181,6 @@ function openMcpModal(index = null) { document.getElementById('mcpToolForm').reset(); mcpProperties = []; } - renderMcpProperties(); document.getElementById('mcpToolModal').style.display = 'block'; } @@ -314,21 +203,15 @@ function handleMcpSubmit(e) { e.preventDefault(); const errorContainer = document.getElementById('mcpErrorContainer'); errorContainer.innerHTML = ''; - const name = document.getElementById('mcpToolName').value.trim(); const description = document.getElementById('mcpToolDescription').value.trim(); const mockResponseText = document.getElementById('mcpMockResponse').value.trim(); - // 检查名称重复 - const isDuplicate = mcpTools.some((tool, index) => - tool.name === name && index !== mcpEditingIndex - ); - + const isDuplicate = mcpTools.some((tool, index) => tool.name === name && index !== mcpEditingIndex); if (isDuplicate) { showMcpError('工具名称已存在,请使用不同的名称'); return; } - // 解析模拟返回结果 let mockResponse = null; if (mockResponseText) { @@ -339,21 +222,13 @@ function handleMcpSubmit(e) { return; } } - // 构建 inputSchema - const inputSchema = { - type: "object", - properties: {}, - required: [] - }; - + const inputSchema = { type: "object", properties: {}, required: [] }; mcpProperties.forEach(prop => { const propSchema = { type: prop.type }; - if (prop.description) { propSchema.description = prop.description; } - if ((prop.type === 'integer' || prop.type === 'number')) { if (prop.minimum !== undefined && prop.minimum !== '') { propSchema.minimum = prop.minimum; @@ -362,20 +237,15 @@ function handleMcpSubmit(e) { propSchema.maximum = prop.maximum; } } - inputSchema.properties[prop.name] = propSchema; - if (prop.required) { inputSchema.required.push(prop.name); } }); - if (inputSchema.required.length === 0) { delete inputSchema.required; } - const tool = { name, description, inputSchema, mockResponse }; - if (mcpEditingIndex !== null) { mcpTools[mcpEditingIndex] = tool; log(`已更新工具: ${name}`, 'success'); @@ -383,7 +253,6 @@ function handleMcpSubmit(e) { mcpTools.push(tool); log(`已添加工具: ${name}`, 'success'); } - saveMcpTools(); renderMcpTools(); closeMcpModal(); @@ -433,11 +302,7 @@ function saveMcpTools() { * 获取工具列表 */ export function getMcpTools() { - return mcpTools.map(tool => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema - })); + return mcpTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })); } /** @@ -451,26 +316,10 @@ function executeLive2DAction(toolName, toolArgs) { const live2dManager = window.chatApp?.live2dManager; if (!live2dManager) { log('Live2D管理器未初始化', 'error'); - return { - success: false, - error: 'Live2D管理器未初始化' - }; + 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' - }; - + 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) { // 通用动作工具,使用指定的动作名称 @@ -479,32 +328,17 @@ function executeLive2DAction(toolName, toolArgs) { // 使用映射表查找对应动作名称 motionName = actionMap[toolName]; } - if (!motionName) { log(`未知的动作: ${toolName}`, 'error'); - return { - success: false, - error: `未知的动作: ${toolName}` - }; + return { success: false, error: `未知的动作: ${toolName}` }; } - // 触发Live2D动作 live2dManager.motion(motionName); - log(`Live2D动作已触发: ${motionName}`, 'success'); - - return { - success: true, - message: `虚拟人已执行动作: ${motionName}`, - action: motionName, - tool: toolName - }; + return { success: true, message: `虚拟人已执行动作: ${motionName}`, action: motionName, tool: toolName }; } catch (error) { log(`执行Live2D动作失败: ${error.message}`, 'error'); - return { - success: false, - error: `执行动作失败: ${error.message}` - }; + return { success: false, error: `执行动作失败: ${error.message}` }; } } @@ -513,25 +347,18 @@ function executeLive2DAction(toolName, toolArgs) { */ export function executeMcpTool(toolName, toolArgs) { const tool = mcpTools.find(t => t.name === toolName); - if (!tool) { log(`未找到工具: ${toolName}`, 'error'); - return { - success: false, - error: `未知工具: ${toolName}` - }; + return { success: false, error: `未知工具: ${toolName}` }; } - // 处理Live2D动作工具 if (toolName.startsWith('live2d.')) { return executeLive2DAction(toolName, toolArgs); } - // 如果有模拟返回结果,使用它 if (tool.mockResponse) { // 替换模板变量 let responseStr = JSON.stringify(tool.mockResponse); - // 替换 ${paramName} 格式的变量 if (toolArgs) { Object.keys(toolArgs).forEach(key => { @@ -539,7 +366,6 @@ export function executeMcpTool(toolName, toolArgs) { responseStr = responseStr.replace(regex, toolArgs[key]); }); } - try { const response = JSON.parse(responseStr); log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success'); @@ -549,21 +375,10 @@ export function executeMcpTool(toolName, toolArgs) { return tool.mockResponse; } } - // 没有模拟返回结果,返回默认成功消息 log(`工具 ${toolName} 执行成功,返回默认结果`, 'success'); - return { - success: true, - message: `工具 ${toolName} 执行成功`, - tool: toolName, - arguments: toolArgs - }; + return { success: true, message: `工具 ${toolName} 执行成功`, tool: toolName, arguments: toolArgs }; } // 暴露全局方法供 HTML 内联事件调用 -window.mcpModule = { - updateMcpProperty, - deleteMcpProperty, - editMcpTool, - deleteMcpTool -}; \ No newline at end of file +window.mcpModule = { updateMcpProperty, deleteMcpProperty, editMcpTool, deleteMcpTool }; diff --git a/main/xiaozhi-server/test/js/core/mcp/tools.test.browser.js b/main/xiaozhi-server/test/js/core/mcp/tools.test.browser.js index dcbd4a41..44043745 100644 --- a/main/xiaozhi-server/test/js/core/mcp/tools.test.browser.js +++ b/main/xiaozhi-server/test/js/core/mcp/tools.test.browser.js @@ -1,25 +1,47 @@ -import { executeMcpTool, initMcpTools } from './tools.js'; +/** + * MCP工具模块测试 - Browser compatible version + * 测试Live2D动作工具执行功能 + * + * This version works without Vitest - uses the simple test framework from test-runner.html + */ + +import { executeMcpTool, initMcpTools, setWebSocket } from './tools.js'; describe('Live2D Action Tools', () => { let mockLive2DManager, originalChatApp; beforeEach(() => { + // Reset mocks before each test vi.clearAllMocks(); + // Save original chatApp originalChatApp = window.chatApp; + // Mock Live2D manager mockLive2DManager = { motion: vi.fn() }; + // Setup window.chatApp window.chatApp = { live2dManager: mockLive2DManager }; + // Mock localStorage localStorage.getItem = vi.fn(() => null); + // Mock fetch for default-mcp-tools.json globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([{ name: 'live2d.smile', description: 'Make the virtual human smile', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.wave', description: 'Make the virtual human wave', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.action', description: 'Trigger a specified action', inputSchema: { type: 'object', properties: { action: { type: 'string' } }, required: ['action'] } }]) })); + // Mock DOM elements - ensure all required elements exist const mockContainer = { innerHTML: '', appendChild: vi.fn(), textContent: '' }; document.getElementById = vi.fn((id) => { + // Return mock elements for all IDs that tools.js might access if (id === 'mcpToolsContainer' || id === 'mcpPropertiesContainer') return mockContainer; if (id === 'mcpToolsCount') return { textContent: '' }; + // Return null for other elements (they're checked with if statements) return null; }); }); - afterEach(() => { window.chatApp = originalChatApp; }); + afterEach(() => { + // Clean up + window.chatApp = originalChatApp; + }); + /** + * 测试 executeMcpTool - smile 动作 + */ test('should execute Live2D smile action', async () => { await initMcpTools(); const result = executeMcpTool('live2d.smile', {}); @@ -29,6 +51,9 @@ describe('Live2D Action Tools', () => { expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp'); }); + /** + * 测试 executeMcpTool - wave 动作 + */ test('should execute Live2D wave action', async () => { await initMcpTools(); const result = executeMcpTool('live2d.wave', {}); @@ -38,6 +63,9 @@ describe('Live2D Action Tools', () => { expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap'); }); + /** + * 测试 executeMcpTool - 通用动作工具 + */ test('should handle generic action tool', async () => { await initMcpTools(); const result = executeMcpTool('live2d.action', { action: 'FlickDown' }); @@ -47,6 +75,9 @@ describe('Live2D Action Tools', () => { expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown'); }); + /** + * 测试 executeMcpTool - Live2D管理器未初始化 + */ test('should handle missing Live2D manager gracefully', async () => { await initMcpTools(); window.chatApp = null; @@ -55,10 +86,13 @@ describe('Live2D Action Tools', () => { expect(result.error).toContain('Live2D管理器未初始化'); }); + /** + * 测试 executeMcpTool - 未知的工具 + */ test('should handle unknown tool gracefully', async () => { await initMcpTools(); const result = executeMcpTool('unknown.tool', {}); expect(result.success).toBe(false); expect(result.error).toContain('未知工具'); }); -}); \ 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 index 4edea119..237b1e28 100644 --- a/main/xiaozhi-server/test/js/core/mcp/tools.test.js +++ b/main/xiaozhi-server/test/js/core/mcp/tools.test.js @@ -12,61 +12,21 @@ describe('Live2D Action Tools', () => { beforeEach(() => { // Reset mocks before each test vi.clearAllMocks(); - // Mock Live2D manager - mockLive2DManager = { - motion: vi.fn() - }; - + mockLive2DManager = { motion: vi.fn() }; // Setup window.chatApp - global.window.chatApp = { - live2dManager: mockLive2DManager - }; - + global.window.chatApp = { live2dManager: mockLive2DManager }; // Mock localStorage global.localStorage.getItem = vi.fn(() => null); - // Mock fetch for default-mcp-tools.json - global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve([ - { - name: 'live2d.smile', - description: 'Make the virtual human smile', - inputSchema: { type: 'object', properties: {} } - }, - { - name: 'live2d.wave', - description: 'Make the virtual human wave', - inputSchema: { type: 'object', properties: {} } - }, - { - name: 'live2d.action', - description: 'Trigger a specified action', - inputSchema: { - type: 'object', - properties: { - action: { type: 'string' } - }, - required: ['action'] - } - } - ]) - }) - ); - + global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([{ name: 'live2d.smile', description: 'Make the virtual human smile', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.wave', description: 'Make the virtual human wave', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.action', description: 'Trigger a specified action', inputSchema: { type: 'object', properties: { action: { type: 'string' } }, required: ['action'] } }]) })); // Mock DOM elements global.document.getElementById = vi.fn((id) => { if (id === 'mcpToolsContainer') { - return { - innerHTML: '', - appendChild: vi.fn() - }; + return { innerHTML: '', appendChild: vi.fn() }; } if (id === 'mcpToolsCount') { - return { - textContent: '' - }; + return { textContent: '' }; } return null; }); @@ -82,9 +42,7 @@ describe('Live2D Action Tools', () => { */ test('should execute Live2D smile action', async () => { await initMcpTools(); - const result = executeMcpTool('live2d.smile', {}); - expect(result.success).toBe(true); expect(result.action).toBe('FlickUp'); expect(result.tool).toBe('live2d.smile'); @@ -96,9 +54,7 @@ describe('Live2D Action Tools', () => { */ test('should execute Live2D wave action', async () => { await initMcpTools(); - const result = executeMcpTool('live2d.wave', {}); - expect(result.success).toBe(true); expect(result.action).toBe('Tap'); expect(result.tool).toBe('live2d.wave'); @@ -110,9 +66,7 @@ describe('Live2D Action Tools', () => { */ test('should handle generic action tool', async () => { await initMcpTools(); - const result = executeMcpTool('live2d.action', { action: 'FlickDown' }); - expect(result.success).toBe(true); expect(result.action).toBe('FlickDown'); expect(result.tool).toBe('live2d.action'); @@ -124,11 +78,8 @@ describe('Live2D Action Tools', () => { */ test('should handle missing Live2D manager gracefully', async () => { await initMcpTools(); - global.window.chatApp = null; - const result = executeMcpTool('live2d.smile', {}); - expect(result.success).toBe(false); expect(result.error).toContain('Live2D管理器未初始化'); }); @@ -138,25 +89,12 @@ describe('Live2D Action Tools', () => { */ test('should handle unknown action gracefully', async () => { await initMcpTools(); - // Add an unknown live2d tool to the list const tools = await global.fetch().then(res => res.json()); - tools.push({ - name: 'live2d.unknown', - description: 'Unknown action', - inputSchema: { type: 'object', properties: {} } - }); - - global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve(tools) - }) - ); - + tools.push({ name: 'live2d.unknown', description: 'Unknown action', inputSchema: { type: 'object', properties: {} } }); + global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve(tools) })); await initMcpTools(); - const result = executeMcpTool('live2d.unknown', {}); - expect(result.success).toBe(false); expect(result.error).toContain('未知的动作'); }); @@ -166,9 +104,7 @@ describe('Live2D Action Tools', () => { */ test('should handle unknown tool gracefully', async () => { await initMcpTools(); - const result = executeMcpTool('unknown.tool', {}); - expect(result.success).toBe(false); expect(result.error).toContain('未知工具'); }); @@ -178,43 +114,20 @@ describe('Live2D Action Tools', () => { */ test('should handle other action mappings', async () => { await initMcpTools(); - - const actionMappings = [ - { tool: 'live2d.happy', expectedAction: 'FlickUp' }, - { tool: 'live2d.sad', expectedAction: 'FlickDown' }, - { tool: 'live2d.tap', expectedAction: 'Tap' }, - { tool: 'live2d.tapBody', expectedAction: 'Tap@Body' }, - { tool: 'live2d.flick', expectedAction: 'Flick' }, - { tool: 'live2d.flickBody', expectedAction: 'Flick@Body' }, - { tool: 'live2d.flickUp', expectedAction: 'FlickUp' }, - { tool: 'live2d.flickDown', expectedAction: 'FlickDown' } - ]; - + const actionMappings = [{ tool: 'live2d.happy', expectedAction: 'FlickUp' }, { tool: 'live2d.sad', expectedAction: 'FlickDown' }, { tool: 'live2d.tap', expectedAction: 'Tap' }, { tool: 'live2d.tapBody', expectedAction: 'Tap@Body' }, { tool: 'live2d.flick', expectedAction: 'Flick' }, { tool: 'live2d.flickBody', expectedAction: 'Flick@Body' }, { tool: 'live2d.flickUp', expectedAction: 'FlickUp' }, { tool: 'live2d.flickDown', expectedAction: 'FlickDown' }]; // Add these tools to the mock const tools = await global.fetch().then(res => res.json()); actionMappings.forEach(mapping => { - tools.push({ - name: mapping.tool, - description: `Test ${mapping.tool}`, - inputSchema: { type: 'object', properties: {} } - }); + tools.push({ name: mapping.tool, description: `Test ${mapping.tool}`, inputSchema: { type: 'object', properties: {} } }); }); - - global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve(tools) - }) - ); - + global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve(tools) })); await initMcpTools(); - for (const mapping of actionMappings) { mockLive2DManager.motion.mockClear(); const result = executeMcpTool(mapping.tool, {}); - expect(result.success).toBe(true); expect(result.action).toBe(mapping.expectedAction); expect(mockLive2DManager.motion).toHaveBeenCalledWith(mapping.expectedAction); } }); -}); \ 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 ce39bb48..e28cac4c 100644 --- a/main/xiaozhi-server/test/js/ui/controller.js +++ b/main/xiaozhi-server/test/js/ui/controller.js @@ -242,7 +242,6 @@ class UIController { // Update record button state if (recordBtn) { const microphoneAvailable = window.microphoneAvailable !== false; - if (isConnected && microphoneAvailable) { recordBtn.disabled = false; recordBtn.title = '开始录音'; @@ -252,9 +251,7 @@ class UIController { } else { recordBtn.disabled = true; if (!microphoneAvailable) { - recordBtn.title = window.isHttpNonLocalhost - ? '当前由于是http访问,无法录音,只能用文字交互' - : '麦克风不可用'; + recordBtn.title = window.isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用'; } else { recordBtn.title = '请先连接服务器'; } @@ -277,8 +274,7 @@ class UIController { recordBtn.classList.remove('recording'); } // Only enable button when microphone is available - const microphoneAvailable = window.microphoneAvailable !== false; - recordBtn.disabled = !microphoneAvailable; + recordBtn.disabled = window.microphoneAvailable === false; } } @@ -289,19 +285,13 @@ class UIController { */ 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访问,无法录音,只能用文字交互' - : '麦克风不可用'; - + recordBtn.title = isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用'; // Display notification message in chat if (isHttpNonLocalhost) { this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false);