import { useEffect, useState, useCallback } from 'react'; import { RoomEvent, type Room } from 'livekit-client'; import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared'; import { DATA_TOPIC } from '@podman/shared'; import { createSyncPr, postOutcome } from '../lib/api'; const browserTtsFallbackEnabled = import.meta.env.VITE_ENABLE_BROWSER_TTS_FALLBACK === 'true'; /** Speak a cue in the browser only when the explicit fallback flag is enabled. */ export function speakInBrowser(text: string): void { if (!browserTtsFallbackEnabled) return; if (typeof window === 'undefined' || !('speechSynthesis' in window) || !text) return; const u = new SpeechSynthesisUtterance(text); u.rate = 1.05; window.speechSynthesis.cancel(); // drop any queued cue so the latest wins window.speechSynthesis.speak(u); } /** Unlock speechSynthesis from a user gesture when the browser fallback is enabled. */ export function primeSpeech(): void { if (!browserTtsFallbackEnabled) return; if (typeof window === 'undefined' || !('speechSynthesis' in window)) return; const u = new SpeechSynthesisUtterance(' '); u.volume = 0; window.speechSynthesis.speak(u); } export function useInterventions(room: Room | null) { const [active, setActive] = useState(null); const [hermes, setHermes] = useState(null); const [voiceCue, setVoiceCue] = useState(null); const [actionUrl, setActionUrl] = useState(null); useEffect(() => { if (!room) return; const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => { if (topic !== DATA_TOPIC) return; const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage; if (msg.type === 'COLLISION') { setActive(msg.intervention); setActionUrl(null); } if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message); if (msg.type === 'VOICE_CUE') { setVoiceCue(msg.text); speakInBrowser(msg.text); } }; room.on(RoomEvent.DataReceived, onData); return () => { room.off(RoomEvent.DataReceived, onData); }; }, [room]); const respond = useCallback( async (status: InterventionStatus, accepted: boolean) => { if (!active) return; if (accepted && active.suggestedAction.kind === 'open_sync_pr') { const pr = await createSyncPr({ file: String(active.suggestedAction.params?.file ?? ''), summary: String(active.suggestedAction.params?.summary ?? active.message), }); setActionUrl(pr.url); } await postOutcome({ interventionId: active.id, collisionId: active.collisionId, podId: active.podId, // Placeholder only — the backend derives the authoritative value from // git overlap at outcome time (the client cannot know). (RSI Step 3) wasRealCollision: false, accepted, recordedAt: new Date().toISOString(), }); await room?.localParticipant.publishData( new TextEncoder().encode( JSON.stringify({ type: 'ACK', interventionId: active.id, status }), ), { reliable: true, topic: DATA_TOPIC }, ); setActive(null); return status; }, [active, room], ); return { active, hermes, voiceCue, actionUrl, respond }; }