Spaces:
Running
Running
| import { | |
| Loader2, | |
| Mic, | |
| MicOff, | |
| Navigation, | |
| ShieldOff, | |
| Volume2, | |
| X, | |
| } from 'lucide-react'; | |
| import { useCallback, useEffect, useRef, useState } from 'react'; | |
| const T = { | |
| bg: '#0d0d0d', | |
| surface: 'rgba(255,255,255,0.025)', | |
| border: 'rgba(255,255,255,0.08)', | |
| accent: '#c9b787', | |
| text: '#f5f5f5', | |
| textDim: '#8a8a8a', | |
| textMuted: '#5e5e5e', | |
| mono: 'ui-monospace, SFMono-Regular, Menlo, monospace', | |
| red: '#ef4444', | |
| green: '#22c55e', | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Navigation intent parsing | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Maps spoken artifact/section names to their URL paths. | |
| * External app paths trigger a full page navigation (window.location.href). | |
| * Internal Command paths use wouter navigation. | |
| */ | |
| const ARTIFACT_ROUTE_MAP: Array<{ keywords: string[]; path: string; label: string; external: boolean }> = [ | |
| // External ecosystem apps | |
| { keywords: ['aegis', 'sentra', 'defense', 'cyber', 'cyber resilience', 'intelligence command', 'cyber command'], path: '/sentra/', label: 'Sentra', external: true }, | |
| { keywords: ['vessels', 'maritime', 'fleet', 'ship'], path: '/vessels/', label: 'SEXTANT', external: true }, | |
| { keywords: ['terra', 'real estate'], path: '/terra/', label: 'DOMAINE', external: true }, | |
| { keywords: ['counsel', 'legal', 'matter command'], path: '/counsel/', label: 'Counsel', external: true }, | |
| { keywords: ['pulse', 'briefing', 'executive briefing'], path: '/pulse/', label: 'Pulse', external: true }, | |
| { keywords: ['a11oy', 'continuum', 'brand orchestration'], path: '/a11oy/', label: 'A11oy', external: true }, | |
| { keywords: ['holdings', 'szl holdings', 'portfolio dashboard'], path: '/szl-holdings/', label: 'SZL Holdings', external: true }, | |
| { keywords: ['lexicon', 'license intelligence', 'license catalog'], path: '/governance/lexicon', label: 'LEXICON', external: false }, | |
| // Internal Command routes | |
| { keywords: ['strategy', 'strategic overview'], path: '/strategy', label: 'Strategy', external: false }, | |
| { keywords: ['operations', 'live operations', 'ops'], path: '/operations/live', label: 'Live Operations', external: false }, | |
| { keywords: ['agents', 'agent monitor', 'agent runtime'], path: '/agents', label: 'Agents', external: false }, | |
| { keywords: ['omnia', 'world model'], path: '/omnia', label: 'OMNIA', external: false }, | |
| { keywords: ['cognitive', 'cognitive loop'], path: '/cognitive', label: 'Cognitive', external: false }, | |
| { keywords: ['infrastructure', 'global fabric'], path: '/infrastructure', label: 'Infrastructure', external: false }, | |
| { keywords: ['approvals', 'hitl', 'approval queue'], path: '/substrate/approvals', label: 'Approvals', external: false }, | |
| { keywords: ['evolution', 'evolution runtime'], path: '/evolution', label: 'Evolution', external: false }, | |
| { keywords: ['forge', 'tool intelligence'], path: '/operations/forge', label: 'Forge', external: false }, | |
| ]; | |
| // --------------------------------------------------------------------------- | |
| // Approval intent detection | |
| // --------------------------------------------------------------------------- | |
| const APPROVAL_INTENT_WORDS = [ | |
| 'approve', 'approved', 'confirm', 'confirmed', 'authorize', 'authorise', | |
| 'proceed', 'yes proceed', 'go ahead', 'i approve', 'i confirm', | |
| ]; | |
| interface ApprovalResult { | |
| actionId: string; | |
| actionLabel: string; | |
| transcript: string; | |
| approved: boolean; | |
| reason?: string; | |
| } | |
| function detectApprovalIntent(userText: string): { detected: boolean; actionLabel: string } { | |
| const lower = userText.toLowerCase(); | |
| const detected = APPROVAL_INTENT_WORDS.some((w) => lower.includes(w)); | |
| const match = lower.match(/(?:approve|confirm|authorize)\s+(?:the\s+)?(.{3,60}?)(?:\s|$)/); | |
| const actionLabel = match ? match[1].trim() : 'pending action'; | |
| return { detected, actionLabel }; | |
| } | |
| /** Navigation trigger phrases the assistant will produce */ | |
| const NAV_TRIGGER_PATTERNS = [ | |
| /navigating\s+(?:you\s+)?to\s+(.+?)(?:\.|$)/i, | |
| /taking\s+you\s+to\s+(.+?)(?:\.|$)/i, | |
| /opening\s+(.+?)(?:\.|$)/i, | |
| /switching\s+to\s+(.+?)(?:\.|$)/i, | |
| /going\s+to\s+(.+?)(?:\.|$)/i, | |
| /i(?:'ll|'m going to)\s+navigate\s+(?:you\s+)?to\s+(.+?)(?:\.|$)/i, | |
| ]; | |
| interface NavigationIntent { | |
| path: string; | |
| label: string; | |
| external: boolean; | |
| } | |
| function parseNavigationIntent(text: string): NavigationIntent | null { | |
| const lowerText = text.toLowerCase(); | |
| // Check for explicit navigation phrases | |
| for (const pattern of NAV_TRIGGER_PATTERNS) { | |
| const match = lowerText.match(pattern); | |
| if (match) { | |
| const target = match[1]?.trim() ?? ''; | |
| for (const route of ARTIFACT_ROUTE_MAP) { | |
| if (route.keywords.some((kw) => target.includes(kw))) { | |
| return { path: route.path, label: route.label, external: route.external }; | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: check if any artifact keyword appears in a navigation context | |
| const navContextWords = ['navigate', 'go', 'open', 'switch', 'take you', 'heading']; | |
| const hasNavContext = navContextWords.some((w) => lowerText.includes(w)); | |
| if (hasNavContext) { | |
| for (const route of ARTIFACT_ROUTE_MAP) { | |
| if (route.keywords.some((kw) => lowerText.includes(kw))) { | |
| return { path: route.path, label: route.label, external: route.external }; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Component | |
| // --------------------------------------------------------------------------- | |
| interface Message { | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| navigationIntent?: NavigationIntent; | |
| } | |
| function getApiBase() { | |
| const base = import.meta.env.BASE_URL?.replace(/\/$/, '') || ''; | |
| return `${base}/api`; | |
| } | |
| async function blobToBase64(blob: Blob): Promise<string> { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const result = reader.result as string; | |
| resolve(result.split(',')[1] ?? ''); | |
| }; | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| interface VoiceCommandPanelProps { | |
| open: boolean; | |
| onClose: () => void; | |
| /** Called when the assistant produces a navigation intent. Path may be external or internal. */ | |
| onNavigate?: (path: string, external: boolean) => void; | |
| } | |
| export function VoiceCommandPanel({ open, onClose, onNavigate }: VoiceCommandPanelProps) { | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [privacyMode, setPrivacyMode] = useState(false); | |
| const [userTranscript, setUserTranscript] = useState(''); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [pendingNavIntent, setPendingNavIntent] = useState<NavigationIntent | null>(null); | |
| const [lastApproval, setLastApproval] = useState<ApprovalResult | null>(null); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const audioChunksRef = useRef<Blob[]>([]); | |
| const audioRef = useRef<HTMLAudioElement | null>(null); | |
| const objectUrlRef = useRef<string | null>(null); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const conversationIdRef = useRef<string | null>(null); | |
| /** Real pending approvals fetched from the DB on panel open — used for voice approvals. */ | |
| const pendingApprovalsRef = useRef<Array<{ id: number; title: string }>>([]); | |
| // Create a conversation session and fetch pending approvals when the panel opens | |
| useEffect(() => { | |
| if (!open) return; | |
| setMessages([]); | |
| setPendingNavIntent(null); | |
| conversationIdRef.current = null; | |
| pendingApprovalsRef.current = []; | |
| const abort = new AbortController(); | |
| // Create conversation session (non-fatal if fails) | |
| fetch(`${getApiBase()}/openai/conversations`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', | |
| signal: abort.signal, | |
| }) | |
| .then((r) => r.json()) | |
| .then((data: { id?: string | number }) => { | |
| if (data.id) conversationIdRef.current = String(data.id); | |
| }) | |
| .catch(() => { /* non-fatal */ }); | |
| // Fetch real pending approvals so voice-approval calls use actual DB IDs | |
| fetch(`${getApiBase()}/approvals?status=pending&limit=20`, { | |
| credentials: 'include', | |
| signal: abort.signal, | |
| }) | |
| .then((r) => r.ok ? r.json() : null) | |
| .then((resp: { data?: Array<{ id: number; title: string }> } | Array<{ id: number; title: string }> | null) => { | |
| // Handles both sendSuccess shapes: raw array OR { data, meta } envelope | |
| const list: Array<{ id: number; title: string }> = Array.isArray(resp) | |
| ? resp | |
| : (resp as { data?: Array<{ id: number; title: string }> })?.data ?? []; | |
| pendingApprovalsRef.current = list.map((a) => ({ id: a.id, title: a.title })); | |
| }) | |
| .catch(() => { /* non-fatal — approval list unavailable */ }); | |
| return () => abort.abort(); | |
| }, [open]); | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [messages, userTranscript]); | |
| useEffect(() => { | |
| return () => { | |
| mediaRecorderRef.current?.stop(); | |
| audioRef.current?.pause(); | |
| if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); | |
| abortRef.current?.abort(); | |
| }; | |
| }, []); | |
| const stopAudio = useCallback(() => { | |
| abortRef.current?.abort(); | |
| audioRef.current?.pause(); | |
| if (objectUrlRef.current) { | |
| URL.revokeObjectURL(objectUrlRef.current); | |
| objectUrlRef.current = null; | |
| } | |
| setIsPlaying(false); | |
| }, []); | |
| const executeNavigation = useCallback( | |
| (intent: NavigationIntent) => { | |
| if (onNavigate) { | |
| onNavigate(intent.path, intent.external); | |
| } else if (intent.external) { | |
| window.location.href = intent.path; | |
| } | |
| setPendingNavIntent(null); | |
| onClose(); | |
| }, | |
| [onNavigate, onClose], | |
| ); | |
| const handleMicPress = useCallback(async () => { | |
| if (privacyMode) return; | |
| if (isRecording) { | |
| setIsRecording(false); | |
| mediaRecorderRef.current?.stop(); | |
| return; | |
| } | |
| setError(null); | |
| setPendingNavIntent(null); | |
| audioChunksRef.current = []; | |
| let stream: MediaStream; | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| } catch { | |
| setError('Microphone access denied. Check browser permissions.'); | |
| return; | |
| } | |
| const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') | |
| ? 'audio/webm;codecs=opus' | |
| : 'audio/webm'; | |
| const recorder = new MediaRecorder(stream, { mimeType }); | |
| mediaRecorderRef.current = recorder; | |
| recorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) audioChunksRef.current.push(e.data); | |
| }; | |
| recorder.onstop = async () => { | |
| stream.getTracks().forEach((t) => t.stop()); | |
| const blob = new Blob(audioChunksRef.current, { type: mimeType }); | |
| audioChunksRef.current = []; | |
| mediaRecorderRef.current = null; | |
| if (blob.size < 1000) return; | |
| setIsProcessing(true); | |
| stopAudio(); | |
| try { | |
| const base64 = await blobToBase64(blob); | |
| const abort = new AbortController(); | |
| abortRef.current = abort; | |
| const body: Record<string, string> = { audio: base64 }; | |
| if (conversationIdRef.current) body.conversationId = conversationIdRef.current; | |
| const res = await fetch(`${getApiBase()}/openai/voice-query`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| credentials: 'include', | |
| signal: abort.signal, | |
| }); | |
| if (!res.ok) throw new Error('Voice query failed'); | |
| const data = await res.json() as { | |
| userTranscript?: string; | |
| assistantTranscript?: string; | |
| audioBase64?: string; | |
| provenance?: { conversationId?: string }; | |
| }; | |
| // Update conversationId if server assigned/echoed one | |
| if (data.provenance?.conversationId && !conversationIdRef.current) { | |
| conversationIdRef.current = data.provenance.conversationId; | |
| } | |
| const userText = data.userTranscript?.trim() || userTranscript || 'Voice query'; | |
| const assistantText = data.assistantTranscript?.trim() || 'No response received.'; | |
| // Check for approval intent in the user's transcript | |
| const { detected: hasApprovalIntent, actionLabel } = detectApprovalIntent(userText); | |
| if (hasApprovalIntent && pendingApprovalsRef.current.length > 0) { | |
| // Match the spoken action label against real pending approvals; default to first pending | |
| const matched = pendingApprovalsRef.current.find( | |
| (a) => actionLabel && a.title.toLowerCase().includes(actionLabel.toLowerCase()), | |
| ) ?? pendingApprovalsRef.current[0]; | |
| fetch(`${getApiBase()}/openai/voice-approval`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| pendingActionId: matched.id, | |
| transcript: userText, | |
| ...(conversationIdRef.current ? { conversationId: conversationIdRef.current } : {}), | |
| }), | |
| credentials: 'include', | |
| }) | |
| .then((r) => r.json()) | |
| .then((result: { approved?: boolean; reason?: string }) => { | |
| setLastApproval({ | |
| actionId: String(matched.id), | |
| actionLabel: matched.title, | |
| transcript: userText, | |
| approved: result.approved ?? false, | |
| reason: result.reason, | |
| }); | |
| // Remove approved item from local ref to prevent re-submission | |
| if (result.approved) { | |
| pendingApprovalsRef.current = pendingApprovalsRef.current.filter( | |
| (a) => a.id !== matched.id, | |
| ); | |
| } | |
| setTimeout(() => setLastApproval(null), 8000); | |
| }) | |
| .catch(() => {/* non-fatal */}); | |
| } else if (hasApprovalIntent && pendingApprovalsRef.current.length === 0) { | |
| // Intent detected but no real pending approvals available | |
| setLastApproval({ | |
| actionId: 'none', | |
| actionLabel, | |
| transcript: userText, | |
| approved: false, | |
| reason: 'No pending approvals in queue — nothing to approve', | |
| }); | |
| setTimeout(() => setLastApproval(null), 6000); | |
| } | |
| // Check for navigation intent | |
| const navIntent = parseNavigationIntent(assistantText); | |
| setUserTranscript(''); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { | |
| id: `user-${Date.now()}`, | |
| role: 'user', | |
| content: userText, | |
| timestamp: new Date(), | |
| }, | |
| { | |
| id: `asst-${Date.now()}`, | |
| role: 'assistant', | |
| content: assistantText, | |
| timestamp: new Date(), | |
| navigationIntent: navIntent ?? undefined, | |
| }, | |
| ]); | |
| if (navIntent) { | |
| setPendingNavIntent(navIntent); | |
| } | |
| if (data.audioBase64 && !abort.signal.aborted) { | |
| const audioBytes = Uint8Array.from(atob(data.audioBase64), (c) => c.charCodeAt(0)); | |
| const audioBlob = new Blob([audioBytes], { type: 'audio/mpeg' }); | |
| const url = URL.createObjectURL(audioBlob); | |
| objectUrlRef.current = url; | |
| const audio = new Audio(url); | |
| audioRef.current = audio; | |
| setIsPlaying(true); | |
| audio.onended = () => { | |
| setIsPlaying(false); | |
| URL.revokeObjectURL(url); | |
| objectUrlRef.current = null; | |
| // Auto-execute navigation after audio finishes (if still pending) | |
| if (navIntent) { | |
| setPendingNavIntent((current) => { | |
| if (current) executeNavigation(current); | |
| return null; | |
| }); | |
| } | |
| }; | |
| audio.onerror = () => setIsPlaying(false); | |
| abort.signal.addEventListener('abort', () => audio.pause()); | |
| audio.play().catch(() => setIsPlaying(false)); | |
| } else if (navIntent) { | |
| // No audio — navigate immediately after a short delay | |
| setTimeout(() => executeNavigation(navIntent), 800); | |
| } | |
| } catch (err) { | |
| if ((err as Error).name !== 'AbortError') { | |
| setError('Voice processing failed. Please try again.'); | |
| } | |
| } finally { | |
| setIsProcessing(false); | |
| } | |
| }; | |
| recorder.start(); | |
| setIsRecording(true); | |
| setUserTranscript('Recording…'); | |
| }, [privacyMode, isRecording, userTranscript, stopAudio, executeNavigation]); | |
| // When privacy mode is enabled mid-recording, immediately stop the mic | |
| useEffect(() => { | |
| if (privacyMode && isRecording) { | |
| mediaRecorderRef.current?.stop(); | |
| setIsRecording(false); | |
| setUserTranscript(''); | |
| } | |
| }, [privacyMode, isRecording]); | |
| const handleClose = useCallback(() => { | |
| if (isRecording) { | |
| mediaRecorderRef.current?.stop(); | |
| setIsRecording(false); | |
| } | |
| stopAudio(); | |
| onClose(); | |
| }, [isRecording, stopAudio, onClose]); | |
| const isBusy = isProcessing || isPlaying; | |
| if (!open) return null; | |
| return ( | |
| <div | |
| style={{ | |
| position: 'fixed', | |
| inset: 0, | |
| zIndex: 9999, | |
| display: 'flex', | |
| alignItems: 'flex-end', | |
| justifyContent: 'flex-end', | |
| pointerEvents: 'none', | |
| }} | |
| > | |
| <div | |
| style={{ | |
| pointerEvents: 'all', | |
| width: 360, | |
| maxHeight: '70vh', | |
| margin: '0 24px 80px 0', | |
| background: T.bg, | |
| border: `1px solid ${T.border}`, | |
| borderRadius: 12, | |
| boxShadow: '0 24px 80px rgba(0,0,0,0.6)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| overflow: 'hidden', | |
| }} | |
| > | |
| {/* Header */} | |
| <div | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| padding: '12px 16px', | |
| borderBottom: `1px solid ${T.border}`, | |
| flexShrink: 0, | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> | |
| <div | |
| style={{ | |
| width: 8, | |
| height: 8, | |
| borderRadius: '50%', | |
| background: isRecording | |
| ? T.red | |
| : isPlaying | |
| ? T.accent | |
| : pendingNavIntent | |
| ? T.green | |
| : 'rgba(255,255,255,0.2)', | |
| boxShadow: isRecording | |
| ? `0 0 8px ${T.red}` | |
| : isPlaying | |
| ? `0 0 8px ${T.accent}` | |
| : pendingNavIntent | |
| ? `0 0 8px ${T.green}` | |
| : 'none', | |
| transition: 'all 0.3s', | |
| }} | |
| /> | |
| <span | |
| style={{ | |
| fontSize: '0.75rem', | |
| fontWeight: 600, | |
| fontFamily: T.mono, | |
| color: T.textDim, | |
| letterSpacing: '0.06em', | |
| textTransform: 'uppercase', | |
| }} | |
| > | |
| Command Voice | |
| </span> | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> | |
| <button | |
| type="button" | |
| onClick={() => setPrivacyMode((p) => !p)} | |
| title={privacyMode ? 'Privacy mode on — mic muted' : 'Enable privacy mode'} | |
| style={{ | |
| padding: 4, | |
| background: privacyMode ? 'rgba(239,68,68,0.15)' : 'none', | |
| border: privacyMode | |
| ? '1px solid rgba(239,68,68,0.3)' | |
| : '1px solid transparent', | |
| borderRadius: 4, | |
| color: privacyMode ? T.red : T.textMuted, | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }} | |
| > | |
| <ShieldOff style={{ width: 13, height: 13 }} /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleClose} | |
| style={{ | |
| padding: 4, | |
| background: 'none', | |
| border: 'none', | |
| color: T.textMuted, | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }} | |
| > | |
| <X style={{ width: 13, height: 13 }} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Voice approval confirmation banner */} | |
| {lastApproval && ( | |
| <div | |
| style={{ | |
| padding: '8px 16px', | |
| background: lastApproval.approved ? 'rgba(34,197,94,0.06)' : 'rgba(239,68,68,0.06)', | |
| borderBottom: `1px solid ${lastApproval.approved ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| flexShrink: 0, | |
| }} | |
| > | |
| <span | |
| style={{ | |
| fontSize: '0.68rem', | |
| color: lastApproval.approved ? T.green : T.red, | |
| fontFamily: T.mono, | |
| }} | |
| > | |
| {lastApproval.approved | |
| ? `✓ Voice approval recorded — "${lastApproval.actionLabel}"` | |
| : `✗ Approval not confirmed — rephrase with "approve" or "confirm"`} | |
| </span> | |
| </div> | |
| )} | |
| {/* Privacy mode banner */} | |
| {privacyMode && ( | |
| <div | |
| style={{ | |
| padding: '8px 16px', | |
| background: 'rgba(239,68,68,0.06)', | |
| borderBottom: `1px solid rgba(239,68,68,0.15)`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| }} | |
| > | |
| <ShieldOff style={{ width: 11, height: 11, color: T.red }} /> | |
| <span style={{ fontSize: '0.68rem', color: T.red, fontFamily: T.mono }}> | |
| Privacy mode — microphone muted | |
| </span> | |
| </div> | |
| )} | |
| {/* Pending navigation intent banner */} | |
| {pendingNavIntent && ( | |
| <div | |
| style={{ | |
| padding: '8px 16px', | |
| background: 'rgba(34,197,94,0.06)', | |
| borderBottom: `1px solid rgba(34,197,94,0.18)`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| gap: 8, | |
| flexShrink: 0, | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
| <Navigation style={{ width: 11, height: 11, color: T.green }} /> | |
| <span style={{ fontSize: '0.68rem', color: T.green, fontFamily: T.mono }}> | |
| Navigate to {pendingNavIntent.label}? | |
| </span> | |
| </div> | |
| <div style={{ display: 'flex', gap: 6 }}> | |
| <button | |
| type="button" | |
| onClick={() => executeNavigation(pendingNavIntent)} | |
| style={{ | |
| padding: '2px 8px', | |
| background: 'rgba(34,197,94,0.15)', | |
| border: '1px solid rgba(34,197,94,0.35)', | |
| borderRadius: 4, | |
| color: T.green, | |
| fontSize: '0.65rem', | |
| fontFamily: T.mono, | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Go | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setPendingNavIntent(null)} | |
| style={{ | |
| padding: '2px 8px', | |
| background: 'none', | |
| border: '1px solid rgba(255,255,255,0.1)', | |
| borderRadius: 4, | |
| color: T.textMuted, | |
| fontSize: '0.65rem', | |
| fontFamily: T.mono, | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Message thread */} | |
| <div | |
| ref={scrollRef} | |
| style={{ | |
| flex: 1, | |
| overflowY: 'auto', | |
| padding: '12px 16px', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 10, | |
| minHeight: 0, | |
| }} | |
| > | |
| {messages.length === 0 && !userTranscript && !isProcessing && ( | |
| <div | |
| style={{ | |
| textAlign: 'center', | |
| padding: '24px 16px', | |
| color: T.textMuted, | |
| fontSize: '0.75rem', | |
| fontFamily: T.mono, | |
| lineHeight: 1.6, | |
| }} | |
| > | |
| <Mic | |
| style={{ | |
| width: 28, | |
| height: 28, | |
| color: T.accent, | |
| margin: '0 auto 12px', | |
| opacity: 0.5, | |
| }} | |
| /> | |
| <div>Speak to navigate, query, or approve actions</div> | |
| <div style={{ marginTop: 6, fontSize: '0.63rem', color: T.textMuted }}> | |
| "Show me active threats" · "Navigate to PARAGON" · "Read my briefing" | |
| </div> | |
| </div> | |
| )} | |
| {messages.map((msg) => ( | |
| <div | |
| key={msg.id} | |
| style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start', | |
| gap: 3, | |
| }} | |
| > | |
| <div | |
| style={{ | |
| maxWidth: '85%', | |
| padding: '8px 12px', | |
| borderRadius: msg.role === 'user' ? '10px 10px 2px 10px' : '10px 10px 10px 2px', | |
| background: | |
| msg.role === 'user' | |
| ? `${T.accent}18` | |
| : msg.navigationIntent | |
| ? 'rgba(34,197,94,0.06)' | |
| : T.surface, | |
| border: `1px solid ${ | |
| msg.role === 'user' | |
| ? `${T.accent}30` | |
| : msg.navigationIntent | |
| ? 'rgba(34,197,94,0.2)' | |
| : T.border | |
| }`, | |
| fontSize: '0.81rem', | |
| color: T.text, | |
| lineHeight: 1.5, | |
| }} | |
| > | |
| {msg.content} | |
| {msg.navigationIntent && ( | |
| <div | |
| style={{ | |
| marginTop: 6, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 4, | |
| fontSize: '0.65rem', | |
| color: T.green, | |
| fontFamily: T.mono, | |
| }} | |
| > | |
| <Navigation style={{ width: 9, height: 9 }} /> | |
| Route: {msg.navigationIntent.label} | |
| </div> | |
| )} | |
| </div> | |
| <span | |
| style={{ | |
| fontSize: '0.58rem', | |
| color: T.textMuted, | |
| fontFamily: T.mono, | |
| padding: '0 4px', | |
| }} | |
| > | |
| {msg.role === 'user' ? 'You' : 'Command'} | |
| {' · '} | |
| {msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} | |
| </span> | |
| </div> | |
| ))} | |
| {userTranscript && ( | |
| <div | |
| style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'flex-end', | |
| gap: 3, | |
| opacity: 0.7, | |
| }} | |
| > | |
| <div | |
| style={{ | |
| maxWidth: '85%', | |
| padding: '8px 12px', | |
| borderRadius: '10px 10px 2px 10px', | |
| background: `${T.accent}0a`, | |
| border: `1px dashed ${T.accent}25`, | |
| fontSize: '0.81rem', | |
| color: T.textDim, | |
| lineHeight: 1.5, | |
| }} | |
| > | |
| {userTranscript} | |
| </div> | |
| </div> | |
| )} | |
| {isProcessing && ( | |
| <div | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| fontSize: '0.75rem', | |
| color: T.accent, | |
| fontFamily: T.mono, | |
| padding: '4px 0', | |
| }} | |
| > | |
| <Loader2 style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} /> | |
| Processing voice query… | |
| </div> | |
| )} | |
| {error && ( | |
| <div | |
| style={{ | |
| padding: '8px 12px', | |
| borderRadius: 6, | |
| background: 'rgba(239,68,68,0.06)', | |
| border: '1px solid rgba(239,68,68,0.2)', | |
| fontSize: '0.75rem', | |
| color: T.red, | |
| }} | |
| > | |
| {error} | |
| </div> | |
| )} | |
| </div> | |
| {/* Controls */} | |
| <div | |
| style={{ | |
| padding: '12px 16px', | |
| borderTop: `1px solid ${T.border}`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: 16, | |
| flexShrink: 0, | |
| }} | |
| > | |
| {isPlaying && ( | |
| <div | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| fontSize: '0.68rem', | |
| color: T.accent, | |
| fontFamily: T.mono, | |
| }} | |
| > | |
| <Volume2 style={{ width: 12, height: 12 }} /> | |
| Speaking… | |
| </div> | |
| )} | |
| {!isBusy && ( | |
| <button | |
| type="button" | |
| onClick={handleMicPress} | |
| disabled={privacyMode} | |
| title={ | |
| privacyMode | |
| ? 'Privacy mode is on — mic muted' | |
| : isRecording | |
| ? 'Tap to send' | |
| : 'Tap to speak' | |
| } | |
| style={{ | |
| width: 52, | |
| height: 52, | |
| borderRadius: '50%', | |
| border: `2px solid ${ | |
| isRecording ? T.red : privacyMode ? 'rgba(255,255,255,0.1)' : T.accent | |
| }`, | |
| background: isRecording | |
| ? 'rgba(239,68,68,0.12)' | |
| : privacyMode | |
| ? 'rgba(255,255,255,0.03)' | |
| : `${T.accent}10`, | |
| color: isRecording ? T.red : privacyMode ? T.textMuted : T.accent, | |
| cursor: privacyMode ? 'not-allowed' : 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| transition: 'all 0.2s', | |
| boxShadow: isRecording ? `0 0 20px rgba(239,68,68,0.25)` : 'none', | |
| }} | |
| > | |
| {isRecording ? ( | |
| <MicOff style={{ width: 20, height: 20 }} /> | |
| ) : ( | |
| <Mic style={{ width: 20, height: 20 }} /> | |
| )} | |
| </button> | |
| )} | |
| {isProcessing && !isPlaying && ( | |
| <Loader2 | |
| style={{ | |
| width: 24, | |
| height: 24, | |
| color: T.accent, | |
| animation: 'spin 1s linear infinite', | |
| }} | |
| /> | |
| )} | |
| </div> | |
| <div | |
| style={{ | |
| padding: '6px 16px 10px', | |
| fontSize: '0.58rem', | |
| color: T.textMuted, | |
| fontFamily: T.mono, | |
| textAlign: 'center', | |
| }} | |
| > | |
| {isRecording | |
| ? 'Recording — tap mic to send' | |
| : isProcessing | |
| ? 'Processing your query…' | |
| : isPlaying | |
| ? 'Command is responding…' | |
| : pendingNavIntent | |
| ? `Ready to navigate to ${pendingNavIntent.label}` | |
| : 'Tap mic to speak'} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |