From 64a4330fd66e36336bbc9b5a92e74ab70ed6e862 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 14:09:38 +0200 Subject: [PATCH 01/22] fix(extension): manifest extensions error handle --- electron/main/python-bridge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts index 4e8d436..f0451ce 100644 --- a/electron/main/python-bridge.ts +++ b/electron/main/python-bridge.ts @@ -56,7 +56,7 @@ export class PythonBridge { MODELS_DIR: this.resolveModelsDir(), WORKSPACE_DIR: this.resolveWorkspaceDir(), EXTENSIONS_DIR: this.resolveExtensionsDir(), - SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '', + ...(process.env['SELECTED_MODEL_ID'] ? { SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] } : {}), HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(), HF_TOKEN: this.resolveHfToken(), } From af7c4a8f4eadbc9126d4081bdd3d80b95869bd2e Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 16:50:03 +0200 Subject: [PATCH 02/22] fix(architecture): terminate stale process runners on extension reinstall and reload --- electron/main/ipc-handlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 24cb9df..be5b3c3 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -701,6 +701,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const destDir = join(extensionsDir, manifest.id) if (existsSync(destDir)) { + terminateProcessRunner(manifest.id) await rmAsync(destDir, { recursive: true, force: true }) } await cp(extractDir, destDir, { recursive: true }) @@ -843,6 +844,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Trigger Python extension reload (without touching the filesystem) ipcMain.handle('extensions:reload', async () => { + terminateAllProcessRunners() try { const res = await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) return { success: true, errors: (res.data as { errors?: Record }).errors ?? {} } From 9b93b453bc3f6ce48af57bf8e616fa4cd0014877 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 16:42:31 +0200 Subject: [PATCH 03/22] fix(architecture): purge stale generation jobs to prevent memory leak --- api/routers/generation.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/routers/generation.py b/api/routers/generation.py index 2c4eb18..bdd0492 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -1,6 +1,7 @@ import asyncio import json import threading +import time import traceback import uuid from typing import Dict @@ -16,6 +17,19 @@ _jobs: Dict[str, JobStatus] = {} _cancelled: set = set() _cancel_events: Dict[str, threading.Event] = {} +_completed_at: Dict[str, float] = {} + +_JOB_TTL = 1800 # purge terminal jobs after 30 minutes + + +def _purge_old_jobs() -> None: + cutoff = time.monotonic() - _JOB_TTL + stale = [jid for jid, t in _completed_at.items() if t < cutoff] + for jid in stale: + _jobs.pop(jid, None) + _cancelled.discard(jid) + _cancel_events.pop(jid, None) + _completed_at.pop(jid, None) @router.post("/from-image") @@ -63,6 +77,8 @@ async def generate_from_image( **model_params, } + _purge_old_jobs() + job = JobStatus(job_id=job_id, status="pending", progress=0) _jobs[job_id] = job _cancel_events[job_id] = threading.Event() @@ -91,6 +107,7 @@ async def cancel_job(job_id: str): _cancel_events[job_id].set() if job.status in ("pending", "running"): job.status = "cancelled" + _completed_at[job_id] = time.monotonic() # Kill the active generator subprocess immediately so inference stops now. # _run_generation will catch the resulting exception, see job_id in _cancelled, # and return cleanly without setting an error status. @@ -162,6 +179,7 @@ def progress_cb(pct: int, step: str = "") -> None: job.status = "done" job.progress = 100 + _completed_at[job_id] = time.monotonic() try: rel = output_path.relative_to(WORKSPACE_DIR) job.output_url = f"/workspace/{rel.as_posix()}" @@ -170,6 +188,7 @@ def progress_cb(pct: int, step: str = "") -> None: except GenerationCancelled: job.status = "cancelled" + _completed_at[job_id] = time.monotonic() except Exception as exc: if job_id in _cancelled: return @@ -177,3 +196,4 @@ def progress_cb(pct: int, step: str = "") -> None: print(f"[Generation ERROR] {exc}\n{tb}") job.status = "error" job.error = tb.strip() + _completed_at[job_id] = time.monotonic() From a22c29535e607949f17962bc2b5534512f545475 Mon Sep 17 00:00:00 2001 From: Lorchie Date: Sun, 10 May 2026 17:29:45 +0200 Subject: [PATCH 04/22] Add Wait node and conditional param visibility --- .../generate/components/WorkflowPanel.tsx | 38 +++++++++++- src/areas/workflows/WorkflowsPage.tsx | 7 ++- src/areas/workflows/nodes/ExtensionNode.tsx | 12 +++- src/areas/workflows/nodes/WaitNode.tsx | 52 ++++++++++++++++ src/areas/workflows/workflowRunStore.ts | 59 +++++++++++++++++-- src/shared/types/electron.d.ts | 1 + 6 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 src/areas/workflows/nodes/WaitNode.tsx diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 0f23770..a6a98b2 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -330,6 +330,39 @@ function TextParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowN ) } +function WaitParamRow({ nodeId }: { nodeId: string }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === nodeId + + return ( +
+
+ + + + Wait +
+ {isPaused ? ( + + ) : ( +

+ Pauses the workflow until you click Continue. +

+ )} +
+ ) +} + function ExtensionParamRow({ nodeId, ext, nodes, onPatch }: { nodeId: string; ext: WorkflowExtension; nodes: FlowNode[]; onPatch: PatchFn }) { const [expanded, setExpanded] = useState(true) const node = nodes.find((n) => n.id === nodeId) @@ -411,7 +444,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { const { setCurrentJob } = useAppStore() const { runState, run, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' // Update AddToScene node when run completes useEffect(() => { @@ -455,7 +488,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { ) const paramNodes = sortedNodes.filter((n) => - (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode') + (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode' || n.type === 'waitNode') && (n.data as { showInGenerate?: boolean }).showInGenerate === true, ) @@ -476,6 +509,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { {node.type === 'imageNode' && } {node.type === 'textNode' && } {node.type === 'meshNode' && } + {node.type === 'waitNode' && } {node.type === 'extensionNode' && (() => { const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) return ext ? : null diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index e2575e6..05641f1 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -27,13 +27,14 @@ import TextNode from './nodes/TextNode' import AddToSceneNode from './nodes/AddToSceneNode' import Load3DMeshNode from './nodes/Load3DMeshNode' import PreviewImageNode from './nodes/PreviewImageNode' +import WaitNode from './nodes/WaitNode' import WorkflowEdge from './nodes/WorkflowEdge' // ─── Constants ──────────────────────────────────────────────────────────────── const DRAG_KEY = 'modly/extension-id' const DRAG_NODE_KEY = 'modly/node-type' -const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode } +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } const EDGE_TYPES = { workflowEdge: WorkflowEdge } const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } @@ -155,6 +156,7 @@ const PANEL_BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <> }, + { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <> }, ] function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { @@ -398,6 +400,7 @@ const BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, + { type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, ] type PaletteItem = @@ -778,7 +781,7 @@ function WorkflowCanvasInner({ }) { const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index d0498ed..692a467 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -152,6 +152,16 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data updateNodeData(id, { params: { ...data.params, [key]: val } }) }, [id, data.params, updateNodeData]) + const paramById = new Map(ext?.params.map((p) => [p.id, p])) + + const isVisible = (param: ParamSchema): boolean => { + if (!param.show_if) return true + return Object.entries(param.show_if).every(([key, expected]) => { + const current = data.params[key] ?? paramById.get(key)?.default + return Array.isArray(expected) ? expected.includes(current as string | number) : current === expected + }) + } + // ── IO subheader ───────────────────────────────────────────────────────── const ioSubheader = isMulti ? ( // Multi-input layout: one row per input, output on first row @@ -242,7 +252,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data > {hasParams && (
- {ext!.params.map((param) => { + {ext!.params.filter(isVisible).map((param) => { const val = (data.params[param.id] ?? param.default) as number | string return (
diff --git a/src/areas/workflows/nodes/WaitNode.tsx b/src/areas/workflows/nodes/WaitNode.tsx new file mode 100644 index 0000000..8e30302 --- /dev/null +++ b/src/areas/workflows/nodes/WaitNode.tsx @@ -0,0 +1,52 @@ +import { Handle, Position } from '@xyflow/react' +import type { WFNodeData } from '@shared/types/electron.d' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const HANDLE_STYLE = { background: '#71717a', width: 14, height: 14, border: '2.5px solid #18181b' } + +export default function WaitNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === id + + return ( + + + + + } + subheader={isPaused ? ( + + ) : undefined} + handles={ + <> + + + + } + > +
+

+ {isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'} +

+
+
+ ) +} diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 44b8c3e..5b86668 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -8,7 +8,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' // ─── Types ──────────────────────────────────────────────────────────────────── export interface WorkflowRunState { - status: 'idle' | 'running' | 'done' | 'error' + status: 'idle' | 'running' | 'paused' | 'done' | 'error' blockIndex: number blockTotal: number blockProgress: number @@ -25,6 +25,14 @@ const IDLE: WorkflowRunState = { // Module-level refs — survive component unmounts / navigation const _cancel = { current: false } const _activeJobId = { current: null as string | null } +const _resume = { current: null as (() => void) | null } + +function flushResume(): void { + const fn = _resume.current + if (!fn) return + _resume.current = null + fn() +} // ─── Topological sort ───────────────────────────────────────────────────────── @@ -60,9 +68,10 @@ interface WorkflowRunStore { /** nodeId → workspace URL for image outputs (populated after each run) */ nodeImageOutputs: Record - run: (workflow: Workflow, allExtensions: WorkflowExtension[]) => Promise - cancel: () => void - reset: () => void + run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise + cancel: () => void + reset: () => void + continueRun: () => void } export const useWorkflowRunStore = create((set) => ({ @@ -77,7 +86,9 @@ export const useWorkflowRunStore = create((set) => ({ const appState = useAppStore.getState() const apiUrl = appState.apiUrl const ordered = topoSort(workflow.nodes, workflow.edges) - const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled) + const execNodes = ordered.filter((n) => + (n.type === 'extensionNode' || n.type === 'waitNode') && n.data.enabled, + ) const selectedImagePath = appState.selectedImagePath ?? '' const selectedImageData = appState.selectedImageData ?? undefined @@ -108,6 +119,7 @@ export const useWorkflowRunStore = create((set) => ({ // nodeId → { filePath, text, outputType } const nodeOutputs = new Map() + const outputNodeIds = new Set(ordered.filter((n) => n.type === 'outputNode').map((n) => n.id)) // Pre-populate source nodes for (const node of ordered) { @@ -182,6 +194,21 @@ export const useWorkflowRunStore = create((set) => ({ runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' }, })) + // ── Wait node → pause until continueRun(), then passthrough ─────── + if (node.type === 'waitNode') { + set((s) => ({ runState: { ...s.runState, status: 'paused', blockStep: 'Paused — click Continue' } })) + await new Promise((resolve) => { _resume.current = resolve }) + if (_cancel.current) { set({ runState: IDLE, activeNodeId: null }); return } + + nodeOutputs.set(node.id, { + filePath: nodeInputPath, + text: nodeInputText, + outputType: incomingEdges[0] ? nodeOutputs.get(incomingEdges[0].source)?.outputType : undefined, + }) + set((s) => ({ runState: { ...s.runState, status: 'running' } })) + continue + } + // ── Model extensions → HTTP API ─────────────────────────────────── // Process extensions → IPC runProcess const isModelNode = ext?.type === 'model' @@ -272,6 +299,20 @@ export const useWorkflowRunStore = create((set) => ({ // Store output with type for downstream routing const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType }) + + // If this node feeds an Add-to-Scene, push the mesh to currentJob + // immediately so the 3D viewer loads it without waiting for the rest of the run. + const norm = nodeInputPath?.replace(/\\/g, '/') + if ( + norm?.startsWith(workspaceDir) && + workflow.edges.some((e) => e.source === node.id && outputNodeIds.has(e.target)) + ) { + useAppStore.getState().updateCurrentJob({ + status: 'done', + progress: 100, + outputUrl: `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}`, + }) + } } // ── Collect image outputs for preview nodes ─────────────────────── @@ -289,7 +330,8 @@ export const useWorkflowRunStore = create((set) => ({ let outputUrl: string | undefined let outputPath: string | undefined - const outputNodeDef = ordered.find((n) => n.type === 'outputNode') + // Use the last AddToScene in topo order — its predecessor is the final scene mesh. + const outputNodeDef = [...ordered].reverse().find((n) => n.type === 'outputNode') if (outputNodeDef) { for (const edge of workflow.edges.filter((e) => e.target === outputNodeDef.id)) { const src = nodeOutputs.get(edge.source) @@ -340,6 +382,7 @@ export const useWorkflowRunStore = create((set) => ({ cancel() { _cancel.current = true + flushResume() if (_activeJobId.current) { const apiUrl = useAppStore.getState().apiUrl axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {}) @@ -351,4 +394,8 @@ export const useWorkflowRunStore = create((set) => ({ reset() { set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) }, + + continueRun() { + flushResume() + }, })) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index acb21ad..f5d5ca8 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -38,6 +38,7 @@ export interface ParamSchema { max?: number step?: number tooltip?: string + show_if?: Record } export interface ProcessExtension { From dde0bbb64468feaf3b6d498e14c714f9de1fcdcf Mon Sep 17 00:00:00 2001 From: DrHepa Date: Sun, 10 May 2026 23:35:56 +0200 Subject: [PATCH 05/22] fix(extensions): harden extension path handling --- electron/main/extension-path-guard.test.ts | 52 +++++++++++++++++++++ electron/main/extension-path-guard.ts | 53 ++++++++++++++++++++++ electron/main/ipc-handlers.ts | 25 ++++++---- 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 electron/main/extension-path-guard.test.ts create mode 100644 electron/main/extension-path-guard.ts diff --git a/electron/main/extension-path-guard.test.ts b/electron/main/extension-path-guard.test.ts new file mode 100644 index 0000000..91d9170 --- /dev/null +++ b/electron/main/extension-path-guard.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' + +async function loadGuard() { + return import(new URL('./extension-path-guard.ts', import.meta.url).href) +} + +test('assertSafeExtensionId accepts conservative extension ids', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.equal(assertSafeExtensionId('mesh-process'), 'mesh-process') + assert.equal(assertSafeExtensionId('image_model.v2'), 'image_model.v2') + assert.equal(assertSafeExtensionId('kimodo-soma-rp'), 'kimodo-soma-rp') +}) + +test('assertSafeExtensionId rejects empty, traversal and absolute ids', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.throws(() => assertSafeExtensionId(''), /must not be empty/i) + assert.throws(() => assertSafeExtensionId('.'), /is invalid/i) + assert.throws(() => assertSafeExtensionId('..'), /is invalid/i) + assert.throws(() => assertSafeExtensionId('../escape'), /path separators/i) + assert.throws(() => assertSafeExtensionId('..\\escape'), /path separators/i) + assert.throws(() => assertSafeExtensionId('/abs/path'), /absolute path|path separators/i) +}) + +test('assertSafeExtensionId rejects uppercase and unsupported characters', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.throws(() => assertSafeExtensionId('Mesh-Process'), /must match/i) + assert.throws(() => assertSafeExtensionId('bad id'), /must match/i) + assert.throws(() => assertSafeExtensionId('bad:id'), /must match/i) +}) + +test('resolveExtensionPathWithinRoot confines paths to root', async () => { + const { resolveExtensionPathWithinRoot } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(resolveExtensionPathWithinRoot(root, 'mesh-process'), path.resolve(root, 'mesh-process')) + assert.throws(() => resolveExtensionPathWithinRoot(root, '../escape'), /path separators/i) +}) + +test('resolvePathWithinRoot rejects canonical escapes', async () => { + const { resolvePathWithinRoot } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(resolvePathWithinRoot(root, '.modly-backup-safe-123'), path.resolve(root, '.modly-backup-safe-123')) + assert.throws(() => resolvePathWithinRoot(root, '../escape'), /escapes root/i) +}) + +test('buildExtensionBackupPath stays within root and rejects unsafe ids', async () => { + const { buildExtensionBackupPath } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(buildExtensionBackupPath(root, 'mesh-process', '123'), path.resolve(root, '.modly-backup-mesh-process-123')) + assert.throws(() => buildExtensionBackupPath(root, '../escape', '123'), /path separators/i) +}) diff --git a/electron/main/extension-path-guard.ts b/electron/main/extension-path-guard.ts new file mode 100644 index 0000000..717dbf3 --- /dev/null +++ b/electron/main/extension-path-guard.ts @@ -0,0 +1,53 @@ +import { isAbsolute, relative, resolve as resolvePath } from 'node:path' + +const EXTENSION_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/ + +export function assertSafeExtensionId(extensionId: unknown): string { + if (typeof extensionId !== 'string') { + throw new Error('Extension id must be a string') + } + + const trimmed = extensionId.trim() + if (!trimmed) { + throw new Error('Extension id must not be empty') + } + + if (trimmed === '.' || trimmed === '..') { + throw new Error(`Extension id "${extensionId}" is invalid`) + } + + if (isAbsolute(trimmed)) { + throw new Error(`Extension id "${extensionId}" must not be an absolute path`) + } + + if (trimmed.includes('/') || trimmed.includes('\\')) { + throw new Error(`Extension id "${extensionId}" must not contain path separators`) + } + + if (!EXTENSION_ID_PATTERN.test(trimmed)) { + throw new Error(`Extension id "${extensionId}" must match ${EXTENSION_ID_PATTERN}`) + } + + return trimmed +} + +export function resolvePathWithinRoot(rootDir: string, unsafeLeaf: string): string { + const resolvedRoot = resolvePath(rootDir) + const resolvedCandidate = resolvePath(resolvedRoot, unsafeLeaf) + const normalizedRelative = relative(resolvedRoot, resolvedCandidate).replace(/\\/g, '/') + + if (normalizedRelative === '..' || normalizedRelative.startsWith('../') || isAbsolute(normalizedRelative)) { + throw new Error(`Resolved path escapes root: ${unsafeLeaf}`) + } + + return resolvedCandidate +} + +export function resolveExtensionPathWithinRoot(rootDir: string, extensionId: unknown): string { + return resolvePathWithinRoot(rootDir, assertSafeExtensionId(extensionId)) +} + +export function buildExtensionBackupPath(rootDir: string, extensionId: unknown, suffix: string): string { + const safeId = assertSafeExtensionId(extensionId) + return resolvePathWithinRoot(rootDir, `.modly-backup-${safeId}-${suffix}`) +} diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index be5b3c3..06579cc 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -18,6 +18,7 @@ import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn } from 'child_process' +import { assertSafeExtensionId, resolveExtensionPathWithinRoot } from './extension-path-guard' type WindowGetter = () => BrowserWindow | null @@ -674,6 +675,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const manifest = JSON.parse(manifestRaw) as ParsedManifest if (!manifest.id) throw new Error('manifest.json: required field "id" missing') + const extensionId = assertSafeExtensionId(manifest.id) + manifest.id = extensionId if (!manifest.nodes?.length) throw new Error('manifest.json: required field "nodes" missing or empty') const isProcess = manifest.type === 'process' @@ -698,10 +701,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // 5. Copy to extensions directory (overwrite if already present) const extensionsDir = getSettings(app.getPath('userData')).extensionsDir await mkdir(extensionsDir, { recursive: true }) - const destDir = join(extensionsDir, manifest.id) + const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) if (existsSync(destDir)) { - terminateProcessRunner(manifest.id) + terminateProcessRunner(extensionId) await rmAsync(destDir, { recursive: true, force: true }) } await cp(extractDir, destDir, { recursive: true }) @@ -783,11 +786,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } catch { /* Python might not be running yet */ } } - emit({ step: 'done', extensionId: manifest.id }) + emit({ step: 'done', extensionId }) const trustedRepos = await fetchTrustedRepos() - const ext = parseExtensionManifest(manifest, manifest.id, trustedRepos) - return { success: true, extensionId: manifest.id, extension: ext } + const ext = parseExtensionManifest(manifest, extensionId, trustedRepos) + return { success: true, extensionId, extension: ext } } catch (err) { emit({ step: 'error', message: String(err) }) @@ -802,16 +805,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Uninstall an extension — built-ins cannot be uninstalled ipcMain.handle('extensions:uninstall', async (_, extensionId: string) => { const userData = app.getPath('userData') - const builtinPath = join(getBuiltinExtensionsDir(), extensionId) + const safeExtensionId = assertSafeExtensionId(extensionId) + const builtinPath = resolveExtensionPathWithinRoot(getBuiltinExtensionsDir(), safeExtensionId) if (existsSync(builtinPath)) { - return { success: false, error: `"${extensionId}" is a built-in extension and cannot be uninstalled.` } + return { success: false, error: `"${safeExtensionId}" is a built-in extension and cannot be uninstalled.` } } const extensionsDir = getSettings(userData).extensionsDir - const extPath = join(extensionsDir, extensionId) + const extPath = resolveExtensionPathWithinRoot(extensionsDir, safeExtensionId) try { // Terminate process runner if it's a process extension - terminateProcessRunner(extensionId) + terminateProcessRunner(safeExtensionId) await rmAsync(extPath, { recursive: true, force: true }) // Hot-reload Python so it stops using the deleted model extension @@ -827,7 +831,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Re-run setup.py for a model extension (creates the venv if missing) ipcMain.handle('extensions:repair', async (_, extensionId: string) => { try { - const extDir = join(getSettings(app.getPath('userData')).extensionsDir, extensionId) + const safeExtensionId = assertSafeExtensionId(extensionId) + const extDir = resolveExtensionPathWithinRoot(getSettings(app.getPath('userData')).extensionsDir, safeExtensionId) if (!existsSync(join(extDir, 'setup.py'))) { return { success: false, error: 'No setup.py found for this extension' } } From f02b4b9ecd9b626267003774fef16b2dbf77f38e Mon Sep 17 00:00:00 2001 From: DrHepa Date: Sun, 10 May 2026 23:38:48 +0200 Subject: [PATCH 06/22] fix(extensions): rollback failed GitHub installs --- electron/main/ipc-handlers.ts | 44 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 06579cc..757b566 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -18,7 +18,7 @@ import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn } from 'child_process' -import { assertSafeExtensionId, resolveExtensionPathWithinRoot } from './extension-path-guard' +import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard' type WindowGetter = () => BrowserWindow | null @@ -702,10 +702,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const extensionsDir = getSettings(app.getPath('userData')).extensionsDir await mkdir(extensionsDir, { recursive: true }) const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) + const backupDir = existsSync(destDir) ? buildExtensionBackupPath(extensionsDir, extensionId, String(Date.now())) : null - if (existsSync(destDir)) { + try { + if (backupDir) { terminateProcessRunner(extensionId) - await rmAsync(destDir, { recursive: true, force: true }) + await rename(destDir, backupDir) } await cp(extractDir, destDir, { recursive: true }) @@ -730,15 +732,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (existsSync(join(destDir, 'setup.py'))) { emit({ step: 'setting_up', message: 'Setting up Python environment…' }) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - try { - await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { - logger.info(`[ext-setup] ${line}`) - emit({ step: 'setting_up', message: line }) - }) - } catch (err) { - logger.warn(`[ext-setup] setup.py failed: ${err}`) - emit({ step: 'setting_up', message: `Warning: setup failed — ${err}` }) - } + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) } } else if (isProcess) { // 6b. JS process extension: npm install if package.json present @@ -771,14 +768,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (existsSync(join(destDir, 'setup.py'))) { emit({ step: 'setting_up', message: 'Setting up Python environment…' }) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - try { - await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { - logger.info(`[ext-setup] ${line}`) - emit({ step: 'setting_up', message: line }) - }) - } catch (setupErr: any) { - throw new Error(`Extension setup failed: ${setupErr?.message ?? setupErr}`) - } + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) } try { @@ -786,6 +779,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } catch { /* Python might not be running yet */ } } + if (backupDir) { + await rmAsync(backupDir, { recursive: true, force: true }) + } + } catch (installErr) { + await rmAsync(destDir, { recursive: true, force: true }).catch(() => {}) + if (backupDir && existsSync(backupDir)) { + await rename(backupDir, destDir) + } + throw installErr + } + emit({ step: 'done', extensionId }) const trustedRepos = await fetchTrustedRepos() From bb482e61f8e53661b9313f79db65379b2cd42aca Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Mon, 11 May 2026 09:34:43 +0200 Subject: [PATCH 07/22] dump version 0.3.6 --- api/main.py | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/main.py b/api/main.py index 586763b..1404bec 100644 --- a/api/main.py +++ b/api/main.py @@ -31,7 +31,7 @@ def filter(self, record): app = FastAPI( title="Modly API", - version="0.3.5", + version="0.3.6", lifespan=lifespan, ) diff --git a/package.json b/package.json index c81592a..c323a0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.3.5", + "version": "0.3.6", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", From 54285c78504f15fb744606d30cd83da83bc6773b Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Tue, 12 May 2026 10:40:13 +0200 Subject: [PATCH 08/22] Add social media links for Modly and Lightning Pixel Added links to Modly and Lightning Pixel on X for updates. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 50738c1..ecc740d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with the latest news, report bugs, and share feedback. +Follow Modly and its development on X: + +- [Modly on X]((https://x.com/modly3d)) +- [Lightning Pixel on X](https://x.com/lightningpiixel) + --- ## License From a9334df0e6337e6ce67c21df10826ce88af40a7c Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Tue, 12 May 2026 10:40:39 +0200 Subject: [PATCH 09/22] Fix link formatting for Modly on X --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ecc740d..033b6cc 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with Follow Modly and its development on X: -- [Modly on X]((https://x.com/modly3d)) +- [Modly on X](https://x.com/modly3d) - [Lightning Pixel on X](https://x.com/lightningpiixel) --- From 091a808e2ab73f17a5bcdbf8d46746b3abb35c39 Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:42:01 +0200 Subject: [PATCH 10/22] Add sponsors section to README Added a sponsors section to acknowledge early supporters. --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 033b6cc..b05a1ca 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,34 @@ Follow Modly and its development on X: --- +## Sponsors + +

+ Thanks to our early sponsors for believing in Modly and helping make local AI 3D generation more accessible. +

+ +

+ + DrHepa +
+ DrHepa +
+    + + benjapenjamin +
+ benjapenjamin +
+    + + iammojogo-sudo +
+ iammojogo-sudo +
+

+ +--- + ## License MIT License — see [LICENSE](LICENSE) for details. From dc63471b122b060baf35939200842546718fe326 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:21:19 -0400 Subject: [PATCH 11/22] Implement path traversal protection in optimize.py Added input path validation to prevent path traversal attacks. --- api/routers/optimize.py | 337 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/api/routers/optimize.py b/api/routers/optimize.py index db7152c..3a0d1a2 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -4,6 +4,343 @@ import tempfile import uuid +try: + import pymeshlab as _pymeshlab + _PYMESHLAB_AVAILABLE = True +except ImportError: + _pymeshlab = None + _PYMESHLAB_AVAILABLE = False + +import numpy as np +import trimesh +import trimesh.visual +from fastapi import APIRouter, HTTPException, UploadFile, File +from fastapi.responses import FileResponse, Response +from pathlib import Path +from urllib.parse import quote +from pydantic import BaseModel + +from services.generator_registry import WORKSPACE_DIR + +router = APIRouter(tags=["optimize"]) + + +class OptimizeRequest(BaseModel): + path: str # format: "{collection}/{filename}" + target_faces: int + + +class SmoothRequest(BaseModel): + path: str # format: "{collection}/{filename}" + iterations: int + + +class TransformRequest(BaseModel): + path: str # format: "{collection}/{filename}" + matrix: list[list[float]] # row-major 4x4 world transform + + +def _require_pymeshlab(): + if not _PYMESHLAB_AVAILABLE: + raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)") + + +def _resolve_input_path(raw_path: str) -> Path: + candidate = Path(raw_path) + if candidate.is_absolute(): + resolved = candidate.resolve() + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + resolved = (WORKSPACE_DIR / raw_path).resolve() + if not str(resolved).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + +@router.post("/mesh") +def optimize_mesh(body: OptimizeRequest): + _require_pymeshlab() + target_faces = max(100, min(500_000, body.target_faces)) + + input_path = _resolve_input_path(body.path) + + tmp_dir = tempfile.mkdtemp() + try: + result = _decimate(str(input_path), target_faces, tmp_dir) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + stem = input_path.stem + output_name = f"{stem}_opt{target_faces}.glb" + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name + result.export(str(output_path)) + + face_count = len(result.faces) + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}", "face_count": face_count} + + +def _has_texture(geom: trimesh.Trimesh) -> bool: + if not isinstance(geom.visual, trimesh.visual.TextureVisuals): + return False + mat = geom.visual.material + if mat is None: + return False + # Simple material (SimpleMaterial / Material) + if getattr(mat, "image", None) is not None: + return True + # PBR material (from Trellis2 SLaT texturing and GLB imports) + if getattr(mat, "baseColorTexture", None) is not None: + return True + return False + + +def _get_texture_image(geom: trimesh.Trimesh): + """Return the base color texture image regardless of material type.""" + mat = geom.visual.material + img = getattr(mat, "image", None) + if img is not None: + return img + return getattr(mat, "baseColorTexture", None) + + +def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trimesh: + loaded = trimesh.load(input_path) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + geom = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + geom = loaded + + ms = _pymeshlab.MeshSet() + + if _has_texture(geom): + # ── Textured path: OBJ intermediate to preserve UV coordinates ────── + obj_in = os.path.join(tmp_dir, "input.obj") + mtl_in = os.path.join(tmp_dir, "input.mtl") + tex_in = os.path.join(tmp_dir, "texture.png") + obj_out = os.path.join(tmp_dir, "output.obj") + + # Save texture image under a known filename (handles PBR and simple materials) + _get_texture_image(geom).save(tex_in) + + # Export OBJ (trimesh writes UV coords + MTL) + geom.export(obj_in) + + # Patch MTL so any map_Kd points to our known texture filename + if os.path.exists(mtl_in): + mtl = open(mtl_in).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_in, "w").write(mtl) + + ms.load_new_mesh(obj_in) + ms.meshing_decimation_quadric_edge_collapse( + targetfacenum=target_faces, + preservetexcoord=True, # ← keeps UV coordinates intact + preservenormal=True, + preservetopology=True, + autoclean=True, + ) + ms.save_current_mesh(obj_out) + + # Patch output MTL too, so trimesh can find the texture on load + mtl_out = obj_out.replace(".obj", ".mtl") + if os.path.exists(mtl_out): + mtl = open(mtl_out).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_out, "w").write(mtl) + + return trimesh.load(obj_out) + + else: + # ── Geometry-only path: PLY (fast, no texture to worry about) ──────── + ply_in = os.path.join(tmp_dir, "input.ply") + ply_out = os.path.join(tmp_dir, "output.ply") + + geom.export(ply_in) + ms.load_new_mesh(ply_in) + ms.meshing_decimation_quadric_edge_collapse( + targetfacenum=target_faces, + preservenormal=True, + preservetopology=True, + autoclean=True, + ) + ms.save_current_mesh(ply_out) + return trimesh.load(ply_out, force="mesh") + + +@router.post("/smooth") +def smooth_mesh(body: SmoothRequest): + _require_pymeshlab() + iterations = max(1, min(20, body.iterations)) + + input_path = _resolve_input_path(body.path) + + tmp_dir = tempfile.mkdtemp() + try: + result = _smooth(str(input_path), iterations, tmp_dir) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + stem = input_path.stem + output_name = f"{stem}_smooth{iterations}.glb" + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name + result.export(str(output_path)) + + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}"} + + +def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh: + loaded = trimesh.load(input_path) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + geom = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + geom = loaded + + ms = _pymeshlab.MeshSet() + + if _has_texture(geom): + obj_in = os.path.join(tmp_dir, "input.obj") + mtl_in = os.path.join(tmp_dir, "input.mtl") + tex_in = os.path.join(tmp_dir, "texture.png") + obj_out = os.path.join(tmp_dir, "output.obj") + + _get_texture_image(geom).save(tex_in) + geom.export(obj_in) + + if os.path.exists(mtl_in): + mtl = open(mtl_in).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_in, "w").write(mtl) + + ms.load_new_mesh(obj_in) + ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations) + ms.save_current_mesh(obj_out) + + mtl_out = obj_out.replace(".obj", ".mtl") + if os.path.exists(mtl_out): + mtl = open(mtl_out).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_out, "w").write(mtl) + + return trimesh.load(obj_out) + + else: + ply_in = os.path.join(tmp_dir, "input.ply") + ply_out = os.path.join(tmp_dir, "output.ply") + + geom.export(ply_in) + ms.load_new_mesh(ply_in) + ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations) + ms.save_current_mesh(ply_out) + return trimesh.load(ply_out, force="mesh") + + +@router.post("/transform") +def transform_mesh(body: TransformRequest): + input_path = (WORKSPACE_DIR / body.path).resolve() + if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not input_path.exists(): + raise HTTPException(404, f"File not found: {body.path}") + + matrix = np.asarray(body.matrix, dtype=float) + if matrix.shape != (4, 4): + raise HTTPException(400, "matrix must be a 4x4 array") + if not np.all(np.isfinite(matrix)): + raise HTTPException(400, "matrix contains non-finite values") + + loaded = trimesh.load(str(input_path)) + loaded.apply_transform(matrix) + + stem = input_path.stem + output_name = f"{stem}_xf.glb" + output_path = input_path.parent / output_name + loaded.export(str(output_path)) + + collection_name = body.path.split("/")[0] + return {"url": f"/workspace/{collection_name}/{output_name}"} + + +class ImportByPathRequest(BaseModel): + path: str # absolute path on disk + + +@router.post("/import-by-path") +async def import_mesh_by_path(body: ImportByPathRequest): + file_path = Path(body.path) + if not file_path.is_file(): + raise HTTPException(400, "File not found") + + ext = file_path.suffix.lstrip(".").lower() + if ext not in ("glb", "obj", "stl", "ply"): + raise HTTPException(400, f"Unsupported format: {ext}") + + if ext == "glb": + # Serve the original file directly — no copy + return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"} + + # Non-GLB: convert to GLB in a temp directory (not the workspace) + tmp_dir = tempfile.mkdtemp(prefix="modly_import_") + output_path = os.path.join(tmp_dir, "mesh.glb") + loaded = trimesh.load(str(file_path)) + loaded.export(output_path) + return {"url": f"/optimize/serve-file?path={quote(output_path)}"} + + +@router.get("/serve-file") +def serve_file(path: str): + file_path = Path(path) + if not file_path.is_file(): + raise HTTPException(404, "File not found") + if file_path.suffix.lower() != ".glb": + raise HTTPException(400, "Only GLB files can be served") + return FileResponse(str(file_path), media_type="model/gltf-binary") + + +@router.get("/export") +def export_mesh(path: str, format: str): + if format not in ("obj", "stl", "ply"): + raise HTTPException(400, "Supported formats: obj, stl, ply") + + input_path = (WORKSPACE_DIR / path).resolve() + if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not input_path.exists(): + raise HTTPException(404, f"File not found: {path}") + + loaded = trimesh.load(str(input_path)) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + mesh = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + mesh = loaded + + data = mesh.export(file_type=format) + stem = input_path.stem + mime = "text/plain" if format == "obj" else "application/octet-stream" + # trimesh exports ply as bytes even in text mode — octet-stream is fine for all binary formats + return Response( + content=data, + media_type=mime, + headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'}, + ) +import os +import re +import shutil +import tempfile +import uuid + try: import pymeshlab as _pymeshlab _PYMESHLAB_AVAILABLE = True From a0ba16ca9552f3575e7eec70d8ac5d2cf67981ac Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:24:08 -0400 Subject: [PATCH 12/22] Add transformMesh function to useApi hook --- src/shared/hooks/useApi.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index 81f0c42..ec9be32 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -111,10 +111,21 @@ export function useApi() { return { url: data.url } } - async function importMesh(filePath: string): Promise<{ url: string }> { - const { data } = await client.post<{ url: string }>('/optimize/import-by-path', { path: filePath }) - return { url: data.url } - } - - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh } + async function importMesh(filePath: string): Promise<{ url: string }> { + const { data } = await client.post<{ url: string }>('/optimize/import-by-path', { path: filePath }) + return { url: data.url } + } + + async function transformMesh( + path: string, + matrix: number[][], + ): Promise<{ url: string }> { + const { data } = await client.post<{ url: string }>('/optimize/transform', { + path, + matrix, + }) + return { url: data.url } + } + + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } } From 0ad7b2c6a219f831651de49c41700fd144d6029e Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:24:50 -0400 Subject: [PATCH 13/22] Add GizmoMode type for transformation modes --- src/areas/generate/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/areas/generate/models.ts b/src/areas/generate/models.ts index c7e704a..3dd2363 100644 --- a/src/areas/generate/models.ts +++ b/src/areas/generate/models.ts @@ -1,3 +1,5 @@ export type ViewMode = 'solid' | 'wireframe' | 'normals' | 'matcap' | 'uv' +export type GizmoMode = 'translate' | 'rotate' | 'scale' + export type CatalogModel = { id: string; name: string } From c8bb9c8c5b3407b3d1819295a2eeb4a9d7810759 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:25:30 -0400 Subject: [PATCH 14/22] Add GizmoMode and related props to ViewerToolbar --- .../generate/components/ViewerToolbar.tsx | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/areas/generate/components/ViewerToolbar.tsx b/src/areas/generate/components/ViewerToolbar.tsx index 78d97ab..635eb03 100644 --- a/src/areas/generate/components/ViewerToolbar.tsx +++ b/src/areas/generate/components/ViewerToolbar.tsx @@ -1,12 +1,17 @@ -import type { ViewMode } from '../models' -export type { ViewMode } +import type { ViewMode, GizmoMode } from '../models' +export type { ViewMode, GizmoMode } interface ViewerToolbarProps { viewMode: ViewMode autoRotate: boolean + gizmoMode: GizmoMode | null + gizmoBusy: boolean onViewMode: (mode: ViewMode) => void onAutoRotate: () => void onScreenshot: () => void + onGizmoMode: (mode: GizmoMode) => void + onApplyTransform: () => void + onResetTransform: () => void } const MODES: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [ @@ -66,12 +71,52 @@ const MODES: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [ }, ] + +const GIZMOS: { mode: GizmoMode; key: string; label: string; icon: React.ReactNode }[] = [ + { + mode: 'translate', + key: 'W', + label: 'Move (W)', + icon: ( + + + + ), + }, + { + mode: 'rotate', + key: 'E', + label: 'Rotate (E)', + icon: ( + + + + + ), + }, + { + mode: 'scale', + key: 'R', + label: 'Scale (R)', + icon: ( + + + + ), + }, +] + export function ViewerToolbar({ viewMode, autoRotate, + gizmoMode, + gizmoBusy, onViewMode, onAutoRotate, onScreenshot, + onGizmoMode, + onApplyTransform, + onResetTransform, }: ViewerToolbarProps): JSX.Element { return (
@@ -84,8 +129,37 @@ export function ViewerToolbar({ > {icon} +))} + +
+ + {GIZMOS.map(({ mode, icon, label }) => ( + onGizmoMode(mode)} + > + {icon} + ))} + {gizmoMode && ( + <> + + + + + + + + + + + + + )} +
void children: React.ReactNode + disabled?: boolean } -function ToolbarButton({ active, label, onClick, children }: ToolbarButtonProps): JSX.Element { +function ToolbarButton({ active, label, onClick, children, disabled = false }: ToolbarButtonProps): JSX.Element { return ( + + {/* Import */} + + +
+ + {/* Undo */} + + + {/* Redo */} + + +
+ + {/* Name input */} + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} + placeholder="Workflow name…" + className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700/80 rounded-md px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" + /> + +
+ +
+ {/* Run / Stop */} + + + {/* Progress indicator */} + {isRunning && ( +
+ + + + {runState.blockStep} +
+ )} + + {/* Export */} + + + {/* Delete */} + + + {/* Help */} + +
+
+ + {helpOpen && setHelpOpen(false)} />} + + {/* React Flow canvas */} +
+ + {/* No model node warning */} + {!nodes.some((n) => n.type === 'extensionNode' && allExtensions.find((e) => e.id === (n.data as WFNodeData).extensionId && e.type === 'model')) && ( +
+
+ + + + No AI model node in this workflow — add one from the extensions panel to generate a 3D mesh. +
+
+ )} + + {/* Floating panel toggle — over the canvas, below the header */} + + + { e.preventDefault(); setEdges((eds) => eds.filter((ed) => ed.id !== edge.id)) }} + defaultEdgeOptions={DEFAULT_EDGE_OPTS} + deleteKeyCode="Delete" + connectionLineStyle={{ stroke: '#71717a', strokeWidth: 1.5 }} + fitView + fitViewOptions={{ padding: 0.3 }} + proOptions={{ hideAttribution: true }} + className="bg-[#0f0f10]" + > + + +
+
+ ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function WorkflowsPage(): JSX.Element { + const { workflows, loading, activeId, load, save, remove, importFile, exportFile, setActive } = useWorkflowsStore() + const { modelExtensions, processExtensions, loadExtensions } = useExtensionsStore() + + const [panelOpen, setPanelOpen] = useState(true) + + const allExtensions = useMemo( + () => buildAllWorkflowExtensions(modelExtensions, processExtensions), + [modelExtensions, processExtensions], + ) + + useEffect(() => { load(); loadExtensions() }, []) + + // Auto-select first workflow when none is active or the active id no longer exists + useEffect(() => { + if (loading) return + if (workflows.length === 0) return + if (activeId && workflows.find((w) => w.id === activeId)) return + setActive(workflows[0].id) + }, [workflows, loading, activeId]) + + const activeWorkflow = workflows.find((w) => w.id === activeId) ?? null + + async function handleCreateBlank() { + const wf = newWorkflow() + await save(wf) + setActive(wf.id) + } + + async function handleImport() { + const result = await importFile() + if (result.success && result.workflow) setActive((result.workflow as Workflow).id) + } + + return ( +
+ + {/* Tab bar */} + {!loading && ( +
+ {workflows.map((wf) => ( +
setActive(wf.id)} + onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); remove(wf.id) } }} + className={`relative flex items-center gap-1.5 pl-3 pr-1.5 h-full text-[11px] font-medium shrink-0 transition-colors border-b-2 cursor-pointer group + ${wf.id === activeId + ? 'text-zinc-100 border-accent bg-zinc-900/50' + : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20 border-transparent' + }`} + > + {wf.name || 'Untitled'} + +
+ ))} +
+ )} + + {/* Canvas + extensions panel */} +
+ + {activeWorkflow ? ( + + { remove(activeWorkflow.id) }} + onExport={() => exportFile(activeWorkflow)} + panelOpen={panelOpen} + onTogglePanel={() => setPanelOpen((o) => !o)} + onNew={handleCreateBlank} + onImport={handleImport} + /> + + ) : ( +
+ + + + +
+

No workflows yet

+

Create one to get started

+
+
+ + +
+
+ )} + + {/* Extensions panel */} + +
+
+ ) +} +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { + ReactFlow, + ReactFlowProvider, + Background, + + addEdge, + useNodesState, + useEdgesState, + useReactFlow, + type Connection, + type Node, + type Edge, + type OnConnectStartParams, +} from '@xyflow/react' +import { useWorkflowsStore } from '@shared/stores/workflowsStore' +import { useExtensionsStore } from '@shared/stores/extensionsStore' +import { useNavStore } from '@shared/stores/navStore' +import { useAppStore } from '@shared/stores/appStore' +import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electron.d' +import { buildAllWorkflowExtensions, getWorkflowExtension } from './mockExtensions' +import type { WorkflowExtension } from './mockExtensions' +import { useWorkflowRunStore } from './workflowRunStore' +import { validateWorkflowPreflight } from './preflight' +import ExtensionNode from './nodes/ExtensionNode' +import ImageNode from './nodes/ImageNode' +import TextNode from './nodes/TextNode' +import AddToSceneNode from './nodes/AddToSceneNode' +import Load3DMeshNode from './nodes/Load3DMeshNode' +import PreviewImageNode from './nodes/PreviewImageNode' +import WaitNode from './nodes/WaitNode' +import WorkflowEdge from './nodes/WorkflowEdge' + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DRAG_KEY = 'modly/extension-id' +const DRAG_NODE_KEY = 'modly/node-type' +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } +const EDGE_TYPES = { workflowEdge: WorkflowEdge } + +const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } + +// ─── IO badge ───────────────────────────────────────────────────────────────── + +const IO_STYLES: Record<'image' | 'text' | 'mesh', string> = { + image: 'bg-sky-500/15 text-sky-400 border-sky-500/25', + mesh: 'bg-violet-500/15 text-violet-400 border-violet-500/25', + text: 'bg-amber-500/15 text-amber-400 border-amber-500/25', +} + +function IoBadge({ type }: { type: 'image' | 'text' | 'mesh' }) { + return ( + + {type} + + ) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function newId(): string { return crypto.randomUUID() } + +function newWorkflow(): Workflow { + const now = new Date().toISOString() + return { id: newId(), name: 'New Workflow', description: '', nodes: [], edges: [], createdAt: now, updatedAt: now } +} + +function newWorkflowFromTemplate(): Workflow { + const now = new Date().toISOString() + const imageNodeId = newId() + const outputNodeId = newId() + return { + id: newId(), + name: 'New Workflow', + description: '', + nodes: [ + { id: imageNodeId, type: 'imageNode', position: { x: 150, y: 180 }, data: { enabled: true, params: {}, showInGenerate: true } }, + { id: outputNodeId, type: 'outputNode', position: { x: 500, y: 180 }, data: { enabled: true, params: {} } }, + ], + edges: [ + { id: newId(), source: imageNodeId, target: outputNodeId, type: 'workflowEdge' }, + ], + createdAt: now, + updatedAt: now, + } +} + +// ─── New workflow modal ─────────────────────────────────────────────────────── + +function NewWorkflowModal({ onBlank, onTemplate, onClose }: { + onBlank: () => void + onTemplate: () => void + onClose: () => void +}) { + return ( +
+
e.stopPropagation()} + > +
+

New Workflow

+

Choose how to start

+
+ +
+ {/* Blank */} + + + {/* Starter template */} + +
+
+
+ ) +} + +// ─── Extensions panel ──────────────────────────────────────────────────────── + +const PANEL_MIN = 240 +const PANEL_MAX = 860 + +const PANEL_BUILTIN_NODES = [ + { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <> }, + { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <> }, + { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, + { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, + { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <> }, + { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <> }, +] + +function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { + return ( + + ) +} + +function ExtensionsPanel({ allExtensions, open }: { allExtensions: WorkflowExtension[]; open: boolean }) { + const [search, setSearch] = useState('') + const [collapsed, setCollapsed] = useState>({}) + const [width, setWidth] = useState(288) + const dragging = useRef(false) + const startX = useRef(0) + const startW = useRef(0) + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!dragging.current) return + const delta = startX.current - e.clientX + setWidth((w) => Math.min(PANEL_MAX, Math.max(PANEL_MIN, startW.current + delta))) + } + const onUp = () => { dragging.current = false; document.body.style.cursor = '' } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + return () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + }, []) + + const cols = width >= 580 ? 3 : width >= 370 ? 2 : 1 + const gridClass = cols === 3 ? 'grid-cols-3' : cols === 2 ? 'grid-cols-2' : 'grid-cols-1' + const query = search.trim().toLowerCase() + + const toggleGroup = (id: string) => setCollapsed((c) => ({ ...c, [id]: !c[id] })) + const isExpanded = (id: string, hasMatches: boolean) => (query && hasMatches) || !collapsed[id] + + // Base group + const filteredBuiltinNodes = PANEL_BUILTIN_NODES.filter((n) => !query || n.label.toLowerCase().includes(query)) + const filteredBuiltinExts = allExtensions.filter((e) => e.builtin && (!query || e.name.toLowerCase().includes(query))) + const baseCount = filteredBuiltinNodes.length + filteredBuiltinExts.length + const baseVisible = !query || baseCount > 0 + + // Non-builtin groups: grouped by extensionId + const nonBuiltinMap = useMemo(() => { + const map = new Map() + for (const ext of allExtensions) { + if (ext.builtin) continue + if (!map.has(ext.extensionId)) map.set(ext.extensionId, { extensionName: ext.extensionName, nodes: [] }) + map.get(ext.extensionId)!.nodes.push(ext) + } + return map + }, [allExtensions]) + + return ( +
+
+ + {/* Resize handle */} +
{ + dragging.current = true; startX.current = e.clientX; startW.current = width + document.body.style.cursor = 'col-resize'; e.preventDefault() + }} + className="w-1 shrink-0 hover:bg-zinc-600 active:bg-accent/60 cursor-col-resize transition-colors self-stretch" + /> + +
+ + {/* Header */} +
+

Extensions

+

Drag onto canvas

+
+ + {/* Search */} +
+
+ + + + setSearch(e.target.value)} + placeholder="Search…" + className="flex-1 bg-transparent text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none min-w-0" + /> + {search && ( + + )} +
+
+ + {/* Groups */} +
+ + {/* ── Base group ── */} + {baseVisible && ( +
+ 0)} + onToggle={() => toggleGroup('base')} + count={baseCount} + /> + {isExpanded('base', baseCount > 0) && ( +
+ {filteredBuiltinNodes.map(({ type, label, color, icon }) => ( +
{ e.dataTransfer.setData(DRAG_NODE_KEY, type); e.dataTransfer.effectAllowed = 'copy' }} + className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" + > +
+ {icon} +

{label}

+
+
+ ))} + {filteredBuiltinExts.map((ext) => ( +
{ e.dataTransfer.setData(DRAG_KEY, ext.id); e.dataTransfer.effectAllowed = 'copy' }} + className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" + > +

{ext.name}

+
+ + + + + +
+
+ ))} +
+ )} +
+ )} + + {/* ── Non-builtin extension groups ── */} + {[...nonBuiltinMap.entries()].map(([extId, { extensionName, nodes }]) => { + const filtered = nodes.filter((e) => !query || e.name.toLowerCase().includes(query)) + if (query && filtered.length === 0) return null + const displayNodes = query ? filtered : nodes + const expanded = isExpanded(extId, filtered.length > 0) + + return ( +
+ toggleGroup(extId)} + count={displayNodes.length} + /> + {expanded && ( +
+ {displayNodes.map((ext) => ( +
{ e.dataTransfer.setData(DRAG_KEY, ext.id); e.dataTransfer.effectAllowed = 'copy' }} + className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" + > +

{ext.name}

+ {ext.description && cols === 1 && ( +

{ext.description}

+ )} +
+ + + + + +
+
+ ))} +
+ )} +
+ ) + })} + + {/* Empty state */} + {query && baseCount === 0 && [...nonBuiltinMap.values()].every((g) => !g.nodes.some((e) => e.name.toLowerCase().includes(query))) && ( +

No results for "{query}"

+ )} + +
+
+
+
+ ) +} + +// ─── Page ─── toggle button icon ───────────────────────────────────────────── + +function PanelToggleIcon({ open }: { open: boolean }) { + return ( + + + + + ) +} + +// ─── Node palette (Space to open) ──────────────────────────────────────────── + +const BUILTIN_NODES = [ + { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' }, + { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' }, + { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, + { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, + { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, + { type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, +] + +type PaletteItem = + | { kind: 'node'; data: typeof BUILTIN_NODES[0] } + | { kind: 'ext'; data: WorkflowExtension } + +type PaletteGroup = { + id: string + title: string + author?: string + expanded: boolean + items: Array +} + +function NodePalette({ + allExtensions, + onSelect, + onClose, +}: { + allExtensions: WorkflowExtension[] + onSelect: (type: string, extensionId?: string) => void + onClose: () => void +}) { + const [query, setQuery] = useState('') + const [collapsed, setCollapsed] = useState>({}) + const [activeIndex, setActiveIndex] = useState(0) + const inputRef = useRef(null) + + const q = query.trim().toLowerCase() + + const nonBuiltinMap = useMemo(() => { + const map = new Map() + for (const ext of allExtensions) { + if (ext.builtin) continue + if (!map.has(ext.extensionId)) map.set(ext.extensionId, { extensionName: ext.extensionName, extensionAuthor: ext.extensionAuthor, nodes: [] }) + map.get(ext.extensionId)!.nodes.push(ext) + } + return map + }, [allExtensions]) + + const toggleGroup = (id: string) => setCollapsed((c) => ({ ...c, [id]: !c[id] })) + const isExpanded = (id: string, hasMatches: boolean) => (!!q && hasMatches) || !collapsed[id] + + // Build groups with pre-assigned flat indices (drives keyboard nav) + const { groups, totalItems } = useMemo(() => { + const groups: PaletteGroup[] = [] + let flatIdx = 0 + + // Base group + const filteredBuiltinNodes = BUILTIN_NODES.filter((n) => !q || n.label.toLowerCase().includes(q) || n.description.toLowerCase().includes(q)) + const filteredBuiltinExts = allExtensions.filter((e) => e.builtin && (!q || e.name.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q))) + const baseCount = filteredBuiltinNodes.length + filteredBuiltinExts.length + const baseVisible = !q || baseCount > 0 + const baseExp = isExpanded('base', baseCount > 0) + + if (baseVisible) { + const items: PaletteGroup['items'] = [] + if (baseExp) { + filteredBuiltinNodes.forEach((n) => items.push({ kind: 'node', data: n, flatIdx: flatIdx++ })) + filteredBuiltinExts.forEach((e) => items.push({ kind: 'ext', data: e, flatIdx: flatIdx++ })) + } + groups.push({ id: 'base', title: 'Base', expanded: baseExp, items }) + } + + // Non-builtin groups + for (const [extId, { extensionName, extensionAuthor, nodes }] of nonBuiltinMap) { + const filtered = nodes.filter((e) => !q || e.name.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q)) + if (q && filtered.length === 0) continue + const displayNodes = q ? filtered : nodes + const expanded = isExpanded(extId, filtered.length > 0) + const items: PaletteGroup['items'] = [] + if (expanded) displayNodes.forEach((e) => items.push({ kind: 'ext', data: e, flatIdx: flatIdx++ })) + groups.push({ id: extId, title: extensionName, author: extensionAuthor || undefined, expanded, items }) + } + + return { groups, totalItems: flatIdx } + }, [q, allExtensions, nonBuiltinMap, collapsed]) + + useEffect(() => { setActiveIndex(0) }, [query]) + useEffect(() => { inputRef.current?.focus() }, []) + + // Flat list for Enter key (derived from groups) + const flatItems = useMemo(() => groups.flatMap((g) => g.items), [groups]) + + const handleKey = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return } + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, totalItems - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); return } + if (e.key === 'Enter') { + e.preventDefault() + const item = flatItems[activeIndex] + if (!item) return + if (item.kind === 'node') onSelect(item.data.type) + else onSelect('extensionNode', item.data.id) + } + }, [activeIndex, flatItems, totalItems, onSelect, onClose]) + + return ( +
+
e.stopPropagation()} + > + {/* Search input */} +
+ + + + setQuery(e.target.value)} + onKeyDown={handleKey} + placeholder="Search nodes and extensions…" + className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none" + /> + Esc +
+ + {/* Groups */} +
+ {groups.map((group) => ( +
+ + {/* Group header */} + + + {/* Group items */} + {group.expanded && group.items.map((item) => { + const isActive = activeIndex === item.flatIdx + if (item.kind === 'node') { + const n = item.data + return ( + + ) + } + const e = item.data + return ( + + ) + })} + +
+ ))} + + {totalItems === 0 && groups.length === 0 && ( +

No results for "{query}"

+ )} +
+
+
+ ) +} + +// ─── Help modal ─────────────────────────────────────────────────────────────── + +function HelpModal({ onClose }: { onClose: () => void }) { + const [helperImg, setHelperImg] = useState(null) + useEffect(() => { + window.electron.fs.readScreenshotDataUrl('workflow-helper.png').then(setHelperImg).catch(() => {}) + }, []) + return createPortal( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+ + {/* Header */} +
+
+
+ + + +
+

