Workflow-App/Workflow/components/AgentVoiceReply.tsx

133 lines
4.1 KiB
TypeScript
Raw Permalink Normal View History

// 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.
*
* <VoiceField onInputModeChange={setMode} ... />
* <AgentVoiceReply
* replyText={agentReply}
* inputMode={mode}
* speakBackEnabled={speakBackEnabled}
* replyVersion={replyVersion}
* />
*/
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<number>(-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<boolean>(() => {
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];
}