From a6a31c808671cdbe1766675bcd940b7aa5d9da0b Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:15 +0200 Subject: [PATCH] feat(sidebar): Tools row + starter preset replaces curated_defaults (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): merge preset HEAD route into GET handler PR #68 shipped koolook_routes.py with @routes.get + @routes.head on the same path. aiohttp's add_get auto-registers HEAD by default, so the explicit @routes.head collided with the auto-synthesized HEAD and crashed ComfyUI startup with "Added route will never be executed, method HEAD is already registered". Trying allow_head=False on the GET to opt out triggered a second crash: ComfyUI's server.py mirrors every PromptServer route into a parallel /api/... table via api_routes.route(method, path)(handler, **kwargs), and RouteTableDef.route's inner closure rejects unknown kwargs with TypeError. So no kwarg-based opt-out is viable. Drop the dedicated HEAD handler. Aiohttp's auto-HEAD-from-GET delivers HEAD probes to get_preset; we branch on request.method to skip the file read for HEAD — preserving the cheap-existence optimization the client's presetExists relies on for multi-MB JSON over Dropbox / iCloud / NFS-mounted libraries. CHANGELOG [Unreleased] updated to match. This bug never reached users — koolook_routes.py was added in #68 (post-v0.2.0) and was never published to Comfy Registry. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sidebar): Tools row + starter preset replaces curated_defaults Two coordinated UX changes for fresh-install distribution + admin ergonomics — designed together and shipped as one unit. 1. New "Tools" row sits between the Snapshot row and the search field. Moves the Export and "Install missing for picks" buttons up out of the Nodes row (admin/advanced operations don't belong in the everyday + Add toolbar). Adds a new "Drop placeholders onto canvas" button — the security_level=normal escape hatch for installing missing packs: instantiates one placeholder per missing pack on a fresh canvas tab so ComfyUI/Manager's standard "Install Missing Custom Nodes" detection takes over via Manager's UI flow (which uses different gates than our programmatic /customnode/install/ git_url call). Nodes row keeps only the everyday + button. 2. curated_defaults.json retired in favor of starter_preset.json. Fresh installs no longer get picks seeded directly into localStorage on first load. Instead the bundled web/starter_preset.json (full snapshot format — picks + workflows + tags + archive) is copied into the user's snapshot library directory as starter.json. The user opens Snapshot -> Load -> starter to apply in one click. Unifies fresh-install distribution with the per-user snapshot library (#68); maintainer flow becomes "build state in ComfyUI -> click the (down-arrow) Tools-row button -> paste over web/starter_preset.json -> commit." Existing users with non-empty picks are explicitly skipped by the new seeder, so their state is untouched. Removed: seedDefaultsIfNeeded, exportPicks, web/curated_defaults.json, SEEDED_KEY, DEFAULTS_URL. Added: seedStarterPresetIfNeeded, exportStarterPreset (snapshot.js), STARTER_SEEDED_KEY, STARTER_URL, STARTER_PRESET_FILENAME, discoverMissingPacks (installer.js), dropPlaceholdersForPacks (canvas_io.js), web/starter_preset.json. Docs: CLAUDE.md "Curated Nodes sidebar — distributed defaults" -> "Kforge Labs sidebar — starter preset distribution". docs/maintainers/curated-sidebar.md rewritten. CHANGELOG.md [Unreleased] updated. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 36 ++++++-- CLAUDE.md | 31 ++++--- docs/maintainers/curated-sidebar.md | 51 +++++++---- web/curated_defaults.json | 13 --- web/koolook_sidebar.js | 8 +- web/sidebar/canvas_io.js | 119 +++++++++++++++++++++++++ web/sidebar/constants.js | 14 ++- web/sidebar/installer.js | 27 ++++++ web/sidebar/picks_store.js | 71 --------------- web/sidebar/snapshot.js | 132 ++++++++++++++++++++++++++++ web/sidebar/tree.js | 103 ++++++++++++++++++---- web/starter_preset.json | 23 +++++ 12 files changed, 489 insertions(+), 139 deletions(-) delete mode 100644 web/curated_defaults.json create mode 100644 web/starter_preset.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c8424..6189f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,11 +113,37 @@ The format is inspired by Keep a Changelog and SemVer. Click a node on the canvas to re-select." `serializeSelection` returns a discriminated `{ kind: "empty" | "stale" | "ok", graph? }` result so the caller can route messaging precisely. -- **Curated-defaults seed failure now toasts.** When - `seedDefaultsIfNeeded` writes the seed list to localStorage and the - write is rejected (full disk, browser quota, private mode), users - see a toast pointing at the console instead of getting a silent - console-only warning. +- **`curated_defaults.json` retired in favor of `starter_preset.json`.** + Fresh installs no longer get picks seeded directly into localStorage + on first load. Instead the bundled `web/starter_preset.json` (full + snapshot format — picks + workflows + tags + archive) is copied into + the user's snapshot library directory as `starter.json`, and the user + opens Snapshot → Load to apply it in one click. The change unifies + fresh-install distribution with the per-user snapshot library + (#68): the maintainer flow becomes "build state in ComfyUI → click + the ↓ Tools-row button → paste over `web/starter_preset.json` → + commit." Existing users with non-empty picks are explicitly skipped + by the new seeder, so their state is untouched. Removed: + `seedDefaultsIfNeeded`, `exportPicks`, `web/curated_defaults.json`, + `SEEDED_KEY`, `DEFAULTS_URL`. Added: `seedStarterPresetIfNeeded`, + `exportStarterPreset` (both in `web/sidebar/snapshot.js`), + `STARTER_SEEDED_KEY`, `STARTER_URL`, `STARTER_PRESET_FILENAME`, + `web/starter_preset.json`. +- **Sidebar toolbar: Tools row split out from Nodes row.** The Export + button (now "Export starter preset") and the "Install missing for + picks" button moved up out of the Nodes row into a new dedicated + **Tools** row above the search field, alongside a new "Drop + placeholders onto canvas" button. The Nodes row keeps only the + everyday `+` Add button. Reasoning: Export and Install-missing are + admin/advanced operations; segregating them keeps the daily flow + uncluttered and makes it harder to fire them by accident. The new + Drop-placeholders button is the `security_level=normal` escape hatch + for "install missing" — it instantiates one placeholder per missing + pack on a fresh canvas tab so ComfyUI/Manager's standard "Install + Missing Custom Nodes" detection picks them up and routes the install + through Manager's UI flow (which doesn't go through + `/customnode/install/git_url`'s security gate the way our + programmatic call does). ### Internal (sidebar tidiness pass — no behavior change) - **Lifted save-modal action sentinels and the cascade picker diff --git a/CLAUDE.md b/CLAUDE.md index 38e88a2..f0dfe3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,24 +60,33 @@ The broader iteration pattern (trigger phrases, what survives across re-syncs vs what doesn't, push/publish gates) is captured in [`docs/maintainers/dev-iteration-loop.md`](docs/maintainers/dev-iteration-loop.md). -## Curated Nodes sidebar — distributed defaults +## Kforge Labs sidebar — starter preset distribution -The "Curated Nodes" ComfyUI sidebar tab (implemented in -`web/koolook_sidebar.js`) seeds users' localStorage from -`web/curated_defaults.json` on first load. To update the distributed -default favorites list: +The "Kforge Labs" ComfyUI sidebar tab (implemented in +`web/koolook_sidebar.js`) ships a single bundled snapshot file at +`web/starter_preset.json`. On first launch the seeder copies it into +the user's snapshot library directory (the same dir the Snapshot +Save/Load dialog reads) as `starter.json`. The user opens Snapshot → +Load, picks `starter`, clicks → picks + workflows + tags + archive +populated in one go. -1. Customize picks inside ComfyUI (sidebar `+` / `×` / right-click → Add). -2. Click the ↓ Export button → JSON copied to clipboard. -3. Paste into `web/curated_defaults.json`, replacing the file contents. +To update the distributed starter preset: + +1. Inside ComfyUI's Kforge Labs tab, dial in the state you want fresh + users to see — picks via `+` / `×` / right-click; workflows via the + Workflows section; tags via right-click → Tags… +2. In the **Tools** row (between Snapshot and the search field), click + the ↓ button → full snapshot JSON copied to clipboard. +3. Paste into `web/starter_preset.json`, replacing the file contents. 4. Commit + push (does **not** trigger publish — only `pyproject.toml` changes merged to `main` do). Full workflow including reset / verification steps: [`docs/maintainers/curated-sidebar.md`](docs/maintainers/curated-sidebar.md). -The seeder runs exactly once per browser, so existing users keep their -personalized list when defaults change — the new default only reaches -fresh installs and users who clear browser data. +The seeder runs exactly once per browser AND skips users with non-empty +existing picks (legacy curated_defaults.json users), so changing the +shipped starter doesn't disturb anyone's existing state — the new +starter only reaches fresh installs and users who clear browser data. ## Releasing diff --git a/docs/maintainers/curated-sidebar.md b/docs/maintainers/curated-sidebar.md index 4be6321..41c5476 100644 --- a/docs/maintainers/curated-sidebar.md +++ b/docs/maintainers/curated-sidebar.md @@ -1,39 +1,58 @@ -# Curated Nodes sidebar — quick reference +# Kforge Labs sidebar starter preset — quick reference -The "Curated Nodes" sidebar tab in ComfyUI; implementation in +The "Kforge Labs" sidebar tab (id `koolook.curatedNodes`); implementation in [`web/koolook_sidebar.js`](../../web/koolook_sidebar.js). -## Update the distributed defaults +## What ships to fresh users -1. In ComfyUI's **Curated Nodes** tab, customize picks via `+` / `×` / right-click. -2. Click the **↓** button → JSON copied to clipboard. -3. Open [`web/curated_defaults.json`](../../web/curated_defaults.json) in your editor, paste over the contents, save. +A single bundled snapshot file at [`web/starter_preset.json`](../../web/starter_preset.json) — same format the user-facing **Snapshot** library uses (picks + workflows + tags + archive). On first launch the seeder copies it into the user's snapshot library directory as `starter.json`. The user opens **Snapshot → Load**, sees one entry called *starter*, clicks → state populated. + +Nothing seeds into `localStorage` directly anymore — replaced the legacy `curated_defaults.json` pick-only seed in 0.3.0. + +## Update the shipped starter preset + +1. In ComfyUI's **Kforge Labs** tab, customize the state you want fresh users to see — picks via `+` / `×` / right-click; workflows via the Workflows section; tags via right-click "Tags…". +2. In the **Tools** row (between Snapshot and the search field), click the **↓** button → full snapshot JSON copied to clipboard. +3. Open [`web/starter_preset.json`](../../web/starter_preset.json) in your editor, paste over the contents, save. 4. `git commit ... && git push`. Does **not** trigger publish — only `pyproject.toml` changes merged to `main` do. ## Files & storage keys | Where | What | |---|---| -| `web/curated_defaults.json` (repo) | Distributed default favorites | -| `web/koolook_sidebar.js` (repo) | Sidebar implementation | -| `localStorage["koolook.curated.userPicks.v1"]` | User's actual list (per-browser) | -| `localStorage["koolook.curated.seeded.v1"]` | `"1"` once defaults seeded for this browser | +| `web/starter_preset.json` (repo) | Bundled starter preset, snapshot format | +| `web/koolook_sidebar.js` (repo) | Sidebar entry point | +| `web/sidebar/snapshot.js` (repo) | `seedStarterPresetIfNeeded` + `exportStarterPreset` | +| `/starter.json` (per-install) | The seeded preset on the user's disk | +| `localStorage["koolook.starter.seeded.v1"]` | `"1"` once the seed has been attempted on this browser | +| `localStorage["koolook.curated.userPicks.v1"]` | User's actual picks list (per-browser) | +| `localStorage["koolook.snapshot.currentPresetName.v1"]` | Last preset the user loaded/saved | -## Reset (force re-seed from defaults) +The library directory is configured by (in priority order): +1. The `libraryPath` field in `/koolook-settings.json` (set via Snapshot → Settings dialog), +2. The `KFORGELABS_PRESETS` env var (deployment / facility config), +3. Default fallback `/koolook-presets/`. + +See [`koolook_routes.py`](../../koolook_routes.py) for the resolution chain. + +## Reset (force re-seed) In the browser DevTools console with ComfyUI open: ```js +localStorage.removeItem("koolook.starter.seeded.v1"); localStorage.removeItem("koolook.curated.userPicks.v1"); -localStorage.removeItem("koolook.curated.seeded.v1"); +// And delete /starter.json on disk (or via Snapshot → Load → × button) location.reload(); ``` -Use this to verify a new default before shipping, or to recover after a manual edit gone wrong. +The seeder bails when picks are non-empty (treats it as "existing user, don't re-seed"), so the picks deletion is necessary for the seed to fire. ## Things that surprised us — keep in mind -- **Paste is manual.** Browsers can't write to your filesystem. The ↓ button only copies to clipboard / falls back to a download. Nothing auto-updates `curated_defaults.json`. -- **Other users can't reach the repo.** Their ↓ click only touches their own clipboard. localStorage is browser-local; nothing uploads anywhere. Only you, with push access, can change the distributed default. -- **Seeding runs exactly once per browser.** Existing users keep their picks when you ship a new default — only fresh installs and users who clear browser data get the new list. To verify the seeded UX yourself, use the reset block above. +- **The seeder needs the snapshot library reachable.** It calls the same `/koolook/presets/file` endpoint the Save dialog uses. If the library path is read-only, points at a non-existent directory, or the routes module isn't loaded, the seed silently retries on the next page load — no error toast for fresh users. See the console for `[Koolook] starter seed: …` messages. +- **Paste is manual.** Browsers can't write to your filesystem. The ↓ button only copies to clipboard / falls back to a download. Nothing auto-updates `starter_preset.json`. +- **Other users can't reach the repo.** Their ↓ click only touches their own clipboard / library. Only you, with push access, can change the distributed starter. +- **Seeding runs exactly once per browser.** Existing users with picks already in localStorage are explicitly skipped (the `loadUserPicks().length > 0` early-return) so a new starter doesn't disturb their state. To preview the fresh-install UX yourself, use the reset block above. +- **The seed populates the library, not the canvas.** After seeding, the user still has empty picks until they explicitly Load the starter preset. This was the design choice for 0.3.0 — discoverable, opt-in, respects user agency. Pattern 1 (auto-apply on first run, identical to the old curated_defaults UX) was rejected because the source-of-state becomes invisible to the user. - **No publish on this kind of push.** Edits under `web/` never trigger the registry publish workflow; only a `pyproject.toml` change merged to `main` does. See [`releasing.md`](releasing.md). diff --git a/web/curated_defaults.json b/web/curated_defaults.json deleted file mode 100644 index 388b851..0000000 --- a/web/curated_defaults.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "picks": [ - "GetImageSizeAndCount", - "GetNode", - "ImageScaleBy", - "LoadEXR", - "NukeConstant", - "NukeOCIOColorSpace", - "SaveEXRFrames", - "Text Multiline", - "easy showAnything" - ] -} diff --git a/web/koolook_sidebar.js b/web/koolook_sidebar.js index 37a824f..9a844fc 100644 --- a/web/koolook_sidebar.js +++ b/web/koolook_sidebar.js @@ -13,7 +13,7 @@ import { TAB_TOOLTIP, TAB_ICON, } from "./sidebar/constants.js"; -import { seedDefaultsIfNeeded } from "./sidebar/picks_store.js"; +import { seedStarterPresetIfNeeded } from "./sidebar/snapshot.js"; import { loadWorkflowsStore, seedWorkflowDefaultsIfNeeded, @@ -28,7 +28,11 @@ app.registerExtension({ console.warn("[Koolook] extensionManager.registerSidebarTab not available; sidebar not registered."); return; } - await seedDefaultsIfNeeded(); + // Seeds the bundled starter preset into the user's snapshot library + // on first run (replaces the legacy `curated_defaults.json` localStorage + // pick-seed). Async-fires server I/O — kept off the critical path so + // sidebar tab registration isn't blocked behind it on a slow server. + seedStarterPresetIfNeeded(); const loadResult = await loadWorkflowsStore(); // Skip workflow seeding when /userdata file is corrupt — we don't want // to overwrite a recoverable-but-unparseable file with stock defaults. diff --git a/web/sidebar/canvas_io.js b/web/sidebar/canvas_io.js index f370da9..538e582 100644 --- a/web/sidebar/canvas_io.js +++ b/web/sidebar/canvas_io.js @@ -169,6 +169,125 @@ export async function loadWorkflowOntoCanvas(dirPath, wfName) { } } +// ============================================================================= +// Placeholder workflow for "Install missing via Manager UI" handoff +// ============================================================================= + +// Build a fresh workflow that contains one node per missing pack, using a +// representative node ID from each pack as the node `type`. The pack isn't +// installed, so ComfyUI renders these as red "missing node type" placeholders +// — which is exactly what triggers Manager's built-in "Install Missing Custom +// Nodes" detection (Manager scans the active graph for unknown types and +// looks them up in its mapping). The user then drives Manager's own UI to +// install, which goes through Manager's UI-driven install path rather than +// our programmatic /customnode/install/git_url call — so it works at +// `security_level=normal` without us monkey-patching anyone's gate. +// +// Layout: 4-column grid, 220×100 px cells, starting at (50, 50). Each pack +// becomes one node, captioned by its representative node ID. Loaded into a +// fresh tab named "missing-nodes-for-picks" so the user can close it without +// touching their real workflow. +// +// `packsByUrl` is the same Map shape `resolvePicksToInstall().willInstall` +// returns: gitUrl → array of node IDs from that pack that are missing. We +// take the first node ID per pack as the placeholder's type. One per pack +// is enough — Manager dedupes by pack, so dropping 27 nodes from one pack +// just slows the canvas without telling Manager anything new. +// +// Confirms canvas replacement before loading (preserves the user's current +// graph as a rollback target if `loadGraphData` throws). Returns a +// discriminated result so the caller can decide how to surface failures. +export async function dropPlaceholdersForPacks(packsByUrl) { + if (!packsByUrl || packsByUrl.size === 0) { + return { ok: false, reason: "no-packs" }; + } + + const cellW = 220, cellH = 100, cols = 4, marginX = 50, marginY = 50; + const nodes = []; + let nextId = 1; + let idx = 0; + for (const nodeIds of packsByUrl.values()) { + if (!Array.isArray(nodeIds) || nodeIds.length === 0) continue; + const repName = nodeIds[0]; + const col = idx % cols; + const row = Math.floor(idx / cols); + nodes.push({ + id: nextId++, + type: repName, + pos: [marginX + col * cellW, marginY + row * cellH], + size: [cellW - 20, cellH - 20], + flags: {}, + order: idx, + mode: 0, + inputs: [], + outputs: [], + properties: { "Node name for S&R": repName }, + widgets_values: [], + }); + idx++; + } + + if (nodes.length === 0) return { ok: false, reason: "no-nodes" }; + + const graph = { + last_node_id: nextId - 1, + last_link_id: 0, + nodes, + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4, + }; + + const apply = async () => { + let previousGraph = null; + try { + if (canvasIsNonEmpty()) previousGraph = app.graph.serialize(); + } catch (e) { + previousGraph = null; + } + try { + await app.loadGraphData(graph, true, true, "missing-nodes-for-picks", {}); + toast( + `Dropped ${nodes.length} placeholder${nodes.length === 1 ? "" : "s"}. ` + + `Open Manager → "Install Missing Custom Nodes" to install.` + ); + return { ok: true, count: nodes.length }; + } catch (e) { + console.error("[Koolook] dropPlaceholders loadGraphData failed:", e); + if (previousGraph) { + try { + await app.loadGraphData(previousGraph); + toast("Failed to drop placeholders. Canvas restored. See console."); + return { ok: false, reason: "load-failed", error: e }; + } catch (restoreErr) { + console.error("[Koolook] canvas restore failed:", restoreErr); + } + } + toast("Failed to drop placeholders. See console."); + return { ok: false, reason: "load-failed", error: e }; + } + }; + + if (canvasIsNonEmpty()) { + return new Promise(resolve => { + showConfirmModal({ + title: "Replace current workflow?", + message: + `This will replace your canvas with ${nodes.length} placeholder ` + + `node${nodes.length === 1 ? "" : "s"} (one per missing pack). ` + + `Manager's "Install Missing Custom Nodes" will then offer to install ` + + `them. Save your current work first if you have anything unsaved.`, + confirmLabel: "Replace canvas", + danger: true, + onConfirm: async () => resolve(await apply()), + }); + }); + } + return await apply(); +} + // ============================================================================= // Node insertion // ============================================================================= diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 023babb..7236314 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -31,9 +31,19 @@ export const WORKFLOWS_GROUP_LABEL = "Workflows"; // Defaults JSON files live in web/ alongside the entry; this module is one // level deeper at web/sidebar/, so the relative URL needs the extra `..`. export const STORAGE_KEY = "koolook.curated.userPicks.v1"; -export const SEEDED_KEY = "koolook.curated.seeded.v1"; export const PICKS_CHANGED_EVENT = "koolook-picks-changed"; -export const DEFAULTS_URL = new URL("../curated_defaults.json", import.meta.url).href; + +// Starter preset — a single shipped snapshot file the seeder copies into the +// user's snapshot library directory on first run (instead of seeding picks +// directly into localStorage). Carries the full snapshot schema (picks + +// workflows + tags + archive), so the user gets one Load click for the whole +// starter state. `STARTER_SEEDED_KEY` is the localStorage flag that says +// "we've already attempted the seed on this browser, don't retry." +export const STARTER_SEEDED_KEY = "koolook.starter.seeded.v1"; +export const STARTER_URL = new URL("../starter_preset.json", import.meta.url).href; +// Filename inside the snapshot library — without `.json`, matches the +// shape `writePreset(fileName, ...)` expects. +export const STARTER_PRESET_FILENAME = "starter"; export const WORKFLOWS_USERDATA_PATH = "koolook_workflows.json"; export const WORKFLOWS_FALLBACK_KEY = "koolook.workflows.fallback.v1"; diff --git a/web/sidebar/installer.js b/web/sidebar/installer.js index 2427afa..90c8842 100644 --- a/web/sidebar/installer.js +++ b/web/sidebar/installer.js @@ -100,6 +100,33 @@ export function resolvePicksToInstall(picks, urlByNodeId) { return { alreadyInstalled, willInstall: { byUrl }, unresolved }; } +// ============================================================================= +// UI-free discovery helper +// ============================================================================= + +// Wraps the detect → loadMappings → resolvePicksToInstall sequence into a +// single call so non-modal callers (e.g. the "drop placeholders onto canvas" +// button) can run discovery without paying for the Install-Missing modal. +// +// Discriminated result. `ok: true` means we have a usable bucket breakdown; +// `ok: false` means we hit a precondition failure that should surface as a +// toast or an in-place message rather than as an empty bucket list. +// +// { ok: true, result: { alreadyInstalled, willInstall: { byUrl }, unresolved } } +// { ok: false, reason: "manager-unreachable" } +// { ok: false, reason: "mapping-load-failed", error: Error } +export async function discoverMissingPacks(picks) { + const managerOk = await detectManager(); + if (!managerOk) return { ok: false, reason: "manager-unreachable" }; + let mappings; + try { + mappings = await loadMappings(); + } catch (e) { + return { ok: false, reason: "mapping-load-failed", error: e }; + } + return { ok: true, result: resolvePicksToInstall(picks, mappings.urlByNodeId) }; +} + // ============================================================================= // Install queue dispatch // ============================================================================= diff --git a/web/sidebar/picks_store.js b/web/sidebar/picks_store.js index df73366..ca64c5c 100644 --- a/web/sidebar/picks_store.js +++ b/web/sidebar/picks_store.js @@ -3,10 +3,7 @@ // ============================================================================= import { STORAGE_KEY, - SEEDED_KEY, PICKS_CHANGED_EVENT, - DEFAULTS_URL, - toast, } from "./constants.js"; export function loadUserPicks() { @@ -64,71 +61,3 @@ export function notifyPicksChanged() { window.dispatchEvent(new CustomEvent(PICKS_CHANGED_EVENT)); } -export async function seedDefaultsIfNeeded() { - if (localStorage.getItem(SEEDED_KEY)) return; - if (loadUserPicks().length > 0) { - localStorage.setItem(SEEDED_KEY, "1"); - return; - } - try { - const resp = await fetch(DEFAULTS_URL); - if (!resp.ok) { - localStorage.setItem(SEEDED_KEY, "1"); - return; - } - const data = await resp.json(); - const picks = (data && Array.isArray(data.picks)) - ? data.picks.filter(p => typeof p === "string") - : []; - if (picks.length > 0) { - // Only mark seeded if the save actually landed — otherwise the next - // page load retries instead of being permanently blocked by the flag. - if (!saveUserPicks(picks)) { - console.warn("[Koolook] seed save failed; will retry on next load"); - // Surface the failure in the UI too — the console-only path - // is invisible to anyone who hasn't opened DevTools, and the - // most common cause (full or quota-restricted localStorage) - // is something the user can act on. - toast("Could not seed default favorites — localStorage write rejected. See console."); - return; - } - } - localStorage.setItem(SEEDED_KEY, "1"); - console.log(`[Koolook] seeded ${picks.length} default pick(s)`); - } catch (e) { - console.warn("[Koolook] failed to load curated_defaults.json:", e); - localStorage.setItem(SEEDED_KEY, "1"); - } -} - -function picksAsDistributionJSON() { - const picks = [...loadUserPicks()].sort(); - return JSON.stringify({ picks }, null, 2) + "\n"; -} - -export async function exportPicks() { - const picks = loadUserPicks(); - if (picks.length === 0) { - toast("No picks to export yet."); - return; - } - const json = picksAsDistributionJSON(); - const noun = picks.length === 1 ? "pick" : "picks"; - try { - await navigator.clipboard.writeText(json); - toast(`Copied ${picks.length} ${noun} to clipboard. Paste into web/curated_defaults.json.`); - return; - } catch (e) { - console.warn("[Koolook] clipboard write failed, falling back to download:", e); - } - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "curated_defaults.json"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - toast(`Downloaded ${picks.length} ${noun} as curated_defaults.json. Replace the file in web/ with it.`); -} diff --git a/web/sidebar/snapshot.js b/web/sidebar/snapshot.js index 27ee56c..529bae1 100644 --- a/web/sidebar/snapshot.js +++ b/web/sidebar/snapshot.js @@ -34,6 +34,12 @@ // ============================================================================= import { loadUserPicks, setAllPicks, notifyPicksChanged } from "./picks_store.js"; import { replaceAllWorkflows, getAllWorkflowsForExport } from "./workflows_store.js"; +import { + STARTER_SEEDED_KEY, + STARTER_URL, + STARTER_PRESET_FILENAME, + toast, +} from "./constants.js"; export const SNAPSHOT_KIND = "koolook-snapshot"; export const SNAPSHOT_VERSION = 1; @@ -117,6 +123,132 @@ export async function applySnapshot(snapshot) { return { picksOk, workflowsOk }; } +// ============================================================================= +// Starter preset distribution — replaces the legacy curated_defaults.json +// pick-only seed with a full snapshot file shipped at web/starter_preset.json. +// On a fresh install the seeder copies it into the user's snapshot library +// (via the same /koolook/presets/file endpoint the Save dialog uses), so it +// shows up as one entry called "starter" in the Load dialog. The user gets +// one click to populate picks + workflows + tags + archive — and full agency +// to delete or edit it later, since it's just a regular preset on disk. +// ============================================================================= + +// Seed the bundled starter preset into the user's snapshot library iff: +// • we haven't already attempted on this browser (`STARTER_SEEDED_KEY`), +// • the user is fresh (no existing picks — existing users keep their state), +// • the library is reachable AND has no preset called "starter" yet. +// +// If the seed succeeds, toast the user so they know the Load dialog now has +// something to apply. On any reachability failure (server down, fetch error) +// we deliberately DON'T set the seeded flag — the next page load retries. +export async function seedStarterPresetIfNeeded() { + if (localStorage.getItem(STARTER_SEEDED_KEY)) return; + + // Existing user with non-empty picks → already seeded by the legacy + // curated_defaults.json flow. Mark and skip; their state is theirs. + if (loadUserPicks().length > 0) { + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + return; + } + + // Existence probe — `presetExists` returns true/false/null where null + // means the library is unreachable. We bail without setting the flag on + // null so a server hiccup doesn't permanently skip the seed. + let exists; + try { + exists = await presetExists(STARTER_PRESET_FILENAME); + } catch (e) { + console.warn("[Koolook] starter seed: presetExists threw:", e); + return; + } + if (exists === null) { + console.warn("[Koolook] starter seed: library unreachable, retrying next load"); + return; + } + if (exists === true) { + // Already there — facility-shared library, or a re-install on a + // machine that previously had it. Mark and move on. + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + return; + } + + // Fetch the bundled preset from the package's web/ folder. A 4xx/5xx or + // a parse error means the package is broken; mark seeded so we don't + // retry forever, and log so the maintainer notices. + let preset; + try { + const resp = await fetch(STARTER_URL); + if (!resp.ok) { + console.warn(`[Koolook] starter_preset.json HTTP ${resp.status}; skipping seed`); + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + return; + } + preset = await resp.json(); + } catch (e) { + console.warn("[Koolook] failed to fetch starter_preset.json:", e); + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + return; + } + + // Validate before writing — we don't want to seed a malformed file into + // every fresh install. validateSnapshot throws with a user-readable + // message; we just log it (no toast — fresh users shouldn't see scary + // errors about a feature they haven't discovered yet). + try { + validateSnapshot(preset); + } catch (e) { + console.warn("[Koolook] starter_preset.json failed validation:", e.message); + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + return; + } + + try { + await writePreset(STARTER_PRESET_FILENAME, preset); + } catch (e) { + console.warn("[Koolook] starter preset write failed (retrying next load):", e.message); + // Don't set the flag — a read-only mount or network blip should + // self-heal on the user's next launch. + return; + } + + localStorage.setItem(STARTER_SEEDED_KEY, "1"); + console.log(`[Koolook] seeded starter preset into snapshot library as "${STARTER_PRESET_FILENAME}.json"`); + toast(`Starter preset "${STARTER_PRESET_FILENAME}" added — open Snapshot → Load to populate picks & workflows.`); +} + +// Maintainer flow: capture the current full state as the bundled starter +// preset for the next release. Copies a JSON-formatted snapshot to clipboard +// (with download fallback) so the maintainer can paste it over +// `web/starter_preset.json` in the repo. +// +// Distinct from the regular Snapshot Save flow — that writes to the user's +// snapshot library directory (per-user state). This writes nothing to disk; +// it just hands the maintainer the JSON for a repo-side commit. +export async function exportStarterPreset() { + const snapshot = gatherSnapshot(STARTER_PRESET_FILENAME); + const json = JSON.stringify(snapshot, null, 2) + "\n"; + const pickN = snapshot.picks.length; + const wfN = countWorkflowsInStore(snapshot.workflows); + const summary = `${pickN} pick${pickN === 1 ? "" : "s"} · ${wfN} workflow${wfN === 1 ? "" : "s"}`; + try { + await navigator.clipboard.writeText(json); + toast(`Copied starter preset to clipboard (${summary}). Paste into web/starter_preset.json.`); + return; + } catch (e) { + console.warn("[Koolook] clipboard write failed, falling back to download:", e); + } + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "starter_preset.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast(`Downloaded starter_preset.json (${summary}). Replace the file in web/ with it.`); +} + // ============================================================================= // Current-preset tracking — remembers which preset the user last loaded or // saved, so a Save button can default to "save over the current one" instead diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index c80803e..01747dd 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -25,7 +25,6 @@ import { removeFromMyPicks, notifyPicksChanged, addToMyPicks, - exportPicks, } from "./picks_store.js"; import { persistMutation, @@ -56,7 +55,9 @@ import { loadWorkflowOntoCanvas, insertNode, getSelectedNodeTypes, + dropPlaceholdersForPacks, } from "./canvas_io.js"; +import { discoverMissingPacks } from "./installer.js"; import { showInputModal, showConfirmModal, @@ -82,6 +83,7 @@ import { saveSettings, getCurrentPresetName, setCurrentPresetName, + exportStarterPreset, } from "./snapshot.js"; // ============================================================================= @@ -1392,6 +1394,83 @@ export function renderPanel(container) { container.appendChild(snapshotRow); + // ---- Row 0b: Tools (admin/advanced — not for daily use) ---- + // Sits between the Snapshot row and the search field. Holds power-user + // utilities a typical user shouldn't trigger by accident: exporting the + // curated_defaults.json (maintainer flow), the Manager-driven install of + // missing packs (only useful on a fresh install or after wiping nodes), + // and the canvas-handoff fallback for `security_level=normal` users + // (drops placeholder nodes so Manager's own "Install Missing Custom + // Nodes" UI takes over). Keeping these out of the per-section toolbars + // declutters the everyday Add/Save flow. + const dividerAfterSnapshot = document.createElement("div"); + dividerAfterSnapshot.className = "koolook-tree-divider"; + container.appendChild(dividerAfterSnapshot); + + const toolsRow = document.createElement("div"); + toolsRow.className = "koolook-actions-row"; + + const toolsLabel = document.createElement("span"); + toolsLabel.className = "koolook-actions-label"; + toolsLabel.textContent = "Tools"; + toolsRow.appendChild(toolsLabel); + + toolsRow.appendChild(makeToolbarButton({ + iconClass: "pi pi-download", + title: "Export current state as starter_preset.json (copies snapshot JSON to clipboard — paste into web/starter_preset.json to ship as the next release's starter)", + onClick: exportStarterPreset, + })); + + // "Install missing for picks" — always enabled. The modal itself decides + // whether anything actually needs installing (after probing Manager and + // walking picks against the live LiteGraph registry), so leaving the + // button enabled lets the user trigger the discovery flow whenever they + // want a status check, including the "everything's already installed" + // confirmation. Click handlers don't read picks here — the modal pulls + // a fresh `loadUserPicks()` snapshot at open-time. + toolsRow.appendChild(makeToolbarButton({ + iconClass: "pi pi-cloud-download", + title: "Install missing custom nodes for current picks (via ComfyUI-Manager)", + onClick: () => showInstallMissingModal({ picks: loadUserPicks() }), + })); + + // "Drop missing onto canvas" — the security_level=normal escape hatch. + // Discovery runs the same detect+map+resolve pipeline as the install + // modal, then for each missing pack we instantiate one placeholder node + // (the pack's first mapped node ID) on a fresh canvas. ComfyUI renders + // unknown types as red error nodes, which Manager's "Install Missing + // Custom Nodes" feature scans for — so the user gets a one-click + // handoff to Manager's UI-driven install path that doesn't go through + // /customnode/install/git_url's security gate. + toolsRow.appendChild(makeToolbarButton({ + iconClass: "pi pi-th-large", + title: "Drop placeholders for missing packs onto canvas — then use Manager's \"Install Missing Custom Nodes\" (works at security_level=normal)", + onClick: async () => { + const picks = loadUserPicks(); + if (!picks || picks.length === 0) { + toast("No picks to check. Add some favorites first."); + return; + } + const discovery = await discoverMissingPacks(picks); + if (!discovery.ok) { + if (discovery.reason === "manager-unreachable") { + toast("ComfyUI-Manager isn't reachable — can't resolve picks to packs."); + } else { + toast(`Could not load Manager's mapping database: ${discovery.error?.message || discovery.reason}.`); + } + return; + } + const byUrl = discovery.result.willInstall.byUrl; + if (byUrl.size === 0) { + toast("Nothing missing — every pick is installed (or unmapped)."); + return; + } + await dropPlaceholdersForPacks(byUrl); + }, + })); + + container.appendChild(toolsRow); + const dividerBeforeSearch = document.createElement("div"); dividerBeforeSearch.className = "koolook-tree-divider"; container.appendChild(dividerBeforeSearch); @@ -1457,24 +1536,10 @@ export function renderPanel(container) { }); nodesRow.appendChild(addBtn); - nodesRow.appendChild(makeToolbarButton({ - iconClass: "pi pi-download", - title: "Export current picks as curated_defaults.json (copies JSON to clipboard)", - onClick: exportPicks, - })); - - // "Install missing for picks" — always enabled. The modal itself decides - // whether anything actually needs installing (after probing Manager and - // walking picks against the live LiteGraph registry), so leaving the - // button enabled lets the user trigger the discovery flow whenever they - // want a status check, including the "everything's already installed" - // confirmation. Click handlers don't read picks here — the modal pulls - // a fresh `loadUserPicks()` snapshot at open-time. - nodesRow.appendChild(makeToolbarButton({ - iconClass: "pi pi-cloud-download", - title: "Install missing custom nodes for current picks (via ComfyUI-Manager)", - onClick: () => showInstallMissingModal({ picks: loadUserPicks() }), - })); + // Daily-use Nodes row keeps only the Add (+) button — Export and + // Install-missing have been promoted up to the dedicated Tools row above + // the search field (they're admin/advanced operations the typical user + // shouldn't trigger from the everyday toolbar). container.appendChild(nodesRow); diff --git a/web/starter_preset.json b/web/starter_preset.json new file mode 100644 index 0000000..55e6477 --- /dev/null +++ b/web/starter_preset.json @@ -0,0 +1,23 @@ +{ + "kind": "koolook-snapshot", + "version": 1, + "name": "starter", + "exportedAt": "2026-05-05T14:30:00.000Z", + "picks": [ + "Frames Slice", + "GetImageSizeAndCount", + "GetNode", + "ImageScaleBy", + "LoadEXR", + "NukeConstant", + "NukeOCIOColorSpace", + "RepeatImageBatch", + "ReplaceImagesInBatch", + "SaveEXRFrames", + "Text Multiline", + "easy showAnything" + ], + "workflows": { + "directories": {} + } +}