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:
2026-05-19 21:09:48 +08:00
parent bcf4d4e621
commit 26a61cb57c
42 changed files with 2953 additions and 568 deletions
+269 -10
View File
@@ -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');