import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { saveRecording, getQueue, getRecording, removeRecording, clearQueue, updateRecordingStatus, appendChunk, assembleChunks, listOrphanedSessions, clearChunks } from './offlineQueue' import { StatusBar, Style } from '@capacitor/status-bar' import Cactus from './lib/cactus' import { runPipeline } from './lib/pipeline' import './App.css' // API_BASE resolves in this order at module-load: // 1. localStorage 'sakhi_server_url' — user-entered LAN URL, set via the // Server URL field below the status line. Required for the Capacitor APK, // where window.location.hostname is 'localhost' (the WebView's own scheme) // so the default expression would point at the phone's loopback. // 2. VITE_API_BASE_URL build-time env var — used by CI / pinned builds. // 3. Default — depends on how the bundle is being served: // a. Vite dev server (port 3000 / 5173) → http://:8000 to // reach the separate FastAPI process. // b. Otherwise (FastAPI-served bundle on a workstation, HF Space, any // production deploy) → same-origin relative URLs. This is what makes // the HF Space work: the Space proxies one HTTPS port, there is no // separate :8000 reachable from outside. function defaultApiBase() { const port = typeof window !== 'undefined' ? window.location.port : '' if (port === '3000' || port === '5173') { return `http://${window.location.hostname}:8000` } return '' } function resolveApiBase() { try { const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('sakhi_server_url') : null if (stored && stored.trim()) return stored.trim().replace(/\/+$/, '') } catch (_) {} if (import.meta.env.VITE_API_BASE_URL) return import.meta.env.VITE_API_BASE_URL return defaultApiBase() } const API_BASE = resolveApiBase() const VISIT_OPTIONS = [ { label: 'Auto-detect', value: 'auto' }, { label: 'ANC Visit', value: 'anc_visit' }, { label: 'PNC Visit', value: 'pnc_visit' }, { label: 'Delivery', value: 'delivery' }, { label: 'Child Health', value: 'child_health' }, ] function initialMetadata() { const stickyAsha = typeof localStorage !== 'undefined' ? localStorage.getItem('sakhi_asha_id') || '' : '' return { patient_name: '', patient_age: '', age_unit: 'years', patient_sex: '', patient_mobile: '', asha_id: stickyAsha, visit_date: new Date().toISOString().slice(0, 10), } } function appendMetadataToFormData(formData, metadata) { if (!metadata) return for (const [k, v] of Object.entries(metadata)) { if (v !== '' && v != null) formData.append(k, String(v)) } } function metadataPayload(metadata) { if (!metadata) return null const out = {} for (const [k, v] of Object.entries(metadata)) { if (v === '' || v == null) continue out[k] = k === 'patient_age' ? Number(v) : v } return Object.keys(out).length ? out : null } function PatientMetadataHeader({ metadata, setMetadata, visitType, setVisitType }) { const update = (k, v) => setMetadata((m) => ({ ...m, [k]: v })) return (

