/* ============================================================
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;
const HUD_WIDGETS = window.HUD_WIDGETS || {};
const list = routed.widgets.map(id => HUD_WIDGETS[id]).filter(Boolean);
if (list.length) {
window.__hudState__ = { open: true, widgets: list, title: `From: "${q.slice(0, 60)}"` };
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 ? (
Loading…
) : (
<>
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,
];
// 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();
}
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 [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]);
// 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;
// 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 evt = new CustomEvent('hud-state-change', {
detail: { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"` },
});
window.__hudState__ = { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"` };
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);
}
};
// 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) {
const spoken = String(latest || '').trim();
if (spoken) {
startCapture(r);
captureTranscriptRef.current = spoken;
setCaptureText(spoken);
}
} else {
const stripped = stripWakeWord(latest, [...DEFAULT_WAKE_PATTERNS, ...ARABIC_WAKE_PATTERNS]);
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();
}
};
// 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: "Listening",
listening: sleepCountdown != null ? `Listening… (${sleepCountdown}s before sleep)` : "Listening…",
capturing: "Capturing…",
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}
Tap → ask
{
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 && (
Speak any time to start
)}
)}
{/* Voice toggle pill removed — replaced by Topbar MicToggle (top-right)
and the inline mic button inside the Ask AI chatbot input row. The
legacy always-on VoiceWidget panel below remains for the dictation
flow but no longer has a standalone bottom-right entry point. */}
{/* v14: Pre-flight explainer modal */}
{modal === 'preflight' && (
setModal(null)}>
Enable voice mode
Voice mode listens continuously and lets you ask the bot
questions out loud without a wake phrase. We'll ask your browser for microphone access — click Allow when
prompted. You can disable this at 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 */}
{}}
>
3.
Try Incognito to bypass cached deny
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 ? (
Loading...
) : (
<>
{/* 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;