How the workflow system works

+
+ +
+ +
+ + {/* Concept */} +
+

Concept

+

+ A workflow is a directed graph of nodes. Each node receives data from its inputs (left handle) and produces a result on its output (right handle). Data flows from left to right — you connect nodes by dragging from one handle to another. +

+
+ + {/* Example screenshot */} + {helperImg && ( +
+ Basic workflow example +

+ Example — Image → AI model → Add to Scene +

+
+ )} + + {/* Node types */} +
+

Node types

+
+ +
+ image +
+

Image

+

Source node. Pick a local image file — it becomes the input of the first processing node.

+
+
+ +
+ text +
+

Text

+

Source node. Pass a text prompt to extensions that accept text input.

+
+
+ +
+ mesh +
+

Load 3D Mesh

+

Source node. Load a .glb, .obj, .stl or .ply file from disk, or use the model currently loaded in the 3D viewer.

+
+
+ +
+
+ image + + mesh +
+
+

Model extension (AI generator)

+

Runs a locally installed AI model to convert an image into a 3D mesh. Requires the model weights to be downloaded first from the Extensions page.

+
+
+ +
+
+ mesh + + mesh +
+
+

Process extension (mesh processor)

+

Transforms a mesh — examples: Optimize Mesh (polygon reduction), Export Mesh (save to file). No GPU required.

+
+
+ +
+ scene +
+

Add to Scene

+

Terminal node. Receives the final mesh and loads it directly into the 3D viewer when the workflow completes.

+
+
+ +
+
+ + {/* Tips */} +
+

Tips

+
    + {[ + ['Space', 'Open the node palette on the canvas'], + ['Eye icon', 'Pin a node to the Generate page side panel'], + ['Drag handle → canvas', 'Auto-opens the palette to connect a new node'], + ['Right-click a link', 'Delete the connection between two nodes'], + ['Run', 'Saves & executes the workflow, result goes to the 3D scene'], + ].map(([key, desc]) => ( +
  • + {key} + {desc} +
  • + ))} +
+
+ +
+
+
, + document.body + ) +} + +// ─── Connection type helpers ────────────────────────────────────────────────── + +function getNodeOutputType(node: Node | undefined, allExts: WorkflowExtension[]): string | undefined { + if (!node) return undefined + if (node.type === 'imageNode') return 'image' + if (node.type === 'meshNode') return 'mesh' + if (node.type === 'textNode') return 'text' + return allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)?.output +} + +function getNodeInputType( + node: Node | undefined, + targetHandle: string | null | undefined, + allExts: WorkflowExtension[], +): string | undefined { + if (!node) return undefined + if (node.type === 'outputNode') return 'mesh' + if (node.type === 'previewNode') return 'image' + const ext = allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId) + if (ext?.inputs && ext.inputs.length > 1 && targetHandle) { + const idx = parseInt(targetHandle.replace('input-', ''), 10) + return ext.inputs[isNaN(idx) ? 0 : idx] ?? ext.input + } + return ext?.input +} + +// ─── Workflow canvas (inner, requires ReactFlowProvider) ────────────────────── + function WorkflowCanvasInner({ workflow, allExtensions, onSave, onDelete, onExport, panelOpen, onTogglePanel, onNew, onImport, }: { From d614b5af163a784398b7f3721f8601d7940b2d3a Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Wed, 10 Jun 2026 11:18:19 -0400 Subject: [PATCH 18/22] Implement GitHub extension installation feature Added GitHub extension install form and related functionality. --- src/areas/models/ModelsPage.tsx | 537 ++++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 5e2e008..2892c7b 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -8,6 +8,543 @@ import type { ExtensionNode } from './components/ExtensionCard' // ─── Page ───────────────────────────────────────────────────────────────────── +export default function ModelsPage(): JSX.Element { + // Extensions store + const modelExtensions = useExtensionsStore((s) => s.modelExtensions) + const processExtensions = useExtensionsStore((s) => s.processExtensions) + const extLoading = useExtensionsStore((s) => s.loading) + const installProgress = useExtensionsStore((s) => s.installProgress) + const installError = useExtensionsStore((s) => s.installError) + const loadErrors = useExtensionsStore((s) => s.loadErrors) + const loadExtensions = useExtensionsStore((s) => s.loadExtensions) + const installFromGH = useExtensionsStore((s) => s.installFromGitHub) + const installFromLocal = useExtensionsStore((s) => s.installFromLocal) + const uninstallExt = useExtensionsStore((s) => s.uninstall) + const reloadExtensions = useExtensionsStore((s) => s.reload) + const clearInstall = useExtensionsStore((s) => s.clearInstallState) + + // All extensions (model + process), sorted builtin-first then by name + const allExtensions: AnyExtension[] = [ + ...modelExtensions, + ...processExtensions, + ].sort((a, b) => { + if (a.builtin !== b.builtin) return a.builtin ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + // Model weight state (needed for node install status + uninstall cleanup) + const [installedVariantIds, setInstalledVariantIds] = useState([]) + const [downloading, setDownloading] = useState>({}) + + // Uninstall modal state + const [uninstallTarget, setUninstallTarget] = useState(null) + const [modelsToDelete, setModelsToDelete] = useState>(new Set()) + + // Search + const [search, setSearch] = useState('') + + // GitHub extension install form. + // Initialized from the store so the progress UI reappears automatically + // when the user switches tabs mid-install and comes back. + const [showGHForm, setShowGHForm] = useState(() => { + const p = useExtensionsStore.getState().installProgress + return p !== null && p.step !== 'done' && p.step !== 'error' + }) + const [ghUrl, setGhUrl] = useState('') + const [ghErr, setGhErr] = useState(null) + + // ── Init ────────────────────────────────────────────────────────────────── + + // Check each model node individually via filesystem IPC — reliable regardless of API state + async function refreshInstalledIds(exts: ModelExtension[]) { + const ids: string[] = [] + for (const ext of exts) { + for (const node of ext.nodes) { + if (!node.hfRepo) continue + const fullId = `${ext.id}/${node.id}` + const ok = await window.electron.model.isDownloaded(fullId, node.downloadCheck) + if (ok) ids.push(fullId) + } + } + setInstalledVariantIds(ids) + } + + useEffect(() => { + loadExtensions().then(async () => { + const exts = useExtensionsStore.getState().modelExtensions + const active = await window.electron.model.activeDownloads() + if (active.length > 0) { + setDownloading((prev) => { + const next = { ...prev } + for (const { modelId, ...progress } of active) if (!next[modelId]) next[modelId] = progress + return next + }) + } + refreshInstalledIds(exts) + }) + window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles, status, bytesDownloaded, totalBytes, stalledSeconds, paused, cancelled }) => { + if (cancelled) { + setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) + return + } + setDownloading((prev) => { + const current = prev[id] + return { + ...prev, + [id]: { + percent: paused ? (current?.percent ?? percent) : percent, + file: file ?? current?.file, + fileIndex: fileIndex ?? current?.fileIndex, + totalFiles: totalFiles ?? current?.totalFiles, + status, + bytesDownloaded: bytesDownloaded ?? current?.bytesDownloaded, + totalBytes: totalBytes ?? current?.totalBytes, + stalledSeconds: stalledSeconds ?? current?.stalledSeconds, + paused, + }, + } + }) + if (percent === 100) { + const exts = useExtensionsStore.getState().modelExtensions + refreshInstalledIds(exts).then(() => { + setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) + }) + } + }) + return () => window.electron.model.offProgress() + }, []) + + useEffect(() => { + if (installError) setGhErr(installError) + }, [installError]) + + // ── GitHub extension install ─────────────────────────────────────────────── + + async function handleGHInstall() { + const url = ghUrl.trim() + if (!url) { setGhErr('GitHub URL required'); return } + if (!url.includes('github.com')) { setGhErr('Must be a GitHub URL'); return } + setGhErr(null) + clearInstall() + const result = await installFromGH(url) + if (result.success) { + setShowGHForm(false) + setGhUrl('') + } else { + setGhErr(result.error ?? 'Installation failed') + } + } + + // ── Local extension install ────────────────────────────────────────── + + async function handleLocalInstall() { + setGhErr(null) + clearInstall() + const result = await installFromLocal() + if ('cancelled' in result && result.cancelled) return // user dismissed dialog + if (!result.success) setGhErr(result.error ?? 'Installation failed') + } + + // ── Uninstall extension ────────────────────────────────────────── + + function openUninstallModal(extId: string) { + const ext = allExtensions.find((e) => e.id === extId) + if (ext?.type === 'model') { + const installedModels = ext.nodes.filter((n) => installedVariantIds.includes(`${extId}/${n.id}`)) + setModelsToDelete(new Set(installedModels.map((n) => `${extId}/${n.id}`))) + } else { + setModelsToDelete(new Set()) + } + setUninstallTarget(extId) + } + + async function handleUninstallExtension(extId: string) { + for (const modelId of modelsToDelete) { + await window.electron.model.delete(modelId) + } + await uninstallExt(extId) + setUninstallTarget(null) + setModelsToDelete(new Set()) + refreshInstalledIds(useExtensionsStore.getState().modelExtensions) + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + const isInstalling = installProgress !== null && + installProgress.step !== 'done' && + installProgress.step !== 'error' + + const isBusy = isInstalling || Object.keys(downloading).length > 0 + + const filteredExtensions = search.trim() + ? allExtensions.filter((e) => + e.name.toLowerCase().includes(search.trim().toLowerCase()) || + (e.description ?? '').toLowerCase().includes(search.trim().toLowerCase()) || + (e.author ?? '').toLowerCase().includes(search.trim().toLowerCase()) + ) + : allExtensions + + function installProgressLabel(): string { + if (!installProgress) return '' + switch (installProgress.step) { + case 'downloading': return `Downloading… ${installProgress.percent ?? 0}%` + case 'extracting': return 'Extracting…' + case 'validating': return 'Validating…' + case 'setting_up': return 'Setting up environment…' + case 'done': return 'Installed!' + default: return '' + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ + {/* ── Header ──────────────────────────────────────────────────────── */} +
+
+

Extensions

