import { agentNativePath, appPath } from "@agent-native/core/client/api-path"; import { useT } from "@agent-native/core/client/i18n"; import { openBuilderConnectPopup } from "@agent-native/core/client/settings"; import { BUILDER_CREDITS_UPGRADE_URL, isBuilderCreditsExhaustedMessage, } from "@shared/builder-credits"; import { IconSearch, IconCopy, IconDownload, IconCheck, IconExternalLink, IconLoader2, IconBolt, IconRefresh, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { msToClock } from "./scrubber"; export interface TranscriptSegment { startMs: number; endMs: number; text: string; } export interface TranscriptPanelProps { segments: TranscriptSegment[]; fullText?: string | null; durationMs?: number | null; currentMs: number; onSeek: (ms: number) => void; status?: "pending" | "ready" | "failed"; failureReason?: string | null; cleanup?: { status?: string | null; provider?: string | null; failureReason?: string | null; updatedAt?: string | null; } | null; recordingTitle?: string; /** Called when the user asks us to retry transcription after fixing an error. */ onRetry?: () => void; /** Called when the user asks for a fresh transcript from the recording media. */ onRegenerate?: () => void; isRegenerating?: boolean; } export function TranscriptPanel(props: TranscriptPanelProps) { const t = useT(); const { segments, fullText, durationMs, currentMs, onSeek, status, failureReason, cleanup, recordingTitle, onRetry, onRegenerate, isRegenerating = false, } = props; const [query, setQuery] = useState(""); const [copied, setCopied] = useState(false); const displaySegments = useMemo(() => { if (segments.length > 0) return segments; const text = fullText?.trim(); if (!text) return []; return [ { startMs: 0, endMs: Math.max(1000, Math.round(durationMs ?? 0)), text, }, ]; }, [segments, fullText, durationMs]); const filtered = useMemo(() => { if (!query.trim()) return displaySegments; const q = query.toLowerCase(); return displaySegments.filter((s) => s.text.toLowerCase().includes(q)); }, [displaySegments, query]); const activeIndex = useMemo( () => displaySegments.findIndex( (s) => currentMs >= s.startMs && currentMs <= s.endMs, ), [displaySegments, currentMs], ); function copyAll() { const text = displaySegments.map((s) => s.text).join(" "); navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } function downloadSrt() { const srt = toSrt(displaySegments); const blob = new Blob([srt], { type: "text/srt;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${sanitizeFilename(recordingTitle ?? "transcript")}.srt`; a.click(); URL.revokeObjectURL(url); } // Surface the setup card when transcription failed due to a provider // configuration issue — missing key, quota error, rejected key, etc. // Builder connection is the recommended fix in all these cases. const noSpeechFailure = isNoSpeechTranscriptFailure(failureReason); const builderCreditsPaused = isBuilderCreditsExhaustedMessage(failureReason); const needsSetup = !noSpeechFailure && !builderCreditsPaused && isTranscriptionSetupNeeded(failureReason); if (status === "failed" && builderCreditsPaused) { return (
); } if (status === "failed" && needsSetup) { return ( ); } if (status === "pending") { return (

{t("transcriptPanel.transcribing")}

{t("transcriptPanel.pendingDescription")}

); } if (status === "failed" && noSpeechFailure) { return (

{t("transcriptPanel.noSpeechDetected")}

{t("transcriptPanel.noSpeechDescription")}

{onRetry ? ( ) : null}
); } if (status === "failed") { return (
{t("transcriptPanel.transcriptUnavailable", { reason: friendlyTranscriptFailure(failureReason, t), })}
{onRetry ? ( ) : null}
); } return (
setQuery(e.target.value)} placeholder={t("transcriptPanel.searchPlaceholder")} className="pl-8 h-8 text-xs" />
{t("transcriptPanel.copyTranscript")} {t("transcriptPanel.downloadSrt")} {onRegenerate ? ( {t("transcriptPanel.regenerate")} ) : null}
{cleanup?.status === "running" ? (
{t("transcriptPanel.cleanupRunning")}
) : null} {cleanup?.status === "failed" && isBuilderCreditsExhaustedMessage(cleanup.failureReason) ? ( ) : cleanup?.status === "failed" ? (
{friendlyCleanupFailure(cleanup.failureReason, t)}
) : null}
{filtered.length === 0 ? (
{query ? t("transcriptPanel.noMatches") : t("transcriptPanel.noTranscript")}
) : (
    {filtered.map((seg) => { const isActive = displaySegments[activeIndex] === seg; return (
  • { if (hasSelectionWithin(event.currentTarget)) return; onSeek(seg.startMs); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onSeek(seg.startMs); }} className={cn( "flex w-full cursor-pointer items-start gap-3 px-3 py-2 text-left hover:bg-accent/50", isActive && "bg-accent", )} > {msToClock(seg.startMs)}
  • ); })}
)}
); } function hasSelectionWithin(element: HTMLElement): boolean { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !selection.toString().trim()) { return false; } const { anchorNode, focusNode } = selection; return Boolean( (anchorNode && element.contains(anchorNode)) || (focusNode && element.contains(focusNode)), ); } function highlight(text: string, q: string): string { const escaped = text.replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">", ); if (!q.trim()) return escaped; const safe = q.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); return escaped.replace( new RegExp(safe, "gi"), (match) => `${match}`, ); } function msToSrtTime(ms: number): string { const h = String(Math.floor(ms / 3600000)).padStart(2, "0"); const m = String(Math.floor((ms % 3600000) / 60000)).padStart(2, "0"); const s = String(Math.floor((ms % 60000) / 1000)).padStart(2, "0"); const millis = String(ms % 1000).padStart(3, "0"); return `${h}:${m}:${s},${millis}`; } function toSrt(segments: TranscriptSegment[]): string { return segments .map((seg, i) => { return `${i + 1}\n${msToSrtTime(seg.startMs)} --> ${msToSrtTime( seg.endMs, )}\n${seg.text}\n`; }) .join("\n"); } function sanitizeFilename(s: string): string { return s.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase(); } const BUILDER_CREDITS_FEATURE_LABELS = [ "builderCredits.featureBackupTranscription", "builderCredits.featureCleanup", "builderCredits.featureSummaries", "builderCredits.featureTitles", ] as const; function BuilderCreditsPausedNotice({ mode, onRetry, className, }: { mode: "transcription" | "cleanup"; onRetry?: () => void; className?: string; }) { const t = useT(); return (

{t("builderCredits.pausedTitle")}

{mode === "cleanup" ? t("builderCredits.cleanupDescription") : t("builderCredits.transcriptionDescription")}

{BUILDER_CREDITS_FEATURE_LABELS.map((key) => ( {t(key)} ))}
{onRetry ? ( ) : null}
); } /** * Returns true when the transcription failure is due to a provider * configuration problem — missing key, quota exceeded, key rejected, * no provider at all. Builder connection fixes all of these. */ function isTranscriptionSetupNeeded( reason: string | null | undefined, ): boolean { if (!reason) return false; const r = reason.toLowerCase(); return ( r.includes("openai_api_key") || r.includes("api key") || r.includes("not configured") || r.includes("no transcription") || r.includes("no backup transcription provider") || r.includes("no fallback provider") || r.includes("quota") || r.includes("credits exhausted") || r.includes("rate limit") || r.includes("rejected the api key") || r.includes("connect builder") ); } function isConnectedBuilderRetryable( reason: string | null | undefined, ): boolean { if (!reason) return false; const r = reason.toLowerCase(); return ( r.includes("no transcription provider configured") || r.includes("connect builder") || r.includes("no transcription provider") ); } function isNoSpeechTranscriptFailure( reason: string | null | undefined, ): boolean { if (!reason) return false; const normalized = reason.toLowerCase(); return ( normalized.includes("no speech") || normalized.includes("no native transcript was captured") || normalized.includes("no transcript was captured by native speech") ); } function friendlyTranscriptFailure( reason: string | null | undefined, t: ReturnType, ): string { if (!reason) return t("transcriptPanel.noTranscriptCaptured"); const normalized = reason.toLowerCase(); if ( normalized.includes("builder transcription failed") || normalized.includes("connecttimeout") || normalized.includes("fetch failed") || normalized.includes("backup transcription could not finish") ) { return t("transcriptPanel.backupFailed"); } if ( normalized.includes("api key") || normalized.includes("not configured") || normalized.includes("connect builder") ) { return t("transcriptPanel.backupNotSetup"); } return reason; } function friendlyCleanupFailure( reason: string | null | undefined, t: ReturnType, ): string { if (!reason) return t("transcriptPanel.cleanupKept"); const normalized = reason.toLowerCase(); if ( normalized.includes("is connected, but") || normalized.includes("returned no text") || normalized.includes("service failed") ) { return t("transcriptPanel.cleanupBuilderFailed"); } if ( normalized.includes("incomplete") || isBuilderCreditsExhaustedMessage(reason) || normalized.includes("connect builder") || normalized.includes("not configured") || normalized.includes("api key") ) { return t("transcriptPanel.cleanupPaused"); } return t("transcriptPanel.cleanupKept"); } /** * Inline card shown when transcription needs a provider set up. * * Builder.io is the only cloud fallback — free, one-click, no separate API * key required (uses BUILDER_PRIVATE_KEY once the user connects). Clips does * not route recording transcription to BYOK speech providers. */ function TranscriptSetupCard({ failureReason, onRetry, }: { failureReason?: string | null; onRetry?: () => void; }) { const t = useT(); const [builderConfigured, setBuilderConfigured] = useState( null, ); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const pollRef = useRef | null>(null); const mountedRef = useRef(true); const autoRetryRef = useRef(false); const onRetryRef = useRef(onRetry); useEffect(() => { onRetryRef.current = onRetry; }, [onRetry]); useEffect(() => { mountedRef.current = true; fetch(agentNativePath("/_agent-native/builder/status")) .then((r) => r.ok ? (r.json() as Promise<{ configured: boolean }>) : null, ) .then((s) => { if (mountedRef.current) setBuilderConfigured(s?.configured ?? false); }) .catch(() => { if (mountedRef.current) setBuilderConfigured(false); }); return () => { mountedRef.current = false; if (pollRef.current) clearInterval(pollRef.current); }; }, []); useEffect(() => { if (!builderConfigured || autoRetryRef.current) return; if (!isConnectedBuilderRetryable(failureReason)) return; autoRetryRef.current = true; const handle = window.setTimeout(() => onRetryRef.current?.(), 250); return () => window.clearTimeout(handle); }, [builderConfigured, failureReason]); const handleConnect = useCallback(() => { if (pollRef.current) clearInterval(pollRef.current); setConnecting(true); setConnectError(null); openBuilderConnectPopup({ source: "clips_transcript_panel" }); const start = Date.now(); pollRef.current = setInterval(async () => { try { const r = await fetch(agentNativePath("/_agent-native/builder/status")); if (!r.ok) return; const s = (await r.json()) as { configured: boolean }; if (!mountedRef.current) { clearInterval(pollRef.current!); return; } if (s.configured) { clearInterval(pollRef.current!); setBuilderConfigured(true); setConnecting(false); onRetry?.(); } else if (Date.now() - start > 5 * 60 * 1000) { clearInterval(pollRef.current!); setConnecting(false); setConnectError(t("transcriptPanel.builderNoResponse")); } } catch { // transient poll error — keep trying } }, 2000); }, [onRetry]); const isProviderError = !isBuilderCreditsExhaustedMessage(failureReason) && (failureReason?.toLowerCase().includes("quota") || failureReason?.toLowerCase().includes("credits exhausted") || failureReason?.toLowerCase().includes("rate limit") || failureReason?.toLowerCase().includes("rejected the api key")); const isConnectedFallbackError = builderConfigured === true && !isProviderError; return (

{isProviderError ? t("transcriptPanel.providerNeedsAttention") : isConnectedFallbackError ? t("transcriptPanel.transcriptUnavailableTitle") : t("transcriptPanel.enableTranscriptionTitle")}

{isProviderError ? t("transcriptPanel.providerNeedsAttentionDescription") : isConnectedFallbackError ? "No speech was captured locally, and backup transcription did not finish. Retry in a moment." : t("transcriptPanel.enableTranscriptionDescription")}

{/* Builder — primary recommended option */}

{builderConfigured ? "Builder.io connected" : "Use Builder.io (free)"}

{!builderConfigured && ( Free )}

{builderConfigured ? "Ready to use for backup transcription." : "One-click setup. No API key required."}

{builderConfigured ? (
Connected {onRetry ? ( ) : null}
) : ( )}
{connectError && (

{connectError}

)}
); }