/* ============================================================ 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 ? ( ) : !canChangeModel ? ( ) : modelGroups.length > 0 ? ( {isAskAdmin && ( )} ) : null ); return ( <> {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); }} >