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
This commit is contained in:
Nexus Dev 2026-04-04 02:36:01 +00:00
parent 9914699e3e
commit 45339bdac1
2 changed files with 173 additions and 0 deletions

View file

@ -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 (
<div className="flex flex-col gap-1">
<Badge variant="outline" className="text-xs mb-2 w-fit">
Voice
</Badge>
<p className="text-sm">{spokenText}</p>
{messageType === "voice_full" && (
<>
<ChatVoicePlayer text={spokenText} autoPlay={autoPlayVoice} />
{detailedMatch && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-xs text-muted-foreground hover:text-foreground mt-1">
{open ? "Hide full response" : "Show full response"}
</CollapsibleTrigger>
<CollapsibleContent>
<ChatMarkdownMessage content={detailedMatch[1].trim()} />
</CollapsibleContent>
</Collapsible>
)}
</>
)}
</div>
);
}

View file

@ -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<PlayerStatus>("loading");
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(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 (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Loading audio...
</span>
);
}
return (
<span className="inline-flex items-center">
{status === "playing" ? (
<Button variant="ghost" size="sm" onClick={handlePause} aria-label="Pause voice response">
<Pause className="h-3 w-3" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={handlePlay}
disabled={!audioUrl}
aria-label="Play voice response"
>
<Play className="h-3 w-3" />
</Button>
)}
<audio
ref={audioRef}
src={audioUrl ?? undefined}
aria-label="Voice response"
onPlay={() => setStatus("playing")}
onPause={() => setStatus("paused")}
onEnded={handleAudioEnded}
/>
</span>
);
}