mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: allow recordings in tool transitions
This commit is contained in:
parent
65c76ca7ff
commit
74dbafb055
38 changed files with 1555 additions and 692 deletions
|
|
@ -1,7 +1,14 @@
|
|||
import { Check, ChevronDown, Pause, Play, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContentInline, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextOrAudioInputProps {
|
||||
type: 'text' | 'audio';
|
||||
|
|
@ -62,36 +69,144 @@ interface RecordingSelectProps {
|
|||
* their own none/custom/audio radio) can use it directly.
|
||||
*/
|
||||
export function RecordingSelect({ value, onChange, recordings }: RecordingSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const { playingId, toggle, stop } = useAudioPlayback();
|
||||
|
||||
const selected = recordings.find((r) => String(r.id) === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return recordings;
|
||||
const q = search.toLowerCase();
|
||||
return recordings.filter((r) =>
|
||||
r.recording_id.toLowerCase().includes(q) ||
|
||||
r.transcript.toLowerCase().includes(q) ||
|
||||
((r.metadata?.original_filename as string) || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [recordings, search]);
|
||||
|
||||
const handleSelect = (rec: RecordingResponseSchema) => {
|
||||
stop();
|
||||
onChange(String(rec.id));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handlePlay = async (e: React.MouseEvent, rec: RecordingResponseSchema) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await toggle(rec.recording_id, rec.storage_key, rec.storage_backend);
|
||||
} catch {
|
||||
// Ignore playback errors
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Select a pre-recorded audio file to play.
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a recording" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordings.length === 0 ? (
|
||||
<SelectItem value="__empty__" disabled>
|
||||
No recordings available
|
||||
</SelectItem>
|
||||
) : (
|
||||
recordings.map((r) => (
|
||||
<SelectItem key={r.recording_id} value={r.recording_id}>
|
||||
<span className="truncate">
|
||||
{(r.metadata?.original_filename as string) || r.recording_id}
|
||||
<Popover modal open={open} onOpenChange={(v) => { if (!v) { stop(); setSearch(""); } setOpen(v); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between h-auto min-h-9 font-normal"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-2 text-left">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
|
||||
{selected.recording_id}
|
||||
</code>
|
||||
<span className="text-sm">
|
||||
{selected.transcript.length > 75
|
||||
? `${selected.transcript.slice(0, 75)}…`
|
||||
: selected.transcript}
|
||||
</span>
|
||||
{r.transcript && (
|
||||
<span className="text-xs text-muted-foreground ml-2 truncate">
|
||||
— {r.transcript}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select a recording</span>
|
||||
)}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContentInline
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
{recordings.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
No recordings available
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by ID, transcript, or filename..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
No recordings match “{search}”
|
||||
</div>
|
||||
) : filtered.map((r) => {
|
||||
const filename = (r.metadata?.original_filename as string) || "";
|
||||
const isSelected = String(r.id) === value;
|
||||
const isPlaying = playingId === r.recording_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelect(r)}
|
||||
>
|
||||
<Check className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)} />
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
|
||||
{r.recording_id}
|
||||
</code>
|
||||
{filename && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 max-w-[100px] truncate">
|
||||
{filename}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded truncate flex-1 min-w-0">
|
||||
{r.transcript}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={(e) => handlePlay(e, r)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PopoverContentInline>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Condition</DialogTitle>
|
||||
{data?.invalid && data.validationMessage && (
|
||||
|
|
@ -82,7 +82,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-4 py-4 overflow-y-auto">
|
||||
<div className="grid gap-2">
|
||||
<Label>Condition Label</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue