// components/AgentVoiceReply.tsx // The "Ysterbaard speaks back to me" loop. // // Watches the upstream InputMode (driven by VoiceField's onInputModeChange // callback). When the user submits in voice mode and speak-back is enabled, // this component auto-narrates the reply through window.speechSynthesis. // When the user submits in text mode, it stays silent. // // The reply text is provided by the parent (the actual agent response). // This component is purely the orchestration layer — it doesn't generate // replies itself. Plug it in front of any text-producing assistant. "use client"; import { useCallback, useEffect, useRef } from "react"; import { useSpeech } from "@/lib/voice/useSpeech"; import type { InputMode } from "@/components/VoiceField"; interface Props { /** What the agent wants to say. Empty string = nothing to speak. */ replyText: string; /** Most recent input mode from the upstream VoiceField. */ inputMode: InputMode; /** BCP-47 lang. Defaults to en-ZA. */ lang?: string; /** * Whether speak-back is currently enabled by the user. Persisted in * localStorage by VoiceField, but the parent can override. */ speakBackEnabled?: boolean; /** * Bumped each time the parent wants the reply re-spoken (e.g. a new * reply arrived). Increments force the speak() effect to re-fire * even if the reply text is identical to the previous one. */ replyVersion?: number; } const SPEAK_BACK_STORAGE_KEY = "wf:speakBackEnabled"; /** * Side-effect component that auto-speaks the agent reply when: * • replyText is non-empty * • the most recent user input was voice * • speak-back is enabled * • a new replyVersion has been emitted * * Renders nothing visible. Drop it as a sibling of VoiceField. * * * */ export default function AgentVoiceReply({ replyText, inputMode, lang = "en-ZA", speakBackEnabled, replyVersion = 0 }: Props) { const speech = useSpeech({ lang }); // Read speak-back preference from localStorage if the parent didn't pass one. const [persistedEnabled, setPersistedEnabled] = useStatePersistedBoolean( SPEAK_BACK_STORAGE_KEY, true ); const effectiveEnabled = typeof speakBackEnabled === "boolean" ? speakBackEnabled : persistedEnabled; const lastSpokenVersionRef = useRef(-1); useEffect(() => { const trimmed = replyText?.trim(); if (!trimmed) return; if (!effectiveEnabled) return; if (inputMode !== "voice") return; if (replyVersion === lastSpokenVersionRef.current) return; lastSpokenVersionRef.current = replyVersion; // Tiny defer so the caller can finish painting the reply text first. const t = setTimeout(() => speech.speak(trimmed), 60); return () => clearTimeout(t); }, [replyText, replyVersion, inputMode, effectiveEnabled, speech]); // Cancel any in-flight speech on unmount. useEffect(() => { return () => { speech.cancel(); }; }, [speech]); // No UI — this is a pure side-effect component. return null; } /** Tiny localStorage-backed boolean state without a real hook import. */ function useStatePersistedBoolean( key: string, fallback: boolean ): [boolean, (next: boolean) => void] { // We import useState lazily so we don't pay for it when the parent overrides. // eslint-disable-next-line @typescript-eslint/no-var-requires const { useState, useEffect } = require("react") as typeof import("react"); const [value, setValue] = useState(() => { if (typeof window === "undefined") return fallback; try { const raw = window.localStorage.getItem(key); if (raw === null) return fallback; return raw === "1"; } catch { return fallback; } }); useEffect(() => { try { window.localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ } }, [key, value]); const setter = useCallback((next: boolean) => setValue(next), []); return [value, setter]; }