Files

1261 lines
46 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// ModelPicker — search input, grouped model list, catalog sections,
// CivitAI live search, version/template submenus, hover cards.
import { untrack, tick } from "svelte";
import { extractPrecisions, resolveFilesForPrecision } from "../../lib/model-constants.js";
import HoverCard from "./HoverCard.svelte";
let {
anchorEl,
recognition = {},
onSelectModel,
onShowDownload,
onShowCatalogDownload,
onClose,
applyTemplate: applyTpl,
// In-place model swap: when the current node is a checkpoint graph, the template
// submenu offers "(Current Workflow)" at the top, which swaps the checkpoint and
// reapplies settings to the existing nodes instead of rebuilding from a template.
onSwapInPlace = null,
hasCheckpointGraph = false,
// When set (e.g. just after a download+restart), the picker scrolls to and
// highlights this model and surfaces its templates: a single template is
// applied automatically (no real choice), 2+ open the submenu so the user
// picks — we never choose the workflow for them.
focusModelFile = null,
} = $props();
// Checkpoint-family models can swap in place; flux/z-image use a different loader.
const NON_CHECKPOINT_ARCHS = new Set(["flux", "flux_fill", "flux_schnell", "flux2", "zimage", "zimage_base"]);
function canSwapInPlace(model) {
if (!hasCheckpointGraph || !onSwapInPlace || !model) return false;
const arch = (modelSettings[model.hash]?.architecture || "").toLowerCase();
return !NON_CHECKPOINT_ARCHS.has(arch);
}
// ── data ──
let models = $state([]);
let templates = $state([]);
let categoryOrder = $state([]);
let catalog = $state([]);
let modelSettings = $state({});
let loading = $state(true);
let searchQuery = $state("");
// CivitAI search
let civitaiResults = $state([]);
let civitaiCursor = $state(null);
let civitaiLoading = $state(false);
let civitaiSearchTimer = null;
// Submenus
let activeSubmenu = $state(null); // { type: "version"|"template", groupKey, anchorRect }
let activeNestedSubmenu = $state(null); // { model, versionLabel, anchorRect }
// Group highlighted after a download+restart (the just-installed model).
let highlightGroupName = $state(null);
// Hover card
let hoverCard;
let hoverTimeout = null;
$effect(() => () => {
clearTimeout(civitaiSearchTimer);
clearTimeout(hoverTimeout);
});
// Panel refs and positioning
let pickerEl;
let listEl;
let searchEl;
let panelStyle = $state("");
// Per-precision install state for every catalog entry, authoritative from the
// backend ("installed" | "partial" | "missing"). Derived straight from the
// catalog payload — no client-side file probing, so it can't drift from the
// real _all_files_installed check the way the old JS heuristic did (it only
// probed the diffusion file and flagged a precision installed while a
// companion encoder/VAE was still missing).
let catalogPrecisionStatus = $derived.by(() => {
const out = {};
for (const c of catalog) out[c.hash] = c.precision_status || {};
return out;
});
// ── load data ──
async function loadData() {
try {
const [modelsRes, templatesRes, catalogRes] = await Promise.all([
fetch("/promptchain/models/list").then(r => r.json()),
fetch("/promptchain/templates/list").then(r => r.json()),
fetch("/promptchain/models/catalog").then(r => r.json()),
]);
models = modelsRes.models || [];
templates = templatesRes.templates || [];
categoryOrder = templatesRes.category_order || [];
catalog = catalogRes.catalog || [];
// Bulk load model settings
const hashes = models.map(m => m.hash).filter(Boolean);
if (hashes.length) {
const settingsRes = await fetch("/promptchain/models/settings/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes }),
}).then(r => r.json());
modelSettings = settingsRes.settings || {};
}
loading = false;
requestAnimationFrame(() => searchEl?.focus());
focusDownloadedModel();
} catch {
loading = false;
}
}
// Surface the just-downloaded model: highlight + scroll to it, then apply its
// template if there's only one, or open the template submenu for the user to
// choose if there are several. Never picks the workflow for them.
async function focusDownloadedModel() {
if (!focusModelFile) return;
await tick();
const group = groupedModels.find(g => g.versions.some(v => v.filename === focusModelFile));
if (!group) return;
const model = group.versions.find(v => v.filename === focusModelFile) || group.versions[0];
highlightGroupName = group.name;
const tpls = templatesForModel(model);
if (tpls.length === 1) {
handleTemplateClick(tpls[0], model); // single template = no choice; apply + close
return;
}
if (tpls.length > 1) {
activeSubmenu = {
type: "template",
model,
displayName: modelSettings[model.hash]?.display_name || model.filename,
};
activeNestedSubmenu = null;
}
await tick();
listEl?.querySelector(".pcr-picker-item-highlight")?.scrollIntoView({ block: "center" });
}
// ── grouped models ──
let groupedModels = $derived.by(() => {
// Sort models alphabetically by display name first
const sorted = models.slice().sort((a, b) => {
const nameA = modelSettings[a.hash]?.display_name || a.filename;
const nameB = modelSettings[b.hash]?.display_name || b.filename;
return nameA.localeCompare(nameB);
});
// Grouping key prefers civitai_model_id so versions stay unified
// even when the CivitAI page name has drifted between recognitions
// (creators often rename / add emoji / append new base-models over
// time, which would otherwise split the group in two). Falls back
// to model_name for non-CivitAI models.
const groups = new Map();
for (const m of sorted) {
const s = modelSettings[m.hash];
const key = s?.civitai_model_id
? `civitai:${s.civitai_model_id}`
: (s?.model_name || s?.display_name || m.filename);
if (!groups.has(key)) {
groups.set(key, {
name: s?.model_name || s?.display_name || m.filename,
architecture: s?.architecture || m.architecture || "",
family: s?.family || "",
versions: [],
});
}
groups.get(key).versions.push(m);
}
// Sort versions within each group by release_date then version.
// After sorting we pick the OLDEST version's model_name as the
// group label — it's the most stable anchor since CivitAI page
// names drift newer→older over time (creators add emoji and
// expanded descriptors to the current listing).
for (const g of groups.values()) {
g.versions.sort((a, b) => {
const sa = modelSettings[a.hash] || {};
const sb = modelSettings[b.hash] || {};
const da = sa.release_date || "";
const db = sb.release_date || "";
if (da || db) return (db || "").localeCompare(da || "");
return (sb.version || "").localeCompare(sa.version || "");
});
const oldestSettings = modelSettings[g.versions[g.versions.length - 1].hash];
if (oldestSettings?.model_name) g.name = oldestSettings.model_name;
else if (oldestSettings?.display_name) g.name = oldestSettings.display_name;
}
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
});
// ── filtered results ──
let filteredGroups = $derived.by(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return groupedModels;
const terms = q.split(/\s+/);
return groupedModels.filter(g => {
const searchText = [
g.name,
...g.versions.map(v => {
const s = modelSettings[v.hash];
return [v.filename, s?.architecture, s?.family, s?.version].filter(Boolean).join(" ");
}),
].join(" ").toLowerCase();
return terms.every(t => searchText.includes(t));
});
});
// Catalog: split into partially installed + fully uninstalled
let catalogInstalled = $derived.by(() => {
const q = searchQuery.toLowerCase().trim();
const terms = q ? q.split(/\s+/) : [];
return catalog.filter(c => {
const status = catalogPrecisionStatus[c.hash] || {};
// In the precision-tag strip when at least one precision has files on disk
// (installed, or a resumable partial). All-missing entries drop to the
// grouped "available for download" list below.
if (!Object.values(status).some(v => v === "installed" || v === "partial")) return false;
if (!terms.length) return true;
const text = [c.display_name, c.model_name, c.architecture, c.family, c.version].join(" ").toLowerCase();
return terms.every(t => text.includes(t));
});
});
let catalogUninstalled = $derived.by(() => {
const q = searchQuery.toLowerCase().trim();
let items = catalog.filter(c => {
const status = catalogPrecisionStatus[c.hash] || {};
return !Object.values(status).some(v => v === "installed" || v === "partial");
});
if (q) {
const terms = q.split(/\s+/);
items = items.filter(c => {
const text = [c.model_name, c.architecture, c.family].filter(Boolean).join(" ").toLowerCase();
return terms.every(t => text.includes(t));
});
}
return items;
});
// Group the uninstalled catalog by model so each model shows once with a
// version submenu (latest first) — mirroring the installed-model grouping.
let catalogGroups = $derived.by(() => {
const groups = new Map();
for (const entry of catalogUninstalled) {
const key = entry.civitai_model_id
? `civitai:${entry.civitai_model_id}`
: (entry.model_name || entry.display_name || entry.hash);
if (!groups.has(key)) {
groups.set(key, {
name: entry.model_name || entry.display_name || "",
architecture: entry.architecture || "",
family: entry.family || "",
partial: false,
versions: [],
});
}
const g = groups.get(key);
g.versions.push(entry);
if (entry.partial) g.partial = true;
}
for (const g of groups.values()) {
g.versions.sort((a, b) => (b.version || "").localeCompare(a.version || "", undefined, { numeric: true }));
}
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
});
// ── positioning ──
function positionPicker() {
if (!pickerEl || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const gap = 4;
const padding = 8;
const spaceAbove = rect.top - padding;
const spaceBelow = window.innerHeight - rect.bottom - padding;
let s = `left:${rect.left}px;`;
if (spaceBelow >= spaceAbove) {
s += `top:${rect.bottom + gap}px;bottom:auto;max-height:${spaceBelow - gap}px;`;
} else {
s += `bottom:${window.innerHeight - rect.top}px;top:auto;max-height:${spaceAbove}px;`;
}
panelStyle = s;
}
// ── outside click ──
function onOutsideClick(e) {
if (pickerEl && !pickerEl.contains(e.target)) {
if (e.target.closest?.(".pcr-model-indicator")) return;
if (e.target.closest?.(".pcr-picker-submenu")) return;
onClose();
}
}
// ── CivitAI dedup ──
function _normalizeModelName(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).sort().join(" ");
}
function civitaiResultIsKnown(r) {
// Check CivitAI model ID
for (const s of Object.values(modelSettings)) {
if (s.civitai_model_id && s.civitai_model_id === r.civitai_model_id) return true;
}
for (const c of catalog) {
if (c.civitai_model_id && c.civitai_model_id === r.civitai_model_id) return true;
}
// Fuzzy name matching
const key = _normalizeModelName(r.model_name || "");
const knownKeys = new Set();
for (const s of Object.values(modelSettings)) {
const mn = s.model_name || s.display_name || "";
if (mn) knownKeys.add(_normalizeModelName(mn));
}
for (const c of catalog) {
const mn = c.model_name || c.display_name || "";
if (mn) knownKeys.add(_normalizeModelName(mn));
}
if (knownKeys.has(key)) return true;
for (const known of knownKeys) {
const knownWords = known.split(" ").filter(w => w.length >= 3);
const resultWords = key.split(" ").filter(w => w.length >= 3);
if (knownWords.length >= 2 && knownWords.every(w => key.includes(w))) return true;
if (resultWords.length >= 2 && resultWords.every(w => known.includes(w))) return true;
}
return false;
}
// ── CivitAI search ──
// Each search owns an AbortController so rapid typing doesn't let a
// slow "dog" response clobber fresh "cat" results after the user has
// moved on.
let civitaiSearchAbort = null;
// True from the moment the user types until a search (including its
// 400ms debounce) completes, fails, or the query is cleared. Drives
// the "Searching CivitAI..." placeholder so the reserved space isn't
// just an empty gap while the request is in flight.
let civitaiHasSearched = $state(false);
function doCivitaiSearch(query, append = false) {
if (!query.trim()) { civitaiResults = []; return; }
civitaiSearchAbort?.abort();
civitaiSearchAbort = new AbortController();
const signal = civitaiSearchAbort.signal;
civitaiLoading = true;
const params = new URLSearchParams({ q: query, limit: "10" });
if (append && civitaiCursor) params.set("cursor", civitaiCursor);
fetch(`/promptchain/civitai/search?${params}`, { signal })
.then(async r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (signal.aborted) return;
const results = data.results || [];
// Deduplicate against known local models + catalog
const filtered = results.filter(r => !civitaiResultIsKnown(r));
civitaiResults = append ? [...civitaiResults, ...filtered] : filtered;
civitaiCursor = data.next_cursor || null;
civitaiLoading = false;
civitaiHasSearched = true;
})
.catch(e => {
if (e.name === "AbortError") return;
console.error("[PromptChain] CivitAI search failed:", e);
civitaiLoading = false;
civitaiHasSearched = true;
});
}
function onSearchInput() {
clearTimeout(civitaiSearchTimer);
civitaiSearchAbort?.abort();
civitaiResults = [];
civitaiCursor = null;
civitaiHasSearched = false;
if (searchQuery.trim()) {
// Flag loading now so the placeholder is visible during debounce,
// not just while the actual network request is in flight.
civitaiLoading = true;
civitaiSearchTimer = setTimeout(() => doCivitaiSearch(searchQuery), 400);
} else {
civitaiLoading = false;
}
}
// ── template helpers ──
function templatesForModel(model) {
const arch = modelSettings[model.hash]?.architecture || model.architecture;
const family = modelSettings[model.hash]?.family || "";
const mName = modelSettings[model.hash]?.model_name || modelSettings[model.hash]?.display_name || model.filename || "";
return templates.filter(tpl => {
if (tpl._hidden) return false;
const scope = tpl.scope || {};
if (scope.type === "version") return scope.model_hash === model.hash;
if (scope.type === "model") return (scope.model_name || scope.display_name) === mName && mName;
if (scope.type === "family") return scope.architecture === arch && scope.family === family && (!scope.version || scope.version === (modelSettings[model.hash]?.version || ""));
return scope.architecture === arch;
});
}
async function handleCatalogPrecisionClick(entry, prec, state) {
// Anything not fully present opens the download modal, which re-checks each
// file (with size) and offers "Download N Missing" — so a partial download
// resumes the missing companion instead of restarting from scratch.
if (state !== "installed") {
onShowCatalogDownload(entry, prec);
return;
}
// Installed: jump to this precision's templates. If the model can't be
// resolved (e.g. files on disk but not yet scanned), fall back to the
// download modal rather than silently closing — that onClose() dead-end is
// exactly what left partial entries looking installed yet unclickable.
const resolved = resolveFilesForPrecision(entry.files, prec);
const diffFile = resolved.find(f => f.folder === "diffusion_models" || f.folder === "unet");
const localModel = diffFile && models.find(m => m.filename === diffFile.filename);
if (localModel && templatesForModel(localModel).length > 0) {
activeSubmenu = {
type: "template",
model: localModel,
displayName: modelSettings[localModel.hash]?.display_name || localModel.filename,
};
activeNestedSubmenu = null;
} else {
onShowCatalogDownload(entry, prec);
}
}
// ── installed-model precision upgrades ──
// An installed model whose config declares several precisions for its
// primary file (e.g. Wan fp8_scaled + fp16) but only has some on disk can be
// "moved up" to a heavier precision. We surface the declared precisions as
// inline tags on the installed row: present ones jump to templates, missing
// ones open the download modal. Detection is client-side — modelSettings
// already ships the config's files, and the models list tells us which
// primary files are on disk.
// The config's primary diffusion/unet file entry — its variants are the
// upgrade options. Mirrors the backend is_primary_model_file: an explicit
// primary:true wins, else the first diffusion/unet entry.
function primaryFileEntry(settings) {
const files = settings?.files || [];
const isPrimaryFolder = f => f.folder === "diffusion_models" || f.folder === "unet" || f.folder === "checkpoints";
return files.find(f => f.primary && isPrimaryFolder(f))
|| files.find(isPrimaryFolder)
|| null;
}
// Declared primary precisions for an installed group, each tagged with
// whether its file is on disk. Empty unless the config has 2+ variants
// (nothing to upgrade otherwise).
function groupPrecisions(group) {
const settings = modelSettings[group.versions[0]?.hash];
const variants = primaryFileEntry(settings)?.variants || [];
if (variants.length < 2) return [];
return variants.map(v => {
const fnameLower = (v.filename || "").toLowerCase();
const localModel = models.find(m => (m.filename || "").toLowerCase() === fnameLower);
return { precision: v.precision, filename: v.filename, installed: !!localModel, model: localModel || null };
});
}
// Catalog-entry shape for the download modal, built from an installed model's
// stored config so the same modal handles a precision upgrade.
function entryForGroup(group) {
const s = modelSettings[group.versions[0]?.hash] || {};
const entry = {
hash: group.versions[0]?.hash,
display_name: s.display_name || group.name,
model_name: s.model_name || group.name,
version: s.version || "",
architecture: s.architecture || group.architecture || "",
family: s.family || group.family || "",
description: s.description || "",
files: s.files || [],
};
if (s.civitai_model_id) entry.civitai_model_id = s.civitai_model_id;
if (s.thumbnail) entry.thumbnail = s.thumbnail;
return entry;
}
// Installed precision tag → that precision's templates.
function handleInstalledPrecisionClick(prec) {
if (prec.model && templatesForModel(prec.model).length > 0) {
activeNestedSubmenu = null;
activeSubmenu = {
type: "template",
model: prec.model,
displayName: modelSettings[prec.model.hash]?.display_name || prec.model.filename,
};
} else {
onClose();
}
}
// Missing precision tag → download modal pre-set to that precision.
function handleMissingPrecisionClick(group, prec) {
onShowCatalogDownload(entryForGroup(group), prec.precision);
}
function sortCategories(cats) {
const known = (categoryOrder || []).filter(c => cats.includes(c));
const unknown = cats.filter(c => !categoryOrder?.includes(c)).sort();
return [...known, ...unknown];
}
function buildVersionLabel(model) {
const settings = modelSettings[model.hash];
if (settings?.version_label) return settings.version_label;
const version = settings?.version || "";
const filename = (model.filename || "").toLowerCase();
// Prefer the precision the config DECLARES for this exact file variant.
// A filename regex collapses GGUF quants (Q4/Q5/Q8 all read "GGUF") and
// fp8_scaled vs fp8, so multiple installed precisions of one model would
// render as identical, indistinguishable rows. The variant's precision
// field disambiguates them. Fall back to a filename sniff for configs
// with no variants array.
const precision = declaredPrecision(settings, filename) || sniffPrecision(filename);
if (precision && version) {
const esc = precision.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${esc}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || model.filename || "";
}
// The precision label a config declares for the file with this exact name,
// checking each file entry's flat filename and its precision variants.
function declaredPrecision(settings, filenameLower) {
for (const f of settings?.files || []) {
if ((f.filename || "").toLowerCase() === filenameLower) return f.precision || null;
for (const v of f.variants || []) {
if ((v.filename || "").toLowerCase() === filenameLower) return v.precision || null;
}
}
return null;
}
// No separator requirement — civitai filenames embed precision in
// camelCase (v4BaseFp8) which lowercasing flattens to "basefp8".
function sniffPrecision(filenameLower) {
const precisionPatterns = [
[/bf16/i, "BF16"], [/fp16/i, "FP16"], [/fp8/i, "FP8"],
[/fp32/i, "FP32"], [/nvfp4/i, "NVFP4"], [/fp4/i, "FP4"],
[/\.gguf$/i, "GGUF"],
];
for (const [pattern, label] of precisionPatterns) {
if (pattern.test(filenameLower)) return label;
}
return null;
}
// Catalog entries have no modelSettings[hash], so derive the version label
// from the entry's own fields + the primary file's precision suffix.
function buildCatalogVersionLabel(entry) {
const version = entry.version || "";
const filename = (entry.files?.[0]?.filename || "").toLowerCase();
const precisionPatterns = [
[/bf16/i, "BF16"], [/fp16/i, "FP16"], [/fp8/i, "FP8"],
[/fp32/i, "FP32"], [/nvfp4/i, "NVFP4"], [/fp4/i, "FP4"],
[/\.gguf$/i, "GGUF"],
];
let precision = null;
for (const [pattern, label] of precisionPatterns) {
if (pattern.test(filename)) { precision = label; break; }
}
if (precision && version) {
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${precision}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || entry.display_name || entry.model_name || "";
}
// ── catalog group click: one version downloads directly, many open a
// version submenu (toggle off if already open) ──
function handleCatalogGroupClick(group) {
if (activeSubmenu?.type === "catalogVersion" && activeSubmenu?.group === group) {
activeSubmenu = null;
activeNestedSubmenu = null;
return;
}
activeNestedSubmenu = null;
if (group.versions.length === 1) {
activeSubmenu = null;
onShowCatalogDownload(group.versions[0]);
} else {
activeSubmenu = { type: "catalogVersion", group };
}
}
function handleCatalogVersionClick(entry) {
activeSubmenu = null;
activeNestedSubmenu = null;
onShowCatalogDownload(entry);
}
// ── group click (toggle: clicking active item closes submenu) ──
function handleGroupClick(group) {
const model = group.versions[0];
// Toggle off if already active
if (activeSubmenu?.type === "version" && activeSubmenu?.group?.name === group.name
|| activeSubmenu?.type === "template" && activeSubmenu?.model?.hash === model.hash) {
activeSubmenu = null;
activeNestedSubmenu = null;
return;
}
activeNestedSubmenu = null;
if (group.versions.length === 1) {
activeSubmenu = {
type: "template",
model,
displayName: modelSettings[model.hash]?.display_name || model.filename,
};
} else {
activeSubmenu = { type: "version", group };
}
}
// ── version click (toggle: clicking open version closes nested) ──
function handleVersionClick(model, versionLabel) {
if (activeNestedSubmenu?.model?.hash === model.hash) {
activeNestedSubmenu = null;
return;
}
activeNestedSubmenu = { model, versionLabel };
}
// ── template click ──
function handleTemplateClick(tpl, model) {
applyTpl(tpl, model.filename, modelSettings[model.hash]?.nodes || {});
}
// ── submenu positioning ──
let submenuStyle = $state("");
let nestedSubmenuStyle = $state("");
function positionSubmenu() {
if (!pickerEl) return;
const rect = pickerEl.getBoundingClientRect();
const width = 220;
const rightSpace = window.innerWidth - rect.right;
const left = rightSpace > width + 10 ? rect.right + 2 : rect.left - width - 2;
submenuStyle = `position:fixed;z-index:10001;left:${left}px;top:${rect.top}px;width:${width}px;max-height:${rect.height}px;overflow-y:auto`;
}
function positionNestedSubmenu() {
if (!pickerEl) return;
const rect = pickerEl.getBoundingClientRect();
const width = 220;
const submenuWidth = 220;
const rightSpace = window.innerWidth - rect.right;
const left = rightSpace > submenuWidth + width + 20
? rect.right + submenuWidth + 4
: rect.left - width - submenuWidth - 4;
nestedSubmenuStyle = `position:fixed;z-index:10002;left:${left}px;top:${rect.top}px;width:${width}px;max-height:${rect.height}px;overflow-y:auto`;
}
// Reposition when submenus change
$effect(() => {
if (activeSubmenu) positionSubmenu();
});
$effect(() => {
if (activeNestedSubmenu || activeSubmenu?.type === "template") positionNestedSubmenu();
});
// ── hover card ──
function onItemEnter(e, data) {
clearTimeout(hoverTimeout);
hoverTimeout = setTimeout(() => hoverCard?.show(e.currentTarget, data), 200);
}
function onItemLeave() {
clearTimeout(hoverTimeout);
hoverCard?.hide();
}
// ── scroll pagination for CivitAI ──
function onListScroll(e) {
const el = e.target;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40 && civitaiCursor && !civitaiLoading) {
doCivitaiSearch(searchQuery, true);
}
}
// ── click inside picker clears submenu (if not on an item) ──
function onPickerPointerDown(e) {
if (!e.target.closest(".pcr-picker-item")) {
activeSubmenu = null;
activeNestedSubmenu = null;
}
}
// ── recognition reload ──
function onRecognitionUpdate() {
// Reload all data when models are recognized in background
loadData();
}
// ── lifecycle ──
$effect(() => {
untrack(() => {
loadData();
requestAnimationFrame(() => positionPicker());
});
document.addEventListener("pointerdown", onOutsideClick, true);
window.addEventListener("pcr-recognition-update", onRecognitionUpdate);
return () => {
document.removeEventListener("pointerdown", onOutsideClick, true);
window.removeEventListener("pcr-recognition-update", onRecognitionUpdate);
clearTimeout(civitaiSearchTimer);
clearTimeout(hoverTimeout);
};
});
</script>
<div class="pcr-model-picker" bind:this={pickerEl} style={panelStyle}
onpointerdown={onPickerPointerDown}>
{#if loading}
<div class="pcr-model-panel-empty">Loading...</div>
{:else}
<!-- Header: search + recognition -->
<div class="pcr-picker-header">
<input class="pcr-picker-search"
bind:this={searchEl}
type="text"
placeholder="Search models..."
bind:value={searchQuery}
oninput={onSearchInput} />
{#if recognition.running}
<div class="pcr-picker-recognition">
Recognizing models... {recognition.done}/{recognition.total}
</div>
{/if}
</div>
<!-- Scrollable list. Height locks when a search is active so async
CivitAI results can't push items out from under the user's cursor
when the panel opens upward (bottom-pinned). -->
<div class="pcr-picker-list"
class:pcr-picker-list-reserved={searchQuery.trim().length > 0}
bind:this={listEl} onscroll={onListScroll}>
<!-- Local models -->
{#each filteredGroups as group (group.name)}
{@const model = group.versions[0]}
{@const displayName = group.name}
{@const meta = [group.architecture, group.family].filter(Boolean).join(" · ")}
{@const precs = groupPrecisions(group)}
<div class="pcr-picker-item"
class:pcr-picker-item-active={activeSubmenu?.type === "version" && activeSubmenu?.group?.name === group.name
|| activeSubmenu?.type === "template" && activeSubmenu?.model?.hash === model.hash}
class:pcr-picker-item-highlight={highlightGroupName === group.name}
onclick={(e) => { e.stopPropagation(); handleGroupClick(group); }}
onpointerenter={(e) => onItemEnter(e, { thumbnail: modelSettings[model.hash]?.thumbnail, name: displayName })}
onpointerleave={onItemLeave}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">
{displayName}
{#if group.versions.length > 1 && !precs.length}
<span class="pcr-model-version-badge">{group.versions.length}</span>
{/if}
</span>
{#if meta}
<span class="pcr-picker-item-meta">{meta}</span>
{/if}
{#if precs.length}
<div class="pcr-picker-precision-row">
{#each precs as p}
<span class="pcr-picker-precision-tag"
class:pcr-precision-installed={p.installed}
class:pcr-precision-missing={!p.installed}
title={p.installed ? `${p.precision.toUpperCase()} installed — use it` : `Download ${p.precision.toUpperCase()}`}
onclick={(e) => {
e.stopPropagation();
p.installed ? handleInstalledPrecisionClick(p) : handleMissingPrecisionClick(group, p);
}}>
{p.precision.toUpperCase()}{p.installed ? "" : " ↓"}
</span>
{/each}
</div>
{/if}
</div>
<span class="pcr-picker-item-arrow">▸</span>
</div>
{/each}
<!-- Started catalog entries (installed / resumable-partial precisions) -->
{#each catalogInstalled as entry}
{@const precisions = extractPrecisions(entry.files || [])}
{@const status = catalogPrecisionStatus[entry.hash] || {}}
<div class="pcr-picker-item pcr-picker-catalog-item"
class:pcr-picker-catalog-partial={precisions.some(p => status[p] === "partial")}
onclick={(e) => {
e.stopPropagation();
const prec = precisions.find(p => status[p] === "installed")
|| precisions.find(p => status[p] === "partial");
if (prec) handleCatalogPrecisionClick(entry, prec, status[prec]);
}}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">{entry.display_name || entry.model_name}</span>
<div class="pcr-picker-precision-row">
{#each precisions as prec}
{@const st = status[prec] || "missing"}
<span class="pcr-picker-precision-tag"
class:pcr-precision-installed={st === "installed"}
class:pcr-precision-partial={st === "partial"}
class:pcr-precision-missing={st === "missing"}
title={st === "installed" ? `${prec.toUpperCase()} installed`
: st === "partial" ? `${prec.toUpperCase()} download unfinished — click to resume`
: `Download ${prec.toUpperCase()}`}
onclick={(e) => {
e.stopPropagation();
handleCatalogPrecisionClick(entry, prec, st);
}}>
{prec.toUpperCase()}{st === "installed" ? "" : st === "partial" ? " ↻" : " ↓"}
</span>
{/each}
</div>
</div>
<span class="pcr-picker-item-arrow">›</span>
</div>
{/each}
<!-- Fully uninstalled catalog — grouped by model with a version submenu -->
{#if catalogGroups.length}
<div class="pcr-picker-civitai-header">AVAILABLE FOR DOWNLOAD</div>
{#each catalogGroups as group (group.name)}
{@const meta = [group.architecture, group.family].filter(Boolean).join(" · ")}
<div class="pcr-picker-item pcr-picker-catalog-item"
class:pcr-picker-item-active={activeSubmenu?.type === "catalogVersion" && activeSubmenu?.group === group}
class:pcr-picker-catalog-partial={group.partial}
onclick={(e) => { e.stopPropagation(); handleCatalogGroupClick(group); }}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">
{group.name}
{#if group.versions.length > 1}
<span class="pcr-model-version-badge">{group.versions.length}</span>
{/if}
</span>
{#if meta || group.partial}
<span class="pcr-picker-item-meta">
{meta}{#if group.partial}{meta ? " · " : ""}<span class="pcr-picker-resume-hint">resume ↻</span>{/if}
</span>
{/if}
</div>
<span class="pcr-picker-item-arrow">{group.versions.length > 1 ? "▸" : (group.partial ? "↻" : "↓")}</span>
</div>
{/each}
{/if}
<!-- CivitAI results — the section surfaces as soon as a search is
kicked off so the reserved space shows progress instead of a
blank gap. First render is the header + a placeholder; once
results arrive, the placeholder is swapped for rows. -->
{#if searchQuery.trim() && (civitaiResults.length || civitaiLoading || civitaiHasSearched)}
<div class="pcr-picker-civitai-header">CIVITAI</div>
<div class="pcr-picker-civitai-section">
{#each civitaiResults as result}
<div class="pcr-picker-item pcr-picker-civitai-item"
onclick={() => onShowDownload(result)}
onpointerenter={(e) => onItemEnter(e, {
thumbnail: result.thumbnail,
name: result.model_name,
version: result.version,
base_model: result.base_model,
thumbs_up: result.thumbs_up,
downloads: result.downloads,
tags: result.tags,
})}
onpointerleave={onItemLeave}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">{result.model_name}</span>
<span class="pcr-picker-item-meta">
{[result.architecture, result.family, result.file?.size_gb ? `${result.file.size_gb} GB` : "",
result.downloads ? `${(result.downloads / 1000).toFixed(0)}k ↓` : ""].filter(Boolean).join(" · ")}
</span>
</div>
<span class="pcr-picker-item-arrow">↓</span>
</div>
{/each}
{#if civitaiLoading && !civitaiResults.length}
<div class="pcr-picker-civitai-loading">Searching CivitAI…</div>
{:else if civitaiLoading}
<div class="pcr-picker-civitai-loading">Loading more…</div>
{:else if civitaiHasSearched && !civitaiResults.length}
<div class="pcr-picker-civitai-empty">No results on CivitAI</div>
{/if}
</div>
{/if}
{#if !filteredGroups.length && !catalogInstalled.length && !catalogUninstalled.length && !civitaiResults.length && !civitaiLoading && !civitaiHasSearched && !loading}
<div class="pcr-model-panel-empty">No matches</div>
{/if}
</div>
{/if}
</div>
<!-- Version submenu -->
{#if activeSubmenu?.type === "version"}
{@const group = activeSubmenu.group}
<div class="pcr-picker-submenu" style={submenuStyle}>
<div class="pcr-picker-submenu-header">SELECT VERSION</div>
{#each group.versions as model}
{@const label = buildVersionLabel(model)}
<div class="pcr-picker-version-item"
class:pcr-submenu-open={activeNestedSubmenu?.model?.hash === model.hash}
onclick={() => handleVersionClick(model, label)}>
<span>{label}</span>
<span class="pcr-picker-version-arrow">▶</span>
</div>
{/each}
</div>
{/if}
<!-- Catalog version submenu: pick a version to download (latest first) -->
{#if activeSubmenu?.type === "catalogVersion"}
{@const group = activeSubmenu.group}
<div class="pcr-picker-submenu" style={submenuStyle}>
<div class="pcr-picker-submenu-header">SELECT VERSION</div>
{#each group.versions as entry}
<div class="pcr-picker-version-item"
onclick={() => handleCatalogVersionClick(entry)}>
<span>{buildCatalogVersionLabel(entry)}</span>
<span class="pcr-picker-version-arrow">↓</span>
</div>
{/each}
</div>
{/if}
<!-- Template submenu (single-version or nested) -->
{#if activeSubmenu?.type === "template" || activeNestedSubmenu}
{@const model = activeNestedSubmenu?.model || activeSubmenu?.model}
{@const tpls = model ? templatesForModel(model) : []}
{@const displayName = activeNestedSubmenu?.versionLabel || activeSubmenu?.displayName || ""}
<div class="pcr-picker-submenu" style={activeNestedSubmenu ? nestedSubmenuStyle : submenuStyle}>
<div class="pcr-picker-submenu-header">{displayName}</div>
{#if canSwapInPlace(model)}
<div class="pcr-picker-submenu-item pcr-picker-current-workflow"
title="Swap this checkpoint into your current graph and reapply its settings — keeps your wiring (regional couple, etc.)"
onclick={() => { onSwapInPlace(model.filename, model.hash); onClose?.(); }}>
(Current Workflow)
</div>
{#if tpls.length}<div class="pcr-picker-category-header">TEMPLATES</div>{/if}
{/if}
{#if tpls.length}
{@const cats = [...new Set(tpls.map(t => t.category || "General"))]}
{@const sortedCats = sortCategories(cats)}
{#each sortedCats as cat}
{@const catTpls = tpls.filter(t => (t.category || "General") === cat)}
{#if catTpls.length}
{#if sortedCats.length > 1}
<div class="pcr-picker-category-header">{cat.toUpperCase()}</div>
{/if}
{#each catTpls as tpl}
<div class="pcr-picker-submenu-item"
title={tpl.description || ""}
onclick={() => handleTemplateClick(tpl, model)}>
{tpl.name}
</div>
{/each}
{/if}
{/each}
{:else}
<div class="pcr-picker-submenu-item pcr-picker-submenu-hint">No templates available</div>
{/if}
</div>
{/if}
<HoverCard bind:this={hoverCard} pickerEl={pickerEl} />
<style>
/* model picker panel */
.pcr-model-picker {
position: fixed;
min-width: 280px;
max-width: 360px;
background: rgba(30, 30, 30, 0.98);
backdrop-filter: blur(12px);
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.6);
z-index: 100000;
color: #ddd;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pcr-picker-header {
padding: 8px 10px;
border-bottom: 1px solid #333;
}
.pcr-picker-search {
width: 100%;
padding: 5px 8px;
font-size: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid #444;
border-radius: 4px;
color: #ddd;
outline: none;
box-sizing: border-box;
}
.pcr-picker-search:focus { border-color: #4fc3f7; }
.pcr-picker-search::placeholder { color: #666; }
.pcr-picker-list {
overflow-y: auto;
max-height: 320px;
padding: 4px 0;
}
/* Active search: lock list to its max height so CivitAI results
arriving async don't shift installed rows out from under the mouse.
Capped at 40vh so a cramped viewport doesn't clip the panel. */
.pcr-picker-list-reserved {
min-height: min(320px, 40vh);
}
.pcr-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
cursor: pointer;
}
.pcr-picker-item:hover { background: rgba(255, 255, 255, 0.05); }
.pcr-picker-item-active { background: rgba(79, 195, 247, 0.1); }
/* Just-installed model after a download+restart — draws the eye to it. */
.pcr-picker-item-highlight {
outline: 2px solid #4fc3f7;
outline-offset: -2px;
border-radius: 4px;
background: rgba(79, 195, 247, 0.14);
}
.pcr-picker-item-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.pcr-picker-item-name {
font-size: 12px;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pcr-picker-item-meta {
font-size: 10px;
color: #777;
}
.pcr-picker-item-arrow {
font-size: 14px;
color: #555;
margin-left: 8px;
flex-shrink: 0;
}
.pcr-picker-item-active .pcr-picker-item-arrow { color: #4fc3f7; }
/* submenus */
.pcr-picker-submenu {
position: fixed;
min-width: 150px;
background: rgba(30, 30, 30, 0.98);
backdrop-filter: blur(12px);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 100001;
padding: 4px 0;
}
.pcr-picker-submenu-header {
padding: 6px 12px 4px;
font-size: 10px;
color: #666;
letter-spacing: 0.5px;
}
.pcr-picker-submenu-item {
padding: 7px 12px;
font-size: 12px;
color: #ddd;
cursor: pointer;
white-space: nowrap;
}
.pcr-picker-submenu-item:hover { background: rgba(255, 255, 255, 0.08); }
.pcr-picker-current-workflow { color: #ff9d5c; font-weight: 600; }
.pcr-picker-submenu-hint { color: #666; cursor: default; font-style: italic; }
.pcr-picker-submenu-hint:hover { background: none; }
/* version items — gold styling */
.pcr-picker-version-item {
display: flex;
justify-content: space-between;
align-items: center;
color: rgba(255, 193, 7, 0.9);
padding: 7px 12px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.pcr-picker-version-item:hover,
.pcr-picker-version-item.pcr-submenu-open {
background: rgba(255, 193, 7, 0.2);
color: rgba(255, 193, 7, 1);
}
.pcr-picker-version-arrow {
font-size: 10px;
opacity: 0.7;
margin-left: 8px;
}
/* nested (level 3) submenu */
.pcr-picker-submenu.pcr-picker-nested-submenu {
z-index: 100002;
}
/* gold version header at top of template submenu */
.pcr-picker-version-header {
background: rgba(255, 193, 7, 0.15);
color: rgba(255, 193, 7, 0.95);
font-size: 11px;
font-weight: 600;
padding: 8px 12px 6px;
border-bottom: 1px solid rgba(255, 193, 7, 0.3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* category dividers in template submenus */
.pcr-picker-category-header {
padding: 6px 12px 4px;
font-size: 10px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(40, 40, 40, 0.8);
border-bottom: 1px solid #333;
}
.pcr-picker-category-header:not(:first-child) {
margin-top: 4px;
border-top: 1px solid #333;
}
/* recognition progress banner */
.pcr-picker-recognition {
padding: 8px 12px;
font-size: 11px;
color: #999;
background: rgba(79, 195, 247, 0.06);
border-bottom: 1px solid #333;
animation: pcr-pulse 2s ease-in-out infinite;
}
/* CivitAI search results */
.pcr-picker-civitai-section {
border-top: 1px solid #444;
}
.pcr-picker-civitai-header {
padding: 8px 12px 4px;
font-size: 10px;
color: #4fc3f7;
letter-spacing: 0.5px;
}
.pcr-picker-civitai-item .pcr-picker-item-arrow {
color: #4fc3f7;
font-size: 14px;
}
/* catalog items */
.pcr-picker-catalog-item {
border-left: 2px solid #4fc3f7;
}
/* precision tags in picker rows */
.pcr-picker-precision-row {
display: flex;
gap: 4px;
margin-top: 3px;
}
.pcr-picker-precision-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
}
.pcr-precision-installed {
background: rgba(79, 195, 247, 0.15);
color: #4fc3f7;
border: 1px solid rgba(79, 195, 247, 0.3);
}
.pcr-precision-installed:hover {
background: rgba(79, 195, 247, 0.25);
}
.pcr-precision-partial {
background: rgba(255, 183, 77, 0.15);
color: #ffb74d;
border: 1px solid rgba(255, 183, 77, 0.4);
}
.pcr-precision-partial:hover {
background: rgba(255, 183, 77, 0.28);
}
.pcr-precision-missing {
background: rgba(255, 255, 255, 0.05);
color: #888;
border: 1px solid #444;
}
.pcr-precision-missing:hover {
color: #ccc;
border-color: #666;
}
/* a started-but-unfinished download gets an amber edge so it reads as
"resume", not "ready" */
.pcr-picker-catalog-partial {
border-left-color: #ffb74d;
}
.pcr-picker-resume-hint {
color: #ffb74d;
}
.pcr-picker-civitai-loading {
padding: 10px 12px;
font-size: 11px;
color: #888;
text-align: center;
animation: pcr-pulse 1.5s ease-in-out infinite;
}
.pcr-picker-civitai-empty {
padding: 10px 12px;
font-size: 11px;
color: #666;
text-align: center;
font-style: italic;
}
/* version count badge */
.pcr-model-version-badge {
display: inline-block;
font-size: 10px;
padding: 0 4px;
margin-left: 6px;
background: rgba(79, 195, 247, 0.15);
color: #4fc3f7;
border-radius: 8px;
vertical-align: middle;
}
</style>