Skip to content
Merged
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
77 changes: 77 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ function saveSettings(settings: Partial<Settings>): void {
}

function createWindow(): void {
const isMac = process.platform === 'darwin'
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
Expand All @@ -222,6 +223,16 @@ function createWindow(): void {
show: false,
autoHideMenuBar: true,
icon: join(__dirname, '../../build/icon.png'),
backgroundColor: '#080c16',
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
...(isMac ? { trafficLightPosition: { x: 10, y: 8 } } : {}),
...(process.platform !== 'darwin' ? {
titleBarOverlay: {
color: '#080c16',
symbolColor: '#6b7280',
height: 32
}
} : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
Expand Down Expand Up @@ -347,6 +358,21 @@ function createWindow(): void {
}

// IPC Handlers

// Update title bar overlay colors when theme changes (Windows only)
ipcMain.handle('update-titlebar-theme', (_, isDark: boolean) => {
if (process.platform === 'darwin' || !mainWindow) return
try {
mainWindow.setTitleBarOverlay({
color: isDark ? '#080c16' : '#f6f7f9',
symbolColor: isDark ? '#9ca3af' : '#6b7280',
height: 32
})
} catch {
// setTitleBarOverlay may not be available on all platforms
}
})

ipcMain.handle('get-api-key', () => {
const settings = loadSettings()
return settings.apiKey
Expand Down Expand Up @@ -476,6 +502,57 @@ ipcMain.handle('download-file', async (_, url: string, defaultFilename: string)
})
})

// Silent file save handler — saves a remote URL to a local directory without dialog
ipcMain.handle('save-file-silent', async (_, url: string, dir: string, fileName: string) => {
try {
if (!fileName) return { success: false, error: 'Missing filename' }
const targetDir = dir || app.getPath('downloads')
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
const filePath = join(targetDir, fileName)

// Handle local-asset:// URLs
if (url.startsWith('local-asset://')) {
const localPath = decodeURIComponent(url.replace('local-asset://', ''))
if (!existsSync(localPath)) return { success: false, error: 'Source file not found' }
copyFileSync(localPath, filePath)
return { success: true, filePath }
}

// Handle data: URLs
if (url.startsWith('data:')) {
const matches = url.match(/^data:[^;]+;base64,(.+)$/)
if (matches) {
writeFileSync(filePath, Buffer.from(matches[1], 'base64'))
return { success: true, filePath }
}
return { success: false, error: 'Invalid data URL' }
}

// Download from http/https
return new Promise((resolve) => {
const httpProtocol = url.startsWith('https') ? https : http
const file = createWriteStream(filePath)
httpProtocol.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
const rp = redirectUrl.startsWith('https') ? https : http
rp.get(redirectUrl, (rr) => {
rr.pipe(file)
file.on('finish', () => { file.close(); resolve({ success: true, filePath }) })
}).on('error', (err) => resolve({ success: false, error: err.message }))
return
}
}
response.pipe(file)
file.on('finish', () => { file.close(); resolve({ success: true, filePath }) })
}).on('error', (err) => resolve({ success: false, error: err.message }))
})
} catch (err) {
return { success: false, error: (err as Error).message }
}
})

