Files
podman/frontend/src/livekit/useInterventions.ts
T
2026-06-28 05:43:38 +00:00

87 lines
3.2 KiB
TypeScript

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<Intervention | null>(null);
const [hermes, setHermes] = useState<HermesMessage | null>(null);
const [voiceCue, setVoiceCue] = useState<string | null>(null);
const [actionUrl, setActionUrl] = useState<string | null>(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,
wasRealCollision: true,
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 };
}