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": {} + } +}