- Move mic-blocked warning into conv-chat-window (prepend) so it scrolls with the chat instead of pushing the input bar off-screen - Remove min-height:400px from conv-chat-window; give conv-chat-panel a viewport-relative height so the input bar is always visible at the bottom - loader.js: append ?v=<timestamp> to section fetches to bust browser cache (was serving stale s-conversation.html after updates) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
651 lines
28 KiB
JavaScript
651 lines
28 KiB
JavaScript
// ── Settings: Logs ────────────────────────────────────────────────────────
|
|
|
|
let _logsLiveTimer = null;
|
|
let _logsActiveLevel = '';
|
|
|
|
function escLog(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
|
|
window.loadSettingsLogs = async function() {
|
|
const viewer = $('s-log-viewer');
|
|
if (!viewer) return;
|
|
try {
|
|
const data = await fetch('/api/logs?limit=300').then(r => r.json());
|
|
const items = (data.items || []).filter(item =>
|
|
!_logsActiveLevel || item.level === _logsActiveLevel
|
|
);
|
|
const count = $('s-log-count');
|
|
if (count) count.textContent = items.length + ' entries';
|
|
if (!items.length) {
|
|
viewer.innerHTML = '<div class="s-log-empty">No log entries</div>';
|
|
return;
|
|
}
|
|
viewer.innerHTML = items.map(item => {
|
|
const ts = item.ts ? item.ts.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : '';
|
|
return `<div class="s-log-row">
|
|
<span class="s-log-ts">${escLog(ts)}</span>
|
|
<span class="s-log-level ${escLog(item.level)}">${escLog(item.level)}</span>
|
|
<span class="s-log-msg">${escLog(item.msg)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
} catch(e) {
|
|
viewer.innerHTML = `<div class="s-log-empty">Failed to load logs: ${escLog(e.message)}</div>`;
|
|
}
|
|
};
|
|
|
|
$('s-logs-refresh-btn')?.addEventListener('click', () => loadSettingsLogs());
|
|
|
|
$('s-logs-clear-btn')?.addEventListener('click', async () => {
|
|
await fetch('/api/logs', { method: 'DELETE' });
|
|
const viewer = $('s-log-viewer');
|
|
if (viewer) viewer.innerHTML = '<div class="s-log-empty">Logs cleared</div>';
|
|
const count = $('s-log-count');
|
|
if (count) count.textContent = '';
|
|
});
|
|
|
|
$('s-logs-live-toggle')?.addEventListener('change', function() {
|
|
clearInterval(_logsLiveTimer);
|
|
if (this.checked) {
|
|
loadSettingsLogs();
|
|
_logsLiveTimer = setInterval(loadSettingsLogs, 3000);
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.s-log-filter').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.s-log-filter').forEach(b => b.classList.remove('is-active'));
|
|
this.classList.add('is-active');
|
|
_logsActiveLevel = this.dataset.logLevel;
|
|
loadSettingsLogs();
|
|
});
|
|
});
|
|
|
|
// ── Settings: About ───────────────────────────────────────────────────────
|
|
|
|
// Fetch and display the server version in the About page
|
|
(async function loadAppVersion() {
|
|
try {
|
|
const d = await fetch('/api/version').then(r => r.json());
|
|
const el = $('s-about-version');
|
|
if (el && d.version) el.textContent = 'v' + d.version;
|
|
} catch (_) {}
|
|
})();
|
|
|
|
function renderSettingsAbout() {
|
|
const el = $('s-about-backends');
|
|
if (!el) return;
|
|
const available = new Set((_ttsBackends || []).filter(b => b.available).map(b => b.id));
|
|
const all = (_ttsBackends || []);
|
|
if (!all.length) { el.innerHTML = ''; return; }
|
|
el.innerHTML = all.map(b => {
|
|
const online = available.has(b.id);
|
|
return `<span class="s-about-backend ${online ? 'online' : 'offline'}">
|
|
<span class="mdi mdi-speaker-outline"></span>${escHtml(b.label)}
|
|
<span class="mdi ${online ? 'mdi-check-circle-outline' : 'mdi-close-circle-outline'}"></span>
|
|
</span>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Voices import ──────────────────────────────────────────────────────────
|
|
|
|
$('s-import-voices-file')?.addEventListener('change', async function () {
|
|
const file = this.files?.[0];
|
|
if (!file) return;
|
|
const st = $('s-import-status');
|
|
if (st) st.textContent = 'Uploading…';
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const r = await fetch('/api/voices/import', { method:'POST', body: fd });
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
if (st) st.textContent = `Imported ${d.imported} files.`;
|
|
toast(`Imported ${d.imported} voice files`, 'success');
|
|
this.value = '';
|
|
} catch(e) {
|
|
if (st) st.textContent = 'Import failed.';
|
|
toast('Import failed: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ── Conversation Playground ────────────────────────────────────────────────
|
|
|
|
(function initConversationPlayground() {
|
|
const chatWindow = $('conv-chat-window');
|
|
const micBtn = $('conv-mic-btn');
|
|
const micIcon = $('conv-mic-icon');
|
|
const micStatus = $('conv-mic-status');
|
|
const micTimer = $('conv-mic-timer');
|
|
const textInput = $('conv-text-input');
|
|
const sendBtn = $('conv-send-btn');
|
|
const clearBtn = $('conv-clear-btn');
|
|
const sttSel = $('conv-stt-select');
|
|
const llmUrlInp = $('conv-llm-url');
|
|
const llmFetchBtn = $('conv-llm-fetch-btn');
|
|
const llmModelSel = $('conv-llm-model-select');
|
|
const ttsBkSel = $('conv-tts-backend-select');
|
|
const ttsFetchBtn = $('conv-tts-fetch-btn');
|
|
const ttsVoiceSel = $('conv-tts-voice-select');
|
|
const systemPrompt = $('conv-system-prompt');
|
|
const turnHistory = $('conv-turn-history');
|
|
if (!chatWindow || !micBtn) return;
|
|
|
|
// Warn if microphone API is unavailable (HTTP on non-localhost = insecure context)
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
if (micStatus) {
|
|
micStatus.textContent = 'Mic unavailable — insecure context';
|
|
micStatus.style.color = 'var(--red, #e05)';
|
|
}
|
|
if (micBtn) {
|
|
micBtn.disabled = true;
|
|
micBtn.title = 'Browser blocks microphone on HTTP. Use http://localhost:7890 or HTTPS.\n' +
|
|
'Chrome fix: chrome://flags/#unsafely-treat-insecure-origin-as-secure';
|
|
micBtn.style.opacity = '0.4';
|
|
}
|
|
const flagsUrl = 'chrome://flags/#unsafely-treat-insecure-origin-as-secure';
|
|
const warn = document.createElement('div');
|
|
warn.style.cssText = 'background:var(--red,#c00);color:#fff;padding:12px 16px;border-radius:8px;margin:0 0 12px;font-size:13px;line-height:1.7;flex-shrink:0';
|
|
warn.innerHTML =
|
|
'<strong>⚠ Microphone blocked by browser</strong><br>' +
|
|
'Browsers only allow microphone access on <b>secure contexts</b> (HTTPS or localhost).<br>' +
|
|
'Quick fix: open the app at <b>http://localhost:7890</b> instead of the IP address.<br>' +
|
|
'Remote access fix: add the URL in Chrome flags:<br>' +
|
|
'<div style="display:flex;align-items:center;gap:8px;margin-top:6px">' +
|
|
`<code style="flex:1;background:rgba(0,0,0,.35);color:#fff;border-radius:4px;padding:4px 8px;font-size:12px;word-break:break-all;font-family:monospace">${flagsUrl}</code>` +
|
|
`<button onclick="navigator.clipboard?.writeText('${flagsUrl}').then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)})" ` +
|
|
'style="flex-shrink:0;background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.45);color:#fff;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;font-weight:600">Copy</button>' +
|
|
'</div>';
|
|
// Insert inside the chat window so it scrolls with the conversation and
|
|
// never pushes the input bar off-screen.
|
|
if (chatWindow) chatWindow.prepend(warn);
|
|
}
|
|
|
|
// Restore conv LLM URL from server settings
|
|
if (llmUrlInp && _appSettings && _appSettings.conv_llm_url) llmUrlInp.value = _appSettings.conv_llm_url;
|
|
if (llmUrlInp) llmUrlInp.addEventListener('input', () => {
|
|
_patchSettings({ conv_llm_url: llmUrlInp.value });
|
|
if (_appSettings) _appSettings.conv_llm_url = llmUrlInp.value;
|
|
});
|
|
|
|
let mediaRecorder = null;
|
|
let recChunks = [];
|
|
let recTimerInterval = null;
|
|
let recStart = 0;
|
|
let conversationHistory = [];
|
|
let turnCount = 0;
|
|
let isProcessing = false;
|
|
|
|
// ── Populate STT backends ────────────────────────────────────────────────
|
|
async function loadConvSttBackends() {
|
|
if (!sttSel) return;
|
|
const prev = sttSel.value;
|
|
try {
|
|
const d = await fetch('/api/stt-backends').then(r => r.json());
|
|
const all = d.backends || [];
|
|
if (!all.length) {
|
|
sttSel.innerHTML = '<option value="configured">Default (configured)</option>';
|
|
return;
|
|
}
|
|
sttSel.innerHTML = all.map(b => {
|
|
const icon = b.available ? '✓' : '✗';
|
|
const label = `${icon} ${escHtml(b.label)}`;
|
|
const disabled = !b.available;
|
|
return `<option value="${escHtml(b.id)}"${disabled ? ' disabled' : ''}>${label}</option>`;
|
|
}).join('');
|
|
// Restore prev selection or pick first available
|
|
const opt = sttSel.querySelector(`option[value="${CSS.escape(prev)}"]`);
|
|
if (opt && !opt.disabled) { sttSel.value = prev; }
|
|
else {
|
|
const first = sttSel.querySelector('option:not([disabled])');
|
|
if (first) sttSel.value = first.value;
|
|
}
|
|
} catch(_) {
|
|
sttSel.innerHTML = '<option value="configured">Default (configured)</option>';
|
|
}
|
|
}
|
|
|
|
// ── Populate TTS backends (reuse global _ttsBackends) ───────────────────
|
|
function populateConvTtsBackends() {
|
|
if (!ttsBkSel) return;
|
|
const prev = ttsBkSel.value;
|
|
const all = _ttsBackends || [];
|
|
if (!all.length) {
|
|
ttsBkSel.innerHTML = '<option value="">No TTS backend available</option>';
|
|
return;
|
|
}
|
|
ttsBkSel.innerHTML = all.map(b => {
|
|
const icon = b.available ? '✓' : '✗';
|
|
return `<option value="${escHtml(b.id)}"${!b.available ? ' disabled' : ''}>${icon} ${escHtml(b.label)}</option>`;
|
|
}).join('');
|
|
const opt = ttsBkSel.querySelector(`option[value="${CSS.escape(prev)}"]`);
|
|
if (opt && !opt.disabled) { ttsBkSel.value = prev; }
|
|
else {
|
|
const first = ttsBkSel.querySelector('option:not([disabled])');
|
|
if (first) ttsBkSel.value = first.value;
|
|
}
|
|
}
|
|
|
|
// ── Fetch LLM models ─────────────────────────────────────────────────────
|
|
async function fetchLlmModels() {
|
|
if (!llmModelSel) return;
|
|
const url = llmUrlInp?.value.trim() || '';
|
|
llmFetchBtn.disabled = true;
|
|
try {
|
|
const d = await fetch('/api/conversation/llm-models' + (url ? '?url=' + encodeURIComponent(url) : '')).then(r => r.json());
|
|
const models = d.models || [];
|
|
llmModelSel.innerHTML = models.length
|
|
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
|
|
: '<option value="">No models found</option>';
|
|
} catch(e) {
|
|
llmModelSel.innerHTML = '<option value="">Fetch failed</option>';
|
|
} finally {
|
|
llmFetchBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Fetch TTS voices ─────────────────────────────────────────────────────
|
|
async function fetchConvTtsVoices() {
|
|
if (!ttsVoiceSel || !ttsBkSel) return;
|
|
const backend = ttsBkSel.value;
|
|
if (!backend) return;
|
|
ttsFetchBtn.disabled = true;
|
|
try {
|
|
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
|
ttsVoiceSel.innerHTML = rawVoices.length
|
|
? rawVoices.map(v => { const id = backendVoiceId(v); return `<option value="${escHtml(id)}">${escHtml(id)}</option>`; }).join('')
|
|
: '<option value="">No voices</option>';
|
|
} catch(e) {
|
|
ttsVoiceSel.innerHTML = '<option value="">Fetch failed</option>';
|
|
} finally {
|
|
ttsFetchBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Chat bubble helpers ──────────────────────────────────────────────────
|
|
function timeStr() {
|
|
const now = new Date();
|
|
return now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
|
|
}
|
|
|
|
function removeWelcome() {
|
|
const w = chatWindow.querySelector('.conv-chat-welcome');
|
|
if (w) w.remove();
|
|
}
|
|
|
|
function addBubble(role, text) {
|
|
removeWelcome();
|
|
const wrap = document.createElement('div');
|
|
wrap.className = `conv-bubble-wrap conv-bubble-wrap--${role}`;
|
|
const bubble = document.createElement('div');
|
|
bubble.className = `conv-bubble conv-bubble--${role}`;
|
|
bubble.textContent = text || '';
|
|
const meta = document.createElement('div');
|
|
meta.className = 'conv-bubble-meta';
|
|
meta.textContent = timeStr();
|
|
wrap.appendChild(bubble);
|
|
wrap.appendChild(meta);
|
|
chatWindow.appendChild(wrap);
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
return bubble;
|
|
}
|
|
|
|
function addTypingBubble() {
|
|
removeWelcome();
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant';
|
|
wrap.id = 'conv-typing-wrap';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'conv-bubble conv-bubble--assistant';
|
|
bubble.innerHTML = '<span class="conv-typing"><span></span><span></span><span></span></span>';
|
|
wrap.appendChild(bubble);
|
|
chatWindow.appendChild(wrap);
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
return bubble;
|
|
}
|
|
|
|
function addErrorBubble(msg) {
|
|
removeWelcome();
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'conv-bubble conv-bubble--error';
|
|
bubble.innerHTML = `<span class="mdi mdi-alert-outline"></span> ${escHtml(msg)}`;
|
|
wrap.appendChild(bubble);
|
|
chatWindow.appendChild(wrap);
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
}
|
|
|
|
// ── Stats panel ──────────────────────────────────────────────────────────
|
|
function fmtMs(ms) { return ms == null ? '—' : ms >= 1000 ? (ms/1000).toFixed(2)+'s' : ms+'ms'; }
|
|
|
|
function updateStatBar(id, val, maxVal) {
|
|
const fill = $(id);
|
|
if (fill) fill.style.width = maxVal > 0 ? Math.min(100, (val / maxVal) * 100) + '%' : '0%';
|
|
}
|
|
|
|
function updateStats(stats) {
|
|
const { stt_ms, llm_ttft_ms, llm_total_ms, tts_ms, total_ms } = stats;
|
|
const max = total_ms || 1;
|
|
const set = (valId, fillId, ms) => {
|
|
const el = $(valId); if (el) el.textContent = fmtMs(ms);
|
|
updateStatBar(fillId, ms || 0, max);
|
|
};
|
|
set('cpv-stt', 'cpf-stt', stt_ms);
|
|
set('cpv-ttft', 'cpf-ttft', llm_ttft_ms);
|
|
set('cpv-llm', 'cpf-llm', llm_total_ms);
|
|
set('cpv-tts', 'cpf-tts', tts_ms);
|
|
set('cpv-total', 'cpf-total', total_ms);
|
|
}
|
|
|
|
function addHistoryItem(n, totalMs, ok) {
|
|
const empty = turnHistory?.querySelector('.conv-history-empty');
|
|
if (empty) empty.remove();
|
|
const item = document.createElement('div');
|
|
item.className = 'conv-hist-item';
|
|
const cls = ok ? 'conv-hist-ok' : 'conv-hist-err';
|
|
const icon = ok ? 'mdi-check-circle-outline' : 'mdi-alert-outline';
|
|
item.innerHTML = `<span class="conv-hist-num">#${n}</span>
|
|
<span class="${cls}"><span class="mdi ${icon}"></span></span>
|
|
<span class="conv-hist-time ${cls}">${fmtMs(totalMs)}</span>`;
|
|
turnHistory.insertBefore(item, turnHistory.firstChild);
|
|
}
|
|
|
|
// ── Recording ────────────────────────────────────────────────────────────
|
|
function startRecTimer() {
|
|
recStart = Date.now();
|
|
recTimerInterval = setInterval(() => {
|
|
const s = Math.floor((Date.now() - recStart) / 1000);
|
|
if (micTimer) micTimer.textContent = s + 's';
|
|
}, 500);
|
|
}
|
|
|
|
function stopRecTimer() {
|
|
clearInterval(recTimerInterval);
|
|
if (micTimer) micTimer.textContent = '';
|
|
}
|
|
|
|
async function startRecording() {
|
|
if (isProcessing) return;
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' +
|
|
'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000);
|
|
return;
|
|
}
|
|
let stream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
} catch(e) {
|
|
toast('Microphone access denied: ' + e.message, 'error');
|
|
return;
|
|
}
|
|
recChunks = [];
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); };
|
|
mediaRecorder.onstop = () => {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
|
processBlob(blob);
|
|
};
|
|
mediaRecorder.start();
|
|
micBtn.classList.add('recording');
|
|
micIcon.className = 'mdi mdi-stop';
|
|
if (micStatus) micStatus.textContent = 'Recording… click to stop';
|
|
startRecTimer();
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
|
mediaRecorder.stop();
|
|
stopRecTimer();
|
|
micBtn.classList.remove('recording');
|
|
micBtn.classList.add('processing');
|
|
micIcon.className = 'mdi mdi-dots-horizontal';
|
|
if (micStatus) micStatus.textContent = 'Processing…';
|
|
isProcessing = true;
|
|
}
|
|
|
|
// ── Send turn via SSE ─────────────────────────────────────────────────────
|
|
async function processBlob(blob) {
|
|
turnCount++;
|
|
const turnN = turnCount;
|
|
const t0 = Date.now();
|
|
|
|
// Show user bubble with placeholder
|
|
const userBubble = addBubble('user', '…');
|
|
const assistantBubble = addTypingBubble();
|
|
let assistantText = '';
|
|
let lastStats = null;
|
|
|
|
const form = new FormData();
|
|
form.append('audio', blob, 'audio.webm');
|
|
form.append('stt_backend', sttSel?.value || 'configured');
|
|
form.append('llm_url', llmUrlInp?.value.trim() || '');
|
|
form.append('llm_model', llmModelSel?.value || '');
|
|
form.append('tts_backend', ttsBkSel?.value || 'voice_clone');
|
|
form.append('tts_voice', ttsVoiceSel?.value || '');
|
|
form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
|
|
form.append('history', JSON.stringify(conversationHistory.slice(-20)));
|
|
|
|
try {
|
|
const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form });
|
|
if (!resp.ok) throw new Error('Server error ' + resp.status);
|
|
const reader = resp.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop();
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data:')) continue;
|
|
let evt;
|
|
try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; }
|
|
|
|
if (evt.type === 'transcript') {
|
|
userBubble.textContent = evt.text || '(empty)';
|
|
if (micStatus) micStatus.textContent = 'Generating reply…';
|
|
} else if (evt.type === 'token') {
|
|
if (assistantBubble.querySelector('.conv-typing')) {
|
|
assistantBubble.innerHTML = '';
|
|
}
|
|
assistantText += evt.delta;
|
|
assistantBubble.textContent = assistantText;
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
} else if (evt.type === 'llm_done') {
|
|
assistantText = evt.text || assistantText;
|
|
assistantBubble.textContent = assistantText;
|
|
if (micStatus) micStatus.textContent = 'Synthesising speech…';
|
|
} else if (evt.type === 'audio') {
|
|
const mime = evt.mime || 'audio/wav';
|
|
const binStr = atob(evt.b64);
|
|
const arr = new Uint8Array(binStr.length);
|
|
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
|
|
const audioBlob = new Blob([arr], { type: mime });
|
|
const url = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(url);
|
|
audio.onended = () => URL.revokeObjectURL(url);
|
|
audio.play().catch(() => {});
|
|
if (micStatus) micStatus.textContent = 'Speaking…';
|
|
} else if (evt.type === 'stats') {
|
|
lastStats = evt;
|
|
updateStats(evt);
|
|
} else if (evt.type === 'done') {
|
|
conversationHistory.push({ role: 'user', content: userBubble.textContent });
|
|
conversationHistory.push({ role: 'assistant', content: assistantText });
|
|
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
|
|
if (micStatus) micStatus.textContent = 'Ready';
|
|
} else if (evt.type === 'error') {
|
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
|
if (wrap) wrap.remove();
|
|
const stage = evt.stage?.toUpperCase() || 'ERR';
|
|
let msg = evt.message || 'Unknown error';
|
|
// Surface the actual server error detail, not the raw HTTP noise
|
|
const detailMatch = msg.match(/HTTP \d+:\s*(.+)/s);
|
|
if (detailMatch) msg = detailMatch[1].trim();
|
|
// Truncate very long stack traces
|
|
if (msg.length > 300) msg = msg.slice(0, 300) + '…';
|
|
addErrorBubble(`[${stage}] ${msg}`);
|
|
addHistoryItem(turnN, Date.now() - t0, false);
|
|
if (micStatus) micStatus.textContent = 'Error — ready';
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {
|
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
|
if (wrap) wrap.remove();
|
|
addErrorBubble(e.message);
|
|
addHistoryItem(turnN, Date.now() - t0, false);
|
|
if (micStatus) micStatus.textContent = 'Error — ready';
|
|
} finally {
|
|
isProcessing = false;
|
|
micBtn.classList.remove('processing');
|
|
micIcon.className = 'mdi mdi-microphone';
|
|
if (sendBtn) sendBtn.disabled = false;
|
|
if (textInput) textInput.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Text-input turn (skips STT, sends text directly) ────────────────────
|
|
async function processText(text) {
|
|
text = text.trim();
|
|
if (!text || isProcessing) return;
|
|
isProcessing = true;
|
|
if (sendBtn) sendBtn.disabled = true;
|
|
if (textInput) { textInput.disabled = true; textInput.value = ''; }
|
|
micBtn.classList.add('processing');
|
|
micIcon.className = 'mdi mdi-dots-horizontal';
|
|
if (micStatus) micStatus.textContent = 'Processing…';
|
|
|
|
turnCount++;
|
|
const turnN = turnCount;
|
|
const t0 = Date.now();
|
|
|
|
const userBubble = addBubble('user', text);
|
|
const assistantBubble = addTypingBubble();
|
|
let assistantText = '';
|
|
let lastStats = null;
|
|
|
|
const form = new FormData();
|
|
form.append('text', text);
|
|
form.append('stt_backend', sttSel?.value || 'configured');
|
|
form.append('llm_url', llmUrlInp?.value.trim() || '');
|
|
form.append('llm_model', llmModelSel?.value || '');
|
|
form.append('tts_backend', ttsBkSel?.value || 'voice_clone');
|
|
form.append('tts_voice', ttsVoiceSel?.value || '');
|
|
form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
|
|
form.append('history', JSON.stringify(conversationHistory.slice(-20)));
|
|
|
|
try {
|
|
const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form });
|
|
if (!resp.ok) throw new Error('Server error ' + resp.status);
|
|
const reader = resp.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop();
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data:')) continue;
|
|
let evt;
|
|
try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; }
|
|
|
|
if (evt.type === 'transcript') {
|
|
if (micStatus) micStatus.textContent = 'Generating reply…';
|
|
} else if (evt.type === 'token') {
|
|
if (assistantBubble.querySelector('.conv-typing')) assistantBubble.innerHTML = '';
|
|
assistantText += evt.delta;
|
|
assistantBubble.textContent = assistantText;
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
} else if (evt.type === 'llm_done') {
|
|
assistantText = evt.text || assistantText;
|
|
assistantBubble.textContent = assistantText;
|
|
if (micStatus) micStatus.textContent = 'Synthesising speech…';
|
|
} else if (evt.type === 'audio') {
|
|
const mime = evt.mime || 'audio/wav';
|
|
const binStr = atob(evt.b64);
|
|
const arr = new Uint8Array(binStr.length);
|
|
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
|
|
const audioBlob = new Blob([arr], { type: mime });
|
|
const url = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(url);
|
|
audio.onended = () => URL.revokeObjectURL(url);
|
|
audio.play().catch(() => {});
|
|
if (micStatus) micStatus.textContent = 'Speaking…';
|
|
} else if (evt.type === 'stats') {
|
|
lastStats = evt;
|
|
updateStats(evt);
|
|
} else if (evt.type === 'done') {
|
|
conversationHistory.push({ role: 'user', content: text });
|
|
conversationHistory.push({ role: 'assistant', content: assistantText });
|
|
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
|
|
if (micStatus) micStatus.textContent = 'Ready';
|
|
} else if (evt.type === 'error') {
|
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
|
if (wrap) wrap.remove();
|
|
addErrorBubble(`[${evt.stage?.toUpperCase() || 'ERR'}] ${evt.message || 'Unknown error'}`);
|
|
addHistoryItem(turnN, Date.now() - t0, false);
|
|
if (micStatus) micStatus.textContent = 'Error — ready';
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {
|
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
|
if (wrap) wrap.remove();
|
|
addErrorBubble(e.message);
|
|
addHistoryItem(turnN, Date.now() - t0, false);
|
|
if (micStatus) micStatus.textContent = 'Error — ready';
|
|
} finally {
|
|
isProcessing = false;
|
|
micBtn.classList.remove('processing');
|
|
micIcon.className = 'mdi mdi-microphone';
|
|
if (sendBtn) sendBtn.disabled = false;
|
|
if (textInput) textInput.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Wire up events ───────────────────────────────────────────────────────
|
|
micBtn.addEventListener('click', () => {
|
|
if (isProcessing) return;
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
});
|
|
|
|
// Text input — Enter key or Send button
|
|
sendBtn?.addEventListener('click', () => processText(textInput?.value || ''));
|
|
textInput?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); processText(textInput.value); }
|
|
});
|
|
|
|
clearBtn?.addEventListener('click', () => {
|
|
conversationHistory = [];
|
|
turnCount = 0;
|
|
chatWindow.innerHTML = '<div class="conv-chat-welcome"><span class="mdi mdi-forum-outline" style="font-size:32px;opacity:.25"></span><p>Type a message or press the microphone button below to start.</p></div>';
|
|
if (turnHistory) turnHistory.innerHTML = '<div class="conv-history-empty">No turns yet.</div>';
|
|
['cpv-stt','cpv-ttft','cpv-llm','cpv-tts','cpv-total'].forEach(id => { const el = $(id); if(el) el.textContent='—'; });
|
|
['cpf-stt','cpf-ttft','cpf-llm','cpf-tts','cpf-total'].forEach(id => { const el = $(id); if(el) el.style.width='0%'; });
|
|
});
|
|
|
|
llmFetchBtn?.addEventListener('click', fetchLlmModels);
|
|
ttsFetchBtn?.addEventListener('click', fetchConvTtsVoices);
|
|
|
|
// Re-populate TTS when backend changes
|
|
ttsBkSel?.addEventListener('change', () => { ttsVoiceSel.innerHTML = '<option value="">— fetch voices —</option>'; });
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────
|
|
loadConvSttBackends();
|
|
populateConvTtsBackends();
|
|
|
|
// Keep TTS backend select in sync after global backend refresh
|
|
window._ttsRefreshHooks = window._ttsRefreshHooks || [];
|
|
window._ttsRefreshHooks.push(populateConvTtsBackends);
|
|
})();
|