// Assets metadata helpers
function loadAssetsMetadata(): AssetMetadata[] {
try {
Expand Down
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,13 @@ const electronAPI = {
clearAllData: (): Promise<boolean> => ipcRenderer.invoke('clear-all-data'),
downloadFile: (url: string, defaultFilename: string): Promise<DownloadResult> =>
ipcRenderer.invoke('download-file', url, defaultFilename),
saveFileSilent: (url: string, dir: string, fileName: string): Promise<DownloadResult> =>
ipcRenderer.invoke('save-file-silent', url, dir, fileName),
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('open-external', url),

// Title bar theme
updateTitlebarTheme: (isDark: boolean): Promise<void> => ipcRenderer.invoke('update-titlebar-theme', isDark),

// Auto-updater APIs
getAppVersion: (): Promise<string> => ipcRenderer.invoke('get-app-version'),
getLogFilePath: (): Promise<string> => ipcRenderer.invoke('get-log-file-path'),
Expand Down
9 changes: 8 additions & 1 deletion electron/workflow/engine/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ export class ExecutionEngine {
}

for (const level of levels) {
// Stop the entire workflow if any node has failed
if (failedNodes.size > 0) break
const batch = level.slice(0, MAX_PARALLEL_EXECUTIONS)
await Promise.all(batch.map(async nodeId => {
// If another node in this batch failed, skip remaining
if (failedNodes.size > 0) return
// Skip if any upstream node failed
const upstreams = upstreamMap.get(nodeId) ?? []
if (upstreams.some(uid => failedNodes.has(uid))) {
Expand Down Expand Up @@ -104,10 +108,13 @@ export class ExecutionEngine {
const nodeMap = new Map(nodes.map(n => [n.id, n]))

const levels = topologicalLevels(nodeIds, simpleEdges)
let stopped = false
for (const level of levels) {
if (stopped) break
const toRun = level.filter(id => downstream.includes(id))
if (toRun.length === 0) continue
await Promise.all(toRun.map(id => this.executeNode(workflowId, id, nodeMap, edges)))
const results = await Promise.all(toRun.map(id => this.executeNode(workflowId, id, nodeMap, edges)))
if (results.some(ok => !ok)) stopped = true
}
}

Expand Down
17 changes: 14 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ class WaveSpeedClient {
}
return response.data.data
} catch (error) {
// Re-throw AxiosError directly so the polling loop in run() can detect
// connection errors and retry instead of aborting the entire prediction.
if (error instanceof AxiosError) throw error
throw createAPIError(error, 'Failed to get result')
}
}
Expand Down Expand Up @@ -277,6 +280,8 @@ class WaveSpeedClient {

// Poll for result with retry on connection errors
const startTime = Date.now()
let consecutiveErrors = 0
const MAX_CONSECUTIVE_ERRORS = 10
while (true) {
throwIfAborted()
if (Date.now() - startTime > timeout) {
Expand All @@ -285,6 +290,7 @@ class WaveSpeedClient {

try {
const result = await this.getResult(requestId, { signal })
consecutiveErrors = 0 // reset on success

if (result.status === 'completed') {
return result
Expand All @@ -297,10 +303,15 @@ class WaveSpeedClient {
}
} catch (error) {
if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError')
// Retry after 1 second on connection errors
// Retry with exponential backoff on connection errors
if (this.isConnectionError(error)) {
console.warn('Connection error during polling, retrying in 1 second...', error)
await new Promise(resolve => setTimeout(resolve, 1000))
consecutiveErrors++
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
throw new Error(`Polling failed after ${MAX_CONSECUTIVE_ERRORS} consecutive connection errors`)
}
const backoff = Math.min(1000 * Math.pow(2, consecutiveErrors - 1), 10000)
console.warn(`Connection error during polling (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}), retrying in ${backoff}ms...`, error)
await new Promise(resolve => setTimeout(resolve, backoff))
continue
}
// Re-throw non-connection errors
Expand Down
16 changes: 14 additions & 2 deletions src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Sidebar } from './Sidebar'
import { AppLogo } from './AppLogo'
import { PageResetContext } from './PageResetContext'
import { Toaster } from '@/components/ui/toaster'
import { TooltipProvider } from '@/components/ui/tooltip'
Expand Down Expand Up @@ -266,15 +267,25 @@ export function Layout() {
return (
<PageResetContext.Provider value={{ resetPage }}>
<TooltipProvider>
<div className="flex h-screen overflow-hidden relative">
<div className="flex flex-col h-screen overflow-hidden relative">
{/* Fixed titlebar — draggable region for macOS & Windows */}
<div className="h-8 min-h-[32px] flex items-center justify-center bg-background electron-drag select-none shrink-0 relative z-50 electron-safe-right">
{/* Show logo on left for Windows/Linux (macOS has traffic lights there) */}
{!/mac/i.test(navigator.platform) && (
<div className="absolute left-0 top-0 bottom-0 w-12 flex items-center justify-center electron-no-drag">
<AppLogo className="h-5 w-5 shrink-0" />
</div>
)}
</div>
<div className="flex flex-1 overflow-hidden">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(prev => !prev)}
lastFreeToolsPage={lastFreeToolsPage}
isMobileOpen={false}
onMobileClose={() => {}}
/>
<main className="relative flex-1 overflow-hidden md:pl-0">
<main className="relative flex-1 overflow-hidden md:pl-0" style={{ background: 'hsl(var(--content-area))' }}>
{requiresLogin ? loginContent : (
<>
{/* Regular routes via Outlet */}
Expand Down Expand Up @@ -358,6 +369,7 @@ export function Layout() {
)}
</main>
<Toaster />
</div>
</div>
</TooltipProvider>
</PageResetContext.Provider>
Expand Down
45 changes: 14 additions & 31 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
Star,
X
} from 'lucide-react'
import { AppLogo } from './AppLogo'

interface NavItem {
titleKey: string
Expand Down Expand Up @@ -146,8 +145,8 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
return (
<div
className={cn(
"flex h-full flex-col border-r border-border/70 bg-background/95 backdrop-blur transition-all duration-300 shrink-0",
collapsed ? "w-16" : "w-52",
"flex h-full flex-col bg-background/95 backdrop-blur transition-all duration-300 shrink-0 electron-drag",
collapsed ? "w-12" : "w-48",
// Mobile overlay when hamburger opens
isMobileOpen && "!fixed inset-y-0 left-0 z-50 w-72 shadow-2xl"
)}
Expand All @@ -163,29 +162,13 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
</button>
)}

{/* Logo */}
<div
style={{ display: 'flex', alignItems: 'center', flexDirection: 'row' }}
className={cn(
"h-16 border-b border-border/70",
collapsed && !isMobileOpen ? "justify-center px-2" : "gap-3 px-5"
)}
>
<AppLogo className="h-10 w-10 shrink-0" />
{(!collapsed || isMobileOpen) && (
<div className="min-w-0">
<span className="block whitespace-nowrap text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-[#1a2654] to-[#1a6b7c] dark:from-[#38bdf8] dark:to-[#34d399]">WaveSpeed</span>
</div>
)}
</div>

{/* Navigation */}
<ScrollArea className="flex-1 px-2 py-4">
<nav className="flex flex-col gap-4 px-1">
<ScrollArea className="flex-1 px-1.5 py-2">
<nav className="flex flex-col gap-5 px-0.5 electron-no-drag">
{navGroups.map((group) => (
<div key={group.key} className="space-y-1">
<div key={group.key} className={collapsed && !isMobileOpen ? 'contents' : 'space-y-5'}>
{(!collapsed || isMobileOpen) && (
<div className="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground/80">
<div className="px-2 pb-0.5 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/80">
{group.label}
</div>
)}
Expand All @@ -210,8 +193,8 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
}}
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'h-9 w-full rounded-xl text-sm transition-all relative overflow-visible',
collapsed && !isMobileOpen ? 'justify-center px-2' : 'justify-start gap-3 px-3',
'h-8 w-full rounded-lg text-xs transition-all relative overflow-visible',
collapsed && !isMobileOpen ? 'justify-center px-0' : 'justify-start gap-2.5 px-2.5',
active
? 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/95 hover:text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
Expand All @@ -220,7 +203,7 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
>
{/* Glow effect for new feature */}
{isNewFeature && !active && (
<span className="absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 animate-pulse" />
<span className="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 animate-pulse" />
)}

<item.icon className="h-5 w-5 shrink-0 relative z-10" />
Expand Down Expand Up @@ -264,7 +247,7 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
</ScrollArea>

{/* Bottom Navigation */}
<div className="mt-auto border-t border-border/70 p-3">
<div className="mt-auto p-1.5 electron-no-drag">
<nav className="flex flex-col gap-1">
{bottomNavItems.map((item) => {
const active = location.pathname === item.href
Expand All @@ -276,8 +259,8 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
onClick={() => navigate(item.href)}
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'h-9 w-full rounded-xl transition-all',
collapsed && !isMobileOpen ? 'justify-center px-2' : 'justify-start gap-3 px-3',
'h-8 w-full rounded-lg transition-all',
collapsed && !isMobileOpen ? 'justify-center px-0' : 'justify-start gap-2.5 px-2.5',
active
? 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/95 hover:text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
Expand Down Expand Up @@ -306,8 +289,8 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
size="sm"
onClick={onToggle}
className={cn(
"mt-2 h-9 w-full rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground",
collapsed ? "justify-center px-2" : "justify-start gap-3 px-3"
"mt-3 h-8 w-full rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground",
collapsed ? "justify-center px-0" : "justify-start gap-2.5 px-2.5"
)}
>
{collapsed ? (
Expand Down
Loading