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:
parent
9914699e3e
commit
45339bdac1
2 changed files with 173 additions and 0 deletions
51
ui/src/components/ChatVoiceBadge.tsx
Normal file
51
ui/src/components/ChatVoiceBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
ui/src/components/ChatVoicePlayer.tsx
Normal file
122
ui/src/components/ChatVoicePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue