update:由于现在的模型本身动作有限,使用起来体验不佳,暂时移除动作控制的代码

This commit is contained in:
hrz
2026-01-27 15:45:53 +08:00
parent 0064f975c2
commit 5d72f9f79d
11 changed files with 45 additions and 639 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -68,37 +68,5 @@
"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"
]
}
}
]
@@ -174,7 +174,6 @@ export class AudioRecorder {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
try {
this.websocket.send(opusData.buffer);
log(`已发送Opus帧,大小: ${opusData.length} 字节`, 'debug');
} catch (error) {
log(`WebSocket发送错误: ${error.message}`, 'error');
}
@@ -1,131 +0,0 @@
/**
* 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(() => {
// 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);
const result = await checkMicrophoneAvailability();
expect(result).toBe(true);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
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', () => {
// 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');
}
});
});
@@ -1,101 +0,0 @@
/**
* Audio recording module tests
* Test microphone availability detection functionality
*/
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js';
describe('Microphone Availability Detection', () => {
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] };
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(mockTrack.stop).toHaveBeenCalled();
});
/**
* Test checkMicrophoneAvailability function - failure case
*/
test('should return false when microphone is not available', async () => {
// Mock getUserMedia to throw an error
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();
});
/**
* Test checkMicrophoneAvailability function - browser not supported
*/
test('should return false when browser does not support getUserMedia', async () => {
// 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;
});
/**
* Test isHttpNonLocalhost function - HTTP non-localhost
*/
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 });
const result = isHttpNonLocalhost();
expect(result).toBe(true);
});
/**
* 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 });
const result = isHttpNonLocalhost();
expect(result).toBe(false);
});
/**
* 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 });
const result = isHttpNonLocalhost();
expect(result).toBe(false);
});
/**
* Test isHttpNonLocalhost function - private IP should return false
*/
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 });
const result = isHttpNonLocalhost();
expect(result).toBe(false);
});
});
/**
* 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 });
const result = isHttpNonLocalhost();
expect(result).toBe(false);
});
});
@@ -305,43 +305,6 @@ export function getMcpTools() {
return mcpTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
}
/**
* 执行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}` };
}
}
/**
* 执行工具调用
*/
@@ -351,10 +314,6 @@ export function executeMcpTool(toolName, toolArgs) {
log(`未找到工具: ${toolName}`, 'error');
return { success: false, error: `未知工具: ${toolName}` };
}
// 处理Live2D动作工具
if (toolName.startsWith('live2d.')) {
return executeLive2DAction(toolName, toolArgs);
}
// 如果有模拟返回结果,使用它
if (tool.mockResponse) {
// 替换模板变量
@@ -1,98 +0,0 @@
/**
* 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(() => {
// Clean up
window.chatApp = originalChatApp;
});
/**
* 测试 executeMcpTool - smile 动作
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp');
});
/**
* 测试 executeMcpTool - wave 动作
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap');
});
/**
* 测试 executeMcpTool - 通用动作工具
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown');
});
/**
* 测试 executeMcpTool - Live2D管理器未初始化
*/
test('should handle missing Live2D manager gracefully', async () => {
await initMcpTools();
window.chatApp = null;
const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(false);
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('未知工具');
});
});
@@ -1,133 +0,0 @@
/**
* MCP工具模块测试
* 测试Live2D动作工具执行功能
*/
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { executeMcpTool, initMcpTools, setWebSocket } from './tools.js';
describe('Live2D Action Tools', () => {
let mockLive2DManager;
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
// Mock Live2D manager
mockLive2DManager = { motion: vi.fn() };
// Setup window.chatApp
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'] } }]) }));
// Mock DOM elements
global.document.getElementById = vi.fn((id) => {
if (id === 'mcpToolsContainer') {
return { innerHTML: '', appendChild: vi.fn() };
}
if (id === 'mcpToolsCount') {
return { textContent: '' };
}
return null;
});
});
afterEach(() => {
// Clean up
global.window.chatApp = null;
});
/**
* 测试 executeMcpTool - smile 动作
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp');
});
/**
* 测试 executeMcpTool - wave 动作
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap');
});
/**
* 测试 executeMcpTool - 通用动作工具
*/
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');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown');
});
/**
* 测试 executeMcpTool - Live2D管理器未初始化
*/
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管理器未初始化');
});
/**
* 测试 executeMcpTool - 未知的动作
*/
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) }));
await initMcpTools();
const result = executeMcpTool('live2d.unknown', {});
expect(result.success).toBe(false);
expect(result.error).toContain('未知的动作');
});
/**
* 测试 executeMcpTool - 未知的工具
*/
test('should handle unknown tool gracefully', async () => {
await initMcpTools();
const result = executeMcpTool('unknown.tool', {});
expect(result.success).toBe(false);
expect(result.error).toContain('未知工具');
});
/**
* 测试 executeMcpTool - 其他动作映射
*/
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' }];
// 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: {} } });
});
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);
}
});
});
@@ -304,7 +304,6 @@ export class WebSocketHandler {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug');
} else if (data instanceof Blob) {
arrayBuffer = await data.arrayBuffer();
log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
@@ -392,7 +391,7 @@ export class WebSocketHandler {
this.websocket.onerror = (error) => {
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
uiController.addChatMessage(`⚠️ WebSocket错误: ${error.message || '未知错误'}`, false);
if (this.onConnectionStateChange) {
this.onConnectionStateChange(false);
}
+44 -41
View File
@@ -1,8 +1,8 @@
// UI controller module
import { loadConfig, saveConfig } from '../config/manager.js';
import { getAudioPlayer } from '../core/audio/player.js';
import { getAudioRecorder } from '../core/audio/recorder.js';
import { getWebSocketHandler } from '../core/network/websocket.js';
import { getAudioPlayer } from '../core/audio/player.js';
// UI controller class
class UIController {
@@ -292,12 +292,7 @@ class UIController {
// 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();
@@ -369,10 +364,20 @@ class UIController {
// Start AI chat session after connection
startAIChatSession() {
this.addChatMessage('连接成功,开始聊天吧~😊', false);
// Start recording
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.click();
// Check microphone availability and show error messages if needed
if (!window.microphoneAvailable) {
if (window.isHttpNonLocalhost) {
this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false);
} else {
this.addChatMessage('⚠️ 麦克风不可用,请检查权限设置,只能用文字交互', false);
}
}
// Start recording only if microphone is available
if (window.microphoneAvailable) {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.click();
}
}
}
@@ -418,13 +423,39 @@ class UIController {
// Get WebSocket handler instance
const wsHandler = getWebSocketHandler();
// Register connection state callback BEFORE connecting
wsHandler.onConnectionStateChange = (isConnected) => {
this.updateConnectionUI(isConnected);
this.updateDialButton(isConnected);
};
// Register chat message callback BEFORE connecting
wsHandler.onChatMessage = (text, isUser) => {
this.addChatMessage(text, isUser);
};
// Register record button state callback BEFORE connecting
wsHandler.onRecordButtonStateChange = (isRecording) => {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
if (isRecording) {
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
} else {
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
}
}
};
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) {
@@ -434,34 +465,6 @@ class UIController {
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) {
if (isRecording) {
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
} else {
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
}
}
};
// Connection successful
this.addChatMessage('OTA连接成功,正在建立WebSocket连接...', false);
// Update dial button state
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
@@ -595,4 +598,4 @@ class UIController {
export const uiController = new UIController();
// Export class for module usage
export { UIController };
export { UIController };
-59
View File
@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unit Tests Runner</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;padding:20px}.container{max-width:1200px;margin:0 auto;background:white;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,0.2);overflow:hidden}.header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;padding:30px;text-align:center}.header h1{font-size:2.5em;margin-bottom:10px}.header p{opacity:0.9;font-size:1.1em}.controls{padding:20px;background:#f5f5f5;border-bottom:2px solid #e0e0e0;display:flex;gap:10px;flex-wrap:wrap;align-items:center}button{padding:12px 24px;border:none;border-radius:6px;font-size:16px;cursor:pointer;transition:all 0.3s;font-weight:600}.btn-run{background:#4CAF50;color:white}.btn-run:hover{background:#45a049;transform:translateY(-2px);box-shadow:0 4px 8px rgba(0,0,0,0.2)}.btn-clear{background:#f44336;color:white}.btn-clear:hover{background:#da190b}.stats{display:flex;gap:20px;margin-left:auto;font-size:14px;color:#666}.stat{padding:8px 16px;background:white;border-radius:4px;border:1px solid #ddd}.stat-value{font-weight:bold;font-size:18px;color:#333}.results{padding:20px;max-height:600px;overflow-y:auto}.test-suite{margin-bottom:30px;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden}.test-suite-header{background:#f9f9f9;padding:15px 20px;font-weight:bold;font-size:18px;border-bottom:2px solid #e0e0e0}.test-case{padding:15px 20px;border-bottom:1px solid #f0f0f0;transition:background 0.2s}.test-case:hover{background:#f9f9f9}.test-case:last-child{border-bottom:none}.test-name{font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:10px}.test-status{display:inline-block;width:12px;height:12px;border-radius:50%}.test-status.pending{background:#ffa500}.test-status.pass{background:#4CAF50}.test-status.fail{background:#f44336}.test-error{margin-top:8px;padding:10px;background:#ffebee;border-left:4px solid #f44336;border-radius:4px;color:#c62828;font-family:'Courier New',monospace;font-size:13px;white-space:pre-wrap}.test-duration{color:#999;font-size:12px;margin-left:auto}.summary{padding:20px;background:#f5f5f5;border-top:2px solid #e0e0e0;text-align:center;font-size:18px}.summary.pass{color:#4CAF50;font-weight:bold}.summary.fail{color:#f44336;font-weight:bold}.loading{text-align:center;padding:40px;color:#666}.spinner{border:4px solid #f3f3f3;border-top:4px solid #667eea;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto 20px}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 Unit Tests Runner</h1>
<p>Browser-based test runner for xiaozhi test modules</p>
</div>
<div class="controls">
<button class="btn-run" onclick="runTests()">▶ Run All Tests</button>
<button class="btn-clear" onclick="clearResults()">🗑️ Clear Results</button>
<div class="stats">
<div class="stat"><div>Total</div><div class="stat-value" id="stat-total">0</div></div>
<div class="stat"><div>Passed</div><div class="stat-value" id="stat-passed" style="color:#4CAF50">0</div></div>
<div class="stat"><div>Failed</div><div class="stat-value" id="stat-failed" style="color:#f44336">0</div></div>
</div>
</div>
<div class="results" id="results">
<div class="loading" style="display:none" id="loading">
<div class="spinner"></div>
<div>Running tests...</div>
</div>
<div style="text-align:center;padding:40px;color:#999">Click "Run All Tests" to start testing</div>
</div>
<div class="summary" id="summary"></div>
</div>
<script type="module">
const mockElement={innerHTML:'',textContent:'',appendChild:()=>{},addEventListener:()=>{},classList:{add:()=>{},remove:()=>{},toggle:()=>{},contains:()=>false},style:{}};
const originalGetElementById=document.getElementById.bind(document);
document.getElementById=function(id){const realElement=originalGetElementById(id);return realElement||mockElement;};
if(!navigator.mediaDevices){navigator.mediaDevices={getUserMedia:()=>Promise.reject(new Error('Not implemented'))};}
const testResults={suites:[],total:0,passed:0,failed:0};
globalThis.describe=function(name,fn){testResults.suites.push({name,tests:[],fn});fn();};
globalThis.test=function(name,fn){const suite=testResults.suites[testResults.suites.length-1];if(suite){suite.tests.push({name,fn,status:'pending',error:null,duration:0});testResults.total++;}};
globalThis.expect=function(actual){return{toBe(expected){if(actual!==expected)throw new Error(`Expected ${expected}, but got ${actual}`);},toHaveBeenCalled(){if(!actual||typeof actual!=='function'||!actual.mock||actual.mock.calls.length===0)throw new Error('Expected function to have been called');},toHaveBeenCalledWith(...args){if(!actual||typeof actual!=='function'||!actual.mock)throw new Error('Expected function to have been called');const deepEqual=(a,b)=>{if(a===b)return true;if(a==null||b==null)return false;if(typeof a!=='object'||typeof b!=='object')return false;const keysA=Object.keys(a),keysB=Object.keys(b);if(keysA.length!==keysB.length)return false;for(const key of keysA){if(!keysB.includes(key))return false;if(!deepEqual(a[key],b[key]))return false;}return true;};const found=actual.mock.calls.some(call=>call.length===args.length&&call.every((val,i)=>deepEqual(val,args[i])));if(!found)throw new Error(`Expected function to have been called with ${JSON.stringify(args)}, but was called with: ${actual.mock.calls.map(c=>JSON.stringify(c)).join(', ')}`);},toContain(substring){if(typeof actual!=='string'||!actual.includes(substring))throw new Error(`Expected string to contain "${substring}"`);}};};
globalThis.vi={fn:function(impl){const mockData={calls:[],resolvedValue:undefined,rejectedValue:undefined};const mock=function(...args){mockData.calls.push(args);if(mockData.rejectedValue!==undefined)return Promise.reject(mockData.rejectedValue);if(mockData.resolvedValue!==undefined)return Promise.resolve(mockData.resolvedValue);return impl?impl(...args):undefined;};mock.mock=mockData;mock.mockResolvedValue=function(value){mockData.resolvedValue=value;mockData.rejectedValue=undefined;return mock;};mock.mockRejectedValue=function(value){mockData.rejectedValue=value;mockData.resolvedValue=undefined;return mock;};return mock;},clearAllMocks:()=>{}};
globalThis.beforeEach=function(fn){const suite=testResults.suites[testResults.suites.length-1];if(suite)suite.beforeEach=fn;};
globalThis.afterEach=function(fn){const suite=testResults.suites[testResults.suites.length-1];if(suite)suite.afterEach=fn;};
async function runTests(){const resultsDiv=document.querySelector('#results'),loadingDiv=document.querySelector('#loading'),summaryDiv=document.querySelector('#summary');if(!resultsDiv||!loadingDiv||!summaryDiv){alert('Error: Test runner UI elements not found. Please refresh the page.');return;}
testResults.suites=[];testResults.total=0;testResults.passed=0;testResults.failed=0;loadingDiv.style.display='block';resultsDiv.innerHTML='';summaryDiv.textContent='';
try{await import('./js/core/audio/recorder.test.browser.js');await import('./js/core/mcp/tools.test.browser.js');
for(const suite of testResults.suites){for(const testCase of suite.tests){const startTime=performance.now();try{document.getElementById=()=>mockElement;if(suite.beforeEach)await suite.beforeEach();await testCase.fn();testCase.status='pass';testResults.passed++;}catch(error){testCase.status='fail';testCase.error=error.message||String(error);testResults.failed++;}finally{testCase.duration=performance.now()-startTime;if(suite.afterEach){try{await suite.afterEach();}catch(e){}}}}}
renderResults();}catch(error){const errorDiv=document.querySelector('#results');if(errorDiv)errorDiv.innerHTML=`<div class="test-error">Error loading tests: ${error.message}</div>`;}finally{const loadingDivFinal=document.querySelector('#loading');if(loadingDivFinal)loadingDivFinal.style.display='none';}}
function renderResults(){const resultsDiv=document.querySelector('#results'),summaryDiv=document.querySelector('#summary');if(!resultsDiv||!summaryDiv)return;let html='';for(const suite of testResults.suites){html+=`<div class="test-suite"><div class="test-suite-header">${suite.name}</div>`;for(const testCase of suite.tests){html+=`<div class="test-case"><div class="test-name"><span class="test-status ${testCase.status}"></span><span>${testCase.name}</span><span class="test-duration">${testCase.duration.toFixed(2)}ms</span></div>`;if(testCase.error)html+=`<div class="test-error">${testCase.error}</div>`;html+=`</div>`;}html+=`</div>`;}
resultsDiv.innerHTML=html;const statTotal=document.querySelector('#stat-total'),statPassed=document.querySelector('#stat-passed'),statFailed=document.querySelector('#stat-failed');if(statTotal)statTotal.textContent=testResults.total;if(statPassed)statPassed.textContent=testResults.passed;if(statFailed)statFailed.textContent=testResults.failed;
if(testResults.failed===0){summaryDiv.className='summary pass';summaryDiv.textContent=`✅ All ${testResults.total} tests passed!`;}else{summaryDiv.className='summary fail';summaryDiv.textContent=`${testResults.failed} of ${testResults.total} tests failed`;}}
function clearResults(){const resultsDiv=document.querySelector('#results'),summaryDiv=document.querySelector('#summary'),statTotal=document.querySelector('#stat-total'),statPassed=document.querySelector('#stat-passed'),statFailed=document.querySelector('#stat-failed');if(resultsDiv)resultsDiv.innerHTML='<div style="text-align:center;padding:40px;color:#999">Click "Run All Tests" to start testing</div>';if(summaryDiv)summaryDiv.textContent='';if(statTotal)statTotal.textContent='0';if(statPassed)statPassed.textContent='0';if(statFailed)statFailed.textContent='0';}
window.runTests=runTests;window.clearResults=clearResults;window._originalGetElementById=originalGetElementById;
</script>
</body>
</html>