+
+ {/* GitHub install button */} + + + {/* Local folder link button */} + +
+
+ + {/* Search bar */} +
+
+ + + + setSearch(e.target.value)} + placeholder="Search extensions…" + className="flex-1 bg-transparent text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none" + /> + {search && ( + + )} + {allExtensions.length > 0 && ( + + {search.trim() ? `${filteredExtensions.length} / ${allExtensions.length}` : `${allExtensions.length}`} + + )} +
+ +
+
+ + {/* ── GitHub install form ──────────────────────────────────────────── */} + {showGHForm && ( +
+
+
+ { setGhUrl(e.target.value); setGhErr(null); clearInstall() }} + onKeyDown={(e) => e.key === 'Enter' && !isInstalling && handleGHInstall()} + placeholder="https://github.com/owner/repo" + autoFocus + disabled={isInstalling} + className="flex-1 px-3 py-2 text-xs rounded-lg bg-zinc-800 border border-zinc-700/60 text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500 transition-colors disabled:opacity-50" + /> + +
+ + {isInstalling && installProgress?.step === 'downloading' && ( +
+
+
+ )} + + {isInstalling && installProgress?.step === 'setting_up' && ( +
+
+
+
+ + {installProgress.message ?? 'Setting up environment…'} + +
+ May take a few minutes +
+ {/* Indeterminate progress bar */} +
+
+
+
+ )} + + {installProgress?.step === 'done' && ( +
+ + + +

Extension installed successfully!

+
+ )} + + {ghErr && ( +
+ + + +

{ghErr}

+
+ )} + +

+ The repo must contain a manifest.json and a generator.py at its root. +

+
+
+ )} + + {/* ── Extensions list ──────────────────────────────────────────────── */} +
+ {allExtensions.length === 0 && !extLoading ? ( +
+ + + + + +
+

No extensions installed

+

+ Install from GitHub or drop into the Modly extensions directory. +

+
+
+ ) : extLoading ? ( +
+
+
+ ) : filteredExtensions.length === 0 ? ( +
+ + + +

No results for "{search}"

+
+ ) : ( +
+ {filteredExtensions.map((ext) => ( + loadErrors[`${ext.id}/${n.id}`]).find(Boolean) + } + onInstall={(node: ExtensionNode, fullId: string) => { + if (!node.hfRepo) return + setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting…' } })) + window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { + if (!result.success && !result.paused && !result.cancelled) { + setGhErr('Download failed') + setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) + } + }) + }} + onPauseDownload={async (fullId) => { + setDownloading((prev) => prev[fullId] ? ({ ...prev, [fullId]: { ...prev[fullId], paused: true, status: 'Pausing…' } }) : prev) + await window.electron.model.pauseDownload(fullId) + }} + onCancelDownload={async (fullId) => { + setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) + await window.electron.model.cancelDownload(fullId) + }} + onUninstallNode={async (fullId: string) => { + await window.electron.model.delete(fullId) + refreshInstalledIds(useExtensionsStore.getState().modelExtensions) + }} + onUninstall={(extId) => openUninstallModal(extId)} + onRepaired={() => reloadExtensions()} + onSynced={() => reloadExtensions()} + /> + ))} +
+ )} +
+ + {/* ── Confirm uninstall extension ──────────────────────────────────── */} + {uninstallTarget && (() => { + const ext = allExtensions.find((e) => e.id === uninstallTarget) + const installedModels = ext?.type === 'model' + ? ext.nodes.filter((n) => installedVariantIds.includes(`${uninstallTarget}/${n.id}`)) + : [] + + return createPortal( +
{ if (e.target === e.currentTarget) { setUninstallTarget(null); setModelsToDelete(new Set()) } }} + > +
+
+
+
+
+ + + + + + +
+
+

+ Uninstall “{ext?.name ?? uninstallTarget}”? +

+

+ The extension folder will be permanently deleted. +

+
+
+ + {installedModels.length > 0 && ( +
+

+ Also delete downloaded model weights: +

+ {installedModels.map((v) => { + const id = `${uninstallTarget}/${v.id}` + const checked = modelsToDelete.has(id) + return ( + + ) + })} +
+ )} + +
+ + +
+
+
+
, + document.body + ) + })()} +
+ ) +} +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useExtensionsStore } from '@shared/stores/extensionsStore' +import type { AnyExtension, ModelExtension } from '@shared/types/electron.d' +import { formatModelName } from './utils' +import { ExtensionCard } from './components/ExtensionCard' +import type { ExtensionNode } from './components/ExtensionCard' + +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function ModelsPage(): JSX.Element { // Extensions store const modelExtensions = useExtensionsStore((s) => s.modelExtensions) From 162905c6252817320bf5417694ac49fc2d52c8cb Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Wed, 10 Jun 2026 11:22:29 -0400 Subject: [PATCH 19/22] Refactor ModelsPage component for better state management --- src/areas/models/ModelsPage.tsx | 536 ++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 2892c7b..b4114ee 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -6,6 +6,542 @@ import { formatModelName } from './utils' import { ExtensionCard } from './components/ExtensionCard' import type { ExtensionNode } from './components/ExtensionCard' +// --- Page --------------------------------------------------------------------- + +export default function ModelsPage(): JSX.Element { + // Extensions store + const modelExtensions = useExtensionsStore((s) => s.modelExtensions) + const processExtensions = useExtensionsStore((s) => s.processExtensions) + const extLoading = useExtensionsStore((s) => s.loading) + const installProgress = useExtensionsStore((s) => s.installProgress) + const installError = useExtensionsStore((s) => s.installError) + const loadErrors = useExtensionsStore((s) => s.loadErrors) + const loadExtensions = useExtensionsStore((s) => s.loadExtensions) + const installFromGH = useExtensionsStore((s) => s.installFromGitHub) + const installFromLocal = useExtensionsStore((s) => s.installFromLocal) + const uninstallExt = useExtensionsStore((s) => s.uninstall) + const reloadExtensions = useExtensionsStore((s) => s.reload) + const clearInstall = useExtensionsStore((s) => s.clearInstallState) + + // All extensions (model + process), sorted builtin-first then by name + const allExtensions: AnyExtension[] = [ + ...modelExtensions, + ...processExtensions, + ].sort((a, b) => { + if (a.builtin !== b.builtin) return a.builtin ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + // Model weight state (needed for node install status + uninstall cleanup) + const [installedVariantIds, setInstalledVariantIds] = useState([]) + const [downloading, setDownloading] = useState>({}) + + // Uninstall modal state + const [uninstallTarget, setUninstallTarget] = useState(null) + const [modelsToDelete, setModelsToDelete] = useState>(new Set()) + + // Search + const [search, setSearch] = useState('') + + // GitHub extension install form. + // Initialized from the store so the progress UI reappears automatically + // when the user switches tabs mid-install and comes back. + const [showGHForm, setShowGHForm] = useState(() => { + const p = useExtensionsStore.getState().installProgress + return p !== null && p.step !== 'done' && p.step !== 'error' + }) + const [ghUrl, setGhUrl] = useState('') + const [ghErr, setGhErr] = useState(null) + + // -- Init ------------------------------------------------------------------ + + // Check each model node individually via filesystem IPC - reliable regardless of API state + async function refreshInstalledIds(exts: ModelExtension[]) { + const ids: string[] = [] + for (const ext of exts) { + for (const node of ext.nodes) { + if (!node.hfRepo) continue + const fullId = `${ext.id}/${node.id}` + const ok = await window.electron.model.isDownloaded(fullId, node.downloadCheck) + if (ok) ids.push(fullId) + } + } + setInstalledVariantIds(ids) + } + + useEffect(() => { + loadExtensions().then(async () => { + const exts = useExtensionsStore.getState().modelExtensions + const active = await window.electron.model.activeDownloads() + if (active.length > 0) { + setDownloading((prev) => { + const next = { ...prev } + for (const { modelId, ...progress } of active) if (!next[modelId]) next[modelId] = progress + return next + }) + } + refreshInstalledIds(exts) + }) + window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles, status, bytesDownloaded, totalBytes, stalledSeconds, paused, cancelled }) => { + if (cancelled) { + setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) + return + } + setDownloading((prev) => { + const current = prev[id] + return { + ...prev, + [id]: { + percent: paused ? (current?.percent ?? percent) : percent, + file: file ?? current?.file, + fileIndex: fileIndex ?? current?.fileIndex, + totalFiles: totalFiles ?? current?.totalFiles, + status, + bytesDownloaded: bytesDownloaded ?? current?.bytesDownloaded, + totalBytes: totalBytes ?? current?.totalBytes, + stalledSeconds: stalledSeconds ?? current?.stalledSeconds, + paused, + }, + } + }) + if (percent === 100) { + const exts = useExtensionsStore.getState().modelExtensions + refreshInstalledIds(exts).then(() => { + setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) + }) + } + }) + return () => window.electron.model.offProgress() + }, []) + + useEffect(() => { + if (installError) setGhErr(installError) + }, [installError]) + + // -- GitHub extension install ----------------------------------------------- + + async function handleGHInstall() { + const url = ghUrl.trim() + if (!url) { setGhErr('GitHub URL required'); return } + if (!url.includes('github.com')) { setGhErr('Must be a GitHub URL'); return } + setGhErr(null) + clearInstall() + const result = await installFromGH(url) + if (result.success) { + setShowGHForm(false) + setGhUrl('') + } else { + setGhErr(result.error ?? 'Installation failed') + } + } + + // -- Local extension install ------------------------------------------ + + async function handleLocalInstall() { + setGhErr(null) + clearInstall() + const result = await installFromLocal() + if ('cancelled' in result && result.cancelled) return // user dismissed dialog + if (!result.success) setGhErr(result.error ?? 'Installation failed') + } + + // -- Uninstall extension ------------------------------------------ + + function openUninstallModal(extId: string) { + const ext = allExtensions.find((e) => e.id === extId) + if (ext?.type === 'model') { + const installedModels = ext.nodes.filter((n) => installedVariantIds.includes(`${extId}/${n.id}`)) + setModelsToDelete(new Set(installedModels.map((n) => `${extId}/${n.id}`))) + } else { + setModelsToDelete(new Set()) + } + setUninstallTarget(extId) + } + + async function handleUninstallExtension(extId: string) { + for (const modelId of modelsToDelete) { + await window.electron.model.delete(modelId) + } + await uninstallExt(extId) + setUninstallTarget(null) + setModelsToDelete(new Set()) + refreshInstalledIds(useExtensionsStore.getState().modelExtensions) + } + + // -- Helpers ---------------------------------------------------------------- + + const isInstalling = installProgress !== null && + installProgress.step !== 'done' && + installProgress.step !== 'error' + + const isBusy = isInstalling || Object.keys(downloading).length > 0 + + const filteredExtensions = search.trim() + ? allExtensions.filter((e) => + e.name.toLowerCase().includes(search.trim().toLowerCase()) || + (e.description ?? '').toLowerCase().includes(search.trim().toLowerCase()) || + (e.author ?? '').toLowerCase().includes(search.trim().toLowerCase()) + ) + : allExtensions + + function installProgressLabel(): string { + if (!installProgress) return '' + switch (installProgress.step) { + case 'downloading': return `Downloading... ${installProgress.percent ?? 0}%` + case 'extracting': return 'Extracting...' + case 'validating': return 'Validating...' + case 'setting_up': return 'Setting up environment...' + case 'done': return 'Installed!' + default: return '' + } + } + + // -- Render ---------------------------------------------------------------- + + return ( +
+ + {/* -- Header -------------------------------------------------------- */} +
+
+

Extensions

+
+ {/* GitHub install button */} + + + {/* Local folder link button */} + +
+
+ + {/* Search bar */} +
+
+ + + + setSearch(e.target.value)} + placeholder="Search extensions..." + className="flex-1 bg-transparent text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none" + /> + {search && ( + + )} + {allExtensions.length > 0 && ( + + {search.trim() ? `${filteredExtensions.length} / ${allExtensions.length}` : `${allExtensions.length}`} + + )} +
+ +
+
+ + {/* -- GitHub install form -------------------------------------------- */} + {showGHForm && ( +
+
+
+ { setGhUrl(e.target.value); setGhErr(null); clearInstall() }} + onKeyDown={(e) => e.key === 'Enter' && !isInstalling && handleGHInstall()} + placeholder="https://github.com/owner/repo" + autoFocus + disabled={isInstalling} + className="flex-1 px-3 py-2 text-xs rounded-lg bg-zinc-800 border border-zinc-700/60 text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500 transition-colors disabled:opacity-50" + /> + +
+ + {isInstalling && installProgress?.step === 'downloading' && ( +
+
+
+ )} + + {isInstalling && installProgress?.step === 'setting_up' && ( +
+
+
+
+ + {installProgress.message ?? 'Setting up environment...'} + +
+ May take a few minutes +
+ {/* Indeterminate progress bar */} +
+
+
+
+ )} + + {installProgress?.step === 'done' && ( +
+ + + +

Extension installed successfully!

+
+ )} + + {ghErr && ( +
+ + + +

{ghErr}

+
+ )} + +

+ The repo must contain a manifest.json and a generator.py at its root. +

+
+
+ )} + + {/* -- Extensions list ------------------------------------------------ */} +
+ {allExtensions.length === 0 && !extLoading ? ( +
+ + + + + +
+

No extensions installed

+

+ Install from GitHub or drop into the Modly extensions directory. +

+
+
+ ) : extLoading ? ( +
+
+
+ ) : filteredExtensions.length === 0 ? ( +
+ + + +

No results for "{search}"

+
+ ) : ( +
+ {filteredExtensions.map((ext) => ( + loadErrors[`${ext.id}/${n.id}`]).find(Boolean) + } + onInstall={(node: ExtensionNode, fullId: string) => { + if (!node.hfRepo) return + setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting...' } })) + window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { + if (!result.success && !result.paused && !result.cancelled) { + setGhErr('Download failed') + setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) + } + }) + }} + onPauseDownload={async (fullId) => { + setDownloading((prev) => prev[fullId] ? ({ ...prev, [fullId]: { ...prev[fullId], paused: true, status: 'Pausing...' } }) : prev) + await window.electron.model.pauseDownload(fullId) + }} + onCancelDownload={async (fullId) => { + setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) + await window.electron.model.cancelDownload(fullId) + }} + onUninstallNode={async (fullId: string) => { + await window.electron.model.delete(fullId) + refreshInstalledIds(useExtensionsStore.getState().modelExtensions) + }} + onUninstall={(extId) => openUninstallModal(extId)} + onRepaired={() => reloadExtensions()} + onSynced={() => reloadExtensions()} + /> + ))} +
+ )} +
+ + {/* -- Confirm uninstall extension ------------------------------------ */} + {uninstallTarget && (() => { + const ext = allExtensions.find((e) => e.id === uninstallTarget) + const installedModels = ext?.type === 'model' + ? ext.nodes.filter((n) => installedVariantIds.includes(`${uninstallTarget}/${n.id}`)) + : [] + + return createPortal( +
{ if (e.target === e.currentTarget) { setUninstallTarget(null); setModelsToDelete(new Set()) } }} + > +
+
+
+
+
+ + + + + + +
+
+

+ Uninstall “{ext?.name ?? uninstallTarget}”? +

+

+ The extension folder will be permanently deleted. +

+
+
+ + {installedModels.length > 0 && ( +
+

+ Also delete downloaded model weights: +

+ {installedModels.map((v) => { + const id = `${uninstallTarget}/${v.id}` + const checked = modelsToDelete.has(id) + return ( + + ) + })} +
+ )} + +
+ + +
+
+
+
, + document.body + ) + })()} +
+ ) +}import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useExtensionsStore } from '@shared/stores/extensionsStore' +import type { AnyExtension, ModelExtension } from '@shared/types/electron.d' +import { formatModelName } from './utils' +import { ExtensionCard } from './components/ExtensionCard' +import type { ExtensionNode } from './components/ExtensionCard' + // ─── Page ───────────────────────────────────────────────────────────────────── export default function ModelsPage(): JSX.Element { From e84972163ecae416eed146bf39276cfed9de929f Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Wed, 10 Jun 2026 11:27:35 -0400 Subject: [PATCH 20/22] Update ModelsPage.tsx --- src/areas/models/ModelsPage.tsx | 1068 ------------------------------- 1 file changed, 1068 deletions(-) diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index b4114ee..43eee17 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -534,1072 +534,4 @@ export default function ModelsPage(): JSX.Element { })()}
) -}import { useEffect, useState } from 'react' -import { createPortal } from 'react-dom' -import { useExtensionsStore } from '@shared/stores/extensionsStore' -import type { AnyExtension, ModelExtension } from '@shared/types/electron.d' -import { formatModelName } from './utils' -import { ExtensionCard } from './components/ExtensionCard' -import type { ExtensionNode } from './components/ExtensionCard' - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function ModelsPage(): JSX.Element { - // Extensions store - const modelExtensions = useExtensionsStore((s) => s.modelExtensions) - const processExtensions = useExtensionsStore((s) => s.processExtensions) - const extLoading = useExtensionsStore((s) => s.loading) - const installProgress = useExtensionsStore((s) => s.installProgress) - const installError = useExtensionsStore((s) => s.installError) - const loadErrors = useExtensionsStore((s) => s.loadErrors) - const loadExtensions = useExtensionsStore((s) => s.loadExtensions) - const installFromGH = useExtensionsStore((s) => s.installFromGitHub) - const installFromLocal = useExtensionsStore((s) => s.installFromLocal) - const uninstallExt = useExtensionsStore((s) => s.uninstall) - const reloadExtensions = useExtensionsStore((s) => s.reload) - const clearInstall = useExtensionsStore((s) => s.clearInstallState) - - // All extensions (model + process), sorted builtin-first then by name - const allExtensions: AnyExtension[] = [ - ...modelExtensions, - ...processExtensions, - ].sort((a, b) => { - if (a.builtin !== b.builtin) return a.builtin ? -1 : 1 - return a.name.localeCompare(b.name) - }) - - // Model weight state (needed for node install status + uninstall cleanup) - const [installedVariantIds, setInstalledVariantIds] = useState([]) - const [downloading, setDownloading] = useState>({}) - - // Uninstall modal state - const [uninstallTarget, setUninstallTarget] = useState(null) - const [modelsToDelete, setModelsToDelete] = useState>(new Set()) - - // Search - const [search, setSearch] = useState('') - - // GitHub extension install form. - // Initialized from the store so the progress UI reappears automatically - // when the user switches tabs mid-install and comes back. - const [showGHForm, setShowGHForm] = useState(() => { - const p = useExtensionsStore.getState().installProgress - return p !== null && p.step !== 'done' && p.step !== 'error' - }) - const [ghUrl, setGhUrl] = useState('') - const [ghErr, setGhErr] = useState(null) - - // ── Init ────────────────────────────────────────────────────────────────── - - // Check each model node individually via filesystem IPC — reliable regardless of API state - async function refreshInstalledIds(exts: ModelExtension[]) { - const ids: string[] = [] - for (const ext of exts) { - for (const node of ext.nodes) { - if (!node.hfRepo) continue - const fullId = `${ext.id}/${node.id}` - const ok = await window.electron.model.isDownloaded(fullId, node.downloadCheck) - if (ok) ids.push(fullId) - } - } - setInstalledVariantIds(ids) - } - - useEffect(() => { - loadExtensions().then(async () => { - const exts = useExtensionsStore.getState().modelExtensions - const active = await window.electron.model.activeDownloads() - if (active.length > 0) { - setDownloading((prev) => { - const next = { ...prev } - for (const { modelId, ...progress } of active) if (!next[modelId]) next[modelId] = progress - return next - }) - } - refreshInstalledIds(exts) - }) - window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles, status, bytesDownloaded, totalBytes, stalledSeconds, paused, cancelled }) => { - if (cancelled) { - setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) - return - } - setDownloading((prev) => { - const current = prev[id] - return { - ...prev, - [id]: { - percent: paused ? (current?.percent ?? percent) : percent, - file: file ?? current?.file, - fileIndex: fileIndex ?? current?.fileIndex, - totalFiles: totalFiles ?? current?.totalFiles, - status, - bytesDownloaded: bytesDownloaded ?? current?.bytesDownloaded, - totalBytes: totalBytes ?? current?.totalBytes, - stalledSeconds: stalledSeconds ?? current?.stalledSeconds, - paused, - }, - } - }) - if (percent === 100) { - const exts = useExtensionsStore.getState().modelExtensions - refreshInstalledIds(exts).then(() => { - setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) - }) - } - }) - return () => window.electron.model.offProgress() - }, []) - - useEffect(() => { - if (installError) setGhErr(installError) - }, [installError]) - - // ── GitHub extension install ─────────────────────────────────────────────── - - async function handleGHInstall() { - const url = ghUrl.trim() - if (!url) { setGhErr('GitHub URL required'); return } - if (!url.includes('github.com')) { setGhErr('Must be a GitHub URL'); return } - setGhErr(null) - clearInstall() - const result = await installFromGH(url) - if (result.success) { - setShowGHForm(false) - setGhUrl('') - } else { - setGhErr(result.error ?? 'Installation failed') - } - } - - // ── Local extension install ────────────────────────────────────────── - - async function handleLocalInstall() { - setGhErr(null) - clearInstall() - const result = await installFromLocal() - if ('cancelled' in result && result.cancelled) return // user dismissed dialog - if (!result.success) setGhErr(result.error ?? 'Installation failed') - } - - // ── Uninstall extension ────────────────────────────────────────── - - function openUninstallModal(extId: string) { - const ext = allExtensions.find((e) => e.id === extId) - if (ext?.type === 'model') { - const installedModels = ext.nodes.filter((n) => installedVariantIds.includes(`${extId}/${n.id}`)) - setModelsToDelete(new Set(installedModels.map((n) => `${extId}/${n.id}`))) - } else { - setModelsToDelete(new Set()) - } - setUninstallTarget(extId) - } - - async function handleUninstallExtension(extId: string) { - for (const modelId of modelsToDelete) { - await window.electron.model.delete(modelId) - } - await uninstallExt(extId) - setUninstallTarget(null) - setModelsToDelete(new Set()) - refreshInstalledIds(useExtensionsStore.getState().modelExtensions) - } - - // ── Helpers ──────────────────────────────────────────────────────────────── - - const isInstalling = installProgress !== null && - installProgress.step !== 'done' && - installProgress.step !== 'error' - - const isBusy = isInstalling || Object.keys(downloading).length > 0 - - const filteredExtensions = search.trim() - ? allExtensions.filter((e) => - e.name.toLowerCase().includes(search.trim().toLowerCase()) || - (e.description ?? '').toLowerCase().includes(search.trim().toLowerCase()) || - (e.author ?? '').toLowerCase().includes(search.trim().toLowerCase()) - ) - : allExtensions - - function installProgressLabel(): string { - if (!installProgress) return '' - switch (installProgress.step) { - case 'downloading': return `Downloading… ${installProgress.percent ?? 0}%` - case 'extracting': return 'Extracting…' - case 'validating': return 'Validating…' - case 'setting_up': return 'Setting up environment…' - case 'done': return 'Installed!' - default: return '' - } - } - - // ── Render ──────────────────────────────────────────────────────────────── - - return ( -
- - {/* ── Header ──────────────────────────────────────────────────────── */} -
-
-

Extensions

-
- {/* GitHub install button */} - - - {/* Local folder link button */} - -
-
- - {/* Search bar */} -
-
- - - - setSearch(e.target.value)} - placeholder="Search extensions…" - className="flex-1 bg-transparent text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none" - /> - {search && ( - - )} - {allExtensions.length > 0 && ( - - {search.trim() ? `${filteredExtensions.length} / ${allExtensions.length}` : `${allExtensions.length}`} - - )} -
- -
-
- - {/* ── GitHub install form ──────────────────────────────────────────── */} - {showGHForm && ( -
-
-
- { setGhUrl(e.target.value); setGhErr(null); clearInstall() }} - onKeyDown={(e) => e.key === 'Enter' && !isInstalling && handleGHInstall()} - placeholder="https://github.com/owner/repo" - autoFocus - disabled={isInstalling} - className="flex-1 px-3 py-2 text-xs rounded-lg bg-zinc-800 border border-zinc-700/60 text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500 transition-colors disabled:opacity-50" - /> - -
- - {isInstalling && installProgress?.step === 'downloading' && ( -
-
-
- )} - - {isInstalling && installProgress?.step === 'setting_up' && ( -
-
-
-
- - {installProgress.message ?? 'Setting up environment…'} - -
- May take a few minutes -
- {/* Indeterminate progress bar */} -
-
-
-
- )} - - {installProgress?.step === 'done' && ( -
- - - -

Extension installed successfully!

-
- )} - - {ghErr && ( -
- - - -

{ghErr}

-
- )} - -

- The repo must contain a manifest.json and a generator.py at its root. -

-
-
- )} - - {/* ── Extensions list ──────────────────────────────────────────────── */} -
- {allExtensions.length === 0 && !extLoading ? ( -
- - - - - -
-

No extensions installed

-

- Install from GitHub or drop into the Modly extensions directory. -

-
-
- ) : extLoading ? ( -
-
-
- ) : filteredExtensions.length === 0 ? ( -
- - - -

No results for "{search}"

-
- ) : ( -
- {filteredExtensions.map((ext) => ( - loadErrors[`${ext.id}/${n.id}`]).find(Boolean) - } - onInstall={(node: ExtensionNode, fullId: string) => { - if (!node.hfRepo) return - setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting…' } })) - window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { - if (!result.success && !result.paused && !result.cancelled) { - setGhErr('Download failed') - setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) - } - }) - }} - onPauseDownload={async (fullId) => { - setDownloading((prev) => prev[fullId] ? ({ ...prev, [fullId]: { ...prev[fullId], paused: true, status: 'Pausing…' } }) : prev) - await window.electron.model.pauseDownload(fullId) - }} - onCancelDownload={async (fullId) => { - setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) - await window.electron.model.cancelDownload(fullId) - }} - onUninstallNode={async (fullId: string) => { - await window.electron.model.delete(fullId) - refreshInstalledIds(useExtensionsStore.getState().modelExtensions) - }} - onUninstall={(extId) => openUninstallModal(extId)} - onRepaired={() => reloadExtensions()} - onSynced={() => reloadExtensions()} - /> - ))} -
- )} -
- - {/* ── Confirm uninstall extension ──────────────────────────────────── */} - {uninstallTarget && (() => { - const ext = allExtensions.find((e) => e.id === uninstallTarget) - const installedModels = ext?.type === 'model' - ? ext.nodes.filter((n) => installedVariantIds.includes(`${uninstallTarget}/${n.id}`)) - : [] - - return createPortal( -
{ if (e.target === e.currentTarget) { setUninstallTarget(null); setModelsToDelete(new Set()) } }} - > -
-
-
-
-
- - - - - - -
-
-

- Uninstall “{ext?.name ?? uninstallTarget}”? -

-

- The extension folder will be permanently deleted. -

-
-
- - {installedModels.length > 0 && ( -
-

- Also delete downloaded model weights: -

- {installedModels.map((v) => { - const id = `${uninstallTarget}/${v.id}` - const checked = modelsToDelete.has(id) - return ( - - ) - })} -
- )} - -
- - -
-
-
-
, - document.body - ) - })()} -
- ) -} -import { useEffect, useState } from 'react' -import { createPortal } from 'react-dom' -import { useExtensionsStore } from '@shared/stores/extensionsStore' -import type { AnyExtension, ModelExtension } from '@shared/types/electron.d' -import { formatModelName } from './utils' -import { ExtensionCard } from './components/ExtensionCard' -import type { ExtensionNode } from './components/ExtensionCard' - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function ModelsPage(): JSX.Element { - // Extensions store - const modelExtensions = useExtensionsStore((s) => s.modelExtensions) - const processExtensions = useExtensionsStore((s) => s.processExtensions) - const extLoading = useExtensionsStore((s) => s.loading) - const installProgress = useExtensionsStore((s) => s.installProgress) - const installError = useExtensionsStore((s) => s.installError) - const loadErrors = useExtensionsStore((s) => s.loadErrors) - const loadExtensions = useExtensionsStore((s) => s.loadExtensions) - const installFromGH = useExtensionsStore((s) => s.installFromGitHub) - const installFromLocal = useExtensionsStore((s) => s.installFromLocal) - const uninstallExt = useExtensionsStore((s) => s.uninstall) - const reloadExtensions = useExtensionsStore((s) => s.reload) - const clearInstall = useExtensionsStore((s) => s.clearInstallState) - - // All extensions (model + process), sorted builtin-first then by name - const allExtensions: AnyExtension[] = [ - ...modelExtensions, - ...processExtensions, - ].sort((a, b) => { - if (a.builtin !== b.builtin) return a.builtin ? -1 : 1 - return a.name.localeCompare(b.name) - }) - - // Model weight state (needed for node install status + uninstall cleanup) - const [installedVariantIds, setInstalledVariantIds] = useState([]) - const [downloading, setDownloading] = useState>({}) - - // Uninstall modal state - const [uninstallTarget, setUninstallTarget] = useState(null) - const [modelsToDelete, setModelsToDelete] = useState>(new Set()) - - // Search - const [search, setSearch] = useState('') - - // GitHub extension install form - const [showGHForm, setShowGHForm] = useState(false) - const [ghUrl, setGhUrl] = useState('') - const [ghErr, setGhErr] = useState(null) - - // ── Init ────────────────────────────────────────────────────────────────── - - // Check each model node individually via filesystem IPC — reliable regardless of API state - async function refreshInstalledIds(exts: ModelExtension[]) { - const ids: string[] = [] - for (const ext of exts) { - for (const node of ext.nodes) { - if (!node.hfRepo) continue - const fullId = `${ext.id}/${node.id}` - const ok = await window.electron.model.isDownloaded(fullId, node.downloadCheck) - if (ok) ids.push(fullId) - } - } - setInstalledVariantIds(ids) - } - - useEffect(() => { - loadExtensions().then(async () => { - const exts = useExtensionsStore.getState().modelExtensions - const active = await window.electron.model.activeDownloads() - if (active.length > 0) { - setDownloading((prev) => { - const next = { ...prev } - for (const { modelId, ...progress } of active) if (!next[modelId]) next[modelId] = progress - return next - }) - } - refreshInstalledIds(exts) - }) - window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles, status, bytesDownloaded, totalBytes, stalledSeconds, paused, cancelled }) => { - if (cancelled) { - setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) - return - } - setDownloading((prev) => { - const current = prev[id] - return { - ...prev, - [id]: { - percent: paused ? (current?.percent ?? percent) : percent, - file: file ?? current?.file, - fileIndex: fileIndex ?? current?.fileIndex, - totalFiles: totalFiles ?? current?.totalFiles, - status, - bytesDownloaded: bytesDownloaded ?? current?.bytesDownloaded, - totalBytes: totalBytes ?? current?.totalBytes, - stalledSeconds: stalledSeconds ?? current?.stalledSeconds, - paused, - }, - } - }) - if (percent === 100) { - const exts = useExtensionsStore.getState().modelExtensions - refreshInstalledIds(exts).then(() => { - setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) - }) - } - }) - return () => window.electron.model.offProgress() - }, []) - - useEffect(() => { - if (installError) setGhErr(installError) - }, [installError]) - - // ── GitHub extension install ─────────────────────────────────────────────── - - async function handleGHInstall() { - const url = ghUrl.trim() - if (!url) { setGhErr('GitHub URL required'); return } - if (!url.includes('github.com')) { setGhErr('Must be a GitHub URL'); return } - setGhErr(null) - clearInstall() - const result = await installFromGH(url) - if (result.success) { - setShowGHForm(false) - setGhUrl('') - } else { - setGhErr(result.error ?? 'Installation failed') - } - } - - // ── Local extension install ────────────────────────────────────────── - - async function handleLocalInstall() { - setGhErr(null) - clearInstall() - const result = await installFromLocal() - if ('cancelled' in result && result.cancelled) return // user dismissed dialog - if (!result.success) setGhErr(result.error ?? 'Installation failed') - } - - // ── Uninstall extension ────────────────────────────────────────── - - function openUninstallModal(extId: string) { - const ext = allExtensions.find((e) => e.id === extId) - if (ext?.type === 'model') { - const installedModels = ext.nodes.filter((n) => installedVariantIds.includes(`${extId}/${n.id}`)) - setModelsToDelete(new Set(installedModels.map((n) => `${extId}/${n.id}`))) - } else { - setModelsToDelete(new Set()) - } - setUninstallTarget(extId) - } - - async function handleUninstallExtension(extId: string) { - for (const modelId of modelsToDelete) { - await window.electron.model.delete(modelId) - } - await uninstallExt(extId) - setUninstallTarget(null) - setModelsToDelete(new Set()) - refreshInstalledIds(useExtensionsStore.getState().modelExtensions) - } - - // ── Helpers ──────────────────────────────────────────────────────────────── - - const isInstalling = installProgress !== null && - installProgress.step !== 'done' && - installProgress.step !== 'error' - - const isBusy = isInstalling || Object.keys(downloading).length > 0 - - const filteredExtensions = search.trim() - ? allExtensions.filter((e) => - e.name.toLowerCase().includes(search.trim().toLowerCase()) || - (e.description ?? '').toLowerCase().includes(search.trim().toLowerCase()) || - (e.author ?? '').toLowerCase().includes(search.trim().toLowerCase()) - ) - : allExtensions - - function installProgressLabel(): string { - if (!installProgress) return '' - switch (installProgress.step) { - case 'downloading': return `Downloading… ${installProgress.percent ?? 0}%` - case 'extracting': return 'Extracting…' - case 'validating': return 'Validating…' - case 'setting_up': return 'Setting up environment…' - case 'done': return 'Installed!' - default: return '' - } - } - - // ── Render ──────────────────────────────────────────────────────────────── - - return ( -
- - {/* ── Header ──────────────────────────────────────────────────────── */} -
-
-

Extensions

-
- {/* GitHub install button */} - - - {/* Local folder link button */} - -
-
- - {/* Search bar */} -
-
- - - - setSearch(e.target.value)} - placeholder="Search extensions…" - className="flex-1 bg-transparent text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none" - /> - {search && ( - - )} - {allExtensions.length > 0 && ( - - {search.trim() ? `${filteredExtensions.length} / ${allExtensions.length}` : `${allExtensions.length}`} - - )} -
- -
-
- - {/* ── GitHub install form ──────────────────────────────────────────── */} - {showGHForm && ( -
-
-
- { setGhUrl(e.target.value); setGhErr(null); clearInstall() }} - onKeyDown={(e) => e.key === 'Enter' && !isInstalling && handleGHInstall()} - placeholder="https://github.com/owner/repo" - autoFocus - disabled={isInstalling} - className="flex-1 px-3 py-2 text-xs rounded-lg bg-zinc-800 border border-zinc-700/60 text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500 transition-colors disabled:opacity-50" - /> - -
- - {isInstalling && installProgress?.step === 'downloading' && ( -
-
-
- )} - - {isInstalling && installProgress?.step === 'setting_up' && ( -
-
-
-
- - {installProgress.message ?? 'Setting up environment…'} - -
- May take a few minutes -
- {/* Indeterminate progress bar */} -
-
-
-
- )} - - {installProgress?.step === 'done' && ( -
- - - -

Extension installed successfully!

-
- )} - - {ghErr && ( -
- - - -

{ghErr}

-
- )} - -

- The repo must contain a manifest.json and a generator.py at its root. -

-
-
- )} - - {/* ── Extensions list ──────────────────────────────────────────────── */} -
- {allExtensions.length === 0 && !extLoading ? ( -
- - - - - -
-

No extensions installed

-

- Install from GitHub or drop into the Modly extensions directory. -

-
-
- ) : extLoading ? ( -
-
-
- ) : filteredExtensions.length === 0 ? ( -
- - - -

No results for "{search}"

-
- ) : ( -
- {filteredExtensions.map((ext) => ( - loadErrors[`${ext.id}/${n.id}`]).find(Boolean) - } - onInstall={(node: ExtensionNode, fullId: string) => { - if (!node.hfRepo) return - setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting…' } })) - window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { - if (!result.success && !result.paused && !result.cancelled) { - setGhErr('Download failed') - setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) - } - }) - }} - onPauseDownload={async (fullId) => { - setDownloading((prev) => prev[fullId] ? ({ ...prev, [fullId]: { ...prev[fullId], paused: true, status: 'Pausing…' } }) : prev) - await window.electron.model.pauseDownload(fullId) - }} - onCancelDownload={async (fullId) => { - setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) - await window.electron.model.cancelDownload(fullId) - }} - onUninstallNode={async (fullId: string) => { - await window.electron.model.delete(fullId) - refreshInstalledIds(useExtensionsStore.getState().modelExtensions) - }} - onUninstall={(extId) => openUninstallModal(extId)} - onRepaired={() => reloadExtensions()} - onSynced={() => reloadExtensions()} - /> - ))} -
- )} -
- - {/* ── Confirm uninstall extension ──────────────────────────────────── */} - {uninstallTarget && (() => { - const ext = allExtensions.find((e) => e.id === uninstallTarget) - const installedModels = ext?.type === 'model' - ? ext.nodes.filter((n) => installedVariantIds.includes(`${uninstallTarget}/${n.id}`)) - : [] - - return createPortal( -
{ if (e.target === e.currentTarget) { setUninstallTarget(null); setModelsToDelete(new Set()) } }} - > -
-
-
-
-
- - - - - - -
-
-

- Uninstall “{ext?.name ?? uninstallTarget}”? -

-

- The extension folder will be permanently deleted. -

-
-
- - {installedModels.length > 0 && ( -
-

- Also delete downloaded model weights: -

- {installedModels.map((v) => { - const id = `${uninstallTarget}/${v.id}` - const checked = modelsToDelete.has(id) - return ( - - ) - })} -
- )} - -
- - -
-
-
-
, - document.body - ) - })()} -
- ) } From 51d7ceccf058269c5f32b12d53bf391671279136 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Wed, 10 Jun 2026 11:32:12 -0400 Subject: [PATCH 21/22] Update fmt.Println message from 'Hello' to 'Goodbye' --- src/areas/workflows/WorkflowsPage.tsx | 1341 ------------------------- 1 file changed, 1341 deletions(-) diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index fb9d18d..1a415e8 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -1354,1344 +1354,3 @@ export default function WorkflowsPage(): JSX.Element {
) } -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { - ReactFlow, - ReactFlowProvider, - Background, - - addEdge, - useNodesState, - useEdgesState, - useReactFlow, - type Connection, - type Node, - type Edge, - type OnConnectStartParams, -} from '@xyflow/react' -import { useWorkflowsStore } from '@shared/stores/workflowsStore' -import { useExtensionsStore } from '@shared/stores/extensionsStore' -import { useNavStore } from '@shared/stores/navStore' -import { useAppStore } from '@shared/stores/appStore' -import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electron.d' -import { buildAllWorkflowExtensions, getWorkflowExtension } from './mockExtensions' -import type { WorkflowExtension } from './mockExtensions' -import { useWorkflowRunStore } from './workflowRunStore' -import { validateWorkflowPreflight } from './preflight' -import ExtensionNode from './nodes/ExtensionNode' -import ImageNode from './nodes/ImageNode' -import TextNode from './nodes/TextNode' -import AddToSceneNode from './nodes/AddToSceneNode' -import Load3DMeshNode from './nodes/Load3DMeshNode' -import PreviewImageNode from './nodes/PreviewImageNode' -import WaitNode from './nodes/WaitNode' -import WorkflowEdge from './nodes/WorkflowEdge' - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const DRAG_KEY = 'modly/extension-id' -const DRAG_NODE_KEY = 'modly/node-type' -const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } -const EDGE_TYPES = { workflowEdge: WorkflowEdge } - -const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } - -// ─── IO badge ───────────────────────────────────────────────────────────────── - -const IO_STYLES: Record<'image' | 'text' | 'mesh', string> = { - image: 'bg-sky-500/15 text-sky-400 border-sky-500/25', - mesh: 'bg-violet-500/15 text-violet-400 border-violet-500/25', - text: 'bg-amber-500/15 text-amber-400 border-amber-500/25', -} - -function IoBadge({ type }: { type: 'image' | 'text' | 'mesh' }) { - return ( - - {type} - - ) -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function newId(): string { return crypto.randomUUID() } - -function newWorkflow(): Workflow { - const now = new Date().toISOString() - return { id: newId(), name: 'New Workflow', description: '', nodes: [], edges: [], createdAt: now, updatedAt: now } -} - -function newWorkflowFromTemplate(): Workflow { - const now = new Date().toISOString() - const imageNodeId = newId() - const outputNodeId = newId() - return { - id: newId(), - name: 'New Workflow', - description: '', - nodes: [ - { id: imageNodeId, type: 'imageNode', position: { x: 150, y: 180 }, data: { enabled: true, params: {}, showInGenerate: true } }, - { id: outputNodeId, type: 'outputNode', position: { x: 500, y: 180 }, data: { enabled: true, params: {} } }, - ], - edges: [ - { id: newId(), source: imageNodeId, target: outputNodeId, type: 'workflowEdge' }, - ], - createdAt: now, - updatedAt: now, - } -} - -// ─── New workflow modal ─────────────────────────────────────────────────────── - -function NewWorkflowModal({ onBlank, onTemplate, onClose }: { - onBlank: () => void - onTemplate: () => void - onClose: () => void -}) { - return ( -
-
e.stopPropagation()} - > -
-

