2025-03-31 09:38:26 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>小智服务器测试页面</title>
|
2025-08-11 14:24:06 +08:00
|
|
|
|
<link rel="stylesheet" href="test_page.css">
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<style>
|
|
|
|
|
|
#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;
|
|
|
|
|
|
}
|
2025-10-24 10:17:32 +08:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
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;
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mcp-badge-required {
|
|
|
|
|
|
background-color: #ffebee;
|
|
|
|
|
|
color: #c62828;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mcp-badge-optional {
|
|
|
|
|
|
background-color: #e3f2fd;
|
|
|
|
|
|
color: #1976d2;
|
|
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// 检测是否使用file://协议打开
|
|
|
|
|
|
if (window.location.protocol === 'file:') {
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
|
|
|
|
// 创建警告框
|
|
|
|
|
|
const warningDiv = document.createElement('div');
|
|
|
|
|
|
warningDiv.id = 'fileProtocolWarning';
|
|
|
|
|
|
warningDiv.innerHTML = `
|
|
|
|
|
|
<h2>⚠️ 警告:请使用HTTP服务器打开此页面</h2>
|
|
|
|
|
|
<p>您当前使用的是本地文件方式打开页面(file://协议),这可能导致页面功能异常。</p>
|
|
|
|
|
|
<p>您可以使用nginx映射启动测试页面,也可以请按照以下步骤使用python启动测试http服务:</p>
|
|
|
|
|
|
<ol>
|
|
|
|
|
|
<li>打开命令行终端</li>
|
|
|
|
|
|
<li>命令行进入到 xiaozhi-server/test 目录</li>
|
|
|
|
|
|
<li>执行以下命令启动HTTP服务器:</li>
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
<pre>python -m http.server 8006</pre>
|
|
|
|
|
|
<p>然后在浏览器中访问:<strong>http://localhost:8006/test_page.html</strong></p>
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(warningDiv);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
2025-03-31 09:38:26 +08:00
|
|
|
|
</head>
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
<body>
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<div class="container">
|
|
|
|
|
|
<h1>小智服务器测试页面</h1>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
|
|
|
|
|
正在加载Opus库...
|
|
|
|
|
|
</div>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<!-- 添加配置面板 -->
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
设备配置
|
|
|
|
|
|
<span class="device-info">
|
2025-04-27 22:49:14 +08:00
|
|
|
|
<span>MAC: <strong id="displayMac"></strong></span>
|
2025-04-16 16:44:44 +08:00
|
|
|
|
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
|
|
|
|
|
|
</span>
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<button class="toggle-button" id="toggleConfig">编辑</button>
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<div class="config-panel" id="configPanel">
|
|
|
|
|
|
<div class="control-panel">
|
|
|
|
|
|
<div class="config-item">
|
|
|
|
|
|
<label for="deviceMac">设备MAC:</label>
|
|
|
|
|
|
<input type="text" id="deviceMac" placeholder="设备MAC地址">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="config-item">
|
|
|
|
|
|
<label for="deviceName">设备名称:</label>
|
|
|
|
|
|
<input type="text" id="deviceName" value="Web测试设备" placeholder="设备名称">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="config-item">
|
|
|
|
|
|
<label for="clientId">客户端ID:</label>
|
|
|
|
|
|
<input type="text" id="clientId" value="web_test_client" placeholder="客户端ID">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="config-item">
|
|
|
|
|
|
<label for="token">认证Token:</label>
|
|
|
|
|
|
<input type="text" id="token" value="your-token1" placeholder="认证Token">
|
|
|
|
|
|
</div>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-03-31 09:38:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
连接信息
|
|
|
|
|
|
<span class="connection-status">
|
2025-08-15 11:26:14 +08:00
|
|
|
|
<span>OTA: <span id="otaStatus" class="status">ota未连接</span></span>
|
|
|
|
|
|
<span>WS: <span id="connectionStatus" class="status">ws未连接</span></span>
|
|
|
|
|
|
</span>
|
2025-08-16 14:36:23 +08:00
|
|
|
|
</h2>
|
|
|
|
|
|
<div class="connection-controls">
|
|
|
|
|
|
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/"
|
|
|
|
|
|
placeholder="OTA服务器地址,如:http://127.0.0.1:8002/xiaozhi/ota/" />
|
2025-10-18 21:24:19 +08:00
|
|
|
|
<input type="text" id="serverUrl" value="" readonly disabled placeholder="点击连接按钮后,自动从OTA接口获取" />
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<button id="connectButton">连接</button>
|
2025-08-15 11:26:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<div class="section">
|
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
|
<button class="tab active" data-tab="text">文本消息</button>
|
|
|
|
|
|
<button class="tab" data-tab="voice">语音消息</button>
|
2025-08-15 11:26:14 +08:00
|
|
|
|
</div>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<div class="tab-content active" id="textTab">
|
|
|
|
|
|
<div class="message-input">
|
|
|
|
|
|
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
|
|
|
|
|
|
<button id="sendTextButton" disabled>发送</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="tab-content" id="voiceTab">
|
|
|
|
|
|
<div class="audio-controls">
|
|
|
|
|
|
<button id="recordButton" class="record-button" disabled>开始录音</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<canvas id="audioVisualizer" class="audio-visualizer"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>会话记录</h2>
|
|
|
|
|
|
<div class="flex-container">
|
|
|
|
|
|
<div id="conversation" class="conversation"></div>
|
|
|
|
|
|
<div id="logContainer">
|
|
|
|
|
|
<div class="log-entry log-info">准备就绪,请连接服务器开始测试...</div>
|
|
|
|
|
|
</div>
|
2025-08-15 11:26:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-24 10:17:32 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- MCP 工具管理区域 -->
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
MCP 工具管理
|
|
|
|
|
|
<span class="connection-status">
|
|
|
|
|
|
<span id="mcpToolsCount">0 个工具</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button class="toggle-button" id="toggleMcpTools">展开</button>
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<div class="config-panel" id="mcpToolsPanel">
|
|
|
|
|
|
<div id="mcpToolsContainer" class="mcp-tools-container">
|
|
|
|
|
|
<!-- 工具列表将动态插入这里 -->
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="text-align: center; padding: 15px 0;">
|
|
|
|
|
|
<button class="btn" id="addMcpToolBtn" style="background-color: #4caf50;">
|
|
|
|
|
|
➕ 添加新工具
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- MCP 工具编辑模态框 -->
|
|
|
|
|
|
<div id="mcpToolModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; overflow-y: auto;">
|
|
|
|
|
|
<div style="background-color: white; border-radius: 10px; padding: 25px; width: 90%; max-width: 700px; margin: 50px auto; max-height: 85vh; overflow-y: auto;">
|
|
|
|
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e0e0e0;">
|
|
|
|
|
|
<h2 id="mcpModalTitle" style="font-size: 20px; font-weight: bold; color: #333; margin: 0;">添加工具</h2>
|
|
|
|
|
|
<button id="closeMcpModalBtn" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px;">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="mcpErrorContainer"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<form id="mcpToolForm">
|
|
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
|
|
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具名称 *</label>
|
|
|
|
|
|
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required
|
|
|
|
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
|
|
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具描述 *</label>
|
|
|
|
|
|
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required
|
|
|
|
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; resize: vertical;"></textarea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
|
|
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">输入参数</label>
|
|
|
|
|
|
<div style="background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px; padding: 15px;">
|
|
|
|
|
|
<div id="mcpPropertiesContainer">
|
|
|
|
|
|
<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" id="addMcpPropertyBtn"
|
|
|
|
|
|
style="width: 100%; margin-top: 10px; padding: 8px 15px; border: none; border-radius: 5px; background-color: #2196f3; color: white; cursor: pointer; font-size: 14px;">
|
|
|
|
|
|
➕ 添加参数
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
|
|
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">
|
|
|
|
|
|
模拟返回结果 (JSON 格式,可选)
|
|
|
|
|
|
<span style="font-size: 12px; color: #999; font-weight: normal;">- 留空则返回默认成功消息</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'
|
|
|
|
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; font-family: 'Courier New', monospace; min-height: 100px; resize: vertical;"></textarea>
|
|
|
|
|
|
<div style="font-size: 12px; color: #666; margin-top: 4px;">
|
|
|
|
|
|
💡 提示:如果设置了模拟返回结果,工具调用时将返回这个 JSON 对象
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;">
|
|
|
|
|
|
<button type="button" id="cancelMcpBtn" style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #9e9e9e; color: white; cursor: pointer; font-size: 14px;">取消</button>
|
|
|
|
|
|
<button type="submit" style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #4caf50; color: white; cursor: pointer; font-size: 14px;">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-15 11:26:14 +08:00
|
|
|
|
</div>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<!-- Opus解码库 -->
|
|
|
|
|
|
<script src="libopus.js"></script>
|
2025-04-04 16:42:07 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
<script type="module">
|
|
|
|
|
|
import { log } from './js/utils/logger.js';
|
|
|
|
|
|
import { webSocketConnect } from './js/xiaoZhiConnect.js';
|
|
|
|
|
|
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
|
|
|
|
|
|
import { addMessage } from './js/document.js'
|
|
|
|
|
|
import BlockingQueue from './js/utils/BlockingQueue.js'
|
2025-08-24 11:31:30 +08:00
|
|
|
|
import { createStreamingContext } from './js/StreamingContext.js'
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 需要加载的脚本列表 - 移除Opus依赖
|
|
|
|
|
|
const scriptFiles = [];
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 脚本加载状态
|
|
|
|
|
|
const scriptStatus = {
|
|
|
|
|
|
loading: 0,
|
|
|
|
|
|
loaded: 0,
|
|
|
|
|
|
failed: 0,
|
|
|
|
|
|
total: scriptFiles.length
|
|
|
|
|
|
};
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 全局变量
|
|
|
|
|
|
let websocket = null;
|
|
|
|
|
|
let mediaRecorder = null;
|
|
|
|
|
|
let audioContext = null;
|
|
|
|
|
|
let analyser = null;
|
|
|
|
|
|
let audioChunks = [];
|
|
|
|
|
|
let isRecording = false;
|
|
|
|
|
|
let visualizerCanvas = document.getElementById('audioVisualizer');
|
|
|
|
|
|
let visualizerContext = visualizerCanvas.getContext('2d');
|
|
|
|
|
|
let audioQueue = [];
|
|
|
|
|
|
let isPlaying = false;
|
|
|
|
|
|
let opusDecoder = null; // Opus解码器
|
|
|
|
|
|
let visualizationRequest = null; // 动画帧请求ID
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 音频流缓冲相关
|
|
|
|
|
|
let audioBuffers = []; // 用于存储接收到的所有音频数据
|
|
|
|
|
|
let totalAudioSize = 0; // 跟踪累积的音频大小
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
let audioBufferQueue = []; // 存储接收到的音频包
|
|
|
|
|
|
let isAudioPlaying = false; // 是否正在播放音频
|
|
|
|
|
|
const BUFFER_THRESHOLD = 3; // 缓冲包数量阈值,至少累积3个包再开始播放
|
|
|
|
|
|
const MIN_AUDIO_DURATION = 0.1; // 最小音频长度(秒),小于这个长度的音频会被合并
|
|
|
|
|
|
let streamingContext = null; // 音频流上下文
|
|
|
|
|
|
const SAMPLE_RATE = 16000; // 采样率
|
|
|
|
|
|
const CHANNELS = 1; // 声道数
|
|
|
|
|
|
const FRAME_SIZE = 960; // 帧大小
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// DOM元素
|
|
|
|
|
|
const connectButton = document.getElementById('connectButton');
|
|
|
|
|
|
const serverUrlInput = document.getElementById('serverUrl');
|
|
|
|
|
|
const connectionStatus = document.getElementById('connectionStatus');
|
|
|
|
|
|
const messageInput = document.getElementById('messageInput');
|
|
|
|
|
|
const sendTextButton = document.getElementById('sendTextButton');
|
|
|
|
|
|
const recordButton = document.getElementById('recordButton');
|
|
|
|
|
|
const stopButton = document.getElementById('stopButton');
|
|
|
|
|
|
const conversationDiv = document.getElementById('conversation');
|
|
|
|
|
|
const logContainer = document.getElementById('logContainer');
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-23 07:47:40 +08:00
|
|
|
|
function getAudioContextInstance() {
|
|
|
|
|
|
if (!audioContext) {
|
|
|
|
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
|
|
|
|
sampleRate: SAMPLE_RATE,
|
|
|
|
|
|
latencyHint: 'interactive'
|
|
|
|
|
|
});
|
|
|
|
|
|
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
|
|
|
|
|
|
}
|
|
|
|
|
|
return audioContext;
|
|
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 初始化可视化器
|
|
|
|
|
|
function initVisualizer() {
|
|
|
|
|
|
visualizerCanvas.width = visualizerCanvas.clientWidth;
|
|
|
|
|
|
visualizerCanvas.height = visualizerCanvas.clientHeight;
|
|
|
|
|
|
visualizerContext.fillStyle = '#fafafa';
|
|
|
|
|
|
visualizerContext.fillRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 绘制音频可视化效果
|
|
|
|
|
|
function drawVisualizer(dataArray) {
|
|
|
|
|
|
visualizationRequest = requestAnimationFrame(() => drawVisualizer(dataArray));
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
if (!isRecording) return;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
analyser.getByteFrequencyData(dataArray);
|
|
|
|
|
|
|
|
|
|
|
|
visualizerContext.fillStyle = '#fafafa';
|
|
|
|
|
|
visualizerContext.fillRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
const barWidth = (visualizerCanvas.width / dataArray.length) * 2.5;
|
|
|
|
|
|
let barHeight;
|
|
|
|
|
|
let x = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
|
|
|
|
barHeight = dataArray[i] / 2;
|
|
|
|
|
|
|
|
|
|
|
|
visualizerContext.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
|
|
|
|
|
visualizerContext.fillRect(x, visualizerCanvas.height - barHeight, barWidth, barHeight);
|
|
|
|
|
|
|
|
|
|
|
|
x += barWidth + 1;
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const queue = new BlockingQueue();
|
|
|
|
|
|
|
|
|
|
|
|
// 启动缓存进程
|
|
|
|
|
|
async function startAudioBuffering() {
|
|
|
|
|
|
log("开始音频缓冲...", 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 先尝试初始化解码器,以便在播放时已准备好
|
|
|
|
|
|
initOpusDecoder().catch(error => {
|
|
|
|
|
|
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
|
|
|
|
|
|
// 继续缓冲,我们会在播放时再次尝试初始化
|
|
|
|
|
|
});
|
|
|
|
|
|
const timeout = 300;
|
2025-08-14 14:44:52 +08:00
|
|
|
|
while (true) {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 每次数据空的时候等三条数据
|
|
|
|
|
|
const packets = await queue.dequeue(
|
|
|
|
|
|
3, // 至少 3 条
|
|
|
|
|
|
timeout, // 最多等 300 ms
|
|
|
|
|
|
(count) => { // 超时额外回调
|
|
|
|
|
|
log(`缓冲超时,当前缓冲包数: ${count},开始播放`, 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
if (packets.length) {
|
|
|
|
|
|
log(`已缓冲 ${packets.length} 个音频包,开始播放`, 'info');
|
|
|
|
|
|
streamingContext.pushAudioBuffer(packets)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 50毫秒里,有多少给多少
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
const data = await queue.dequeue(99, 50)
|
|
|
|
|
|
if (data.length) {
|
|
|
|
|
|
streamingContext.pushAudioBuffer(data)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-08-14 14:44:52 +08:00
|
|
|
|
}
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 播放已缓冲的音频
|
|
|
|
|
|
async function playBufferedAudio() {
|
|
|
|
|
|
// 确保Opus解码器已初始化
|
2025-03-31 09:38:26 +08:00
|
|
|
|
try {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 确保音频上下文存在
|
2025-08-23 07:47:40 +08:00
|
|
|
|
audioContext = getAudioContextInstance();
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 确保解码器已初始化
|
|
|
|
|
|
if (!opusDecoder) {
|
|
|
|
|
|
log('初始化Opus解码器...', 'info');
|
2025-03-31 09:38:26 +08:00
|
|
|
|
try {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
opusDecoder = await initOpusDecoder();
|
|
|
|
|
|
if (!opusDecoder) {
|
|
|
|
|
|
throw new Error('解码器初始化失败');
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
log('Opus解码器初始化成功', 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log('Opus解码器初始化失败: ' + error.message, 'error');
|
|
|
|
|
|
isAudioPlaying = false;
|
|
|
|
|
|
return;
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 创建流式播放上下文
|
|
|
|
|
|
if (!streamingContext) {
|
2025-08-24 11:31:30 +08:00
|
|
|
|
streamingContext = createStreamingContext(opusDecoder, audioContext, SAMPLE_RATE, CHANNELS, MIN_AUDIO_DURATION);
|
2025-08-16 14:36:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
streamingContext.decodeOpusFrames();
|
|
|
|
|
|
streamingContext.startPlaying();
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
|
|
|
|
|
|
isAudioPlaying = false;
|
|
|
|
|
|
streamingContext = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-24 11:31:30 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
|
|
|
|
|
async function initOpusDecoder() {
|
|
|
|
|
|
if (opusDecoder) return opusDecoder; // 已经初始化
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 检查ModuleInstance是否存在
|
|
|
|
|
|
if (typeof window.ModuleInstance === 'undefined') {
|
|
|
|
|
|
if (typeof Module !== 'undefined') {
|
|
|
|
|
|
// 使用全局Module作为ModuleInstance
|
|
|
|
|
|
window.ModuleInstance = Module;
|
|
|
|
|
|
log('使用全局Module作为ModuleInstance', 'info');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mod = window.ModuleInstance;
|
|
|
|
|
|
|
|
|
|
|
|
// 创建解码器对象
|
|
|
|
|
|
opusDecoder = {
|
|
|
|
|
|
channels: CHANNELS,
|
|
|
|
|
|
rate: SAMPLE_RATE,
|
|
|
|
|
|
frameSize: FRAME_SIZE,
|
|
|
|
|
|
module: mod,
|
|
|
|
|
|
decoderPtr: null, // 初始为null
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化解码器
|
|
|
|
|
|
init: function () {
|
|
|
|
|
|
if (this.decoderPtr) return true; // 已经初始化
|
|
|
|
|
|
|
|
|
|
|
|
// 获取解码器大小
|
|
|
|
|
|
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
|
|
|
|
|
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
|
|
|
|
|
|
|
|
|
|
|
|
// 分配内存
|
|
|
|
|
|
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) {
|
|
|
|
|
|
this.destroy(); // 清理资源
|
|
|
|
|
|
throw new Error(`Opus解码器初始化失败: ${err}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log("Opus解码器初始化成功", 'success');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 解码方法
|
|
|
|
|
|
decode: function (opusData) {
|
|
|
|
|
|
if (!this.decoderPtr) {
|
|
|
|
|
|
if (!this.init()) {
|
|
|
|
|
|
throw new Error("解码器未初始化且无法初始化");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
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}`);
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 复制解码后的数据
|
|
|
|
|
|
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;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`Opus解码错误: ${error.message}`, 'error');
|
|
|
|
|
|
return new Int16Array(0);
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
},
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 销毁方法
|
|
|
|
|
|
destroy: function () {
|
|
|
|
|
|
if (this.decoderPtr) {
|
|
|
|
|
|
this.module._free(this.decoderPtr);
|
|
|
|
|
|
this.decoderPtr = null;
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 初始化解码器
|
|
|
|
|
|
if (!opusDecoder.init()) {
|
|
|
|
|
|
throw new Error("Opus解码器初始化失败");
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
|
|
|
|
|
return opusDecoder;
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`Opus解码器初始化失败: ${error.message}`, 'error');
|
|
|
|
|
|
opusDecoder = null; // 重置为null,以便下次重试
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 初始化音频录制和处理
|
|
|
|
|
|
async function initAudio() {
|
2025-03-31 09:38:26 +08:00
|
|
|
|
try {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 请求麦克风权限
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
audio: {
|
|
|
|
|
|
echoCancellation: true,
|
|
|
|
|
|
noiseSuppression: true,
|
|
|
|
|
|
sampleRate: 16000, // 确保16kHz采样率
|
|
|
|
|
|
channelCount: 1 // 确保单声道
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
log('已获取麦克风访问权限', 'success');
|
2025-04-16 16:44:44 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 创建音频上下文
|
2025-08-23 07:47:40 +08:00
|
|
|
|
audioContext = getAudioContextInstance();
|
2025-08-16 14:36:23 +08:00
|
|
|
|
const source = audioContext.createMediaStreamSource(stream);
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 获取实际音频轨道设置
|
|
|
|
|
|
const audioTracks = stream.getAudioTracks();
|
|
|
|
|
|
if (audioTracks.length > 0) {
|
|
|
|
|
|
const track = audioTracks[0];
|
|
|
|
|
|
const settings = track.getSettings();
|
|
|
|
|
|
log(`实际麦克风设置 - 采样率: ${settings.sampleRate || '未知'}Hz, 声道数: ${settings.channelCount || '未知'}`, 'info');
|
|
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 创建分析器用于可视化
|
|
|
|
|
|
analyser = audioContext.createAnalyser();
|
|
|
|
|
|
analyser.fftSize = 2048;
|
|
|
|
|
|
source.connect(analyser);
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 尝试初始化MediaRecorder,按优先级尝试不同编码选项
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 优先尝试使用Opus编码
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream, {
|
|
|
|
|
|
mimeType: 'audio/webm;codecs=opus',
|
|
|
|
|
|
audioBitsPerSecond: 16000
|
|
|
|
|
|
});
|
|
|
|
|
|
log('已初始化MediaRecorder (使用Opus编码)', 'success');
|
|
|
|
|
|
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
|
|
|
|
|
} catch (e1) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 如果Opus不支持,尝试MP3
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream, {
|
|
|
|
|
|
mimeType: 'audio/webm',
|
|
|
|
|
|
audioBitsPerSecond: 16000
|
|
|
|
|
|
});
|
|
|
|
|
|
log('已初始化MediaRecorder (使用WebM标准编码,Opus不支持)', 'warning');
|
|
|
|
|
|
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
|
|
|
|
|
} catch (e2) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 尝试其他备选格式
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream, {
|
|
|
|
|
|
mimeType: 'audio/ogg;codecs=opus',
|
|
|
|
|
|
audioBitsPerSecond: 16000
|
|
|
|
|
|
});
|
|
|
|
|
|
log('已初始化MediaRecorder (使用OGG+Opus编码)', 'warning');
|
|
|
|
|
|
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
|
|
|
|
|
} catch (e3) {
|
|
|
|
|
|
// 最后使用默认编码
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
|
|
|
|
log(`已初始化MediaRecorder (使用默认编码: ${mediaRecorder.mimeType})`, 'warning');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 处理录制的数据
|
|
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
|
|
|
|
if (event.data.size > 0) {
|
|
|
|
|
|
audioChunks.push(event.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 录制结束后处理数据
|
|
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
|
|
|
|
// 停止可视化
|
|
|
|
|
|
if (visualizationRequest) {
|
|
|
|
|
|
cancelAnimationFrame(visualizationRequest);
|
|
|
|
|
|
visualizationRequest = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log(`录音结束,已收集的音频块数量: ${audioChunks.length}`, 'info');
|
|
|
|
|
|
if (audioChunks.length === 0) {
|
|
|
|
|
|
log('警告:没有收集到任何音频数据,请检查麦克风是否工作正常', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建完整的录音blob
|
|
|
|
|
|
const blob = new Blob(audioChunks, { type: audioChunks[0].type });
|
|
|
|
|
|
log(`已创建音频Blob,MIME类型: ${audioChunks[0].type},大小: ${(blob.size / 1024).toFixed(2)} KB`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 保存原始块,以防清空后需要调试
|
|
|
|
|
|
const chunks = [...audioChunks];
|
|
|
|
|
|
audioChunks = [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 将blob转换为ArrayBuffer
|
|
|
|
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
|
|
|
|
const uint8Array = new Uint8Array(arrayBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
log(`已转换为Uint8Array,准备发送,大小: ${(arrayBuffer.byteLength / 1024).toFixed(2)} KB`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 检查WebSocket状态
|
|
|
|
|
|
if (!websocket) {
|
|
|
|
|
|
log('错误:WebSocket连接不存在', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (websocket.readyState !== WebSocket.OPEN) {
|
|
|
|
|
|
log(`错误:WebSocket连接未打开,当前状态: ${websocket.readyState}`, 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 直接发送二进制音频数据 - 这是最简单有效的方式
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 注意:开始和结束消息已在录音开始和结束时发送
|
|
|
|
|
|
// 这里只需要发送音频数据
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
|
|
|
|
|
|
|
|
// 处理WebM容器格式,提取纯Opus数据
|
|
|
|
|
|
// 服务器使用opuslib_next.Decoder,需要纯Opus帧
|
|
|
|
|
|
log('正在处理音频数据,提取纯Opus帧...', 'info');
|
|
|
|
|
|
const opusData = extractOpusFrames(uint8Array);
|
|
|
|
|
|
|
|
|
|
|
|
// 记录Opus数据大小
|
|
|
|
|
|
log(`已提取Opus数据,大小: ${(opusData.byteLength / 1024).toFixed(2)} KB`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 发送音频消息第二步:二进制音频数据
|
|
|
|
|
|
websocket.send(opusData);
|
|
|
|
|
|
log(`已发送Opus音频数据: ${(opusData.byteLength / 1024).toFixed(2)} KB`, 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`音频数据发送失败: ${error.message}`, 'error');
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试使用base64编码作为备选方案
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('尝试使用base64编码方式发送...', 'info');
|
|
|
|
|
|
const base64Data = arrayBufferToBase64(arrayBuffer);
|
|
|
|
|
|
const audioDataMessage = {
|
|
|
|
|
|
type: 'audio',
|
|
|
|
|
|
action: 'data',
|
|
|
|
|
|
format: 'opus',
|
|
|
|
|
|
sample_rate: 16000,
|
|
|
|
|
|
channels: 1,
|
|
|
|
|
|
mime_type: chunks[0].type,
|
|
|
|
|
|
encoding: 'base64',
|
|
|
|
|
|
data: base64Data
|
|
|
|
|
|
};
|
|
|
|
|
|
websocket.send(JSON.stringify(audioDataMessage));
|
|
|
|
|
|
log(`已使用base64编码发送音频数据: ${(arrayBuffer.byteLength / 1024).toFixed(2)} KB`, 'warning');
|
|
|
|
|
|
} catch (base64Error) {
|
|
|
|
|
|
log(`所有数据发送方式均失败: ${base64Error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`处理录音数据错误: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试初始化Opus解码器
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 检查ModuleInstance是否存在(本地库导出的全局变量)
|
|
|
|
|
|
if (typeof window.ModuleInstance === 'undefined') {
|
|
|
|
|
|
throw new Error('Opus库未加载,ModuleInstance对象不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 简单测试ModuleInstance是否可用
|
|
|
|
|
|
if (typeof window.ModuleInstance._opus_decoder_get_size === 'function') {
|
|
|
|
|
|
const testSize = window.ModuleInstance._opus_decoder_get_size(1);
|
|
|
|
|
|
log(`Opus解码器测试成功,解码器大小: ${testSize} 字节`, 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('Opus解码函数未找到');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log(`Opus解码器初始化警告: ${err.message},将在需要时重试`, 'warning');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('音频系统初始化完成', 'success');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`音频初始化错误: ${error.message}`, 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 开始录音
|
|
|
|
|
|
function startRecording() {
|
|
|
|
|
|
if (isRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 最小录音时长提示
|
|
|
|
|
|
log('请至少录制1-2秒钟的音频,确保采集到足够数据', 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 获取服务器类型 - 从URL判断
|
|
|
|
|
|
const serverUrl = serverUrlInput.value.trim();
|
|
|
|
|
|
let isXiaozhiNative = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否是小智原生服务器 (根据URL特征判断)
|
|
|
|
|
|
if (serverUrl.includes('xiaozhi') || serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) {
|
|
|
|
|
|
isXiaozhiNative = true;
|
|
|
|
|
|
log('检测到小智原生服务器,使用标准listen协议', 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用直接PCM录音和libopus编码的方式
|
|
|
|
|
|
startDirectRecording();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`录音启动错误: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
|
function stopRecording() {
|
|
|
|
|
|
if (!isRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 使用直接PCM录音停止
|
|
|
|
|
|
stopDirectRecording();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`停止录音错误: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 连接WebSocket服务器
|
|
|
|
|
|
async function connectToServer() {
|
|
|
|
|
|
const url = serverUrlInput.value.trim();
|
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
|
// 先检查OTA状态
|
|
|
|
|
|
log('正在检查OTA状态...', 'info');
|
|
|
|
|
|
const otaUrl = document.getElementById('otaUrl').value.trim();
|
|
|
|
|
|
localStorage.setItem('otaUrl', otaUrl);
|
|
|
|
|
|
localStorage.setItem('wsUrl', url);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-17 17:51:56 +08:00
|
|
|
|
const ws = await webSocketConnect(otaUrl, config)
|
2025-08-16 14:36:23 +08:00
|
|
|
|
if (ws === undefined) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
websocket = ws
|
|
|
|
|
|
|
|
|
|
|
|
// 设置接收二进制数据的类型为ArrayBuffer
|
|
|
|
|
|
websocket.binaryType = 'arraybuffer';
|
|
|
|
|
|
|
|
|
|
|
|
websocket.onopen = async () => {
|
|
|
|
|
|
log(`已连接到服务器: ${url}`, 'success');
|
|
|
|
|
|
connectionStatus.textContent = 'ws已连接';
|
|
|
|
|
|
connectionStatus.style.color = 'green';
|
|
|
|
|
|
|
|
|
|
|
|
// 连接成功后发送hello消息
|
|
|
|
|
|
await sendHelloMessage();
|
|
|
|
|
|
|
|
|
|
|
|
connectButton.textContent = '断开';
|
|
|
|
|
|
connectButton.removeEventListener('click', connectToServer);
|
|
|
|
|
|
connectButton.addEventListener('click', disconnectFromServer);
|
|
|
|
|
|
// connectButton.onclick = disconnectFromServer;
|
|
|
|
|
|
messageInput.disabled = false;
|
|
|
|
|
|
sendTextButton.disabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
const audioInitialized = await initAudio();
|
|
|
|
|
|
if (audioInitialized) {
|
|
|
|
|
|
recordButton.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
websocket.onclose = () => {
|
|
|
|
|
|
log('已断开连接', 'info');
|
|
|
|
|
|
connectionStatus.textContent = 'ws已断开';
|
|
|
|
|
|
connectionStatus.style.color = 'red';
|
|
|
|
|
|
|
|
|
|
|
|
connectButton.textContent = '连接';
|
|
|
|
|
|
connectButton.removeEventListener('click', disconnectFromServer);
|
|
|
|
|
|
connectButton.addEventListener('click', connectToServer);
|
|
|
|
|
|
// connectButton.onclick = connectToServer;
|
|
|
|
|
|
messageInput.disabled = true;
|
|
|
|
|
|
sendTextButton.disabled = true;
|
|
|
|
|
|
recordButton.disabled = true;
|
2025-10-17 17:51:56 +08:00
|
|
|
|
// stopButton.disabled = true;
|
2025-08-16 14:36:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
websocket.onerror = (error) => {
|
|
|
|
|
|
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
|
|
|
|
|
|
connectionStatus.textContent = 'ws未连接';
|
|
|
|
|
|
connectionStatus.style.color = 'red';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
websocket.onmessage = function (event) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 检查是否为文本消息
|
|
|
|
|
|
if (typeof event.data === 'string') {
|
|
|
|
|
|
const message = JSON.parse(event.data);
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'hello') {
|
|
|
|
|
|
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
|
|
|
|
|
|
} else if (message.type === 'tts') {
|
|
|
|
|
|
// TTS状态消息
|
|
|
|
|
|
if (message.state === 'start') {
|
|
|
|
|
|
log('服务器开始发送语音', 'info');
|
|
|
|
|
|
} else if (message.state === 'sentence_start') {
|
|
|
|
|
|
log(`服务器发送语音段: ${message.text}`, 'info');
|
|
|
|
|
|
// 添加文本到会话记录
|
|
|
|
|
|
if (message.text) {
|
|
|
|
|
|
addMessage(message.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (message.state === 'sentence_end') {
|
|
|
|
|
|
log(`语音段结束: ${message.text}`, 'info');
|
|
|
|
|
|
} else if (message.state === 'stop') {
|
|
|
|
|
|
log('服务器语音传输结束', 'info');
|
|
|
|
|
|
// 结束后更新UI状态
|
|
|
|
|
|
if (recordButton.disabled) {
|
|
|
|
|
|
recordButton.disabled = false;
|
|
|
|
|
|
recordButton.textContent = '开始录音';
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (message.type === 'audio') {
|
|
|
|
|
|
// 音频控制消息
|
|
|
|
|
|
log(`收到音频控制消息: ${JSON.stringify(message)}`, 'info');
|
|
|
|
|
|
} else if (message.type === 'stt') {
|
|
|
|
|
|
// 语音识别结果
|
|
|
|
|
|
log(`识别结果: ${message.text}`, 'info');
|
|
|
|
|
|
// 添加识别结果到会话记录
|
|
|
|
|
|
addMessage(`[语音识别] ${message.text}`, true);
|
|
|
|
|
|
} else if (message.type === 'llm') {
|
|
|
|
|
|
// 大模型回复
|
|
|
|
|
|
log(`大模型回复: ${message.text}`, 'info');
|
|
|
|
|
|
// 添加大模型回复到会话记录
|
|
|
|
|
|
if (message.text && message.text !== '😊') {
|
|
|
|
|
|
addMessage(message.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (message.type === 'mcp') {
|
|
|
|
|
|
const payload = message.payload || {};
|
|
|
|
|
|
log(`服务器下发: ${JSON.stringify(message)}`, 'info');
|
2025-10-24 10:17:32 +08:00
|
|
|
|
|
|
|
|
|
|
if (payload.method === 'tools/list') {
|
|
|
|
|
|
// 返回工具列表
|
|
|
|
|
|
const tools = getMcpTools();
|
|
|
|
|
|
|
|
|
|
|
|
const replyMessage = JSON.stringify({
|
|
|
|
|
|
"session_id": message.session_id || "",
|
|
|
|
|
|
"type": "mcp",
|
|
|
|
|
|
"payload": {
|
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
|
"id": payload.id,
|
|
|
|
|
|
"result": {
|
|
|
|
|
|
"tools": tools
|
2025-08-16 14:36:23 +08:00
|
|
|
|
}
|
2025-10-24 10:17:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
log(`客户端上报: ${replyMessage}`, 'info');
|
|
|
|
|
|
websocket.send(replyMessage);
|
|
|
|
|
|
log(`回复MCP工具列表: ${tools.length} 个工具`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
} else if (payload.method === 'tools/call') {
|
|
|
|
|
|
// 调用工具
|
|
|
|
|
|
const toolName = payload.params?.name;
|
|
|
|
|
|
const toolArgs = payload.params?.arguments;
|
|
|
|
|
|
|
|
|
|
|
|
log(`调用工具: ${toolName} 参数: ${JSON.stringify(toolArgs)}`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
// 执行工具
|
|
|
|
|
|
const result = executeMcpTool(toolName, toolArgs);
|
|
|
|
|
|
|
|
|
|
|
|
const replyMessage = JSON.stringify({
|
|
|
|
|
|
"session_id": message.session_id || "",
|
|
|
|
|
|
"type": "mcp",
|
|
|
|
|
|
"payload": {
|
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
|
"id": payload.id,
|
|
|
|
|
|
"result": {
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": JSON.stringify(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"isError": false
|
2025-08-16 14:36:23 +08:00
|
|
|
|
}
|
2025-10-24 10:17:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
2025-10-24 10:17:32 +08:00
|
|
|
|
log(`客户端上报: ${replyMessage}`, 'info');
|
|
|
|
|
|
|
|
|
|
|
|
websocket.send(replyMessage);
|
|
|
|
|
|
} else if(payload.method === 'initialize') {
|
|
|
|
|
|
log(`收到工具初始化请求: ${JSON.stringify(payload.params)}`, 'info');
|
|
|
|
|
|
// 目前仅记录日志
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log(`未知的MCP方法: ${payload.method}`, 'warning');
|
|
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 未知消息类型
|
|
|
|
|
|
log(`未知消息类型: ${message.type}`, 'info');
|
|
|
|
|
|
addMessage(JSON.stringify(message, null, 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 处理二进制数据 - 兼容多种二进制格式
|
|
|
|
|
|
handleBinaryMessage(event.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`WebSocket消息处理错误: ${error.message}`, 'error');
|
|
|
|
|
|
// 非JSON格式文本消息直接显示
|
|
|
|
|
|
if (typeof event.data === 'string') {
|
|
|
|
|
|
addMessage(event.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
connectionStatus.textContent = 'ws未连接';
|
|
|
|
|
|
connectionStatus.style.color = 'orange';
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`连接错误: ${error.message}`, 'error');
|
|
|
|
|
|
connectionStatus.textContent = 'ws未连接';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送hello握手消息
|
|
|
|
|
|
async function sendHelloMessage() {
|
|
|
|
|
|
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
|
|
|
|
|
|
|
// 设置设备信息
|
|
|
|
|
|
const helloMessage = {
|
|
|
|
|
|
type: 'hello',
|
|
|
|
|
|
device_id: config.deviceId,
|
|
|
|
|
|
device_name: config.deviceName,
|
|
|
|
|
|
device_mac: config.deviceMac,
|
|
|
|
|
|
token: config.token,
|
|
|
|
|
|
features: {
|
|
|
|
|
|
mcp: true
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
log('发送hello握手消息', 'info');
|
|
|
|
|
|
websocket.send(JSON.stringify(helloMessage));
|
|
|
|
|
|
|
|
|
|
|
|
// 等待服务器响应
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
|
// 5秒超时
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
log('等待hello响应超时', 'error');
|
|
|
|
|
|
log('提示: 请尝试点击"测试认证"按钮进行连接排查', 'info');
|
|
|
|
|
|
resolve(false);
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
|
|
|
|
|
|
// 临时监听一次消息,接收hello响应
|
|
|
|
|
|
const onMessageHandler = (event) => {
|
2025-03-31 09:38:26 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const response = JSON.parse(event.data);
|
|
|
|
|
|
if (response.type === 'hello' && response.session_id) {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
log(`服务器握手成功,会话ID: ${response.session_id}`, 'success');
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
websocket.removeEventListener('message', onMessageHandler);
|
|
|
|
|
|
resolve(true);
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 忽略非JSON消息
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
websocket.addEventListener('message', onMessageHandler);
|
|
|
|
|
|
});
|
2025-03-31 09:38:26 +08:00
|
|
|
|
} catch (error) {
|
2025-08-16 14:36:23 +08:00
|
|
|
|
log(`发送hello消息错误: ${error.message}`, 'error');
|
|
|
|
|
|
return false;
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 断开WebSocket连接
|
|
|
|
|
|
function disconnectFromServer() {
|
|
|
|
|
|
if (!websocket) return;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
websocket.close();
|
|
|
|
|
|
stopRecording();
|
|
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 发送文本消息
|
|
|
|
|
|
function sendTextMessage() {
|
|
|
|
|
|
const message = messageInput.value.trim();
|
|
|
|
|
|
if (message === '' || !websocket || websocket.readyState !== WebSocket.OPEN) return;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 直接发送listen消息,不需要重复发送hello
|
|
|
|
|
|
const listenMessage = {
|
|
|
|
|
|
type: 'listen',
|
|
|
|
|
|
mode: 'manual',
|
|
|
|
|
|
state: 'detect',
|
|
|
|
|
|
text: message
|
|
|
|
|
|
};
|
2025-04-04 16:42:07 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
websocket.send(JSON.stringify(listenMessage));
|
|
|
|
|
|
addMessage(message, true);
|
|
|
|
|
|
log(`发送文本消息: ${message}`, 'info');
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
messageInput.value = '';
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`发送消息错误: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成随机MAC地址
|
|
|
|
|
|
function generateRandomMac() {
|
|
|
|
|
|
const hexDigits = '0123456789ABCDEF';
|
|
|
|
|
|
let mac = '';
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
|
if (i > 0) mac += ':';
|
|
|
|
|
|
for (let j = 0; j < 2; j++) {
|
|
|
|
|
|
mac += hexDigits.charAt(Math.floor(Math.random() * 16));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return mac;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化事件监听器
|
|
|
|
|
|
function initEventListeners() {
|
|
|
|
|
|
connectButton.addEventListener('click', connectToServer);
|
|
|
|
|
|
|
|
|
|
|
|
// 设备配置面板折叠/展开
|
|
|
|
|
|
const toggleButton = document.getElementById('toggleConfig');
|
|
|
|
|
|
const configPanel = document.getElementById('configPanel');
|
|
|
|
|
|
const deviceMacInput = document.getElementById('deviceMac');
|
|
|
|
|
|
const clientIdInput = document.getElementById('clientId');
|
|
|
|
|
|
const displayMac = document.getElementById('displayMac');
|
|
|
|
|
|
const displayClient = document.getElementById('displayClient');
|
|
|
|
|
|
|
|
|
|
|
|
// 从localStorage加载MAC地址,如果没有则生成新的
|
|
|
|
|
|
let savedMac = localStorage.getItem('deviceMac');
|
|
|
|
|
|
if (!savedMac) {
|
|
|
|
|
|
savedMac = generateRandomMac();
|
|
|
|
|
|
localStorage.setItem('deviceMac', savedMac);
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceMacInput.value = savedMac;
|
|
|
|
|
|
displayMac.textContent = savedMac;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新显示的值
|
|
|
|
|
|
function updateDisplayValues() {
|
|
|
|
|
|
const newMac = deviceMacInput.value;
|
|
|
|
|
|
displayMac.textContent = newMac;
|
|
|
|
|
|
displayClient.textContent = clientIdInput.value;
|
|
|
|
|
|
// 保存MAC地址到localStorage
|
|
|
|
|
|
localStorage.setItem('deviceMac', newMac);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听输入变化
|
|
|
|
|
|
deviceMacInput.addEventListener('input', updateDisplayValues);
|
|
|
|
|
|
clientIdInput.addEventListener('input', updateDisplayValues);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始更新显示值
|
|
|
|
|
|
updateDisplayValues();
|
|
|
|
|
|
|
|
|
|
|
|
const savedOtaUrl = localStorage.getItem('otaUrl');
|
|
|
|
|
|
if (savedOtaUrl) {
|
|
|
|
|
|
document.getElementById('otaUrl').value = savedOtaUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换面板显示
|
|
|
|
|
|
toggleButton.addEventListener('click', () => {
|
|
|
|
|
|
const isExpanded = configPanel.classList.contains('expanded');
|
|
|
|
|
|
configPanel.classList.toggle('expanded');
|
|
|
|
|
|
toggleButton.textContent = isExpanded ? '编辑' : '收起';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 标签页切换
|
|
|
|
|
|
const tabs = document.querySelectorAll('.tab');
|
|
|
|
|
|
tabs.forEach(tab => {
|
|
|
|
|
|
tab.addEventListener('click', () => {
|
|
|
|
|
|
// 移除所有标签页的active类
|
|
|
|
|
|
tabs.forEach(t => t.classList.remove('active'));
|
|
|
|
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
|
|
|
|
|
|
|
|
// 添加当前标签页的active类
|
|
|
|
|
|
tab.classList.add('active');
|
|
|
|
|
|
document.getElementById(`${tab.dataset.tab}Tab`).classList.add('active');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sendTextButton.addEventListener('click', sendTextMessage);
|
|
|
|
|
|
messageInput.addEventListener('keypress', (e) => {
|
|
|
|
|
|
if (e.key === 'Enter') sendTextMessage();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
recordButton.addEventListener('click', () => {
|
|
|
|
|
|
if (isRecording) {
|
|
|
|
|
|
stopRecording();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startRecording();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', initVisualizer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 帮助函数:ArrayBuffer转Base64
|
|
|
|
|
|
function arrayBufferToBase64(buffer) {
|
|
|
|
|
|
let binary = '';
|
|
|
|
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
|
|
const len = bytes.byteLength;
|
|
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
|
|
|
|
binary += String.fromCharCode(bytes[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return window.btoa(binary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Opus编码器
|
|
|
|
|
|
let opusEncoder;
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化应用
|
|
|
|
|
|
function initApp() {
|
|
|
|
|
|
initVisualizer();
|
|
|
|
|
|
initEventListeners();
|
|
|
|
|
|
|
|
|
|
|
|
// 检查libopus.js是否正确加载
|
|
|
|
|
|
checkOpusLoaded();
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化Opus编码器
|
|
|
|
|
|
opusEncoder = initOpusEncoder();
|
|
|
|
|
|
|
|
|
|
|
|
// 预加载Opus解码器
|
|
|
|
|
|
log('预加载Opus解码器...', 'info');
|
|
|
|
|
|
initOpusDecoder().then(() => {
|
|
|
|
|
|
log('Opus解码器预加载成功', 'success');
|
|
|
|
|
|
}).catch(error => {
|
|
|
|
|
|
log(`Opus解码器预加载失败: ${error.message},将在需要时重试`, 'warning');
|
|
|
|
|
|
});
|
|
|
|
|
|
playBufferedAudio()
|
|
|
|
|
|
startAudioBuffering()
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PCM录音处理器代码 - 会被注入到AudioWorklet中
|
|
|
|
|
|
const audioProcessorCode = `
|
2025-03-31 09:38:26 +08:00
|
|
|
|
class AudioRecorderProcessor extends AudioWorkletProcessor {
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
super();
|
|
|
|
|
|
this.buffers = [];
|
|
|
|
|
|
this.frameSize = 960; // 60ms @ 16kHz = 960 samples
|
|
|
|
|
|
this.buffer = new Int16Array(this.frameSize);
|
|
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
|
|
this.isRecording = false;
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
// 监听来自主线程的消息
|
|
|
|
|
|
this.port.onmessage = (event) => {
|
|
|
|
|
|
if (event.data.command === 'start') {
|
|
|
|
|
|
this.isRecording = true;
|
|
|
|
|
|
this.port.postMessage({ type: 'status', status: 'started' });
|
|
|
|
|
|
} else if (event.data.command === 'stop') {
|
|
|
|
|
|
this.isRecording = false;
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
// 发送剩余的缓冲区
|
|
|
|
|
|
if (this.bufferIndex > 0) {
|
|
|
|
|
|
const finalBuffer = this.buffer.slice(0, this.bufferIndex);
|
|
|
|
|
|
this.port.postMessage({
|
|
|
|
|
|
type: 'buffer',
|
|
|
|
|
|
buffer: finalBuffer
|
|
|
|
|
|
});
|
|
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
|
|
}
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
this.port.postMessage({ type: 'status', status: 'stopped' });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
process(inputs, outputs, parameters) {
|
|
|
|
|
|
if (!this.isRecording) return true;
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
const input = inputs[0][0]; // 获取第一个输入通道
|
|
|
|
|
|
if (!input) return true;
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
// 将浮点采样转换为16位整数并存储
|
|
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
|
|
|
|
if (this.bufferIndex >= this.frameSize) {
|
|
|
|
|
|
// 缓冲区已满,发送给主线程并重置
|
|
|
|
|
|
this.port.postMessage({
|
|
|
|
|
|
type: 'buffer',
|
|
|
|
|
|
buffer: this.buffer.slice(0)
|
|
|
|
|
|
});
|
|
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
|
|
}
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
// 转换为16位整数 (-32768到32767)
|
|
|
|
|
|
this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
|
|
|
|
|
}
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-25 15:24:06 +08:00
|
|
|
|
|
2025-03-31 09:38:26 +08:00
|
|
|
|
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
|
|
|
|
|
|
`;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 创建音频处理器
|
|
|
|
|
|
async function createAudioProcessor() {
|
2025-08-23 07:47:40 +08:00
|
|
|
|
audioContext = getAudioContextInstance();
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 检查是否支持AudioWorklet
|
|
|
|
|
|
if (audioContext.audioWorklet) {
|
|
|
|
|
|
// 注册音频处理器
|
|
|
|
|
|
const blob = new Blob([audioProcessorCode], { type: 'application/javascript' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
await audioContext.audioWorklet.addModule(url);
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建音频处理节点
|
|
|
|
|
|
const audioProcessor = new AudioWorkletNode(audioContext, 'audio-recorder-processor');
|
|
|
|
|
|
|
|
|
|
|
|
// 设置音频处理消息处理
|
|
|
|
|
|
audioProcessor.port.onmessage = (event) => {
|
|
|
|
|
|
if (event.data.type === 'buffer') {
|
|
|
|
|
|
// 收到PCM缓冲区数据
|
|
|
|
|
|
processPCMBuffer(event.data.buffer);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
log('使用AudioWorklet处理音频', 'success');
|
|
|
|
|
|
return { node: audioProcessor, type: 'worklet' };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 使用旧版ScriptProcessorNode作为回退方案
|
|
|
|
|
|
log('AudioWorklet不可用,使用ScriptProcessorNode作为回退方案', 'warning');
|
|
|
|
|
|
|
|
|
|
|
|
const frameSize = 4096; // ScriptProcessorNode缓冲区大小
|
|
|
|
|
|
const scriptProcessor = audioContext.createScriptProcessor(frameSize, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
// 将audioProcess事件设置为处理音频数据
|
|
|
|
|
|
scriptProcessor.onaudioprocess = (event) => {
|
|
|
|
|
|
if (!isRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
const input = event.inputBuffer.getChannelData(0);
|
|
|
|
|
|
const buffer = new Int16Array(input.length);
|
|
|
|
|
|
|
|
|
|
|
|
// 将浮点数据转换为16位整数
|
|
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
|
|
|
|
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理PCM数据
|
|
|
|
|
|
processPCMBuffer(buffer);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 需要连接输出,否则不会触发处理
|
|
|
|
|
|
// 我们创建一个静音通道
|
|
|
|
|
|
const silent = audioContext.createGain();
|
|
|
|
|
|
silent.gain.value = 0;
|
|
|
|
|
|
scriptProcessor.connect(silent);
|
|
|
|
|
|
silent.connect(audioContext.destination);
|
|
|
|
|
|
|
|
|
|
|
|
return { node: scriptProcessor, type: 'processor' };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`创建音频处理器失败: ${error.message},尝试回退方案`, 'error');
|
|
|
|
|
|
|
|
|
|
|
|
// 最后回退方案:使用ScriptProcessorNode
|
|
|
|
|
|
try {
|
|
|
|
|
|
const frameSize = 4096; // ScriptProcessorNode缓冲区大小
|
|
|
|
|
|
const scriptProcessor = audioContext.createScriptProcessor(frameSize, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
scriptProcessor.onaudioprocess = (event) => {
|
|
|
|
|
|
if (!isRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
const input = event.inputBuffer.getChannelData(0);
|
|
|
|
|
|
const buffer = new Int16Array(input.length);
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
|
|
|
|
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
processPCMBuffer(buffer);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const silent = audioContext.createGain();
|
|
|
|
|
|
silent.gain.value = 0;
|
|
|
|
|
|
scriptProcessor.connect(silent);
|
|
|
|
|
|
silent.connect(audioContext.destination);
|
|
|
|
|
|
|
|
|
|
|
|
log('使用ScriptProcessorNode作为回退方案成功', 'warning');
|
|
|
|
|
|
return { node: scriptProcessor, type: 'processor' };
|
|
|
|
|
|
} catch (fallbackError) {
|
|
|
|
|
|
log(`回退方案也失败: ${fallbackError.message}`, 'error');
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化直接从PCM数据录音的系统
|
|
|
|
|
|
let audioProcessor = null;
|
|
|
|
|
|
let audioProcessorType = null;
|
|
|
|
|
|
let audioSource = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 处理PCM缓冲数据
|
|
|
|
|
|
let pcmDataBuffer = new Int16Array();
|
|
|
|
|
|
|
|
|
|
|
|
function processPCMBuffer(buffer) {
|
|
|
|
|
|
if (!isRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 将新的PCM数据追加到缓冲区
|
|
|
|
|
|
const newBuffer = new Int16Array(pcmDataBuffer.length + buffer.length);
|
|
|
|
|
|
newBuffer.set(pcmDataBuffer);
|
|
|
|
|
|
newBuffer.set(buffer, pcmDataBuffer.length);
|
|
|
|
|
|
pcmDataBuffer = newBuffer;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有足够的数据进行Opus编码(16000Hz, 60ms = 960个采样点)
|
|
|
|
|
|
const samplesPerFrame = 960; // 60ms @ 16kHz
|
|
|
|
|
|
|
|
|
|
|
|
while (pcmDataBuffer.length >= samplesPerFrame) {
|
|
|
|
|
|
// 从缓冲区取出一帧数据
|
|
|
|
|
|
const frameData = pcmDataBuffer.slice(0, samplesPerFrame);
|
|
|
|
|
|
pcmDataBuffer = pcmDataBuffer.slice(samplesPerFrame);
|
|
|
|
|
|
|
|
|
|
|
|
// 编码为Opus
|
|
|
|
|
|
encodeAndSendOpus(frameData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 编码并发送Opus数据
|
|
|
|
|
|
function encodeAndSendOpus(pcmData = null) {
|
|
|
|
|
|
if (!opusEncoder) {
|
|
|
|
|
|
log('Opus编码器未初始化', 'error');
|
2025-03-31 09:38:26 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 如果提供了PCM数据,则编码该数据
|
|
|
|
|
|
if (pcmData) {
|
|
|
|
|
|
// 使用已初始化的Opus编码器编码
|
|
|
|
|
|
const opusData = opusEncoder.encode(pcmData);
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
if (opusData && opusData.length > 0) {
|
|
|
|
|
|
// 存储音频帧
|
|
|
|
|
|
audioBuffers.push(opusData.buffer);
|
|
|
|
|
|
totalAudioSize += opusData.length;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 如果WebSocket已连接,则发送数据
|
|
|
|
|
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 服务端期望接收原始Opus数据,不需要任何额外包装
|
|
|
|
|
|
websocket.send(opusData.buffer);
|
|
|
|
|
|
log(`发送Opus帧,大小:${opusData.length}字节`, 'debug');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`WebSocket发送错误: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('Opus编码失败,无有效数据返回', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 处理剩余的PCM数据
|
|
|
|
|
|
if (pcmDataBuffer.length > 0) {
|
|
|
|
|
|
// 如果剩余的采样点不足一帧,用静音填充
|
|
|
|
|
|
const samplesPerFrame = 960;
|
|
|
|
|
|
if (pcmDataBuffer.length < samplesPerFrame) {
|
|
|
|
|
|
const paddedBuffer = new Int16Array(samplesPerFrame);
|
|
|
|
|
|
paddedBuffer.set(pcmDataBuffer);
|
|
|
|
|
|
// 剩余部分为0(静音)
|
|
|
|
|
|
encodeAndSendOpus(paddedBuffer);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
encodeAndSendOpus(pcmDataBuffer.slice(0, samplesPerFrame));
|
|
|
|
|
|
}
|
|
|
|
|
|
pcmDataBuffer = new Int16Array(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`Opus编码错误: ${error.message}`, 'error');
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
}
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 开始直接从PCM数据录音
|
|
|
|
|
|
async function startDirectRecording() {
|
|
|
|
|
|
if (isRecording) return;
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 初始化Opus编码器
|
|
|
|
|
|
if (!initOpusEncoder()) {
|
|
|
|
|
|
log('无法启动录音: Opus编码器初始化失败', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 请求麦克风权限
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
audio: {
|
|
|
|
|
|
echoCancellation: true,
|
|
|
|
|
|
noiseSuppression: true,
|
|
|
|
|
|
sampleRate: 16000,
|
|
|
|
|
|
channelCount: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 创建音频上下文和分析器
|
2025-08-23 07:47:40 +08:00
|
|
|
|
audioContext = getAudioContextInstance();
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 创建音频处理器
|
|
|
|
|
|
const processorResult = await createAudioProcessor();
|
|
|
|
|
|
if (!processorResult) {
|
|
|
|
|
|
log('无法创建音频处理器', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
audioProcessor = processorResult.node;
|
|
|
|
|
|
audioProcessorType = processorResult.type;
|
|
|
|
|
|
|
|
|
|
|
|
// 连接音频处理链
|
|
|
|
|
|
audioSource = audioContext.createMediaStreamSource(stream);
|
|
|
|
|
|
analyser = audioContext.createAnalyser();
|
|
|
|
|
|
analyser.fftSize = 2048;
|
|
|
|
|
|
|
|
|
|
|
|
audioSource.connect(analyser);
|
|
|
|
|
|
audioSource.connect(audioProcessor);
|
|
|
|
|
|
|
|
|
|
|
|
// 启动录音
|
|
|
|
|
|
pcmDataBuffer = new Int16Array();
|
|
|
|
|
|
audioBuffers = [];
|
|
|
|
|
|
totalAudioSize = 0;
|
|
|
|
|
|
isRecording = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 启动音频处理器的录音 - 只有AudioWorklet才需要发送消息
|
|
|
|
|
|
if (audioProcessorType === 'worklet' && audioProcessor.port) {
|
|
|
|
|
|
audioProcessor.port.postMessage({ command: 'start' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送监听开始消息
|
|
|
|
|
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
// 使用与服务端期望的listen消息格式
|
|
|
|
|
|
const listenMessage = {
|
|
|
|
|
|
type: 'listen',
|
|
|
|
|
|
mode: 'manual', // 使用手动模式,由我们控制开始/停止
|
|
|
|
|
|
state: 'start' // 表示开始录音
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
log(`发送录音开始消息: ${JSON.stringify(listenMessage)}`, 'info');
|
|
|
|
|
|
websocket.send(JSON.stringify(listenMessage));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('WebSocket未连接,无法发送开始消息', 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 开始音频可视化
|
|
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
|
|
drawVisualizer(dataArray);
|
|
|
|
|
|
|
|
|
|
|
|
// 在UI上显示录音计时器
|
|
|
|
|
|
let recordingSeconds = 0;
|
|
|
|
|
|
const recordingTimer = setInterval(() => {
|
|
|
|
|
|
recordingSeconds += 0.1;
|
|
|
|
|
|
recordButton.textContent = `停止录音 ${recordingSeconds.toFixed(1)}秒`;
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
// 保存计时器,以便在停止时清除
|
|
|
|
|
|
window.recordingTimer = recordingTimer;
|
|
|
|
|
|
|
|
|
|
|
|
recordButton.classList.add('recording');
|
|
|
|
|
|
recordButton.disabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
log('开始PCM直接录音', 'success');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`直接录音启动错误: ${error.message}`, 'error');
|
|
|
|
|
|
isRecording = false;
|
2025-08-15 11:26:14 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 停止直接从PCM数据录音
|
|
|
|
|
|
function stopDirectRecording() {
|
|
|
|
|
|
if (!isRecording) return;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
|
isRecording = false;
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 停止音频处理器的录音
|
|
|
|
|
|
if (audioProcessor) {
|
|
|
|
|
|
// 只有AudioWorklet才需要发送停止消息
|
|
|
|
|
|
if (audioProcessorType === 'worklet' && audioProcessor.port) {
|
|
|
|
|
|
audioProcessor.port.postMessage({ command: 'stop' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
audioProcessor.disconnect();
|
|
|
|
|
|
audioProcessor = null;
|
2025-03-31 09:38:26 +08:00
|
|
|
|
}
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 断开音频连接
|
|
|
|
|
|
if (audioSource) {
|
|
|
|
|
|
audioSource.disconnect();
|
|
|
|
|
|
audioSource = null;
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
2025-08-16 14:36:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 停止可视化
|
|
|
|
|
|
if (visualizationRequest) {
|
|
|
|
|
|
cancelAnimationFrame(visualizationRequest);
|
|
|
|
|
|
visualizationRequest = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除录音计时器
|
|
|
|
|
|
if (window.recordingTimer) {
|
|
|
|
|
|
clearInterval(window.recordingTimer);
|
|
|
|
|
|
window.recordingTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 编码并发送剩余的数据
|
|
|
|
|
|
encodeAndSendOpus();
|
|
|
|
|
|
|
|
|
|
|
|
// 发送一个空的消息作为结束标志(模拟接收到空音频数据的情况)
|
|
|
|
|
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
// 使用空的Uint8Array发送最后一个空帧
|
|
|
|
|
|
const emptyOpusFrame = new Uint8Array(0);
|
|
|
|
|
|
websocket.send(emptyOpusFrame);
|
|
|
|
|
|
|
|
|
|
|
|
// 发送监听结束消息
|
|
|
|
|
|
const stopMessage = {
|
|
|
|
|
|
type: 'listen',
|
|
|
|
|
|
mode: 'manual',
|
|
|
|
|
|
state: 'stop'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
websocket.send(JSON.stringify(stopMessage));
|
|
|
|
|
|
log('已发送录音停止信号', 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置UI
|
|
|
|
|
|
recordButton.textContent = '开始录音';
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
recordButton.disabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
log('停止PCM直接录音', 'success');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`直接录音停止错误: ${error.message}`, 'error');
|
|
|
|
|
|
return false;
|
2025-08-15 11:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
async function handleBinaryMessage(data) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
let arrayBuffer;
|
|
|
|
|
|
// 根据数据类型进行处理
|
|
|
|
|
|
if (data instanceof ArrayBuffer) {
|
|
|
|
|
|
arrayBuffer = data;
|
|
|
|
|
|
log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug');
|
|
|
|
|
|
} else if (data instanceof Blob) {
|
|
|
|
|
|
// 如果是Blob类型,转换为ArrayBuffer
|
|
|
|
|
|
arrayBuffer = await data.arrayBuffer();
|
|
|
|
|
|
log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log(`收到未知类型的二进制数据: ${typeof data}`, 'warning');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 创建Uint8Array用于处理
|
|
|
|
|
|
const opusData = new Uint8Array(arrayBuffer);
|
|
|
|
|
|
if (opusData.length > 0) {
|
|
|
|
|
|
// 将数据添加到缓冲队列
|
|
|
|
|
|
queue.enqueue(opusData);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('收到空音频数据帧,可能是结束标志', 'warning');
|
|
|
|
|
|
// 如果正在播放,发送结束信号
|
|
|
|
|
|
if (isAudioPlaying && streamingContext) {
|
|
|
|
|
|
streamingContext.endOfStream = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`处理二进制消息出错: ${error.message}`, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-15 11:26:14 +08:00
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
// 获取配置值
|
|
|
|
|
|
function getConfig() {
|
|
|
|
|
|
const deviceMac = document.getElementById('deviceMac').value.trim();
|
|
|
|
|
|
return {
|
|
|
|
|
|
deviceId: deviceMac, // 使用MAC地址作为deviceId
|
|
|
|
|
|
deviceName: document.getElementById('deviceName').value.trim(),
|
|
|
|
|
|
deviceMac: deviceMac,
|
|
|
|
|
|
clientId: document.getElementById('clientId').value.trim(),
|
|
|
|
|
|
token: document.getElementById('token').value.trim()
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 10:17:32 +08:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// MCP 工具管理逻辑
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
// 默认工具数据
|
2025-10-24 15:58:01 +08:00
|
|
|
|
const defaultMcpTools = await fetch("default-mcp-tools.json").then(res => res.json());
|
2025-10-24 10:17:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 全局变量
|
|
|
|
|
|
let mcpTools = [];
|
|
|
|
|
|
let mcpEditingIndex = null;
|
|
|
|
|
|
let mcpProperties = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化 MCP 工具
|
|
|
|
|
|
function initMcpTools() {
|
|
|
|
|
|
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="editMcpTool(${index})"
|
|
|
|
|
|
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #2196f3; color: white; cursor: pointer; font-size: 12px;">
|
|
|
|
|
|
✏️ 编辑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onclick="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="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="updateMcpProperty(${index}, 'name', this.value)" required>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label class="mcp-small-label">数据类型 *</label>
|
|
|
|
|
|
<select class="mcp-small-input" onchange="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="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="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="updateMcpProperty(${index}, 'description', this.value)">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label class="mcp-checkbox-label">
|
|
|
|
|
|
<input type="checkbox" ${prop.required ? 'checked' : ''}
|
|
|
|
|
|
onchange="updateMcpProperty(${index}, 'required', this.checked)">
|
|
|
|
|
|
必填参数
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加参数
|
|
|
|
|
|
function addMcpProperty() {
|
|
|
|
|
|
mcpProperties.push({
|
|
|
|
|
|
name: `param_${mcpProperties.length + 1}`,
|
|
|
|
|
|
type: 'string',
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
description: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
renderMcpProperties();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新参数
|
|
|
|
|
|
window.updateMcpProperty = function(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();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 删除参数
|
|
|
|
|
|
window.deleteMcpProperty = function(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');
|
2025-10-24 16:01:12 +08:00
|
|
|
|
errorContainer.innerHTML = `<div class="mcp-error">${message}</div>`;
|
2025-10-24 10:17:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 编辑工具
|
|
|
|
|
|
window.editMcpTool = function(index) {
|
|
|
|
|
|
openMcpModal(index);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 删除工具
|
|
|
|
|
|
window.deleteMcpTool = function(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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取工具列表
|
|
|
|
|
|
function getMcpTools() {
|
|
|
|
|
|
return mcpTools.map(tool => ({
|
|
|
|
|
|
name: tool.name,
|
|
|
|
|
|
description: tool.description,
|
|
|
|
|
|
inputSchema: tool.inputSchema
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 执行工具调用
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-16 14:36:23 +08:00
|
|
|
|
initApp();
|
2025-10-24 10:17:32 +08:00
|
|
|
|
initMcpTools();
|
2025-08-16 14:36:23 +08:00
|
|
|
|
</script>
|
2025-03-31 09:38:26 +08:00
|
|
|
|
</body>
|
2025-03-31 21:04:11 +08:00
|
|
|
|
|
|
|
|
|
|
</html>
|