From 6b60f42a251ded2ec7591606df7d16792c210f99 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 4 Apr 2026 02:36:01 +0000 Subject: [PATCH] feat(37-03): ChatVoicePlayer + ChatVoiceBadge components - ChatVoicePlayer: POST /api/synthesize, play/pause controls, autoPlay support, blob URL cleanup - ChatVoiceBadge: Voice badge, SPOKEN/DETAILED parsing, collapsible full markdown for voice_full --- ui/src/components/ChatVoiceBadge.tsx | 51 +++++++++++ ui/src/components/ChatVoicePlayer.tsx | 122 ++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 ui/src/components/ChatVoiceBadge.tsx create mode 100644 ui/src/components/ChatVoicePlayer.tsx diff --git a/ui/src/components/ChatVoiceBadge.tsx b/ui/src/components/ChatVoiceBadge.tsx new file mode 100644 index 00000000..2bd5e2cf --- /dev/null +++ b/ui/src/components/ChatVoiceBadge.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChatVoicePlayer } from "./ChatVoicePlayer"; +import { ChatMarkdownMessage } from "./ChatMarkdownMessage"; + +interface ChatVoiceBadgeProps { + content: string; + messageType: string; // "voice_input" | "voice_full" + autoPlayVoice?: boolean; +} + +export function ChatVoiceBadge({ + content, + messageType, + autoPlayVoice = false, +}: ChatVoiceBadgeProps) { + const [open, setOpen] = useState(false); + + const spokenMatch = content.match(/SPOKEN:\s*([\s\S]*?)(?=\nDETAILED:|$)/); + const spokenText = spokenMatch?.[1]?.trim() ?? content; + const detailedMatch = content.match(/DETAILED:\s*([\s\S]*)/); + + return ( +
+ + Voice + +

{spokenText}

+ {messageType === "voice_full" && ( + <> + + {detailedMatch && ( + + + {open ? "Hide full response" : "Show full response"} + + + + + + )} + + )} +
+ ); +} diff --git a/ui/src/components/ChatVoicePlayer.tsx b/ui/src/components/ChatVoicePlayer.tsx new file mode 100644 index 00000000..cab24677 --- /dev/null +++ b/ui/src/components/ChatVoicePlayer.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2, Pause, Play } from "lucide-react"; + +interface ChatVoicePlayerProps { + text: string; + autoPlay?: boolean; +} + +type PlayerStatus = "idle" | "loading" | "playing" | "paused"; + +export function ChatVoicePlayer({ text, autoPlay = false }: ChatVoicePlayerProps) { + const [status, setStatus] = useState("loading"); + const [audioUrl, setAudioUrl] = useState(null); + const audioRef = useRef(null); + + useEffect(() => { + let objectUrl: string | null = null; + let cancelled = false; + + async function fetchAudio() { + setStatus("loading"); + try { + const res = await fetch("/api/synthesize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ text }), + }); + if (cancelled) return; + if (!res.ok) { + setStatus("idle"); + return; + } + const blob = await res.blob(); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setAudioUrl(objectUrl); + setStatus("idle"); + } catch { + if (!cancelled) { + setStatus("idle"); + } + } + } + + fetchAudio(); + + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [text]); + + useEffect(() => { + if (autoPlay && audioUrl && audioRef.current) { + audioRef.current.play().catch(() => { + // Browser may block autoplay; fall back to idle state + setStatus("idle"); + }); + } + }, [autoPlay, audioUrl]); + + function handlePlay() { + if (audioRef.current) { + audioRef.current.play(); + } + } + + function handlePause() { + if (audioRef.current) { + audioRef.current.pause(); + } + } + + function handleAudioEnded() { + setStatus("idle"); + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + setAudioUrl(null); + } + } + + if (status === "loading") { + return ( + + + Loading audio... + + ); + } + + return ( + + {status === "playing" ? ( + + ) : ( + + )} + + ); +}