New Workflow

-

Choose how to start

-
- -
- {/* Blank */} - - - {/* Starter template */} - -
-
-
- ) -} - -// ─── Extensions panel ──────────────────────────────────────────────────────── - -const PANEL_MIN = 240 -const PANEL_MAX = 860 - -const PANEL_BUILTIN_NODES = [ - { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <> }, - { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <> }, - { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, - { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, - { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <> }, - { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <> }, -] - -function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { - return ( - - ) -} - -function ExtensionsPanel({ allExtensions, open }: { allExtensions: WorkflowExtension[]; open: boolean }) { - const [search, setSearch] = useState('') - const [collapsed, setCollapsed] = useState>({}) - const [width, setWidth] = useState(288) - const dragging = useRef(false) - const startX = useRef(0) - const startW = useRef(0) - - useEffect(() => { - const onMove = (e: MouseEvent) => { - if (!dragging.current) return - const delta = startX.current - e.clientX - setWidth((w) => Math.min(PANEL_MAX, Math.max(PANEL_MIN, startW.current + delta))) - } - const onUp = () => { dragging.current = false; document.body.style.cursor = '' } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - return () => { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - } - }, []) - - const cols = width >= 580 ? 3 : width >= 370 ? 2 : 1 - const gridClass = cols === 3 ? 'grid-cols-3' : cols === 2 ? 'grid-cols-2' : 'grid-cols-1' - const query = search.trim().toLowerCase() - - const toggleGroup = (id: string) => setCollapsed((c) => ({ ...c, [id]: !c[id] })) - const isExpanded = (id: string, hasMatches: boolean) => (query && hasMatches) || !collapsed[id] - - // Base group - const filteredBuiltinNodes = PANEL_BUILTIN_NODES.filter((n) => !query || n.label.toLowerCase().includes(query)) - const filteredBuiltinExts = allExtensions.filter((e) => e.builtin && (!query || e.name.toLowerCase().includes(query))) - const baseCount = filteredBuiltinNodes.length + filteredBuiltinExts.length - const baseVisible = !query || baseCount > 0 - - // Non-builtin groups: grouped by extensionId - const nonBuiltinMap = useMemo(() => { - const map = new Map() - for (const ext of allExtensions) { - if (ext.builtin) continue - if (!map.has(ext.extensionId)) map.set(ext.extensionId, { extensionName: ext.extensionName, nodes: [] }) - map.get(ext.extensionId)!.nodes.push(ext) - } - return map - }, [allExtensions]) - - return ( -
-
- - {/* Resize handle */} -
{ - dragging.current = true; startX.current = e.clientX; startW.current = width - document.body.style.cursor = 'col-resize'; e.preventDefault() - }} - className="w-1 shrink-0 hover:bg-zinc-600 active:bg-accent/60 cursor-col-resize transition-colors self-stretch" - /> - -
- - {/* Header */} -
-

