mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
* feat: add transcript format parameter to GET endpoint
Add transcript_format query parameter to /v1/transcripts/{id} endpoint
with support for multiple output formats using discriminated unions.
Formats supported:
- text: Plain speaker dialogue (default)
- text-timestamped: Dialogue with [MM:SS] timestamps
- webvtt-named: WebVTT subtitles with participant names
- json: Structured segments with full metadata
Response models use Pydantic discriminated unions with transcript_format
as discriminator field. POST/PATCH endpoints return GetTranscriptWithParticipants
for minimal responses. GET endpoint returns format-specific models.
* Copy transcript format
* Regenerate types
* Fix transcript formats
* Don't throw inside try
* Remove any type
* Toast share copy errors
* transcript_format exhaustiveness and python idiomatic assert_never
* format_timestamp_mmss clear type definition
* Rename seconds_to_timestamp
* Test transcript format with overlapping speakers
* exact match for vtt multispeaker test
---------
Co-authored-by: Sergey Mankovsky <sergey@monadical.com>
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
152 lines
4.5 KiB
TypeScript
152 lines
4.5 KiB
TypeScript
import { useState } from "react";
|
|
import type { components, operations } from "../../reflector-api";
|
|
type GetTranscriptWithParticipants =
|
|
components["schemas"]["GetTranscriptWithParticipants"];
|
|
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
|
import { Button, BoxProps, Box, Menu, Text } from "@chakra-ui/react";
|
|
import { LuChevronDown } from "react-icons/lu";
|
|
import { client } from "../../lib/apiClient";
|
|
import { toaster } from "../../components/ui/toaster";
|
|
|
|
type ShareCopyProps = {
|
|
finalSummaryElement: HTMLDivElement | null;
|
|
transcript: GetTranscriptWithParticipants;
|
|
topics: GetTranscriptTopic[];
|
|
};
|
|
|
|
export default function ShareCopy({
|
|
finalSummaryElement,
|
|
transcript,
|
|
topics,
|
|
...boxProps
|
|
}: ShareCopyProps & BoxProps) {
|
|
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
|
|
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
|
|
const [isCopying, setIsCopying] = useState(false);
|
|
|
|
type ApiTranscriptFormat = NonNullable<
|
|
operations["v1_transcript_get"]["parameters"]["query"]
|
|
>["transcript_format"];
|
|
const TRANSCRIPT_FORMATS = [
|
|
"text",
|
|
"text-timestamped",
|
|
"webvtt-named",
|
|
"json",
|
|
] as const satisfies ApiTranscriptFormat[];
|
|
type TranscriptFormat = (typeof TRANSCRIPT_FORMATS)[number];
|
|
|
|
const TRANSCRIPT_FORMAT_LABELS: { [k in TranscriptFormat]: string } = {
|
|
text: "Plain text",
|
|
"text-timestamped": "Text + timestamps",
|
|
"webvtt-named": "WebVTT (named)",
|
|
json: "JSON",
|
|
};
|
|
|
|
const formatOptions = TRANSCRIPT_FORMATS.map((f) => ({
|
|
value: f,
|
|
label: TRANSCRIPT_FORMAT_LABELS[f],
|
|
}));
|
|
|
|
const onCopySummaryClick = () => {
|
|
const text_to_copy = finalSummaryElement?.innerText;
|
|
if (text_to_copy) {
|
|
navigator.clipboard.writeText(text_to_copy).then(() => {
|
|
setIsCopiedSummary(true);
|
|
// Reset the copied state after 2 seconds
|
|
setTimeout(() => setIsCopiedSummary(false), 2000);
|
|
});
|
|
}
|
|
};
|
|
|
|
const onCopyTranscriptFormatClick = async (format: TranscriptFormat) => {
|
|
try {
|
|
setIsCopying(true);
|
|
const { data, error } = await client.GET(
|
|
"/v1/transcripts/{transcript_id}",
|
|
{
|
|
params: {
|
|
path: { transcript_id: transcript.id },
|
|
query: { transcript_format: format },
|
|
},
|
|
},
|
|
);
|
|
if (error) {
|
|
console.error("Failed to copy transcript:", error);
|
|
toaster.create({
|
|
duration: 3000,
|
|
render: () => (
|
|
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
|
|
<Text fontWeight="bold">Error</Text>
|
|
<Text fontSize="sm">Failed to fetch transcript</Text>
|
|
</Box>
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const copiedText =
|
|
format === "json"
|
|
? JSON.stringify(data?.transcript ?? {}, null, 2)
|
|
: String(data?.transcript ?? "");
|
|
|
|
if (copiedText) {
|
|
await navigator.clipboard.writeText(copiedText);
|
|
setIsCopiedTranscript(true);
|
|
setTimeout(() => setIsCopiedTranscript(false), 2000);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to copy transcript:", e);
|
|
toaster.create({
|
|
duration: 3000,
|
|
render: () => (
|
|
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
|
|
<Text fontWeight="bold">Error</Text>
|
|
<Text fontSize="sm">Failed to copy transcript</Text>
|
|
</Box>
|
|
),
|
|
});
|
|
} finally {
|
|
setIsCopying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box {...boxProps}>
|
|
<Menu.Root
|
|
closeOnSelect={true}
|
|
lazyMount={true}
|
|
positioning={{ gutter: 4 }}
|
|
>
|
|
<Menu.Trigger asChild>
|
|
<Button
|
|
mr={2}
|
|
variant="subtle"
|
|
loading={isCopying}
|
|
loadingText="Copying..."
|
|
>
|
|
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
|
|
<LuChevronDown style={{ marginLeft: 6 }} />
|
|
</Button>
|
|
</Menu.Trigger>
|
|
<Menu.Positioner>
|
|
<Menu.Content>
|
|
{formatOptions.map((opt) => (
|
|
<Menu.Item
|
|
key={opt.value}
|
|
value={opt.value}
|
|
_hover={{ backgroundColor: "gray.100" }}
|
|
onClick={() => onCopyTranscriptFormatClick(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</Menu.Item>
|
|
))}
|
|
</Menu.Content>
|
|
</Menu.Positioner>
|
|
</Menu.Root>
|
|
<Button onClick={onCopySummaryClick} variant="subtle">
|
|
{isCopiedSummary ? "Copied!" : "Copy Summary"}
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|