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: `
Manage models, downloads, and Jetlink connections.
{{ status.jetlink.state }} · selected: {{ status.jetlink.model }}
{{ status.jetlink.progress }}
{{ status.jetlink.reason }}
Chestnut is connected. Jetlink stays off while Chestnut is fitted.
Prepared means the remote model is built, not active. The local Small model remains available. Small and Chestnut catalog selections are unchanged.
Set up parked with a USB 3 data cable and separate power. Keep the computer awake; keep the iPhone app open and unlocked.
Selections take effect the next time the driving model starts.
A different verified model is chosen each start. {{ status.gpuAvailable && activeBigModel ? 'Big GPU' : 'Small on-device' }} models are used. {{ (status.blacklistedModels || []).length }} excluded. Favorites only mark your preferred models.
An external GPU is not detected. Active Small is used; your Big selection is saved for later.
{{ m.unavailableReason }}
{{ dialog.message }}