Extensions

-

Drag onto canvas

-
- - {/* Search */} -
-
- - - - setSearch(e.target.value)} - placeholder="Search…" - className="flex-1 bg-transparent text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none min-w-0" - /> - {search && ( - - )} -
-
- - {/* Groups */} -
- - {/* ── Base group ── */} - {baseVisible && ( -
- 0)} - onToggle={() => toggleGroup('base')} - count={baseCount} - /> - {isExpanded('base', baseCount > 0) && ( -
- {filteredBuiltinNodes.map(({ type, label, color, icon }) => ( -
{ e.dataTransfer.setData(DRAG_NODE_KEY, type); e.dataTransfer.effectAllowed = 'copy' }} - className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" - > -
- {icon} -

{label}

-
-
- ))} - {filteredBuiltinExts.map((ext) => ( -
{ e.dataTransfer.setData(DRAG_KEY, ext.id); e.dataTransfer.effectAllowed = 'copy' }} - className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" - > -

{ext.name}

-
- - - - - -
-
- ))} -
- )} -
- )} - - {/* ── Non-builtin extension groups ── */} - {[...nonBuiltinMap.entries()].map(([extId, { extensionName, nodes }]) => { - const filtered = nodes.filter((e) => !query || e.name.toLowerCase().includes(query)) - if (query && filtered.length === 0) return null - const displayNodes = query ? filtered : nodes - const expanded = isExpanded(extId, filtered.length > 0) - - return ( -
- toggleGroup(extId)} - count={displayNodes.length} - /> - {expanded && ( -
- {displayNodes.map((ext) => ( -
{ e.dataTransfer.setData(DRAG_KEY, ext.id); e.dataTransfer.effectAllowed = 'copy' }} - className="flex flex-col gap-2 px-3 py-3 rounded-lg border border-zinc-800 bg-zinc-900 transition-colors cursor-grab hover:bg-zinc-800/60 hover:border-zinc-700 active:cursor-grabbing" - > -

{ext.name}

- {ext.description && cols === 1 && ( -

{ext.description}

- )} -
- - - - - -
-
- ))} -
- )} -
- ) - })} - - {/* Empty state */} - {query && baseCount === 0 && [...nonBuiltinMap.values()].every((g) => !g.nodes.some((e) => e.name.toLowerCase().includes(query))) && ( -

No results for "{query}"

- )} - -
-
-
-
- ) -} - -// ─── Page ─── toggle button icon ───────────────────────────────────────────── - -function PanelToggleIcon({ open }: { open: boolean }) { - return ( - - - - - ) -} - -// ─── Node palette (Space to open) ──────────────────────────────────────────── - -const BUILTIN_NODES = [ - { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' }, - { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' }, - { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, - { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, - { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, - { type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, -] - -type PaletteItem = - | { kind: 'node'; data: typeof BUILTIN_NODES[0] } - | { kind: 'ext'; data: WorkflowExtension } - -type PaletteGroup = { - id: string - title: string - author?: string - expanded: boolean - items: Array -} - -function NodePalette({ - allExtensions, - onSelect, - onClose, -}: { - allExtensions: WorkflowExtension[] - onSelect: (type: string, extensionId?: string) => void - onClose: () => void -}) { - const [query, setQuery] = useState('') - const [collapsed, setCollapsed] = useState>({}) - const [activeIndex, setActiveIndex] = useState(0) - const inputRef = useRef(null) - - const q = query.trim().toLowerCase() - - const nonBuiltinMap = useMemo(() => { - const map = new Map() - for (const ext of allExtensions) { - if (ext.builtin) continue - if (!map.has(ext.extensionId)) map.set(ext.extensionId, { extensionName: ext.extensionName, extensionAuthor: ext.extensionAuthor, nodes: [] }) - map.get(ext.extensionId)!.nodes.push(ext) - } - return map - }, [allExtensions]) - - const toggleGroup = (id: string) => setCollapsed((c) => ({ ...c, [id]: !c[id] })) - const isExpanded = (id: string, hasMatches: boolean) => (!!q && hasMatches) || !collapsed[id] - - // Build groups with pre-assigned flat indices (drives keyboard nav) - const { groups, totalItems } = useMemo(() => { - const groups: PaletteGroup[] = [] - let flatIdx = 0 - - // Base group - const filteredBuiltinNodes = BUILTIN_NODES.filter((n) => !q || n.label.toLowerCase().includes(q) || n.description.toLowerCase().includes(q)) - const filteredBuiltinExts = allExtensions.filter((e) => e.builtin && (!q || e.name.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q))) - const baseCount = filteredBuiltinNodes.length + filteredBuiltinExts.length - const baseVisible = !q || baseCount > 0 - const baseExp = isExpanded('base', baseCount > 0) - - if (baseVisible) { - const items: PaletteGroup['items'] = [] - if (baseExp) { - filteredBuiltinNodes.forEach((n) => items.push({ kind: 'node', data: n, flatIdx: flatIdx++ })) - filteredBuiltinExts.forEach((e) => items.push({ kind: 'ext', data: e, flatIdx: flatIdx++ })) - } - groups.push({ id: 'base', title: 'Base', expanded: baseExp, items }) - } - - // Non-builtin groups - for (const [extId, { extensionName, extensionAuthor, nodes }] of nonBuiltinMap) { - const filtered = nodes.filter((e) => !q || e.name.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q)) - if (q && filtered.length === 0) continue - const displayNodes = q ? filtered : nodes - const expanded = isExpanded(extId, filtered.length > 0) - const items: PaletteGroup['items'] = [] - if (expanded) displayNodes.forEach((e) => items.push({ kind: 'ext', data: e, flatIdx: flatIdx++ })) - groups.push({ id: extId, title: extensionName, author: extensionAuthor || undefined, expanded, items }) - } - - return { groups, totalItems: flatIdx } - }, [q, allExtensions, nonBuiltinMap, collapsed]) - - useEffect(() => { setActiveIndex(0) }, [query]) - useEffect(() => { inputRef.current?.focus() }, []) - - // Flat list for Enter key (derived from groups) - const flatItems = useMemo(() => groups.flatMap((g) => g.items), [groups]) - - const handleKey = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Escape') { onClose(); return } - if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, totalItems - 1)); return } - if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); return } - if (e.key === 'Enter') { - e.preventDefault() - const item = flatItems[activeIndex] - if (!item) return - if (item.kind === 'node') onSelect(item.data.type) - else onSelect('extensionNode', item.data.id) - } - }, [activeIndex, flatItems, totalItems, onSelect, onClose]) - - return ( -
-
e.stopPropagation()} - > - {/* Search input */} -
- - - - setQuery(e.target.value)} - onKeyDown={handleKey} - placeholder="Search nodes and extensions…" - className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none" - /> - Esc -
- - {/* Groups */} -
- {groups.map((group) => ( -
- - {/* Group header */} - - - {/* Group items */} - {group.expanded && group.items.map((item) => { - const isActive = activeIndex === item.flatIdx - if (item.kind === 'node') { - const n = item.data - return ( - - ) - } - const e = item.data - return ( - - ) - })} - -
- ))} - - {totalItems === 0 && groups.length === 0 && ( -

No results for "{query}"

- )} -
-
-
- ) -} - -// ─── Help modal ─────────────────────────────────────────────────────────────── - -function HelpModal({ onClose }: { onClose: () => void }) { - const [helperImg, setHelperImg] = useState(null) - useEffect(() => { - window.electron.fs.readScreenshotDataUrl('workflow-helper.png').then(setHelperImg).catch(() => {}) - }, []) - return createPortal( -
{ if (e.target === e.currentTarget) onClose() }} - > -
-
- - {/* Header */} -
-
-
- - - -
-

