Optimize code density: simplify changed files while keeping comments (reduce 364 lines)

This commit is contained in:
spider-yamet
2026-01-26 14:48:08 -08:00
parent da71dce860
commit 3fc33e9095
8 changed files with 148 additions and 512 deletions
+1 -21
View File
@@ -17,33 +17,24 @@ class App {
// 初始化应用 // 初始化应用
async init() { async init() {
log('正在初始化应用...', 'info'); log('正在初始化应用...', 'info');
// 初始化UI控制器 // 初始化UI控制器
this.uiController = uiController; this.uiController = uiController;
this.uiController.init(); this.uiController.init();
// 检查Opus库 // 检查Opus库
checkOpusLoaded(); checkOpusLoaded();
// 初始化Opus编码器 // 初始化Opus编码器
initOpusEncoder(); initOpusEncoder();
// 初始化音频播放器 // 初始化音频播放器
this.audioPlayer = getAudioPlayer(); this.audioPlayer = getAudioPlayer();
await this.audioPlayer.start(); await this.audioPlayer.start();
// 初始化MCP工具 // 初始化MCP工具
initMcpTools(); initMcpTools();
// 检查麦克风可用性 // 检查麦克风可用性
await this.checkMicrophoneAvailability(); await this.checkMicrophoneAvailability();
// 初始化Live2D // 初始化Live2D
await this.initLive2D(); await this.initLive2D();
// 关闭加载loading // 关闭加载loading
this.setModelLoadingStatus(false); this.setModelLoadingStatus(false);
log('应用初始化完成', 'success'); log('应用初始化完成', 'success');
} }
@@ -54,21 +45,17 @@ class App {
if (typeof window.Live2DManager === 'undefined') { if (typeof window.Live2DManager === 'undefined') {
throw new Error('Live2DManager未加载,请检查脚本引入顺序'); throw new Error('Live2DManager未加载,请检查脚本引入顺序');
} }
this.live2dManager = new window.Live2DManager(); this.live2dManager = new window.Live2DManager();
await this.live2dManager.initializeLive2D(); await this.live2dManager.initializeLive2D();
// 更新UI状态 // 更新UI状态
const live2dStatus = document.getElementById('live2dStatus'); const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) { if (live2dStatus) {
live2dStatus.textContent = '● 已加载'; live2dStatus.textContent = '● 已加载';
live2dStatus.className = 'status loaded'; live2dStatus.className = 'status loaded';
} }
log('Live2D初始化完成', 'success'); log('Live2D初始化完成', 'success');
} catch (error) { } catch (error) {
log(`Live2D初始化失败: ${error.message}`, 'error'); log(`Live2D初始化失败: ${error.message}`, 'error');
// 更新UI状态 // 更新UI状态
const live2dStatus = document.getElementById('live2dStatus'); const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) { if (live2dStatus) {
@@ -94,16 +81,13 @@ class App {
try { try {
const isAvailable = await checkMicrophoneAvailability(); const isAvailable = await checkMicrophoneAvailability();
const isHttp = isHttpNonLocalhost(); const isHttp = isHttpNonLocalhost();
// 保存可用性状态到全局变量 // 保存可用性状态到全局变量
window.microphoneAvailable = isAvailable; window.microphoneAvailable = isAvailable;
window.isHttpNonLocalhost = isHttp; window.isHttpNonLocalhost = isHttp;
// 更新UI // 更新UI
if (this.uiController) { if (this.uiController) {
this.uiController.updateMicrophoneAvailability(isAvailable, isHttp); this.uiController.updateMicrophoneAvailability(isAvailable, isHttp);
} }
log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning'); log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning');
} catch (error) { } catch (error) {
log(`检查麦克风可用性失败: ${error.message}`, 'error'); log(`检查麦克风可用性失败: ${error.message}`, 'error');
@@ -119,14 +103,10 @@ class App {
// 创建并启动应用 // 创建并启动应用
const app = new App(); const app = new App();
// 将应用实例暴露到全局,供其他模块访问 // 将应用实例暴露到全局,供其他模块访问
window.chatApp = app; window.chatApp = app;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// 初始化应用 // 初始化应用
app.init(); app.init();
}); });
export default app;
export default app;
@@ -19,7 +19,6 @@ export class AudioRecorder {
this.visualizationRequest = null; this.visualizationRequest = null;
this.recordingTimer = null; this.recordingTimer = null;
this.websocket = null; this.websocket = null;
// Callback functions // Callback functions
this.onRecordingStart = null; this.onRecordingStart = null;
this.onRecordingStop = null; this.onRecordingStop = null;
@@ -33,8 +32,7 @@ export class AudioRecorder {
// Get AudioContext instance // Get AudioContext instance
getAudioContext() { getAudioContext() {
const audioPlayer = getAudioPlayer(); return getAudioPlayer().getAudioContext();
return audioPlayer.getAudioContext();
} }
// Initialize encoder // Initialize encoder
@@ -56,50 +54,35 @@ export class AudioRecorder {
this.buffer = new Int16Array(this.frameSize); this.buffer = new Int16Array(this.frameSize);
this.bufferIndex = 0; this.bufferIndex = 0;
this.isRecording = false; this.isRecording = false;
this.port.onmessage = (event) => { this.port.onmessage = (event) => {
if (event.data.command === 'start') { if (event.data.command === 'start') {
this.isRecording = true; this.isRecording = true;
this.port.postMessage({ type: 'status', status: 'started' }); this.port.postMessage({ type: 'status', status: 'started' });
} else if (event.data.command === 'stop') { } else if (event.data.command === 'stop') {
this.isRecording = false; this.isRecording = false;
if (this.bufferIndex > 0) { if (this.bufferIndex > 0) {
const finalBuffer = this.buffer.slice(0, this.bufferIndex); const finalBuffer = this.buffer.slice(0, this.bufferIndex);
this.port.postMessage({ this.port.postMessage({ type: 'buffer', buffer: finalBuffer });
type: 'buffer',
buffer: finalBuffer
});
this.bufferIndex = 0; this.bufferIndex = 0;
} }
this.port.postMessage({ type: 'status', status: 'stopped' }); this.port.postMessage({ type: 'status', status: 'stopped' });
} }
}; };
} }
process(inputs, outputs, parameters) { process(inputs, outputs, parameters) {
if (!this.isRecording) return true; if (!this.isRecording) return true;
const input = inputs[0][0]; const input = inputs[0][0];
if (!input) return true; if (!input) return true;
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
if (this.bufferIndex >= this.frameSize) { if (this.bufferIndex >= this.frameSize) {
this.port.postMessage({ this.port.postMessage({ type: 'buffer', buffer: this.buffer.slice(0) });
type: 'buffer',
buffer: this.buffer.slice(0)
});
this.bufferIndex = 0; this.bufferIndex = 0;
} }
this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
} }
return true; return true;
} }
} }
registerProcessor('audio-recorder-processor', AudioRecorderProcessor); registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
`; `;
} }
@@ -107,24 +90,19 @@ export class AudioRecorder {
// Create audio processor // Create audio processor
async createAudioProcessor() { async createAudioProcessor() {
this.audioContext = this.getAudioContext(); this.audioContext = this.getAudioContext();
try { try {
if (this.audioContext.audioWorklet) { if (this.audioContext.audioWorklet) {
const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' }); const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
await this.audioContext.audioWorklet.addModule(url); await this.audioContext.audioWorklet.addModule(url);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor'); const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor');
audioProcessor.port.onmessage = (event) => { audioProcessor.port.onmessage = (event) => {
if (event.data.type === 'buffer') { if (event.data.type === 'buffer') {
this.processPCMBuffer(event.data.buffer); this.processPCMBuffer(event.data.buffer);
} }
}; };
log('Using AudioWorklet to process audio', 'success'); log('Using AudioWorklet to process audio', 'success');
const silent = this.audioContext.createGain(); const silent = this.audioContext.createGain();
silent.gain.value = 0; silent.gain.value = 0;
audioProcessor.connect(silent); audioProcessor.connect(silent);
@@ -145,25 +123,19 @@ export class AudioRecorder {
try { try {
const frameSize = 4096; const frameSize = 4096;
const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1); const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1);
scriptProcessor.onaudioprocess = (event) => { scriptProcessor.onaudioprocess = (event) => {
if (!this.isRecording) return; if (!this.isRecording) return;
const input = event.inputBuffer.getChannelData(0); const input = event.inputBuffer.getChannelData(0);
const buffer = new Int16Array(input.length); const buffer = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
} }
this.processPCMBuffer(buffer); this.processPCMBuffer(buffer);
}; };
const silent = this.audioContext.createGain(); const silent = this.audioContext.createGain();
silent.gain.value = 0; silent.gain.value = 0;
scriptProcessor.connect(silent); scriptProcessor.connect(silent);
silent.connect(this.audioContext.destination); silent.connect(this.audioContext.destination);
log('Using ScriptProcessorNode as fallback successfully', 'warning'); log('Using ScriptProcessorNode as fallback successfully', 'warning');
return { node: scriptProcessor, type: 'processor' }; return { node: scriptProcessor, type: 'processor' };
} catch (fallbackError) { } catch (fallbackError) {
@@ -175,18 +147,14 @@ export class AudioRecorder {
// Process PCM buffer data // Process PCM buffer data
processPCMBuffer(buffer) { processPCMBuffer(buffer) {
if (!this.isRecording) return; if (!this.isRecording) return;
const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length); const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length);
newBuffer.set(this.pcmDataBuffer); newBuffer.set(this.pcmDataBuffer);
newBuffer.set(buffer, this.pcmDataBuffer.length); newBuffer.set(buffer, this.pcmDataBuffer.length);
this.pcmDataBuffer = newBuffer; this.pcmDataBuffer = newBuffer;
const samplesPerFrame = 960; const samplesPerFrame = 960;
while (this.pcmDataBuffer.length >= samplesPerFrame) { while (this.pcmDataBuffer.length >= samplesPerFrame) {
const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame); const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame);
this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame); this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame);
this.encodeAndSendOpus(frameData); this.encodeAndSendOpus(frameData);
} }
} }
@@ -197,15 +165,12 @@ export class AudioRecorder {
log('Opus encoder not initialized', 'error'); log('Opus encoder not initialized', 'error');
return; return;
} }
try { try {
if (pcmData) { if (pcmData) {
const opusData = this.opusEncoder.encode(pcmData); const opusData = this.opusEncoder.encode(pcmData);
if (opusData && opusData.length > 0) { if (opusData && opusData.length > 0) {
this.audioBuffers.push(opusData.buffer); this.audioBuffers.push(opusData.buffer);
this.totalAudioSize += opusData.length; this.totalAudioSize += opusData.length;
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
try { try {
this.websocket.send(opusData.buffer); this.websocket.send(opusData.buffer);
@@ -238,73 +203,47 @@ export class AudioRecorder {
// Start recording // Start recording
async start() { async start() {
if (this.isRecording) return false; if (this.isRecording) return false;
try { try {
// Check if WebSocketHandler instance exists // Check if WebSocketHandler instance exists
const { getWebSocketHandler } = await import('../network/websocket.js'); const { getWebSocketHandler } = await import('../network/websocket.js');
const wsHandler = getWebSocketHandler(); const wsHandler = getWebSocketHandler();
// If machine is speaking, send abort message // If machine is speaking, send abort message
if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) { if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) {
const abortMessage = { const abortMessage = { session_id: wsHandler.currentSessionId, type: 'abort', reason: 'wake_word_detected' };
session_id: wsHandler.currentSessionId,
type: 'abort',
reason: 'wake_word_detected'
};
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(abortMessage)); this.websocket.send(JSON.stringify(abortMessage));
log('Sent abort message', 'info'); log('Sent abort message', 'info');
} }
} }
if (!this.initEncoder()) { if (!this.initEncoder()) {
log('Unable to start recording: Opus encoder initialization failed', 'error'); log('Unable to start recording: Opus encoder initialization failed', 'error');
return false; return false;
} }
log('Please record at least 1-2 seconds of audio to ensure sufficient data is collected', 'info'); log('Please record at least 1-2 seconds of audio to ensure sufficient data is collected', 'info');
const stream = await navigator.mediaDevices.getUserMedia({ audio: { 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(); this.audioContext = this.getAudioContext();
if (this.audioContext.state === 'suspended') { if (this.audioContext.state === 'suspended') {
await this.audioContext.resume(); await this.audioContext.resume();
} }
const processorResult = await this.createAudioProcessor(); const processorResult = await this.createAudioProcessor();
if (!processorResult) { if (!processorResult) {
log('Unable to create audio processor', 'error'); log('Unable to create audio processor', 'error');
return false; return false;
} }
this.audioProcessor = processorResult.node; this.audioProcessor = processorResult.node;
this.audioProcessorType = processorResult.type; this.audioProcessorType = processorResult.type;
this.audioSource = this.audioContext.createMediaStreamSource(stream); this.audioSource = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser(); this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 2048; this.analyser.fftSize = 2048;
this.audioSource.connect(this.analyser); this.audioSource.connect(this.analyser);
this.audioSource.connect(this.audioProcessor); this.audioSource.connect(this.audioProcessor);
this.pcmDataBuffer = new Int16Array(); this.pcmDataBuffer = new Int16Array();
this.audioBuffers = []; this.audioBuffers = [];
this.totalAudioSize = 0; this.totalAudioSize = 0;
this.isRecording = true; this.isRecording = true;
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
this.audioProcessor.port.postMessage({ command: 'start' }); this.audioProcessor.port.postMessage({ command: 'start' });
} }
// Send listening start message // Send listening start message
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
log(`Sent recording start message`, 'info'); log(`Sent recording start message`, 'info');
@@ -312,18 +251,15 @@ export class AudioRecorder {
log('WebSocket not connected, unable to send start message', 'error'); log('WebSocket not connected, unable to send start message', 'error');
return false; return false;
} }
// Start visualization // Start visualization
if (this.onVisualizerUpdate) { if (this.onVisualizerUpdate) {
const dataArray = new Uint8Array(this.analyser.frequencyBinCount); const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.startVisualization(dataArray); this.startVisualization(dataArray);
} }
// Immediately notify recording start, update button state // Immediately notify recording start, update button state
if (this.onRecordingStart) { if (this.onRecordingStart) {
this.onRecordingStart(0); this.onRecordingStart(0);
} }
// Start recording timer // Start recording timer
let recordingSeconds = 0; let recordingSeconds = 0;
this.recordingTimer = setInterval(() => { this.recordingTimer = setInterval(() => {
@@ -332,7 +268,6 @@ export class AudioRecorder {
this.onRecordingStart(recordingSeconds); this.onRecordingStart(recordingSeconds);
} }
}, 100); }, 100);
log('Started PCM direct recording', 'success'); log('Started PCM direct recording', 'success');
return true; return true;
} catch (error) { } catch (error) {
@@ -346,11 +281,8 @@ export class AudioRecorder {
startVisualization(dataArray) { startVisualization(dataArray) {
const draw = () => { const draw = () => {
this.visualizationRequest = requestAnimationFrame(() => draw()); this.visualizationRequest = requestAnimationFrame(() => draw());
if (!this.isRecording) return; if (!this.isRecording) return;
this.analyser.getByteFrequencyData(dataArray); this.analyser.getByteFrequencyData(dataArray);
if (this.onVisualizerUpdate) { if (this.onVisualizerUpdate) {
this.onVisualizerUpdate(dataArray); this.onVisualizerUpdate(dataArray);
} }
@@ -361,48 +293,38 @@ export class AudioRecorder {
// Stop recording // Stop recording
stop() { stop() {
if (!this.isRecording) return false; if (!this.isRecording) return false;
try { try {
this.isRecording = false; this.isRecording = false;
if (this.audioProcessor) { if (this.audioProcessor) {
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
this.audioProcessor.port.postMessage({ command: 'stop' }); this.audioProcessor.port.postMessage({ command: 'stop' });
} }
this.audioProcessor.disconnect(); this.audioProcessor.disconnect();
this.audioProcessor = null; this.audioProcessor = null;
} }
if (this.audioSource) { if (this.audioSource) {
this.audioSource.disconnect(); this.audioSource.disconnect();
this.audioSource = null; this.audioSource = null;
} }
if (this.visualizationRequest) { if (this.visualizationRequest) {
cancelAnimationFrame(this.visualizationRequest); cancelAnimationFrame(this.visualizationRequest);
this.visualizationRequest = null; this.visualizationRequest = null;
} }
if (this.recordingTimer) { if (this.recordingTimer) {
clearInterval(this.recordingTimer); clearInterval(this.recordingTimer);
this.recordingTimer = null; this.recordingTimer = null;
} }
// Encode and send remaining data // Encode and send remaining data
this.encodeAndSendOpus(); this.encodeAndSendOpus();
// Send end signal // Send end signal
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const emptyOpusFrame = new Uint8Array(0); const emptyOpusFrame = new Uint8Array(0);
this.websocket.send(emptyOpusFrame); this.websocket.send(emptyOpusFrame);
log('Sent recording stop signal', 'info'); log('Sent recording stop signal', 'info');
} }
if (this.onRecordingStop) { if (this.onRecordingStop) {
this.onRecordingStop(); this.onRecordingStop();
} }
log('Stopped PCM direct recording', 'success'); log('Stopped PCM direct recording', 'success');
return true; return true;
} catch (error) { } catch (error) {
@@ -437,21 +359,11 @@ export async function checkMicrophoneAvailability() {
log('Browser does not support getUserMedia API', 'warning'); log('Browser does not support getUserMedia API', 'warning');
return false; return false;
} }
try { try {
// Try to access microphone // Try to access microphone
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
channelCount: 1
}
});
// Immediately stop all tracks to release microphone // Immediately stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop()); stream.getTracks().forEach(track => track.stop());
log('Microphone availability check successful', 'success'); log('Microphone availability check successful', 'success');
return true; return true;
} catch (error) { } catch (error) {
@@ -467,24 +379,18 @@ export async function checkMicrophoneAvailability() {
export function isHttpNonLocalhost() { export function isHttpNonLocalhost() {
const protocol = window.location.protocol; const protocol = window.location.protocol;
const hostname = window.location.hostname; const hostname = window.location.hostname;
// Check if it is HTTP protocol // Check if it is HTTP protocol
if (protocol !== 'http:') { if (protocol !== 'http:') {
return false; return false;
} }
// localhost and 127.0.0.1 can use microphone // localhost and 127.0.0.1 can use microphone
if (hostname === 'localhost' || hostname === '127.0.0.1') { if (hostname === 'localhost' || hostname === '127.0.0.1') {
return false; return false;
} }
// Private IP addresses can also use microphone (browser allows) // Private IP addresses can also use microphone (browser allows)
if (hostname.startsWith('192.168.') || if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.')) {
hostname.startsWith('10.') ||
hostname.startsWith('172.')) {
return false; return false;
} }
// Other HTTP access is considered non-localhost // Other HTTP access is considered non-localhost
return true; return true;
} }
@@ -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'; import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js';
describe('Microphone Availability Detection', () => { 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 () => { test('should return true when microphone is available', async () => {
// Mock navigator.mediaDevices.getUserMedia to return a successful stream
const mockTrack = { stop: vi.fn() }; const mockTrack = { stop: vi.fn() };
const mockStream = { getTracks: () => [mockTrack] }; const mockStream = { getTracks: () => [mockTrack] };
navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream);
@@ -13,60 +27,105 @@ describe('Microphone Availability Detection', () => {
expect(mockTrack.stop).toHaveBeenCalled(); expect(mockTrack.stop).toHaveBeenCalled();
}); });
/**
* Test checkMicrophoneAvailability function - failure case
*/
test('should return false when microphone is not available', async () => { 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')); navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied'));
const result = await checkMicrophoneAvailability(); const result = await checkMicrophoneAvailability();
expect(result).toBe(false); expect(result).toBe(false);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
}); });
/**
* Test checkMicrophoneAvailability function - browser not supported
*/
test('should return false when browser does not support getUserMedia', async () => { test('should return false when browser does not support getUserMedia', async () => {
// Mock navigator.mediaDevices.getUserMedia to be undefined
const originalGetUserMedia = navigator.mediaDevices.getUserMedia; const originalGetUserMedia = navigator.mediaDevices.getUserMedia;
navigator.mediaDevices.getUserMedia = undefined; navigator.mediaDevices.getUserMedia = undefined;
const result = await checkMicrophoneAvailability(); const result = await checkMicrophoneAvailability();
expect(result).toBe(false); expect(result).toBe(false);
// Restore
navigator.mediaDevices.getUserMedia = originalGetUserMedia; 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', () => { 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('should return false for localhost', () => {
// Test the logic by checking if current location is localhost
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
// If we're on localhost, result should be false
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
expect(result).toBe(false); expect(result).toBe(false);
} else { } else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('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('should return false for 127.0.0.1', () => {
// Test the logic by checking if current location is 127.0.0.1
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
// If we're on 127.0.0.1, result should be false
if (window.location.hostname === '127.0.0.1') { if (window.location.hostname === '127.0.0.1') {
expect(result).toBe(false); expect(result).toBe(false);
} else { } else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('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('should return false for private IP addresses', () => {
// Test the logic by checking if current location is a private IP
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
const hostname = window.location.hostname; const hostname = window.location.hostname;
const isPrivateIP = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.'); const isPrivateIP = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.');
if (isPrivateIP && window.location.protocol === 'http:') { if (isPrivateIP && window.location.protocol === 'http:') {
expect(result).toBe(false); expect(result).toBe(false);
} else { } else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('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('should return false for HTTPS protocol', () => {
// Test the logic by checking if current protocol is HTTPS
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
// If we're on HTTPS, result should be false
if (window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
expect(result).toBe(false); expect(result).toBe(false);
} else { } else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('boolean'); expect(typeof result).toBe('boolean');
} }
}); });
}); });
@@ -17,26 +17,12 @@ describe('Microphone Availability Detection', () => {
*/ */
test('should return true when microphone is available', async () => { test('should return true when microphone is available', async () => {
// Mock navigator.mediaDevices.getUserMedia to return a successful stream // Mock navigator.mediaDevices.getUserMedia to return a successful stream
const mockTrack = { const mockTrack = { stop: vi.fn() };
stop: vi.fn() const mockStream = { getTracks: () => [mockTrack] };
};
const mockStream = {
getTracks: () => [mockTrack]
};
global.navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); global.navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream);
const result = await checkMicrophoneAvailability(); const result = await checkMicrophoneAvailability();
expect(result).toBe(true); expect(result).toBe(true);
expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
channelCount: 1
}
});
expect(mockTrack.stop).toHaveBeenCalled(); expect(mockTrack.stop).toHaveBeenCalled();
}); });
@@ -45,11 +31,8 @@ describe('Microphone Availability Detection', () => {
*/ */
test('should return false when microphone is not available', async () => { test('should return false when microphone is not available', async () => {
// Mock getUserMedia to throw an error // Mock getUserMedia to throw an error
const mockError = new Error('Permission denied'); global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied'));
global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(mockError);
const result = await checkMicrophoneAvailability(); const result = await checkMicrophoneAvailability();
expect(result).toBe(false); expect(result).toBe(false);
expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
}); });
@@ -61,11 +44,8 @@ describe('Microphone Availability Detection', () => {
// Mock navigator without mediaDevices // Mock navigator without mediaDevices
const originalMediaDevices = global.navigator.mediaDevices; const originalMediaDevices = global.navigator.mediaDevices;
delete global.navigator.mediaDevices; delete global.navigator.mediaDevices;
const result = await checkMicrophoneAvailability(); const result = await checkMicrophoneAvailability();
expect(result).toBe(false); expect(result).toBe(false);
// Restore // Restore
global.navigator.mediaDevices = originalMediaDevices; global.navigator.mediaDevices = originalMediaDevices;
}); });
@@ -75,15 +55,7 @@ describe('Microphone Availability Detection', () => {
*/ */
test('should return true for HTTP non-localhost access', () => { test('should return true for HTTP non-localhost access', () => {
// Mock window.location for HTTP non-localhost // Mock window.location for HTTP non-localhost
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'example.com' }, writable: true, configurable: true });
value: {
protocol: 'http:',
hostname: 'example.com'
},
writable: true,
configurable: true
});
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
expect(result).toBe(true); expect(result).toBe(true);
}); });
@@ -92,15 +64,7 @@ describe('Microphone Availability Detection', () => {
* Test isHttpNonLocalhost function - localhost should return false * Test isHttpNonLocalhost function - localhost should return false
*/ */
test('should return false for localhost', () => { test('should return false for localhost', () => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'localhost' }, writable: true, configurable: true });
value: {
protocol: 'http:',
hostname: 'localhost'
},
writable: true,
configurable: true
});
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -109,15 +73,7 @@ describe('Microphone Availability Detection', () => {
* Test isHttpNonLocalhost function - 127.0.0.1 should return false * Test isHttpNonLocalhost function - 127.0.0.1 should return false
*/ */
test('should return false for 127.0.0.1', () => { test('should return false for 127.0.0.1', () => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: '127.0.0.1' }, writable: true, configurable: true });
value: {
protocol: 'http:',
hostname: '127.0.0.1'
},
writable: true,
configurable: true
});
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -127,17 +83,8 @@ describe('Microphone Availability Detection', () => {
*/ */
test('should return false for private IP addresses', () => { test('should return false for private IP addresses', () => {
const privateIPs = ['192.168.1.100', '10.0.0.1', '172.16.0.1']; const privateIPs = ['192.168.1.100', '10.0.0.1', '172.16.0.1'];
privateIPs.forEach(ip => { privateIPs.forEach(ip => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: ip }, writable: true, configurable: true });
value: {
protocol: 'http:',
hostname: ip
},
writable: true,
configurable: true
});
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -147,16 +94,8 @@ describe('Microphone Availability Detection', () => {
* Test isHttpNonLocalhost function - HTTPS should return false * Test isHttpNonLocalhost function - HTTPS should return false
*/ */
test('should return false for HTTPS protocol', () => { test('should return false for HTTPS protocol', () => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', { value: { protocol: 'https:', hostname: 'example.com' }, writable: true, configurable: true });
value: {
protocol: 'https:',
hostname: 'example.com'
},
writable: true,
configurable: true
});
const result = isHttpNonLocalhost(); const result = isHttpNonLocalhost();
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
+16 -201
View File
@@ -24,7 +24,6 @@ export function setWebSocket(ws) {
export async function initMcpTools() { export async function initMcpTools() {
// 加载默认工具数据 // 加载默认工具数据
const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json()); const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json());
const savedTools = localStorage.getItem('mcpTools'); const savedTools = localStorage.getItem('mcpTools');
if (savedTools) { if (savedTools) {
try { try {
@@ -36,11 +35,10 @@ export async function initMcpTools() {
} else { } else {
mcpTools = [...defaultMcpTools]; mcpTools = [...defaultMcpTools];
} }
renderMcpTools(); renderMcpTools();
// Only setup event listeners if DOM elements exist // Only setup event listeners if DOM elements exist
if (document.getElementById('toggleMcpTools')) { if (document.getElementById('toggleMcpTools')) {
setupMcpEventListeners(); setupMcpEventListeners();
} }
} }
@@ -50,51 +48,21 @@ export async function initMcpTools() {
function renderMcpTools() { function renderMcpTools() {
const container = document.getElementById('mcpToolsContainer'); const container = document.getElementById('mcpToolsContainer');
const countSpan = document.getElementById('mcpToolsCount'); const countSpan = document.getElementById('mcpToolsCount');
if (!container) { if (!container) {
return; // Container not found, skip rendering return; // Container not found, skip rendering
} }
if (countSpan) { if (countSpan) {
countSpan.textContent = `${mcpTools.length} 个工具`; countSpan.textContent = `${mcpTools.length} 个工具`;
} }
if (mcpTools.length === 0) { if (mcpTools.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>'; container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>';
return; return;
} }
container.innerHTML = mcpTools.map((tool, index) => { container.innerHTML = mcpTools.map((tool, index) => {
const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0; const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0;
const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0; const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0;
const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0; const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0;
return `<div class="mcp-tool-card"><div class="mcp-tool-header"><div class="mcp-tool-name">${tool.name}</div><div class="mcp-tool-actions"><button class="mcp-edit-btn" onclick="window.mcpModule.editMcpTool(${index})">✏️ 编辑</button><button class="mcp-delete-btn" onclick="window.mcpModule.deleteMcpTool(${index})">🗑️ 删除</button></div></div><div class="mcp-tool-description">${tool.description}</div><div class="mcp-tool-info"><div class="mcp-tool-info-row"><span class="mcp-tool-info-label">参数数量:</span><span class="mcp-tool-info-value">${paramCount}${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''}</span></div><div class="mcp-tool-info-row"><span class="mcp-tool-info-label">模拟返回:</span><span class="mcp-tool-info-value">${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'}</span></div></div></div>`;
return `
<div class="mcp-tool-card">
<div class="mcp-tool-header">
<div class="mcp-tool-name">${tool.name}</div>
<div class="mcp-tool-actions">
<button class="mcp-edit-btn" onclick="window.mcpModule.editMcpTool(${index})">
✏️ 编辑
</button>
<button class="mcp-delete-btn" onclick="window.mcpModule.deleteMcpTool(${index})">
🗑️ 删除
</button>
</div>
</div>
<div class="mcp-tool-description">${tool.description}</div>
<div class="mcp-tool-info">
<div class="mcp-tool-info-row">
<span class="mcp-tool-info-label">参数数量:</span>
<span class="mcp-tool-info-value">${paramCount}${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''}</span>
</div>
<div class="mcp-tool-info-row">
<span class="mcp-tool-info-label">模拟返回:</span>
<span class="mcp-tool-info-value">${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'}</span>
</div>
</div>
</div>
`;
}).join(''); }).join('');
} }
@@ -103,81 +71,21 @@ function renderMcpTools() {
*/ */
function renderMcpProperties() { function renderMcpProperties() {
const container = document.getElementById('mcpPropertiesContainer'); const container = document.getElementById('mcpPropertiesContainer');
if (!container) { if (!container) {
return; // Container not found, skip rendering return; // Container not found, skip rendering
} }
if (mcpProperties.length === 0) { if (mcpProperties.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>'; container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>';
return; return;
} }
container.innerHTML = mcpProperties.map((prop, index) => `<div class="mcp-property-item"><div class="mcp-property-header"><span class="mcp-property-name">${prop.name}</span><button type="button" onclick="window.mcpModule.deleteMcpProperty(${index})" style="padding: 3px 8px; border: none; border-radius: 3px; background-color: #f44336; color: white; cursor: pointer; font-size: 11px;">删除</button></div><div class="mcp-property-row"><div><label class="mcp-small-label">参数名称 *</label><input type="text" class="mcp-small-input" value="${prop.name}" onchange="window.mcpModule.updateMcpProperty(${index}, 'name', this.value)" required></div><div><label class="mcp-small-label">数据类型 *</label><select class="mcp-small-input" onchange="window.mcpModule.updateMcpProperty(${index}, 'type', this.value)"><option value="string" ${prop.type === 'string' ? 'selected' : ''}>字符串</option><option value="integer" ${prop.type === 'integer' ? 'selected' : ''}>整数</option><option value="number" ${prop.type === 'number' ? 'selected' : ''}>数字</option><option value="boolean" ${prop.type === 'boolean' ? 'selected' : ''}>布尔值</option><option value="array" ${prop.type === 'array' ? 'selected' : ''}>数组</option><option value="object" ${prop.type === 'object' ? 'selected' : ''}>对象</option></select></div></div>${(prop.type === 'integer' || prop.type === 'number') ? `<div class="mcp-property-row"><div><label class="mcp-small-label">最小值</label><input type="number" class="mcp-small-input" value="${prop.minimum !== undefined ? prop.minimum : ''}" placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'minimum', this.value ? parseFloat(this.value) : undefined)"></div><div><label class="mcp-small-label">最大值</label><input type="number" class="mcp-small-input" value="${prop.maximum !== undefined ? prop.maximum : ''}" placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'maximum', this.value ? parseFloat(this.value) : undefined)"></div></div>` : ''}<div class="mcp-property-row-full"><label class="mcp-small-label">参数描述</label><input type="text" class="mcp-small-input" value="${prop.description || ''}" placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'description', this.value)"></div><label class="mcp-checkbox-label"><input type="checkbox" ${prop.required ? 'checked' : ''} onchange="window.mcpModule.updateMcpProperty(${index}, 'required', this.checked)">必填参数</label></div>`).join('');
container.innerHTML = mcpProperties.map((prop, index) => `
<div class="mcp-property-item">
<div class="mcp-property-header">
<span class="mcp-property-name">${prop.name}</span>
<button type="button" onclick="window.mcpModule.deleteMcpProperty(${index})"
style="padding: 3px 8px; border: none; border-radius: 3px; background-color: #f44336; color: white; cursor: pointer; font-size: 11px;">
删除
</button>
</div>
<div class="mcp-property-row">
<div>
<label class="mcp-small-label">参数名称 *</label>
<input type="text" class="mcp-small-input" value="${prop.name}"
onchange="window.mcpModule.updateMcpProperty(${index}, 'name', this.value)" required>
</div>
<div>
<label class="mcp-small-label">数据类型 *</label>
<select class="mcp-small-input" onchange="window.mcpModule.updateMcpProperty(${index}, 'type', this.value)">
<option value="string" ${prop.type === 'string' ? 'selected' : ''}>字符串</option>
<option value="integer" ${prop.type === 'integer' ? 'selected' : ''}>整数</option>
<option value="number" ${prop.type === 'number' ? 'selected' : ''}>数字</option>
<option value="boolean" ${prop.type === 'boolean' ? 'selected' : ''}>布尔值</option>
<option value="array" ${prop.type === 'array' ? 'selected' : ''}>数组</option>
<option value="object" ${prop.type === 'object' ? 'selected' : ''}>对象</option>
</select>
</div>
</div>
${(prop.type === 'integer' || prop.type === 'number') ? `
<div class="mcp-property-row">
<div>
<label class="mcp-small-label">最小值</label>
<input type="number" class="mcp-small-input" value="${prop.minimum !== undefined ? prop.minimum : ''}"
placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'minimum', this.value ? parseFloat(this.value) : undefined)">
</div>
<div>
<label class="mcp-small-label">最大值</label>
<input type="number" class="mcp-small-input" value="${prop.maximum !== undefined ? prop.maximum : ''}"
placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'maximum', this.value ? parseFloat(this.value) : undefined)">
</div>
</div>
` : ''}
<div class="mcp-property-row-full">
<label class="mcp-small-label">参数描述</label>
<input type="text" class="mcp-small-input" value="${prop.description || ''}"
placeholder="可选" onchange="window.mcpModule.updateMcpProperty(${index}, 'description', this.value)">
</div>
<label class="mcp-checkbox-label">
<input type="checkbox" ${prop.required ? 'checked' : ''}
onchange="window.mcpModule.updateMcpProperty(${index}, 'required', this.checked)">
必填参数
</label>
</div>
`).join('');
} }
/** /**
* 添加参数 * 添加参数
*/ */
function addMcpProperty() { function addMcpProperty() {
mcpProperties.push({ mcpProperties.push({ name: `param_${mcpProperties.length + 1}`, type: 'string', required: false, description: '' });
name: `param_${mcpProperties.length + 1}`,
type: 'string',
required: false,
description: ''
});
renderMcpProperties(); renderMcpProperties();
} }
@@ -193,9 +101,7 @@ function updateMcpProperty(index, field, value) {
return; return;
} }
} }
mcpProperties[index][field] = value; mcpProperties[index][field] = value;
if (field === 'type' && value !== 'integer' && value !== 'number') { if (field === 'type' && value !== 'integer' && value !== 'number') {
delete mcpProperties[index].minimum; delete mcpProperties[index].minimum;
delete mcpProperties[index].maximum; delete mcpProperties[index].maximum;
@@ -223,30 +129,24 @@ function setupMcpEventListeners() {
const cancelBtn = document.getElementById('cancelMcpBtn'); const cancelBtn = document.getElementById('cancelMcpBtn');
const form = document.getElementById('mcpToolForm'); const form = document.getElementById('mcpToolForm');
const addPropertyBtn = document.getElementById('addMcpPropertyBtn'); const addPropertyBtn = document.getElementById('addMcpPropertyBtn');
// Return early if required elements don't exist (e.g., in test environment) // Return early if required elements don't exist (e.g., in test environment)
if (!toggleBtn || !panel || !addBtn || !modal || !closeBtn || !cancelBtn || !form || !addPropertyBtn) { if (!toggleBtn || !panel || !addBtn || !modal || !closeBtn || !cancelBtn || !form || !addPropertyBtn) {
return; return;
} }
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
const isExpanded = panel.classList.contains('expanded'); const isExpanded = panel.classList.contains('expanded');
panel.classList.toggle('expanded'); panel.classList.toggle('expanded');
toggleBtn.textContent = isExpanded ? '收起' : '展开'; toggleBtn.textContent = isExpanded ? '收起' : '展开';
}); });
// 确保面板默认展开 // 确保面板默认展开
panel.classList.add('expanded'); panel.classList.add('expanded');
addBtn.addEventListener('click', () => openMcpModal()); addBtn.addEventListener('click', () => openMcpModal());
closeBtn.addEventListener('click', closeMcpModal); closeBtn.addEventListener('click', closeMcpModal);
cancelBtn.addEventListener('click', closeMcpModal); cancelBtn.addEventListener('click', closeMcpModal);
addPropertyBtn.addEventListener('click', addMcpProperty); addPropertyBtn.addEventListener('click', addMcpProperty);
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) closeMcpModal(); if (e.target === modal) closeMcpModal();
}); });
form.addEventListener('submit', handleMcpSubmit); form.addEventListener('submit', handleMcpSubmit);
} }
@@ -259,31 +159,21 @@ function openMcpModal(index = null) {
alert('WebSocket 已连接,无法编辑工具'); alert('WebSocket 已连接,无法编辑工具');
return; return;
} }
mcpEditingIndex = index; mcpEditingIndex = index;
const errorContainer = document.getElementById('mcpErrorContainer'); const errorContainer = document.getElementById('mcpErrorContainer');
errorContainer.innerHTML = ''; errorContainer.innerHTML = '';
if (index !== null) { if (index !== null) {
document.getElementById('mcpModalTitle').textContent = '编辑工具'; document.getElementById('mcpModalTitle').textContent = '编辑工具';
const tool = mcpTools[index]; const tool = mcpTools[index];
document.getElementById('mcpToolName').value = tool.name; document.getElementById('mcpToolName').value = tool.name;
document.getElementById('mcpToolDescription').value = tool.description; document.getElementById('mcpToolDescription').value = tool.description;
document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : ''; document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : '';
mcpProperties = []; mcpProperties = [];
const schema = tool.inputSchema; const schema = tool.inputSchema;
if (schema.properties) { if (schema.properties) {
Object.keys(schema.properties).forEach(key => { Object.keys(schema.properties).forEach(key => {
const prop = schema.properties[key]; const prop = schema.properties[key];
mcpProperties.push({ mcpProperties.push({ name: key, type: prop.type || 'string', minimum: prop.minimum, maximum: prop.maximum, description: prop.description || '', required: schema.required && schema.required.includes(key) });
name: key,
type: prop.type || 'string',
minimum: prop.minimum,
maximum: prop.maximum,
description: prop.description || '',
required: schema.required && schema.required.includes(key)
});
}); });
} }
} else { } else {
@@ -291,7 +181,6 @@ function openMcpModal(index = null) {
document.getElementById('mcpToolForm').reset(); document.getElementById('mcpToolForm').reset();
mcpProperties = []; mcpProperties = [];
} }
renderMcpProperties(); renderMcpProperties();
document.getElementById('mcpToolModal').style.display = 'block'; document.getElementById('mcpToolModal').style.display = 'block';
} }
@@ -314,21 +203,15 @@ function handleMcpSubmit(e) {
e.preventDefault(); e.preventDefault();
const errorContainer = document.getElementById('mcpErrorContainer'); const errorContainer = document.getElementById('mcpErrorContainer');
errorContainer.innerHTML = ''; errorContainer.innerHTML = '';
const name = document.getElementById('mcpToolName').value.trim(); const name = document.getElementById('mcpToolName').value.trim();
const description = document.getElementById('mcpToolDescription').value.trim(); const description = document.getElementById('mcpToolDescription').value.trim();
const mockResponseText = document.getElementById('mcpMockResponse').value.trim(); const mockResponseText = document.getElementById('mcpMockResponse').value.trim();
// 检查名称重复 // 检查名称重复
const isDuplicate = mcpTools.some((tool, index) => const isDuplicate = mcpTools.some((tool, index) => tool.name === name && index !== mcpEditingIndex);
tool.name === name && index !== mcpEditingIndex
);
if (isDuplicate) { if (isDuplicate) {
showMcpError('工具名称已存在,请使用不同的名称'); showMcpError('工具名称已存在,请使用不同的名称');
return; return;
} }
// 解析模拟返回结果 // 解析模拟返回结果
let mockResponse = null; let mockResponse = null;
if (mockResponseText) { if (mockResponseText) {
@@ -339,21 +222,13 @@ function handleMcpSubmit(e) {
return; return;
} }
} }
// 构建 inputSchema // 构建 inputSchema
const inputSchema = { const inputSchema = { type: "object", properties: {}, required: [] };
type: "object",
properties: {},
required: []
};
mcpProperties.forEach(prop => { mcpProperties.forEach(prop => {
const propSchema = { type: prop.type }; const propSchema = { type: prop.type };
if (prop.description) { if (prop.description) {
propSchema.description = prop.description; propSchema.description = prop.description;
} }
if ((prop.type === 'integer' || prop.type === 'number')) { if ((prop.type === 'integer' || prop.type === 'number')) {
if (prop.minimum !== undefined && prop.minimum !== '') { if (prop.minimum !== undefined && prop.minimum !== '') {
propSchema.minimum = prop.minimum; propSchema.minimum = prop.minimum;
@@ -362,20 +237,15 @@ function handleMcpSubmit(e) {
propSchema.maximum = prop.maximum; propSchema.maximum = prop.maximum;
} }
} }
inputSchema.properties[prop.name] = propSchema; inputSchema.properties[prop.name] = propSchema;
if (prop.required) { if (prop.required) {
inputSchema.required.push(prop.name); inputSchema.required.push(prop.name);
} }
}); });
if (inputSchema.required.length === 0) { if (inputSchema.required.length === 0) {
delete inputSchema.required; delete inputSchema.required;
} }
const tool = { name, description, inputSchema, mockResponse }; const tool = { name, description, inputSchema, mockResponse };
if (mcpEditingIndex !== null) { if (mcpEditingIndex !== null) {
mcpTools[mcpEditingIndex] = tool; mcpTools[mcpEditingIndex] = tool;
log(`已更新工具: ${name}`, 'success'); log(`已更新工具: ${name}`, 'success');
@@ -383,7 +253,6 @@ function handleMcpSubmit(e) {
mcpTools.push(tool); mcpTools.push(tool);
log(`已添加工具: ${name}`, 'success'); log(`已添加工具: ${name}`, 'success');
} }
saveMcpTools(); saveMcpTools();
renderMcpTools(); renderMcpTools();
closeMcpModal(); closeMcpModal();
@@ -433,11 +302,7 @@ function saveMcpTools() {
* 获取工具列表 * 获取工具列表
*/ */
export function getMcpTools() { export function getMcpTools() {
return mcpTools.map(tool => ({ return mcpTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
}));
} }
/** /**
@@ -451,26 +316,10 @@ function executeLive2DAction(toolName, toolArgs) {
const live2dManager = window.chatApp?.live2dManager; const live2dManager = window.chatApp?.live2dManager;
if (!live2dManager) { if (!live2dManager) {
log('Live2D管理器未初始化', 'error'); log('Live2D管理器未初始化', 'error');
return { return { success: false, error: 'Live2D管理器未初始化' };
success: false,
error: 'Live2D管理器未初始化'
};
} }
// 动作映射:工具名称到Live2D动作名称 // 动作映射:工具名称到Live2D动作名称
const actionMap = { 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' };
'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; let motionName;
if (toolName === 'live2d.action' && toolArgs?.action) { if (toolName === 'live2d.action' && toolArgs?.action) {
// 通用动作工具,使用指定的动作名称 // 通用动作工具,使用指定的动作名称
@@ -479,32 +328,17 @@ function executeLive2DAction(toolName, toolArgs) {
// 使用映射表查找对应动作名称 // 使用映射表查找对应动作名称
motionName = actionMap[toolName]; motionName = actionMap[toolName];
} }
if (!motionName) { if (!motionName) {
log(`未知的动作: ${toolName}`, 'error'); log(`未知的动作: ${toolName}`, 'error');
return { return { success: false, error: `未知的动作: ${toolName}` };
success: false,
error: `未知的动作: ${toolName}`
};
} }
// 触发Live2D动作 // 触发Live2D动作
live2dManager.motion(motionName); live2dManager.motion(motionName);
log(`Live2D动作已触发: ${motionName}`, 'success'); log(`Live2D动作已触发: ${motionName}`, 'success');
return { success: true, message: `虚拟人已执行动作: ${motionName}`, action: motionName, tool: toolName };
return {
success: true,
message: `虚拟人已执行动作: ${motionName}`,
action: motionName,
tool: toolName
};
} catch (error) { } catch (error) {
log(`执行Live2D动作失败: ${error.message}`, 'error'); log(`执行Live2D动作失败: ${error.message}`, 'error');
return { return { success: false, error: `执行动作失败: ${error.message}` };
success: false,
error: `执行动作失败: ${error.message}`
};
} }
} }
@@ -513,25 +347,18 @@ function executeLive2DAction(toolName, toolArgs) {
*/ */
export function executeMcpTool(toolName, toolArgs) { export function executeMcpTool(toolName, toolArgs) {
const tool = mcpTools.find(t => t.name === toolName); const tool = mcpTools.find(t => t.name === toolName);
if (!tool) { if (!tool) {
log(`未找到工具: ${toolName}`, 'error'); log(`未找到工具: ${toolName}`, 'error');
return { return { success: false, error: `未知工具: ${toolName}` };
success: false,
error: `未知工具: ${toolName}`
};
} }
// 处理Live2D动作工具 // 处理Live2D动作工具
if (toolName.startsWith('live2d.')) { if (toolName.startsWith('live2d.')) {
return executeLive2DAction(toolName, toolArgs); return executeLive2DAction(toolName, toolArgs);
} }
// 如果有模拟返回结果,使用它 // 如果有模拟返回结果,使用它
if (tool.mockResponse) { if (tool.mockResponse) {
// 替换模板变量 // 替换模板变量
let responseStr = JSON.stringify(tool.mockResponse); let responseStr = JSON.stringify(tool.mockResponse);
// 替换 ${paramName} 格式的变量 // 替换 ${paramName} 格式的变量
if (toolArgs) { if (toolArgs) {
Object.keys(toolArgs).forEach(key => { Object.keys(toolArgs).forEach(key => {
@@ -539,7 +366,6 @@ export function executeMcpTool(toolName, toolArgs) {
responseStr = responseStr.replace(regex, toolArgs[key]); responseStr = responseStr.replace(regex, toolArgs[key]);
}); });
} }
try { try {
const response = JSON.parse(responseStr); const response = JSON.parse(responseStr);
log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success'); log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success');
@@ -549,21 +375,10 @@ export function executeMcpTool(toolName, toolArgs) {
return tool.mockResponse; return tool.mockResponse;
} }
} }
// 没有模拟返回结果,返回默认成功消息 // 没有模拟返回结果,返回默认成功消息
log(`工具 ${toolName} 执行成功,返回默认结果`, 'success'); log(`工具 ${toolName} 执行成功,返回默认结果`, 'success');
return { return { success: true, message: `工具 ${toolName} 执行成功`, tool: toolName, arguments: toolArgs };
success: true,
message: `工具 ${toolName} 执行成功`,
tool: toolName,
arguments: toolArgs
};
} }
// 暴露全局方法供 HTML 内联事件调用 // 暴露全局方法供 HTML 内联事件调用
window.mcpModule = { window.mcpModule = { updateMcpProperty, deleteMcpProperty, editMcpTool, deleteMcpTool };
updateMcpProperty,
deleteMcpProperty,
editMcpTool,
deleteMcpTool
};
@@ -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', () => { describe('Live2D Action Tools', () => {
let mockLive2DManager, originalChatApp; let mockLive2DManager, originalChatApp;
beforeEach(() => { beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks(); vi.clearAllMocks();
// Save original chatApp
originalChatApp = window.chatApp; originalChatApp = window.chatApp;
// Mock Live2D manager
mockLive2DManager = { motion: vi.fn() }; mockLive2DManager = { motion: vi.fn() };
// Setup window.chatApp
window.chatApp = { live2dManager: mockLive2DManager }; window.chatApp = { live2dManager: mockLive2DManager };
// Mock localStorage
localStorage.getItem = vi.fn(() => null); 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'] } }]) })); 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: '' }; const mockContainer = { innerHTML: '', appendChild: vi.fn(), textContent: '' };
document.getElementById = vi.fn((id) => { 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 === 'mcpToolsContainer' || id === 'mcpPropertiesContainer') return mockContainer;
if (id === 'mcpToolsCount') return { textContent: '' }; if (id === 'mcpToolsCount') return { textContent: '' };
// Return null for other elements (they're checked with if statements)
return null; return null;
}); });
}); });
afterEach(() => { window.chatApp = originalChatApp; }); afterEach(() => {
// Clean up
window.chatApp = originalChatApp;
});
/**
* 测试 executeMcpTool - smile 动作
*/
test('should execute Live2D smile action', async () => { test('should execute Live2D smile action', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.smile', {}); const result = executeMcpTool('live2d.smile', {});
@@ -29,6 +51,9 @@ describe('Live2D Action Tools', () => {
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp'); expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp');
}); });
/**
* 测试 executeMcpTool - wave 动作
*/
test('should execute Live2D wave action', async () => { test('should execute Live2D wave action', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.wave', {}); const result = executeMcpTool('live2d.wave', {});
@@ -38,6 +63,9 @@ describe('Live2D Action Tools', () => {
expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap'); expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap');
}); });
/**
* 测试 executeMcpTool - 通用动作工具
*/
test('should handle generic action tool', async () => { test('should handle generic action tool', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.action', { action: 'FlickDown' }); const result = executeMcpTool('live2d.action', { action: 'FlickDown' });
@@ -47,6 +75,9 @@ describe('Live2D Action Tools', () => {
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown'); expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown');
}); });
/**
* 测试 executeMcpTool - Live2D管理器未初始化
*/
test('should handle missing Live2D manager gracefully', async () => { test('should handle missing Live2D manager gracefully', async () => {
await initMcpTools(); await initMcpTools();
window.chatApp = null; window.chatApp = null;
@@ -55,10 +86,13 @@ describe('Live2D Action Tools', () => {
expect(result.error).toContain('Live2D管理器未初始化'); expect(result.error).toContain('Live2D管理器未初始化');
}); });
/**
* 测试 executeMcpTool - 未知的工具
*/
test('should handle unknown tool gracefully', async () => { test('should handle unknown tool gracefully', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('unknown.tool', {}); const result = executeMcpTool('unknown.tool', {});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('未知工具'); expect(result.error).toContain('未知工具');
}); });
}); });
@@ -12,61 +12,21 @@ describe('Live2D Action Tools', () => {
beforeEach(() => { beforeEach(() => {
// Reset mocks before each test // Reset mocks before each test
vi.clearAllMocks(); vi.clearAllMocks();
// Mock Live2D manager // Mock Live2D manager
mockLive2DManager = { mockLive2DManager = { motion: vi.fn() };
motion: vi.fn()
};
// Setup window.chatApp // Setup window.chatApp
global.window.chatApp = { global.window.chatApp = { live2dManager: mockLive2DManager };
live2dManager: mockLive2DManager
};
// Mock localStorage // Mock localStorage
global.localStorage.getItem = vi.fn(() => null); global.localStorage.getItem = vi.fn(() => null);
// Mock fetch for default-mcp-tools.json // Mock fetch for default-mcp-tools.json
global.fetch = vi.fn(() => 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'] } }]) }));
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 // Mock DOM elements
global.document.getElementById = vi.fn((id) => { global.document.getElementById = vi.fn((id) => {
if (id === 'mcpToolsContainer') { if (id === 'mcpToolsContainer') {
return { return { innerHTML: '', appendChild: vi.fn() };
innerHTML: '',
appendChild: vi.fn()
};
} }
if (id === 'mcpToolsCount') { if (id === 'mcpToolsCount') {
return { return { textContent: '' };
textContent: ''
};
} }
return null; return null;
}); });
@@ -82,9 +42,7 @@ describe('Live2D Action Tools', () => {
*/ */
test('should execute Live2D smile action', async () => { test('should execute Live2D smile action', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.smile', {}); const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.action).toBe('FlickUp'); expect(result.action).toBe('FlickUp');
expect(result.tool).toBe('live2d.smile'); expect(result.tool).toBe('live2d.smile');
@@ -96,9 +54,7 @@ describe('Live2D Action Tools', () => {
*/ */
test('should execute Live2D wave action', async () => { test('should execute Live2D wave action', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.wave', {}); const result = executeMcpTool('live2d.wave', {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.action).toBe('Tap'); expect(result.action).toBe('Tap');
expect(result.tool).toBe('live2d.wave'); expect(result.tool).toBe('live2d.wave');
@@ -110,9 +66,7 @@ describe('Live2D Action Tools', () => {
*/ */
test('should handle generic action tool', async () => { test('should handle generic action tool', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.action', { action: 'FlickDown' }); const result = executeMcpTool('live2d.action', { action: 'FlickDown' });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.action).toBe('FlickDown'); expect(result.action).toBe('FlickDown');
expect(result.tool).toBe('live2d.action'); expect(result.tool).toBe('live2d.action');
@@ -124,11 +78,8 @@ describe('Live2D Action Tools', () => {
*/ */
test('should handle missing Live2D manager gracefully', async () => { test('should handle missing Live2D manager gracefully', async () => {
await initMcpTools(); await initMcpTools();
global.window.chatApp = null; global.window.chatApp = null;
const result = executeMcpTool('live2d.smile', {}); const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Live2D管理器未初始化'); expect(result.error).toContain('Live2D管理器未初始化');
}); });
@@ -138,25 +89,12 @@ describe('Live2D Action Tools', () => {
*/ */
test('should handle unknown action gracefully', async () => { test('should handle unknown action gracefully', async () => {
await initMcpTools(); await initMcpTools();
// Add an unknown live2d tool to the list // Add an unknown live2d tool to the list
const tools = await global.fetch().then(res => res.json()); const tools = await global.fetch().then(res => res.json());
tools.push({ tools.push({ name: 'live2d.unknown', description: 'Unknown action', inputSchema: { type: 'object', properties: {} } });
name: 'live2d.unknown', global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve(tools) }));
description: 'Unknown action',
inputSchema: { type: 'object', properties: {} }
});
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve(tools)
})
);
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('live2d.unknown', {}); const result = executeMcpTool('live2d.unknown', {});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('未知的动作'); expect(result.error).toContain('未知的动作');
}); });
@@ -166,9 +104,7 @@ describe('Live2D Action Tools', () => {
*/ */
test('should handle unknown tool gracefully', async () => { test('should handle unknown tool gracefully', async () => {
await initMcpTools(); await initMcpTools();
const result = executeMcpTool('unknown.tool', {}); const result = executeMcpTool('unknown.tool', {});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('未知工具'); expect(result.error).toContain('未知工具');
}); });
@@ -178,43 +114,20 @@ describe('Live2D Action Tools', () => {
*/ */
test('should handle other action mappings', async () => { test('should handle other action mappings', async () => {
await initMcpTools(); 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 // Add these tools to the mock
const tools = await global.fetch().then(res => res.json()); const tools = await global.fetch().then(res => res.json());
actionMappings.forEach(mapping => { actionMappings.forEach(mapping => {
tools.push({ tools.push({ name: mapping.tool, description: `Test ${mapping.tool}`, inputSchema: { type: 'object', properties: {} } });
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(); await initMcpTools();
for (const mapping of actionMappings) { for (const mapping of actionMappings) {
mockLive2DManager.motion.mockClear(); mockLive2DManager.motion.mockClear();
const result = executeMcpTool(mapping.tool, {}); const result = executeMcpTool(mapping.tool, {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.action).toBe(mapping.expectedAction); expect(result.action).toBe(mapping.expectedAction);
expect(mockLive2DManager.motion).toHaveBeenCalledWith(mapping.expectedAction); expect(mockLive2DManager.motion).toHaveBeenCalledWith(mapping.expectedAction);
} }
}); });
}); });
+3 -13
View File
@@ -242,7 +242,6 @@ class UIController {
// Update record button state // Update record button state
if (recordBtn) { if (recordBtn) {
const microphoneAvailable = window.microphoneAvailable !== false; const microphoneAvailable = window.microphoneAvailable !== false;
if (isConnected && microphoneAvailable) { if (isConnected && microphoneAvailable) {
recordBtn.disabled = false; recordBtn.disabled = false;
recordBtn.title = '开始录音'; recordBtn.title = '开始录音';
@@ -252,9 +251,7 @@ class UIController {
} else { } else {
recordBtn.disabled = true; recordBtn.disabled = true;
if (!microphoneAvailable) { if (!microphoneAvailable) {
recordBtn.title = window.isHttpNonLocalhost recordBtn.title = window.isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用';
? '当前由于是http访问,无法录音,只能用文字交互'
: '麦克风不可用';
} else { } else {
recordBtn.title = '请先连接服务器'; recordBtn.title = '请先连接服务器';
} }
@@ -277,8 +274,7 @@ class UIController {
recordBtn.classList.remove('recording'); recordBtn.classList.remove('recording');
} }
// Only enable button when microphone is available // Only enable button when microphone is available
const microphoneAvailable = window.microphoneAvailable !== false; recordBtn.disabled = window.microphoneAvailable === false;
recordBtn.disabled = !microphoneAvailable;
} }
} }
@@ -289,19 +285,13 @@ class UIController {
*/ */
updateMicrophoneAvailability(isAvailable, isHttpNonLocalhost) { updateMicrophoneAvailability(isAvailable, isHttpNonLocalhost) {
const recordBtn = document.getElementById('recordBtn'); const recordBtn = document.getElementById('recordBtn');
if (!recordBtn) return; if (!recordBtn) return;
if (!isAvailable) { if (!isAvailable) {
// Disable record button // Disable record button
recordBtn.disabled = true; recordBtn.disabled = true;
// Update button text and title // Update button text and title
recordBtn.querySelector('.btn-text').textContent = '录音'; recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.title = isHttpNonLocalhost recordBtn.title = isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用';
? '当前由于是http访问,无法录音,只能用文字交互'
: '麦克风不可用';
// Display notification message in chat // Display notification message in chat
if (isHttpNonLocalhost) { if (isHttpNonLocalhost) {
this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false); this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false);