import { KeyRound, ListChecks, RefreshCw, Save, Trash2, Wand2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { useI18n } from "@/contexts/I18nContext"; import type { GuideAiProvider, GuideAiSettings, GuideLanguage, GuideOcrProfile, GuideSession, GuideSnapshot, } from "@/guide/contracts"; import { captureGuideSnapshots } from "@/guide/snapshot/extractGuideSnapshots"; interface GuidePanelProps { recordingId: number | string | null; videoPath: string | null; videoSourcePath: string | null; currentTimeMs: number; } type BusyAction = "load" | "generate"; interface GuideProgressState { label: string; current: number; total: number; detail?: string; } const COPY = { en: { title: "Guide", noRecording: "Record with Guide Mode to create a step guide.", noSession: "No guide session yet.", generateGuide: "Generate guide", generating: "Generating...", prepare: "Prepare", snapshots: "Snapshots", ocr: "OCR", draft: "Draft", deepseek: "DeepSeek", local: "Local", exportMd: "MD", exportHtml: "HTML", events: "events", shots: "shots", text: "text", steps: "steps", captureStep: "Capture step", captureLabel: "Manual capture", settings: "Settings", guideSettings: "Guide settings", apiKey: "API key env", apiKeyPlaceholder: "DEEPSEEK_API_KEY", baseUrl: "Base URL", model: "Model", ocrProfile: "OCR profile", ocrLanguage: "OCR languages", ocrFast: "Fast Latin", ocrVietnamese: "Vietnamese Enhanced", ocrHybrid: "Hybrid Vi + Latin", saveSettings: "Save", clearKey: "Reset env", settingsSaved: "Guide settings saved.", keyMissing: "Set a DeepSeek API key environment variable before generating with DeepSeek.", keyConfigured: "Env ready", keyNotConfigured: "Env value missing", ready: "Guide artifacts are ready.", noEvents: "No click events were captured for this guide.", ocrUnavailable: "Local OCR service is unavailable. You can still create a local draft.", exported: "Guide exported", progressPreparing: "Preparing events", progressSnapshots: "Capturing snapshots", progressOcr: "Running OCR", progressDraft: "Writing draft", progressExport: "Exporting files", }, vi: { title: "Hướng dẫn", noRecording: "Hãy quay bằng Guide Mode để tạo hướng dẫn từng bước.", noSession: "Chưa có guide session.", generateGuide: "Tạo hướng dẫn", generating: "Đang tạo...", prepare: "Chuẩn bị", snapshots: "Ảnh", ocr: "OCR", draft: "Draft", deepseek: "DeepSeek", local: "Local", exportMd: "MD", exportHtml: "HTML", events: "events", shots: "ảnh", text: "text", steps: "steps", captureStep: "Chụp bước", captureLabel: "Chụp thủ công", settings: "Cài đặt", guideSettings: "Guide settings", apiKey: "API key env", apiKeyPlaceholder: "DEEPSEEK_API_KEY", baseUrl: "Base URL", model: "Model", ocrProfile: "OCR profile", ocrLanguage: "OCR languages", ocrFast: "Fast Latin", ocrVietnamese: "Vietnamese Enhanced", ocrHybrid: "Hybrid Vi + Latin", saveSettings: "Lưu", clearKey: "Reset env", settingsSaved: "Da luu cai dat guide.", keyMissing: "Hãy set biến môi trường DeepSeek API key trước khi tạo draft bằng DeepSeek.", keyConfigured: "Env ready", keyNotConfigured: "Chưa thấy giá trị env", ready: "Đã sẵn sàng tài liệu hướng dẫn.", noEvents: "Chưa ghi nhận click event nào cho guide này.", ocrUnavailable: "OCR local chưa chạy. Vẫn có thể tạo draft local.", exported: "Đã export hướng dẫn", progressPreparing: "Đang chuẩn bị events", progressSnapshots: "Đang chụp ảnh", progressOcr: "Đang OCR", progressDraft: "Đang tạo draft", progressExport: "Đang export file", }, } as const; function getPendingOcrSnapshots(session: GuideSession): GuideSnapshot[] { const ocrCompletedSnapshotIds = new Set(session.ocrBlocks.map((block) => block.snapshotId)); return session.snapshots.filter( (snapshot) => !snapshot.ocrCompletedAt && !ocrCompletedSnapshotIds.has(snapshot.id), ); } function getProgressPercent(progress: GuideProgressState | null): number { if (!progress) { return 0; } if (progress.total <= 0) { return 100; } const percent = Math.round((progress.current / progress.total) * 100); return Math.min(100, Math.max(progress.current > 0 ? 8 : 4, percent)); } export function GuidePanel({ recordingId, videoPath, videoSourcePath }: GuidePanelProps) { const { locale } = useI18n(); const copy = useMemo(() => (locale.startsWith("vi") ? COPY.vi : COPY.en), [locale]); const guideLanguage: GuideLanguage = locale.startsWith("vi") ? "vi" : "en"; const [session, setSession] = useState(null); const [provider, setProvider] = useState("local"); const [busyAction, setBusyAction] = useState(null); const [aiSettings, setAiSettings] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsBusy, setSettingsBusy] = useState(false); const [deepSeekApiKeyEnvName, setDeepSeekApiKeyEnvName] = useState("DEEPSEEK_API_KEY"); const [deepSeekBaseUrl, setDeepSeekBaseUrl] = useState("https://api.deepseek.com"); const [deepSeekModel, setDeepSeekModel] = useState("deepseek-chat"); const [ocrProfile, setOcrProfile] = useState("vietnamese"); const [ocrLanguage, setOcrLanguage] = useState("vi,en"); const [message, setMessage] = useState(null); const [progress, setProgress] = useState(null); const isBusy = busyAction !== null; const progressPercent = getProgressPercent(progress); const canUseGuide = Boolean(recordingId && videoSourcePath && window.electronAPI?.guide); const generatedSteps = session?.generatedGuide?.steps ?? []; const statusLabel = useMemo(() => { if (!session) { return copy.noSession; } return [ `${session.events.length} ${copy.events}`, `${session.snapshots.length} ${copy.shots}`, `${session.ocrBlocks.length} ${copy.text}`, `${generatedSteps.length} ${copy.steps}`, ].join(" / "); }, [copy, generatedSteps.length, session]); const loadAiSettings = useCallback(async () => { if (!window.electronAPI?.guide?.getAiSettings) { return; } const result = await window.electronAPI.guide.getAiSettings(); if (!result.success) { setMessage(result.error); return; } setAiSettings(result.data); setDeepSeekBaseUrl(result.data.deepseek.baseUrl); setDeepSeekModel(result.data.deepseek.model); setDeepSeekApiKeyEnvName(result.data.deepseek.apiKeyEnvName); setOcrProfile(result.data.ocr.profile); setOcrLanguage(result.data.ocr.language); }, []); useEffect(() => { void loadAiSettings(); }, [loadAiSettings]); const loadSession = useCallback(async () => { if (!recordingId || !window.electronAPI?.guide) { setSession(null); setBusyAction(null); return; } setBusyAction("load"); const result = await window.electronAPI.guide.readSession(recordingId); setBusyAction(null); if (result.success) { setSession(result.data); setMessage(null); return; } if (result.code === "guide-session-not-found") { setSession(null); setMessage(null); return; } setMessage(result.error); }, [recordingId]); useEffect(() => { let cancelled = false; async function load() { if (!recordingId || !window.electronAPI?.guide) { setSession(null); setBusyAction(null); return; } setBusyAction("load"); const result = await window.electronAPI.guide.readSession(recordingId); if (cancelled) { return; } setBusyAction(null); if (result.success) { setSession(result.data); setMessage(null); } else if (result.code === "guide-session-not-found") { setSession(null); setMessage(null); } else { setMessage(result.error); } } load(); return () => { cancelled = true; }; }, [recordingId]); const ensureEventsSession = useCallback(async (): Promise => { if (!recordingId || !videoSourcePath) { throw new Error(copy.noRecording); } let current = session; const readResult = await window.electronAPI.guide.readSession(recordingId); if (readResult.success) { current = readResult.data; } else if (readResult.code === "guide-session-not-found") { current = null; } else if (!current) { throw new Error(readResult.error); } if (!current) { const startResult = await window.electronAPI.guide.startSession(recordingId); if (!startResult.success) { throw new Error(startResult.error); } current = startResult.data; } if (current.status === "recording" || current.videoPath !== videoSourcePath) { const finalizeResult = await window.electronAPI.guide.finalizeEvents({ recordingId, videoPath: videoSourcePath, }); if (!finalizeResult.success) { throw new Error(finalizeResult.error); } current = finalizeResult.data; } setSession(current); return current; }, [copy.noRecording, recordingId, session, videoSourcePath]); const runAction = useCallback( async (action: BusyAction, task: () => Promise) => { if (!canUseGuide) { setMessage(copy.noRecording); return; } setBusyAction(action); setMessage(null); setProgress(null); try { await task(); } catch (error) { const text = error instanceof Error ? error.message : String(error); setMessage(text); toast.error(text); } finally { setBusyAction(null); } }, [canUseGuide, copy.noRecording], ); const handleProviderChange = useCallback( (nextProvider: GuideAiProvider) => { setProvider(nextProvider); if (nextProvider === "deepseek" && !aiSettings?.deepseek.hasApiKey) { setSettingsOpen(true); setMessage(copy.keyMissing); } }, [aiSettings?.deepseek.hasApiKey, copy.keyMissing], ); const handleSaveAiSettings = useCallback(async () => { if (!window.electronAPI?.guide?.saveAiSettings) { return; } setSettingsBusy(true); setMessage(null); try { const result = await window.electronAPI.guide.saveAiSettings({ deepseekApiKeyEnvName: deepSeekApiKeyEnvName, baseUrl: deepSeekBaseUrl, model: deepSeekModel, ocrProfile, ocrLanguage, }); if (!result.success) { throw new Error(result.error); } setAiSettings(result.data); setDeepSeekApiKeyEnvName(result.data.deepseek.apiKeyEnvName); setDeepSeekBaseUrl(result.data.deepseek.baseUrl); setDeepSeekModel(result.data.deepseek.model); setOcrProfile(result.data.ocr.profile); setOcrLanguage(result.data.ocr.language); toast.success(copy.settingsSaved); } catch (error) { const text = error instanceof Error ? error.message : String(error); setMessage(text); toast.error(text); } finally { setSettingsBusy(false); } }, [ copy.settingsSaved, deepSeekApiKeyEnvName, deepSeekBaseUrl, deepSeekModel, ocrLanguage, ocrProfile, ]); const handleClearDeepSeekKey = useCallback(async () => { if (!window.electronAPI?.guide?.saveAiSettings) { return; } setSettingsBusy(true); setMessage(null); try { const result = await window.electronAPI.guide.saveAiSettings({ clearDeepseekApiKeyEnvName: true, baseUrl: deepSeekBaseUrl, model: deepSeekModel, ocrProfile, ocrLanguage, }); if (!result.success) { throw new Error(result.error); } setAiSettings(result.data); setDeepSeekApiKeyEnvName(result.data.deepseek.apiKeyEnvName); setOcrProfile(result.data.ocr.profile); setOcrLanguage(result.data.ocr.language); toast.success(copy.settingsSaved); } catch (error) { const text = error instanceof Error ? error.message : String(error); setMessage(text); toast.error(text); } finally { setSettingsBusy(false); } }, [copy.settingsSaved, deepSeekBaseUrl, deepSeekModel, ocrLanguage, ocrProfile]); const handleGenerateGuide = useCallback(() => { void runAction("generate", async () => { if (provider === "deepseek" && !aiSettings?.deepseek.hasApiKey) { setSettingsOpen(true); throw new Error(copy.keyMissing); } if (!videoPath) { throw new Error("Video URL is not available."); } setProgress({ label: copy.progressPreparing, current: 0, total: 1, detail: "0/1", }); let current = await ensureEventsSession(); setProgress({ label: copy.progressPreparing, current: 1, total: 1, detail: "1/1", }); if (current.events.length === 0) { throw new Error(copy.noEvents); } const snapshotEventIds = new Set(current.snapshots.map((snapshot) => snapshot.eventId)); const pendingSnapshotTotal = current.events.filter( (event) => !snapshotEventIds.has(event.id), ).length; if (pendingSnapshotTotal > 0) { setProgress({ label: copy.progressSnapshots, current: 0, total: pendingSnapshotTotal, detail: `0/${pendingSnapshotTotal}`, }); current = await captureGuideSnapshots({ session: current, videoUrl: videoPath, maxWidth: 1280, onProgress: ({ completed, total }) => { setProgress({ label: copy.progressSnapshots, current: completed, total, detail: `${completed}/${total}`, }); }, }); setSession(current); } const pendingOcrSnapshots = getPendingOcrSnapshots(current); for (const [index, snapshot] of pendingOcrSnapshots.entries()) { setProgress({ label: copy.progressOcr, current: index, total: pendingOcrSnapshots.length, detail: `${index + 1}/${pendingOcrSnapshots.length}`, }); const ocrResult = await window.electronAPI.guide.runOcr({ recordingId: current.recordingId, snapshotIds: [snapshot.id], }); if (!ocrResult.success) { if (ocrResult.code === "guide-ocr-unavailable") { toast.warning(copy.ocrUnavailable); } throw new Error(ocrResult.error); } current = ocrResult.data; setSession(current); setProgress({ label: copy.progressOcr, current: index + 1, total: pendingOcrSnapshots.length, detail: `${index + 1}/${pendingOcrSnapshots.length}`, }); } setProgress({ label: copy.progressDraft, current: 0, total: 1, detail: "0/1", }); const result = await window.electronAPI.guide.generateDraft({ recordingId: current.recordingId, language: guideLanguage, provider, }); if (!result.success) { throw new Error(result.error); } current = result.data; setSession(current); setProgress({ label: copy.progressDraft, current: 1, total: 1, detail: "1/1", }); setProgress({ label: copy.progressExport, current: 0, total: 2, detail: "0/2", }); const markdownResult = await window.electronAPI.guide.exportMarkdown({ recordingId: current.recordingId, }); if (!markdownResult.success) { throw new Error(markdownResult.error); } setProgress({ label: copy.progressExport, current: 1, total: 2, detail: "1/2", }); const htmlResult = await window.electronAPI.guide.exportHtml({ recordingId: current.recordingId, }); if (!htmlResult.success) { throw new Error(htmlResult.error); } setProgress({ label: copy.progressExport, current: 2, total: 2, detail: "2/2", }); const revealResult = await window.electronAPI.revealInFolder(htmlResult.data.path); if (!revealResult.success) { toast.warning(revealResult.error ?? "Unable to open guide folder."); } setSession(htmlResult.data.session); toast.success(copy.exported, { description: `${markdownResult.data.path}\n${htmlResult.data.path}`, }); }); }, [ aiSettings?.deepseek.hasApiKey, copy.exported, copy.keyMissing, copy.noEvents, copy.ocrUnavailable, copy.progressDraft, copy.progressExport, copy.progressOcr, copy.progressPreparing, copy.progressSnapshots, ensureEventsSession, guideLanguage, provider, runAction, videoPath, ]); return (
{copy.title}

{canUseGuide ? statusLabel : copy.noRecording}

{message &&

{message}

} {progress && (
{progress.label} {progress.detail ?? `${progress.current}/${progress.total}`}
)}
{settingsOpen && (
{copy.guideSettings}
{aiSettings?.deepseek.hasApiKey ? `${copy.keyConfigured}: ${aiSettings.deepseek.apiKeyEnvName}` : `${copy.keyNotConfigured}: ${ aiSettings?.deepseek.apiKeyEnvName ?? "DEEPSEEK_API_KEY" }`}
{aiSettings?.deepseek.storage ?? "none"}
)}
{generatedSteps.slice(0, 4).map((step) => (
{step.order}. {step.title}

{step.instruction}

))}
); }