How the workflow system works

-
- -
- -
- - {/* Concept */} -
-

Concept

-

- A workflow is a directed graph of nodes. Each node receives data from its inputs (left handle) and produces a result on its output (right handle). Data flows from left to right — you connect nodes by dragging from one handle to another. -

-
- - {/* Example screenshot */} - {helperImg && ( -
- Basic workflow example -

- Example — Image → AI model → Add to Scene -

-
- )} - - {/* Node types */} -
-

Node types

-
- -
- image -
-

Image

-

Source node. Pick a local image file — it becomes the input of the first processing node.

-
-
- -
- text -
-

Text

-

Source node. Pass a text prompt to extensions that accept text input.

-
-
- -
- mesh -
-

Load 3D Mesh

-

Source node. Load a .glb, .obj, .stl or .ply file from disk, or use the model currently loaded in the 3D viewer.

-
-
- -
-
- image - - mesh -
-
-

Model extension (AI generator)

-

Runs a locally installed AI model to convert an image into a 3D mesh. Requires the model weights to be downloaded first from the Extensions page.

-
-
- -
-
- mesh - - mesh -
-
-

Process extension (mesh processor)

-

Transforms a mesh — examples: Optimize Mesh (polygon reduction), Export Mesh (save to file). No GPU required.

-
-
- -
- scene -
-

Add to Scene

-

Terminal node. Receives the final mesh and loads it directly into the 3D viewer when the workflow completes.

-
-
- -
-
- - {/* Tips */} -
-

Tips

-
    - {[ - ['Space', 'Open the node palette on the canvas'], - ['Eye icon', 'Pin a node to the Generate page side panel'], - ['Drag handle → canvas', 'Auto-opens the palette to connect a new node'], - ['Right-click a link', 'Delete the connection between two nodes'], - ['Run', 'Saves & executes the workflow, result goes to the 3D scene'], - ].map(([key, desc]) => ( -
  • - {key} - {desc} -
  • - ))} -
-
- -
-
-
, - document.body - ) -} - -// ─── Connection type helpers ────────────────────────────────────────────────── - -function getNodeOutputType(node: Node | undefined, allExts: WorkflowExtension[]): string | undefined { - if (!node) return undefined - if (node.type === 'imageNode') return 'image' - if (node.type === 'meshNode') return 'mesh' - if (node.type === 'textNode') return 'text' - return allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)?.output -} - -function getNodeInputType( - node: Node | undefined, - targetHandle: string | null | undefined, - allExts: WorkflowExtension[], -): string | undefined { - if (!node) return undefined - if (node.type === 'outputNode') return 'mesh' - if (node.type === 'previewNode') return 'image' - const ext = allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId) - if (ext?.inputs && ext.inputs.length > 1 && targetHandle) { - const idx = parseInt(targetHandle.replace('input-', ''), 10) - return ext.inputs[isNaN(idx) ? 0 : idx] ?? ext.input - } - return ext?.input -} - -// ─── Workflow canvas (inner, requires ReactFlowProvider) ────────────────────── - -function WorkflowCanvasInner({ - workflow, allExtensions, onSave, onDelete, onExport, panelOpen, onTogglePanel, onNew, onImport, -}: { - workflow: Workflow - allExtensions: WorkflowExtension[] - onSave: (w: Workflow) => void - onDelete: () => void - onExport: () => void - panelOpen: boolean - onTogglePanel: () => void - onNew: () => void - onImport: () => void -}) { - const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() - const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() - const currentMeshUrl = useAppStore((s) => s.currentJob?.outputUrl) - const showToast = useAppStore((s) => s.showToast) - const isRunning = runState.status === 'running' || runState.status === 'paused' - - const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) - const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) - const [name, setName] = useState(workflow.name) - const [paletteOpen, setPaletteOpen] = useState(false) - const [helpOpen, setHelpOpen] = useState(false) - - // Pending connection: set when user drags a handle and releases on empty canvas - const pendingConnectionRef = useRef(null) - const connectionCompletedRef = useRef(false) - const [pendingDropPos, setPendingDropPos] = useState<{ x: number; y: number } | null>(null) - - const saveTimer = useRef | null>(null) - const preflightToastTimer = useRef | null>(null) - const didMountRef = useRef(false) - - // ─── Undo / Redo ────────────────────────────────────────────────────────── - type Snapshot = { nodes: Node[]; edges: Edge[]; name: string } - const historyRef = useRef([{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[], name: workflow.name }]) - const histIdxRef = useRef(0) - const [histIdx, setHistIdx] = useState(0) - const skipPushRef = useRef(true) // skip the initial autosave-triggered push - - // Re-sync when workflow switches - useEffect(() => { - setNodes(workflow.nodes as Node[]) - setEdges(workflow.edges as Edge[]) - setName(workflow.name) - historyRef.current = [{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[], name: workflow.name }] - histIdxRef.current = 0 - setHistIdx(0) - skipPushRef.current = true - }, [workflow.id]) - - // Auto-save + history push debounced - useEffect(() => { - if (saveTimer.current) clearTimeout(saveTimer.current) - saveTimer.current = setTimeout(() => { - const updated: Workflow = { - ...workflow, - name, - nodes: nodes as WFNode[], - edges: edges as WFEdge[], - updatedAt: new Date().toISOString(), - } - onSave(updated) - - if (!skipPushRef.current) { - const next = historyRef.current.slice(0, histIdxRef.current + 1) - next.push({ nodes, edges, name }) - if (next.length > 50) next.shift() - historyRef.current = next - const newIdx = next.length - 1 - histIdxRef.current = newIdx - setHistIdx(newIdx) - } - skipPushRef.current = false - }, 500) - return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } - }, [nodes, edges, name]) - - const preflightIssues = useMemo(() => { - const draft: Workflow = { - ...workflow, - name, - nodes: nodes as WFNode[], - edges: edges as WFEdge[], - updatedAt: workflow.updatedAt, - } - return validateWorkflowPreflight(draft, allExtensions, { currentMeshUrl }) - }, [workflow, name, nodes, edges, allExtensions, currentMeshUrl]) - - useEffect(() => { - if (!didMountRef.current) { - didMountRef.current = true - return - } - if (preflightToastTimer.current) clearTimeout(preflightToastTimer.current) - if (preflightIssues.length === 0) return - preflightToastTimer.current = setTimeout(() => { - showToast(preflightIssues[0].message) - }, 250) - return () => { - if (preflightToastTimer.current) clearTimeout(preflightToastTimer.current) - } - }, [preflightIssues, showToast]) - - const undo = useCallback(() => { - const idx = histIdxRef.current - if (idx <= 0) return - const newIdx = idx - 1 - const snap = historyRef.current[newIdx] - skipPushRef.current = true - setNodes(snap.nodes) - setEdges(snap.edges) - setName(snap.name) - histIdxRef.current = newIdx - setHistIdx(newIdx) - }, [setNodes, setEdges]) - - const redo = useCallback(() => { - const idx = histIdxRef.current - if (idx >= historyRef.current.length - 1) return - const newIdx = idx + 1 - const snap = historyRef.current[newIdx] - skipPushRef.current = true - setNodes(snap.nodes) - setEdges(snap.edges) - setName(snap.name) - histIdxRef.current = newIdx - setHistIdx(newIdx) - }, [setNodes, setEdges]) - - const canUndo = histIdx > 0 - const canRedo = histIdx < historyRef.current.length - 1 - - const isValidConnection = useCallback((connection: Connection) => { - const srcType = getNodeOutputType(getNode(connection.source) as Node, allExtensions) - const tgtType = getNodeInputType(getNode(connection.target) as Node, connection.targetHandle, allExtensions) - if (!srcType || !tgtType) return true // unknown type — allow - return srcType === tgtType - }, [getNode, allExtensions]) - - const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, params: OnConnectStartParams) => { - pendingConnectionRef.current = params - connectionCompletedRef.current = false - }, []) - - const onConnect = useCallback((params: Connection) => { - connectionCompletedRef.current = true - setEdges((eds) => addEdge({ ...params, ...DEFAULT_EDGE_OPTS }, eds)) - }, [setEdges]) - - const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => { - if (connectionCompletedRef.current || !pendingConnectionRef.current?.nodeId) { - pendingConnectionRef.current = null - return - } - // Dropped on empty canvas (not on a handle or node body) - const target = event.target as Element - if (target.closest('.react-flow__node') || target.closest('.react-flow__handle')) { - pendingConnectionRef.current = null - return - } - const clientX = 'clientX' in event ? event.clientX : (event as TouchEvent).changedTouches[0].clientX - const clientY = 'clientY' in event ? event.clientY : (event as TouchEvent).changedTouches[0].clientY - setPendingDropPos({ x: clientX, y: clientY }) - setPaletteOpen(true) - }, []) - - const onDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); e.dataTransfer.dropEffect = 'copy' - }, []) - - const onDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - const position = screenToFlowPosition({ x: e.clientX, y: e.clientY }) - - const nodeType = e.dataTransfer.getData(DRAG_NODE_KEY) - if (nodeType) { - setNodes((nds) => [...nds, { - id: newId(), type: nodeType, position, - data: { enabled: true, params: {} } as WFNodeData, - }]) - return - } - - const extensionId = e.dataTransfer.getData(DRAG_KEY) - if (!extensionId) return - setNodes((nds) => [...nds, { - id: newId(), type: 'extensionNode', position, - data: { extensionId, enabled: true, params: {} } as WFNodeData, - }]) - }, [screenToFlowPosition, setNodes]) - - // Keyboard shortcuts (Space, Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z) - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return - if (e.code === 'Space') { - e.preventDefault() - setPaletteOpen(true) - return - } - if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') { - e.preventDefault() - undo() - return - } - if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { - e.preventDefault() - redo() - } - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, [undo, redo]) - - const addNodeFromPalette = useCallback((type: string, extensionId?: string) => { - const position = screenToFlowPosition( - pendingDropPos ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 } - ) - const newNodeId = newId() - setNodes((nds) => [...nds, { - id: newNodeId, type, position, - data: { extensionId, enabled: true, params: {} } as WFNodeData, - }]) - - // If palette was opened from a connection drag, wire the edge automatically - const pending = pendingConnectionRef.current - if (pending?.nodeId) { - const isSource = pending.handleType === 'source' - const edge = isSource - ? { id: newId(), source: pending.nodeId, sourceHandle: pending.handleId ?? undefined, target: newNodeId } - : { id: newId(), source: newNodeId, target: pending.nodeId, targetHandle: pending.handleId ?? undefined } - setEdges((eds) => addEdge({ ...edge, ...DEFAULT_EDGE_OPTS }, eds)) - } - - pendingConnectionRef.current = null - setPendingDropPos(null) - setPaletteOpen(false) - }, [screenToFlowPosition, setNodes, setEdges, pendingDropPos]) - - const handleRun = useCallback(() => { - if (isRunning) { cancel(); return } - if (preflightIssues.length > 0) { - showToast(preflightIssues[0].message) - return - } - const wf: Workflow = { ...workflow, name, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString() } - onSave(wf) - runWorkflow(wf, allExtensions) - }, [workflow, name, nodes, edges, onSave, allExtensions, isRunning, runWorkflow, cancel, preflightIssues, showToast]) - - return ( -
- - {paletteOpen && ( - { - pendingConnectionRef.current = null - setPendingDropPos(null) - setPaletteOpen(false) - }} - /> - )} - - {/* Header toolbar */} -
- - {/* New */} - - - {/* Import */} - - -
- - {/* Undo */} - - - {/* Redo */} - - -
- - {/* Name input */} - setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} - placeholder="Workflow name…" - className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700/80 rounded-md px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" - /> - -
- -
- {/* Run / Stop */} - - - {/* Progress indicator */} - {isRunning && ( -
- - - - {runState.blockStep} -
- )} - - {/* Export */} - - - {/* Delete */} - - - {/* Help */} - -
-
- - {helpOpen && setHelpOpen(false)} />} - - {/* React Flow canvas */} -
- - {/* No model node warning */} - {!nodes.some((n) => n.type === 'extensionNode' && allExtensions.find((e) => e.id === (n.data as WFNodeData).extensionId && e.type === 'model')) && ( -
-
- - - - No AI model node in this workflow — add one from the extensions panel to generate a 3D mesh. -
-
- )} - - {/* Floating panel toggle — over the canvas, below the header */} - - - { e.preventDefault(); setEdges((eds) => eds.filter((ed) => ed.id !== edge.id)) }} - defaultEdgeOptions={DEFAULT_EDGE_OPTS} - deleteKeyCode="Delete" - connectionLineStyle={{ stroke: '#71717a', strokeWidth: 1.5 }} - fitView - fitViewOptions={{ padding: 0.3 }} - proOptions={{ hideAttribution: true }} - className="bg-[#0f0f10]" - > - - -
-
- ) -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function WorkflowsPage(): JSX.Element { - const { workflows, loading, activeId, load, save, remove, importFile, exportFile, setActive } = useWorkflowsStore() - const { modelExtensions, processExtensions, loadExtensions } = useExtensionsStore() - - const [panelOpen, setPanelOpen] = useState(true) - - const allExtensions = useMemo( - () => buildAllWorkflowExtensions(modelExtensions, processExtensions), - [modelExtensions, processExtensions], - ) - - useEffect(() => { load(); loadExtensions() }, []) - - // Auto-select first workflow when none is active or the active id no longer exists - useEffect(() => { - if (loading) return - if (workflows.length === 0) return - if (activeId && workflows.find((w) => w.id === activeId)) return - setActive(workflows[0].id) - }, [workflows, loading, activeId]) - - const activeWorkflow = workflows.find((w) => w.id === activeId) ?? null - - async function handleCreateBlank() { - const wf = newWorkflow() - await save(wf) - setActive(wf.id) - } - - async function handleImport() { - const result = await importFile() - if (result.success && result.workflow) setActive((result.workflow as Workflow).id) - } - - return ( -
- - {/* Tab bar */} - {!loading && ( -
- {workflows.map((wf) => ( -
setActive(wf.id)} - onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); remove(wf.id) } }} - className={`relative flex items-center gap-1.5 pl-3 pr-1.5 h-full text-[11px] font-medium shrink-0 transition-colors border-b-2 cursor-pointer group - ${wf.id === activeId - ? 'text-zinc-100 border-accent bg-zinc-900/50' - : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20 border-transparent' - }`} - > - {wf.name || 'Untitled'} - -
- ))} -
- )} - - {/* Canvas + extensions panel */} -
- - {activeWorkflow ? ( - - { remove(activeWorkflow.id) }} - onExport={() => exportFile(activeWorkflow)} - panelOpen={panelOpen} - onTogglePanel={() => setPanelOpen((o) => !o)} - onNew={handleCreateBlank} - onImport={handleImport} - /> - - ) : ( -
- - - - -
-

No workflows yet

-

Create one to get started

-
-
- - -
-
- )} - - {/* Extensions panel */} - -
-
- ) -} From 1094410cff1b372822443ce718063b687d08b611 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Wed, 10 Jun 2026 11:32:46 -0400 Subject: [PATCH 22/22] Add newline at end of WorkflowPanel.tsx Fix missing newline at the end of WorkflowPanel.tsx