mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 01:53:53 +08:00
Merge pull request #2533 from xinnan-tech/fix-test-page
update:优化音频测试页面的样式
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,503 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>小智语音服务测试</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 8px 15px;
|
|
||||||
margin-right: 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #4285f4;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background-color: #3367d6;
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
background-color: #cccccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
#status {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
#scriptStatus {
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: block;
|
|
||||||
z-index: 100;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
#scriptStatus.info {
|
|
||||||
background-color: #e8f0fe;
|
|
||||||
color: #4285f4;
|
|
||||||
border-left: 4px solid #4285f4;
|
|
||||||
}
|
|
||||||
#scriptStatus.success {
|
|
||||||
background-color: #e6f4ea;
|
|
||||||
color: #0f9d58;
|
|
||||||
border-left: 4px solid #0f9d58;
|
|
||||||
}
|
|
||||||
#scriptStatus.error {
|
|
||||||
background-color: #fce8e6;
|
|
||||||
color: #db4437;
|
|
||||||
border-left: 4px solid #db4437;
|
|
||||||
}
|
|
||||||
#debugInfo {
|
|
||||||
margin-top: 20px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#showDebug {
|
|
||||||
margin-top: 10px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
color: #444;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
#audioMeter {
|
|
||||||
margin-top: 10px;
|
|
||||||
height: 20px;
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#audioLevel {
|
|
||||||
height: 100%;
|
|
||||||
width: 0%;
|
|
||||||
background-color: #4285f4;
|
|
||||||
transition: width 0.1s;
|
|
||||||
}
|
|
||||||
.conversation {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
background-color: white;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 80%;
|
|
||||||
}
|
|
||||||
.user {
|
|
||||||
background-color: #e2f2ff;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: 10px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.server {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
margin-right: auto;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
#serverUrl {
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 60%;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
#messageInput {
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 70%;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h2>小智语音服务测试</h2>
|
|
||||||
|
|
||||||
<div id="scriptStatus" class="info">正在加载Opus库...</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>WebSocket连接 <span id="connectionStatus">未连接</span></h3>
|
|
||||||
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
|
||||||
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/" placeholder="WebSocket服务器地址">
|
|
||||||
<button id="connectButton">连接</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>录音测试</h3>
|
|
||||||
<button id="initAudio" style="background-color: #34a853;">初始化音频</button>
|
|
||||||
<button id="testMic" style="background-color: #fbbc05;">测试麦克风</button>
|
|
||||||
<p></p>
|
|
||||||
<button id="start" disabled>开始录音</button>
|
|
||||||
<button id="stop" disabled>停止录音</button>
|
|
||||||
<button id="play" disabled>播放录音</button>
|
|
||||||
<p>录音状态: <span id="status">待机,正在初始化...</span></p>
|
|
||||||
<div id="audioMeter">
|
|
||||||
<div id="audioLevel"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>文本消息</h3>
|
|
||||||
<div style="display: flex; align-items: center;">
|
|
||||||
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
|
|
||||||
<button id="sendTextButton" disabled>发送</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>会话记录</h3>
|
|
||||||
<div id="conversation" class="conversation"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="showDebug">显示/隐藏调试信息</button>
|
|
||||||
<div id="debugInfo"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 定义全局变量以跟踪库加载状态
|
|
||||||
window.opusLoaded = false;
|
|
||||||
window.startButton = document.getElementById("start");
|
|
||||||
window.stopButton = document.getElementById("stop");
|
|
||||||
window.playButton = document.getElementById("play");
|
|
||||||
window.statusLabel = document.getElementById("status");
|
|
||||||
window.debugInfo = document.getElementById("debugInfo");
|
|
||||||
window.audioContextReady = false;
|
|
||||||
window.testMicActive = false;
|
|
||||||
|
|
||||||
// 显示/隐藏调试信息
|
|
||||||
document.getElementById("showDebug").addEventListener("click", function() {
|
|
||||||
if (debugInfo.style.display === "none" || !debugInfo.style.display) {
|
|
||||||
debugInfo.style.display = "block";
|
|
||||||
this.textContent = "隐藏调试信息";
|
|
||||||
} else {
|
|
||||||
debugInfo.style.display = "none";
|
|
||||||
this.textContent = "显示调试信息";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加初始化音频按钮事件
|
|
||||||
document.getElementById("initAudio").addEventListener("click", function() {
|
|
||||||
initializeAudioSystem();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加测试麦克风按钮事件
|
|
||||||
document.getElementById("testMic").addEventListener("click", function() {
|
|
||||||
if (window.testMicActive) {
|
|
||||||
stopMicTest();
|
|
||||||
} else {
|
|
||||||
startMicTest();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化音频系统
|
|
||||||
function initializeAudioSystem() {
|
|
||||||
try {
|
|
||||||
log("初始化音频系统...");
|
|
||||||
// 创建临时AudioContext来触发用户授权
|
|
||||||
const tempContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: 16000,
|
|
||||||
latencyHint: 'interactive'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建振荡器并播放短促的声音
|
|
||||||
const oscillator = tempContext.createOscillator();
|
|
||||||
const gain = tempContext.createGain();
|
|
||||||
gain.gain.value = 0.1; // 很小的音量
|
|
||||||
oscillator.connect(gain);
|
|
||||||
gain.connect(tempContext.destination);
|
|
||||||
oscillator.frequency.value = 440; // A4
|
|
||||||
oscillator.start();
|
|
||||||
|
|
||||||
// 0.2秒后停止
|
|
||||||
setTimeout(() => {
|
|
||||||
oscillator.stop();
|
|
||||||
// 关闭上下文
|
|
||||||
tempContext.close().then(() => {
|
|
||||||
log("音频系统初始化成功", "success");
|
|
||||||
updateScriptStatus("音频系统已激活", "success");
|
|
||||||
document.getElementById("initAudio").disabled = true;
|
|
||||||
document.getElementById("initAudio").textContent = "音频已初始化";
|
|
||||||
window.audioContextReady = true;
|
|
||||||
|
|
||||||
// 如果Opus已加载,启用开始录音按钮
|
|
||||||
if (window.opusLoaded) {
|
|
||||||
startButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
} catch (err) {
|
|
||||||
log("初始化音频系统失败: " + err.message, "error");
|
|
||||||
updateScriptStatus("初始化音频失败: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试麦克风
|
|
||||||
function startMicTest() {
|
|
||||||
const audioMeter = document.getElementById("audioMeter");
|
|
||||||
const audioLevel = document.getElementById("audioLevel");
|
|
||||||
const testMicBtn = document.getElementById("testMic");
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
log("浏览器不支持麦克风访问", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log("开始麦克风测试...");
|
|
||||||
audioMeter.style.display = "block";
|
|
||||||
testMicBtn.textContent = "停止测试";
|
|
||||||
testMicBtn.style.backgroundColor = "#ea4335";
|
|
||||||
window.testMicActive = true;
|
|
||||||
|
|
||||||
// 创建音频上下文
|
|
||||||
const testContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
window.testContext = testContext;
|
|
||||||
|
|
||||||
// 获取麦克风权限
|
|
||||||
navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(stream => {
|
|
||||||
log("已获取麦克风访问权限", "success");
|
|
||||||
|
|
||||||
// 保存流以便稍后关闭
|
|
||||||
window.testStream = stream;
|
|
||||||
|
|
||||||
// 创建音频分析器
|
|
||||||
const source = testContext.createMediaStreamSource(stream);
|
|
||||||
const analyser = testContext.createAnalyser();
|
|
||||||
analyser.fftSize = 256;
|
|
||||||
source.connect(analyser);
|
|
||||||
|
|
||||||
// 创建音量显示更新函数
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
|
|
||||||
function updateMeter() {
|
|
||||||
if (!window.testMicActive) return;
|
|
||||||
|
|
||||||
analyser.getByteFrequencyData(dataArray);
|
|
||||||
|
|
||||||
// 计算音量级别 (0-100)
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
sum += dataArray[i];
|
|
||||||
}
|
|
||||||
const average = sum / bufferLength;
|
|
||||||
const level = Math.min(100, Math.max(0, average * 2));
|
|
||||||
|
|
||||||
// 更新音量计
|
|
||||||
audioLevel.style.width = level + "%";
|
|
||||||
|
|
||||||
// 如果有声音,记录日志
|
|
||||||
if (level > 10) {
|
|
||||||
log(`检测到声音: ${level.toFixed(1)}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 循环更新
|
|
||||||
window.testMicAnimationFrame = requestAnimationFrame(updateMeter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始更新
|
|
||||||
updateMeter();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
log("麦克风测试失败: " + err.message, "error");
|
|
||||||
window.testMicActive = false;
|
|
||||||
testMicBtn.textContent = "测试麦克风";
|
|
||||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
|
||||||
audioMeter.style.display = "none";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止麦克风测试
|
|
||||||
function stopMicTest() {
|
|
||||||
const audioMeter = document.getElementById("audioMeter");
|
|
||||||
const testMicBtn = document.getElementById("testMic");
|
|
||||||
|
|
||||||
log("停止麦克风测试");
|
|
||||||
window.testMicActive = false;
|
|
||||||
testMicBtn.textContent = "测试麦克风";
|
|
||||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
|
||||||
|
|
||||||
// 停止分析器动画
|
|
||||||
if (window.testMicAnimationFrame) {
|
|
||||||
cancelAnimationFrame(window.testMicAnimationFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止麦克风流
|
|
||||||
if (window.testStream) {
|
|
||||||
window.testStream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭测试上下文
|
|
||||||
if (window.testContext) {
|
|
||||||
window.testContext.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏音量计
|
|
||||||
audioMeter.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加调试日志
|
|
||||||
function log(message, type = "info") {
|
|
||||||
console.log(message);
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
const entry = document.createElement("div");
|
|
||||||
entry.textContent = `[${time}] ${message}`;
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
entry.style.color = "#db4437";
|
|
||||||
} else if (type === "success") {
|
|
||||||
entry.style.color = "#0f9d58";
|
|
||||||
}
|
|
||||||
|
|
||||||
debugInfo.appendChild(entry);
|
|
||||||
debugInfo.scrollTop = debugInfo.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新脚本状态显示
|
|
||||||
function updateScriptStatus(message, type) {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) {
|
|
||||||
statusElement.textContent = message;
|
|
||||||
statusElement.className = type;
|
|
||||||
statusElement.style.display = 'block';
|
|
||||||
}
|
|
||||||
log(message, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查Opus库是否已加载
|
|
||||||
function checkOpusLoaded() {
|
|
||||||
try {
|
|
||||||
// 检查Module是否存在(本地库导出的全局变量)
|
|
||||||
if (typeof Module === 'undefined') {
|
|
||||||
log("Module对象不存在", "error");
|
|
||||||
throw new Error('Opus库未加载,Module对象不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录Module对象结构以便调试
|
|
||||||
log("Module对象结构: " + Object.keys(Module).join(", "));
|
|
||||||
|
|
||||||
// 尝试使用全局Module函数
|
|
||||||
if (typeof Module._opus_decoder_get_size === 'function') {
|
|
||||||
window.ModuleInstance = Module;
|
|
||||||
log('Opus库加载成功(使用全局Module)', "success");
|
|
||||||
updateScriptStatus('Opus库加载成功', 'success');
|
|
||||||
|
|
||||||
// 启用开始录音按钮
|
|
||||||
startButton.disabled = false;
|
|
||||||
statusLabel.textContent = "待机";
|
|
||||||
|
|
||||||
// 3秒后隐藏状态
|
|
||||||
setTimeout(() => {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) statusElement.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
window.opusLoaded = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试使用Module.instance
|
|
||||||
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
|
||||||
window.ModuleInstance = Module.instance;
|
|
||||||
log('Opus库加载成功(使用Module.instance)', "success");
|
|
||||||
updateScriptStatus('Opus库加载成功', 'success');
|
|
||||||
|
|
||||||
// 启用开始录音按钮
|
|
||||||
startButton.disabled = false;
|
|
||||||
statusLabel.textContent = "待机";
|
|
||||||
|
|
||||||
// 3秒后隐藏状态
|
|
||||||
setTimeout(() => {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) statusElement.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
window.opusLoaded = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有其他导出方式
|
|
||||||
log("Module上可用方法: " + Object.getOwnPropertyNames(Module).filter(prop => typeof Module[prop] === 'function').join(", "));
|
|
||||||
|
|
||||||
if (typeof Module.onRuntimeInitialized === 'function' || typeof Module.onRuntimeInitialized === 'undefined') {
|
|
||||||
log("Module.onRuntimeInitialized 尚未执行,等待模块初始化完成...");
|
|
||||||
// Module可能未完成初始化,注册回调
|
|
||||||
Module.onRuntimeInitialized = function() {
|
|
||||||
log("Opus库运行时初始化完成,重新检查");
|
|
||||||
checkOpusLoaded();
|
|
||||||
};
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
|
||||||
} catch (err) {
|
|
||||||
log(`Opus库加载失败: ${err.message}`, "error");
|
|
||||||
updateScriptStatus(`Opus库加载失败: ${err.message}`, 'error');
|
|
||||||
statusLabel.textContent = "错误:Opus库加载失败";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载完成后检查浏览器能力和Opus库
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
log("页面加载完成,开始初始化...");
|
|
||||||
updateScriptStatus('正在初始化录音环境...', 'info');
|
|
||||||
|
|
||||||
// 延迟一小段时间再检查,确保库有时间加载
|
|
||||||
setTimeout(function checkAndRetry() {
|
|
||||||
if (!checkOpusLoaded()) {
|
|
||||||
// 如果加载失败,5秒后重试
|
|
||||||
log("Opus库加载失败,5秒后重试...");
|
|
||||||
updateScriptStatus('Opus库加载失败,正在重试...', 'error');
|
|
||||||
setTimeout(checkAndRetry, 5000);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 防止按钮误操作
|
|
||||||
document.getElementById("stop").disabled = true;
|
|
||||||
document.getElementById("play").disabled = true;
|
|
||||||
</script>
|
|
||||||
<script src="./../libopus.js"></script>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"name": "self.get_device_status",
|
|
||||||
"description": "Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\nUse this tool for: \n1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
},
|
|
||||||
"mockResponse": {
|
|
||||||
"audio_speaker": {
|
|
||||||
"volume": 50,
|
|
||||||
"muted": false
|
|
||||||
},
|
|
||||||
"screen": {
|
|
||||||
"brightness": 80,
|
|
||||||
"theme": "light"
|
|
||||||
},
|
|
||||||
"battery": {
|
|
||||||
"level": 85,
|
|
||||||
"charging": false
|
|
||||||
},
|
|
||||||
"network": {
|
|
||||||
"connected": true,
|
|
||||||
"type": "wifi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "self.audio_speaker.set_volume",
|
|
||||||
"description": "Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"volume": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"volume"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mockResponse": {
|
|
||||||
"success": true,
|
|
||||||
"volume": "${volume}",
|
|
||||||
"message": "音量已设置为 ${volume}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "self.screen.set_brightness",
|
|
||||||
"description": "Set the brightness of the screen.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"brightness": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"brightness"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mockResponse": {
|
|
||||||
"success": true,
|
|
||||||
"brightness": "${brightness}",
|
|
||||||
"message": "亮度已设置为 ${brightness}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlockingQueue from './utils/BlockingQueue.js';
|
import BlockingQueue from './utils/blockingQueue.js';
|
||||||
import { log } from './utils/logger.js';
|
import { log } from './utils/logger.js';
|
||||||
|
|
||||||
// 音频流播放上下文类
|
// 音频流播放上下文类
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ const logContainer = document.getElementById('logContainer');
|
|||||||
let visualizerCanvas = document.getElementById('audioVisualizer');
|
let visualizerCanvas = document.getElementById('audioVisualizer');
|
||||||
|
|
||||||
// ota 是否连接成功,修改成对应的样式
|
// ota 是否连接成功,修改成对应的样式
|
||||||
export function otaStatusStyle (flan) {
|
export function otaStatusStyle(flan) {
|
||||||
if(flan){
|
if (flan) {
|
||||||
document.getElementById('otaStatus').textContent = 'ota已连接';
|
document.getElementById('otaStatus').textContent = 'OTA已连接';
|
||||||
document.getElementById('otaStatus').style.color = 'green';
|
document.getElementById('otaStatus').style.color = 'green';
|
||||||
}else{
|
} else {
|
||||||
document.getElementById('otaStatus').textContent = 'ota未连接';
|
document.getElementById('otaStatus').textContent = 'OTA未连接';
|
||||||
document.getElementById('otaStatus').style.color = 'red';
|
document.getElementById('otaStatus').style.color = 'red';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ota 是否连接成功,修改成对应的样式
|
// ota 是否连接成功,修改成对应的样式
|
||||||
export function getLogContainer (flan) {
|
export function getLogContainer(flan) {
|
||||||
return logContainer;
|
return logContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新Opus库状态显示
|
// 更新Opus库状态显示
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { log } from './utils/logger.js';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MCP 工具管理逻辑
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 全局变量
|
||||||
|
let mcpTools = [];
|
||||||
|
let mcpEditingIndex = null;
|
||||||
|
let mcpProperties = [];
|
||||||
|
let websocket = null; // 将从外部设置
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 WebSocket 实例
|
||||||
|
* @param {WebSocket} ws - WebSocket 连接实例
|
||||||
|
*/
|
||||||
|
export function setWebSocket(ws) {
|
||||||
|
websocket = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 MCP 工具
|
||||||
|
*/
|
||||||
|
export async function initMcpTools() {
|
||||||
|
// 加载默认工具数据
|
||||||
|
const defaultMcpTools = await fetch("js/default-mcp-tools.json").then(res => res.json());
|
||||||
|
|
||||||
|
const savedTools = localStorage.getItem('mcpTools');
|
||||||
|
if (savedTools) {
|
||||||
|
try {
|
||||||
|
mcpTools = JSON.parse(savedTools);
|
||||||
|
} catch (e) {
|
||||||
|
log('加载MCP工具失败,使用默认工具', 'warning');
|
||||||
|
mcpTools = [...defaultMcpTools];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mcpTools = [...defaultMcpTools];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMcpTools();
|
||||||
|
setupMcpEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染工具列表
|
||||||
|
*/
|
||||||
|
function renderMcpTools() {
|
||||||
|
const container = document.getElementById('mcpToolsContainer');
|
||||||
|
const countSpan = document.getElementById('mcpToolsCount');
|
||||||
|
|
||||||
|
countSpan.textContent = `${mcpTools.length} 个工具`;
|
||||||
|
|
||||||
|
if (mcpTools.length === 0) {
|
||||||
|
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = mcpTools.map((tool, index) => {
|
||||||
|
const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0;
|
||||||
|
const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0;
|
||||||
|
const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mcp-tool-card">
|
||||||
|
<div class="mcp-tool-header">
|
||||||
|
<div class="mcp-tool-name">${tool.name}</div>
|
||||||
|
<div class="mcp-tool-actions">
|
||||||
|
<button onclick="window.mcpModule.editMcpTool(${index})"
|
||||||
|
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #2196f3; color: white; cursor: pointer; font-size: 12px;">
|
||||||
|
✏️ 编辑
|
||||||
|
</button>
|
||||||
|
<button onclick="window.mcpModule.deleteMcpTool(${index})"
|
||||||
|
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #f44336; color: white; cursor: pointer; font-size: 12px;">
|
||||||
|
🗑️ 删除
|
||||||
|
</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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染参数列表
|
||||||
|
*/
|
||||||
|
function renderMcpProperties() {
|
||||||
|
const container = document.getElementById('mcpPropertiesContainer');
|
||||||
|
|
||||||
|
if (mcpProperties.length === 0) {
|
||||||
|
container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>';
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加参数
|
||||||
|
*/
|
||||||
|
function addMcpProperty() {
|
||||||
|
mcpProperties.push({
|
||||||
|
name: `param_${mcpProperties.length + 1}`,
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
renderMcpProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新参数
|
||||||
|
*/
|
||||||
|
function updateMcpProperty(index, field, value) {
|
||||||
|
if (field === 'name') {
|
||||||
|
const isDuplicate = mcpProperties.some((p, i) => i !== index && p.name === value);
|
||||||
|
if (isDuplicate) {
|
||||||
|
alert('参数名称已存在,请使用不同的名称');
|
||||||
|
renderMcpProperties();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpProperties[index][field] = value;
|
||||||
|
|
||||||
|
if (field === 'type' && value !== 'integer' && value !== 'number') {
|
||||||
|
delete mcpProperties[index].minimum;
|
||||||
|
delete mcpProperties[index].maximum;
|
||||||
|
renderMcpProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除参数
|
||||||
|
*/
|
||||||
|
function deleteMcpProperty(index) {
|
||||||
|
mcpProperties.splice(index, 1);
|
||||||
|
renderMcpProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件监听
|
||||||
|
*/
|
||||||
|
function setupMcpEventListeners() {
|
||||||
|
const toggleBtn = document.getElementById('toggleMcpTools');
|
||||||
|
const panel = document.getElementById('mcpToolsPanel');
|
||||||
|
const addBtn = document.getElementById('addMcpToolBtn');
|
||||||
|
const modal = document.getElementById('mcpToolModal');
|
||||||
|
const closeBtn = document.getElementById('closeMcpModalBtn');
|
||||||
|
const cancelBtn = document.getElementById('cancelMcpBtn');
|
||||||
|
const form = document.getElementById('mcpToolForm');
|
||||||
|
const addPropertyBtn = document.getElementById('addMcpPropertyBtn');
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
const isExpanded = panel.classList.contains('expanded');
|
||||||
|
panel.classList.toggle('expanded');
|
||||||
|
toggleBtn.textContent = isExpanded ? '展开' : '收起';
|
||||||
|
});
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => openMcpModal());
|
||||||
|
closeBtn.addEventListener('click', closeMcpModal);
|
||||||
|
cancelBtn.addEventListener('click', closeMcpModal);
|
||||||
|
addPropertyBtn.addEventListener('click', addMcpProperty);
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) closeMcpModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', handleMcpSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开模态框
|
||||||
|
*/
|
||||||
|
function openMcpModal(index = null) {
|
||||||
|
const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
|
||||||
|
if (isConnected) {
|
||||||
|
alert('WebSocket 已连接,无法编辑工具');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpEditingIndex = index;
|
||||||
|
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||||
|
errorContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (index !== null) {
|
||||||
|
document.getElementById('mcpModalTitle').textContent = '编辑工具';
|
||||||
|
const tool = mcpTools[index];
|
||||||
|
document.getElementById('mcpToolName').value = tool.name;
|
||||||
|
document.getElementById('mcpToolDescription').value = tool.description;
|
||||||
|
document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : '';
|
||||||
|
|
||||||
|
mcpProperties = [];
|
||||||
|
const schema = tool.inputSchema;
|
||||||
|
if (schema.properties) {
|
||||||
|
Object.keys(schema.properties).forEach(key => {
|
||||||
|
const prop = schema.properties[key];
|
||||||
|
mcpProperties.push({
|
||||||
|
name: key,
|
||||||
|
type: prop.type || 'string',
|
||||||
|
minimum: prop.minimum,
|
||||||
|
maximum: prop.maximum,
|
||||||
|
description: prop.description || '',
|
||||||
|
required: schema.required && schema.required.includes(key)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('mcpModalTitle').textContent = '添加工具';
|
||||||
|
document.getElementById('mcpToolForm').reset();
|
||||||
|
mcpProperties = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMcpProperties();
|
||||||
|
document.getElementById('mcpToolModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭模态框
|
||||||
|
*/
|
||||||
|
function closeMcpModal() {
|
||||||
|
document.getElementById('mcpToolModal').style.display = 'none';
|
||||||
|
mcpEditingIndex = null;
|
||||||
|
document.getElementById('mcpToolForm').reset();
|
||||||
|
mcpProperties = [];
|
||||||
|
document.getElementById('mcpErrorContainer').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理表单提交
|
||||||
|
*/
|
||||||
|
function handleMcpSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||||
|
errorContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const name = document.getElementById('mcpToolName').value.trim();
|
||||||
|
const description = document.getElementById('mcpToolDescription').value.trim();
|
||||||
|
const mockResponseText = document.getElementById('mcpMockResponse').value.trim();
|
||||||
|
|
||||||
|
// 检查名称重复
|
||||||
|
const isDuplicate = mcpTools.some((tool, index) =>
|
||||||
|
tool.name === name && index !== mcpEditingIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
showMcpError('工具名称已存在,请使用不同的名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析模拟返回结果
|
||||||
|
let mockResponse = null;
|
||||||
|
if (mockResponseText) {
|
||||||
|
try {
|
||||||
|
mockResponse = JSON.parse(mockResponseText);
|
||||||
|
} catch (e) {
|
||||||
|
showMcpError('模拟返回结果不是有效的 JSON 格式: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 inputSchema
|
||||||
|
const inputSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: []
|
||||||
|
};
|
||||||
|
|
||||||
|
mcpProperties.forEach(prop => {
|
||||||
|
const propSchema = { type: prop.type };
|
||||||
|
|
||||||
|
if (prop.description) {
|
||||||
|
propSchema.description = prop.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((prop.type === 'integer' || prop.type === 'number')) {
|
||||||
|
if (prop.minimum !== undefined && prop.minimum !== '') {
|
||||||
|
propSchema.minimum = prop.minimum;
|
||||||
|
}
|
||||||
|
if (prop.maximum !== undefined && prop.maximum !== '') {
|
||||||
|
propSchema.maximum = prop.maximum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputSchema.properties[prop.name] = propSchema;
|
||||||
|
|
||||||
|
if (prop.required) {
|
||||||
|
inputSchema.required.push(prop.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inputSchema.required.length === 0) {
|
||||||
|
delete inputSchema.required;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = { name, description, inputSchema, mockResponse };
|
||||||
|
|
||||||
|
if (mcpEditingIndex !== null) {
|
||||||
|
mcpTools[mcpEditingIndex] = tool;
|
||||||
|
log(`已更新工具: ${name}`, 'success');
|
||||||
|
} else {
|
||||||
|
mcpTools.push(tool);
|
||||||
|
log(`已添加工具: ${name}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMcpTools();
|
||||||
|
renderMcpTools();
|
||||||
|
closeMcpModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示错误
|
||||||
|
*/
|
||||||
|
function showMcpError(message) {
|
||||||
|
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||||
|
errorContainer.innerHTML = `<div class="mcp-error">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑工具
|
||||||
|
*/
|
||||||
|
function editMcpTool(index) {
|
||||||
|
openMcpModal(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除工具
|
||||||
|
*/
|
||||||
|
function deleteMcpTool(index) {
|
||||||
|
const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
|
||||||
|
if (isConnected) {
|
||||||
|
alert('WebSocket 已连接,无法编辑工具');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirm(`确定要删除工具 "${mcpTools[index].name}" 吗?`)) {
|
||||||
|
const toolName = mcpTools[index].name;
|
||||||
|
mcpTools.splice(index, 1);
|
||||||
|
saveMcpTools();
|
||||||
|
renderMcpTools();
|
||||||
|
log(`已删除工具: ${toolName}`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存工具
|
||||||
|
*/
|
||||||
|
function saveMcpTools() {
|
||||||
|
localStorage.setItem('mcpTools', JSON.stringify(mcpTools));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工具列表
|
||||||
|
*/
|
||||||
|
export function getMcpTools() {
|
||||||
|
return mcpTools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行工具调用
|
||||||
|
*/
|
||||||
|
export function executeMcpTool(toolName, toolArgs) {
|
||||||
|
const tool = mcpTools.find(t => t.name === toolName);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
log(`未找到工具: ${toolName}`, 'error');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `未知工具: ${toolName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有模拟返回结果,使用它
|
||||||
|
if (tool.mockResponse) {
|
||||||
|
// 替换模板变量
|
||||||
|
let responseStr = JSON.stringify(tool.mockResponse);
|
||||||
|
|
||||||
|
// 替换 ${paramName} 格式的变量
|
||||||
|
if (toolArgs) {
|
||||||
|
Object.keys(toolArgs).forEach(key => {
|
||||||
|
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
|
||||||
|
responseStr = responseStr.replace(regex, toolArgs[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(responseStr);
|
||||||
|
log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success');
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
log(`解析模拟返回结果失败: ${e.message}`, 'error');
|
||||||
|
return tool.mockResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有模拟返回结果,返回默认成功消息
|
||||||
|
log(`工具 ${toolName} 执行成功,返回默认结果`, 'success');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `工具 ${toolName} 执行成功`,
|
||||||
|
tool: toolName,
|
||||||
|
arguments: toolArgs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露全局方法供 HTML 内联事件调用
|
||||||
|
window.mcpModule = {
|
||||||
|
updateMcpProperty,
|
||||||
|
deleteMcpProperty,
|
||||||
|
editMcpTool,
|
||||||
|
deleteMcpTool
|
||||||
|
};
|
||||||
@@ -1,670 +0,0 @@
|
|||||||
const SAMPLE_RATE = 16000;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const FRAME_SIZE = 960; // 对应于60ms帧大小 (16000Hz * 0.06s = 960 samples)
|
|
||||||
const OPUS_APPLICATION = 2049; // OPUS_APPLICATION_AUDIO
|
|
||||||
const BUFFER_SIZE = 4096;
|
|
||||||
|
|
||||||
let audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
|
|
||||||
let mediaStream, mediaSource, audioProcessor;
|
|
||||||
let recordedPcmData = []; // 存储原始PCM数据
|
|
||||||
let recordedOpusData = []; // 存储Opus编码后的数据
|
|
||||||
let opusEncoder, opusDecoder;
|
|
||||||
let isRecording = false;
|
|
||||||
|
|
||||||
const startButton = document.getElementById("start");
|
|
||||||
const stopButton = document.getElementById("stop");
|
|
||||||
const playButton = document.getElementById("play");
|
|
||||||
const statusLabel = document.getElementById("status");
|
|
||||||
|
|
||||||
startButton.addEventListener("click", startRecording);
|
|
||||||
stopButton.addEventListener("click", stopRecording);
|
|
||||||
playButton.addEventListener("click", playRecording);
|
|
||||||
|
|
||||||
// 初始化Opus编码器与解码器
|
|
||||||
async function initOpus() {
|
|
||||||
if (typeof window.ModuleInstance === 'undefined') {
|
|
||||||
if (typeof Module !== 'undefined') {
|
|
||||||
// 尝试使用全局Module
|
|
||||||
window.ModuleInstance = Module;
|
|
||||||
console.log('使用全局Module作为ModuleInstance');
|
|
||||||
} else {
|
|
||||||
console.error("Opus库未加载,ModuleInstance和Module对象都不存在");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mod = window.ModuleInstance;
|
|
||||||
|
|
||||||
// 创建编码器
|
|
||||||
opusEncoder = {
|
|
||||||
channels: CHANNELS,
|
|
||||||
sampleRate: SAMPLE_RATE,
|
|
||||||
frameSize: FRAME_SIZE,
|
|
||||||
maxPacketSize: 4000,
|
|
||||||
module: mod,
|
|
||||||
|
|
||||||
// 初始化编码器
|
|
||||||
init: function() {
|
|
||||||
// 获取编码器大小
|
|
||||||
const encoderSize = mod._opus_encoder_get_size(this.channels);
|
|
||||||
console.log(`Opus编码器大小: ${encoderSize}字节`);
|
|
||||||
|
|
||||||
// 分配内存
|
|
||||||
this.encoderPtr = mod._malloc(encoderSize);
|
|
||||||
if (!this.encoderPtr) {
|
|
||||||
throw new Error("无法分配编码器内存");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化编码器
|
|
||||||
const err = mod._opus_encoder_init(
|
|
||||||
this.encoderPtr,
|
|
||||||
this.sampleRate,
|
|
||||||
this.channels,
|
|
||||||
OPUS_APPLICATION
|
|
||||||
);
|
|
||||||
|
|
||||||
if (err < 0) {
|
|
||||||
throw new Error(`Opus编码器初始化失败: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 编码方法
|
|
||||||
encode: function(pcmData) {
|
|
||||||
const mod = this.module;
|
|
||||||
|
|
||||||
// 为PCM数据分配内存
|
|
||||||
const pcmPtr = mod._malloc(pcmData.length * 2); // Int16 = 2字节
|
|
||||||
|
|
||||||
// 将数据复制到WASM内存
|
|
||||||
for (let i = 0; i < pcmData.length; i++) {
|
|
||||||
mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为Opus编码数据分配内存
|
|
||||||
const maxEncodedSize = this.maxPacketSize;
|
|
||||||
const encodedPtr = mod._malloc(maxEncodedSize);
|
|
||||||
|
|
||||||
// 编码
|
|
||||||
const encodedBytes = mod._opus_encode(
|
|
||||||
this.encoderPtr,
|
|
||||||
pcmPtr,
|
|
||||||
this.frameSize,
|
|
||||||
encodedPtr,
|
|
||||||
maxEncodedSize
|
|
||||||
);
|
|
||||||
|
|
||||||
if (encodedBytes < 0) {
|
|
||||||
mod._free(pcmPtr);
|
|
||||||
mod._free(encodedPtr);
|
|
||||||
throw new Error(`Opus编码失败: ${encodedBytes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制编码后的数据
|
|
||||||
const encodedData = new Uint8Array(encodedBytes);
|
|
||||||
for (let i = 0; i < encodedBytes; i++) {
|
|
||||||
encodedData[i] = mod.HEAPU8[encodedPtr + i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 释放内存
|
|
||||||
mod._free(pcmPtr);
|
|
||||||
mod._free(encodedPtr);
|
|
||||||
|
|
||||||
return encodedData;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 销毁方法
|
|
||||||
destroy: function() {
|
|
||||||
if (this.encoderPtr) {
|
|
||||||
this.module._free(this.encoderPtr);
|
|
||||||
this.encoderPtr = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建解码器
|
|
||||||
opusDecoder = {
|
|
||||||
channels: CHANNELS,
|
|
||||||
rate: SAMPLE_RATE,
|
|
||||||
frameSize: FRAME_SIZE,
|
|
||||||
module: mod,
|
|
||||||
|
|
||||||
// 初始化解码器
|
|
||||||
init: function() {
|
|
||||||
// 获取解码器大小
|
|
||||||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
|
||||||
console.log(`Opus解码器大小: ${decoderSize}字节`);
|
|
||||||
|
|
||||||
// 分配内存
|
|
||||||
this.decoderPtr = mod._malloc(decoderSize);
|
|
||||||
if (!this.decoderPtr) {
|
|
||||||
throw new Error("无法分配解码器内存");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化解码器
|
|
||||||
const err = mod._opus_decoder_init(
|
|
||||||
this.decoderPtr,
|
|
||||||
this.rate,
|
|
||||||
this.channels
|
|
||||||
);
|
|
||||||
|
|
||||||
if (err < 0) {
|
|
||||||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 解码方法
|
|
||||||
decode: function(opusData) {
|
|
||||||
const mod = this.module;
|
|
||||||
|
|
||||||
// 为Opus数据分配内存
|
|
||||||
const opusPtr = mod._malloc(opusData.length);
|
|
||||||
mod.HEAPU8.set(opusData, opusPtr);
|
|
||||||
|
|
||||||
// 为PCM输出分配内存
|
|
||||||
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
|
|
||||||
|
|
||||||
// 解码
|
|
||||||
const decodedSamples = mod._opus_decode(
|
|
||||||
this.decoderPtr,
|
|
||||||
opusPtr,
|
|
||||||
opusData.length,
|
|
||||||
pcmPtr,
|
|
||||||
this.frameSize,
|
|
||||||
0 // 不使用FEC
|
|
||||||
);
|
|
||||||
|
|
||||||
if (decodedSamples < 0) {
|
|
||||||
mod._free(opusPtr);
|
|
||||||
mod._free(pcmPtr);
|
|
||||||
throw new Error(`Opus解码失败: ${decodedSamples}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制解码后的数据
|
|
||||||
const decodedData = new Int16Array(decodedSamples);
|
|
||||||
for (let i = 0; i < decodedSamples; i++) {
|
|
||||||
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 释放内存
|
|
||||||
mod._free(opusPtr);
|
|
||||||
mod._free(pcmPtr);
|
|
||||||
|
|
||||||
return decodedData;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 销毁方法
|
|
||||||
destroy: function() {
|
|
||||||
if (this.decoderPtr) {
|
|
||||||
this.module._free(this.decoderPtr);
|
|
||||||
this.decoderPtr = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化编码器和解码器
|
|
||||||
if (opusEncoder.init() && opusDecoder.init()) {
|
|
||||||
console.log("Opus 编码器和解码器初始化成功。");
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.error("Opus 初始化失败");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Opus 初始化失败:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将Float32音频数据转换为Int16音频数据
|
|
||||||
function convertFloat32ToInt16(float32Data) {
|
|
||||||
const int16Data = new Int16Array(float32Data.length);
|
|
||||||
for (let i = 0; i < float32Data.length; i++) {
|
|
||||||
// 将[-1,1]范围转换为[-32768,32767]
|
|
||||||
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
|
||||||
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
|
||||||
}
|
|
||||||
return int16Data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将Int16音频数据转换为Float32音频数据
|
|
||||||
function convertInt16ToFloat32(int16Data) {
|
|
||||||
const float32Data = new Float32Array(int16Data.length);
|
|
||||||
for (let i = 0; i < int16Data.length; i++) {
|
|
||||||
// 将[-32768,32767]范围转换为[-1,1]
|
|
||||||
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
|
|
||||||
}
|
|
||||||
return float32Data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRecording() {
|
|
||||||
if (isRecording) return;
|
|
||||||
|
|
||||||
// 确保有权限并且AudioContext是活跃的
|
|
||||||
if (audioContext.state === 'suspended') {
|
|
||||||
audioContext.resume().then(() => {
|
|
||||||
console.log("AudioContext已恢复");
|
|
||||||
continueStartRecording();
|
|
||||||
}).catch(err => {
|
|
||||||
console.error("恢复AudioContext失败:", err);
|
|
||||||
statusLabel.textContent = "无法激活音频上下文,请再次点击";
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
continueStartRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实际开始录音的逻辑
|
|
||||||
function continueStartRecording() {
|
|
||||||
// 重置录音数据
|
|
||||||
recordedPcmData = [];
|
|
||||||
recordedOpusData = [];
|
|
||||||
window.audioDataBuffer = new Int16Array(0); // 重置缓冲区
|
|
||||||
|
|
||||||
// 初始化Opus
|
|
||||||
initOpus().then(success => {
|
|
||||||
if (!success) {
|
|
||||||
statusLabel.textContent = "Opus初始化失败";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("开始录音,参数:", {
|
|
||||||
sampleRate: SAMPLE_RATE,
|
|
||||||
channels: CHANNELS,
|
|
||||||
frameSize: FRAME_SIZE,
|
|
||||||
bufferSize: BUFFER_SIZE
|
|
||||||
});
|
|
||||||
|
|
||||||
// 请求麦克风权限
|
|
||||||
navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: {
|
|
||||||
sampleRate: SAMPLE_RATE,
|
|
||||||
channelCount: CHANNELS,
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(stream => {
|
|
||||||
console.log("获取到麦克风流,实际参数:", stream.getAudioTracks()[0].getSettings());
|
|
||||||
|
|
||||||
// 检查流是否有效
|
|
||||||
if (!stream || !stream.getAudioTracks().length || !stream.getAudioTracks()[0].enabled) {
|
|
||||||
throw new Error("获取到的音频流无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaStream = stream;
|
|
||||||
mediaSource = audioContext.createMediaStreamSource(stream);
|
|
||||||
|
|
||||||
// 创建ScriptProcessor(虽然已弃用,但兼容性好)
|
|
||||||
// 在降级到ScriptProcessor之前尝试使用AudioWorklet
|
|
||||||
createAudioProcessor().then(processor => {
|
|
||||||
if (processor) {
|
|
||||||
console.log("使用AudioWorklet处理音频");
|
|
||||||
audioProcessor = processor;
|
|
||||||
// 连接音频处理链
|
|
||||||
mediaSource.connect(audioProcessor);
|
|
||||||
audioProcessor.connect(audioContext.destination);
|
|
||||||
} else {
|
|
||||||
console.log("回退到ScriptProcessor");
|
|
||||||
// 创建ScriptProcessor节点
|
|
||||||
audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, CHANNELS, CHANNELS);
|
|
||||||
|
|
||||||
// 处理音频数据
|
|
||||||
audioProcessor.onaudioprocess = processAudioData;
|
|
||||||
|
|
||||||
// 连接音频处理链
|
|
||||||
mediaSource.connect(audioProcessor);
|
|
||||||
audioProcessor.connect(audioContext.destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
isRecording = true;
|
|
||||||
statusLabel.textContent = "录音中...";
|
|
||||||
startButton.disabled = true;
|
|
||||||
stopButton.disabled = false;
|
|
||||||
playButton.disabled = true;
|
|
||||||
}).catch(error => {
|
|
||||||
console.error("创建音频处理器失败:", error);
|
|
||||||
statusLabel.textContent = "创建音频处理器失败";
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("获取麦克风失败:", error);
|
|
||||||
statusLabel.textContent = "获取麦克风失败: " + error.message;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建AudioWorklet处理器
|
|
||||||
async function createAudioProcessor() {
|
|
||||||
try {
|
|
||||||
// 尝试使用更现代的AudioWorklet API
|
|
||||||
if ('AudioWorklet' in window && 'AudioWorkletNode' in window) {
|
|
||||||
// 定义AudioWorklet处理器代码
|
|
||||||
const workletCode = `
|
|
||||||
class OpusRecorderProcessor extends AudioWorkletProcessor {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.buffers = [];
|
|
||||||
this.frameSize = ${FRAME_SIZE};
|
|
||||||
this.buffer = new Float32Array(this.frameSize);
|
|
||||||
this.bufferIndex = 0;
|
|
||||||
this.isRecording = false;
|
|
||||||
|
|
||||||
this.port.onmessage = (event) => {
|
|
||||||
if (event.data.command === 'start') {
|
|
||||||
this.isRecording = true;
|
|
||||||
} else if (event.data.command === 'stop') {
|
|
||||||
this.isRecording = false;
|
|
||||||
// 发送最后的缓冲区
|
|
||||||
if (this.bufferIndex > 0) {
|
|
||||||
const finalBuffer = this.buffer.slice(0, this.bufferIndex);
|
|
||||||
this.port.postMessage({ buffer: finalBuffer });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
process(inputs, outputs) {
|
|
||||||
if (!this.isRecording) return true;
|
|
||||||
|
|
||||||
// 获取输入数据
|
|
||||||
const input = inputs[0][0]; // mono channel
|
|
||||||
if (!input || input.length === 0) return true;
|
|
||||||
|
|
||||||
// 将输入数据添加到缓冲区
|
|
||||||
for (let i = 0; i < input.length; i++) {
|
|
||||||
this.buffer[this.bufferIndex++] = input[i];
|
|
||||||
|
|
||||||
// 当缓冲区填满时,发送给主线程
|
|
||||||
if (this.bufferIndex >= this.frameSize) {
|
|
||||||
this.port.postMessage({ buffer: this.buffer.slice() });
|
|
||||||
this.bufferIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProcessor('opus-recorder-processor', OpusRecorderProcessor);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 创建Blob URL
|
|
||||||
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// 加载AudioWorklet模块
|
|
||||||
await audioContext.audioWorklet.addModule(url);
|
|
||||||
|
|
||||||
// 创建AudioWorkletNode
|
|
||||||
const workletNode = new AudioWorkletNode(audioContext, 'opus-recorder-processor');
|
|
||||||
|
|
||||||
// 处理从AudioWorklet接收的消息
|
|
||||||
workletNode.port.onmessage = (event) => {
|
|
||||||
if (event.data.buffer) {
|
|
||||||
// 使用与ScriptProcessor相同的处理逻辑
|
|
||||||
processAudioData({
|
|
||||||
inputBuffer: {
|
|
||||||
getChannelData: () => event.data.buffer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 启动录音
|
|
||||||
workletNode.port.postMessage({ command: 'start' });
|
|
||||||
|
|
||||||
// 保存停止函数
|
|
||||||
workletNode.stopRecording = () => {
|
|
||||||
workletNode.port.postMessage({ command: 'stop' });
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("AudioWorklet 音频处理器创建成功");
|
|
||||||
return workletNode;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("创建AudioWorklet失败,将使用ScriptProcessor:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果AudioWorklet不可用或失败,返回null以便回退到ScriptProcessor
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理音频数据
|
|
||||||
function processAudioData(e) {
|
|
||||||
// 获取输入缓冲区
|
|
||||||
const inputBuffer = e.inputBuffer;
|
|
||||||
|
|
||||||
// 获取第一个通道的Float32数据
|
|
||||||
const inputData = inputBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
// 添加调试信息
|
|
||||||
const nonZeroCount = Array.from(inputData).filter(x => Math.abs(x) > 0.001).length;
|
|
||||||
console.log(`接收到音频数据: ${inputData.length} 个样本, 非零样本数: ${nonZeroCount}`);
|
|
||||||
|
|
||||||
// 如果全是0,可能是麦克风没有正确获取声音
|
|
||||||
if (nonZeroCount < 5) {
|
|
||||||
console.warn("警告: 检测到大量静音样本,请检查麦克风是否正常工作");
|
|
||||||
// 继续处理,以防有些样本确实是静音
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储PCM数据用于调试
|
|
||||||
recordedPcmData.push(new Float32Array(inputData));
|
|
||||||
|
|
||||||
// 转换为Int16数据供Opus编码
|
|
||||||
const int16Data = convertFloat32ToInt16(inputData);
|
|
||||||
|
|
||||||
// 如果收集到的数据不是FRAME_SIZE的整数倍,需要进行处理
|
|
||||||
// 创建静态缓冲区来存储不足一帧的数据
|
|
||||||
if (!window.audioDataBuffer) {
|
|
||||||
window.audioDataBuffer = new Int16Array(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并之前缓存的数据和新数据
|
|
||||||
const combinedData = new Int16Array(window.audioDataBuffer.length + int16Data.length);
|
|
||||||
combinedData.set(window.audioDataBuffer);
|
|
||||||
combinedData.set(int16Data, window.audioDataBuffer.length);
|
|
||||||
|
|
||||||
// 处理完整帧
|
|
||||||
const frameCount = Math.floor(combinedData.length / FRAME_SIZE);
|
|
||||||
console.log(`可编码的完整帧数: ${frameCount}, 缓冲区总大小: ${combinedData.length}`);
|
|
||||||
|
|
||||||
for (let i = 0; i < frameCount; i++) {
|
|
||||||
const frameData = combinedData.subarray(i * FRAME_SIZE, (i + 1) * FRAME_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`编码第 ${i+1}/${frameCount} 帧, 帧大小: ${frameData.length}`);
|
|
||||||
const encodedData = opusEncoder.encode(frameData);
|
|
||||||
if (encodedData) {
|
|
||||||
console.log(`编码成功: ${encodedData.length} 字节`);
|
|
||||||
recordedOpusData.push(encodedData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Opus编码帧 ${i+1} 失败:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存剩余不足一帧的数据
|
|
||||||
const remainingSamples = combinedData.length % FRAME_SIZE;
|
|
||||||
if (remainingSamples > 0) {
|
|
||||||
window.audioDataBuffer = combinedData.subarray(frameCount * FRAME_SIZE);
|
|
||||||
console.log(`保留 ${remainingSamples} 个样本到下一次处理`);
|
|
||||||
} else {
|
|
||||||
window.audioDataBuffer = new Int16Array(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
if (!isRecording) return;
|
|
||||||
|
|
||||||
// 处理剩余的缓冲数据
|
|
||||||
if (window.audioDataBuffer && window.audioDataBuffer.length > 0) {
|
|
||||||
console.log(`停止录音,处理剩余的 ${window.audioDataBuffer.length} 个样本`);
|
|
||||||
// 如果剩余数据不足一帧,可以通过补零的方式凑成一帧
|
|
||||||
if (window.audioDataBuffer.length < FRAME_SIZE) {
|
|
||||||
const paddedFrame = new Int16Array(FRAME_SIZE);
|
|
||||||
paddedFrame.set(window.audioDataBuffer);
|
|
||||||
// 剩余部分填充为0
|
|
||||||
for (let i = window.audioDataBuffer.length; i < FRAME_SIZE; i++) {
|
|
||||||
paddedFrame[i] = 0;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
console.log(`编码最后一帧(补零): ${paddedFrame.length} 样本`);
|
|
||||||
const encodedData = opusEncoder.encode(paddedFrame);
|
|
||||||
if (encodedData) {
|
|
||||||
recordedOpusData.push(encodedData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("最后一帧Opus编码失败:", error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果数据超过一帧,按正常流程处理
|
|
||||||
processAudioData({
|
|
||||||
inputBuffer: {
|
|
||||||
getChannelData: () => convertInt16ToFloat32(window.audioDataBuffer)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.audioDataBuffer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果使用的是AudioWorklet,调用其特定的停止方法
|
|
||||||
if (audioProcessor && typeof audioProcessor.stopRecording === 'function') {
|
|
||||||
audioProcessor.stopRecording();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止麦克风
|
|
||||||
if (mediaStream) {
|
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 断开音频处理链
|
|
||||||
if (audioProcessor) {
|
|
||||||
try {
|
|
||||||
audioProcessor.disconnect();
|
|
||||||
if (mediaSource) mediaSource.disconnect();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("断开音频处理链时出错:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
isRecording = false;
|
|
||||||
statusLabel.textContent = "已停止录音,收集了 " + recordedOpusData.length + " 帧Opus数据";
|
|
||||||
startButton.disabled = false;
|
|
||||||
stopButton.disabled = true;
|
|
||||||
playButton.disabled = recordedOpusData.length === 0;
|
|
||||||
|
|
||||||
console.log("录制完成:",
|
|
||||||
"PCM帧数:", recordedPcmData.length,
|
|
||||||
"Opus帧数:", recordedOpusData.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function playRecording() {
|
|
||||||
if (!recordedOpusData.length) {
|
|
||||||
statusLabel.textContent = "没有可播放的录音";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将所有Opus数据解码为PCM
|
|
||||||
let allDecodedData = [];
|
|
||||||
|
|
||||||
for (const opusData of recordedOpusData) {
|
|
||||||
try {
|
|
||||||
// 解码为Int16数据
|
|
||||||
const decodedData = opusDecoder.decode(opusData);
|
|
||||||
|
|
||||||
if (decodedData && decodedData.length > 0) {
|
|
||||||
// 将Int16数据转换为Float32
|
|
||||||
const float32Data = convertInt16ToFloat32(decodedData);
|
|
||||||
|
|
||||||
// 添加到总解码数据中
|
|
||||||
allDecodedData.push(...float32Data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Opus解码失败:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有解码出数据,返回
|
|
||||||
if (allDecodedData.length === 0) {
|
|
||||||
statusLabel.textContent = "解码失败,无法播放";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建音频缓冲区
|
|
||||||
const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
|
|
||||||
audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
|
|
||||||
|
|
||||||
// 创建音频源并播放
|
|
||||||
const source = audioContext.createBufferSource();
|
|
||||||
source.buffer = audioBuffer;
|
|
||||||
source.connect(audioContext.destination);
|
|
||||||
source.start();
|
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
statusLabel.textContent = "正在播放...";
|
|
||||||
playButton.disabled = true;
|
|
||||||
|
|
||||||
// 播放结束后恢复UI
|
|
||||||
source.onended = () => {
|
|
||||||
statusLabel.textContent = "播放完毕";
|
|
||||||
playButton.disabled = false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟服务端返回的Opus数据进行解码播放
|
|
||||||
function playOpusFromServer(opusData) {
|
|
||||||
// 这个函数展示如何处理服务端返回的opus数据
|
|
||||||
// opusData应该是一个包含opus帧的数组
|
|
||||||
|
|
||||||
if (!opusDecoder) {
|
|
||||||
initOpus().then(success => {
|
|
||||||
if (success) {
|
|
||||||
decodeAndPlayOpusData(opusData);
|
|
||||||
} else {
|
|
||||||
statusLabel.textContent = "Opus解码器初始化失败";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
decodeAndPlayOpusData(opusData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeAndPlayOpusData(opusData) {
|
|
||||||
let allDecodedData = [];
|
|
||||||
|
|
||||||
for (const frame of opusData) {
|
|
||||||
try {
|
|
||||||
const decodedData = opusDecoder.decode(frame);
|
|
||||||
if (decodedData && decodedData.length > 0) {
|
|
||||||
const float32Data = convertInt16ToFloat32(decodedData);
|
|
||||||
allDecodedData.push(...float32Data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("服务端Opus数据解码失败:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allDecodedData.length === 0) {
|
|
||||||
statusLabel.textContent = "服务端数据解码失败";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
|
|
||||||
audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
|
|
||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
|
||||||
source.buffer = audioBuffer;
|
|
||||||
source.connect(audioContext.destination);
|
|
||||||
source.start();
|
|
||||||
|
|
||||||
statusLabel.textContent = "正在播放服务端数据...";
|
|
||||||
source.onended = () => statusLabel.textContent = "服务端数据播放完毕";
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Opus 编解码测试</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 8px 15px;
|
|
||||||
margin-right: 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #4285f4;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background-color: #3367d6;
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
background-color: #cccccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
#status {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
#scriptStatus {
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: block;
|
|
||||||
z-index: 100;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
#scriptStatus.info {
|
|
||||||
background-color: #e8f0fe;
|
|
||||||
color: #4285f4;
|
|
||||||
border-left: 4px solid #4285f4;
|
|
||||||
}
|
|
||||||
#scriptStatus.success {
|
|
||||||
background-color: #e6f4ea;
|
|
||||||
color: #0f9d58;
|
|
||||||
border-left: 4px solid #0f9d58;
|
|
||||||
}
|
|
||||||
#scriptStatus.error {
|
|
||||||
background-color: #fce8e6;
|
|
||||||
color: #db4437;
|
|
||||||
border-left: 4px solid #db4437;
|
|
||||||
}
|
|
||||||
#debugInfo {
|
|
||||||
margin-top: 20px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#showDebug {
|
|
||||||
margin-top: 10px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
color: #444;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h2>Opus 编解码录音播放测试</h2>
|
|
||||||
|
|
||||||
<div id="scriptStatus" class="info">正在加载Opus库...</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button id="initAudio" style="background-color: #34a853;">初始化音频</button>
|
|
||||||
<button id="testMic" style="background-color: #fbbc05;">测试麦克风</button>
|
|
||||||
<p></p>
|
|
||||||
<button id="start" disabled>开始录音</button>
|
|
||||||
<button id="stop" disabled>停止录音</button>
|
|
||||||
<button id="play" disabled>播放录音</button>
|
|
||||||
<p>录音状态: <span id="status">待机,正在初始化...</span></p>
|
|
||||||
<button id="showDebug">显示/隐藏调试信息</button>
|
|
||||||
<div id="debugInfo"></div>
|
|
||||||
<div id="audioMeter" style="margin-top: 10px; height: 20px; background-color: #eee; border-radius: 4px; overflow: hidden; display: none;">
|
|
||||||
<div id="audioLevel" style="height: 100%; width: 0%; background-color: #4285f4; transition: width 0.1s;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Opus解码库 - 这个库会设置一个全局Module变量 -->
|
|
||||||
<script>
|
|
||||||
// 定义全局变量以跟踪库加载状态
|
|
||||||
window.opusLoaded = false;
|
|
||||||
window.startButton = document.getElementById("start");
|
|
||||||
window.stopButton = document.getElementById("stop");
|
|
||||||
window.playButton = document.getElementById("play");
|
|
||||||
window.statusLabel = document.getElementById("status");
|
|
||||||
window.debugInfo = document.getElementById("debugInfo");
|
|
||||||
window.audioContextReady = false;
|
|
||||||
window.testMicActive = false;
|
|
||||||
|
|
||||||
// 显示/隐藏调试信息
|
|
||||||
document.getElementById("showDebug").addEventListener("click", function() {
|
|
||||||
if (debugInfo.style.display === "none" || !debugInfo.style.display) {
|
|
||||||
debugInfo.style.display = "block";
|
|
||||||
this.textContent = "隐藏调试信息";
|
|
||||||
} else {
|
|
||||||
debugInfo.style.display = "none";
|
|
||||||
this.textContent = "显示调试信息";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加初始化音频按钮事件
|
|
||||||
document.getElementById("initAudio").addEventListener("click", function() {
|
|
||||||
initializeAudioSystem();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加测试麦克风按钮事件
|
|
||||||
document.getElementById("testMic").addEventListener("click", function() {
|
|
||||||
if (window.testMicActive) {
|
|
||||||
stopMicTest();
|
|
||||||
} else {
|
|
||||||
startMicTest();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化音频系统
|
|
||||||
function initializeAudioSystem() {
|
|
||||||
try {
|
|
||||||
log("初始化音频系统...");
|
|
||||||
// 创建临时AudioContext来触发用户授权
|
|
||||||
const tempContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: 16000,
|
|
||||||
latencyHint: 'interactive'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建振荡器并播放短促的声音
|
|
||||||
const oscillator = tempContext.createOscillator();
|
|
||||||
const gain = tempContext.createGain();
|
|
||||||
gain.gain.value = 0.1; // 很小的音量
|
|
||||||
oscillator.connect(gain);
|
|
||||||
gain.connect(tempContext.destination);
|
|
||||||
oscillator.frequency.value = 440; // A4
|
|
||||||
oscillator.start();
|
|
||||||
|
|
||||||
// 0.2秒后停止
|
|
||||||
setTimeout(() => {
|
|
||||||
oscillator.stop();
|
|
||||||
// 关闭上下文
|
|
||||||
tempContext.close().then(() => {
|
|
||||||
log("音频系统初始化成功", "success");
|
|
||||||
updateScriptStatus("音频系统已激活", "success");
|
|
||||||
document.getElementById("initAudio").disabled = true;
|
|
||||||
document.getElementById("initAudio").textContent = "音频已初始化";
|
|
||||||
window.audioContextReady = true;
|
|
||||||
|
|
||||||
// 如果Opus已加载,启用开始录音按钮
|
|
||||||
if (window.opusLoaded) {
|
|
||||||
startButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
} catch (err) {
|
|
||||||
log("初始化音频系统失败: " + err.message, "error");
|
|
||||||
updateScriptStatus("初始化音频失败: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试麦克风
|
|
||||||
function startMicTest() {
|
|
||||||
const audioMeter = document.getElementById("audioMeter");
|
|
||||||
const audioLevel = document.getElementById("audioLevel");
|
|
||||||
const testMicBtn = document.getElementById("testMic");
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
log("浏览器不支持麦克风访问", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log("开始麦克风测试...");
|
|
||||||
audioMeter.style.display = "block";
|
|
||||||
testMicBtn.textContent = "停止测试";
|
|
||||||
testMicBtn.style.backgroundColor = "#ea4335";
|
|
||||||
window.testMicActive = true;
|
|
||||||
|
|
||||||
// 创建音频上下文
|
|
||||||
const testContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
window.testContext = testContext;
|
|
||||||
|
|
||||||
// 获取麦克风权限
|
|
||||||
navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(stream => {
|
|
||||||
log("已获取麦克风访问权限", "success");
|
|
||||||
|
|
||||||
// 保存流以便稍后关闭
|
|
||||||
window.testStream = stream;
|
|
||||||
|
|
||||||
// 创建音频分析器
|
|
||||||
const source = testContext.createMediaStreamSource(stream);
|
|
||||||
const analyser = testContext.createAnalyser();
|
|
||||||
analyser.fftSize = 256;
|
|
||||||
source.connect(analyser);
|
|
||||||
|
|
||||||
// 创建音量显示更新函数
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
|
|
||||||
function updateMeter() {
|
|
||||||
if (!window.testMicActive) return;
|
|
||||||
|
|
||||||
analyser.getByteFrequencyData(dataArray);
|
|
||||||
|
|
||||||
// 计算音量级别 (0-100)
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
sum += dataArray[i];
|
|
||||||
}
|
|
||||||
const average = sum / bufferLength;
|
|
||||||
const level = Math.min(100, Math.max(0, average * 2));
|
|
||||||
|
|
||||||
// 更新音量计
|
|
||||||
audioLevel.style.width = level + "%";
|
|
||||||
|
|
||||||
// 如果有声音,记录日志
|
|
||||||
if (level > 10) {
|
|
||||||
log(`检测到声音: ${level.toFixed(1)}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 循环更新
|
|
||||||
window.testMicAnimationFrame = requestAnimationFrame(updateMeter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始更新
|
|
||||||
updateMeter();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
log("麦克风测试失败: " + err.message, "error");
|
|
||||||
window.testMicActive = false;
|
|
||||||
testMicBtn.textContent = "测试麦克风";
|
|
||||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
|
||||||
audioMeter.style.display = "none";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止麦克风测试
|
|
||||||
function stopMicTest() {
|
|
||||||
const audioMeter = document.getElementById("audioMeter");
|
|
||||||
const testMicBtn = document.getElementById("testMic");
|
|
||||||
|
|
||||||
log("停止麦克风测试");
|
|
||||||
window.testMicActive = false;
|
|
||||||
testMicBtn.textContent = "测试麦克风";
|
|
||||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
|
||||||
|
|
||||||
// 停止分析器动画
|
|
||||||
if (window.testMicAnimationFrame) {
|
|
||||||
cancelAnimationFrame(window.testMicAnimationFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止麦克风流
|
|
||||||
if (window.testStream) {
|
|
||||||
window.testStream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭测试上下文
|
|
||||||
if (window.testContext) {
|
|
||||||
window.testContext.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏音量计
|
|
||||||
audioMeter.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化AudioContext
|
|
||||||
function initAudioContext() {
|
|
||||||
try {
|
|
||||||
window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (!window.AudioContext) {
|
|
||||||
throw new Error("浏览器不支持AudioContext");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 我们在app.js中会创建AudioContext,这里只检查兼容性
|
|
||||||
log("AudioContext API 可用");
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
log("AudioContext初始化失败: " + err.message, "error");
|
|
||||||
updateScriptStatus("AudioContext初始化失败,录音功能不可用", "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查麦克风权限
|
|
||||||
function checkMicrophonePermission() {
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
log("浏览器不支持getUserMedia API", "error");
|
|
||||||
updateScriptStatus("浏览器不支持录音功能", "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log("getUserMedia API 可用");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加调试日志
|
|
||||||
function log(message, type = "info") {
|
|
||||||
console.log(message);
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
const entry = document.createElement("div");
|
|
||||||
entry.textContent = `[${time}] ${message}`;
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
entry.style.color = "#db4437";
|
|
||||||
} else if (type === "success") {
|
|
||||||
entry.style.color = "#0f9d58";
|
|
||||||
}
|
|
||||||
|
|
||||||
debugInfo.appendChild(entry);
|
|
||||||
debugInfo.scrollTop = debugInfo.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新脚本状态显示
|
|
||||||
function updateScriptStatus(message, type) {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) {
|
|
||||||
statusElement.textContent = message;
|
|
||||||
statusElement.className = type;
|
|
||||||
statusElement.style.display = 'block';
|
|
||||||
}
|
|
||||||
log(message, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查Opus库是否已加载
|
|
||||||
function checkOpusLoaded() {
|
|
||||||
try {
|
|
||||||
// 检查Module是否存在(本地库导出的全局变量)
|
|
||||||
if (typeof Module === 'undefined') {
|
|
||||||
log("Module对象不存在", "error");
|
|
||||||
throw new Error('Opus库未加载,Module对象不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录Module对象结构以便调试
|
|
||||||
log("Module对象结构: " + Object.keys(Module).join(", "));
|
|
||||||
|
|
||||||
// 尝试使用全局Module函数
|
|
||||||
if (typeof Module._opus_decoder_get_size === 'function') {
|
|
||||||
window.ModuleInstance = Module;
|
|
||||||
log('Opus库加载成功(使用全局Module)', "success");
|
|
||||||
updateScriptStatus('Opus库加载成功', 'success');
|
|
||||||
|
|
||||||
// 启用开始录音按钮
|
|
||||||
startButton.disabled = false;
|
|
||||||
statusLabel.textContent = "待机";
|
|
||||||
|
|
||||||
// 3秒后隐藏状态
|
|
||||||
setTimeout(() => {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) statusElement.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
window.opusLoaded = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试使用Module.instance
|
|
||||||
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
|
||||||
window.ModuleInstance = Module.instance;
|
|
||||||
log('Opus库加载成功(使用Module.instance)', "success");
|
|
||||||
updateScriptStatus('Opus库加载成功', 'success');
|
|
||||||
|
|
||||||
// 启用开始录音按钮
|
|
||||||
startButton.disabled = false;
|
|
||||||
statusLabel.textContent = "待机";
|
|
||||||
|
|
||||||
// 3秒后隐藏状态
|
|
||||||
setTimeout(() => {
|
|
||||||
const statusElement = document.getElementById('scriptStatus');
|
|
||||||
if (statusElement) statusElement.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
window.opusLoaded = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有其他导出方式
|
|
||||||
log("Module上可用方法: " + Object.getOwnPropertyNames(Module).filter(prop => typeof Module[prop] === 'function').join(", "));
|
|
||||||
|
|
||||||
if (typeof Module.onRuntimeInitialized === 'function' || typeof Module.onRuntimeInitialized === 'undefined') {
|
|
||||||
log("Module.onRuntimeInitialized 尚未执行,等待模块初始化完成...");
|
|
||||||
// Module可能未完成初始化,注册回调
|
|
||||||
Module.onRuntimeInitialized = function() {
|
|
||||||
log("Opus库运行时初始化完成,重新检查");
|
|
||||||
checkOpusLoaded();
|
|
||||||
};
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
|
||||||
} catch (err) {
|
|
||||||
log(`Opus库加载失败: ${err.message}`, "error");
|
|
||||||
updateScriptStatus(`Opus库加载失败: ${err.message}`, 'error');
|
|
||||||
statusLabel.textContent = "错误:Opus库加载失败";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载完成后检查浏览器能力和Opus库
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
log("页面加载完成,开始初始化...");
|
|
||||||
updateScriptStatus('正在初始化录音环境...', 'info');
|
|
||||||
|
|
||||||
// 检查浏览器能力
|
|
||||||
const audioContextSupported = initAudioContext();
|
|
||||||
const microphoneSupported = checkMicrophonePermission();
|
|
||||||
|
|
||||||
if (!audioContextSupported || !microphoneSupported) {
|
|
||||||
log("浏览器不支持必要API,无法进行录音", "error");
|
|
||||||
updateScriptStatus('浏览器不支持录音功能', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log("检查环境完成,开始加载Opus库");
|
|
||||||
updateScriptStatus('正在加载Opus库...', 'info');
|
|
||||||
|
|
||||||
// 延迟一小段时间再检查,确保库有时间加载
|
|
||||||
setTimeout(function checkAndRetry() {
|
|
||||||
if (!checkOpusLoaded()) {
|
|
||||||
// 如果加载失败,5秒后重试
|
|
||||||
log("Opus库加载失败,5秒后重试...");
|
|
||||||
updateScriptStatus('Opus库加载失败,正在重试...', 'error');
|
|
||||||
setTimeout(checkAndRetry, 5000);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 防止按钮误操作
|
|
||||||
document.getElementById("stop").disabled = true;
|
|
||||||
document.getElementById("play").disabled = true;
|
|
||||||
</script>
|
|
||||||
<script src="./../libopus.js"></script>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,52 +1,58 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 0px;
|
||||||
background-color: #f5f5f5;
|
background-color: #f7f8fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1000px;
|
max-width: 80%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background-color: white;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
padding: 0px;
|
||||||
padding: 10px 20px 10px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 5px;
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 10px;
|
border-radius: 15px;
|
||||||
background-color: #f9f9f9;
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section h2 {
|
.section h2 {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
color: #444;
|
color: #444;
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section h2 .toggle-button {
|
.toggle-button,
|
||||||
|
.connect-button {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
border: 1px solid #007aff;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #007aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-button {
|
||||||
|
box-shadow: 0px 0px 10px 2px rgba(0, 122, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.device-info {
|
.device-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -70,37 +76,56 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-panel {
|
.config-panel {
|
||||||
display: none;
|
display: block;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
margin-top: 5px;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-panel.expanded {
|
/* 只为MCP工具面板设置默认隐藏 */
|
||||||
|
#mcpToolsPanel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mcpToolsPanel.expanded {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-panel {
|
.control-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input {
|
||||||
|
flex: 1;
|
||||||
|
width: 0;
|
||||||
|
/* 确保input能自动填充剩余空间 */
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: #4285f4;
|
background-color: #007aff;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: #3367d6;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
background-color: #cccccc;
|
background-color: #cccccc;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -124,53 +149,45 @@ button:disabled {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 5px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nfcCardId {
|
#sendTextButton,
|
||||||
flex-grow: 1;
|
#recordButton {
|
||||||
padding: 8px;
|
border-radius: 20px;
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation {
|
.conversation {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 5px;
|
border-right: none;
|
||||||
|
border-radius: 5px 0px 0px 5px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: white;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
width: fit-content;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
background-color: #e2f2ff;
|
background-color: #95ec69;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server {
|
.server {
|
||||||
background-color: #f0f0f0;
|
background-color: #fff;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.status {
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-controls {
|
.audio-controls {
|
||||||
@@ -218,13 +235,12 @@ button:disabled {
|
|||||||
#logContainer {
|
#logContainer {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f0f0f0;
|
border-radius: 0px 5px 5px 0px;
|
||||||
border-radius: 5px;
|
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 10px;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
@@ -322,11 +338,11 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
color: #4285f4;
|
color: #007aff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: #4285f4;
|
color: #007aff;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +353,7 @@ button:disabled {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background-color: #4285f4;
|
background-color: #007aff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
@@ -350,8 +366,8 @@ button:disabled {
|
|||||||
|
|
||||||
.flex-container {
|
.flex-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
background-color: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item {
|
.config-item {
|
||||||
@@ -362,10 +378,11 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-item label {
|
.config-item label {
|
||||||
width: 100px;
|
white-space: nowrap;
|
||||||
text-align: right;
|
min-width: 70px;
|
||||||
margin-right: 10px;
|
text-align: left;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item input {
|
.config-item input {
|
||||||
@@ -375,6 +392,24 @@ button:disabled {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-item input:disabled {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-color: #e0e0e0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 两栏布局样式 */
|
||||||
|
.two-column-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column-layout>.section {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.control-panel {
|
.control-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -384,17 +419,18 @@ button:disabled {
|
|||||||
|
|
||||||
.connection-controls {
|
.connection-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-controls input {
|
.connection-controls input {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
min-width: 200px;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-controls button {
|
.connection-controls button {
|
||||||
@@ -408,18 +444,233 @@ button:disabled {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
background-color: #f9f9f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
height: 28px;
|
height: 28px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-status span {
|
.connection-status span {
|
||||||
color: #666;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-status .status {
|
.connection-status .status {
|
||||||
|
/* 默认未连接状态 */
|
||||||
|
background-color: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已连接状态样式 */
|
||||||
|
.connection-status .status.connected {
|
||||||
|
background-color: #ecfdf3;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileProtocolWarning {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileProtocolWarning h2 {
|
||||||
|
color: #ff4d4d;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileProtocolWarning pre {
|
||||||
|
background-color: green;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileProtocolWarning button {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 10px 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileProtocolWarning button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MCP 工具管理样式 */
|
||||||
|
.mcp-tools-container {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-card {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-card.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-info {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-info-label {
|
||||||
|
color: #999;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-info-value {
|
||||||
|
color: #333;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-property-item {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-property-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-property-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-property-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-property-row-full {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-small-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-small-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-badge-required {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-badge-optional {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user