/* ============================================================
page-ask-ai.jsx — floating Ask AI widget (bottom-right) + VoiceWidget
v16: admin AI model policy panel; user_can_change gating; GET+PUT /api/settings/ai-model-policy — diagnostic panel, 3 escalation paths,
test-mic button, native dictation fallback, technical-info console
v17: premium TTS via /api/ai/voice-chat/synthesize; POST-first with browser fallback;
v18: VoiceWidget listens for voice-pref-change events so picker changes take effect immediately;
VoiceSettingsSection extended with admin-only "Premium voice (paid)" panel
============================================================ */
const SUGGESTED_PROMPTS = [
{ icon:'pulse', title:'Who is at risk of burnout?', hint:'30-day hours and late patterns' },
{ icon:'alert', title:'Summarize today\'s blockers', hint:'Group by team, suggest owners' },
{ icon:'chart', title:'Eng vs Ops attendance', hint:'Past 30 days · outliers' },
{ icon:'pto', title:'Who\'s out next week?', hint:'Approved + tentative PTO' },
];
function AskAIWidget() {
const D = window.STATUS_DATA;
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([
{ role:'system', content:"I'm Pulse. I can read all attendance, PTO (pending/approved/denied), strikes, standups (last 365 days), audit events (last 180 days), and live K9 presence. Flip on Action mode and I can also take admin actions for you — file time-off, approve/deny, manage strikes & roles, or message someone — and I'll always show what I'll do and ask before running it." }
]);
const [input, setInput] = useState('');
const [attachedServer, setAttachedServer] = useState(null);
const [showServerPicker, setShowServerPicker] = useState(false);
const [busy, setBusy] = useState(false);
const [model, setModel] = useState('');
const [modelGroups, setModelGroups] = useState([]);
const [modelLocked, setModelLocked] = useState(false);
const _meRole = (window.STATUS_DATA && window.STATUS_DATA.ME && window.STATUS_DATA.ME.org_role) || 'employee';
const isAskAdmin = _meRole === 'admin';
const [modelsLoading, setModelsLoading] = useState(true);
const [noModels, setNoModels] = useState(false);
const [userCanChange, setUserCanChange] = useState(true);
const [showPolicyPanel, setShowPolicyPanel] = useState(false);
const [policyLoading, setPolicyLoading] = useState(false);
const [policyAllowChange, setPolicyAllowChange] = useState(true);
const [policyDefaultModel, setPolicyDefaultModel] = useState('');
const [policyAllowedModels, setPolicyAllowedModels] = useState([]);
const [unread, setUnread] = useState(0);
const [days, setDays] = useState(30);
const scrollRef = useRef(null);
// Action Mode — when on, send() routes through /api/ai/plan so the
// assistant can ask clarifying Q/A chips and propose confirmable
// actions instead of only answering in text. Off (default) keeps the
// existing read-only /api/ask behaviour.
const [actionMode, setActionMode] = useState(() => {
try { return localStorage.getItem('aiActionMode') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('aiActionMode', actionMode ? '1' : '0'); } catch {}
}, [actionMode]);
// Pending clarify follow-up answers, keyed by question id. Once all the
// questions in a clarify block are answered, we POST the bundle back to
// /api/ai/plan with `answers` set so the planner can propose the action.
const [pendingAnswers, setPendingAnswers] = useState({});
const [pendingNextAction, setPendingNextAction] = useState(null);
const [pendingSeedQuestion, setPendingSeedQuestion] = useState('');
// Saved / pinned / scheduled prompts (Stage 3c).
const [savedPrompts, setSavedPrompts] = useState([]);
const loadSavedPrompts = React.useCallback(() => {
fetch('/api/ai/prompts', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && Array.isArray(d.prompts)) setSavedPrompts(d.prompts); })
.catch(() => {});
}, []);
useEffect(() => { if (open) loadSavedPrompts(); }, [open, loadSavedPrompts]);
const saveCurrentPrompt = async () => {
const lastUser = [...messages].reverse().find(m => m.role === 'user' && typeof m.content === 'string');
const seed = (input.trim() || (lastUser && lastUser.content) || '').trim();
if (!seed) { alert('Type or ask something first, then save it.'); return; }
const label = (window.prompt('Name this saved prompt:', seed.slice(0, 60)) || '').trim();
if (!label) return;
const sched = (window.prompt('Schedule? leave blank for none, or type: daily / weekly / monthly', '') || '').trim().toLowerCase();
try {
const r = await fetch('/api/ai/prompts', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, prompt_text: seed, pinned: true, schedule: ['daily','weekly','monthly'].includes(sched) ? sched : '' }),
});
if (r.ok) loadSavedPrompts();
} catch {}
};
const deleteSavedPrompt = async (id) => {
try { const r = await fetch(`/api/ai/prompts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (r.ok) loadSavedPrompts(); } catch {}
};
// Mic mode for the inline chatbot mic button. Mirrors the Topbar toggle
// (localStorage.voice_enabled). 'idle' shows the mic icon; 'recording'
// means we're currently capturing; 'transcribing' means uploading to STT.
const [voiceOn, setVoiceOn] = useState(() => localStorage.getItem('voice_enabled') === '1');
const [micState, setMicState] = useState('idle');
const [micErr, setMicErr] = useState('');
const recRef = useRef(null);
useEffect(() => {
const h = () => setVoiceOn(localStorage.getItem('voice_enabled') === '1');
window.addEventListener('voice-pref-change', h);
return () => window.removeEventListener('voice-pref-change', h);
}, []);
// One-shot record → /api/ai/transcribe → setInput (user can review/send).
const askMicRecord = async () => {
if (micState !== 'idle') {
// Click while recording = stop early.
try { recRef.current?.stop(); } catch (_) {}
return;
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
setMicErr('Recording not supported in this browser.');
setTimeout(() => setMicErr(''), 4000);
return;
}
setMicErr('');
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (_) {
setMicErr('Mic permission denied.');
setTimeout(() => setMicErr(''), 4000);
return;
}
const tryTypes = ['audio/webm;codecs=opus','audio/webm','audio/mp4','audio/ogg;codecs=opus'];
const mime = tryTypes.find(t => MediaRecorder.isTypeSupported(t)) || '';
let rec;
try {
rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
} catch (err) {
setMicErr(`Recorder init failed: ${err?.message || err}`);
setTimeout(() => setMicErr(''), 4000);
stream.getTracks().forEach(t => t.stop());
return;
}
recRef.current = rec;
const chunks = [];
let peakLevel = 0;
rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
let audioCtx, analyser, srcNode, rafId;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
srcNode = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
srcNode.connect(analyser);
const buf = new Uint8Array(analyser.fftSize);
const tick = () => {
analyser.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / buf.length);
if (rms > peakLevel) peakLevel = rms;
rafId = requestAnimationFrame(tick);
};
tick();
} catch (_) {}
rec.onstop = async () => {
try { cancelAnimationFrame(rafId); } catch (_) {}
try { audioCtx?.close(); } catch (_) {}
try { srcNode?.disconnect(); } catch (_) {}
stream.getTracks().forEach(t => t.stop());
recRef.current = null;
const blob = new Blob(chunks, { type: rec.mimeType || mime || 'audio/webm' });
if (peakLevel < 0.005) {
setMicErr("Mic didn't pick up any audio. Check input device and that no other app is using it.");
setMicState('idle');
setTimeout(() => setMicErr(''), 6000);
return;
}
if (blob.size < 500) {
setMicErr('Recording too short — try again, speak 1-2 seconds.');
setMicState('idle');
setTimeout(() => setMicErr(''), 4000);
return;
}
setMicState('transcribing');
try {
const ext = (blob.type.includes('mp4') ? 'mp4' : blob.type.includes('ogg') ? 'ogg' : 'webm');
const fd = new FormData();
fd.append('file', blob, `clip.${ext}`);
const r = await fetch('/api/ai/transcribe', { method: 'POST', credentials: 'same-origin', body: fd });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
setMicErr(j?.detail || `Transcribe HTTP ${r.status}`);
setTimeout(() => setMicErr(''), 5000);
setMicState('idle');
return;
}
const j = await r.json();
const transcript = (j.transcript || '').trim();
if (!transcript) {
setMicErr("Whisper returned no transcript — speak clearer or longer.");
setTimeout(() => setMicErr(''), 5000);
setMicState('idle');
return;
}
setInput(prev => (prev ? prev + ' ' : '') + transcript);
setMicState('idle');
} catch (err) {
setMicErr(`Transcribe error: ${err?.message || err}`);
setTimeout(() => setMicErr(''), 5000);
setMicState('idle');
}
};
rec.onerror = (e) => {
setMicErr(`Recorder error: ${e?.error?.message || 'unknown'}`);
setTimeout(() => setMicErr(''), 5000);
setMicState('idle');
stream.getTracks().forEach(t => t.stop());
};
rec.start();
setMicState('recording');
setTimeout(() => {
if (rec.state === 'recording') { try { rec.stop(); } catch (_) {} }
}, 7000);
};
useEffect(() => {
setModelsLoading(true);
fetch('/api/ai/model-lock', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : { locked: false })
.then(d => setModelLocked(!!(d && d.locked)))
.catch(() => {});
fetch('/api/ai/models', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !data.groups || data.groups.length === 0) { setNoModels(true); return; }
setModelGroups(data.groups);
if (data.default_value) {
setModel(data.default_value);
} else {
const first = data.groups[0]?.models?.[0]?.value || data.groups[0]?.options?.[0]?.value;
if (first) setModel(first);
}
if (data.user_can_change === false) setUserCanChange(false);
})
.catch(() => setNoModels(true))
.finally(() => setModelsLoading(false));
}, []);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, busy, open]);
useEffect(() => {
const onKey = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') { e.preventDefault(); setOpen(o => !o); }
if (e.key === 'Escape' && open) setOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
useEffect(() => { if (open) setUnread(0); }, [open]);
const send = async (text) => {
const serverCtx = attachedServer ? `[Server: hostname=${attachedServer.hostname}, ip=${attachedServer.ip}, status=${attachedServer.status}, region=${attachedServer.region}]\n\n` : '';
const q = (serverCtx + (text ?? input)).trim();
if (!q || busy) return;
// Conversation memory — replay the recent real turns (skip the system
// intro, clarify/action/HUD bubbles) so Pulse answers follow-ups in context.
const history = messages
.filter(m => (m.role === 'user' || m.role === 'assistant')
&& typeof m.content === 'string' && m.content && !m.hudInfo && !m.error)
.slice(-6)
.map(m => ({ role: m.role, content: m.content }));
setInput('');
setMessages(m => [...m, { role:'user', content: q }]);
setBusy(true);
// HUD Phase 3 — fire HUD intent route in parallel with the LLM /api/ask
// call. If the keyword router matches, the HUD pops with relevant
// visuals while the long-form text answer is still cooking. The bot's
// "Setting up the HUD with X…" message lands as a chat bubble.
fetch('/api/ai/hud-route', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
}).then(r => r.ok ? r.json() : null).then(routed => {
if (!routed || !routed.widgets || !routed.widgets.length) return;
// Stash the parsed date range so the HUD's attendance widgets render
// the window the user asked for ("last week") instead of only today.
window.__hudRange__ = (routed.range && routed.range.label && routed.range.label !== 'today') ? routed.range : null;
const HUD_WIDGETS = window.HUD_WIDGETS || {};
const list = routed.widgets.map(id => HUD_WIDGETS[id]).filter(Boolean);
if (list.length) {
const _rl = window.__hudRange__ ? ` · ${window.__hudRange__.label}` : '';
window.__hudState__ = { open: true, widgets: list, title: `From: "${q.slice(0, 60)}"${_rl}` };
window.dispatchEvent(new CustomEvent('hud-state-change', {
detail: window.__hudState__,
}));
}
// Surface the friendly "Setting up the HUD with X…" line as its own
// chat bubble — distinct from the longer LLM reply that follows.
setMessages(m => [...m, {
role: 'assistant',
content: `🛰 ${routed.message || 'Opening HUD…'}`,
hudInfo: { widgets: routed.widgets, deep_link: routed.deep_link },
}]);
}).catch(() => {/* best-effort */});
try {
// ── Action mode → planner ────────────────────────────────────────
// The planner can answer (regular text), ask multi-choice clarify
// questions, or propose an action card for the user to confirm.
if (actionMode) {
const planResp = await fetch('/api/ai/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
question: q,
follow_up_to: pendingNextAction || undefined,
answers: Object.keys(pendingAnswers).length ? pendingAnswers : undefined,
}),
});
if (!planResp.ok) {
const errBody = await planResp.json().catch(() => null);
throw new Error(errBody?.detail || `${planResp.status} ${planResp.statusText}`);
}
const plan = await planResp.json();
if (plan.kind === 'answer') {
setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
setMessages(m => [...m, { role:'assistant', content: plan.text || '(no response)' }]);
} else if (plan.kind === 'clarify') {
setMessages(m => [...m, { role:'assistant', clarify: plan.questions }]);
} else if (plan.kind === 'action_proposal') {
setMessages(m => [...m, { role:'assistant', actionProposal: plan.action }]);
} else {
setMessages(m => [...m, { role:'assistant', content: JSON.stringify(plan) }]);
}
if (!open) setUnread(n => n + 1);
return;
}
// ── Plain Q&A (default) ─────────────────────────────────────────
const resp = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ question: q, model: model || undefined, days, history }),
});
if (!resp.ok) {
const errBody = await resp.json().catch(() => null);
throw new Error(errBody?.detail || `${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const answer = data.answer || data.text || '(no response)';
const modelUsed = data.model_used || model;
setMessages(m => [...m, { role:'assistant', content: answer, modelUsed }]);
if (!open) setUnread(n => n + 1);
} catch (err) {
setMessages(m => [...m, { role:'assistant', content: err.message || 'Could not reach the AI provider. Check Admin → Providers.', error: true }]);
} finally {
setBusy(false);
}
};
// Pick a clarify chip. We accumulate answers and re-call /api/ai/plan
// immediately — the planner decides whether to ask the next question
// or propose the action.
const pickClarify = async (qid, value, nextAction) => {
const nextAnswers = { ...pendingAnswers, [qid]: value };
setPendingAnswers(nextAnswers);
setPendingNextAction(nextAction);
if (!pendingSeedQuestion) {
// First time we hit a clarify in this thread — seed the original
// question so the planner can re-classify against it.
const lastUser = [...messages].reverse().find(m => m.role === 'user');
setPendingSeedQuestion(lastUser?.content || nextAction || '');
}
setMessages(m => [...m, { role:'user', content: `→ ${value}` }]);
setBusy(true);
try {
const seed = pendingSeedQuestion || nextAction || '';
const planResp = await fetch('/api/ai/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
question: seed, follow_up_to: nextAction, answers: nextAnswers,
}),
});
const plan = await planResp.json();
if (plan.kind === 'answer') {
setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
setMessages(m => [...m, { role:'assistant', content: plan.text }]);
} else if (plan.kind === 'clarify') {
setMessages(m => [...m, { role:'assistant', clarify: plan.questions }]);
} else if (plan.kind === 'action_proposal') {
setMessages(m => [...m, { role:'assistant', actionProposal: plan.action }]);
}
} catch (err) {
setMessages(m => [...m, { role:'assistant', content: `Plan failed: ${err.message}`, error: true }]);
} finally {
setBusy(false);
}
};
// Confirm + run a proposed action via /api/ai/execute.
const runAction = async (action) => {
setBusy(true);
try {
const r = await fetch('/api/ai/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ name: action.name, args: action.args || {} }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d?.detail || `${r.status}`);
setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
setMessages(m => [...m, {
role:'assistant',
content: `✅ Done — ${action.summary || action.name}`,
actionResult: d,
}]);
} catch (err) {
setMessages(m => [...m, {
role:'assistant',
content: `❌ Action failed: ${err.message}`,
error: true,
}]);
} finally {
setBusy(false);
}
};
const cancelAction = (idx) => {
setMessages(m => m.map((mm, i) => i === idx
? { role:'assistant', content: '↩︎ Action cancelled.' }
: mm));
setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
};
const reset = () => {
setMessages([{ role:'system', content:'I have read access to all attendance history, PTO requests (pending, approved, denied), format-violation strikes, standup messages (last 365 days), audit events (last 180 days), and live K9 presence.' }]);
setInput('');
};
const isAdminUser = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME) || (D?.ME?.org_role === 'manager')
: (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || D.ME.org_role === 'manager' || (D.ME.role || '').toLowerCase() === 'admin')));
const canChangeModel = isAdminUser || userCanChange;
const modelSelect = noModels ? null : (
modelsLoading ? (
Loading…
) : !canChangeModel ? (
{modelGroups.flatMap(g => (g.models || g.options || []).map(o => (
{o.label || o.name || o.value}
)))}
) : modelGroups.length > 0 ? (
{isAskAdmin && (
{
const next = !modelLocked;
try {
const r = await fetch('/api/ai/model-lock', {method:'PUT', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({locked: next})});
if (r.ok) setModelLocked(next);
} catch(_) {}
}}>
{modelLocked ? '🔒 model' : '🔓 model'}
)}
setModel(e.target.value)} title={modelLocked ? "Model locked by admin" : "Model"} disabled={modelLocked && !isAskAdmin}>
{modelGroups.map(g => {
const opts = g.models || g.options || [];
return (
{opts.map(o => (
{o.label || o.name || o.value}
))}
);
})}
) : null
);
return (
<>
setOpen(o => !o)} title="Ask Pulse (⌘J)" aria-label="Ask Pulse">
{open ?
: }
{!open && unread > 0 && {unread} }
{open && (
<>
{/* Tap-outside backdrop — closes on mobile (and desktop), gives
the chat panel the obvious "modal" affordance iOS users expect. */}
setOpen(false)}
aria-hidden="true"
/>
{
// swipe-down-to-dismiss on the header area only
const t = e.touches[0];
const target = e.target;
if (target && target.closest && target.closest('.ai-pop-head')) {
e.currentTarget.dataset.dragStartY = String(t.clientY);
}
}}
onTouchMove={(e) => {
const startY = parseFloat(e.currentTarget.dataset.dragStartY || '');
if (isNaN(startY)) return;
const dy = e.touches[0].clientY - startY;
if (dy > 0) {
e.currentTarget.style.transform = `translateY(${Math.min(dy, 400)}px)`;
}
}}
onTouchEnd={(e) => {
const startY = parseFloat(e.currentTarget.dataset.dragStartY || '');
if (isNaN(startY)) return;
const lastTouch = e.changedTouches[0];
const dy = lastTouch ? lastTouch.clientY - startY : 0;
e.currentTarget.style.transform = '';
delete e.currentTarget.dataset.dragStartY;
if (dy > 90) setOpen(false);
}}
>
Ask Pulse
{isAdminUser
? (actionMode ? 'Read · Write · Execute' : 'Read · Write · Execute · read mode')
: 'Read-only · org-scoped'}
{isAdminUser && showPolicyPanel && (
AI Model Policy
{policyLoading ? (
) : (
<>
setPolicyAllowChange(e.target.checked)} />
Allow non-admin users to change AI model
Default model
setPolicyDefaultModel(e.target.value)}>
(use active provider default)
{modelGroups.flatMap(g => (g.models || g.options || []).map(o => (
{o.label || o.name || o.value}
)))}
{
fetch('/api/settings/ai-model-policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
user_model_change_allowed: policyAllowChange,
default_model: policyDefaultModel || null,
allowed_models: policyAllowedModels,
}),
}).then(r => r.ok ? r.json() : null).then(d => {
if (d?.ok) {
setUserCanChange(policyAllowChange);
setShowPolicyPanel(false);
}
});
}}>Save policy
>
)}
)}
{messages.map((m, i) => (
{m.role === 'system' ? (
{m.content}
) : (
<>
{m.role === 'assistant' &&
}
{m.error &&
error }
{/* Plain text answer */}
{!m.clarify && !m.actionProposal && (
{m.content}
)}
{/* Q/A clarify chips — planner is asking for more info */}
{m.clarify && (
{m.clarify.map((qq, qi) => (
{qq.prompt}
{qq.free_text ? (
) : (
{(qq.choices || []).map(c => (
pickClarify(qq.id, c.value, qq.next_action)}
style={{
fontSize: 11.5, padding:'5px 10px',
border:'1px solid var(--line-hi)',
background:'var(--bg-elev-2)',
color:'var(--text)',
borderRadius: 999, cursor:'pointer',
}}>
{c.label}
))}
)}
))}
)}
{/* Action proposal card — Run / Cancel */}
{m.actionProposal && (
Action proposal · {m.actionProposal.name}
{m.actionProposal.summary}
runAction(m.actionProposal)}>
{m.actionProposal.confirm_label || 'Run'}
cancelAction(i)}>
Cancel
)}
{m.modelUsed && !m.error && (
{m.modelUsed}
)}
>
)}
))}
{busy && (
)}
{messages.length <= 1 && !busy && (
Try
{SUGGESTED_PROMPTS.map(p => (
send(p.title)}>
))}
{savedPrompts.length > 0 && (
Saved
{savedPrompts.map(sp => (
send(sp.prompt_text)} title={sp.prompt_text}
style={{ background:'none', border:'none', color:'var(--text-1)', cursor:'pointer', padding:0, font:'inherit' }}>
{sp.pinned ? '📌 ' : ''}{sp.label}{sp.schedule ? ` · ${sp.schedule}` : ''}
deleteSavedPrompt(sp.id)} title="Delete saved prompt"
style={{ background:'none', border:'none', color:'var(--text-3)', cursor:'pointer', padding:'0 2px', lineHeight:1, fontSize:14 }}>×
))}
)}
)}
{/* Action Mode toggle — when on, the assistant uses Q/A
clarification + confirmable action cards instead of
plain answers. */}
setActionMode(e.target.checked)}
style={{ accentColor: 'var(--brand)' }} />
Action mode
📌 Save
{actionMode
? 'Acting — Pulse confirms before any change'
: 'Read-only — toggle to let Pulse act'}
{micErr && (
{micErr}
)}
↵ send · ⇧↵ newline · ⌘J toggle
{showServerPicker && (
Attach server context to question:
{
if (!id) { setAttachedServer(null); setShowServerPicker(false); return; }
// Fetch servers list to find full server object
fetch('/api/me/vpsie/servers', { credentials: 'include' })
.then(r => r.ok ? r.json() : { servers: [] })
.then(d => {
const s = (d.servers || []).find(x => String(x.id) === String(id));
setAttachedServer(s || { id, hostname: id, ip: '', status: '', region: '' });
setShowServerPicker(false);
})
.catch(() => { setAttachedServer({ id, hostname: id, ip: '', status: '', region: '' }); setShowServerPicker(false); });
}}
placeholder="Select a server to attach…"
/>
{attachedServer && (
setAttachedServer(null)} style={{ marginTop: 4, fontSize: 11, color: 'var(--err)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
Remove attached server
)}
)}
>
)}
>
);
}
/* ============================================================
VoiceWidget — V1-V10 full spec implementation
============================================================ */
// Short 200ms 440Hz beep on wake detection
const WAKE_BEEP_B64 = (() => {
try {
const sr = 8000, freq = 440, dur = 0.2;
const n = Math.floor(sr * dur);
const buf = new Uint8Array(44 + n * 2);
const dv = new DataView(buf.buffer);
const ws = (s, off) => { for (let i=0;i
bin += String.fromCharCode(b));
return 'data:audio/wav;base64,' + btoa(bin);
} catch { return null; }
})();
function playWakeBeep() {
if (!WAKE_BEEP_B64) return;
try { const a = new Audio(WAKE_BEEP_B64); a.volume = 0.4; a.play().catch(()=>{}); } catch {}
}
// Default wake-word patterns (English)
const DEFAULT_WAKE_PATTERNS = [
/hey\s+chat\s*-?\s*bot/i,
/hi\s+chat\s*-?\s*bot/i,
/hello\s+chat\s*-?\s*bot/i,
/hey\s+bot/i,
/hi\s+bot/i,
// Product name — "K9 Pulse". "hey pulse" is the natural phrase.
/hey\s+pulse/i,
/hi\s+pulse/i,
/hello\s+pulse/i,
/hey\s+k\s*-?\s*9/i,
/okay\s+pulse/i,
];
// Arabic wake-word patterns (V10)
const ARABIC_WAKE_PATTERNS = [
/يا\s+شات\s*بوت/,
/هاي\s+شات\s*بوت/,
/تشات\s*بوت/,
];
// End-of-turn cue phrases (V2) — finalize immediately when detected at end of utterance
const EOT_CUES = ['okay bot', 'go ahead', 'answer', 'go ahead bot', 'thats it', "that's it"];
function stripWakeWord(t, patterns) {
let s = t;
for (const p of patterns) s = s.replace(p, '');
return s.trim();
}
// Everything the user said AFTER the wake phrase — drops any ambient speech
// that preceded "hey bot", so "um, ok, hey bot how many are in" → "how many
// are in". Falls back to strip-only if no wake match is found in the text.
function afterWakePhrase(text, customWords = []) {
const customPatterns = (customWords || [])
.filter(Boolean)
.map(w => { try { return new RegExp(w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); } catch { return null; } })
.filter(Boolean);
const patterns = [...DEFAULT_WAKE_PATTERNS, ...ARABIC_WAKE_PATTERNS, ...customPatterns];
let lastEnd = -1;
for (const p of patterns) {
const m = text.match(p);
if (m && typeof m.index === 'number' && (m.index + m[0].length) > lastEnd) {
lastEnd = m.index + m[0].length;
}
}
if (lastEnd >= 0) return text.slice(lastEnd).trim();
return stripWakeWord(text, patterns);
}
function stripEOTCue(t) {
let s = t.trim();
for (const cue of EOT_CUES) {
const idx = s.toLowerCase().lastIndexOf(cue);
if (idx !== -1 && idx >= s.length - cue.length - 2) {
s = s.slice(0, idx).trim();
break;
}
}
return s;
}
function isWakeWord(transcript, customWakeWords = []) {
// Check default patterns
if (DEFAULT_WAKE_PATTERNS.some(p => p.test(transcript))) return true;
if (ARABIC_WAKE_PATTERNS.some(p => p.test(transcript))) return true;
// Check custom wake words (case-insensitive substring match)
const lower = transcript.toLowerCase();
return customWakeWords.some(w => w && lower.includes(w.toLowerCase()));
}
function hasEOTCue(transcript) {
const lower = transcript.toLowerCase().trim();
return EOT_CUES.some(cue => lower.endsWith(cue) || lower.endsWith(cue + '.') || lower.endsWith(cue + '?'));
}
// V3 — Curated voices with fallback resolution
function resolveCuratedVoice(voices, key) {
if (!voices || !voices.length) return null;
if (key === 'nadia') {
// Nadia persona: warm female voice. Prefer modern macOS/Chrome
// "Samantha" / "Karen" / "Victoria" / "Allison" — fall back to any
// en-US/en-GB female-flagged voice. If none matched, the browser
// default voice is used (no `utt.voice` set).
return voices.find(v => v.name === 'Samantha')
|| voices.find(v => v.name === 'Karen')
|| voices.find(v => v.name === 'Allison')
|| voices.find(v => v.name === 'Victoria')
|| voices.find(v => v.name.toLowerCase().includes('nadia'))
|| voices.find(v => /(en-US|en-GB).*female/i.test((v.lang||'') + ' ' + (v.name||'')))
|| voices.find(v => v.lang && v.lang.startsWith('en-'))
|| null;
}
if (key === 'uk-female') {
return voices.find(v => v.name.includes('UK English Female'))
|| voices.find(v => v.lang && v.lang.startsWith('en-GB') && v.name.toLowerCase().includes('female'))
|| voices.find(v => v.lang && v.lang.startsWith('en-GB'))
|| null;
}
if (key === 'uk-male') {
return voices.find(v => v.name.includes('UK English Male'))
|| voices.find(v => v.lang && v.lang.startsWith('en-GB') && v.name.toLowerCase().includes('male'))
|| null;
}
if (key === 'us-female') {
return voices.find(v => v.name.includes('US English Female'))
|| voices.find(v => v.name === 'Samantha')
|| voices.find(v => v.lang && v.lang.startsWith('en-US') && v.name.toLowerCase().includes('female'))
|| voices.find(v => v.lang && v.lang.startsWith('en-US'))
|| null;
}
if (key === 'us-male') {
return voices.find(v => v.name.includes('US English Male'))
|| voices.find(v => v.name === 'Daniel' || v.name === 'Alex')
|| voices.find(v => v.lang && v.lang.startsWith('en-US') && v.name.toLowerCase().includes('male'))
|| null;
}
if (key === 'ar') {
return voices.find(v => v.lang && (v.lang.startsWith('ar') || v.lang === 'ar-SA'))
|| null;
}
return null;
}
const CURATED_VOICES = [
{ key: 'uk-male', label: 'UK Male (default)' },
{ key: 'uk-female', label: 'UK Female' },
{ key: 'us-female', label: 'US Female' },
{ key: 'us-male', label: 'US Male' },
{ key: 'nadia', label: 'Nadia (warm female)' },
];
// V6 — Waveform visualizer
function WaveformCanvas({ mediaStream }) {
const canvasRef = useRef(null);
const rafRef = useRef(null);
const analyserRef = useRef(null);
useEffect(() => {
if (!mediaStream) {
// Draw flat line
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'var(--text-3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, canvas.height/2);
ctx.lineTo(canvas.width, canvas.height/2);
ctx.stroke();
}
return;
}
try {
const ac = new (window.AudioContext || window.webkitAudioContext)();
const source = ac.createMediaStreamSource(mediaStream);
const analyser = ac.createAnalyser();
analyser.fftSize = 64;
source.connect(analyser);
analyserRef.current = analyser;
const data = new Uint8Array(analyser.frequencyBinCount);
const draw = () => {
rafRef.current = requestAnimationFrame(draw);
const canvas = canvasRef.current;
if (!canvas) return;
analyser.getByteFrequencyData(data);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barW = canvas.width / data.length;
ctx.fillStyle = 'var(--brand)';
for (let i = 0; i < data.length; i++) {
const h = (data[i] / 255) * canvas.height;
ctx.fillRect(i * barW, canvas.height - h, barW - 1, h);
}
};
draw();
return () => {
cancelAnimationFrame(rafRef.current);
ac.close().catch(()=>{});
};
} catch {
// AudioContext not available — handled by fallback in render
}
}, [mediaStream]);
return (
);
}
function VoiceWidget() {
const D = window.STATUS_DATA;
const isAdmin = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME)
: (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || (D.ME.role || '').toLowerCase() === 'admin')));
// V9: voice mode starts OFF on every page load (do NOT load from localStorage)
const [voiceEnabled, setVoiceEnabled] = useState(false);
// status: idle | listening | capturing | thinking | speaking | blocked
const [status, setStatus] = useState('idle');
const [errMsg, setErrMsg] = useState('');
const [voices, setVoices] = useState([]);
// V3: curated voice key — persisted
const [selectedVoiceKey, setSelectedVoiceKey] = useState(() => {
try { return localStorage.getItem('voice_persona_key') || 'uk-male'; } catch { return 'uk-male'; }
});
// V10: language — persisted
const [voiceLang, setVoiceLang] = useState(() => {
try { return localStorage.getItem('voice_language') || 'en-US'; } catch { return 'en-US'; }
});
// V1: custom wake words — persisted
const [customWakeWords, setCustomWakeWords] = useState(() => {
try { return JSON.parse(localStorage.getItem('voice_wake_words') || '[]'); } catch { return []; }
});
const [newWakeWord, setNewWakeWord] = useState('');
// V6: overlay
const [captureText, setCaptureText] = useState(''); // live interim transcript
const [botReply, setBotReply] = useState('');
const [overlayErr, setOverlayErr] = useState('');
const [mediaStream, setMediaStream] = useState(null);
// V7: auto-relisten countdown
const [sleepCountdown, setSleepCountdown] = useState(null);
const sleepTimerRef = useRef(null);
const sleepCountdownRef = useRef(null);
// v14: modal state — 'preflight' | 'blocked' | 'notfound' | null
const [modal, setModal] = useState(null);
// v14: track stop-confirm dialog
const [stopConfirm, setStopConfirm] = useState(false);
// v15: blocked-modal diagnostic state
const [diagResult, setDiagResult] = useState(null); // null | object
const [diagLoading, setDiagLoading] = useState(false);
const [testMicResult, setTestMicResult] = useState(null); // null | { ok, name, message }
const [testMicRunning, setTestMicRunning] = useState(false);
const [resetCopied, setResetCopied] = useState(false);
const [chromeCopied, setChromeCopied] = useState(false);
const [incognitoCopied, setIncognitoCopied] = useState(false);
const [showResetSteps, setShowResetSteps] = useState(false);
const recognitionRef = useRef(null);
const recognitionRunRef = useRef(0);
const stopRequestedRef = useRef(false);
// BUG FIX (mic-not-capturing): mirror of mediaStream as a ref so the
// startListeningInternal() function can synchronously check whether a
// live track already exists, without depending on stale state closure.
const mediaStreamRef = useRef(null);
const voiceEnabledRef = useRef(voiceEnabled);
const statusRef = useRef(status);
const captureTimerRef = useRef(null);
const silenceTimerRef = useRef(null);
const captureTranscriptRef = useRef('');
const wakingRef = useRef(false);
const autoRelistenRef = useRef(false); // V7: true = auto-relisten mode (no wake word needed)
// v17: cached premium TTS status {enabled, fetchedAt}
const premiumTtsRef = useRef({ enabled: false, fetchedAt: 0 });
useEffect(() => { voiceEnabledRef.current = voiceEnabled; }, [voiceEnabled]);
useEffect(() => { statusRef.current = status; }, [status]);
// Mirror custom wake words into a ref so the long-lived onresult closure
// always sees the latest list (editing wake words takes effect live).
const customWakeWordsRef = useRef(customWakeWords);
useEffect(() => { customWakeWordsRef.current = customWakeWords; }, [customWakeWords]);
// Load TTS voices
useEffect(() => {
const load = () => {
const v = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
if (v.length) setVoices(v);
};
load();
if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = load;
return () => { if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = null; };
}, []);
// v18: sync voice prefs when user changes them in settings (VoiceSettingsSection dispatches this)
useEffect(() => {
const onPrefChange = (e) => {
const { key, value } = e.detail || {};
if (key === 'voice_persona_key') setSelectedVoiceKey(value);
if (key === 'voice_language') setVoiceLang(value);
};
window.addEventListener('voice-pref-change', onPrefChange);
return () => window.removeEventListener('voice-pref-change', onPrefChange);
}, []);
// V9: stop on logout — watch for session expiry
useEffect(() => {
const onLogout = () => {
if (voiceEnabled) {
setVoiceEnabled(false);
stopListening();
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
}
};
window.addEventListener('voice-logout', onLogout);
const origFetch = window._origFetch || window.fetch;
if (!window._voiceFetchPatched) {
window._voiceFetchPatched = true;
window._origFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
if (url.includes('/api/auth/logout')) {
window.dispatchEvent(new Event('voice-logout'));
}
return origFetch.apply(this, args);
};
}
return () => window.removeEventListener('voice-logout', onLogout);
}, [voiceEnabled, mediaStream]);
// v17: browser TTS fallback (used when premium is off or fails)
const speakBrowser = (text, onEnd) => {
if (!window.speechSynthesis || !text) { if (onEnd) onEnd(); return; }
window.speechSynthesis.cancel();
const utt = new SpeechSynthesisUtterance(text);
utt.lang = voiceLang;
const isArabic = voiceLang.startsWith('ar');
const voiceKey = isArabic ? 'ar' : selectedVoiceKey;
const resolved = resolveCuratedVoice(voices, voiceKey);
if (resolved) utt.voice = resolved;
utt.rate = 1.0;
utt.pitch = 1.0;
utt.onstart = () => {
if (stopRequestedRef.current || !voiceEnabledRef.current) return;
setStatus('speaking');
};
const finishSpeech = () => {
setStatus('idle');
if (!stopRequestedRef.current && voiceEnabledRef.current && onEnd) onEnd();
};
utt.onend = finishSpeech;
utt.onerror = finishSpeech;
console.log('Speaking with voice:', utt.voice?.name || '(browser default)', '| key:', voiceKey);
window.speechSynthesis.speak(utt);
};
// v17: premium TTS status (cached 30s to avoid per-utterance roundtrips)
const _fetchPremiumStatus = async () => {
const now = Date.now();
if (now - premiumTtsRef.current.fetchedAt < 30000) return premiumTtsRef.current.enabled;
try {
const r = await fetch('/api/ai/tts/status', { credentials: 'same-origin' });
if (r.ok) {
const d = await r.json();
premiumTtsRef.current = { enabled: !!d.enabled, fetchedAt: now };
return !!d.enabled;
}
} catch { /* ignore */ }
premiumTtsRef.current = { enabled: false, fetchedAt: now };
return false;
};
// v17: main speak entry point — tries premium TTS first, falls back to browser
const speakReply = (text, onEnd) => {
if (!text) { if (onEnd) onEnd(); return; }
setStatus('speaking');
_fetchPremiumStatus().then(premiumEnabled => {
if (!premiumEnabled) {
speakBrowser(text, onEnd);
return;
}
// Attempt premium TTS via backend
fetch('/api/ai/voice-chat/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text }),
})
.then(async r => {
if (!r.ok) {
// Not available (disabled, limit hit, error) — fall back silently
speakBrowser(text, onEnd);
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => {
URL.revokeObjectURL(url);
setStatus('idle');
if (!stopRequestedRef.current && voiceEnabledRef.current && onEnd) onEnd();
};
audio.onerror = () => {
URL.revokeObjectURL(url);
// Fall back to browser TTS on audio decode error
speakBrowser(text, onEnd);
};
audio.play().catch(() => speakBrowser(text, onEnd));
})
.catch(() => speakBrowser(text, onEnd));
});
};
const doVoiceChat = async (transcript) => {
setStatus('thinking');
setCaptureText('');
setBotReply('');
setOverlayErr('');
// HUD Phase 3 — fire-and-forget HUD intent route in parallel.
// The bot says "Setting up the HUD with X…" immediately so the admin
// gets visuals while the longer voice-chat reply is still cooking.
// Fails silently if the keyword router can't match — full voice-chat
// reply still lands as normal.
fetch('/api/ai/hud-route', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: transcript }),
}).then(r => r.ok ? r.json() : null).then(routed => {
if (!routed || !routed.widgets || !routed.widgets.length) return;
// Stash the parsed date range so the HUD's attendance widgets render
// the window the user asked for ("last week") instead of only today.
window.__hudRange__ = (routed.range && routed.range.label && routed.range.label !== 'today') ? routed.range : null;
// Open the HUD immediately with the picked widgets.
const HUD_WIDGETS = window.HUD_WIDGETS || {};
const list = routed.widgets.map(id => HUD_WIDGETS[id]).filter(Boolean);
if (list.length && typeof window.__hudState__ === 'object') {
const _rl = window.__hudRange__ ? ` · ${window.__hudRange__.label}` : '';
const evt = new CustomEvent('hud-state-change', {
detail: { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"${_rl}` },
});
window.__hudState__ = { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"${_rl}` };
window.dispatchEvent(evt);
}
// Speak the short feedback message before the richer reply arrives.
try { speakReply(routed.message || 'Setting up the HUD…', () => {}); } catch (_) {}
}).catch(() => {/* best-effort */});
try {
const r = await fetch('/api/ai/voice-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ transcript }),
});
if (!r.ok) {
const errData = await r.json().catch(() => ({}));
const techReason = errData?.detail || `HTTP ${r.status}`;
setOverlayErr(techReason);
speakReply("Sorry, I couldn't process that — try again?", () => startAutoRelisten());
return;
}
const data = await r.json();
const reply = data.reply || '(no response)';
setBotReply(reply);
if (data.nav_hint) {
window.dispatchEvent(new CustomEvent('voice-nav-hint', { detail: data.nav_hint }));
}
speakReply(reply, () => startAutoRelisten());
} catch (e) {
const techReason = e.message || 'Unknown error';
setOverlayErr(techReason);
speakReply("Sorry, I couldn't process that — try again?", () => startAutoRelisten());
}
};
// One-shot "Tap to ask" — server-side STT via MediaRecorder + Groq Whisper.
// Bypasses Chrome's flaky cloud webkitSpeechRecognition (which silently
// produces no transcripts on many networks). Records ~7s of audio, POSTs
// the blob to /api/ai/transcribe, and feeds the transcript into the
// existing doVoiceChat() flow. Live RMS metering catches a flat-line mic
// before we waste a round-trip on silent audio.
const tapToAskOnce = async () => {
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
setOverlayErr('Recording not supported in this browser. Use the text box.');
return;
}
try { recognitionRef.current?.stop(); } catch (_) {}
cancelAutoRelisten();
setOverlayErr('');
setBotReply('');
setCaptureText('Recording…');
setStatus('capturing');
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
setMediaStream(stream);
mediaStreamRef.current = stream;
} catch (_) {
setOverlayErr('Microphone permission denied.');
setStatus('idle');
return;
}
const tryTypes = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
];
let mimeType = '';
for (const t of tryTypes) {
if (MediaRecorder.isTypeSupported(t)) { mimeType = t; break; }
}
let rec;
try {
rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
} catch (err) {
setOverlayErr(`Recorder init failed: ${err?.message || err}`);
setStatus('idle');
return;
}
const chunks = [];
rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
// Live RMS meter — confirms the mic is producing audio.
let audioCtx, analyser, srcNode, rafId;
let peakLevel = 0;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
srcNode = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
srcNode.connect(analyser);
const buf = new Uint8Array(analyser.fftSize);
const tick = () => {
analyser.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / buf.length);
if (rms > peakLevel) peakLevel = rms;
rafId = requestAnimationFrame(tick);
};
tick();
} catch (_) { /* meter optional */ }
const cleanupMeter = () => {
try { cancelAnimationFrame(rafId); } catch (_) {}
try { audioCtx?.close(); } catch (_) {}
try { srcNode?.disconnect(); } catch (_) {}
};
rec.onstop = async () => {
cleanupMeter();
const blob = new Blob(chunks, { type: rec.mimeType || mimeType || 'audio/webm' });
// If the mic produced no measurable signal, surface a precise error.
if (peakLevel < 0.005) {
setOverlayErr("Mic didn't pick up any audio. Check that your mic is unmuted, selected as input, and not held by another app (Zoom, Slack huddle, etc.).");
setStatus('idle');
return;
}
if (blob.size < 500) {
setOverlayErr('Recording was too short. Try again and speak for at least 1-2 seconds.');
setStatus('idle');
return;
}
setStatus('thinking');
setCaptureText('Transcribing…');
try {
const ext = (blob.type.includes('mp4') ? 'mp4'
: blob.type.includes('ogg') ? 'ogg' : 'webm');
const fd = new FormData();
fd.append('file', blob, `clip.${ext}`);
const resp = await fetch('/api/ai/transcribe', {
method: 'POST',
credentials: 'same-origin',
body: fd,
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
setOverlayErr(j?.detail || `Transcribe failed: HTTP ${resp.status}`);
setStatus('idle');
return;
}
const j = await resp.json();
const transcript = (j.transcript || '').trim();
if (!transcript) {
setOverlayErr("Mic worked but Whisper returned no transcript — try speaking more clearly or for longer.");
setStatus('idle');
return;
}
setCaptureText(transcript);
doVoiceChat(transcript);
} catch (err) {
setOverlayErr(`Transcribe error: ${err?.message || err}`);
setStatus('idle');
}
};
rec.onerror = (e) => {
cleanupMeter();
setOverlayErr(`Recorder error: ${e?.error?.message || e?.error || 'unknown'}`);
setStatus('idle');
};
rec.start();
// Cap recording at 7s.
setTimeout(() => {
if (rec.state === 'recording') {
try { rec.stop(); } catch (_) {}
}
}, 7000);
};
// V7: auto-relisten — no wake word for 30s
const startAutoRelisten = () => {
if (!voiceEnabledRef.current || stopRequestedRef.current) return;
autoRelistenRef.current = true;
setSleepCountdown(30);
let remaining = 30;
sleepCountdownRef.current = setInterval(() => {
remaining -= 1;
setSleepCountdown(remaining);
if (remaining <= 0) {
clearInterval(sleepCountdownRef.current);
setSleepCountdown(null);
autoRelistenRef.current = false;
setStatus('idle');
}
}, 1000);
setStatus('listening');
startListening(true);
};
const cancelAutoRelisten = () => {
autoRelistenRef.current = false;
clearInterval(sleepCountdownRef.current);
setSleepCountdown(null);
};
const startCapture = (recognition) => {
wakingRef.current = true;
captureTranscriptRef.current = '';
setStatus('capturing');
setCaptureText('');
playWakeBeep();
captureTimerRef.current = setTimeout(() => {
if (wakingRef.current) finishCapture(recognition);
}, 30000);
};
const finishCapture = (recognition) => {
if (!wakingRef.current) return;
wakingRef.current = false;
clearTimeout(captureTimerRef.current);
clearTimeout(silenceTimerRef.current);
cancelAutoRelisten();
const transcript = captureTranscriptRef.current.trim();
if (transcript) {
doVoiceChat(transcript);
} else {
setStatus('idle');
setTimeout(() => {
if (voiceEnabledRef.current && !stopRequestedRef.current) startListeningInternal();
}, 500);
}
};
// Bare "hey Pulse" (no same-breath question) → greet the admin with who's
// signed in, then auto-listen for their question. No button press needed.
const greetAndListen = async (r) => {
wakingRef.current = false;
clearTimeout(silenceTimerRef.current);
clearTimeout(captureTimerRef.current);
setStatus('speaking'); // blocks onend from auto-restarting recognition
setCaptureText('');
try { r && r.stop(); } catch (_) {}
let greeting = 'Hi — what can I help you with?';
try {
const s = await (await fetch('/api/login/stats', { credentials: 'same-origin' })).json();
let who = '';
try {
const rows = await (await fetch('/api/attendance', { credentials: 'same-origin' })).json();
const names = (Array.isArray(rows) ? rows : [])
.filter(x => x.signed_in)
.map(x => (x.display_name || '').split(/\s+/)[0])
.filter(Boolean);
if (names.length) who = `, including ${names.slice(0, 2).join(' and ')}`;
} catch (_) {}
const n = s.signed_in_today, tot = s.total_users;
greeting = (typeof n === 'number' && typeof tot === 'number')
? `Hi — ${n} of ${tot} signed in right now${who}. What can I help you with?`
: 'Hi — what can I help you with?';
} catch (_) {}
playWakeBeep();
setBotReply(greeting);
speakReply(greeting, () => {
if (voiceEnabledRef.current && !stopRequestedRef.current) startAutoRelisten();
});
};
// Internal: actually start recognition (assumes permission already granted)
const startListeningInternal = async (autoRelisten = false) => {
if (!voiceEnabledRef.current || stopRequestedRef.current) return;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { setErrMsg('SpeechRecognition not supported in this browser'); return; }
try {
stopRequestedRef.current = false;
try { recognitionRef.current?.stop(); } catch {}
// ── BUG FIX (mic-not-capturing): the waveform's getUserMedia call MUST
// happen BEFORE r.start(). Chrome only allows one consumer per audio
// device; if getUserMedia is called AFTER SpeechRecognition is already
// listening, it silently steals the device — `onresult` never fires
// (no error, no audio), so the user sees the mic icon "on" but nothing
// is transcribed. Acquiring the stream first means SpeechRecognition
// piggybacks on the already-held device permission.
// We also skip the duplicate acquisition entirely if we still hold a
// live stream from a previous run.
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
const existing = mediaStreamRef.current;
const stillLive = !!(existing && existing.getAudioTracks().some(t => t.readyState === 'live'));
if (!stillLive) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
setMediaStream(stream);
mediaStreamRef.current = stream;
} catch (_) {
setMediaStream(null);
}
}
}
const r = new SR();
const runId = ++recognitionRunRef.current;
r.continuous = true;
r.interimResults = true;
r.lang = voiceLang;
recognitionRef.current = r;
r.onresult = (e) => {
const latest = Array.from(e.results).map(r => r[0].transcript).join(' ');
if (autoRelistenRef.current && !wakingRef.current) {
// P1 #1/#2: no wake phrase. Any speech during the auto-relisten
// window starts a capture immediately.
const spoken = String(latest || '').trim();
if (spoken) {
cancelAutoRelisten();
startCapture(r);
captureTranscriptRef.current = spoken;
setCaptureText(spoken);
}
return;
}
if (!wakingRef.current) {
// Idle listening: wait for the wake word ("hey bot", "hey chat-bot",
// Arabic variants, or any custom phrase). Ambient speech is ignored
// until the wake word is heard — THEN we beep, ack, and capture the
// question. If the wake word and the question arrive in one breath
// ("hey bot, how many are signed in?"), seed the capture with the
// words that follow the wake phrase so nothing is lost.
const spoken = String(latest || '').trim();
const customs = customWakeWordsRef.current || [];
if (spoken && isWakeWord(spoken, customs)) {
const afterWake = afterWakePhrase(spoken, customs);
const hasQuestion = afterWake && afterWake.replace(/[^0-9a-z-ۿ]/gi, '').length >= 2;
if (hasQuestion) {
// "hey Pulse, how many are signed in?" — capture straight away.
startCapture(r);
captureTranscriptRef.current = afterWake;
setCaptureText(afterWake);
} else {
// Bare "hey Pulse" — greet, then listen for the question.
greetAndListen(r);
}
}
} else {
const stripped = afterWakePhrase(latest, customWakeWordsRef.current || []);
captureTranscriptRef.current = stripped;
setCaptureText(stripped);
if (hasEOTCue(stripped)) {
const cleaned = stripEOTCue(stripped);
captureTranscriptRef.current = cleaned;
setCaptureText(cleaned);
finishCapture(r);
return;
}
clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => finishCapture(r), 2000);
}
};
r.onend = () => {
if (recognitionRef.current !== r || runId !== recognitionRunRef.current) return;
if (voiceEnabledRef.current && !stopRequestedRef.current && !['speaking', 'thinking', 'blocked'].includes(statusRef.current)) {
try { r.start(); } catch {}
}
};
r.onerror = async (e) => {
if (recognitionRef.current !== r || runId !== recognitionRunRef.current) return;
if (e.error === 'not-allowed') {
// fix #22: only treat as 'blocked' if the Permissions API confirms
// denial; otherwise this is usually a transient (page lost focus,
// another tab grabbed the mic, etc.) and we shouldnt scare the user.
let confirmed_denied = false;
try {
if (navigator.permissions && navigator.permissions.query) {
const p = await navigator.permissions.query({ name: 'microphone' });
confirmed_denied = p.state === 'denied';
}
} catch(_) {}
if (confirmed_denied) {
setStatus('blocked');
setModal('blocked');
setVoiceEnabled(false);
} else {
// Transient — soft retry
setStatus('idle');
setTimeout(() => { try { r.start(); } catch(_) {} }, 1500);
}
} else if (e.error === 'audio-capture') {
setStatus('blocked');
setModal('notfound');
setVoiceEnabled(false);
}
};
r.start();
if (!autoRelisten) setStatus('idle');
} catch (e) {
setErrMsg('Could not start microphone: ' + e.message);
}
};
// v14: startListening now checks permission state first
const startListening = (autoRelisten = false) => {
if (!isAdmin || !voiceEnabled) return;
startListeningInternal(autoRelisten);
};
const stopListening = () => {
stopRequestedRef.current = true;
recognitionRunRef.current += 1;
try { window.speechSynthesis?.cancel(); } catch(_) {}
try {
if (recognitionRef.current) {
recognitionRef.current.onresult = null;
recognitionRef.current.onend = null;
recognitionRef.current.onerror = null;
}
} catch(_) {}
try { recognitionRef.current?.abort?.(); } catch(_) {}
try { recognitionRef.current?.stop(); } catch(_) {}
recognitionRef.current = null;
clearTimeout(captureTimerRef.current);
clearTimeout(silenceTimerRef.current);
cancelAutoRelisten();
wakingRef.current = false;
autoRelistenRef.current = false;
setStatus('idle');
setCaptureText('');
setBotReply('');
setOverlayErr('');
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); setMediaStream(null); }
mediaStreamRef.current = null; // BUG FIX (mic-not-capturing)
};
// v14: check permission state before enabling
const handleEnableVoice = async () => {
let permState = 'prompt';
try {
const result = await navigator.permissions.query({ name: 'microphone' });
permState = result.state; // 'granted' | 'prompt' | 'denied'
} catch {
// Permissions API not supported (older Safari/Firefox) — fall through to prompt
permState = 'prompt';
}
if (permState === 'denied') {
setModal('blocked');
return;
}
if (permState === 'granted') {
// Already have access — start immediately
setVoiceEnabled(true);
return;
}
// 'prompt' — show pre-flight explainer first
setModal('preflight');
};
// Called when user confirms pre-flight modal
const handlePreflightConfirm = async () => {
setModal(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
// Permission granted — now enable
setVoiceEnabled(true);
} catch (err) {
const isNotFound = err && (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError');
setModal(isNotFound ? 'notfound' : 'blocked');
}
};
// toggle handler — turn off is immediate (P0 #22 cancel/exit lag).
// The confirm modal was removed: 'Voice off' click hard-stops listen,
// releases mic + cancels TTS in one synchronous pass.
const toggleVoice = () => {
if (!isAdmin) return;
if (voiceEnabled) {
// Hard cancel — flip state, then sync-stop everything.
setVoiceEnabled(false);
setModal(null);
setStopConfirm(false);
try { window.speechSynthesis?.cancel(); } catch(_) {}
stopListening();
} else {
handleEnableVoice();
}
};
// Expose the toggle + broadcast on/off state so the Topbar mic button (next
// to Edit) can drive voice mode without lifting this whole component up.
window.__voiceToggle__ = toggleVoice;
useEffect(() => {
const available = isAdmin && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
window.__voiceAvailable__ = available;
window.__voiceEnabled__ = voiceEnabled;
window.dispatchEvent(new CustomEvent('voice-enabled-change', { detail: { enabled: voiceEnabled, available } }));
}, [voiceEnabled, isAdmin]);
// Only auto-start when voiceEnabled flips to true (permission already checked)
useEffect(() => {
if (voiceEnabled && isAdmin) {
stopRequestedRef.current = false;
startListeningInternal();
} else if (!voiceEnabled) {
stopListening();
}
return stopListening;
}, [voiceEnabled]);
if (!isAdmin) return null;
const voiceOptions = CURATED_VOICES.map(cv => {
const resolved = resolveCuratedVoice(voices, cv.key);
return { ...cv, available: !!resolved };
});
// v14: button appearance per status
const btnConfig = (() => {
if (!voiceEnabled) return {
icon: '🎤', label: 'Voice off',
bg: '#1f2937', color: '#d1d5db',
border: '1px solid #374151',
};
if (status === 'blocked') return {
icon: '🚫', label: 'Mic blocked — click to fix',
bg: 'rgba(239,68,68,0.25)', color: '#fca5a5',
border: '1px solid rgba(239,68,68,0.4)',
};
if (status === 'idle') return {
icon: '👂', label: "Listening — speak any time",
bg: 'rgba(16,185,129,0.18)', color: '#6ee7b7',
border: '1px solid rgba(16,185,129,0.35)',
};
if (status === 'capturing') return {
icon: '🎤', label: 'Listening…',
bg: 'rgba(245,158,11,0.25)', color: '#fcd34d',
border: '1px solid rgba(245,158,11,0.4)',
};
if (status === 'thinking') return {
icon: '⏳', label: 'Thinking…',
bg: 'rgba(139,92,246,0.25)', color: '#c4b5fd',
border: '1px solid rgba(139,92,246,0.4)',
};
if (status === 'speaking') return {
icon: '🗣️', label: 'Speaking…',
bg: 'rgba(14,165,233,0.25)', color: '#7dd3fc',
border: '1px solid rgba(14,165,233,0.4)',
};
if (status === 'listening') return {
icon: '👂', label: sleepCountdown != null ? `Auto-relisten (${sleepCountdown}s)` : 'Listening…',
bg: 'rgba(16,185,129,0.18)', color: '#6ee7b7',
border: '1px solid rgba(16,185,129,0.35)',
};
return {
icon: '🎤', label: 'Voice off',
bg: '#1f2937', color: '#d1d5db',
border: '1px solid #374151',
};
})();
// v14: shared modal backdrop + container
const ModalWrap = ({ children, onClose }) => (
e.stopPropagation()}
style={{
background:'var(--bg-elev-2)',
border:'1px solid var(--line-hi)',
borderRadius:'var(--r-lg)',
padding:'24px 28px',
maxWidth: 420, width:'100%',
boxShadow:'0 16px 48px #0008',
fontSize: 14,
}}
>
{children}
);
const stateLabels = {
idle: "Say “hey Pulse”",
listening: sleepCountdown != null ? `Listening… (${sleepCountdown}s)` : "Listening…",
capturing: "Listening…",
thinking: "Thinking…",
speaking: "Speaking…",
blocked: "Mic blocked",
};
const statePillColor = {
idle: 'var(--text-3)',
listening: 'var(--brand)',
capturing: 'var(--brand)',
thinking: 'var(--warn)',
speaking: 'var(--ok)',
blocked: 'var(--err)',
};
return (
<>
{/* V6: Bottom-right overlay panel */}
{voiceEnabled && (
Voice mode
{stateLabels[status] || status}
{
try { recognitionRef.current?.stop(); } catch {}
if (mediaStream) mediaStream.getTracks().forEach(t => { t.enabled = !t.enabled; });
}}
title="Mute mic"
>
{mediaStream ? (
) : (
• • •
)}
{captureText && (
{captureText}
)}
{botReply && (
Bot
{botReply}
)}
{overlayErr && (
Technical: {overlayErr}
)}
{errMsg && (
{errMsg}
)}
{!captureText && !botReply && !overlayErr && !errMsg && (
Say “hey Pulse” — I'll greet you and listen
)}
)}
{/* The voice on/off control now lives in the top bar (next to Edit).
VoiceWidget exposes its toggle globally + broadcasts state so the
Topbar MicToggle can drive it without lifting the whole component. */}
{/* v14: Pre-flight explainer modal */}
{modal === 'preflight' && (
setModal(null)}>
Enable voice mode
Voice mode listens quietly in the background. Say “hey Pulse” (or “hey bot”)
and I'll chime, capture your question, and answer out loud. We'll ask your browser for
microphone access — click Allow when prompted. You can disable this any time.
setModal(null)}>Cancel
Continue
)}
{/* v15: Microphone blocked/notfound recovery modal — rich diagnostic + escalation */}
{(modal === 'blocked' || modal === 'notfound') && (() => {
const isNotFound = modal === 'notfound';
const runDiag = async () => {
setDiagLoading(true);
setDiagResult(null);
const result = {};
// Permission state
try {
const ps = await navigator.permissions.query({ name: 'microphone' });
result.permState = ps.state;
} catch (e) {
result.permState = 'Permissions API unavailable (' + (e.message || e.name) + ')';
}
// getUserMedia probe
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach(t => t.stop());
result.getUserMediaOk = true;
result.getUserMediaErr = null;
} catch (e) {
result.getUserMediaOk = false;
result.getUserMediaErrName = e.name || 'UnknownError';
result.getUserMediaErrMsg = e.message || '';
}
// Audio devices
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
result.audioInputCount = audioInputs.length;
result.firstDeviceLabel = audioInputs[0]?.label || '(blank — browser has not granted access this session)';
} catch {
result.audioInputCount = 'Error reading devices';
result.firstDeviceLabel = 'N/A';
}
result.origin = location.origin;
result.userAgent = navigator.userAgent;
result.enterpriseNote = 'If your laptop is work-issued, check chrome://policy for AudioCaptureAllowed / AudioCaptureAllowedUrls.';
setDiagResult(result);
setDiagLoading(false);
};
const testMic = async () => {
setTestMicRunning(true);
setTestMicResult(null);
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach(t => t.stop());
setTestMicResult({ ok: true, name: null, message: 'Microphone access granted!' });
} catch (e) {
setTestMicResult({ ok: false, name: e.name || 'UnknownError', message: e.message || '' });
}
setTestMicRunning(false);
};
const openChromeSettings = () => {
try {
window.open('chrome://settings/content/microphone', '_blank');
} catch {
// chrome:// blocked by browser — copy to clipboard
try {
navigator.clipboard.writeText('chrome://settings/content/microphone').then(() => {
setChromeCopied(true);
setTimeout(() => setChromeCopied(false), 3000);
});
} catch {}
}
setChromeCopied(true);
setTimeout(() => setChromeCopied(false), 3000);
};
const diagJson = diagResult ? JSON.stringify(diagResult, null, 2) : '';
return (
{ setModal(null); setDiagResult(null); setTestMicResult(null); setShowResetSteps(false); }}>
{isNotFound ? 'Microphone not found' : 'Microphone is blocked'}
{isNotFound
? 'No microphone device was detected. Plug in a microphone or headset, then reload the page.'
: 'Chrome is blocking microphone access for this origin. Use one of the recovery options below.'}
{/* Diagnostic panel */}
{!isNotFound && (
Diagnostic
{diagLoading ? 'Running…' : diagResult ? 'Re-run' : 'Run diagnostic'}
{diagResult && (
Permission state:
{diagResult.permState}
getUserMedia result:
{diagResult.getUserMediaOk
? Granted (try reloading the page)
: <>{diagResult.getUserMediaErrName} {diagResult.getUserMediaErrMsg ? ' — ' + diagResult.getUserMediaErrMsg : ''}>
}
Audio inputs:
{diagResult.audioInputCount}
First device label:
{diagResult.firstDeviceLabel}
Origin: {diagResult.origin}
{diagResult.enterpriseNote}
)}
{!diagResult && !diagLoading && (
Click "Run diagnostic" to inspect browser permission state and device list.
)}
)}
{/* Three escalation paths */}
{!isNotFound && (
Recovery options
{/* Path 1: Open Chrome mic settings */}
1.
Open Chrome microphone settings
{chromeCopied && URL copied — paste in address bar }
Opens chrome://settings/content/microphone (or copies URL if blocked).
Find status.k9.ms in the Blocked list and set it to Allow.
{/* Path 2: Reset all permissions */}
setShowResetSteps(s => !s)}
>
2.
Reset all permissions for this site
{showResetSteps ? '▲ hide' : '▼ show steps'}
{showResetSteps && (
In Chrome address bar type: chrome://settings/content/all and press Enter
Search for status.k9.ms
Click the entry, then click Reset permissions
Close the tab and reload status.k9.ms
Click "Voice off" again — Chrome will prompt fresh
)}
{/* Path 3: Incognito */}
{
// A page can't open an Incognito window itself (browser
// security). The useful action — mirroring Path 1 — is to
// copy this site's URL so the user can paste it into a
// fresh Incognito window where the cached deny is absent.
try {
navigator.clipboard.writeText(location.origin).then(() => {
setIncognitoCopied(true);
setTimeout(() => setIncognitoCopied(false), 3000);
}).catch(() => {});
} catch (_) {}
}}
>
3.
Try Incognito to bypass cached deny
{incognitoCopied && URL copied — paste in a new Incognito window }
Press Cmd+Shift+N (Mac) or Ctrl+Shift+N (Windows).
Open status.k9.ms in Incognito — permission state resets fresh each session.
)}
{/* Test mic button */}
{!isNotFound && (
{testMicRunning ? 'Testing…' : 'Test microphone (bypasses permissions cache)'}
{testMicResult && (
{testMicResult.ok
? 'Microphone access granted — reload the page and try enabling voice mode again.'
: <>{testMicResult.name} {testMicResult.message ? ': ' + testMicResult.message : ''}>
}
)}
)}
{ setModal(null); setDiagResult(null); setTestMicResult(null); setShowResetSteps(false); }}>Close
{/* Native dictation fallback */}
Can't get mic working? {' '}
Use macOS native dictation (press Fn twice) or{' '}
Windows Voice Typing (Win+H ) and dictate directly into the{' '}
Ask Pulse text box — no browser mic permission needed.
{/* Technical info console */}
{diagResult && (
Show technical info
{diagJson}
{ try { navigator.clipboard.writeText(diagJson); } catch {} }}
>
Copy to clipboard
)}
);
})()}
{/* v14: Stop confirm dialog */}
{stopConfirm && (
setStopConfirm(false)}>
Stop voice mode?
The microphone will be released and voice recognition will stop.
setStopConfirm(false)}>Cancel
{
setStopConfirm(false);
setVoiceEnabled(false);
}}
>
Stop
)}
>
);
}
/* ============================================================
VoiceSettingsSection — rendered inside the gear popover in shell.jsx
V1: wake words editor
V3: curated 4 voices
V9: voice toggle (starts off, never persisted)
V10: language radio
V5: help blurb
============================================================ */
function VoiceSettingsSection() {
const D = window.STATUS_DATA;
const isAdmin = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME)
: (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || (D.ME.role || '').toLowerCase() === 'admin')));
if (!isAdmin) return null;
// Read voice prefs from localStorage (sub-preferences only, not enabled state)
const [selectedVoiceKey, setSelectedVoiceKey] = useState(() => {
try { return localStorage.getItem('voice_persona_key') || 'uk-male'; } catch { return 'uk-male'; }
});
const [voiceLang, setVoiceLang] = useState(() => {
try { return localStorage.getItem('voice_language') || 'en-US'; } catch { return 'en-US'; }
});
const [customWakeWords, setCustomWakeWords] = useState(() => {
try { return JSON.parse(localStorage.getItem('voice_wake_words') || '[]'); } catch { return []; }
});
const [newWakeWord, setNewWakeWord] = useState('');
const [voices, setVoices] = useState([]);
// v17: premium TTS state
const [premCfg, setPremCfg] = useState(null); // null = not loaded yet
const [premLoading, setPremLoading] = useState(false);
const [premSaving, setPremSaving] = useState(false);
const [premMsg, setPremMsg] = useState(''); // success / error feedback
const [premVoices, setPremVoices] = useState([]);
const [premVoicesLoading, setPremVoicesLoading] = useState(false);
const [premTesting, setPremTesting] = useState(false);
const [premTestMsg, setPremTestMsg] = useState('');
// local editable form
const [premForm, setPremForm] = useState({
enabled: false, provider: 'openai', api_key: '', voice_id: 'onyx', model: 'tts-1',
});
useEffect(() => {
const load = () => {
const v = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
if (v.length) setVoices(v);
};
load();
if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = load;
}, []);
// Load premium TTS config on mount
useEffect(() => {
setPremLoading(true);
fetch('/api/admin/tts/config', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d) {
setPremCfg(d);
setPremForm({
enabled: d.enabled,
provider: d.provider || 'openai',
api_key: '', // never pre-fill — placeholder shows masked version
voice_id: d.voice_id || 'onyx',
model: d.model || 'tts-1',
});
}
})
.catch(() => {})
.finally(() => setPremLoading(false));
}, []);
// Load voice list when provider/api_key changes (only after a successful save)
const loadPremVoices = () => {
setPremVoicesLoading(true);
fetch('/api/admin/tts/voices', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : { voices: [] })
.then(d => setPremVoices(d.voices || []))
.catch(() => setPremVoices([]))
.finally(() => setPremVoicesLoading(false));
};
const saveVoiceKey = (k) => {
setSelectedVoiceKey(k);
try { localStorage.setItem('voice_persona_key', k); } catch {}
window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { key: 'voice_persona_key', value: k } }));
};
const saveLang = (l) => {
setVoiceLang(l);
try { localStorage.setItem('voice_language', l); } catch {}
window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { key: 'voice_language', value: l } }));
};
const addWakeWord = () => {
const w = newWakeWord.trim();
if (!w || customWakeWords.includes(w)) return;
const next = [...customWakeWords, w];
setCustomWakeWords(next);
try { localStorage.setItem('voice_wake_words', JSON.stringify(next)); } catch {}
setNewWakeWord('');
};
const removeWakeWord = (w) => {
const next = customWakeWords.filter(x => x !== w);
setCustomWakeWords(next);
try { localStorage.setItem('voice_wake_words', JSON.stringify(next)); } catch {}
};
const setPF = (k, v) => setPremForm(f => ({ ...f, [k]: v }));
const savePremium = async () => {
setPremSaving(true); setPremMsg('');
try {
const body = {
enabled: premForm.enabled,
provider: premForm.provider,
voice_id: premForm.voice_id,
model: premForm.model,
};
// Only send api_key if user typed something new
if (premForm.api_key && premForm.api_key.trim()) body.api_key = premForm.api_key.trim();
const r = await fetch('/api/admin/tts/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
setPremMsg('Save failed: ' + (err.detail || r.status));
return;
}
setPremMsg('Saved!');
setPremForm(f => ({ ...f, api_key: '' })); // clear input after save
// Refresh config display and voice list
const cfg = await fetch('/api/admin/tts/config', { credentials: 'same-origin' }).then(r => r.json()).catch(() => null);
if (cfg) { setPremCfg(cfg); }
loadPremVoices();
setTimeout(() => setPremMsg(''), 3000);
} catch (e) {
setPremMsg('Error: ' + e.message);
}
setPremSaving(false);
};
const testPremium = async () => {
setPremTesting(true); setPremTestMsg('');
try {
const r = await fetch('/api/admin/tts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: 'Hello, this is a test of the premium voice assistant.' }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
setPremTestMsg('TTS test failed — ' + (err.detail || 'check API key'));
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play().catch(() => setPremTestMsg('Audio playback blocked by browser'));
} catch (e) {
setPremTestMsg('Error: ' + e.message);
}
setPremTesting(false);
};
const premiumActive = premCfg && premCfg.enabled;
const voiceOptions = CURATED_VOICES.map(cv => ({
...cv,
available: !!resolveCuratedVoice(voices, cv.key),
}));
// Build voice dropdown options: use fetched list if available, else defaults
const premVoiceOptions = premVoices.length > 0 ? premVoices : (
premForm.provider === 'openai'
? [
{ id: 'alloy', name: 'Alloy (neutral)' },
{ id: 'echo', name: 'Echo (male)' },
{ id: 'fable', name: 'Fable (British male)' },
{ id: 'onyx', name: 'Onyx (deep male)' },
{ id: 'nova', name: 'Nova (female)' },
{ id: 'nadia', name: 'Nadia (warm female)' },
{ id: 'shimmer', name: 'Shimmer (female)' },
]
: []
);
return (
Voice assistant
{premiumActive && (
Premium voice active
)}
{!premiumActive && (
Browser voice (free)
)}
{/* ── Premium voice (paid) — admin only ── */}
Premium voice (paid)
{premLoading ? (
) : (
<>
{/* Toggle */}
setPF('enabled', e.target.checked)}
style={{ margin:0 }}
/>
Use premium TTS instead of browser voice
{/* Provider segmented control */}
Provider
{[{v:'openai', l:'OpenAI'}, {v:'elevenlabs', l:'ElevenLabs'}].map(p => (
{ setPF('provider', p.v); setPF('voice_id', p.v === 'openai' ? 'onyx' : ''); setPremVoices([]); }}
>{p.l}
))}
{/* API key input */}
{/* Voice dropdown */}
Voice {premVoicesLoading && (loading...) }
setPF('voice_id', e.target.value)}
style={{ fontSize:12, width:'100%' }}
>
{premVoiceOptions.map(v => (
{v.name}
))}
{premVoiceOptions.length === 0 && (
Save config first to load voices
)}
{/* Model dropdown — OpenAI only */}
{premForm.provider === 'openai' && (
Model
setPF('model', e.target.value)}
style={{ fontSize:12, width:'100%' }}
>
tts-1 (fast, $0.015/1k chars)
tts-1-hd (higher quality, $0.030/1k chars)
)}
{/* Cost note */}
{premForm.provider === 'openai'
? 'OpenAI: ~$0.015/1k chars (tts-1) or ~$0.030/1k chars (tts-1-hd) · billed to your OpenAI account'
: 'ElevenLabs: ~$0.30/1k chars · starter plan ~$5/mo for 30k chars · billed to your ElevenLabs account'}
Daily hard limit: 25 min of synthesized audio per workspace.
{/* Action buttons */}
{premSaving ? 'Saving...' : 'Save'}
{premTesting ? 'Testing...' : 'Test voice'}
{premMsg && (
{premMsg}
)}
{premTestMsg && (
{premTestMsg}
)}
>
)}
{/* Language radio (V10) */}
{/* Curated 4 voices (V3) — grayed out when premium is active */}
Browser voice {premiumActive && (premium voice in use — configure above) }
{voiceOptions.map(opt => (
opt.available && !premiumActive && saveVoiceKey(opt.key)}
disabled={!opt.available || premiumActive}
style={{ margin:0 }}
/>
{opt.label}
{!opt.available && (
(not available)
)}
))}
{/* Custom wake words (V1) */}
Voice wake words
Wake phrases are optional. Once enabled, normal speech starts the conversation.
{customWakeWords.map(w => (
{w}
removeWakeWord(w)}
>remove
))}
setNewWakeWord(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addWakeWord()}
/>
Add
{/* Voice can / cannot blurb (V5) */}
Voice can: answer questions about team attendance, PTO, strikes, presence. Create PTO requests. Set progress scores.
Voice cannot: delete users, rooms, bots, or teams — use the dashboard for destructive actions.
);
}
window.AskAIWidget = AskAIWidget;
window.VoiceWidget = VoiceWidget;
window.VoiceSettingsSection = VoiceSettingsSection;