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
a50daa2129
commit
6b60f42a25
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