Patient & Visit Info

) } const VOICE_STAGE_META = { asr: 'Transcribing audio...', normalize: 'Normalizing Hindi numbers...', detect: 'Detecting visit type...', form: 'Extracting structured form...', danger: 'Detecting danger signs...', } const TEXT_STAGE_META = { detect: 'Detecting visit type...', form: 'Extracting structured form...', danger: 'Detecting danger signs...', } function PipelineProgress({ stages }) { return (
{stages.map((stage) => (
{stage.status === 'done' ? '\u2713' : stage.status === 'running' ? '\u25CF' : '\u25CB'} {stage.label} {stage.status === 'done' && stage.time != null && ({stage.time}s)}
))}
) } function prettyLabel(text) { return String(text || '') .replaceAll('_', ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) } function keyValueRows(data, prefix = '') { if (!data || typeof data !== 'object' || Array.isArray(data)) return [] const rows = [] Object.entries(data).forEach(([key, value]) => { const fullKey = prefix ? `${prefix} > ${prettyLabel(key)}` : prettyLabel(key) if (value && typeof value === 'object' && !Array.isArray(value)) { rows.push(...keyValueRows(value, fullKey)) return } if (Array.isArray(value)) { rows.push({ key: fullKey, value: value.length ? value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v))).join(', ') : '—', }) return } rows.push({ key: fullKey, value: value ?? '—' }) }) return rows } function App() { // Native APK: dark status-bar icons (LinkedIn-style) so the airplane icon // and clock render visibly against Sakhi's light content. No-op in browser. useEffect(() => { (async () => { try { await StatusBar.setOverlaysWebView({ overlay: true }) await StatusBar.setStyle({ style: Style.Light }) } catch { /* not running in a native Capacitor context */ } })() }, []) const [activeTab, setActiveTab] = useState('voice') const [health, setHealth] = useState('Checking backend...') const [apiReachable, setApiReachable] = useState(null) // null = unknown, true/false after probe const [serverUrlInput, setServerUrlInput] = useState(API_BASE) const [serverUrlEditing, setServerUrlEditing] = useState(false) const [examples, setExamples] = useState([]) const [history, setHistory] = useState(() => { try { return JSON.parse(localStorage.getItem('sakhi_history') || '[]') } catch { return [] } }) const [viewingHistory, setViewingHistory] = useState(null) // Shared by Voice + Field record tabs (a single patient context per session). // Text tab and Field on-device card keep separate visit-type state below. const [recordingVisitType, setRecordingVisitType] = useState('auto') const [metadata, setMetadata] = useState(initialMetadata) const [textVisitType, setTextVisitType] = useState('auto') const [textInput, setTextInput] = useState('') const [selectedExample, setSelectedExample] = useState('') const [audioFile, setAudioFile] = useState(null) const [audioUrl, setAudioUrl] = useState('') const [isRecording, setIsRecording] = useState(false) // Curated audio examples served by the backend at /api/audio-examples. // Lets a judge play a real role-play clip + run extraction without // needing their own Hindi audio. const [audioExamples, setAudioExamples] = useState([]) const [selectedAudioExample, setSelectedAudioExample] = useState('') // On-demand English translation of the Hindi transcript. Not in the main // extraction path — fires only when the reviewer clicks Translate. const [translation, setTranslation] = useState({ loading: false, english: '', error: '' }) const mediaRecorderRef = useRef(null) const streamRef = useRef(null) const chunksRef = useRef([]) const [voiceState, setVoiceState] = useState({ loading: false, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null, _raw: null, }) const [textState, setTextState] = useState({ loading: false, error: '', visitType: '', form: null, danger: null, timing: null, _raw: null, }) const [pipelineStages, setPipelineStages] = useState([]) // Field Mode state const [isOnline, setIsOnline] = useState(navigator.onLine) const [offlineQueue, setOfflineQueue] = useState([]) const [fieldRecording, setFieldRecording] = useState(false) const [syncingId, setSyncingId] = useState(null) const fieldRecorderRef = useRef(null) const fieldStreamRef = useRef(null) const fieldSessionIdRef = useRef(null) const [fieldError, setFieldError] = useState('') const [playingId, setPlayingId] = useState(null) const playAudioRef = useRef(null) const [orphanedSessions, setOrphanedSessions] = useState([]) // On-device Field text-in extraction (Cactus + pipeline.js) const [fieldOnDeviceText, setFieldOnDeviceText] = useState('') const [fieldOnDeviceVisitType, setFieldOnDeviceVisitType] = useState('auto') const [fieldOnDeviceState, setFieldOnDeviceState] = useState({ loading: false, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null, _raw: null, }) const [devViewEnabled, setDevViewEnabled] = useState(false) // Cactus on-device probe state const [cactusStatus, setCactusStatus] = useState(null) const [cactusBusy, setCactusBusy] = useState(false) const [cactusLog, setCactusLog] = useState([]) const [importProgress, setImportProgress] = useState(null) // { phase, pct, entries, totalEntries, bytes } const pushLog = (msg) => setCactusLog((prev) => [...prev.slice(-30), `[${new Date().toLocaleTimeString('en-IN')}] ${msg}`]) useEffect(() => { fetch(`${API_BASE}/api/health`) .then((r) => r.json()) .then((d) => { setHealth( d.whisper ? `API: ${d.status} · LLM: ${d.model} · ASR: ${d.whisper}` : `API: ${d.status} · Model: ${d.model}` ) setApiReachable(true) }) .catch(() => { setHealth(`API not reachable at ${API_BASE || window.location.origin}`) setApiReachable(false) }) fetch(`${API_BASE}/api/examples`) .then((r) => r.json()) .then((data) => { setExamples(data || []) const defaultEx = (data || []).find((e) => e.default) || data?.[0] if (defaultEx) { setSelectedExample(defaultEx.label) setTextInput(defaultEx.transcript || '') } }) .catch(() => {}) fetch(`${API_BASE}/api/audio-examples`) .then((r) => r.json()) .then((data) => setAudioExamples(Array.isArray(data) ? data : [])) .catch(() => {}) }, []) async function loadAudioExample(id) { setSelectedAudioExample(id) if (!id) return const ex = audioExamples.find((e) => e.id === id) if (!ex) return setVoiceState((s) => ({ ...s, error: '' })) setTranslation({ loading: false, english: '', error: '' }) try { const res = await fetch(`${API_BASE}${ex.url}`) if (!res.ok) throw new Error(`fetch ${ex.url} → ${res.status}`) const blob = await res.blob() const file = new File([blob], ex.file, { type: blob.type || 'audio/ogg' }) if (audioUrl) URL.revokeObjectURL(audioUrl) setAudioFile(file) setAudioUrl(URL.createObjectURL(file)) if (ex.visit_type_hint) setRecordingVisitType(ex.visit_type_hint) } catch (err) { setVoiceState((s) => ({ ...s, error: `Could not load example: ${err.message}` })) } } async function translateTranscript() { const text = (voiceState.transcript || '').trim() if (!text) return setTranslation({ loading: true, english: '', error: '' }) try { const res = await fetch(`${API_BASE}/api/translate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() setTranslation({ loading: false, english: data.english || '', error: '' }) } catch (err) { setTranslation({ loading: false, english: '', error: err.message }) } } function saveServerUrl() { const cleaned = (serverUrlInput || '').trim().replace(/\/+$/, '') if (!cleaned) { try { localStorage.removeItem('sakhi_server_url') } catch (_) {} } else { try { localStorage.setItem('sakhi_server_url', cleaned) } catch (_) {} } // Reload so every module-level API_BASE caller picks up the new value. window.location.reload() } useEffect(() => { return () => { if (audioUrl) URL.revokeObjectURL(audioUrl) if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()) } } }, [audioUrl]) useEffect(() => { if (metadata.asha_id) localStorage.setItem('sakhi_asha_id', metadata.asha_id) }, [metadata.asha_id]) // Online/offline detection + queue loading. // On network flip, immediately reflect the new API reachability so the // top banner + Field Mode badge stop showing a stale "Connected" state. useEffect(() => { const probeHealth = () => { fetch(`${API_BASE}/api/health`) .then((r) => r.json()) .then((d) => { setHealth( d.whisper ? `API: ${d.status} · LLM: ${d.model} · ASR: ${d.whisper}` : `API: ${d.status} · Model: ${d.model}` ) setApiReachable(true) }) .catch(() => { setHealth(`API not reachable at ${API_BASE || window.location.origin}`) setApiReachable(false) }) } const goOnline = () => { setIsOnline(true) probeHealth() } const goOffline = () => { setIsOnline(false) setApiReachable(false) setHealth('API not reachable — phone is offline') } window.addEventListener('online', goOnline) window.addEventListener('offline', goOffline) loadQueue() return () => { window.removeEventListener('online', goOnline) window.removeEventListener('offline', goOffline) } }, []) async function loadQueue() { const q = await getQueue() setOfflineQueue(q) } async function loadOrphaned() { try { const list = await listOrphanedSessions() setOrphanedSessions(list) } catch { setOrphanedSessions([]) } } useEffect(() => { if (activeTab === 'field') loadOrphaned() }, [activeTab]) async function recoverOrphan(sessionId, visitType) { try { const result = await assembleChunks(sessionId) if (result && result.blob && result.blob.size > 0) { await saveRecording( result.blob, visitType || 'auto', `Recovered ${new Date().toLocaleTimeString('en-IN')}`, result.metadata, ) } await clearChunks(sessionId) await loadOrphaned() await loadQueue() } catch (err) { setFieldError(`Recovery failed: ${err.message}`) } } async function discardOrphan(sessionId) { try { await clearChunks(sessionId) await loadOrphaned() } catch (err) { setFieldError(`Discard failed: ${err.message}`) } } async function cactusCheck() { setCactusBusy(true) try { const s = await Cactus.isAvailable() setCactusStatus(s) pushLog(`status: available=${s.available} modelPresent=${s.modelPresent ?? false}${s.modelFound ? ` @ ${s.modelFound}` : ''}`) } catch (err) { pushLog(`status check failed: ${err.message || err}`) setCactusStatus({ available: false, error: String(err) }) } finally { setCactusBusy(false) } } async function cactusLoad() { setCactusBusy(true) try { pushLog('loading model...') const r = await Cactus.init() pushLog(`model loaded in ${r.initMs || '?'}ms from ${r.modelPath}`) setCactusStatus((s) => ({ ...(s || {}), ...r, loaded: true })) } catch (err) { pushLog(`init failed: ${err.message || err}`) } finally { setCactusBusy(false) } } async function cactusTest() { setCactusBusy(true) try { pushLog('running test completion...') const t0 = Date.now() const r = await Cactus.complete({ messages: [ { role: 'user', content: 'नमस्ते, आप कैसे हैं?' }, ], options: { max_tokens: 64, temperature: 0.3 }, }) const elapsed = Date.now() - t0 pushLog(`got ${r.text?.length || 0} chars in ${elapsed}ms (decode ${r.decodeTps?.toFixed?.(1) || '?'} tps)`) pushLog(`text: ${(r.text || r.raw || '').slice(0, 200)}`) } catch (err) { pushLog(`complete failed: ${err.message || err}`) } finally { setCactusBusy(false) } } async function cactusUnload() { setCactusBusy(true) try { await Cactus.destroy() pushLog('model unloaded') setCactusStatus((s) => ({ ...(s || {}), loaded: false, handle: 0 })) } catch (err) { pushLog(`destroy failed: ${err.message || err}`) } finally { setCactusBusy(false) } } async function cactusImport() { setCactusBusy(true) setImportProgress(null) try { pushLog('opening file picker...') // We log only on every 5% crossover (or on terminal events) to keep // the log card readable — the progress bar itself updates per 1%. let lastLogBucket = -1 const r = await Cactus.importModelFromZip((evt) => { setImportProgress(evt) const mb = evt.bytes != null ? (evt.bytes / (1024 * 1024)).toFixed(0) : '?' if (evt.phase === 'scanning_done') { const totalMb = evt.totalBytes ? (evt.totalBytes / (1024 * 1024)).toFixed(0) : '?' pushLog(`starting extract (zip is ${totalMb} MB)`) } else if (evt.phase === 'extracting') { const bucket = Math.floor((evt.pct || 0) / 5) if (bucket > lastLogBucket) { lastLogBucket = bucket pushLog(`extract ${evt.pct}% — ${evt.entries} files, ${mb} MB`) } } else if (evt.phase === 'done') { pushLog(`extract 100% — ${evt.entries} files (${mb} MB) written`) } }) if (r.cancelled) { pushLog('import cancelled') return } const mb = r.bytes ? (r.bytes / (1024 * 1024)).toFixed(0) : '?' pushLog(`imported ${r.entries} files (${mb} MB) → ${r.modelPath}`) // Re-probe so the UI sees the new model. const s = await Cactus.isAvailable() setCactusStatus(s) } catch (err) { pushLog(`import failed: ${err.message || err}`) } finally { setCactusBusy(false) setImportProgress(null) } } async function processFieldOnDevice() { const text = fieldOnDeviceText.trim() if (!text) { setFieldOnDeviceState((s) => ({ ...s, error: 'Type a Hindi note first.' })) return } setFieldOnDeviceState({ loading: true, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null, _raw: null }) try { const result = await runPipeline({ engine: Cactus, transcript: text, visitType: fieldOnDeviceVisitType === 'auto' ? null : fieldOnDeviceVisitType, metadata, }) setFieldOnDeviceState({ loading: false, error: '', transcript: result.transcript, visitType: result.visitType, form: result.form, danger: result.danger, timing: result.timing, _raw: result._raw || null, }) saveToHistory('field', result.visitType, result.form, result.danger, result.transcript, result.timing) } catch (err) { setFieldOnDeviceState((s) => ({ ...s, loading: false, error: `On-device extraction failed: ${err.message || err}` })) } } async function startFieldRecording() { setFieldError('') // Stop any active recorders first if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop() } if (fieldRecorderRef.current && fieldRecorderRef.current.state !== 'inactive') { fieldRecorderRef.current.stop() } // Release all mic streams if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()) streamRef.current = null } if (fieldStreamRef.current) { fieldStreamRef.current.getTracks().forEach((t) => t.stop()) fieldStreamRef.current = null } // Small delay to let the OS release the device await new Promise((r) => setTimeout(r, 300)) try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true } }) fieldStreamRef.current = stream const recorder = new MediaRecorder(stream) const sessionId = (crypto.randomUUID && crypto.randomUUID()) || `s-${Date.now()}-${Math.random().toString(36).slice(2)}` fieldSessionIdRef.current = sessionId const capturedVisitType = recordingVisitType const capturedMetadata = { ...metadata } recorder.ondataavailable = async (e) => { if (e.data && e.data.size > 0) { try { await appendChunk(sessionId, e.data, capturedVisitType, capturedMetadata) } catch (err) { console.error('appendChunk failed', err) } } } recorder.onstop = async () => { stream.getTracks().forEach((t) => t.stop()) fieldStreamRef.current = null try { const result = await assembleChunks(sessionId) if (result && result.blob && result.blob.size > 0) { await saveRecording(result.blob, capturedVisitType, '', capturedMetadata) } await clearChunks(sessionId) } catch (err) { setFieldError(`Save failed: ${err.message}`) } fieldSessionIdRef.current = null await loadQueue() await loadOrphaned() } fieldRecorderRef.current = recorder recorder.start(5000) setFieldRecording(true) } catch (err) { setFieldError(`Microphone error: ${err.name}: ${err.message}`) } } function stopFieldRecording() { if (!fieldRecorderRef.current) return fieldRecorderRef.current.stop() setFieldRecording(false) } async function syncRecording(id) { setSyncingId(id) setPipelineStages([]) setVoiceState({ loading: true, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null }) setActiveTab('voice') const entry = await getRecording(id) if (!entry) { setSyncingId(null); return } await updateRecordingStatus(id, 'processing') await loadQueue() const file = new File([entry.audioBlob], `field-${entry.id}.webm`, { type: entry.audioType }) const formData = new FormData() formData.append('audio', file) formData.append('visit_type', entry.visitType) appendMetadataToFormData(formData, entry.metadata) try { const res = await fetch(`${API_BASE}/api/process-audio-stream`, { method: 'POST', body: formData }) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' await new Promise((resolve, reject) => { function read() { reader.read().then(({ done, value }) => { if (done) { resolve(); return } buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (!line.startsWith('data: ')) continue const evt = JSON.parse(line.slice(6)) handleSSE(evt, 'voice', VOICE_STAGE_META) } read() }).catch(reject) } read() }) await removeRecording(id) await loadQueue() } catch (err) { await updateRecordingStatus(id, 'pending') await loadQueue() setVoiceState((s) => ({ ...s, loading: false, error: `Sync failed: ${err.message}` })) } setSyncingId(null) } async function syncAll() { const pending = offlineQueue.filter((e) => e.status === 'pending') for (const entry of pending) { await syncRecording(entry.id) } } async function removeFromQueue(id) { await removeRecording(id) await loadQueue() } async function clearAllQueue() { await clearQueue() await loadQueue() } async function playRecording(id) { if (playingId === id) { if (playAudioRef.current) { playAudioRef.current.pause(); playAudioRef.current = null } setPlayingId(null) return } if (playAudioRef.current) { playAudioRef.current.pause(); playAudioRef.current = null } const entry = await getRecording(id) if (!entry) return const url = URL.createObjectURL(entry.audioBlob) const audio = new Audio(url) audio.onended = () => { URL.revokeObjectURL(url); setPlayingId(null); playAudioRef.current = null } playAudioRef.current = audio setPlayingId(id) audio.play() } const dangerSigns = useMemo( () => textState.danger?.danger_signs || voiceState.danger?.danger_signs || fieldOnDeviceState.danger?.danger_signs || [], [textState.danger, voiceState.danger, fieldOnDeviceState.danger] ) async function startRecording() { // Release any existing mic streams first if (fieldStreamRef.current) { fieldStreamRef.current.getTracks().forEach((t) => t.stop()) fieldStreamRef.current = null } if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()) streamRef.current = null } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) streamRef.current = stream const recorder = new MediaRecorder(stream) chunksRef.current = [] recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data) } recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: 'audio/webm' }) const file = new File([blob], `recording-${Date.now()}.webm`, { type: 'audio/webm' }) if (audioUrl) URL.revokeObjectURL(audioUrl) setAudioFile(file) setAudioUrl(URL.createObjectURL(blob)) stream.getTracks().forEach((t) => t.stop()) streamRef.current = null } mediaRecorderRef.current = recorder recorder.start(5000) setIsRecording(true) } catch { setVoiceState((s) => ({ ...s, error: 'Microphone permission denied or unavailable.' })) } } function stopRecording() { if (!mediaRecorderRef.current) return mediaRecorderRef.current.stop() setIsRecording(false) } function onUploadAudio(event) { const file = event.target.files?.[0] if (!file) return if (audioUrl) URL.revokeObjectURL(audioUrl) setAudioFile(file) setAudioUrl(URL.createObjectURL(file)) setVoiceState((s) => ({ ...s, error: '' })) } function handleSSE(evt, source, stageMeta) { if (evt.error) { const setter = source === 'voice' ? setVoiceState : setTextState setter((s) => ({ ...s, loading: false, error: evt.error })) return } if (evt.stage === 'complete') { const setter = source === 'voice' ? setVoiceState : setTextState setter({ loading: false, error: '', transcript: evt.transcript || '', visitType: evt.visit_type || '', form: evt.form || {}, danger: evt.danger || {}, timing: evt.timing || {}, _raw: { tool_calls: evt.tool_calls || [], form: evt.form || {}, danger: evt.danger || {}, metadata: evt.metadata || null, }, }) setPipelineStages((prev) => prev.map((s) => ({ ...s, status: 'done' }))) saveToHistory(source, evt.visit_type, evt.form, evt.danger, evt.transcript || null, evt.timing) return } if (evt.status === 'running') { const label = stageMeta[evt.stage] || evt.stage setPipelineStages((prev) => { const exists = prev.find((s) => s.key === evt.stage) if (exists) return prev.map((s) => s.key === evt.stage ? { ...s, status: 'running' } : s) return [...prev, { key: evt.stage, label, status: 'running', time: null }] }) } if (evt.status === 'done') { setPipelineStages((prev) => prev.map((s) => s.key === evt.stage ? { ...s, status: 'done', time: evt.time ?? null } : s) ) if (evt.transcript) { setVoiceState((s) => ({ ...s, transcript: evt.transcript })) } } } function processVoice() { if (!audioFile) { setVoiceState((s) => ({ ...s, error: 'Upload or record audio first.' })) return } setVoiceState({ loading: true, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null, _raw: null }) setPipelineStages([]) const formData = new FormData() formData.append('audio', audioFile) formData.append('visit_type', recordingVisitType) appendMetadataToFormData(formData, metadata) fetch(`${API_BASE}/api/process-audio-stream`, { method: 'POST', body: formData }) .then((res) => { const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' function read() { reader.read().then(({ done, value }) => { if (done) return buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (!line.startsWith('data: ')) continue const evt = JSON.parse(line.slice(6)) handleSSE(evt, 'voice', VOICE_STAGE_META) } read() }) } read() }) .catch((err) => { setVoiceState((s) => ({ ...s, loading: false, error: err.message })) }) } function processText() { if (!textInput.trim()) { setTextState((s) => ({ ...s, error: 'Transcript is empty.' })) return } setTextState({ loading: true, error: '', visitType: '', form: null, danger: null, timing: null, _raw: null }) setPipelineStages([]) fetch(`${API_BASE}/api/process-text-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: textInput, visit_type: textVisitType }), }) .then((res) => { const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' function read() { reader.read().then(({ done, value }) => { if (done) return buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (!line.startsWith('data: ')) continue const evt = JSON.parse(line.slice(6)) handleSSE(evt, 'text', TEXT_STAGE_META) } read() }) } read() }) .catch((err) => { setTextState((s) => ({ ...s, loading: false, error: err.message })) }) } function onSelectExample(label) { setSelectedExample(label) const ex = examples.find((e) => e.label === label) if (ex) setTextInput(ex.transcript || '') } function downloadJSON() { const data = activeState.form if (!data) return const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `sakhi-${activeState.visitType || 'form'}-${Date.now()}.json` a.click() URL.revokeObjectURL(url) } function downloadCSV() { const rows = keyValueRows(activeState.form) if (!rows.length) return const csv = 'Field,Value\n' + rows.map((r) => `"${r.key}","${String(r.value).replace(/"/g, '""')}"`).join('\n') const blob = new Blob([csv], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `sakhi-${activeState.visitType || 'form'}-${Date.now()}.csv` a.click() URL.revokeObjectURL(url) } const saveToHistory = useCallback((source, visitType, form, danger, transcript, timing) => { const entry = { id: Date.now(), date: new Date().toLocaleString('en-IN'), source, visitType, form, danger, transcript: transcript || null, timing, } setHistory((prev) => { const updated = [entry, ...prev].slice(0, 50) localStorage.setItem('sakhi_history', JSON.stringify(updated)) return updated }) }, []) const activeState = activeTab === 'voice' ? voiceState : activeTab === 'field' ? fieldOnDeviceState : textState return (

Sakhi (सखी)

AI companion for India's ASHA health workers

Gemma 4 E4B Offline-First Hindi Voice
{health} {' · '}
{serverUrlEditing && (

On the phone APK, set this to http://<PC-LAN-IP>:8000 (e.g. http://192.168.1.9:8000). Saved in this device's localStorage; survives reinstalls only if app data isn't cleared.

)}
{history.length > 0 && ( )}
{activeTab === 'voice' && (

Record or upload Hindi ASHA conversation

{audioExamples.length > 0 && (
{selectedAudioExample && (() => { const ex = audioExamples.find((e) => e.id === selectedAudioExample) return ex ?

{ex.description}

: null })()}
)}

Transcript

{voiceState.transcript || 'Transcript will appear here after processing audio.'}
{voiceState.transcript && (
{translation.error && ( {translation.error} )}
)} {translation.english && ( <>

English translation

{translation.english}
)}
)} {activeTab === 'text' && (

Paste transcript and extract structured form