import { html, reactive } from "/assets/vendor/arrow-core.js"; const state = reactive({ loading: true, refreshing: false, error: "", actionBusy: false, sortMode: "alphabetical", communityFavoriteFilter: "all", userFavoriteFilter: "all", models: [], currentModel: "", summary: { installed: 0, missing: 0, total: 0 }, status: { modelToDownload: "", downloadAll: false, downloading: false, cancelling: false, progress: "", isOnroad: false, terminal: false, }, }); let initialized = false; let pollingHandle = null; let statusInFlight = false; let lastStatusSignature = ""; const REQUEST_TIMEOUT_MS = 20000; const ACTIVE_POLL_INTERVAL_MS = 1000; const IDLE_POLL_INTERVAL_MS = 4000; function notify(message, variant = "success") { if (typeof showSnackbar === "function") { showSnackbar(message, variant); } else if (variant === "error") { console.error(message); } else { console.log(message); } } function logDebug(message, details = null) { if (details === null || details === undefined) { console.log(`[ModelManager] ${message}`); } else { console.log(`[ModelManager] ${message}`, details); } } function isModelRouteActive() { return window.location.pathname === "/manage_models"; } function safeText(value, fallback = "") { if (value === null || value === undefined) return fallback; return String(value); } function toBool(value) { return !!value; } function toInt(value) { const n = Number(value); return Number.isFinite(n) ? n : 0; } function parseReleased(value) { const ts = Date.parse(safeText(value, "")); return Number.isNaN(ts) ? 0 : ts; } function normalizeSeries(model) { return safeText(model?.series, "Custom Series") || "Custom Series"; } function modelSortCompare(a, b) { if (state.sortMode === "release_date") { const dateDelta = parseReleased(b?.released) - parseReleased(a?.released); if (dateDelta !== 0) return dateDelta; } return safeText(a?.label, a?.value).localeCompare( safeText(b?.label, b?.value), undefined, { sensitivity: "base", numeric: true }, ); } function getFilteredModels() { let rows = [...state.models].filter(model => model && typeof model === "object"); if (state.userFavoriteFilter === "yes") { rows = rows.filter(model => !!model.userFavorite); } else if (state.userFavoriteFilter === "no") { rows = rows.filter(model => !model.userFavorite); } if (state.communityFavoriteFilter === "yes") { rows = rows.filter(model => !!model.communityFavorite); } else if (state.communityFavoriteFilter === "no") { rows = rows.filter(model => !model.communityFavorite); } return rows; } function getSeriesGroups() { const grouped = {}; for (const model of getFilteredModels()) { const seriesName = normalizeSeries(model); if (!grouped[seriesName]) grouped[seriesName] = []; grouped[seriesName].push(model); } const seriesNames = Object.keys(grouped); for (const seriesName of seriesNames) { grouped[seriesName].sort(modelSortCompare); } if (state.sortMode === "release_date") { seriesNames.sort((a, b) => { const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released))); const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released))); const delta = bNewest - aNewest; if (delta !== 0) return delta; return a.localeCompare(b, undefined, { sensitivity: "base" }); }); } else { seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); } return { grouped, seriesNames }; } function getVisibleModels() { const { grouped, seriesNames } = getSeriesGroups(); const rows = []; for (const seriesName of seriesNames) { rows.push(...grouped[seriesName]); } return rows; } function getReleaseOrderedModels() { return getFilteredModels().sort(modelSortCompare); } function getInstalledModels() { return state.models .filter(model => model && typeof model === "object" && !!model.installed) .sort(modelSortCompare); } function getUserFavoriteModels(installedOnly = false) { const rows = state.models.filter(model => model && typeof model === "object" && !!model.userFavorite); const filtered = installedOnly ? rows.filter(model => !!model.installed) : rows; return filtered.sort(modelSortCompare); } function getCurrentModelName() { const current = safeText(state.currentModel, ""); if (!current) return "none"; const match = state.models.find(model => safeText(model?.value, "") === current); if (!match) return current; return safeText(match.label, current); } async function fetchJson(url, options = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { ...options, signal: controller.signal }); let payload = {}; try { payload = await response.json(); } catch { payload = {}; } if (!response.ok) { const message = payload?.error || payload?.message || `Request failed (${response.status})`; throw new Error(message); } return payload; } finally { clearTimeout(timer); } } async function fetchStatus() { if (statusInFlight) return; statusInFlight = true; try { const payload = await fetchJson("/api/models/status"); const models = Array.isArray(payload.models) ? payload.models.filter(model => model && typeof model === "object") : []; state.models = models; state.currentModel = safeText(payload.currentModel, ""); const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {}; state.summary = { installed: toInt(summary.installed), missing: toInt(summary.missing), total: toInt(summary.total), }; state.status = { modelToDownload: safeText(payload.modelToDownload, ""), downloadAll: toBool(payload.downloadAll), downloading: toBool(payload.downloading), cancelling: toBool(payload.cancelling), progress: safeText(payload.progress, ""), isOnroad: toBool(payload.isOnroad), terminal: toBool(payload.terminal), }; state.error = ""; const signature = [ state.models.length, state.currentModel, state.status.downloading, state.status.downloadAll, state.status.modelToDownload, state.status.progress, ].join("|"); if (signature !== lastStatusSignature) { lastStatusSignature = signature; logDebug("Status updated", { models: state.models.length, currentModel: state.currentModel || "none", downloading: state.status.downloading, progress: state.status.progress || "Idle", }); } } catch (error) { state.error = error?.message || String(error); logDebug("Status fetch failed", state.error); } finally { statusInFlight = false; state.loading = false; state.refreshing = false; } } async function refreshAll(showToast = false) { state.refreshing = true; if (state.models.length === 0) { state.loading = true; } await fetchStatus(); if (showToast && !state.error) { notify("Model list refreshed."); } } function ensurePolling() { if (pollingHandle) return; const poll = async () => { if (!isModelRouteActive()) { pollingHandle = null; return; } let nextDelay = IDLE_POLL_INTERVAL_MS; try { await fetchStatus(); nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; } finally { pollingHandle = setTimeout(poll, nextDelay); } }; pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS); } async function setActiveModel(modelKey) { const payload = await fetchJson("/api/params", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "Model", value: modelKey }), }); notify(payload.message || `Selected "${modelKey}".`); } async function startDownload(modelKey) { const payload = await fetchJson("/api/models/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelKey }), }); notify(payload.message || `Downloading "${modelKey}"...`); } async function startDownloadAll() { const payload = await fetchJson("/api/models/download_all", { method: "POST" }); notify(payload.message || "Started downloading all models."); } async function cancelDownload() { const payload = await fetchJson("/api/models/cancel", { method: "POST" }); notify(payload.message || "Cancellation requested."); } async function deleteModel(modelKey) { const payload = await fetchJson("/api/models/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelKey }), }); notify(payload.message || `Deleted files for "${modelKey}".`); } async function setUserFavorite(modelKey, shouldFavorite) { const key = safeText(modelKey, ""); if (!key) return; const favorites = getUserFavoriteModels(false) .map(model => safeText(model.value, "")) .filter(Boolean) .filter(value => value !== key); if (shouldFavorite) { favorites.push(key); } const payload = await fetchJson("/api/models/preferences", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userFavorites: favorites }), }); notify(payload.message || (shouldFavorite ? "Model added to your favorites." : "Model removed from your favorites.")); } async function refreshManifest() { const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" }); notify(payload.message || "Model manifest refreshed."); } async function runAction(action, modelKey = "") { if (state.actionBusy) { notify("Please wait for the current action to finish.", "error"); return; } state.actionBusy = true; try { if (action === "refresh") { await refreshManifest(); await refreshAll(false); return; } const allowedOnroadActions = new Set(["refresh", "favorite", "unfavorite"]); if (state.status.isOnroad && !allowedOnroadActions.has(action)) { notify("Actions are blocked while onroad.", "error"); return; } if (action === "select") { if (!modelKey) return; await setActiveModel(modelKey); } else if (action === "download") { if (!modelKey) return; await startDownload(modelKey); } else if (action === "download-all") { await startDownloadAll(); } else if (action === "cancel") { await cancelDownload(); } else if (action === "delete") { if (!modelKey) return; const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`); if (!confirmed) return; await deleteModel(modelKey); } else if (action === "favorite") { if (!modelKey) return; await setUserFavorite(modelKey, true); } else if (action === "unfavorite") { if (!modelKey) return; await setUserFavorite(modelKey, false); } await fetchStatus(); } catch (error) { notify(error?.message || String(error), "error"); } finally { state.actionBusy = false; } } function bindDomHandlers() { if (window.__modelManagerHandlersBound) return; window.__modelManagerHandlersBound = true; document.addEventListener("click", event => { if (!isModelRouteActive()) return; const target = event.target; if (!(target instanceof Element)) return; const button = target.closest("[data-mm-action]"); if (!button) return; const action = safeText(button.getAttribute("data-mm-action"), ""); const modelKey = safeText(button.getAttribute("data-model"), ""); runAction(action, modelKey).catch(() => {}); }); document.addEventListener("change", event => { if (!isModelRouteActive()) return; const target = event.target; if (!(target instanceof HTMLSelectElement)) return; if (target.id === "mm-active-model-select") { const modelKey = safeText(target.value, ""); if (!modelKey) return; runAction("select", modelKey).catch(() => {}); return; } if (target.id === "mm-favorite-model-select") { const modelKey = safeText(target.value, ""); if (!modelKey) return; runAction("select", modelKey).catch(() => {}); target.value = ""; return; } if (target.id === "mm-sort-mode-select") { const value = safeText(target.value, "alphabetical"); state.sortMode = value === "release_date" ? "release_date" : "alphabetical"; return; } if (target.id === "mm-community-filter-select") { const value = safeText(target.value, "all"); if (value === "yes" || value === "no" || value === "all") { state.communityFavoriteFilter = value; } else { state.communityFavoriteFilter = "all"; } } if (target.id === "mm-user-filter-select") { const value = safeText(target.value, "all"); if (value === "yes" || value === "no" || value === "all") { state.userFavoriteFilter = value; } else { state.userFavoriteFilter = "all"; } } }); } function renderActions(model) { const modelKey = safeText(model.value, ""); const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey; if (state.currentModel === modelKey) { return html`Active`; } if (state.status.downloading) { if (state.status.downloadAll || modelIsDownloading) { return html``; } return html`Busy`; } if (model.installed) { return html` ${model.builtin ? "" : html``} `; } return html``; } function renderModelRow(model) { const label = safeText(model.label, safeText(model.value, "Unnamed")); const key = safeText(model.value, ""); const favoriteAction = model.userFavorite ? "unfavorite" : "favorite"; const favoriteTitle = model.userFavorite ? "Remove from your favorites" : "Add to your favorites"; return html`
${label}
${key} ${model.builtin ? html`Built-in` : ""} ${state.sortMode === "release_date" ? "" : model.series ? html`${safeText(model.series)}` : ""} ${model.version ? html`Version ${safeText(model.version)}` : ""} ${model.released ? html`Released ${safeText(model.released)}` : ""} ${model.userFavorite ? html`Your Favorite` : ""} ${model.communityFavorite ? html`Community Favorite` : ""} ${model.partial ? html`Partial Files` : ""}
${renderActions(model)}
`; } function renderSeriesSection(seriesName, models) { return html`

${seriesName}

${models.length}
${models.map(model => renderModelRow(model))}
`; } export function ModelManager() { if (!initialized) { initialized = true; bindDomHandlers(); logDebug("Initializing component"); refreshAll().catch(error => { state.error = error?.message || String(error); state.loading = false; state.refreshing = false; logDebug("Initial refresh failed", state.error); }); } ensurePolling(); return html`

Model Manager

${() => state.error ? html`
${state.error}
` : ""}
Available Models=${() => state.models.length} Current Model=${() => getCurrentModelName()}
${state.summary.installed} installed ${state.summary.missing} missing ${state.summary.total} total
${() => state.status.downloading ? html`` : html``}
Current: ${getCurrentModelName()} Progress: ${safeText(state.status.progress, "Idle")} ${() => getUserFavoriteModels(false).length} personal favorites ${() => state.status.isOnroad ? html`Onroad: actions disabled` : ""}
${() => state.loading ? html`
Loading models...
` : ""} ${() => !state.loading ? html`
${(() => { if (state.sortMode === "release_date") { const models = getReleaseOrderedModels(); return models.length === 0 ? html`
No models available.
` : models.map(model => renderModelRow(model)); } const { grouped, seriesNames } = getSeriesGroups(); return seriesNames.length === 0 ? html`
No models available.
` : seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName])); })()}
` : ""}
`; }