refactor: 认证系统重构 + DevTools CLI 重写 + 文档全面更新

- auth: Login 简化为管理员始终通过 .env 验证,GetProfile 修正 admin DB 查询
- devtools: .sh/.bat 同步重写为完整 CLI (start/stop/status/logs/build/db:*)
- docs: 新增 devtools.md,重写 Deploy.md (三种方式+Windows说明),更新 README/gateway-api
- voice-service: DashScope 实时流式 STT 支持
- gateway: Phase 6 多模型配置 + 多端客户端管理 + WebSocket 增强

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 14:55:47 +08:00
parent 83e94d9e97
commit 7eb5e984c2
18 changed files with 2405 additions and 677 deletions
+132 -13
View File
@@ -164,8 +164,10 @@ tr.expanded td { background: var(--bg3); }
/* 表单 */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px; font-weight: 500; }
.form-row { display: flex; gap: 10px; }
.form-row > * { flex: 1; }
.form-row { display: flex; gap: 10px; align-items: flex-start; }
.form-row > label { flex: 0 0 140px; padding-top: 6px; }
.form-row > :not(label) { flex: 1; }
.form-row .form-row-narrow { flex: 0 0 auto !important; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; font-family: inherit;
@@ -819,11 +821,12 @@ function connectWS() {
document.getElementById('ws-status-text').textContent = '断开(重连中)';
wsRetryTimer = setTimeout(connectWS, 3000);
};
ws.onerror = () => ws.close();
ws.onerror = () => { document.getElementById('ws-dot').className = 'disconnected'; };
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 === 'voice_transcript') handleVoiceTranscript(msg);
if (msg.type === 'status') {
STATE.serviceStatus = msg.data;
if (STATE.activePanel === 'services') renderServiceCards();
@@ -3014,7 +3017,116 @@ async function renderSTTPanel() {
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
}
container.innerHTML = statsHtml + tableHtml + voiceStatusHtml;
// 语音录制测试卡片
var recorderHtml = buildVoiceRecorderCard();
container.innerHTML = statsHtml + recorderHtml + tableHtml + voiceStatusHtml;
}
// ---- 语音录制测试 ----
var voiceMediaRecorder = null;
var voiceAudioChunks = [];
function buildVoiceRecorderCard() {
var isRecording = !!voiceMediaRecorder;
return '<div class="card" style="margin-bottom:14px">' +
'<div class="card-header"><span class="card-title">🎙️ 语音录制测试</span>' +
'<span style="font-size:11px;color:var(--text3)">录音后自动发送到 ASR 模型识别并传入对话</span></div>' +
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
'<button class="btn btn-accent" id="btn-record-start" onclick="startVoiceRecord()" ' + (isRecording ? 'disabled' : '') + '>🎙️ 开始录音</button>' +
'<button class="btn btn-red" id="btn-record-stop" onclick="stopVoiceRecord()" ' + (isRecording ? '' : 'disabled') + '>⏹️ 停止录音</button>' +
'<span id="record-status" style="font-size:12px;color:var(--text2)">' + (isRecording ? '🔴 录音中...' : '点击按钮开始录音') + '</span>' +
'<span id="record-timer" style="font-size:12px;font-family:monospace;color:var(--accent)"></span>' +
'</div>' +
'<div id="voice-result" style="margin-top:10px;font-size:12px;color:var(--text2)"></div>' +
'</div>';
}
var voiceRecordTimer = null;
var voiceRecordSeconds = 0;
async function startVoiceRecord() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('当前浏览器不支持录音功能');
return;
}
try {
var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
voiceAudioChunks = [];
voiceMediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' });
voiceMediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) voiceAudioChunks.push(e.data); };
voiceMediaRecorder.onstop = function() {
stream.getTracks().forEach(function(t) { t.stop(); });
processVoiceRecording();
};
voiceMediaRecorder.start();
voiceRecordSeconds = 0;
updateVoiceRecordUI(true);
voiceRecordTimer = setInterval(function() {
voiceRecordSeconds++;
var el = document.getElementById('record-timer');
if (el) el.textContent = voiceRecordSeconds + 's';
}, 1000);
} catch(e) {
alert('无法访问麦克风: ' + e.message);
}
}
function stopVoiceRecord() {
if (voiceMediaRecorder && voiceMediaRecorder.state === 'recording') {
voiceMediaRecorder.stop();
}
if (voiceRecordTimer) { clearInterval(voiceRecordTimer); voiceRecordTimer = null; }
updateVoiceRecordUI(false);
}
function updateVoiceRecordUI(isRecording) {
var startBtn = document.getElementById('btn-record-start');
var stopBtn = document.getElementById('btn-record-stop');
var statusEl = document.getElementById('record-status');
var timerEl = document.getElementById('record-timer');
if (startBtn) startBtn.disabled = isRecording;
if (stopBtn) stopBtn.disabled = !isRecording;
if (statusEl) statusEl.innerHTML = isRecording ? '🔴 录音中...' : '点击按钮开始录音';
if (timerEl) timerEl.textContent = isRecording ? '0s' : '';
}
async function processVoiceRecording() {
var resultEl = document.getElementById('voice-result');
if (!resultEl) return;
if (voiceAudioChunks.length === 0) {
resultEl.innerHTML = '<span style="color:var(--orange)">录音数据为空</span>';
return;
}
var blob = new Blob(voiceAudioChunks, { type: 'audio/webm' });
resultEl.innerHTML = '<span style="color:var(--text3)">📤 发送音频到 ASR 引擎 (' + (blob.size / 1024).toFixed(1) + ' KB)...</span>';
var reader = new FileReader();
reader.onload = function() {
var base64 = reader.result.split(',')[1];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'voice_input',
mode: 'voice_msg',
audio_data: base64,
session_id: STATE.activeSession || 'default',
timestamp: Date.now()
}));
resultEl.innerHTML = '<span style="color:var(--text3)">⏳ 等待语音识别结果...</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--red)">WebSocket 未连接,无法发送语音</span>';
}
};
reader.readAsDataURL(blob);
}
function handleVoiceTranscript(msg) {
var resultEl = document.getElementById('voice-result');
if (!resultEl) return;
if (msg.text) {
resultEl.innerHTML = '<div style="padding:8px 12px;background:var(--green-bg);border-radius:var(--radius-sm);border:1px solid var(--green)">' +
'<strong style="color:var(--green)">✅ 识别结果:</strong> ' + escHtml(msg.text) + '</div>';
}
}
function prependSTTTableRow(entry) {
@@ -3910,10 +4022,10 @@ function updateModelTemplateOptions() {
html += filtered.map(function(m) {
return '<div class="fetched-model-item"' +
' data-model="' + escHtml(m) + '"' +
' onclick="var mn=this.getAttribute(\"data-model\");selectFetchedModel(mn);"' +
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
' onmouseenter="this.style.background=\"var(--bg3)\""' +
' onmouseleave="this.style.background=\"\"">' + escHtml(m) + '</div>';
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
}).join('');
}
html += '</div>';
@@ -3936,10 +4048,10 @@ function updateModelTemplateOptions() {
} else {
results.innerHTML = filtered.map(function(m) {
return '<div class="fetched-model-item" data-model="' + escHtml(m) + '"' +
' onclick="var mn=this.getAttribute(\"data-model\");selectFetchedModel(mn);"' +
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
' onmouseenter="this.style.background=\"var(--bg3)\""' +
' onmouseleave="this.style.background=\"\"">' + escHtml(m) + '</div>';
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
}).join('');
}
}
@@ -4059,11 +4171,13 @@ function showModelForm(id) {
}).join('') + '</select></div>' +
(isEdit ? '' :
'<div class="form-row"><label>📋 快速模板</label>' +
'<div>' +
'<div style="display:flex;gap:8px">' +
'<div id="model-template-area" style="flex:1"><select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option></select></div>' +
'<button type="button" class="btn btn-sm" id="btn-fetch-models" onclick="fetchProviderModels()" style="white-space:nowrap" disabled>🔍 查询</button></div>' +
'<div style="font-size:11px;color:var(--text3);margin-top:4px">选择 Provider 后可用模板或点击查询在线获取模型列表</div></div>') +
'<div style="font-size:11px;color:var(--text3);margin-top:4px">选择 Provider 后可用模板或点击查询在线获取模型列表</div>' +
'</div></div>') +
'<div class="form-row"><label>描述</label>' +
'<input id="model-desc" class="input" value="' + escHtml(defaults.description || '') + '" placeholder="用于日常对话的模型"></div>' +
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>优先级</label>' +
@@ -4188,12 +4302,17 @@ function showRoutingForm(purpose) {
'<option value="memory_extraction"'+ (defaults.purpose === 'memory_extraction' ? ' selected' : '') +'>memory_extraction (记忆提取)</option>' +
'<option value="roleplay"'+ (defaults.purpose === 'roleplay' ? ' selected' : '') +'>roleplay (角色扮演)</option>' +
'<option value="long_context"'+ (defaults.purpose === 'long_context' ? ' selected' : '') +'>long_context (长文档处理)</option>' +
'<option value="speech_recognition"'+ (defaults.purpose === 'speech_recognition' ? ' selected' : '') +'>speech_recognition (实时语音识别)</option>' +
'<option value="speech_recognition_offline"'+ (defaults.purpose === 'speech_recognition_offline' ? ' selected' : '') +'>speech_recognition_offline (非实时语音识别)</option>' +
'</select></div>' +
'<div class="form-row"><label>回退模型链 <span style="color:var(--text2);font-weight:400">(勾选即加入,顺序=表格显示顺序)</span></label>' +
(models.length > 0 ? '<div class="btn-group" style="margin-bottom:8px">' +
'<div style="margin-bottom:10px">' +
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">' +
'<label style="flex:0 0 140px;padding-top:6px">回退模型链 <span style="color:var(--text2);font-weight:400">(勾选即加入,顺序=表格显示顺序)</span></label>' +
(models.length > 0 ? '<div class="btn-group">' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=true})">全选</button>' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=false})">取消全选</button>' +
'</div>' : '') +
'</div>' +
modelCheckboxes + '</div>' +
'<div class="form-row"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
'<input type="checkbox" id="routing-required"' + (defaults.required ? ' checked' : '') + '> 必需 (所有模型不可用时返回错误,而非回退到 .env)</label></div>' +