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:
@@ -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