import { GxDialog } from "./dialog.js" import { GxState } from "./state.js" import { GxSummary } from "./summary.js" import { GxNotice } from "./notice.js" import { connectionError } from "./polling.js" import { GalaxySelect } from "./galaxy-select.js" import { reportSave } from "./save-feedback.js" // Read-only model identity; a saved label never proves what modeld loaded. export class ModelStatusFeed { constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) { Object.assign(this, { publish, unauthorized, fetcher, later, cancel }) this.active = false this.generation = 0 this.displayGeneration = 0 this.request = null this.poll = null this.expiry = null } stop() { this.active = false this.generation++ this.displayGeneration++ this.request?.abort() this.request = null if (this.poll !== null) this.cancel(this.poll) if (this.expiry !== null) this.cancel(this.expiry) this.poll = this.expiry = null this.publish({ status: "idle", data: null, error: "" }) } start() { this.stop() this.active = true return this.load() } async load() { if (!this.active) return const generation = ++this.generation this.request?.abort() if (this.poll !== null) this.cancel(this.poll) this.poll = null const request = new AbortController() this.request = request const timeout = this.later(() => request.abort(), 2500) try { const response = await this.fetcher("./api/models/status", { signal: request.signal, cache: "no-store" }) if (!this.active || generation !== this.generation || request.signal.aborted) return if (response.status === 401) { this.stop() this.unauthorized() return } if (!response.ok) throw new Error("Model status is unavailable") const data = await response.json() if (!this.active || generation !== this.generation || request.signal.aborted) return const states = ["unavailable", "loading", "identity-unavailable", "active", "stale", "failed"] const variants = [null, "small", "chestnut"] const digest = data?.artifactSha256 const ids = Array.isArray(data?.catalog) ? data.catalog.map(m => m?.id) : [] if (data?.schemaVersion !== 1 || !ids.length || new Set(ids).size !== ids.length || data.catalog.some(m => typeof m.id !== "string" || !m.id || typeof m.name !== "string" || typeof m.selectable !== "boolean") || !ids.includes(data.requestedId) || !(data.loadedId === null || ids.includes(data.loadedId)) || !variants.includes(data.variant) || !states.includes(data.health) || ![null, "chestnut-load-failed", "chestnut-run-stalled", "selected-load-failed"].includes(data.fallbackReason) || !(digest === null || typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)) || typeof data.pendingNextStart !== "boolean" || (data.pendingNextStart && (data.loadedId === null || data.requestedId === data.loadedId)) || (data.loadedId === null ? data.variant !== null || digest !== null : data.variant === null || digest === null) || (data.health === "active" && data.loadedId === null)) { throw new Error("Invalid model status") } this.publish({ status: "ready", data, error: "" }) if (this.expiry !== null) this.cancel(this.expiry) const displayGeneration = ++this.displayGeneration this.expiry = this.later(() => { if (this.active && displayGeneration === this.displayGeneration) { this.publish({ status: "stale", data: null, error: "Model status refresh delayed" }) } }, 1500) } catch (error) { if (this.active && generation === this.generation) { this.displayGeneration++ if (this.expiry !== null) this.cancel(this.expiry) this.expiry = null this.publish({ status: "unavailable", data: null, error: error.message }) } } finally { this.cancel(timeout) if (generation === this.generation) this.request = null if (this.active && generation === this.generation) this.poll = this.later(() => this.load(), 1000) } } } const FILTER_KEY = "galaxy.modelManager.hardwareFilter"; const FILTERS = new Set(["both", "gpu", "comma", "none"]); function readHardwareFilter() { try { const value = localStorage.getItem(FILTER_KEY); return FILTERS.has(value) ? value : "both"; } catch { return "both"; } } function saveHardwareFilter(value) { const filter = FILTERS.has(value) ? value : "both"; try { localStorage.setItem(FILTER_KEY, filter); } catch {} return filter; } function matchesHardware(model, filter) { if (filter === "none") return false; if (filter === "gpu") return model?.requiresGpu === true; if (filter === "comma") return model?.requiresGpu === false; return true; } function hardwareLabel(model) { if (model?.requiresGpu === true) return "GPU · External"; if (model?.requiresGpu === false) return "Comma · On-device"; return "Hardware unknown"; } function bytes(value) { return Number.isSafeInteger(value) && value > 0 ? value : null; } function formatBytes(value) { return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`; } function fileSizeText(model) { const installed = bytes(model?.fileSizeBytes); const declared = bytes(model?.declaredSizeBytes || model?.artifactSize); const downloaded = bytes(model?.downloadedBytes); if (model?.partial || model?.sizeStatus === "partial") { return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`; } if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`; if (installed) return formatBytes(installed); if (declared) return formatBytes(declared); return "Unavailable"; } const actionPaths = { "select-small": "active", "select-big": "active", favorite: "preferences", unfavorite: "preferences", "enable-randomizer": "preferences", "disable-randomizer": "preferences", exclude: "preferences", include: "preferences", download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest", jetlink: "jetlink" } const actionCapabilities = { "select-small": "select", "select-big": "select", favorite: "favorites", unfavorite: "favorites", "enable-randomizer": "randomizer", "disable-randomizer": "randomizer", exclude: "exclusions", include: "exclusions", download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh", jetlink: "jetlink" } export function fitsModelProfile(model, profile) { return Array.isArray(model?.profiles) ? model.profiles.includes(profile) : profile === "big" ? model?.requiresGpu === true : model?.requiresGpu === false } export function modelActionAllowed(data, action, model = null) { if (!data || data.capabilities?.[actionCapabilities[action]] !== true) return false if (data.isOnroad !== false && !["favorite", "unfavorite", "cancel"].includes(action)) return false if (action === "jetlink") return data.jetlink !== undefined if (["select-small", "select-big"].includes(action)) { if (data.downloading || data.randomizer === true) return false if (action === "select-big" && model === null) return true return model?.installed === true && model.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small") } if (action === "download") return !data.downloading && !!model && !model.installed && model.downloadAvailable === true if (action === "downloadAll") return !data.downloading && data.models.some(m => !m.installed && m.downloadAvailable === true) if (action === "cancel") return data.downloading === true if (action === "delete") return !data.downloading && !!model && !model.builtin && model.deletable !== false && (model.installed || model.partial) && ![data.currentModel, data.activeSmallModel, data.activeBigModel].includes(model.value) if (["exclude", "include"].includes(action)) return !!model if (["enable-randomizer", "disable-randomizer"].includes(action)) return !data.downloading return ["favorite", "unfavorite"].includes(action) ? !!model : action === "refresh" && !data.downloading } export class ModelManagerFeed { constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) { Object.assign(this, { publish, unauthorized, fetcher, later, cancel }) this.active = false this.generation = 0 this.request = this.poll = this.expiry = null this.data = null this.saving = false this.lastError = "" } stop() { this.active = false this.generation++ this.request?.abort() this.request = null if (this.poll !== null) this.cancel(this.poll) this.poll = null if (this.expiry !== null) this.cancel(this.expiry) this.expiry = null this.data = null this.saving = false this.publish({ loading: false, data: null, error: "" }) } start() { this.stop() this.active = true return this.load() } async requestJson(path, payload) { const generation = ++this.generation if (payload) this.lastError = "" this.request?.abort() if (this.poll !== null) this.cancel(this.poll) this.poll = null const request = new AbortController() this.request = request const timeout = this.later(() => { if (!this.active || generation !== this.generation) return request.abort() this.generation++ this.request = null this.data = null this.saving = false this.lastError = payload ? "The result is unknown. Checking automatically…" : "Model Manager is unavailable. Reconnecting automatically…" this.publish({ loading: false, data: null, error: this.lastError }) this.poll = this.later(() => this.load(), 2000) }, 10000) try { const response = await this.fetcher(`./api/models/${path}`, { signal: request.signal, cache: "no-store", credentials: "same-origin", ...(payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } : {}) }) if (!this.active || generation !== this.generation || request.signal.aborted) return null const data = await response.json() if (!this.active || generation !== this.generation || request.signal.aborted) return null if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { this.stop() this.unauthorized() return null } if (!response.ok) throw new Error(data?.message || data?.error || "Model request failed") return data } catch (error) { if (this.active && generation === this.generation) { this.data = null this.lastError = connectionError(error) this.publish({ loading: false, data: null, error: this.lastError }) } return null } finally { this.cancel(timeout) if (this.request === request) this.request = null } } async load() { if (!this.active || this.saving) return null if (!this.data && !this.lastError) this.publish({ loading: true, data: null, error: "" }) const data = await this.requestJson("manager") if (!this.active) return null if (data) { if (data.schemaVersion !== 1 || !Array.isArray(data.models) || data.models.some(m => !m || typeof m.value !== "string" || typeof m.label !== "string") || typeof data.isOnroad !== "boolean" || typeof data.capabilities !== "object" || data.capabilities === null) { this.data = null this.publish({ loading: false, data: null, error: "Invalid Model Manager response" }) } else { this.data = data this.lastError = "" if (this.expiry !== null) this.cancel(this.expiry) this.expiry = null if (data.jetlink?.active === true) { this.expiry = this.later(() => { this.expiry = null if (!this.active || this.data !== data) return this.data = { ...data, jetlink: { ...data.jetlink, active: false, state: "checking execution", runtimeState: "unavailable" } } this.publish({ loading: false, data: this.data, error: "" }) }, 1500) } this.publish({ loading: false, data, error: "" }) } } if (this.poll === null) this.poll = this.later(() => this.load(), this.data?.jetlink?.mode !== "off" && this.data?.jetlink ? 1000 : 2000) return this.data } async action(action, model = null, extra = {}) { if (!this.active || this.saving || !this.data) return null if (model !== null) { model = this.data.models.find(m => m.value === model.value) if (!model) return null } if (!modelActionAllowed(this.data, action, model)) return null const data = this.data const key = model?.value || "" let payload = action === "jetlink" ? extra : {} if (action.startsWith("select-")) payload = { profile: action === "select-big" ? "big" : "small", model: key } else if (["favorite", "unfavorite"].includes(action)) { const userFavorites = data.models.filter(m => m.userFavorite && m.value !== key).map(m => m.value) if (action === "favorite") userFavorites.push(key) payload = { userFavorites } } else if (["exclude", "include"].includes(action)) { const blacklistedModels = (data.blacklistedModels || []).filter(value => value !== key) if (action === "exclude") blacklistedModels.push(key) payload = { blacklistedModels } } else if (["enable-randomizer", "disable-randomizer"].includes(action)) payload = { randomizer: action === "enable-randomizer" } else if (["download", "delete"].includes(action)) payload = { model: key } if (["download", "downloadAll"].includes(action)) payload.allowGpuWithoutGpu = extra.allowGpuWithoutGpu === true if (typeof data.view === "string") payload.view = data.view this.saving = true this.data = null const result = await this.requestJson(actionPaths[action], payload) if (!this.active) return null this.saving = false const error = this.lastError await this.load() if (error && this.active) this.publish({ loading: false, data: this.data, error }) return result } } export const ModelsPage = { name: "ModelsPage", components: { GxDialog, GxState, GxSummary, GalaxySelect, GxNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, data() { return { loading: true, error: "", message: "", trackingProgress: false, busy: "", selectionUncertain: true, disposed: false, sortMode: "release_date", userFilter: "all", communityFilter: "all", hardwareFilter: readHardwareFilter(), models: [], currentModel: "", activeSmallModel: "", activeBigModel: "", capabilities: {}, summary: { installed: 0, missing: 0, total: 0 }, status: { isOnroad: true }, runtime: { status: "idle", data: null, error: "" }, dialog: null, pendingChoice: null } }, computed: { currentLabel() { return this.models.find(m => m.value === this.currentModel)?.label || this.currentModel || "" }, runningModelLabel() { if (!this.selectionUncertain && this.status.jetlink?.active) return "Jetlink remote model" if (this.runtime.status === "unavailable" || this.runtime.status === "stale") return "Status unavailable" if (this.runtime.status !== "ready" || !this.runtime.data) return "Checking…" const model = this.runtime.data if (model.health === "unavailable") return "No running model" if (!model.loadedId) return model.health === "loading" ? "Loading…" : "Identity unverified" return model.catalog.find(item => item.id === model.loadedId)?.name || model.loadedId }, installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.installed && m.selectable) && fitsModelProfile(m, "small")) }, installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.installed && m.selectable) && fitsModelProfile(m, "big")) }, sorted() { return this.models.filter(m => matchesHardware(m, this.hardwareFilter)) .filter(m => this.userFilter === "all" || !!m.userFavorite === (this.userFilter === "yes")) .filter(m => this.communityFilter === "all" || !!m.communityFavorite === (this.communityFilter === "yes")) .sort((a, b) => (this.sortMode === "release_date" ? (Date.parse(b.released) || 0) - (Date.parse(a.released) || 0) : 0) || (a.label || a.value).localeCompare(b.label || b.value, undefined, { sensitivity: "base", numeric: true })) }, downloadTargetLabel() { return this.status.downloadAll ? "all missing models" : this.models.find(m => m.value === this.status.modelToDownload)?.label || "a model" }, }, created() { this.feed = new ModelStatusFeed({ publish: update => { this.runtime = update }, unauthorized: this.unauthorized }) this.manager = new ModelManagerFeed({ publish: update => { this.loading = update.loading this.error = update.error this.selectionUncertain = !update.data this.capabilities = update.data?.capabilities || {} if (!update.data && this.status.jetlink) this.status = { ...this.status, jetlink: { ...this.status.jetlink, active: false, state: "unavailable" } } if (update.data) { const p = update.data this.models = p.models this.currentModel = p.currentModel || "" this.activeSmallModel = p.activeSmallModel || "" this.activeBigModel = p.activeBigModel || "" this.summary = p.summary || { installed: 0, missing: 0, total: 0 } this.status = p if (this.trackingProgress && p.progress) { this.message = p.progress if (!p.downloading) this.trackingProgress = false } } }, unauthorized: this.unauthorized }) }, mounted() { this.visibility = () => { if (document.hidden) { this.finishDialog(false); this.feed.stop(); this.manager.stop() } else if (this.mode === "local") { this.feed.start(); this.manager.start() } } document.addEventListener("visibilitychange", this.visibility) if (this.mode === "local" && !document.hidden) { this.feed.start(); this.manager.start() } }, beforeUnmount() { this.disposed = true this.finishDialog(false) document.removeEventListener("visibilitychange", this.visibility) this.feed.stop() this.manager.stop() }, methods: { hardwareLabel, fileSizeText, setHardwareFilter(value) { this.hardwareFilter = saveHardwareFilter(value) }, toggleHardware(kind, checked) { const gpu = kind === "gpu" ? checked : ["both", "gpu"].includes(this.hardwareFilter) const comma = kind === "comma" ? checked : ["both", "comma"].includes(this.hardwareFilter) this.setHardwareFilter(gpu && comma ? "both" : gpu ? "gpu" : comma ? "comma" : "none") }, actionAvailable(action, model = null) { return !this.selectionUncertain && modelActionAllowed(this.status, action, model) }, canAction(action, model = null) { return !this.busy && this.actionAvailable(action, model) }, rowState(model) { if ([this.currentModel, this.activeSmallModel, this.activeBigModel].includes(model.value)) return "active" if (this.status.downloading) return this.status.downloadAll || this.status.modelToDownload === model.value ? "cancellable" : "busy" return model.installed ? "installed" : "available" }, confirmAction(dialog) { return new Promise(resolve => { this.dialog = dialog this.resolveDialog = resolve this.$nextTick(() => this.$refs.confirmButton?.focus()) }) }, finishDialog(value) { this.resolveDialog?.(value) this.resolveDialog = null this.dialog = null }, toggleRandomizer(event) { const enabled = event.target.checked event.target.checked = this.status.randomizer === true return this.runAction(enabled ? "enable-randomizer" : "disable-randomizer") }, toggleJetlinkCharge(event) { const enabled = event.target.checked event.target.checked = this.status.jetlink.chargePhone === true return this.configureJetlink(this.status.jetlink.mode, enabled) }, async configureJetlink(mode, chargePhone = this.status.jetlink?.chargePhone === true) { if (!this.canAction("jetlink")) return this.pendingChoice = { action: "jetlink", value: mode } this.busy = "jetlink:" try { const result = await reportSave(() => this.manager.action("jetlink", null, { mode, chargePhone })) if (result) this.message = result.message } finally { this.pendingChoice = null; this.busy = "" } }, async runAction(action, model = null) { if (this.disposed || !this.canAction(action, model)) return if (action.startsWith("select-")) this.pendingChoice = { action, value: model?.value || "" } this.busy = `${action}:${model?.value || ""}` this.message = "" this.trackingProgress = false try { let allowGpuWithoutGpu = false if (action === "enable-randomizer" && !await this.confirmAction({ title: "Model Randomizer", message: "Choose a different installed, verified model each start. Excluded models are skipped. Favorites do not change the random pool.", confirmLabel: "Enable" })) return if (action === "delete" && !await this.confirmAction({ title: "Delete Model", message: `Delete local files for "${model.label}"?`, confirmLabel: "Delete", danger: true })) return const needsGpu = action === "download" ? model.requiresGpu && !model.gpuAvailable : action === "downloadAll" && this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable && m.downloadAvailable === true) if (needsGpu) { allowGpuWithoutGpu = await this.confirmAction({ title: "No External GPU Detected", confirmLabel: "Download Anyway", message: "These model files require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected. GPU model files can be large. Downloading does not activate a model." }) if (!allowGpuWithoutGpu) return } if (this.disposed) return const perform = () => this.manager.action(action, model, { allowGpuWithoutGpu }) const result = await (["enable-randomizer", "disable-randomizer", "include", "exclude"].includes(action) ? reportSave(perform) : perform()) if (!this.disposed && result) { this.trackingProgress = ["download", "downloadAll", "refresh"].includes(action) this.message = (this.trackingProgress && this.status.progress) || result.message || (action.startsWith("select-") ? "Selection saved for the next start." : "Saved.") } } finally { if (!this.disposed) { this.pendingChoice = null; this.busy = "" } } }, }, template: `
Model Manager is unavailable in the offline preview. Reading installed models and available downloads…

{{ dialog.title }}

{{ dialog.message }}

`, }