Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
1 change: 1 addition & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
24 changes: 22 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,8 +20,6 @@ import {
FileInput,
} from 'lucide-react';

const IS_ELECTRON = !!window.electronAPI;

type Panel = 'ai' | 'settings' | 'export' | null;

export default function App() {
Expand All @@ -33,6 +33,8 @@ export default function App() {
setTranscription,
setTranscribing,
backendUrl,
backendStatus,
setBackendStatus,
} = useEditorStore();

const [activePanel, setActivePanel] = useState<Panel>(null);
Expand All @@ -48,6 +50,23 @@ export default function App() {
}
}, [setBackendUrl]);

// Health-check polling: 1s when offline, 5s when online
useEffect(() => {
let id: ReturnType<typeof setTimeout>;
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 {
Expand Down Expand Up @@ -228,6 +247,7 @@ export default function App() {
active={activePanel === 'settings'}
onClick={() => togglePanel('settings')}
/>
<BackendStatusDot status={backendStatus} className="ml-2" />
</div>
</header>

Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/BackendStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { BackendStatus } from '../types/project';

const STATUS_COLORS: Record<BackendStatus, string> = {
online: 'bg-green-500',
offline: 'bg-red-500',
checking: 'bg-yellow-500 animate-pulse',
};

const STATUS_LABELS: Record<BackendStatus, string> = {
online: 'Backend online',
offline: 'Backend offline',
checking: 'Connecting to backend…',
};

export default function BackendStatusDot({
status,
className = '',
}: {
status: BackendStatus;
className?: string;
}) {
return (
<div
title={STATUS_LABELS[status]}
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_COLORS[status]} ${className}`}
/>
);
}
37 changes: 34 additions & 3 deletions frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);

const handleStart = async () => {
setBackendStatus('checking');
await startBackend();
};

const fetchOllamaModels = async () => {
setLoadingModels(true);
try {
Expand Down Expand Up @@ -41,9 +48,33 @@ export default function SettingsPanel() {
claude: 'Claude (Anthropic)',
};

const isChecking = backendStatus === 'checking';

return (
<div className="p-4 space-y-6">
<h3 className="text-sm font-semibold">AI Settings</h3>
<h3 className="text-sm font-semibold">Settings</h3>

<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-editor-text-muted">
<Server className="w-3.5 h-3.5" />
Backend
</div>
<div className="p-3 bg-editor-surface rounded-lg space-y-2">
<div className="flex items-center gap-2">
<BackendStatusDot status={backendStatus} />
<span className="text-xs flex-1">{BACKEND_STATUS_LABEL[backendStatus]}</span>
<span className="text-[10px] text-editor-text-muted truncate max-w-[120px]">{backendUrl}</span>
</div>
<button
onClick={handleStart}
disabled={isChecking}
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 text-white rounded transition-colors"
>
<RefreshCw className={`w-3 h-3 ${isChecking ? 'animate-spin' : ''}`} />
{isChecking ? 'Connecting…' : IS_ELECTRON ? 'Restart Backend' : 'Start Backend'}
</button>
</div>
</div>

{/* Default provider selector */}
<div className="space-y-2">
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/store/aiStore.ts
Original file line number Diff line number Diff line change
@@ -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_';

Expand Down Expand Up @@ -30,8 +31,8 @@ async function encryptAndStore(key: string, value: string): Promise<void> {
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));
Expand All @@ -41,9 +42,9 @@ async function encryptAndStore(key: string, value: string): Promise<void> {
async function loadAndDecrypt(key: string): Promise<string> {
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 '';
}
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/store/editorStore.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -62,6 +64,7 @@ const initialState: EditorState = {
isExporting: false,
exportProgress: 0,
backendUrl: 'http://localhost:8642',
backendStatus: 'checking',
};

let nextRangeId = 1;
Expand All @@ -72,6 +75,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
...initialState,

setBackendUrl: (url) => set({ backendUrl: url }),
setBackendStatus: (status) => set({ backendStatus: status }),

loadVideo: (path) => {
const backend = get().backendUrl;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/project.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type BackendStatus = 'checking' | 'online' | 'offline';

export interface Word {
word: string;
start: number;
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { BackendStatus } from '../types/project';

export const IS_ELECTRON = !!window.electronAPI;

export const BACKEND_STATUS_LABEL: Record<BackendStatus, string> = {
online: 'Online',
offline: 'Offline',
checking: 'Connecting…',
};

export async function startBackend(): Promise<void> {
if (IS_ELECTRON) {
await window.electronAPI!.restartBackend();
} else {
await fetch('/api/start-backend', { method: 'POST' });
}
}
1 change: 1 addition & 0 deletions frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ElectronAPI {
decryptString: (encrypted: string) => Promise<string>;
readFile: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<boolean>;
restartBackend: () => Promise<boolean>;
}

interface Window {
Expand Down
41 changes: 40 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down