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
+162
View File
@@ -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) ----