From 9ccd146ce27644dee975d6489985e9a596e60aaf Mon Sep 17 00:00:00 2001 From: Randy Lutcavich Date: Sun, 12 Apr 2026 20:27:02 -0500 Subject: [PATCH 1/3] feat: add backend status indicator and restart controls Add a live backend connection status indicator (green/red/yellow dot) to the toolbar with adaptive health polling (1s retry when offline, 5s when online). Include restart/start controls in both Electron (IPC-based) and browser mode (Vite dev middleware). - New BackendStatusDot component with color-coded status - Extract IS_ELECTRON and startBackend() to shared utils/env.ts - Add BackendStatus type to project types - Backend section in Settings panel with status + restart button - Vite plugin to spawn backend in browser dev mode - Electron IPC handler for backend:restart --- electron/main.js | 9 +++++ electron/preload.js | 1 + frontend/src/App.tsx | 24 +++++++++++- frontend/src/components/BackendStatusDot.tsx | 28 +++++++++++++ frontend/src/components/SettingsPanel.tsx | 37 ++++++++++++++++-- frontend/src/store/aiStore.ts | 9 +++-- frontend/src/store/editorStore.ts | 6 ++- frontend/src/types/project.ts | 2 + frontend/src/utils/env.ts | 17 ++++++++ frontend/src/vite-env.d.ts | 1 + frontend/vite.config.ts | 41 +++++++++++++++++++- 11 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/BackendStatusDot.tsx create mode 100644 frontend/src/utils/env.ts diff --git a/electron/main.js b/electron/main.js index ac11da6..aed99f8 100644 --- a/electron/main.js +++ b/electron/main.js @@ -129,3 +129,12 @@ ipcMain.handle('fs:writeFile', async (_event, filePath, content) => { fs.writeFileSync(filePath, content, 'utf-8'); return true; }); + +ipcMain.handle('backend:restart', async () => { + if (!pythonBackend) return false; + pythonBackend.stop(); + // Brief pause for the OS to release the port before re-spawning + await new Promise((r) => setTimeout(r, 600)); + await pythonBackend.start(); + return true; +}); diff --git a/electron/preload.js b/electron/preload.js index 32d5ac0..d78408a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,4 +9,5 @@ contextBridge.exposeInMainWorld('electronAPI', { decryptString: (encrypted) => ipcRenderer.invoke('safe-storage:decrypt', encrypted), readFile: (path) => ipcRenderer.invoke('fs:readFile', path), writeFile: (path, content) => ipcRenderer.invoke('fs:writeFile', path, content), + restartBackend: () => ipcRenderer.invoke('backend:restart'), }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8542392..fc38c1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import WaveformTimeline from './components/WaveformTimeline'; import AIPanel from './components/AIPanel'; import ExportDialog from './components/ExportDialog'; import SettingsPanel from './components/SettingsPanel'; +import BackendStatusDot from './components/BackendStatusDot'; +import { IS_ELECTRON } from './utils/env'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { Film, @@ -18,8 +20,6 @@ import { FileInput, } from 'lucide-react'; -const IS_ELECTRON = !!window.electronAPI; - type Panel = 'ai' | 'settings' | 'export' | null; export default function App() { @@ -33,6 +33,8 @@ export default function App() { setTranscription, setTranscribing, backendUrl, + backendStatus, + setBackendStatus, } = useEditorStore(); const [activePanel, setActivePanel] = useState(null); @@ -48,6 +50,23 @@ export default function App() { } }, [setBackendUrl]); + // Health-check polling: 1s when offline, 5s when online + useEffect(() => { + let id: ReturnType; + const check = async () => { + try { + const res = await fetch(`${backendUrl}/health`); + setBackendStatus(res.ok ? 'online' : 'offline'); + } catch { + setBackendStatus('offline'); + } + const delay = useEditorStore.getState().backendStatus === 'online' ? 5000 : 1000; + id = setTimeout(check, delay); + }; + check(); + return () => clearTimeout(id); + }, [backendUrl, setBackendStatus]); + const handleLoadProject = async () => { if (!IS_ELECTRON) return; try { @@ -228,6 +247,7 @@ export default function App() { active={activePanel === 'settings'} onClick={() => togglePanel('settings')} /> + diff --git a/frontend/src/components/BackendStatusDot.tsx b/frontend/src/components/BackendStatusDot.tsx new file mode 100644 index 0000000..fde3e72 --- /dev/null +++ b/frontend/src/components/BackendStatusDot.tsx @@ -0,0 +1,28 @@ +import type { BackendStatus } from '../types/project'; + +const STATUS_COLORS: Record = { + online: 'bg-green-500', + offline: 'bg-red-500', + checking: 'bg-yellow-500 animate-pulse', +}; + +const STATUS_LABELS: Record = { + online: 'Backend online', + offline: 'Backend offline', + checking: 'Connecting to backend…', +}; + +export default function BackendStatusDot({ + status, + className = '', +}: { + status: BackendStatus; + className?: string; +}) { + return ( +
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 7919d1b..bc05fa2 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -2,14 +2,21 @@ import { useAIStore } from '../store/aiStore'; import { useState, useEffect } from 'react'; import type { AIProvider } from '../types/project'; import { useEditorStore } from '../store/editorStore'; -import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react'; +import BackendStatusDot from './BackendStatusDot'; +import { IS_ELECTRON, startBackend, BACKEND_STATUS_LABEL } from '../utils/env'; +import { Bot, Cloud, Brain, RefreshCw, Server } from 'lucide-react'; export default function SettingsPanel() { const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore(); - const { backendUrl } = useEditorStore(); + const { backendUrl, backendStatus, setBackendStatus } = useEditorStore(); const [ollamaModels, setOllamaModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const handleStart = async () => { + setBackendStatus('checking'); + await startBackend(); + }; + const fetchOllamaModels = async () => { setLoadingModels(true); try { @@ -41,9 +48,33 @@ export default function SettingsPanel() { claude: 'Claude (Anthropic)', }; + const isChecking = backendStatus === 'checking'; + return (
-

AI Settings

+

Settings

+ +
+
+ + Backend +
+
+
+ + {BACKEND_STATUS_LABEL[backendStatus]} + {backendUrl} +
+ +
+
{/* Default provider selector */}
diff --git a/frontend/src/store/aiStore.ts b/frontend/src/store/aiStore.ts index 78bc277..285065f 100644 --- a/frontend/src/store/aiStore.ts +++ b/frontend/src/store/aiStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { AIProvider, AIProviderConfig, FillerWordResult, ClipSuggestion } from '../types/project'; +import { IS_ELECTRON } from '../utils/env'; const ENCRYPTED_KEY_PREFIX = 'aive_enc_'; @@ -30,8 +31,8 @@ async function encryptAndStore(key: string, value: string): Promise { localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key); return; } - if (window.electronAPI) { - const encrypted = await window.electronAPI.encryptString(value); + if (IS_ELECTRON) { + const encrypted = await window.electronAPI!.encryptString(value); localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted); } else { localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, btoa(value)); @@ -41,9 +42,9 @@ async function encryptAndStore(key: string, value: string): Promise { async function loadAndDecrypt(key: string): Promise { const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key); if (!stored) return ''; - if (window.electronAPI) { + if (IS_ELECTRON) { try { - return await window.electronAPI.decryptString(stored); + return await window.electronAPI!.decryptString(stored); } catch { return ''; } diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index ec9448d..8ff3d05 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { temporal } from 'zundo'; -import type { Word, Segment, DeletedRange, TranscriptionResult } from '../types/project'; +import type { Word, Segment, DeletedRange, TranscriptionResult, BackendStatus } from '../types/project'; interface EditorState { videoPath: string | null; @@ -23,10 +23,12 @@ interface EditorState { exportProgress: number; backendUrl: string; + backendStatus: BackendStatus; } interface EditorActions { setBackendUrl: (url: string) => void; + setBackendStatus: (status: BackendStatus) => void; loadVideo: (path: string) => void; setTranscription: (result: TranscriptionResult) => void; setCurrentTime: (time: number) => void; @@ -62,6 +64,7 @@ const initialState: EditorState = { isExporting: false, exportProgress: 0, backendUrl: 'http://localhost:8642', + backendStatus: 'checking', }; let nextRangeId = 1; @@ -72,6 +75,7 @@ export const useEditorStore = create()( ...initialState, setBackendUrl: (url) => set({ backendUrl: url }), + setBackendStatus: (status) => set({ backendStatus: status }), loadVideo: (path) => { const backend = get().backendUrl; diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index 7e4892d..4567235 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -1,3 +1,5 @@ +export type BackendStatus = 'checking' | 'online' | 'offline'; + export interface Word { word: string; start: number; diff --git a/frontend/src/utils/env.ts b/frontend/src/utils/env.ts new file mode 100644 index 0000000..0149d44 --- /dev/null +++ b/frontend/src/utils/env.ts @@ -0,0 +1,17 @@ +import type { BackendStatus } from '../types/project'; + +export const IS_ELECTRON = !!window.electronAPI; + +export const BACKEND_STATUS_LABEL: Record = { + online: 'Online', + offline: 'Offline', + checking: 'Connecting…', +}; + +export async function startBackend(): Promise { + if (IS_ELECTRON) { + await window.electronAPI!.restartBackend(); + } else { + await fetch('/api/start-backend', { method: 'POST' }); + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 089ea01..7993938 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface ElectronAPI { decryptString: (encrypted: string) => Promise; readFile: (path: string) => Promise; writeFile: (path: string, content: string) => Promise; + restartBackend: () => Promise; } interface Window { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bc167f2..77471e1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,47 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const configDir = path.dirname(fileURLToPath(import.meta.url)); +const backendDir = path.join(configDir, '..', 'backend'); + +const backendLauncher = () => { + let spawning = false; + return { + name: 'backend-launcher', + configureServer(server: any) { + server.middlewares.use('/api/start-backend', (req: any, res: any) => { + if (req.method !== 'POST') { + res.statusCode = 405; + res.end(); + return; + } + if (!spawning) { + spawning = true; + const child = spawn( + 'python', + ['-m', 'uvicorn', 'main:app', '--reload', '--port', '8642'], + { cwd: backendDir, detached: true, stdio: 'ignore' }, + ); + child.unref(); + // On crash wait 2s before allowing another attempt to prevent rapid re-spawn loops. + child.on('exit', (code) => { + if (code === 0) { spawning = false; } + else { setTimeout(() => { spawning = false; }, 2000); } + }); + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + }, + }; +}; + export default defineConfig({ - plugins: [react()], + plugins: [react(), backendLauncher()], base: './', server: { port: 5173, From 32a165f5f997a468077d31f0e1c6df73253fff52 Mon Sep 17 00:00:00 2001 From: Randy Lutcavich Date: Sun, 12 Apr 2026 20:30:27 -0500 Subject: [PATCH 2/3] feat: add copy/paste transcript editing with LCS diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bulk transcript editing: copy the transcript, edit it externally, and paste it back to apply all deletions at once. Workflow: 1. Click "Copy" in the transcript toolbar to copy all non-deleted words 2. Edit the text in any external editor (delete unwanted words) 3. Click "Paste edits" to open a diff preview dialog 4. Review the preview (word count, cut count, warnings) and click "Apply" The diff algorithm uses Longest Common Subsequence (LCS) to detect only deletions — modified words are ignored. Uses Uint32Array for the DP table to support transcripts beyond 65k words. The diff computation is debounced to 300ms to avoid running on every keystroke. Also extracts buildDeletedSet() utility to eliminate duplicate set- building loops across components. --- .../src/components/PasteTranscriptDialog.tsx | 132 ++++++++++++++++++ frontend/src/components/TranscriptEditor.tsx | 49 +++++-- frontend/src/store/editorStore.ts | 53 ++++++- frontend/src/utils/buildDeletedSet.ts | 9 ++ frontend/src/utils/diffTranscript.ts | 99 +++++++++++++ 5 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/PasteTranscriptDialog.tsx create mode 100644 frontend/src/utils/buildDeletedSet.ts create mode 100644 frontend/src/utils/diffTranscript.ts diff --git a/frontend/src/components/PasteTranscriptDialog.tsx b/frontend/src/components/PasteTranscriptDialog.tsx new file mode 100644 index 0000000..6619836 --- /dev/null +++ b/frontend/src/components/PasteTranscriptDialog.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useRef } from 'react'; +import { X } from 'lucide-react'; +import { useEditorStore } from '../store/editorStore'; +import { diffTranscript, groupContiguousIndices } from '../utils/diffTranscript'; +import { buildDeletedSet } from '../utils/buildDeletedSet'; + +interface Props { + onClose: () => void; +} + +interface Preview { + deletedCount: number; + rangeCount: number; + totalActive: number; +} + +export default function PasteTranscriptDialog({ onClose }: Props) { + const words = useEditorStore((s) => s.words); + const deletedRanges = useEditorStore((s) => s.deletedRanges); + const applyPastedTranscript = useEditorStore((s) => s.applyPastedTranscript); + + const [pastedText, setPastedText] = useState(''); + const [preview, setPreview] = useState(null); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (pastedText.trim() === '') { + setPreview(null); + return; + } + + debounceRef.current = setTimeout(() => { + const alreadyDeletedSet = buildDeletedSet(deletedRanges); + const totalActive = words.length - alreadyDeletedSet.size; + const deletedIndices = diffTranscript(words, pastedText, alreadyDeletedSet); + const rangeCount = groupContiguousIndices(deletedIndices).length; + setPreview({ deletedCount: deletedIndices.length, rangeCount, totalActive }); + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [pastedText, words, deletedRanges]); + + const handleApply = () => { + applyPastedTranscript(pastedText); + onClose(); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + const allDeleted = preview !== null && preview.deletedCount === preview.totalActive; + const noChanges = preview !== null && preview.deletedCount === 0; + const percent = preview !== null && preview.totalActive > 0 + ? Math.round((preview.deletedCount / preview.totalActive) * 100) + : 0; + + return ( +
+
+
+

Paste Edited Transcript

+ +
+ +

+ Paste your edited transcript below. Deleted words will be cut automatically. + Modified words are ignored — only deletions are detected. +

+ +