diff --git a/docs/model-management-plan.md b/docs/model-management-plan.md new file mode 100644 index 000000000..ef71d5150 --- /dev/null +++ b/docs/model-management-plan.md @@ -0,0 +1,153 @@ +# Model Management & Downloads Panel Plan + +## Goal + +Bring the desktop/ComfyUI_frontend model management experience into the +Launcher's **Models** view, and add a global **Downloads sidebar panel** — a +toggleable drawer docked to the left sidebar (like browser download drawers, or +the existing `comfyContentScript.ts` floating toast in the ComfyUI windows). + +--- + +## Current State + +### What the Launcher already has +- **`ModelsView.vue`** — shows configured model directories with DirCard + widgets (browse, add, remove, reorder). No model file listing, no browsing, + no search. +- **`DownloadsPanel.vue`** — renders active & finished downloads as inline + cards. Currently embedded *inside* ModelsView at the bottom. +- **`downloadStore.ts`** — Pinia store that subscribes to + `model-download-progress` IPC events from the main process and tracks them + in a reactive `Map`. +- **`comfyDownloadManager.ts`** (main process) — handles Electron + `will-download`, temp files, managed model downloads and general + browser-like downloads. Broadcasts progress to both the originating + ComfyUI window and the Launcher window. +- **`comfyContentScript.ts`** — injected into ComfyUI webview pages; creates a + floating download toast panel with a collapsible tab, drag-to-undock, and + inline pause/resume/cancel buttons. Theme-aware. + +### What the desktop app / frontend has +- **`DownloadManager.ts`** (desktop) — singleton managing `.safetensors` + downloads via Electron `session.downloadURL`. IPC handlers for start / pause + / resume / cancel / getAllDownloads / deleteModel. +- **`AssetBrowserModal.vue`** — full model browser with search, left nav by + category, filter bar (architecture, ownership), asset grid, right info panel. + Uses `useAssetBrowser`, `useModelTypes`, `useModelUpload` composables. +- **`UploadModelDialog.vue`** — 3-step wizard: enter URL → confirm metadata → + upload progress. +- **`ModelImportProgressDialog.vue`** — uses `HoneyToast` (expandable toast) + with `ProgressToastItem` cards, filter popover (all/completed/failed). +- **`modelStore.ts`** — `ComfyModelDef` class (metadata from safetensors), + `ModelFolder` (lazy-load per directory). Loaded from ComfyUI API. + +--- + +## Design + +### 1. Downloads Sidebar Panel (global, always accessible) + +Replace the current approach of embedding `DownloadsPanel` inside `ModelsView` +with a **sidebar-attached drawer** that is accessible from any view. + +**Behaviour:** +- A persistent **download indicator** appears in the sidebar when there are + active or recent downloads — a small icon/badge next to the Models tab (this + already exists as `sidebar-count`). +- Clicking the indicator (or a dedicated button) toggles a **drawer panel** + that slides out from the left sidebar, overlaying the content area. +- The drawer stays open across view switches. Clicking outside or pressing + Escape closes it. +- The drawer shows the same content currently in `DownloadsPanel.vue`: active + downloads with progress bars, pause/resume/cancel, and finished downloads + with dismiss/show-in-folder. + +**Implementation:** + +| Layer | File | Change | +|-------|------|--------| +| Component | `src/renderer/src/components/DownloadsDrawer.vue` | **New.** Renders a slide-out drawer panel anchored to the sidebar. Reuses all the formatting logic from `DownloadsPanel.vue` (move or import). Add a close button and "clear completed" button in the header. | +| App shell | `src/renderer/src/App.vue` | Add `` alongside the sidebar. Add `downloadsDrawerOpen` ref. Wire sidebar indicator click to toggle it. Remove `` from ModelsView embedding. | +| View | `src/renderer/src/views/ModelsView.vue` | Remove the `` include (downloads move to the global drawer). | +| Style | `src/renderer/src/assets/main.css` | Add `.downloads-drawer` styles: fixed position left of content, slide transition, backdrop, z-index layering. | +| Store | `downloadStore.ts` | No changes needed — already provides the reactive data. | + +**Drawer visual spec:** +``` +┌──────────┬──────────────────┬──────────────────────┐ +│ Sidebar │ Downloads Drawer │ Content area (dimmed) │ +│ │ ┌──────────────┐ │ │ +│ ● Dash │ │ Downloads │ │ │ +│ Inst │ │ │ │ │ +│ Run │ │ [card] │ │ │ +│ ● Models │ │ [card] │ │ │ +│ Media │ │ [card] │ │ │ +│ Sett │ └──────────────┘ │ │ +└──────────┴──────────────────┴──────────────────────┘ +``` + +Width: ~340px. Background: `var(--surface)`. Border-right: `var(--border)`. + +### 2. Model Browser in ModelsView + +Replace the current directory-card-only Models view with a tabbed layout: + +**Tab 1: "Directories"** (current content) — model directory configuration. + +**Tab 2: "Browse Models"** — a file browser that lists model files in the +configured directories, grouped by folder type. + +**Implementation:** + +| Layer | File | Change | +|-------|------|--------| +| IPC type | `src/types/ipc.ts` | Add `ModelFileInfo` type (`name`, `directory`, `sizeBytes`, `modified`). Add `getModelFiles(directory: string): Promise` to `ElectronApi`. | +| Main | `src/main/lib/models.ts` | Add `listModelFiles(baseDir, directory)` function that scans a model subdirectory and returns file info. | +| Main IPC | `src/main/lib/ipc.ts` | Register `get-model-files` handler. | +| Preload | `src/preload/index.ts` | Expose `getModelFiles`. | +| Component | `src/renderer/src/components/ModelBrowser.vue` | **New.** Left sidebar listing model folder types (`checkpoints`, `loras`, etc. from `MODEL_FOLDER_TYPES`). Main area shows a file list/grid for the selected folder. Search bar at top. File cards show name, size, date. | +| Component | `src/renderer/src/components/ModelFileCard.vue` | **New.** Single model file card — name, size, modified date, "Show in Folder" button, optional delete button. | +| View | `src/renderer/src/views/ModelsView.vue` | Add tab switcher (Directories / Browse). Conditionally render `ModelBrowser` or the existing dir-card content. | + +### 3. Download from URL (stretch) + +Add the ability to initiate a model download from the Launcher's Models view +(similar to the frontend's `UploadModelDialog` wizard). + +| Layer | File | Change | +|-------|------|--------| +| Component | `src/renderer/src/components/DownloadModelDialog.vue` | **New.** Simple dialog: URL input, destination folder dropdown (from `MODEL_FOLDER_TYPES`), optional filename override, Download button. | +| Main | `src/main/lib/comfyDownloadManager.ts` | Add a `startLauncherDownload(url, filename, directory)` variant that works from the Launcher window directly (not from a ComfyUI webview). The infrastructure is already there — we just need an IPC handler that calls `startModelDownload` with the launcher window. | +| Preload | `src/preload/index.ts` | Expose `startModelDownload(url, filename, directory)`. | +| IPC type | `src/types/ipc.ts` | Add `startModelDownload` to `ElectronApi`. | + +--- + +## Implementation Order + +1. **Phase 1: Downloads Drawer** — Extract `DownloadsPanel` into + `DownloadsDrawer`, wire it into the App shell sidebar. This is the most + impactful UX improvement and is self-contained. + +2. **Phase 2: Model Browser** — Add the file-listing backend IPC + the + `ModelBrowser` component in `ModelsView`. + +3. **Phase 3: Download from URL** — Add the download dialog and + launcher-initiated download path. + +--- + +## Notes + +- The Launcher already uses `lucide-vue-next` for sidebar icons — use the same + for the drawer's close/clear/folder icons. +- The existing CSS design system (`--surface`, `--border`, `--accent`, etc.) + should be used throughout — no new design tokens needed. +- The drawer should work with all existing themes (dark, light, nord, etc.). +- The `comfyContentScript.ts` floating toast in ComfyUI windows is independent + and should NOT be changed — it serves a different context (inside the + webview). +- General (non-model) downloads already flow through the same pipeline via the + `will-download` fallback in `comfyDownloadManager.ts`, so the drawer will + automatically show those too. diff --git a/locales/en.json b/locales/en.json index 25c91066a..e9f0e745c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -52,7 +52,16 @@ "completed": "Complete", "cancelled": "Cancelled", "error": "Failed", - "sizeProgress": "{received} / {total}" + "sizeProgress": "{received} / {total}", + "clearCompleted": "Clear completed", + "downloadModel": "Download Model", + "urlLabel": "URL", + "urlPlaceholder": "https://example.com/model.safetensors", + "filenameLabel": "Filename", + "filenamePlaceholder": "model.safetensors", + "directoryLabel": "Destination Folder", + "startDownload": "Download", + "downloadFailed": "Download failed. Please check the URL and try again." }, "media": { @@ -70,7 +79,14 @@ "removeDir": "Remove", "primary": "primary", "default": "default", - "makePrimary": "Make Primary" + "makePrimary": "Make Primary", + "browse": "Browse", + "directoriesTab": "Directories", + "folderTypes": "Model Types", + "searchPlaceholder": "Search files…", + "noFiles": "No model files found", + "openFolder": "Open folder", + "fileCount": "{count} files" }, "common": { @@ -102,6 +118,7 @@ "extract": "Extract", "useSharedPaths": "Use Shared Directories", "done": "Done", + "loading": "Loading…", "noItems": "No items available.", "advanced": "Advanced", "tabStatus": "Status", diff --git a/src/main/lib/comfyDownloadManager.ts b/src/main/lib/comfyDownloadManager.ts index 1a82cc006..2809b5c94 100644 --- a/src/main/lib/comfyDownloadManager.ts +++ b/src/main/lib/comfyDownloadManager.ts @@ -476,4 +476,13 @@ export function registerDownloadIpc(): void { shell.showItemInFolder(path.resolve(savePath)) } }) + + ipcMain.handle( + 'launcher-start-download', + (event, { url, filename, directory }: { url: string; filename: string; directory: string }) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return false + return startModelDownload(win, url, filename, directory) + }, + ) } diff --git a/src/main/lib/ipc.ts b/src/main/lib/ipc.ts index c8fb1a0cb..a8a9ef62e 100644 --- a/src/main/lib/ipc.ts +++ b/src/main/lib/ipc.ts @@ -26,7 +26,7 @@ import { formatTime } from './util' import { getActiveDownloads } from './comfyDownloadManager' import * as releaseCache from './release-cache' import * as i18n from './i18n' -import { ensureModelPathsConfig } from './models' +import { ensureModelPathsConfig, MODEL_FOLDER_TYPES } from './models' import { copyDirWithProgress } from './copy' import { fetchJSON } from './fetch' import { fetchLatestRelease, truncateNotes } from './comfyui-releases' @@ -1175,6 +1175,43 @@ export function register(callbacks: RegisterCallbacks = {}): void { } }) + ipcMain.handle('get-model-folders', () => { + return [...MODEL_FOLDER_TYPES] + }) + + ipcMain.handle('get-model-files', (_event, directory: string) => { + const modelsDirs = (settings.get('modelsDirs') as string[]) || settings.defaults.modelsDirs + const files: Array<{ name: string; directory: string; fullPath: string; sizeBytes: number; modifiedAt: number }> = [] + const ALLOWED_EXTS = new Set(['.safetensors', '.sft', '.ckpt', '.pth', '.pt', '.bin', '.onnx']) + + for (const baseDir of modelsDirs) { + const dirPath = path.join(baseDir, directory) + try { + if (!fs.existsSync(dirPath)) continue + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile()) continue + const ext = path.extname(entry.name).toLowerCase() + if (!ALLOWED_EXTS.has(ext)) continue + try { + const filePath = path.join(dirPath, entry.name) + const stat = fs.statSync(filePath) + files.push({ + name: entry.name, + directory, + fullPath: filePath, + sizeBytes: stat.size, + modifiedAt: stat.mtimeMs, + }) + } catch {} + } + } catch {} + } + + files.sort((a, b) => b.modifiedAt - a.modifiedAt) + return files + }) + ipcMain.handle('get-media-sections', () => { const s = settings.getAll() return [ diff --git a/src/preload/index.ts b/src/preload/index.ts index 817419820..5b69c66b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -96,6 +96,12 @@ const api: ElectronApi = { resumeModelDownload: (url) => ipcRenderer.invoke('model-download-resume', { url }), cancelModelDownload: (url) => ipcRenderer.invoke('model-download-cancel', { url }), showDownloadInFolder: (savePath) => ipcRenderer.invoke('show-download-in-folder', { savePath }), + startModelDownload: (url, filename, directory) => + ipcRenderer.invoke('launcher-start-download', { url, filename, directory }), + + // Model file browser + getModelFolders: () => ipcRenderer.invoke('get-model-folders'), + getModelFiles: (directory: string) => ipcRenderer.invoke('get-model-files', directory), // Updates checkForUpdate: () => ipcRenderer.invoke('check-for-update'), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8c4a54ac3..e0b577e97 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -604,6 +604,27 @@ input[type="checkbox"]:checked::after { transform: translateX(16px); } .modal-select-item-label { flex: 1; font-weight: 500; } .modal-select-item-desc { color: var(--text-muted); font-size: 12px; } +/* Modal layout (for structured dialogs with header/body/footer) */ +.modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 24px; border-bottom: 1px solid var(--border); +} +.modal-body { padding: 16px 24px; } +.modal-footer { + display: flex; align-items: center; justify-content: flex-end; gap: 8px; + padding: 12px 24px; border-top: 1px solid var(--border); +} + +/* Form field inputs */ +.field-input { + padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; + background: var(--surface); color: var(--text); font-size: 14px; + outline: none; width: 100%; +} +.field-input:focus { border-color: var(--accent); } +.field-input::placeholder { color: var(--text-faint); } +select.field-input { cursor: pointer; } + /* Update banner */ .update-banner { display: flex; align-items: center; gap: 12px; @@ -881,3 +902,37 @@ button.detail-header-btn.active:hover:not(:disabled) { button.detail-header-btn:disabled { cursor: default; opacity: 0.4; } + +/* Downloads drawer */ +.downloads-drawer-backdrop { + position: fixed; inset: 0; z-index: 40; + background: var(--overlay-bg); + transition: opacity 0.2s ease; +} +.downloads-drawer { + position: fixed; top: 0; bottom: 0; left: 200px; z-index: 45; + width: 340px; background: var(--surface); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + transition: transform 0.2s ease; + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.3); +} +.downloads-drawer-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 16px 12px; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.downloads-drawer-title { + font-size: 15px; font-weight: 600; color: var(--text); +} +.downloads-drawer-actions { + display: flex; align-items: center; gap: 4px; +} +.downloads-drawer-body { + flex: 1; overflow-y: auto; padding: 12px; + display: flex; flex-direction: column; gap: 8px; +} +.downloads-drawer-empty { + display: flex; align-items: center; justify-content: center; + height: 100%; color: var(--text-muted); font-size: 14px; +} diff --git a/src/renderer/src/components/DownloadModelDialog.vue b/src/renderer/src/components/DownloadModelDialog.vue new file mode 100644 index 000000000..f1513bd83 --- /dev/null +++ b/src/renderer/src/components/DownloadModelDialog.vue @@ -0,0 +1,125 @@ + + + diff --git a/src/renderer/src/components/DownloadsDrawer.vue b/src/renderer/src/components/DownloadsDrawer.vue new file mode 100644 index 000000000..a5d1e2d5c --- /dev/null +++ b/src/renderer/src/components/DownloadsDrawer.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/src/renderer/src/components/ModelBrowser.vue b/src/renderer/src/components/ModelBrowser.vue new file mode 100644 index 000000000..62af31b01 --- /dev/null +++ b/src/renderer/src/components/ModelBrowser.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/src/renderer/src/types/ipc.ts b/src/renderer/src/types/ipc.ts index 055706593..401b14513 100644 --- a/src/renderer/src/types/ipc.ts +++ b/src/renderer/src/types/ipc.ts @@ -43,6 +43,7 @@ export type { UpdateDownloadProgress, ModelDownloadStatus, ModelDownloadProgress, + ModelFileInfo, TrackResult, CopyEvent, SnapshotDiffSummary, diff --git a/src/renderer/src/views/ModelsView.vue b/src/renderer/src/views/ModelsView.vue index f5567218c..32f173e76 100644 --- a/src/renderer/src/views/ModelsView.vue +++ b/src/renderer/src/views/ModelsView.vue @@ -2,8 +2,11 @@ import { ref, onMounted } from 'vue' import DirCard from '../components/DirCard.vue' import DownloadsPanel from '../components/DownloadsPanel.vue' +import ModelBrowser from '../components/ModelBrowser.vue' import type { ModelsSection } from '../types/ipc' +const activeTab = ref<'directories' | 'browse'>('directories') + const systemDefault = ref('') const sections = ref([]) @@ -69,8 +72,24 @@ defineExpose({ loadModels }) {{ $t('models.title') }} +
+ + +
-
+
+ +
+ +
diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 3a204157d..bea24ab9c 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -342,6 +342,15 @@ export interface ModelDownloadProgress { error?: string } +// --- Model file browser types --- +export interface ModelFileInfo { + name: string + directory: string + fullPath: string + sizeBytes: number + modifiedAt: number +} + // --- Track types --- export interface TrackResult { ok: boolean @@ -552,6 +561,11 @@ export interface ElectronApi { resumeModelDownload(url: string): Promise cancelModelDownload(url: string): Promise showDownloadInFolder(savePath: string): Promise + startModelDownload(url: string, filename: string, directory: string): Promise + + // Model file browser + getModelFolders(): Promise + getModelFiles(directory: string): Promise // Event listeners (return unsubscribe functions) onInstallProgress(callback: (data: ProgressData) => void): Unsubscribe