feat: Add model search, testing, and TTS/STT/embedding management to admin
- Fix model search for all providers: Bedrock now falls back to built-in list (with live ListFoundationModels attempt), Azure returns built-in list - Add Test button on every model row (built-in, discovered, custom) that sends a live prompt and shows response + latency in a toast - Add TTS management section: search voices from provider API (Google TTS voices.list, LiteLLM /v1/models, ElevenLabs /v1/voices), Set as Default writes tts.voice/tts.model to DB, runtime respects DB override - Add STT management section: search models from provider (Gemini, Whisper, LiteLLM, OpenAI, local), Set as Default writes stt.model to DB, runtime respects DB override in transcribe.js - Add Embedding models section: search from provider (LiteLLM, Vertex, OpenAI), Set as Default writes embeddings.model+dimensions to DB, embeddings.js respects DB override - Add record-and-transcribe STT test (browser MediaRecorder) - Add TTS synthesize-and-play test (returns base64 audio) - Add embedding generate test (shows dims + vector sample) - Expand PUT /config/:key(*) whitelist to include tts., stt., embeddings. - Add @aws-sdk/client-bedrock as optional dependency for live Bedrock discovery
This commit is contained in:
parent
78bb30e42f
commit
9c1d86d4b6
8 changed files with 1266 additions and 12 deletions
|
|
@ -36,6 +36,7 @@
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
"@aws-sdk/client-bedrock": "^3.700.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
|
||||||
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
||||||
"@aws-sdk/client-s3": "^3.700.0",
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,110 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TTS Model Management ───────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-volume-up"></i> Text-to-Speech (TTS)</h3>
|
||||||
|
<span id="admin-tts-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||||
|
<div id="admin-tts-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Search voices from provider -->
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Voices / Models from Provider</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" id="admin-tts-search" placeholder="Filter voices (e.g. Journey, Neural, alloy)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||||
|
<button id="btn-discover-tts" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-tts-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||||
|
<p id="admin-tts-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available voices from your configured TTS provider.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test TTS -->
|
||||||
|
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Synthesis</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;min-width:80px;">Voice:</label>
|
||||||
|
<select id="admin-tts-voice" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
|
||||||
|
</div>
|
||||||
|
<textarea id="admin-tts-test-text" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Text to synthesize...">Hello, this is a text to speech test for the Pediatric AI Scribe.</textarea>
|
||||||
|
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<button id="btn-test-tts" class="btn-sm btn-primary"><i class="fas fa-play"></i> Synthesize & Play</button>
|
||||||
|
<audio id="admin-tts-audio" controls style="display:none;height:32px;flex:1;min-width:180px;"></audio>
|
||||||
|
</div>
|
||||||
|
<p id="admin-tts-result" style="font-size:12px;color:var(--g500);margin:6px 0 0;"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── STT Model Management ───────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-microphone"></i> Speech-to-Text (STT)</h3>
|
||||||
|
<span id="admin-stt-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||||
|
<div id="admin-stt-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Search models from provider -->
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" id="admin-stt-search" placeholder="Filter models (e.g. gemini, whisper)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||||
|
<button id="btn-discover-stt" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-stt-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||||
|
<p id="admin-stt-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available STT models from your configured provider.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test STT -->
|
||||||
|
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test STT — Record & Transcribe</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<button id="btn-stt-record" class="btn-sm btn-primary"><i class="fas fa-microphone"></i> Start Recording</button>
|
||||||
|
<span id="admin-stt-recording-status" style="font-size:12px;color:var(--g500);"></span>
|
||||||
|
</div>
|
||||||
|
<div id="admin-stt-result" style="margin-top:10px;padding:10px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g700);display:none;">
|
||||||
|
<strong>Transcription:</strong> <span id="admin-stt-text"></span>
|
||||||
|
<div id="admin-stt-meta" style="font-size:11px;color:var(--g400);margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Embedding Models ───────────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-network-wired"></i> Embedding Models</h3>
|
||||||
|
<span id="admin-embed-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||||
|
<div id="admin-embed-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Search embedding models from provider -->
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" id="admin-embed-search" placeholder="Filter models (e.g. embedding, vertex)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||||
|
<button id="btn-discover-embeddings" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-embed-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||||
|
<p id="admin-embed-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available embedding models from your configured provider.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Embedding -->
|
||||||
|
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Embedding</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" id="admin-embed-test-text" value="Pediatric patient with fever and cough" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;" placeholder="Sample text to embed...">
|
||||||
|
<button id="btn-test-embedding" class="btn-sm btn-primary"><i class="fas fa-code-branch"></i> Generate</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-embed-result" style="margin-top:8px;font-size:12px;color:var(--g500);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||||
<div class="card" style="border:1px solid var(--red-light);">
|
<div class="card" style="border:1px solid var(--red-light);">
|
||||||
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,10 @@
|
||||||
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
|
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
|
||||||
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
|
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
|
||||||
if (e.target.closest('#btn-clear-all-models')) clearAllModels();
|
if (e.target.closest('#btn-clear-all-models')) clearAllModels();
|
||||||
|
if (e.target.closest('.admin-model-test-btn')) {
|
||||||
|
var btn = e.target.closest('.admin-model-test-btn');
|
||||||
|
testModel(btn.dataset.mid, btn);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow Enter key to trigger search
|
// Allow Enter key to trigger search
|
||||||
|
|
@ -649,12 +653,15 @@
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = data.models.map(function(m) {
|
container.innerHTML = data.models.map(function(m) {
|
||||||
var checked = m.enabled !== false ? 'checked' : '';
|
var checked = m.enabled !== false ? 'checked' : '';
|
||||||
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);cursor:pointer;font-size:13px;">' +
|
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<label style="display:flex;align-items:center;gap:8px;flex:1;cursor:pointer;margin:0;">' +
|
||||||
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
|
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
|
||||||
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
|
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
|
||||||
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
'</label>';
|
'</label>' +
|
||||||
|
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Test</button>' +
|
||||||
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.querySelectorAll('.admin-model-toggle').forEach(function(cb) {
|
container.querySelectorAll('.admin-model-toggle').forEach(function(cb) {
|
||||||
|
|
@ -716,6 +723,7 @@
|
||||||
data.models.slice(0, 100).map(function(m) {
|
data.models.slice(0, 100).map(function(m) {
|
||||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
|
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
|
||||||
|
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
|
||||||
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
|
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
|
||||||
|
|
@ -819,6 +827,7 @@
|
||||||
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
|
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
|
||||||
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
|
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
|
||||||
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
|
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -879,4 +888,557 @@
|
||||||
.catch(function() { showToast('Request failed', 'error'); });
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testModel(modelId, btn) {
|
||||||
|
if (!modelId) return;
|
||||||
|
var origText = btn ? btn.textContent : 'Test';
|
||||||
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
|
||||||
|
fetch('/api/admin/config/models/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ modelId: modelId })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
|
if (data.success) {
|
||||||
|
showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success');
|
||||||
|
} else {
|
||||||
|
showToast('Test failed: ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
|
showToast('Request failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN TTS MANAGEMENT
|
||||||
|
// ============================================================
|
||||||
|
(function() {
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('#btn-test-tts')) testTTS();
|
||||||
|
if (e.target.closest('#btn-discover-tts')) discoverTTS();
|
||||||
|
if (e.target.closest('.admin-tts-set-btn')) {
|
||||||
|
var btn = e.target.closest('.admin-tts-set-btn');
|
||||||
|
setTTSDefault(btn.dataset.id, btn.dataset.type, btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTTSConfig() {
|
||||||
|
fetch('/api/admin/config/tts', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
var badge = document.getElementById('admin-tts-provider-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = (data.provider || 'none').toUpperCase();
|
||||||
|
badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)';
|
||||||
|
badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)';
|
||||||
|
}
|
||||||
|
var info = document.getElementById('admin-tts-info');
|
||||||
|
if (info) {
|
||||||
|
var parts = [];
|
||||||
|
if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider);
|
||||||
|
if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice);
|
||||||
|
else if (data.envVoice) parts.push('Env voice: ' + data.envVoice);
|
||||||
|
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
|
||||||
|
else if (data.envModel) parts.push('Env model: ' + data.envModel);
|
||||||
|
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
|
||||||
|
if (configured.length) parts.push('Configured: ' + configured.join(', '));
|
||||||
|
info.textContent = parts.join(' · ') || 'Auto-detected from env';
|
||||||
|
}
|
||||||
|
var voiceSel = document.getElementById('admin-tts-voice');
|
||||||
|
if (voiceSel) {
|
||||||
|
voiceSel.innerHTML = '';
|
||||||
|
var voices = (data.voices && data.voices[data.provider]) || [];
|
||||||
|
if (data.currentVoice && voices.indexOf(data.currentVoice) === -1) voices = [data.currentVoice].concat(voices);
|
||||||
|
if (voices.length === 0) voices = ['default'];
|
||||||
|
voices.forEach(function(v) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = v;
|
||||||
|
opt.textContent = v + (v === data.currentVoice ? ' (active)' : '');
|
||||||
|
if (v === data.currentVoice) opt.selected = true;
|
||||||
|
voiceSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverTTS() {
|
||||||
|
var search = (document.getElementById('admin-tts-search') || {}).value || '';
|
||||||
|
var container = document.getElementById('admin-tts-discovered');
|
||||||
|
var hint = document.getElementById('admin-tts-discover-hint');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/tts/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = data.voices || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')</p>' +
|
||||||
|
items.slice(0, 100).map(function(v) {
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="voice" style="padding:2px 8px;font-size:11px;">Set</button>' +
|
||||||
|
'<span style="flex:1;">' + esc(v.name) + '</span>' +
|
||||||
|
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTTSDefault(id, type, btn) {
|
||||||
|
var key = type === 'model' ? 'tts.model' : 'tts.voice';
|
||||||
|
var origText = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
|
||||||
|
fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ value: id })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = 'var(--green)'; setTimeout(function() { btn.style.background = ''; }, 2000); }
|
||||||
|
if (data.success) {
|
||||||
|
showToast('TTS ' + type + ' set to: ' + id, 'success');
|
||||||
|
// Update voice selector
|
||||||
|
var voiceSel = document.getElementById('admin-tts-voice');
|
||||||
|
if (voiceSel && type === 'voice') {
|
||||||
|
var found = Array.from(voiceSel.options).find(function(o) { return o.value === id; });
|
||||||
|
if (!found) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = id; opt.textContent = id + ' (active)';
|
||||||
|
voiceSel.insertBefore(opt, voiceSel.firstChild);
|
||||||
|
}
|
||||||
|
voiceSel.value = id;
|
||||||
|
}
|
||||||
|
loadTTSConfig();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
|
showToast('Request failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTTS() {
|
||||||
|
var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.';
|
||||||
|
var voice = (document.getElementById('admin-tts-voice') || {}).value || '';
|
||||||
|
var btn = document.getElementById('btn-test-tts');
|
||||||
|
var resultEl = document.getElementById('admin-tts-result');
|
||||||
|
var audioEl = document.getElementById('admin-tts-audio');
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Synthesizing...'; }
|
||||||
|
if (resultEl) resultEl.textContent = '';
|
||||||
|
if (audioEl) audioEl.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/tts/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ text: text, voice: voice })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||||
|
if (!data.success) {
|
||||||
|
if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (audioEl && data.audio) {
|
||||||
|
var blob = base64ToBlob(data.audio, 'audio/mpeg');
|
||||||
|
audioEl.src = URL.createObjectURL(blob);
|
||||||
|
audioEl.style.display = 'inline-block';
|
||||||
|
audioEl.play();
|
||||||
|
}
|
||||||
|
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||||
|
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(base64, type) {
|
||||||
|
var binary = atob(base64);
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new Blob([bytes], { type: type });
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN STT MANAGEMENT
|
||||||
|
// ============================================================
|
||||||
|
(function() {
|
||||||
|
var mediaRecorder = null;
|
||||||
|
var audioChunks = [];
|
||||||
|
var recording = false;
|
||||||
|
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('#btn-stt-record')) toggleRecording();
|
||||||
|
if (e.target.closest('#btn-discover-stt')) discoverSTT();
|
||||||
|
if (e.target.closest('.admin-stt-set-btn')) {
|
||||||
|
var btn = e.target.closest('.admin-stt-set-btn');
|
||||||
|
setSTTDefault(btn.dataset.id, btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSTTConfig() {
|
||||||
|
fetch('/api/admin/config/stt', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
var badge = document.getElementById('admin-stt-provider-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = (data.provider || 'none').toUpperCase();
|
||||||
|
badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)';
|
||||||
|
badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)';
|
||||||
|
}
|
||||||
|
var info = document.getElementById('admin-stt-info');
|
||||||
|
if (info) {
|
||||||
|
var parts = [];
|
||||||
|
if (data.envProvider !== 'auto') parts.push('TRANSCRIBE_PROVIDER=' + data.envProvider);
|
||||||
|
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
|
||||||
|
else if (data.envModel) parts.push('Env model: ' + data.envModel);
|
||||||
|
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
|
||||||
|
if (configured.length) parts.push('Configured: ' + configured.join(', '));
|
||||||
|
info.textContent = parts.join(' · ') || 'Auto-detected from env';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverSTT() {
|
||||||
|
var search = (document.getElementById('admin-stt-search') || {}).value || '';
|
||||||
|
var container = document.getElementById('admin-stt-discovered');
|
||||||
|
var hint = document.getElementById('admin-stt-discover-hint');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/stt/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = data.models || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
|
||||||
|
items.map(function(m) {
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<button class="btn-sm btn-primary admin-stt-set-btn" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
|
||||||
|
'<span style="flex:1;">' + esc(m.name || m.id) + '</span>' +
|
||||||
|
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSTTDefault(modelId, btn) {
|
||||||
|
var origText = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
fetch('/api/admin/config/' + encodeURIComponent('stt.model'), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ value: modelId })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = data.success ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); }
|
||||||
|
if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); }
|
||||||
|
else showToast(data.error || 'Failed', 'error');
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
|
showToast('Request failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording() {
|
||||||
|
if (recording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
var btn = document.getElementById('btn-stt-record');
|
||||||
|
var status = document.getElementById('admin-stt-recording-status');
|
||||||
|
var resultEl = document.getElementById('admin-stt-result');
|
||||||
|
if (resultEl) resultEl.style.display = 'none';
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
.then(function(stream) {
|
||||||
|
audioChunks = [];
|
||||||
|
mediaRecorder = new MediaRecorder(stream);
|
||||||
|
mediaRecorder.addEventListener('dataavailable', function(e) {
|
||||||
|
if (e.data.size > 0) audioChunks.push(e.data);
|
||||||
|
});
|
||||||
|
mediaRecorder.addEventListener('stop', function() {
|
||||||
|
stream.getTracks().forEach(function(t) { t.stop(); });
|
||||||
|
transcribeRecording();
|
||||||
|
});
|
||||||
|
mediaRecorder.start();
|
||||||
|
recording = true;
|
||||||
|
if (btn) { btn.innerHTML = '<i class="fas fa-stop"></i> Stop Recording'; btn.style.background = 'var(--red)'; btn.style.color = 'white'; }
|
||||||
|
if (status) status.textContent = 'Recording... (click Stop when done)';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (status) status.textContent = 'Microphone access denied: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
recording = false;
|
||||||
|
var btn = document.getElementById('btn-stt-record');
|
||||||
|
var status = document.getElementById('admin-stt-recording-status');
|
||||||
|
if (btn) { btn.innerHTML = '<i class="fas fa-microphone"></i> Start Recording'; btn.style.background = ''; btn.style.color = ''; }
|
||||||
|
if (status) status.textContent = 'Processing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcribeRecording() {
|
||||||
|
if (audioChunks.length === 0) return;
|
||||||
|
var blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onloadend = function() {
|
||||||
|
var base64 = reader.result.split(',')[1];
|
||||||
|
fetch('/api/admin/config/stt/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ audioBase64: base64, mimeType: 'audio/webm' })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var status = document.getElementById('admin-stt-recording-status');
|
||||||
|
var resultEl = document.getElementById('admin-stt-result');
|
||||||
|
var textEl = document.getElementById('admin-stt-text');
|
||||||
|
var metaEl = document.getElementById('admin-stt-meta');
|
||||||
|
if (status) status.textContent = '';
|
||||||
|
if (resultEl) resultEl.style.display = 'block';
|
||||||
|
if (data.success) {
|
||||||
|
if (textEl) textEl.textContent = data.text || '(empty result)';
|
||||||
|
if (metaEl) metaEl.textContent = 'Provider: ' + (data.provider || '?') + ' · ' + (data.duration || 0) + 'ms';
|
||||||
|
} else {
|
||||||
|
if (textEl) textEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
||||||
|
if (metaEl) metaEl.textContent = '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
var status = document.getElementById('admin-stt-recording-status');
|
||||||
|
if (status) status.textContent = 'Request failed: ' + err.message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN EMBEDDING MODELS MANAGEMENT
|
||||||
|
// ============================================================
|
||||||
|
(function() {
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('#btn-test-embedding')) testEmbedding();
|
||||||
|
if (e.target.closest('#btn-discover-embeddings')) discoverEmbeddings();
|
||||||
|
if (e.target.closest('.admin-embed-set-btn')) {
|
||||||
|
var btn = e.target.closest('.admin-embed-set-btn');
|
||||||
|
setEmbeddingDefault(btn.dataset.id, btn.dataset.dims, btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEmbeddingConfig() {
|
||||||
|
fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
var badge = document.getElementById('admin-embed-provider-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = (data.provider || 'none').toUpperCase();
|
||||||
|
badge.style.background = data.configured ? 'var(--g100)' : 'var(--red-light)';
|
||||||
|
badge.style.color = data.configured ? 'var(--g600)' : 'var(--red)';
|
||||||
|
}
|
||||||
|
var info = document.getElementById('admin-embed-info');
|
||||||
|
if (info) {
|
||||||
|
var parts = [];
|
||||||
|
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
|
||||||
|
else if (data.envModel) parts.push('Env model: ' + data.envModel);
|
||||||
|
parts.push('Dims: ' + (data.currentDimensions || '?'));
|
||||||
|
if (!data.configured) parts.push('⚠️ Not configured');
|
||||||
|
info.textContent = parts.join(' · ');
|
||||||
|
}
|
||||||
|
var modelsEl = document.getElementById('admin-embed-models');
|
||||||
|
if (modelsEl && data.models) {
|
||||||
|
modelsEl.innerHTML = data.models.map(function(m) {
|
||||||
|
var isCurrent = m.id === data.currentModel;
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<button class="btn-sm admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + m.dims + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Set</button>' +
|
||||||
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span></span>' +
|
||||||
|
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
|
||||||
|
(isCurrent ? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">ACTIVE</span>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverEmbeddings() {
|
||||||
|
var search = (document.getElementById('admin-embed-search') || {}).value || '';
|
||||||
|
var container = document.getElementById('admin-embed-discovered');
|
||||||
|
var hint = document.getElementById('admin-embed-discover-hint');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/embeddings/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = data.models || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
|
||||||
|
items.map(function(m) {
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<button class="btn-sm btn-primary admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + (m.dims || '') + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
|
||||||
|
'<span style="flex:1;">' + esc(m.name || m.id) + (m.dims && m.dims !== '?' ? ' <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span>' : '') + '</span>' +
|
||||||
|
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEmbeddingDefault(modelId, dims, btn) {
|
||||||
|
var origText = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
var promises = [
|
||||||
|
fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), {
|
||||||
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId })
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
];
|
||||||
|
if (dims && dims !== '?') {
|
||||||
|
promises.push(
|
||||||
|
fetch('/api/admin/config/' + encodeURIComponent('embeddings.dimensions'), {
|
||||||
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: String(dims) })
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Promise.all(promises)
|
||||||
|
.then(function(results) {
|
||||||
|
var ok = results.every(function(r) { return r.success; });
|
||||||
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = ok ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); }
|
||||||
|
if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); }
|
||||||
|
else showToast(results[0].error || 'Failed', 'error');
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
|
showToast('Request failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEmbedding() {
|
||||||
|
var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test';
|
||||||
|
var resultEl = document.getElementById('admin-embed-result');
|
||||||
|
var btn = document.getElementById('btn-test-embedding');
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
||||||
|
if (resultEl) resultEl.textContent = 'Generating...';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/embeddings/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ text: text })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||||
|
if (!data.success) {
|
||||||
|
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.innerHTML =
|
||||||
|
'<strong>Dimensions:</strong> ' + data.dimensions + ' | ' +
|
||||||
|
'<strong>Model:</strong> ' + esc(data.model) + ' | ' +
|
||||||
|
'<strong>' + data.duration + 'ms</strong>' +
|
||||||
|
'<div style="margin-top:4px;font-family:monospace;font-size:11px;color:var(--g400);">Sample: [' + (data.sample || []).join(', ') + ', ...]</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||||
|
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,533 @@ router.post('/config/models/add-discovered', async function(req, res) {
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST test a model with a simple prompt ────────────────────────────────
|
||||||
|
router.post('/config/models/test', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { modelId } = req.body;
|
||||||
|
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
||||||
|
var { callAI } = require('../utils/ai');
|
||||||
|
var start = Date.now();
|
||||||
|
var result = await callAI(
|
||||||
|
[{ role: 'user', content: 'Reply with only the word: OK' }],
|
||||||
|
{ model: modelId, maxTokens: 20, temperature: 0 }
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
response: (result.content || '').trim(),
|
||||||
|
model: result.model,
|
||||||
|
provider: result.provider,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
tokens: result.usage
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET TTS provider status, voice list, and DB overrides ────────────────
|
||||||
|
router.get('/config/tts', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { isGoogleTTSConfigured } = require('../utils/ttsGoogle');
|
||||||
|
var envProvider = process.env.TTS_PROVIDER || 'auto';
|
||||||
|
var activeProvider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (process.env.LITELLM_API_BASE) activeProvider = 'litellm';
|
||||||
|
else if (isGoogleTTSConfigured()) activeProvider = 'google';
|
||||||
|
else if (process.env.ELEVENLABS_API_KEY) activeProvider = 'elevenlabs';
|
||||||
|
else activeProvider = 'none';
|
||||||
|
}
|
||||||
|
var dbVoice = await db.getSetting('tts.voice') || '';
|
||||||
|
var dbModel = await db.getSetting('tts.model') || '';
|
||||||
|
var envVoice = process.env.GOOGLE_TTS_VOICE || process.env.LITELLM_TTS_VOICE || '';
|
||||||
|
var envModel = process.env.LITELLM_TTS_MODEL || '';
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
provider: activeProvider,
|
||||||
|
envProvider: envProvider,
|
||||||
|
currentVoice: dbVoice || envVoice,
|
||||||
|
currentModel: dbModel || envModel,
|
||||||
|
dbVoice: dbVoice,
|
||||||
|
dbModel: dbModel,
|
||||||
|
envVoice: envVoice,
|
||||||
|
envModel: envModel,
|
||||||
|
configured: {
|
||||||
|
google: isGoogleTTSConfigured(),
|
||||||
|
litellm: !!process.env.LITELLM_API_BASE,
|
||||||
|
elevenlabs: !!process.env.ELEVENLABS_API_KEY
|
||||||
|
},
|
||||||
|
voices: {
|
||||||
|
google: ['en-US-Journey-F','en-US-Journey-D','en-US-Studio-O','en-US-Neural2-A','en-US-Neural2-C','en-US-Neural2-D','en-US-Neural2-E','en-US-Neural2-F','en-US-Neural2-G','en-US-Neural2-H','en-US-Neural2-I','en-US-Neural2-J'],
|
||||||
|
litellm: ['alloy','echo','fable','onyx','nova','shimmer','en-US-Journey-F','en-US-Journey-D'],
|
||||||
|
elevenlabs: ['pNInz6obpgDQGcFmaJgB']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET discover TTS voices from provider ────────────────────────────────
|
||||||
|
router.get('/config/tts/discover', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var search = (req.query.q || '').toLowerCase().trim();
|
||||||
|
var axios = require('axios');
|
||||||
|
var { isGoogleTTSConfigured } = require('../utils/ttsGoogle');
|
||||||
|
var discovered = [];
|
||||||
|
|
||||||
|
var envProvider = process.env.TTS_PROVIDER || 'auto';
|
||||||
|
var provider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (process.env.LITELLM_API_BASE) provider = 'litellm';
|
||||||
|
else if (isGoogleTTSConfigured()) provider = 'google';
|
||||||
|
else if (process.env.ELEVENLABS_API_KEY) provider = 'elevenlabs';
|
||||||
|
else provider = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
try {
|
||||||
|
var GoogleAuth = require('google-auth-library').GoogleAuth;
|
||||||
|
var auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
|
||||||
|
var client = await auth.getClient();
|
||||||
|
var tokenData = await client.getAccessToken();
|
||||||
|
var gResp = await axios.get('https://texttospeech.googleapis.com/v1/voices?languageCode=en', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + tokenData.token }, timeout: 10000
|
||||||
|
});
|
||||||
|
if (gResp.data && gResp.data.voices) {
|
||||||
|
gResp.data.voices.forEach(function(v) {
|
||||||
|
discovered.push({ id: v.name, name: v.name + ' (' + (v.ssmlGender || 'NEUTRAL') + ')', gender: v.ssmlGender, source: 'google-api' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Google TTS voice API failed: ' + e.message + ' — using built-in list');
|
||||||
|
['en-US-Journey-F','en-US-Journey-D','en-US-Studio-O','en-US-Neural2-A','en-US-Neural2-C','en-US-Neural2-D','en-US-Neural2-E','en-US-Neural2-F','en-US-Neural2-G','en-US-Neural2-H','en-US-Neural2-I','en-US-Neural2-J'].forEach(function(v) {
|
||||||
|
discovered.push({ id: v, name: v, source: 'google-builtin' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var lHeaders = {};
|
||||||
|
if (process.env.LITELLM_API_KEY) lHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
try {
|
||||||
|
var lResp = await axios.get(base + '/v1/models', { headers: lHeaders, timeout: 10000 });
|
||||||
|
if (lResp.data && lResp.data.data) {
|
||||||
|
lResp.data.data.forEach(function(m) {
|
||||||
|
var mid = (m.id || '').toLowerCase();
|
||||||
|
if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1) {
|
||||||
|
discovered.push({ id: m.id, name: m.id, source: 'litellm-api' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
|
||||||
|
['alloy','echo','fable','onyx','nova','shimmer'].forEach(function(v) {
|
||||||
|
if (!discovered.find(function(d) { return d.id === v; })) {
|
||||||
|
discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'builtin' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) {
|
||||||
|
try {
|
||||||
|
var elResp = await axios.get('https://api.elevenlabs.io/v1/voices', {
|
||||||
|
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY }, timeout: 10000
|
||||||
|
});
|
||||||
|
if (elResp.data && elResp.data.voices) {
|
||||||
|
elResp.data.voices.forEach(function(v) {
|
||||||
|
discovered.push({ id: v.voice_id, name: v.name, source: 'elevenlabs-api' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { logger.warn('ElevenLabs voice list failed: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
discovered = discovered.filter(function(d) {
|
||||||
|
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ success: true, provider: provider, voices: discovered, count: discovered.length });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST test TTS — returns base64 audio ─────────────────────────────────
|
||||||
|
router.post('/config/tts/test', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
|
||||||
|
var voice = req.body.voice;
|
||||||
|
var { isGoogleTTSConfigured, synthesizeWithGoogleTTS } = require('../utils/ttsGoogle');
|
||||||
|
var axios = require('axios');
|
||||||
|
|
||||||
|
var envProvider = process.env.TTS_PROVIDER || 'auto';
|
||||||
|
var provider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (process.env.LITELLM_API_BASE) provider = 'litellm';
|
||||||
|
else if (isGoogleTTSConfigured()) provider = 'google';
|
||||||
|
else if (process.env.ELEVENLABS_API_KEY) provider = 'elevenlabs';
|
||||||
|
else provider = 'none';
|
||||||
|
}
|
||||||
|
if (provider === 'none') return res.json({ success: false, error: 'No TTS provider configured' });
|
||||||
|
|
||||||
|
var buffer, usedVoice = voice;
|
||||||
|
if (provider === 'google') {
|
||||||
|
usedVoice = voice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
|
||||||
|
buffer = await synthesizeWithGoogleTTS(text, usedVoice);
|
||||||
|
} else if (provider === 'litellm') {
|
||||||
|
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
|
||||||
|
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
|
||||||
|
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
|
||||||
|
usedVoice = voice || process.env.LITELLM_TTS_VOICE || 'alloy';
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var ttsHeaders = { 'Content-Type': 'application/json' };
|
||||||
|
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
var ttsResp = await axios.post(base + '/v1/audio/speech',
|
||||||
|
{ model: ttsModel, voice: usedVoice, input: text },
|
||||||
|
{ headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 }
|
||||||
|
);
|
||||||
|
buffer = Buffer.from(ttsResp.data);
|
||||||
|
} else if (provider === 'elevenlabs') {
|
||||||
|
if (!process.env.ELEVENLABS_API_KEY) return res.json({ success: false, error: 'ElevenLabs not configured' });
|
||||||
|
usedVoice = voice || 'pNInz6obpgDQGcFmaJgB';
|
||||||
|
var elResp = await axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://api.elevenlabs.io/v1/text-to-speech/' + usedVoice,
|
||||||
|
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json' },
|
||||||
|
data: { text: text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } },
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
});
|
||||||
|
buffer = Buffer.from(elResp.data);
|
||||||
|
}
|
||||||
|
res.json({ success: true, audio: buffer.toString('base64'), provider: provider, voice: usedVoice });
|
||||||
|
} catch (e) {
|
||||||
|
var detail = e.response && e.response.data
|
||||||
|
? (Buffer.isBuffer(e.response.data) ? e.response.data.toString('utf8').substring(0, 300) : JSON.stringify(e.response.data).substring(0, 300))
|
||||||
|
: e.message;
|
||||||
|
res.json({ success: false, error: detail });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET STT provider status and model list ─────────────────────────────────
|
||||||
|
router.get('/config/stt', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { isGoogleSTTConfigured } = require('../utils/transcribeGoogle');
|
||||||
|
var { isAWSTranscribeConfigured } = require('../utils/transcribeAWS');
|
||||||
|
var { isLocalWhisperConfigured } = require('../utils/transcribeLocal');
|
||||||
|
var { whisperClient, litellmClient } = require('../utils/ai');
|
||||||
|
|
||||||
|
var envProvider = process.env.TRANSCRIBE_PROVIDER || 'auto';
|
||||||
|
var activeProvider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (isGoogleSTTConfigured()) activeProvider = 'google';
|
||||||
|
else if (isAWSTranscribeConfigured()) activeProvider = 'aws';
|
||||||
|
else if (isLocalWhisperConfigured()) activeProvider = 'local';
|
||||||
|
else if (litellmClient) activeProvider = 'litellm';
|
||||||
|
else if (whisperClient) activeProvider = 'openai';
|
||||||
|
else activeProvider = 'none';
|
||||||
|
}
|
||||||
|
var dbModel = await db.getSetting('stt.model') || '';
|
||||||
|
var envModel = process.env.GOOGLE_STT_MODEL || process.env.LITELLM_STT_MODEL || '';
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
provider: activeProvider,
|
||||||
|
envProvider: envProvider,
|
||||||
|
currentModel: dbModel || envModel,
|
||||||
|
dbModel: dbModel,
|
||||||
|
envModel: envModel,
|
||||||
|
configured: {
|
||||||
|
google: isGoogleSTTConfigured(),
|
||||||
|
aws: isAWSTranscribeConfigured(),
|
||||||
|
local: isLocalWhisperConfigured(),
|
||||||
|
litellm: !!litellmClient,
|
||||||
|
openai: !!whisperClient
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
google: ['gemini-2.0-flash', 'gemini-2.5-flash', 'gemini-2.0-flash-lite'],
|
||||||
|
aws: ['standard', 'medical'],
|
||||||
|
openai: ['whisper-1'],
|
||||||
|
litellm: ['whisper-1', 'gemini-2.0-flash', 'gemini-2.5-flash'],
|
||||||
|
local: ['tiny', 'base', 'small', 'medium', 'large']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET discover STT models from provider ────────────────────────────────
|
||||||
|
router.get('/config/stt/discover', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var search = (req.query.q || '').toLowerCase().trim();
|
||||||
|
var axios = require('axios');
|
||||||
|
var { isGoogleSTTConfigured } = require('../utils/transcribeGoogle');
|
||||||
|
var { isAWSTranscribeConfigured } = require('../utils/transcribeAWS');
|
||||||
|
var { isLocalWhisperConfigured } = require('../utils/transcribeLocal');
|
||||||
|
var { whisperClient, litellmClient } = require('../utils/ai');
|
||||||
|
var discovered = [];
|
||||||
|
|
||||||
|
var envProvider = process.env.TRANSCRIBE_PROVIDER || 'auto';
|
||||||
|
var provider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (isGoogleSTTConfigured()) provider = 'google';
|
||||||
|
else if (isAWSTranscribeConfigured()) provider = 'aws';
|
||||||
|
else if (isLocalWhisperConfigured()) provider = 'local';
|
||||||
|
else if (litellmClient) provider = 'litellm';
|
||||||
|
else if (whisperClient) provider = 'openai';
|
||||||
|
else provider = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
['gemini-2.5-flash','gemini-2.0-flash','gemini-2.0-flash-lite','gemini-1.5-pro','gemini-1.5-flash'].forEach(function(m) {
|
||||||
|
discovered.push({ id: m, name: m, source: 'google-builtin' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'aws') {
|
||||||
|
['standard','medical'].forEach(function(m) {
|
||||||
|
discovered.push({ id: m, name: 'AWS Transcribe ' + m.charAt(0).toUpperCase() + m.slice(1), source: 'aws-builtin' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'openai' && whisperClient) {
|
||||||
|
try {
|
||||||
|
var oModels = await whisperClient.models.list();
|
||||||
|
oModels.data.forEach(function(m) {
|
||||||
|
if (m.id.toLowerCase().indexOf('whisper') !== -1 || m.id.toLowerCase().indexOf('transcri') !== -1) {
|
||||||
|
discovered.push({ id: m.id, name: m.id, source: 'openai-api' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
discovered.push({ id: 'whisper-1', name: 'whisper-1', source: 'openai-builtin' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var sHeaders = {};
|
||||||
|
if (process.env.LITELLM_API_KEY) sHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
try {
|
||||||
|
var sResp = await axios.get(base + '/v1/models', { headers: sHeaders, timeout: 10000 });
|
||||||
|
if (sResp.data && sResp.data.data) {
|
||||||
|
sResp.data.data.forEach(function(m) {
|
||||||
|
var mid = (m.id || '').toLowerCase();
|
||||||
|
if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1) {
|
||||||
|
discovered.push({ id: m.id, name: m.id, source: 'litellm-api' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { logger.warn('LiteLLM STT discovery failed: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'local') {
|
||||||
|
var localModel = process.env.WHISPER_MODEL_SIZE || 'base';
|
||||||
|
discovered.push({ id: localModel, name: 'Local Whisper: ' + localModel + ' (configured)', source: 'local-env' });
|
||||||
|
['tiny','base','small','medium','large'].forEach(function(m) {
|
||||||
|
if (m !== localModel) discovered.push({ id: m, name: 'Local Whisper: ' + m, source: 'local-builtin' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
discovered = discovered.filter(function(d) {
|
||||||
|
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST test STT — accepts base64 audio, returns transcription ───────────
|
||||||
|
router.post('/config/stt/test', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { audioBase64, mimeType } = req.body;
|
||||||
|
if (!audioBase64) return res.json({ success: false, error: 'audioBase64 required' });
|
||||||
|
var audioBuffer = Buffer.from(audioBase64, 'base64');
|
||||||
|
var mime = mimeType || 'audio/webm';
|
||||||
|
|
||||||
|
var { isGoogleSTTConfigured, transcribeWithGemini } = require('../utils/transcribeGoogle');
|
||||||
|
var { isAWSTranscribeConfigured, transcribeWithAWS } = require('../utils/transcribeAWS');
|
||||||
|
var { isLocalWhisperConfigured, transcribeWithLocal } = require('../utils/transcribeLocal');
|
||||||
|
var { whisperClient, litellmClient } = require('../utils/ai');
|
||||||
|
var axios = require('axios');
|
||||||
|
|
||||||
|
var envProvider = process.env.TRANSCRIBE_PROVIDER || 'auto';
|
||||||
|
var provider = envProvider;
|
||||||
|
if (envProvider === 'auto') {
|
||||||
|
if (isGoogleSTTConfigured()) provider = 'google';
|
||||||
|
else if (isAWSTranscribeConfigured()) provider = 'aws';
|
||||||
|
else if (isLocalWhisperConfigured()) provider = 'local';
|
||||||
|
else if (litellmClient) provider = 'litellm';
|
||||||
|
else if (whisperClient) provider = 'openai';
|
||||||
|
else return res.json({ success: false, error: 'No STT provider configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = Date.now();
|
||||||
|
var text = '';
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
var model = process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
|
||||||
|
text = await transcribeWithGemini(audioBuffer, mime, model);
|
||||||
|
} else if (provider === 'aws') {
|
||||||
|
text = await transcribeWithAWS(audioBuffer, mime);
|
||||||
|
} else if (provider === 'local') {
|
||||||
|
text = await transcribeWithLocal(audioBuffer, mime);
|
||||||
|
} else if (provider === 'litellm') {
|
||||||
|
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
|
||||||
|
var sttModel = process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
var sttResp = await axios.post(base + '/v1/chat/completions', {
|
||||||
|
model: sttModel,
|
||||||
|
messages: [{ role: 'user', content: [
|
||||||
|
{ type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } },
|
||||||
|
{ type: 'text', text: 'Transcribe this audio. Output the spoken words only.' }
|
||||||
|
]}]
|
||||||
|
}, { headers: headers, timeout: 60000 });
|
||||||
|
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
|
||||||
|
text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || '';
|
||||||
|
}
|
||||||
|
} else if (provider === 'openai') {
|
||||||
|
if (!whisperClient) return res.json({ success: false, error: 'Whisper not configured' });
|
||||||
|
var file = new File([audioBuffer], 'audio.webm', { type: mime });
|
||||||
|
var wResult = await whisperClient.audio.transcriptions.create({
|
||||||
|
file: file, model: 'whisper-1', language: 'en', response_format: 'text'
|
||||||
|
});
|
||||||
|
text = typeof wResult === 'string' ? wResult : (wResult.text || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, text: text.trim(), provider: provider, duration: Date.now() - start });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET embedding model config ────────────────────────────────────────────
|
||||||
|
router.get('/config/embeddings', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { isEmbeddingsAvailable, DEFAULT_MODEL, DEFAULT_DIMS } = require('../utils/embeddings');
|
||||||
|
var dbModel = await db.getSetting('embeddings.model') || '';
|
||||||
|
var dbDims = await db.getSetting('embeddings.dimensions') || '';
|
||||||
|
var envModel = process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
||||||
|
var envDims = parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
|
||||||
|
|
||||||
|
var provider = 'none';
|
||||||
|
if (process.env.LITELLM_API_BASE) provider = 'litellm';
|
||||||
|
else if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT || process.env.GOOGLE_VERTEX_PROJECT) provider = 'vertex';
|
||||||
|
else if (process.env.OPENAI_API_KEY) provider = 'openai';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
provider: provider,
|
||||||
|
configured: isEmbeddingsAvailable(),
|
||||||
|
currentModel: dbModel || envModel,
|
||||||
|
currentDimensions: dbDims ? parseInt(dbDims) : envDims,
|
||||||
|
dbModel: dbModel,
|
||||||
|
dbDimensions: dbDims,
|
||||||
|
envModel: envModel,
|
||||||
|
envDimensions: envDims,
|
||||||
|
models: [
|
||||||
|
{ id: 'vertex_ai/text-embedding-005', name: 'Vertex text-embedding-005', dims: 768, tag: 'RECOMMENDED' },
|
||||||
|
{ id: 'vertex_ai/gemini-embedding-001', name: 'Vertex gemini-embedding-001', dims: 3072, tag: 'HIGH-DIM' },
|
||||||
|
{ id: 'vertex_ai/text-multilingual-embedding-002', name: 'Vertex multilingual-embedding-002', dims: 768, tag: 'MULTILINGUAL' },
|
||||||
|
{ id: 'text-embedding-3-small', name: 'OpenAI text-embedding-3-small', dims: 1536, tag: 'OPENAI' },
|
||||||
|
{ id: 'text-embedding-3-large', name: 'OpenAI text-embedding-3-large', dims: 3072, tag: 'OPENAI' },
|
||||||
|
{ id: 'text-embedding-ada-002', name: 'OpenAI text-embedding-ada-002', dims: 1536, tag: 'OPENAI' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET discover embedding models from provider ───────────────────────────
|
||||||
|
router.get('/config/embeddings/discover', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var search = (req.query.q || '').toLowerCase().trim();
|
||||||
|
var axios = require('axios');
|
||||||
|
var { DEFAULT_MODEL } = require('../utils/embeddings');
|
||||||
|
var discovered = [];
|
||||||
|
|
||||||
|
var provider = 'none';
|
||||||
|
if (process.env.LITELLM_API_BASE) provider = 'litellm';
|
||||||
|
else if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT || process.env.GOOGLE_VERTEX_PROJECT) provider = 'vertex';
|
||||||
|
else if (process.env.OPENAI_API_KEY) provider = 'openai';
|
||||||
|
|
||||||
|
var knownVertex = [
|
||||||
|
{ id: 'vertex_ai/text-embedding-005', name: 'Vertex text-embedding-005', dims: 768 },
|
||||||
|
{ id: 'vertex_ai/gemini-embedding-001', name: 'Vertex gemini-embedding-001', dims: 3072 },
|
||||||
|
{ id: 'vertex_ai/text-multilingual-embedding-002', name: 'Vertex multilingual-embedding-002', dims: 768 },
|
||||||
|
{ id: 'text-embedding-005', name: 'text-embedding-005 (direct)', dims: 768 }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (provider === 'litellm') {
|
||||||
|
var eBase = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var eHeaders = {};
|
||||||
|
if (process.env.LITELLM_API_KEY) eHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
try {
|
||||||
|
var eResp = await axios.get(eBase + '/v1/models', { headers: eHeaders, timeout: 10000 });
|
||||||
|
if (eResp.data && eResp.data.data) {
|
||||||
|
eResp.data.data.forEach(function(m) {
|
||||||
|
if ((m.id || '').toLowerCase().indexOf('embed') !== -1) {
|
||||||
|
var dims = m.id.indexOf('3-large') !== -1 ? 3072 : m.id.indexOf('3-small') !== -1 ? 1536 : m.id.indexOf('005') !== -1 ? 768 : '?';
|
||||||
|
discovered.push({ id: m.id, name: m.id, dims: dims, source: 'litellm-api' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { logger.warn('LiteLLM embedding discovery failed: ' + e.message); }
|
||||||
|
knownVertex.forEach(function(m) {
|
||||||
|
if (!discovered.find(function(d) { return d.id === m.id; })) {
|
||||||
|
discovered.push(Object.assign({ source: 'builtin' }, m));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'vertex') {
|
||||||
|
knownVertex.forEach(function(m) {
|
||||||
|
discovered.push(Object.assign({ source: 'vertex-builtin' }, m));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'openai') {
|
||||||
|
try {
|
||||||
|
var OpenAI = require('openai').OpenAI;
|
||||||
|
var oc = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
var oMods = await oc.models.list();
|
||||||
|
oMods.data.forEach(function(m) {
|
||||||
|
if (m.id.toLowerCase().indexOf('embedding') !== -1) {
|
||||||
|
var dims = m.id === 'text-embedding-3-large' ? 3072 : m.id === 'text-embedding-3-small' ? 1536 : 1536;
|
||||||
|
discovered.push({ id: m.id, name: m.id, dims: dims, source: 'openai-api' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
[{ id: 'text-embedding-3-small', dims: 1536 }, { id: 'text-embedding-3-large', dims: 3072 }, { id: 'text-embedding-ada-002', dims: 1536 }].forEach(function(m) {
|
||||||
|
discovered.push({ id: m.id, name: m.id, dims: m.dims, source: 'openai-builtin' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
discovered = discovered.filter(function(d) {
|
||||||
|
return d.id.toLowerCase().indexOf(search) !== -1 || String(d.name).toLowerCase().indexOf(search) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST test embedding ───────────────────────────────────────────────────
|
||||||
|
router.post('/config/embeddings/test', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500);
|
||||||
|
var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings');
|
||||||
|
var start = Date.now();
|
||||||
|
var vector = await generateEmbedding(text);
|
||||||
|
var dims = Array.isArray(vector) ? vector.length : 0;
|
||||||
|
var sample = Array.isArray(vector) ? vector.slice(0, 8).map(function(v) { return v.toFixed(4); }) : [];
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
dimensions: dims,
|
||||||
|
sample: sample,
|
||||||
|
model: process.env.EMBEDDING_MODEL || DEFAULT_MODEL,
|
||||||
|
duration: Date.now() - start
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// WILDCARD CONFIG — Must come AFTER all specific model routes above
|
// WILDCARD CONFIG — Must come AFTER all specific model routes above
|
||||||
// :key(*) matches slashes, so it would intercept /config/models/toggle
|
// :key(*) matches slashes, so it would intercept /config/models/toggle
|
||||||
|
|
@ -386,7 +913,7 @@ router.put('/config/:key(*)', async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security: only allow known key prefixes
|
// Security: only allow known key prefixes
|
||||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.'];
|
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.'];
|
||||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
return res.status(400).json({ error: 'Unknown config key' });
|
return res.status(400).json({ error: 'Unknown config key' });
|
||||||
|
|
|
||||||
|
|
@ -56,15 +56,16 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
|
||||||
var startTime = Date.now();
|
var startTime = Date.now();
|
||||||
var fileSize = req.file.size;
|
var fileSize = req.file.size;
|
||||||
|
|
||||||
// Get user's preferred STT model (if set)
|
// Get user's preferred STT model (if set), then admin DB default, then env
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user.id]);
|
var userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user.id]);
|
||||||
var userModel = userPrefs?.stt_model;
|
var userModel = userPrefs?.stt_model;
|
||||||
|
var adminSttModel = await db.getSetting('stt.model') || '';
|
||||||
|
|
||||||
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider + (userModel ? ' (user model: ' + userModel + ')' : ''));
|
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider + (userModel ? ' (user model: ' + userModel + ')' : ''));
|
||||||
|
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
var model = userModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
|
var model = userModel || adminSttModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
|
||||||
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model);
|
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model);
|
||||||
console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms');
|
console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms');
|
||||||
return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime });
|
return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime });
|
||||||
|
|
@ -88,7 +89,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
|
||||||
// LiteLLM /audio/transcriptions does NOT support Vertex AI Chirp (Unmapped provider error).
|
// LiteLLM /audio/transcriptions does NOT support Vertex AI Chirp (Unmapped provider error).
|
||||||
// Instead, use /v1/chat/completions with a Gemini model — Gemini understands audio natively
|
// Instead, use /v1/chat/completions with a Gemini model — Gemini understands audio natively
|
||||||
// via base64 inline data. Set LITELLM_STT_MODEL to your Gemini model name in LiteLLM.
|
// via base64 inline data. Set LITELLM_STT_MODEL to your Gemini model name in LiteLLM.
|
||||||
var sttModel = userModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
|
var sttModel = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
|
||||||
var axios = require('axios');
|
var axios = require('axios');
|
||||||
var base64audio = req.file.buffer.toString('base64');
|
var base64audio = req.file.buffer.toString('base64');
|
||||||
var mimeType = req.file.mimetype || 'audio/webm';
|
var mimeType = req.file.mimetype || 'audio/webm';
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,15 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||||
var text = (req.body.text || '').substring(0, 5000);
|
var text = (req.body.text || '').substring(0, 5000);
|
||||||
if (!text) return res.status(400).json({ error: 'No text provided' });
|
if (!text) return res.status(400).json({ error: 'No text provided' });
|
||||||
|
|
||||||
// Get user's preferred TTS voice (if set)
|
// Get user's preferred TTS voice (if set), then fall back to DB admin default, then env
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
|
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
|
||||||
var userVoice = userPrefs?.tts_voice;
|
var userVoice = userPrefs?.tts_voice;
|
||||||
|
var adminVoice = await db.getSetting('tts.voice') || '';
|
||||||
|
var adminModel = await db.getSetting('tts.model') || '';
|
||||||
|
|
||||||
if (ttsProvider === 'google') {
|
if (ttsProvider === 'google') {
|
||||||
var voice = userVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
|
var voice = userVoice || adminVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
|
||||||
var buffer = await synthesizeWithGoogleTTS(text, voice);
|
var buffer = await synthesizeWithGoogleTTS(text, voice);
|
||||||
res.set('Content-Type', 'audio/mpeg');
|
res.set('Content-Type', 'audio/mpeg');
|
||||||
res.set('X-TTS-Provider', 'google-tts/' + voice);
|
res.set('X-TTS-Provider', 'google-tts/' + voice);
|
||||||
|
|
@ -49,9 +51,9 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||||
// Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias
|
// Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias
|
||||||
// from your LiteLLM model_list. Full path is safer if your config uses it directly.
|
// from your LiteLLM model_list. Full path is safer if your config uses it directly.
|
||||||
// Prefix with openai/ so LiteLLM routes correctly to the TTS endpoint
|
// Prefix with openai/ so LiteLLM routes correctly to the TTS endpoint
|
||||||
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
|
var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1';
|
||||||
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
|
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
|
||||||
var ttsVoice = userVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
|
var ttsVoice = userVoice || adminVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
|
||||||
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
var ttsHeaders = { 'Content-Type': 'application/json' };
|
var ttsHeaders = { 'Content-Type': 'application/json' };
|
||||||
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,57 @@ async function discoverModels() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bedrock: try live ListFoundationModels, fall back to built-in list
|
||||||
|
if (activeProvider === 'bedrock') {
|
||||||
|
var bedrockDiscovered = false;
|
||||||
|
try {
|
||||||
|
var BedrockListModule = require('@aws-sdk/client-bedrock');
|
||||||
|
var listClient = new BedrockListModule.BedrockClient({
|
||||||
|
region: process.env.AWS_BEDROCK_REGION || 'us-east-1',
|
||||||
|
credentials: process.env.AWS_ACCESS_KEY_ID ? {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
var listResp = await listClient.send(new BedrockListModule.ListFoundationModelsCommand({}));
|
||||||
|
if (listResp.modelSummaries && listResp.modelSummaries.length > 0) {
|
||||||
|
listResp.modelSummaries.forEach(function(m) {
|
||||||
|
if (m.inferenceTypesSupported && m.inferenceTypesSupported.includes('ON_DEMAND')) {
|
||||||
|
discovered.push({
|
||||||
|
id: m.modelId,
|
||||||
|
name: m.modelName || m.modelId,
|
||||||
|
cost: '?',
|
||||||
|
category: 'smart',
|
||||||
|
tag: (m.providerName || 'BEDROCK').toUpperCase(),
|
||||||
|
source: 'bedrock-api'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bedrockDiscovered = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Bedrock live discovery failed (' + e.code + ': ' + e.message + '), using built-in list');
|
||||||
|
}
|
||||||
|
if (!bedrockDiscovered) {
|
||||||
|
// Fall back to built-in BEDROCK_MODELS filtered by region
|
||||||
|
var { BEDROCK_MODELS } = require('./models');
|
||||||
|
var region = process.env.AWS_BEDROCK_REGION || 'us-east-1';
|
||||||
|
BEDROCK_MODELS.filter(function(m) {
|
||||||
|
return !m.regions || m.regions.indexOf(region) !== -1;
|
||||||
|
}).forEach(function(m) {
|
||||||
|
discovered.push(Object.assign({}, m, { source: 'bedrock-builtin' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure: return built-in model list
|
||||||
|
if (activeProvider === 'azure') {
|
||||||
|
var { AZURE_MODELS } = require('./models');
|
||||||
|
AZURE_MODELS.forEach(function(m) {
|
||||||
|
discovered.push(Object.assign({}, m, { source: 'azure-builtin' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return discovered;
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,14 @@ var DEFAULT_DIMS = 768;
|
||||||
*/
|
*/
|
||||||
async function generateEmbedding(text, opts) {
|
async function generateEmbedding(text, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var model = opts.model || process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
var dbModel, dbDims;
|
||||||
var dimensions = opts.dimensions || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
|
try {
|
||||||
|
var db = require('../db/database');
|
||||||
|
dbModel = await db.getSetting('embeddings.model') || '';
|
||||||
|
dbDims = await db.getSetting('embeddings.dimensions') || '';
|
||||||
|
} catch(e) { /* DB not available during startup */ }
|
||||||
|
var model = opts.model || dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
||||||
|
var dimensions = opts.dimensions || (dbDims ? parseInt(dbDims) : 0) || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
|
||||||
|
|
||||||
// Truncate text to ~2000 tokens (~8000 chars) to avoid API errors
|
// Truncate text to ~2000 tokens (~8000 chars) to avoid API errors
|
||||||
// NOTE: Large PDFs (e.g., 100MB) will be truncated to first ~8000 chars for embedding.
|
// NOTE: Large PDFs (e.g., 100MB) will be truncated to first ~8000 chars for embedding.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue