mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
update:由于现在的模型本身动作有限,使用起来体验不佳,暂时移除动作控制的代码
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user