diff --git a/api/main.py b/api/main.py index fd98b5e..7465692 100644 --- a/api/main.py +++ b/api/main.py @@ -22,7 +22,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Modly API", - version="0.1.0", + version="0.1.3", lifespan=lifespan, ) diff --git a/api/routers/model.py b/api/routers/model.py index b2d9808..5a4d53d 100644 --- a/api/routers/model.py +++ b/api/routers/model.py @@ -39,6 +39,17 @@ async def switch_model(model_id: str): raise HTTPException(400, str(e)) +@router.post("/unload/{model_id}") +async def unload_model(model_id: str): + """Unloads a model from memory so its files can be safely deleted.""" + try: + gen = generator_registry.get_generator(model_id) + gen.unload() + return {"unloaded": True} + except ValueError: + return {"unloaded": True} # already not loaded, that's fine + + @router.get("/hf-download") async def hf_download(repo_id: str, model_id: str): """ diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 31db80e..b2bf758 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -96,13 +96,18 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return result.canceled ? null : result.filePath }) - ipcMain.handle('model:delete', async (_, modelId: string): Promise => { + ipcMain.handle('model:delete', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => { const modelDir = join(app.getPath('userData'), 'models', modelId) try { - await rmAsync(modelDir, { recursive: true, force: true }) - return true + await axios.post(`${API_BASE_URL}/model/unload/${encodeURIComponent(modelId)}`, {}, { timeout: 5000 }) } catch { - return false + // unload is best-effort — proceed with deletion anyway + } + try { + await rmAsync(modelDir, { recursive: true, force: true }) + return { success: true } + } catch (err) { + return { success: false, error: String(err) } } }) diff --git a/electron/main/logger.ts b/electron/main/logger.ts index e3ac51c..09efd8b 100644 --- a/electron/main/logger.ts +++ b/electron/main/logger.ts @@ -4,10 +4,10 @@ import { join } from 'path' const MAX_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB -function getLogPath(): string { +function getLogsDir(): string { const logsDir = join(app.getPath('userData'), 'logs') mkdirSync(logsDir, { recursive: true }) - return join(logsDir, 'modly.log') + return logsDir } function rotate(logPath: string): void { @@ -18,18 +18,26 @@ function rotate(logPath: string): void { } catch {} } -function write(level: string, message: string): void { +function writeTo(filename: string, line: string): void { try { - const logPath = getLogPath() + const logPath = join(getLogsDir(), filename) rotate(logPath) - const line = `[${new Date().toISOString()}] [${level}] ${message}\n` appendFileSync(logPath, line, 'utf-8') } catch {} } +function line(level: string, message: string): string { + return `[${new Date().toISOString()}] [${level}] ${message}\n` +} + export const logger = { - info: (msg: string) => { console.log(msg); write('INFO', msg) }, - warn: (msg: string) => { console.warn(msg); write('WARN', msg) }, - error: (msg: string) => { console.error(msg); write('ERROR', msg) }, - python:(msg: string) => { write('PYTHON', msg) }, + info: (msg: string) => { console.log(msg); writeTo('modly.log', line('INFO', msg)) }, + warn: (msg: string) => { console.warn(msg); writeTo('modly.log', line('WARN', msg)) }, + error: (msg: string) => { console.error(msg); writeTo('modly.log', line('ERROR', msg)); writeTo('errors.log', line('ERROR', msg)) }, + python: (msg: string) => { + writeTo('runtime.log', line('RUNTIME', msg)) + if (/error|exception|traceback|critical/i.test(msg)) { + writeTo('errors.log', line('RUNTIME', msg)) + } + }, } diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts index aec69ae..2664f29 100644 --- a/electron/main/python-bridge.ts +++ b/electron/main/python-bridge.ts @@ -58,7 +58,7 @@ export class PythonBridge { MODELS_DIR: this.resolveModelsDir(), WORKSPACE_DIR: this.resolveWorkspaceDir(), EXTENSIONS_DIR: this.resolveExtensionsDir(), - SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? 'sf3d', + SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '', } }) diff --git a/electron/main/python-setup.ts b/electron/main/python-setup.ts index a4cf8f8..09c0fd4 100644 --- a/electron/main/python-setup.ts +++ b/electron/main/python-setup.ts @@ -2,12 +2,29 @@ import { BrowserWindow, app } from 'electron' import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs' import { join } from 'path' import { spawn, execSync } from 'child_process' +import { createHash } from 'crypto' -const SETUP_VERSION = 1 +const SETUP_VERSION = 2 const TOTAL_PACKAGES = 20 interface SetupJson { version: number + requirementsHash?: string +} + +function getRequirementsPath(): string { + return app.isPackaged + ? join(process.resourcesPath, 'api', 'requirements.txt') + : join(app.getAppPath(), 'api', 'requirements.txt') +} + +function hashRequirements(): string { + try { + const content = readFileSync(getRequirementsPath(), 'utf-8') + return createHash('sha256').update(content).digest('hex') + } catch { + return '' + } } // ─── Public helpers ────────────────────────────────────────────────────────── @@ -18,6 +35,7 @@ export function checkSetupNeeded(userData: string): boolean { try { const data = JSON.parse(readFileSync(jsonPath, 'utf-8')) as SetupJson if (data.version < SETUP_VERSION) return true + if (data.requirementsHash !== hashRequirements()) return true } catch { return true } @@ -30,7 +48,11 @@ export function checkSetupNeeded(userData: string): boolean { export function markSetupDone(userData: string): void { const jsonPath = join(userData, 'python_setup.json') - writeFileSync(jsonPath, JSON.stringify({ version: SETUP_VERSION }), 'utf-8') + writeFileSync( + jsonPath, + JSON.stringify({ version: SETUP_VERSION, requirementsHash: hashRequirements() }), + 'utf-8' + ) } /** Path to the venv Python executable created during setup (packaged Unix). */ @@ -181,9 +203,7 @@ function createVenv(python3: string, venvDir: string, win: BrowserWindow): Promi export async function runFullSetup(win: BrowserWindow, userData: string): Promise { try { - const requirementsPath = app.isPackaged - ? join(process.resourcesPath, 'api', 'requirements.txt') - : join(app.getAppPath(), 'api', 'requirements.txt') + const requirementsPath = getRequirementsPath() if (process.platform === 'win32') { // Windows: use embedded Python bundled with the app diff --git a/package.json b/package.json index 8a2af26..27de181 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.1.2", + "version": "0.1.3", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", diff --git a/src/App.tsx b/src/App.tsx index d78790e..ddd5e0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,23 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useAppStore } from '@shared/stores/appStore' import FirstRunSetup from '@areas/setup/FirstRunSetup' import MainLayout from '@shared/components/layout/MainLayout' +import { UpdateModal } from '@shared/components/ui/UpdateModal' + +function compareSemver(a: string, b: string): number { + const pa = a.replace(/^v/, '').split('.').map(Number) + const pb = b.replace(/^v/, '').split('.').map(Number) + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1 + if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1 + } + return 0 +} export default function App(): JSX.Element { const { checkSetup, setupStatus, initApp, backendStatus } = useAppStore() + const [updateVersion, setUpdateVersion] = useState(null) + const [currentVersion, setCurrentVersion] = useState('') useEffect(() => { checkSetup() @@ -14,6 +27,31 @@ export default function App(): JSX.Element { if (setupStatus === 'done') initApp() }, [setupStatus]) - if (backendStatus === 'ready') return + useEffect(() => { + if (backendStatus !== 'ready') return + window.electron.app.info().then(({ version }) => { + setCurrentVersion(version) + fetch('https://api.github.com/repos/lightningpixel/modly/releases/latest') + .then((r) => r.json()) + .then((data) => { + const latest = data?.tag_name as string | undefined + if (latest && compareSemver(latest, version) > 0) setUpdateVersion(latest) + }) + .catch(() => {}) + }) + }, [backendStatus]) + + if (backendStatus === 'ready') return ( + <> + + {updateVersion && ( + setUpdateVersion(null)} + /> + )} + + ) return } diff --git a/src/areas/generate/components/GenerationHUD.tsx b/src/areas/generate/components/GenerationHUD.tsx index 4034177..c3d980a 100644 --- a/src/areas/generate/components/GenerationHUD.tsx +++ b/src/areas/generate/components/GenerationHUD.tsx @@ -109,7 +109,7 @@ export default function GenerationHUD(): JSX.Element | null {
- {step ?? (status === 'uploading' ? 'Uploading image…' : 'Generating 3D mesh…')} + {step ?? (status === 'uploading' ? 'Reading image…' : 'Generating 3D mesh…')}
{formatElapsed(elapsed)} diff --git a/src/areas/generate/components/GenerationOptions.tsx b/src/areas/generate/components/GenerationOptions.tsx index f89a772..0cf0f18 100644 --- a/src/areas/generate/components/GenerationOptions.tsx +++ b/src/areas/generate/components/GenerationOptions.tsx @@ -167,7 +167,7 @@ export default function GenerationOptions(): JSX.Element { window.electron.model.listDownloaded() .then((list) => { setModels(list) - if (list.length > 0 && !list.find((m) => m.id === generationOptions.modelId)) { + if (list.length > 0 && (!generationOptions.modelId || !list.find((m) => m.id === generationOptions.modelId))) { setGenerationOptions({ modelId: list[0].id }) } }) diff --git a/src/areas/generate/components/GenerationPanel.tsx b/src/areas/generate/components/GenerationPanel.tsx index 8979f0a..d9f4c17 100644 --- a/src/areas/generate/components/GenerationPanel.tsx +++ b/src/areas/generate/components/GenerationPanel.tsx @@ -43,7 +43,7 @@ export default function GenerationPanel(): JSX.Element { const { status: jobStatus, progress, step, error } = currentJob const statusLabel: Record = { - uploading: 'Uploading image…', + uploading: 'Reading image…', generating: step ?? 'Generating 3D mesh…', done: 'Done!', error: 'Generation failed', diff --git a/src/areas/generate/components/ImageUpload.tsx b/src/areas/generate/components/ImageUpload.tsx index 245e68f..b10725d 100644 --- a/src/areas/generate/components/ImageUpload.tsx +++ b/src/areas/generate/components/ImageUpload.tsx @@ -4,7 +4,7 @@ import { useGeneration } from '@shared/hooks/useGeneration' export default function ImageUpload(): JSX.Element { const { currentJob } = useGeneration() - const { setSelectedImagePath, selectedImagePreviewUrl, setSelectedImagePreviewUrl } = useAppStore() + const { setSelectedImagePath, selectedImagePreviewUrl, setSelectedImagePreviewUrl, setSelectedImageData } = useAppStore() const [isDragging, setIsDragging] = useState(false) const isGenerating = currentJob?.status === 'uploading' || currentJob?.status === 'generating' @@ -12,6 +12,7 @@ export default function ImageUpload(): JSX.Element { const handleFileSelect = useCallback(async () => { const path = await window.electron.fs.selectImage() if (!path) return + setSelectedImageData(null) setSelectedImagePath(path) // Read via IPC → blob URL (file:// blocked when served from localhost in dev) @@ -19,17 +20,32 @@ export default function ImageUpload(): JSX.Element { const byteArray = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) const blob = new Blob([byteArray], { type: 'image/png' }) setSelectedImagePreviewUrl(URL.createObjectURL(blob)) - }, [setSelectedImagePath, setSelectedImagePreviewUrl]) + }, [setSelectedImagePath, setSelectedImagePreviewUrl, setSelectedImageData]) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files[0] if (!file || !file.type.startsWith('image/')) return - const url = URL.createObjectURL(file) - setSelectedImagePreviewUrl(url) - setSelectedImagePath((file as File & { path?: string }).path ?? null) - }, [setSelectedImagePath, setSelectedImagePreviewUrl]) + + setSelectedImagePreviewUrl(URL.createObjectURL(file)) + + const filePath = (file as File & { path?: string }).path + if (filePath) { + setSelectedImageData(null) + setSelectedImagePath(filePath) + } else { + // file.path unavailable (some Electron configs) — read directly via FileReader + const reader = new FileReader() + reader.onload = (ev) => { + const dataUrl = ev.target?.result as string + const base64 = dataUrl.split(',')[1] + setSelectedImageData(base64) + setSelectedImagePath('__blob__') + } + reader.readAsDataURL(file) + } + }, [setSelectedImagePath, setSelectedImagePreviewUrl, setSelectedImageData]) return (
diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 1123050..65d118d 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -31,6 +31,7 @@ export default function ModelsPage(): JSX.Element { const [models, setModels] = useState([]) const [downloading, setDownloading] = useState>({}) const [deleteTarget, setDeleteTarget] = useState(null) + const [deleteError, setDeleteError] = useState(null) const [uninstallTarget, setUninstallTarget] = useState(null) // GitHub extension install form @@ -64,8 +65,13 @@ export default function ModelsPage(): JSX.Element { }, [installError]) async function handleDelete(model: LocalModel) { - await window.electron.model.delete(model.id) + const result = await window.electron.model.delete(model.id) + if (!result.success) { + setDeleteError('Failed to delete the model. Try restarting the app and deleting again.') + return + } setDeleteTarget(null) + setDeleteError(null) refresh() } @@ -105,6 +111,8 @@ export default function ModelsPage(): JSX.Element { installProgress.step !== 'done' && installProgress.step !== 'error' + const isBusy = isInstalling || inProgressIds.length > 0 + function installProgressLabel(): string { if (!installProgress) return '' switch (installProgress.step) { @@ -256,6 +264,7 @@ export default function ModelsPage(): JSX.Element { ext={ext} installedIds={models.map((m) => m.id)} downloading={downloading} + disabled={isBusy} loadError={ loadErrors[ext.id] ?? ext.models.map((v) => loadErrors[v.id]).find(Boolean) @@ -311,6 +320,7 @@ export default function ModelsPage(): JSX.Element { setDeleteTarget(model)} onGenerate={() => { setGenerationOptions({ modelId: model.id }) @@ -327,12 +337,12 @@ export default function ModelsPage(): JSX.Element { {deleteTarget && ( handleDelete(deleteTarget)} - onCancel={() => setDeleteTarget(null)} + onCancel={() => { setDeleteTarget(null); setDeleteError(null) }} /> )} diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index 46fe807..d7f26c5 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -7,11 +7,12 @@ interface Props { installedIds: string[] downloading: Record loadError?: string + disabled?: boolean onInstall: (variant: ExtensionVariant) => void onUninstall: (extId: string) => void } -export function ExtensionCard({ ext, installedIds, downloading, loadError, onInstall, onUninstall }: Props): JSX.Element { +export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onUninstall }: Props): JSX.Element { return (
@@ -59,8 +60,9 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, onIns {/* Uninstall extension button */}
) : ( + +
+ +
+ + , + document.body + ) +} diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index ec8c3b1..9cd335c 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -10,9 +10,10 @@ export function useApi() { imagePath: string, options: GenerationOptions, collection: string = 'Default', + imageData?: string, ): Promise<{ jobId: string }> { - // Read file via IPC (avoids file:// restrictions in the renderer) - const base64 = await window.electron.fs.readFileBase64(imagePath) + // Use provided base64 (drag & drop) or read from disk via IPC + const base64 = imageData ?? await window.electron.fs.readFileBase64(imagePath) const byteArray = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) const blob = new Blob([byteArray], { type: 'image/png' }) const filename = imagePath.split(/[\\/]/).pop() ?? 'image.png' diff --git a/src/shared/hooks/useGeneration.ts b/src/shared/hooks/useGeneration.ts index 95d4179..e4c5970 100644 --- a/src/shared/hooks/useGeneration.ts +++ b/src/shared/hooks/useGeneration.ts @@ -4,7 +4,7 @@ import { useCollectionsStore } from '@shared/stores/collectionsStore' import { useApi } from './useApi' export function useGeneration() { - const { currentJob, setCurrentJob, updateCurrentJob, generationOptions } = useAppStore() + const { currentJob, setCurrentJob, updateCurrentJob, generationOptions, selectedImageData } = useAppStore() const addToWorkspace = useCollectionsStore((s) => s.addToWorkspace) const activeCollectionId = useCollectionsStore((s) => s.activeCollectionId) const { generateFromImage, pollJobStatus } = useApi() @@ -23,7 +23,7 @@ export function useGeneration() { setCurrentJob(job) try { - const { jobId } = await generateFromImage(imagePath, generationOptions, activeCollectionId) + const { jobId } = await generateFromImage(imagePath, generationOptions, activeCollectionId, selectedImageData ?? undefined) updateCurrentJob({ status: 'generating', progress: 0 }) diff --git a/src/shared/stores/appStore.ts b/src/shared/stores/appStore.ts index 8bb11bd..a8afa94 100644 --- a/src/shared/stores/appStore.ts +++ b/src/shared/stores/appStore.ts @@ -41,7 +41,7 @@ export interface GenerationOptions { } const DEFAULT_OPTIONS: GenerationOptions = { - modelId: 'sf3d', + modelId: '', vertexCount: 10000, remesh: 'quad', enableTexture: false, @@ -66,6 +66,8 @@ interface AppState { setSelectedImagePath: (path: string | null) => void selectedImagePreviewUrl: string | null setSelectedImagePreviewUrl: (url: string | null) => void + selectedImageData: string | null // base64 content for drag & drop (when path is unavailable) + setSelectedImageData: (data: string | null) => void // Generation options generationOptions: GenerationOptions @@ -135,6 +137,8 @@ export const useAppStore = create()( setSelectedImagePath: (path) => set({ selectedImagePath: path }), selectedImagePreviewUrl: null, setSelectedImagePreviewUrl: (url) => set({ selectedImagePreviewUrl: url }), + selectedImageData: null, + setSelectedImageData: (data) => set({ selectedImageData: data }), generationOptions: DEFAULT_OPTIONS, meshStats: null, setMeshStats: (stats) => set({ meshStats: stats }), diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 28f52ba..d80163c 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -41,7 +41,7 @@ declare global { listDownloaded: () => Promise<{ id: string; name: string; size_gb: number }[]> isDownloaded: (modelId: string) => Promise download: (repoId: string, modelId: string) => Promise<{ success: boolean; error?: string }> - delete: (modelId: string) => Promise + delete: (modelId: string) => Promise<{ success: boolean; error?: string }> onProgress: (cb: (data: { modelId: string; percent: number }) => void) => void offProgress: () => void } diff --git a/src/shared/ui/index.tsx b/src/shared/ui/index.tsx index 5eef227..9fba52d 100644 --- a/src/shared/ui/index.tsx +++ b/src/shared/ui/index.tsx @@ -110,9 +110,12 @@ export function Select({ value, onChange, options }: { ) } -export function LinkButton({ label }: { label: string }): JSX.Element { +export function LinkButton({ label, href }: { label: string; href?: string }): JSX.Element { + const handleClick = (): void => { + if (href) window.open(href, '_blank') + } return ( -