import React from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; import { BaseModalDescription, BaseModalTitle, } from "#/components/shared/modals/confirmation-modals/base-modal"; import { BrandButton } from "#/components/features/settings/brand-button"; import { downloadBlob } from "#/utils/utils"; import { eventsToHtml, eventsToMarkdown, type TranscriptExportFormat, } from "#/utils/transcript-export"; import { useTracking } from "#/hooks/use-tracking"; import { useEventStore } from "#/stores/use-event-store"; import EventService from "#/api/event-service/event-service.api"; import { loadCompleteTranscriptEvents } from "#/utils/transcript-export/load-complete-events"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; const TRANSCRIPT_FORMAT_RADIO_NAME = "transcript-export-format"; interface TranscriptExportModalProps { conversationId: string; conversationUrl?: string | null; sessionApiKey?: string | null; conversationTitle?: string | null; model?: string | null; onClose: () => void; } export function TranscriptExportModal({ conversationId, conversationUrl, sessionApiKey, conversationTitle, model, onClose, }: TranscriptExportModalProps) { const { t } = useTranslation("openhands"); const { trackConversationExported } = useTracking(); const [format, setFormat] = React.useState("markdown"); const [includeToolDetails, setIncludeToolDetails] = React.useState(true); const [includeTimestamps, setIncludeTimestamps] = React.useState(true); const [isExporting, setIsExporting] = React.useState(false); const isExportingRef = React.useRef(false); const isCancelledRef = React.useRef(false); const isMountedRef = React.useRef(true); React.useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; isCancelledRef.current = true; }; }, []); const handleClose = () => { if (isExportingRef.current) isCancelledRef.current = true; onClose(); }; const handleExport = async () => { if (isExportingRef.current) return; isCancelledRef.current = false; isExportingRef.current = true; setIsExporting(true); try { // The count lets us reject silently truncated pagination. Archived cloud // runtimes may no longer expose this endpoint, while their persisted // App API history remains exportable, so treat the check as best-effort. const expectedEventCount = await EventService.getEventCount( conversationId, conversationUrl ?? "", sessionApiKey, ).catch(() => undefined); if (isCancelledRef.current) return; const eventStore = useEventStore.getState(); const loadedEvents = eventStore.loadedConversationId === conversationId ? eventStore.events : []; const events = await loadCompleteTranscriptEvents( loadedEvents, (searchOptions) => EventService.searchEvents( conversationId, conversationUrl, sessionApiKey, searchOptions, ), expectedEventCount, ); if (isCancelledRef.current) return; const options = { includeToolDetails, includeTimestamps, title: conversationTitle, model, }; const isMarkdown = format === "markdown"; const content = isMarkdown ? eventsToMarkdown(events, options) : eventsToHtml(events, options); const extension = isMarkdown ? "md" : "html"; const mimeType = isMarkdown ? "text/markdown;charset=utf-8" : "text/html;charset=utf-8"; downloadBlob( new Blob([content], { type: mimeType }), `conversation-${conversationId}.${extension}`, ); trackConversationExported(format); onClose(); } catch { if (!isCancelledRef.current) { displayErrorToast(t(I18nKey.ERROR$GENERIC)); } } finally { isExportingRef.current = false; if (isMountedRef.current) setIsExporting(false); } }; return (
{t(I18nKey.TRANSCRIPT_EXPORT$FORMAT)}
{t(I18nKey.BUTTON$CANCEL)} {t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
); }