feat: 第四轮大版本更新 — 修复4个严重Bug、2个UI Bug,实现自主思考重构与主-子会话架构
## 🐛 Bug 修复 - 修复前端对话无响应:消除 ChatContainer 中的双重 WebSocket 连接,优化 sendMessage 失败提示 - 修复 Memory-Service 数据库迁移失败:ai-core 和 memory-service 均添加 ALTER TABLE ADD COLUMN IF NOT EXISTS 模式演化 - 修复语音/STT 不可用:添加 MediaRecorder API 降级方案,修复 whisper-cli 输出文件名错误 - 修复仪表盘数据库按钮失效:补充按钮 ID 属性,重写 controlDB() 控制逻辑 ## 🎨 UI 修复 - 修正用户消息头像位置:从 flex-row-reverse 改为 justify-end - 移除空聊天列表的 emoji 占位图标 ## ✨ 新功能 - devtools 新增 STT 处理日志面板(环形缓冲区 + WebSocket 广播 + 可视化表格) - 新增 ADMIN_NICKNAME 环境变量,支持自定义管理员昵称 ## 🔧 改进 - 注册流程增加昵称必填字段(前后端同步) ## 🏗️ 架构重构 - 重构自主思考逻辑:从定时器轮询改为事件驱动(对话后触发 + 静默检测),优化提示词使其更自然人性化 - 实现主-子会话架构:新增 4 种子会话类型(general/memory/iot/knowledge),意图分析 → 并行分发 → 结果合成流程 ## 📄 新增文档 - docs/architecture/main-session-sub-session-design.md — 子会话架构设计文档
This commit is contained in:
+269
-10
@@ -628,6 +628,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<button class="nav-item" data-panel="toolCalls">
|
||||
<span class="nav-icon">🔧</span><span class="nav-label">工具调用</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="stt">
|
||||
<span class="nav-icon">🎤</span><span class="nav-label">语音识别</span>
|
||||
<span class="nav-badge" id="stt-badge" style="display:none">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="database">
|
||||
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
|
||||
<span class="nav-badge" id="db-badge" style="display:none">●</span>
|
||||
@@ -668,6 +672,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<div class="panel" id="panel-database"></div>
|
||||
<!-- 工具调用记录 -->
|
||||
<div class="panel" id="panel-toolCalls"></div>
|
||||
<!-- 语音识别日志 -->
|
||||
<div class="panel" id="panel-stt"></div>
|
||||
<!-- 自主思考日志 -->
|
||||
<div class="panel" id="panel-thinking"></div>
|
||||
<!-- 记忆时间线 -->
|
||||
@@ -714,6 +720,10 @@ const STATE = {
|
||||
memoryFilterImportance: 0,
|
||||
memorySearchText: '',
|
||||
memoryPanelInitialized: false,
|
||||
// STT 语音识别日志面板状态
|
||||
sttLogs: [],
|
||||
sttAutoRefresh: null,
|
||||
sttAutoRefreshInterval: null,
|
||||
// 时间线面板状态
|
||||
timelineData: [],
|
||||
timelineUserId: 'admin_admin',
|
||||
@@ -743,6 +753,7 @@ function connectWS() {
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'log') handleWSLog(msg.data);
|
||||
if (msg.type === 'stt-log') handleSTTLog(msg);
|
||||
if (msg.type === 'status') {
|
||||
STATE.serviceStatus = msg.data;
|
||||
if (STATE.activePanel === 'services') renderServiceCards();
|
||||
@@ -767,6 +778,35 @@ function handleWSLog(data) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSTTLog(msg) {
|
||||
const entry = msg.data || msg;
|
||||
if (!entry || !entry.id) return;
|
||||
// 添加到本地状态缓存(去重)
|
||||
const exists = STATE.sttLogs.find(function(e) { return e.id === entry.id; });
|
||||
if (!exists) {
|
||||
STATE.sttLogs.unshift(entry);
|
||||
if (STATE.sttLogs.length > 200) STATE.sttLogs.length = 200;
|
||||
}
|
||||
// 更新 badge
|
||||
updateSTTBadge();
|
||||
// 如果当前正在查看 STT 面板,增量更新表格
|
||||
if (STATE.activePanel === 'stt') {
|
||||
prependSTTTableRow(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSTTBadge() {
|
||||
const badge = document.getElementById('stt-badge');
|
||||
if (!badge) return;
|
||||
const count = STATE.sttLogs.length;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
@@ -891,7 +931,7 @@ function switchPanel(name) {
|
||||
const titles = {
|
||||
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
||||
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||
toolCalls: '🔧 工具调用记录', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
||||
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
||||
};
|
||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||
|
||||
@@ -912,6 +952,7 @@ function switchPanel(name) {
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
||||
}
|
||||
@@ -1013,9 +1054,9 @@ async function renderDashboard() {
|
||||
'<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="btn-group" style="margin-top:10px">' +
|
||||
'<button class="btn btn-xs btn-green" onclick="controlDB(\'start\')">▶ 启动</button>' +
|
||||
'<button class="btn btn-xs btn-red" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
|
||||
'<button class="btn btn-xs" onclick="controlDB(\'restart\')">🔄 重启</button>' +
|
||||
'<button class="btn btn-xs btn-green" id="db-card-start-btn" onclick="controlDB(\'start\')">▶ 启动</button>' +
|
||||
'<button class="btn btn-xs btn-red" id="db-card-stop-btn" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
|
||||
'<button class="btn btn-xs" id="db-card-restart-btn" onclick="controlDB(\'restart\')">🔄 重启</button>' +
|
||||
'<a href="#" onclick="switchPanel(\'database\');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
@@ -2449,6 +2490,10 @@ async function tunnelAction(action) {
|
||||
}
|
||||
|
||||
// ========== 数据库卡片控制 ==========
|
||||
var DB_ACTION_LABELS = {
|
||||
start: '启动', stop: '停止', restart: '重启'
|
||||
};
|
||||
|
||||
async function renderDBCard() {
|
||||
const data = await api('/api/db/status');
|
||||
const badge = document.getElementById('db-status-badge');
|
||||
@@ -2459,6 +2504,7 @@ async function renderDBCard() {
|
||||
if (data.error) {
|
||||
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
|
||||
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
|
||||
updateDBCardButtons(true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2470,17 +2516,72 @@ async function renderDBCard() {
|
||||
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
|
||||
if (portDisplay) portDisplay.textContent = data.port || 5432;
|
||||
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
|
||||
|
||||
// 同步按钮 disabled 状态: 在线时禁用启动,离线时禁用停止
|
||||
updateDBCardButtons(false, online);
|
||||
}
|
||||
|
||||
function updateDBCardButtons(disabled, online) {
|
||||
var startBtn = document.getElementById('db-card-start-btn');
|
||||
var stopBtn = document.getElementById('db-card-stop-btn');
|
||||
if (disabled) {
|
||||
if (startBtn) startBtn.disabled = true;
|
||||
if (stopBtn) stopBtn.disabled = true;
|
||||
} else {
|
||||
if (startBtn) startBtn.disabled = !!online;
|
||||
if (stopBtn) stopBtn.disabled = !online;
|
||||
}
|
||||
}
|
||||
|
||||
async function controlDB(action) {
|
||||
showToast('正在' + action + '数据库...', 'info');
|
||||
const data = await api('/api/db/' + action, { method: 'POST' });
|
||||
if (data.error) {
|
||||
var label = DB_ACTION_LABELS[action] || action;
|
||||
showToast('正在' + label + '数据库...', 'info');
|
||||
|
||||
// 禁用所有按钮防止重复点击
|
||||
updateDBCardButtons(true);
|
||||
|
||||
// 显示日志区域
|
||||
var logContainer = document.getElementById('db-card-log-container');
|
||||
var logEl = document.getElementById('db-card-log');
|
||||
if (!logContainer) {
|
||||
// 首次使用时动态创建日志区域
|
||||
var dbCard = document.getElementById('db-card');
|
||||
if (dbCard) {
|
||||
logContainer = document.createElement('div');
|
||||
logContainer.id = 'db-card-log-container';
|
||||
logContainer.style.cssText = 'margin-top:8px;display:none';
|
||||
logContainer.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
||||
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
|
||||
'<button class="btn btn-xs" onclick="var c=document.getElementById(\'db-card-log-container\');if(c)c.style.display=\'none\'">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="tunnel-log" id="db-card-log" style="max-height:120px"></div>';
|
||||
dbCard.appendChild(logContainer);
|
||||
logEl = document.getElementById('db-card-log');
|
||||
}
|
||||
}
|
||||
if (logContainer) logContainer.style.display = 'block';
|
||||
if (logEl) logEl.textContent = '执行中...';
|
||||
|
||||
var data = await api('/api/db/' + action, { method: 'POST' });
|
||||
if (data.error && !data.output) {
|
||||
if (logEl) logEl.textContent = '错误: ' + data.error;
|
||||
showToast('操作失败: ' + data.error, 'error');
|
||||
// 恢复按钮状态 — 重新读取在线状态
|
||||
setTimeout(renderDBCard, 1000);
|
||||
} else {
|
||||
showToast('数据库 ' + action + ' 完成', 'success');
|
||||
// 等待2秒后刷新状态
|
||||
setTimeout(renderDBCard, 2000);
|
||||
if (logEl) logEl.textContent = data.output || data.error || '(无输出)';
|
||||
if (data.success) {
|
||||
showToast('数据库' + label + '完成', 'success');
|
||||
} else {
|
||||
showToast(label + '完成 (查看日志)', 'info');
|
||||
}
|
||||
// 等待2秒后刷新数据库卡片和仪表盘
|
||||
setTimeout(function() {
|
||||
renderDBCard();
|
||||
// 如果当前在仪表盘面板,刷新仪表盘
|
||||
if (STATE.activePanel === 'dashboard') renderDashboard();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2664,6 +2765,164 @@ function toggleToolCallsAutoRefresh(on) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 面板8.5: 语音识别日志 (STT) ==========
|
||||
async function renderSTTPanel() {
|
||||
var container = document.getElementById('panel-stt');
|
||||
|
||||
if (!STATE.sttAutoRefresh) STATE.sttAutoRefresh = null;
|
||||
|
||||
var actionsEl = document.getElementById('panel-actions');
|
||||
var autoRefreshOn = STATE.sttAutoRefresh !== null;
|
||||
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
|
||||
'<input type="checkbox" id="stt-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleSTTAutoRefresh(this.checked)">' +
|
||||
'自动刷新 (5s)</label>' +
|
||||
'<button class="btn btn-sm" onclick="refreshSTTPanel()" style="margin-left:8px">🔄 刷新</button>' +
|
||||
'<button class="btn btn-sm btn-red" onclick="clearSTTLogs()" style="margin-left:4px">🗑 清空</button>';
|
||||
|
||||
// 首次渲染时从 API 拉取数据覆盖本地缓存
|
||||
var data = await api('/api/voice/logs?limit=200');
|
||||
if (data.error) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
|
||||
escHtml(data.error) +
|
||||
(data.hint ? '<br><small>' + escHtml(data.hint) + '</small>' : '') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
STATE.sttLogs = data.logs || [];
|
||||
updateSTTBadge();
|
||||
|
||||
// 统计卡片
|
||||
var totalLogs = data.total || STATE.sttLogs.length;
|
||||
var successCount = 0, errorCount = 0, totalDurationMs = 0;
|
||||
STATE.sttLogs.forEach(function(e) {
|
||||
if (e.status === 'success') successCount++;
|
||||
else errorCount++;
|
||||
totalDurationMs += (e.durationMs || 0);
|
||||
});
|
||||
var avgDurationMs = STATE.sttLogs.length > 0 ? Math.round(totalDurationMs / STATE.sttLogs.length) : 0;
|
||||
|
||||
var statsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
|
||||
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总请求数</div></div>' +
|
||||
'<div class="stat-card green"><div class="stat-value">' + successCount + '</div><div class="stat-label">成功</div></div>' +
|
||||
'<div class="stat-card red"><div class="stat-value">' + errorCount + '</div><div class="stat-label">失败</div></div>' +
|
||||
'<div class="stat-card blue"><div class="stat-value">' + avgDurationMs + 'ms</div><div class="stat-label">平均处理时间</div></div>' +
|
||||
'</div>';
|
||||
|
||||
// 构建表格
|
||||
var tableHtml = '<div class="table-wrap"><table>' +
|
||||
'<thead><tr>' +
|
||||
'<th style="width:130px;">时间</th>' +
|
||||
'<th style="width:60px;">状态</th>' +
|
||||
'<th style="width:80px;">音频大小</th>' +
|
||||
'<th style="width:70px;">预计时长</th>' +
|
||||
'<th style="width:70px;">语言</th>' +
|
||||
'<th style="width:80px;">处理时间</th>' +
|
||||
'<th>识别结果</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
|
||||
if (STATE.sttLogs.length > 0) {
|
||||
for (var i = 0; i < STATE.sttLogs.length; i++) {
|
||||
var log = STATE.sttLogs[i];
|
||||
var timeStr = log.timestamp ? new Date(log.timestamp).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||||
var statusBadgeHtml = log.status === 'success'
|
||||
? '<span class="badge badge-running">✓ 成功</span>'
|
||||
: '<span class="badge badge-error">✗ 失败</span>';
|
||||
var audioSizeMB = log.audioSizeMB || '—';
|
||||
var estDuration = log.estimatedDurationSec ? log.estimatedDurationSec + 's' : '—';
|
||||
var lang = log.language || '—';
|
||||
var durationMs = log.durationMs ? log.durationMs + 'ms' : '—';
|
||||
var textDisplay = log.status === 'success'
|
||||
? (log.text || '<span style="color:var(--text3);font-style:italic;">(空结果)</span>')
|
||||
: ('<span style="color:var(--red);">' + escHtml(log.error || '未知错误') + '</span>');
|
||||
var textLenLabel = log.textLength ? ' (' + log.textLength + '字)' : '';
|
||||
var rowId = 'stt-row-' + i;
|
||||
|
||||
tableHtml += '<tr class="stt-row" data-rowid="' + rowId + '" style="cursor:pointer;" onclick="toggleSTTExpand(\'' + rowId + '\')">' +
|
||||
'<td style="font-size:11px;color:var(--text2);white-space:nowrap;">' + escHtml(timeStr) + '</td>' +
|
||||
'<td>' + statusBadgeHtml + '</td>' +
|
||||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(audioSizeMB) + ' MB</td>' +
|
||||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(estDuration) + '</td>' +
|
||||
'<td style="text-align:center;">' + escHtml(lang) + '</td>' +
|
||||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;color:' + (log.durationMs > 5000 ? 'var(--orange)' : 'var(--text2)') + ';">' + escHtml(durationMs) + '</td>' +
|
||||
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + (log.status === 'success' ? escHtml((log.text || '').substring(0, 80)) + textLenLabel : textDisplay) + '</td>' +
|
||||
'</tr>';
|
||||
|
||||
// 展开行: 完整内容
|
||||
tableHtml += '<tr class="stt-expand" id="' + rowId + '-expand" style="display:none;">' +
|
||||
'<td colspan="7" style="background:var(--bg);padding:12px 24px;">' +
|
||||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
|
||||
'<div><strong style="color:var(--text2);font-size:11px;">文件名:</strong> <span style="font-size:11px;">' + escHtml(log.filename || '—') + '</span></div>' +
|
||||
'<div><strong style="color:var(--text2);font-size:11px;">日志 ID:</strong> <code style="font-size:10px;">' + escHtml(log.id || '—') + '</code></div>' +
|
||||
'</div>';
|
||||
if (log.status === 'success') {
|
||||
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--text2);font-size:11px;">完整识别结果 (' + (log.textLength || 0) + ' 字):</strong>' +
|
||||
'<pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.text || '') + '</pre></div>';
|
||||
} else {
|
||||
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--red);font-size:11px;">错误详情:</strong>' +
|
||||
'<pre style="background:var(--red-bg);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:120px;overflow:auto;white-space:pre-wrap;color:var(--red);">' + escHtml(log.error || '未知错误') + '</pre></div>';
|
||||
}
|
||||
tableHtml += '</td></tr>';
|
||||
}
|
||||
} else {
|
||||
tableHtml += '<tr><td colspan="7" style="text-align:center;color:var(--text3);padding:30px;">暂无语音识别记录</td></tr>';
|
||||
}
|
||||
|
||||
tableHtml += '</tbody></table></div>';
|
||||
|
||||
// Voice Service 状态提示
|
||||
var voiceStatusHtml = '';
|
||||
try {
|
||||
var voiceStatusResp = await fetch('/api/voice/health');
|
||||
if (!voiceStatusResp.ok) {
|
||||
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
|
||||
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
|
||||
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = statsHtml + tableHtml + voiceStatusHtml;
|
||||
}
|
||||
|
||||
function prependSTTTableRow(entry) {
|
||||
var container = document.getElementById('panel-stt');
|
||||
var table = container.querySelector('table tbody');
|
||||
if (!table) return;
|
||||
// 简单刷新整个面板以保持统计准确
|
||||
renderSTTPanel();
|
||||
}
|
||||
|
||||
function toggleSTTExpand(rowId) {
|
||||
var expandRow = document.getElementById(rowId + '-expand');
|
||||
if (expandRow) {
|
||||
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSTTPanel() {
|
||||
renderSTTPanel();
|
||||
}
|
||||
|
||||
function clearSTTLogs() {
|
||||
STATE.sttLogs = [];
|
||||
updateSTTBadge();
|
||||
if (STATE.activePanel === 'stt') renderSTTPanel();
|
||||
}
|
||||
|
||||
function toggleSTTAutoRefresh(on) {
|
||||
if (STATE.sttAutoRefresh) {
|
||||
clearInterval(STATE.sttAutoRefresh);
|
||||
STATE.sttAutoRefresh = null;
|
||||
}
|
||||
if (on) {
|
||||
STATE.sttAutoRefresh = setInterval(function() {
|
||||
if (STATE.activePanel === 'stt') renderSTTPanel();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 面板9: 自主思考日志 ==========
|
||||
async function renderThinkingPanel() {
|
||||
var container = document.getElementById('panel-thinking');
|
||||
|
||||
@@ -620,6 +620,168 @@ app.get('/api/tool-calls/stats', async (_req, res) => {
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- STT 处理日志存储 (内存环形缓冲区) ----
|
||||
const sttLogEntries = [];
|
||||
const MAX_STT_LOGS = 200;
|
||||
|
||||
/**
|
||||
* 记录 STT 请求日志(devtools 自身维护,因为 voice-service 无持久化日志)
|
||||
*/
|
||||
function recordSTTLog(entry) {
|
||||
sttLogEntries.unshift(entry);
|
||||
if (sttLogEntries.length > MAX_STT_LOGS) {
|
||||
sttLogEntries.length = MAX_STT_LOGS;
|
||||
}
|
||||
// 通过 WebSocket 广播给前端面板实时更新
|
||||
broadcast('stt-log', entry);
|
||||
}
|
||||
|
||||
// GET /api/voice/logs — 获取 STT 处理日志
|
||||
app.get('/api/voice/logs', (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const logs = sttLogEntries.slice(0, limit);
|
||||
res.json({
|
||||
total: sttLogEntries.length,
|
||||
logs,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/voice/transcribe — 代理到 Voice-Service 并记录日志
|
||||
// 接受 JSON (base64 音频) 并转发为 multipart/form-data 到 Voice-Service
|
||||
app.post('/api/voice/transcribe', async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
let { audio_base64, language, filename } = req.body || {};
|
||||
|
||||
// 也支持直接通过 FormData 上传 (express.raw 中间件处理后手动解析)
|
||||
if (!audio_base64 && req.is('multipart/form-data')) {
|
||||
return res.status(400).json({ error: 'multipart/form-data 暂不支持,请使用 JSON 格式发送 base64 编码的音频' });
|
||||
}
|
||||
|
||||
if (!audio_base64) {
|
||||
return res.status(400).json({ error: '缺少 audio_base64 字段' });
|
||||
}
|
||||
|
||||
// 计算音频大小 (解码后)
|
||||
let audioBuffer;
|
||||
try {
|
||||
audioBuffer = Buffer.from(audio_base64, 'base64');
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'audio_base64 格式无效,无法解码' });
|
||||
}
|
||||
const audioSizeBytes = audioBuffer.length;
|
||||
// 估算音频时长 (WAV 16kHz 16bit mono: ~32000 bytes/sec)
|
||||
const estimatedDurationSec = audioSizeBytes > 0 ? (audioSizeBytes / 32000).toFixed(1) : '0';
|
||||
|
||||
if (!filename) filename = 'audio.wav';
|
||||
|
||||
try {
|
||||
// 构建 multipart/form-data 请求转发到 Voice-Service
|
||||
const boundary = '----DevToolsFormBoundary' + Math.random().toString(36).slice(2);
|
||||
const crlf = '\r\n';
|
||||
const headerParts = [
|
||||
'--' + boundary + crlf,
|
||||
'Content-Disposition: form-data; name="audio"; filename="' + filename + '"' + crlf,
|
||||
'Content-Type: application/octet-stream' + crlf,
|
||||
crlf,
|
||||
];
|
||||
const headerBytes = Buffer.from(headerParts.join(''), 'utf-8');
|
||||
const footerBytes = Buffer.from(crlf + '--' + boundary + '--' + crlf, 'utf-8');
|
||||
|
||||
// 如果有 language 参数
|
||||
let languagePart = Buffer.alloc(0);
|
||||
if (language) {
|
||||
const langHeader = [
|
||||
'--' + boundary + crlf,
|
||||
'Content-Disposition: form-data; name="language"' + crlf,
|
||||
crlf,
|
||||
language + crlf,
|
||||
];
|
||||
languagePart = Buffer.from(langHeader.join(''), 'utf-8');
|
||||
}
|
||||
|
||||
const multipartBody = Buffer.concat([headerBytes, audioBuffer, footerBytes]);
|
||||
// 如果需要 language 字段,插入在 audio 字段之后
|
||||
const finalBody = languagePart.length > 0
|
||||
? Buffer.concat([headerBytes, audioBuffer, languagePart, footerBytes])
|
||||
: multipartBody;
|
||||
|
||||
const voiceResp = await fetch(`${VOICE_SERVICE_URL}/api/v1/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||
},
|
||||
body: finalBody,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
|
||||
const voiceBody = await voiceResp.json().catch(() => null);
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (!voiceResp.ok || (voiceBody && voiceBody.error)) {
|
||||
const logEntry = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||||
estimatedDurationSec,
|
||||
language: language || 'zh',
|
||||
filename,
|
||||
durationMs: elapsedMs,
|
||||
text: null,
|
||||
error: voiceBody?.error || `HTTP ${voiceResp.status}`,
|
||||
};
|
||||
recordSTTLog(logEntry);
|
||||
return res.status(voiceResp.status).json({
|
||||
...voiceBody,
|
||||
devtools_log_id: logEntry.id,
|
||||
});
|
||||
}
|
||||
|
||||
const logEntry = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||||
estimatedDurationSec,
|
||||
language: voiceBody?.language || language || 'zh',
|
||||
filename,
|
||||
durationMs: voiceBody?.duration_ms || elapsedMs,
|
||||
text: voiceBody?.text || '',
|
||||
textLength: (voiceBody?.text || '').length,
|
||||
};
|
||||
recordSTTLog(logEntry);
|
||||
return res.json({
|
||||
...voiceBody,
|
||||
devtools_log_id: logEntry.id,
|
||||
});
|
||||
} catch (err) {
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
const logEntry = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||||
estimatedDurationSec,
|
||||
language: language || 'zh',
|
||||
filename,
|
||||
durationMs: elapsedMs,
|
||||
text: null,
|
||||
error: err.message,
|
||||
};
|
||||
recordSTTLog(logEntry);
|
||||
|
||||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||||
return res.status(502).json({
|
||||
error: `Voice-Service 不可达: ${err.message}`,
|
||||
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
|
||||
hint: isConnRefused
|
||||
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
|
||||
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
|
||||
devtools_log_id: logEntry.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 自主思考日志代理 (转发到 memory-service) ----
|
||||
|
||||
// ---- 语音识别服务代理 (转发到 voice-service) ----
|
||||
|
||||
Reference in New Issue
Block a user