From 32f8388da594f1eb5e6c1bf5065b0e82c934a83d Mon Sep 17 00:00:00 2001 From: xycld Date: Tue, 10 Nov 2026 03:53:04 -0500 Subject: [PATCH] feat(models): let models.json add models safely without a release --- .github/workflows/catalog.yml | 35 ++++ apps/desktop/src/main/lib/content.ts | 9 + apps/desktop/src/main/services/models.ts | 45 ++++- apps/desktop/src/shared/modelCatalog.ts | 174 ++++++++++++++++++ apps/desktop/test/main/lib/content.test.ts | 15 ++ .../test/main/models/modelBundles.test.ts | 2 +- .../test/main/models/models.catalog.test.ts | 97 +++++++++- .../test/main/models/models.downloads.test.ts | 2 +- apps/desktop/test/shared/modelCatalog.test.ts | 110 +++++++++++ docs/README.md | 1 + docs/how-to/add-a-model.md | 37 ++++ docs/reference/models.md | 16 +- 12 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/catalog.yml create mode 100644 apps/desktop/src/shared/modelCatalog.ts create mode 100644 apps/desktop/test/main/lib/content.test.ts create mode 100644 apps/desktop/test/shared/modelCatalog.test.ts create mode 100644 docs/how-to/add-a-model.md diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml new file mode 100644 index 00000000..07466f4b --- /dev/null +++ b/.github/workflows/catalog.yml @@ -0,0 +1,35 @@ +name: Model catalog + +# Released apps read apps/desktop/resources/models.json from main, through raw.githubusercontent.com +# (cached about five minutes) or jsDelivr, which can serve an old copy for hours. After a merge that changes +# the file, ask jsDelivr to drop its copy so a new model reaches every app within minutes. +# The file was checked on the pull request (test/shared/modelCatalog.test.ts); this only refreshes caches. +on: + push: + branches: [main] + paths: [apps/desktop/resources/models.json] + workflow_dispatch: + +permissions: + contents: read + +jobs: + purge: + name: Refresh the jsDelivr copy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Purge + run: | + url='https://purge.jsdelivr.net/gh/ShallowElec/MossStudio@main/apps/desktop/resources/models.json' + for attempt in 1 2 3 4 5; do + if body=$(curl --fail --silent --show-error --max-time 30 "$url"); then + echo "$body" + # A purge refused for rate limits still answers 200 and marks the path throttled. + if ! grep -q '"throttled":true' <<< "$body"; then exit 0; fi + fi + echo "attempt $attempt did not purge; retrying" + sleep $((attempt * 10)) + done + echo "::error::jsDelivr did not purge models.json; apps that read it there see the change once its cache expires" + exit 1 diff --git a/apps/desktop/src/main/lib/content.ts b/apps/desktop/src/main/lib/content.ts index 6cca018b..9007ffce 100644 --- a/apps/desktop/src/main/lib/content.ts +++ b/apps/desktop/src/main/lib/content.ts @@ -12,3 +12,12 @@ export function contentDir(): string | null { const dir = app.isPackaged ? process.resourcesPath : join(app.getAppPath(), 'resources') return existsSync(join(dir, 'models.json')) || existsSync(join(dir, 'rules.json')) ? dir : null } + +/** + * Where a models.json that wins over the catalog on `main` lives: the source tree, in development + * only, so an edit shows at once. A packaged app's copy is its fallback, never an override, or it + * would never see a model added after its release. + */ +export function catalogOverrideDir(): string | null { + return app.isPackaged ? null : contentDir() +} diff --git a/apps/desktop/src/main/services/models.ts b/apps/desktop/src/main/services/models.ts index a4b68a0c..c52da333 100644 --- a/apps/desktop/src/main/services/models.ts +++ b/apps/desktop/src/main/services/models.ts @@ -13,7 +13,9 @@ import { emit } from '../lib/events' import { paths } from '../lib/paths' import { assertFreeSpace, freeSpace, nearestExisting } from '../lib/disk' import { getLastNetwork } from './network' -import { contentDir } from '../lib/content' +import { app } from 'electron' +import { catalogProblems, entriesForApp } from '@shared/modelCatalog' +import { catalogOverrideDir } from '../lib/content' import { listJobs } from './supervisor' import packagedCatalog from '../../../resources/models.json' import { @@ -254,6 +256,39 @@ async function fetchText(url: string, timeoutMs = 8000): Promise { return response.text() } +const CATALOG_PATH = '/apps/desktop/resources/models.json' + +/** + * The catalog on `main`, else the last copy of it this app could use (so a model added after the + * release stays listed offline), else null for the packaged one. A copy older than the packaged + * catalog loses to it: an update brings a newer list than a copy saved before it. + */ +async function downloadedCatalog(): Promise<{ raw: RawCatalog; source: 'remote' | 'cache' } | null> { + const saved = join(paths.cache(), 'models.json') + try { + const base = getLastNetwork()?.selected.manifest ?? 'https://raw.githubusercontent.com/ShallowElec/MossStudio/main' + const text = await fetchText(`${base.replace(/\/$/, '')}${CATALOG_PATH}`) + const raw = JSON.parse(text) as RawCatalog + if (catalogProblems(raw).length === 0) { + // Saving is a convenience for the next offline start; failing to save loses nothing now. + await mkdir(paths.cache(), { recursive: true }) + .then(() => writeFile(saved, text)) + .catch(() => undefined) + return { raw, source: 'remote' } + } + } catch { + /* Offline, or the mirror is unreachable: the saved copy below. */ + } + try { + const raw = JSON.parse(await readFile(saved, 'utf8')) as RawCatalog + const older = (raw.updated_at ?? '') < (BUILTIN.updated_at ?? '') + if (!older && catalogProblems(raw).length === 0) return { raw, source: 'cache' } + } catch { + /* No saved copy yet. */ + } + return null +} + function importedModelsIndex(settings: Settings): string { return join(paths.modelImports(settings.modelsRoot), 'index.json') } @@ -360,21 +395,21 @@ export async function getCatalog(settings: Settings, force = false): Promise + +const ROLE_OF = { dit: 'dit', textEncoder: 'text_encoder', vae: 'vae' } as const + +/** + * Everything wrong with a catalog file, as one line each; empty when the app can use it. + * Beyond its shape: ids are unique, and every bundle names parts that exist in the role it uses them. + */ +export function catalogProblems(raw: unknown): string[] { + const parsed = catalogFileSchema.safeParse(raw) + if (!parsed.success) return parsed.error.issues.map((issue) => `${issue.path.join('.') || '(file)'}: ${issue.message}`) + const file = parsed.data + const problems: string[] = [] + if (file.schema_version > CATALOG_SCHEMA_VERSION) { + problems.push(`schema_version ${file.schema_version} is newer than this app reads (${CATALOG_SCHEMA_VERSION})`) + } + const duplicates = (list: Array<{ id: string }>, where: string): void => { + const seen = new Set() + for (const item of list) { + if (seen.has(item.id)) problems.push(`${where}: "${item.id}" appears twice`) + seen.add(item.id) + } + } + duplicates(file.models, 'models') + duplicates([...(file.bundles ?? []), ...(file.single_file_bundles ?? [])], 'bundles') + duplicates(file.adapters ?? [], 'adapters') + const models = new Map(file.models.map((item) => [item.id, item])) + for (const item of [...(file.bundles ?? []), ...(file.single_file_bundles ?? [])]) { + for (const [slot, role] of Object.entries(ROLE_OF) as Array<[keyof typeof ROLE_OF, string]>) { + const part = models.get(item.components[slot]) + if (!part) problems.push(`bundle "${item.id}": ${slot} "${item.components[slot]}" is not in models`) + else if (part.role !== role) problems.push(`bundle "${item.id}": ${slot} "${part.id}" has role ${part.role}, not ${role}`) + } + const support = models.get(item.support) + if (!support) problems.push(`bundle "${item.id}": support "${item.support}" is not in models`) + else if (support.role !== 'support') problems.push(`bundle "${item.id}": support "${support.id}" has role ${support.role}, not support`) + } + return problems +} + +/** + * Whether an app at `appVersion` may offer an entry that needs `minimum`. A prerelease of a + * version counts as that version (a nightly of 2.1.0 has the code 2.1.0 ships), unless the + * minimum names a prerelease itself. + */ +export function meetsMinimumVersion(appVersion: string, minimum: string | undefined): boolean { + if (!minimum) return true + const app = minimum.includes('-') ? appVersion : appVersion.replace(/[-+].*$/, '') + return compareSemver(app, minimum) >= 0 +} + +interface Gated { + id: string + min_app_version?: string +} +interface GatedBundle extends Gated { + components: { dit: string; textEncoder: string; vae: string } + support: string +} + +/** + * The entries an app at `appVersion` can use: entries needing a newer app are left out, and so + * is every bundle that names a part left out. + */ +export function entriesForApp< + T extends { + models: Gated[] + bundles?: GatedBundle[] + single_file_bundles?: GatedBundle[] + adapters?: Gated[] + }, +>(catalog: T, appVersion: string): T { + const fits = (item: Gated): boolean => meetsMinimumVersion(appVersion, item.min_app_version) + const dropped = new Set(catalog.models.filter((item) => !fits(item)).map((item) => item.id)) + if (!dropped.size && [...(catalog.bundles ?? []), ...(catalog.single_file_bundles ?? []), ...(catalog.adapters ?? [])].every(fits)) { + return catalog + } + const usable = (item: GatedBundle): boolean => + fits(item) && ![item.components.dit, item.components.textEncoder, item.components.vae, item.support].some((part) => dropped.has(part)) + return { + ...catalog, + models: catalog.models.filter(fits), + ...(catalog.bundles && { bundles: catalog.bundles.filter(usable) }), + ...(catalog.single_file_bundles && { single_file_bundles: catalog.single_file_bundles.filter(usable) }), + ...(catalog.adapters && { adapters: catalog.adapters.filter(fits) }), + } +} diff --git a/apps/desktop/test/main/lib/content.test.ts b/apps/desktop/test/main/lib/content.test.ts new file mode 100644 index 00000000..7eb54248 --- /dev/null +++ b/apps/desktop/test/main/lib/content.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' +import { app } from 'electron' +import { catalogOverrideDir, contentDir } from '@main/lib/content' + +describe('catalogOverrideDir', () => { + it('is the source tree in development, so an edit to models.json shows at once', () => { + expect(contentDir()).not.toBeNull() + expect(catalogOverrideDir()).toBe(contentDir()) + }) + + it('is nothing in a packaged app: its models.json is a fallback, and the catalog on main is read', () => { + vi.spyOn(app, 'isPackaged', 'get').mockReturnValue(true) + expect(catalogOverrideDir()).toBeNull() + }) +}) diff --git a/apps/desktop/test/main/models/modelBundles.test.ts b/apps/desktop/test/main/models/modelBundles.test.ts index 1bdfe8dc..2518512d 100644 --- a/apps/desktop/test/main/models/modelBundles.test.ts +++ b/apps/desktop/test/main/models/modelBundles.test.ts @@ -7,7 +7,7 @@ import { useTempDataRoot } from '@test/helpers/tempDir' import { cacheDirFor, installOnDisk, rawModel, testSettings, writeCatalog } from './modelFixtures' const contentDir = vi.hoisted(() => vi.fn<() => string | null>()) -vi.mock('@main/lib/content', () => ({ contentDir })) +vi.mock('@main/lib/content', () => ({ contentDir, catalogOverrideDir: () => contentDir() })) vi.mock('@main/services/supervisor', () => ({ listJobs: async () => [] })) vi.mock('@main/services/network', () => ({ getLastNetwork: () => null })) vi.mock('@main/services/hfNetwork', () => ({ fetchHf: (url: string) => fetch(url) })) diff --git a/apps/desktop/test/main/models/models.catalog.test.ts b/apps/desktop/test/main/models/models.catalog.test.ts index 4be94361..0c961b1f 100644 --- a/apps/desktop/test/main/models/models.catalog.test.ts +++ b/apps/desktop/test/main/models/models.catalog.test.ts @@ -8,7 +8,7 @@ import { cacheDirFor, installOnDisk, rawModel, sha256, testSettings, vaeFiles, w const contentDir = vi.hoisted(() => vi.fn<() => string | null>()) const listJobs = vi.hoisted(() => vi.fn<() => Promise>>()) -vi.mock('@main/lib/content', () => ({ contentDir })) +vi.mock('@main/lib/content', () => ({ contentDir, catalogOverrideDir: () => contentDir() })) vi.mock('@main/services/supervisor', () => ({ listJobs })) vi.mock('@main/services/network', () => ({ getLastNetwork: () => null })) vi.mock('@main/services/hfNetwork', () => ({ fetchHf: (url: string) => fetch(url) })) @@ -144,6 +144,101 @@ describe('getCatalog sources', () => { }) }) +describe('the catalog on main', () => { + const support = rawModel({ id: 'support-a', repo: 'org/pipe', role: 'support', files: ['model_index.json'], size_bytes: 1 }) + const dit = rawModel({ id: 'dit-a', repo: 'org/dit', role: 'dit', files: ['transformer/*'], size_bytes: 5 }) + const encoder = rawModel({ id: 'te-a', repo: 'org/te', role: 'text_encoder', files: ['text_encoder/*'], size_bytes: 3 }) + const complete = { schema_version: 3, updated_at: '2099-01-01T00:00:00Z', models: [vae, support, dit, encoder], bundles: [bundle] } + const saved = (): string => join(dataRoot(), 'cache', 'models.json') + + /** A packaged app: its own models.json is a fallback, so main's catalog is fetched. */ + function serve(body: unknown | Error): void { + contentDir.mockReturnValue(null) + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (body instanceof Error) throw body + return new Response(JSON.stringify(body)) + }), + ) + } + + it('is saved, and listed from the saved copy while offline', async () => { + serve(complete) + expect((await models.getCatalog(settings, true)).source).toBe('remote') + expect(JSON.parse(readFileSync(saved(), 'utf8'))).toEqual(complete) + + serve(new TypeError('fetch failed')) + const offline = await models.getCatalog(settings, true) + expect(offline.source).toBe('cache') + expect(offline.bundles.map((b) => b.id)).toEqual(['b1']) + }) + + it('a saved copy older than the packaged catalog loses to it', async () => { + serve({ ...complete, updated_at: '2020-01-01T00:00:00Z' }) + await models.getCatalog(settings, true) + serve(new TypeError('fetch failed')) + const offline = await models.getCatalog(settings, true) + expect(offline.source).toBe('builtin') + expect(offline.bundles.map((b) => b.id)).toContain('moss-sft') + }) + + it('is not used when it is newer than this app reads, or broken; the saved copy is', async () => { + serve(complete) + await models.getCatalog(settings, true) + for (const body of [ + { ...complete, schema_version: 4, models: [] }, + { ...complete, bundles: [{ ...bundle, components: { ...bundle.components, vae: 'gone' } }] }, + { ...complete, models: [{ ...vae, revision: 'v1' }] }, + ]) { + serve(body) + const catalog = await models.getCatalog(settings, true) + expect(catalog.source).toBe('cache') + expect(catalog.models.map((m) => m.id)).toContain('dit-a') + } + expect(JSON.parse(readFileSync(saved(), 'utf8'))).toEqual(complete) + }) + + it('leaves out what needs a newer app, and the models built on it', async () => { + vi.stubEnv('MOSSSTUDIO_TEST_APP_VERSION', '2.1.0-nightly.20261110') + const next = rawModel({ + id: 'dit-next', + repo: 'org/next', + role: 'dit', + files: ['x.safetensors'], + size_bytes: 5, + min_app_version: '2.2.0', + }) + serve({ + ...complete, + models: [...complete.models, next, { ...dit, id: 'dit-now', min_app_version: '2.1.0' }], + bundles: [bundle, { ...bundle, id: 'b-next', components: { ...bundle.components, dit: 'dit-next' } }], + single_file_bundles: [{ ...bundle, id: 'b-beta', min_app_version: '2.1.0-beta.1' }], + adapters: [ + { + id: 'lora-next', + name: 'L', + provider: 'hf', + repo: 'org/l', + revision: 'main', + kind: 'lora', + license: 'MIT', + files: ['l.safetensors'], + size_bytes: 2, + min_app_version: '3.0.0', + }, + ], + }) + const catalog = await models.getCatalog(settings, true) + expect(catalog.source).toBe('remote') + // A nightly of 2.1.0 has what 2.1.0 ships; 2.2.0 is still ahead of it. + expect(catalog.models.map((m) => m.id)).toContain('dit-now') + expect(catalog.models.map((m) => m.id)).not.toContain('dit-next') + expect(catalog.bundles.map((b) => b.id)).toEqual(['b1', 'b-beta']) + expect(catalog.adapters.map((a) => a.id)).not.toContain('lora-next') + }) +}) + describe('getCatalog caching', () => { it('caches per storage location until invalidated or forced', async () => { const first = await models.getCatalog(settings) diff --git a/apps/desktop/test/main/models/models.downloads.test.ts b/apps/desktop/test/main/models/models.downloads.test.ts index 7490f127..19f6de6b 100644 --- a/apps/desktop/test/main/models/models.downloads.test.ts +++ b/apps/desktop/test/main/models/models.downloads.test.ts @@ -20,7 +20,7 @@ import { const contentDir = vi.hoisted(() => vi.fn<() => string | null>()) const listJobs = vi.hoisted(() => vi.fn<() => Promise>>()) const fetchHf = vi.hoisted(() => vi.fn<(url: string) => Promise>()) -vi.mock('@main/lib/content', () => ({ contentDir })) +vi.mock('@main/lib/content', () => ({ contentDir, catalogOverrideDir: () => contentDir() })) vi.mock('@main/services/supervisor', () => ({ listJobs })) vi.mock('@main/services/network', () => ({ getLastNetwork: () => null })) vi.mock('@main/services/hfNetwork', () => ({ fetchHf })) diff --git a/apps/desktop/test/shared/modelCatalog.test.ts b/apps/desktop/test/shared/modelCatalog.test.ts new file mode 100644 index 00000000..ce652859 --- /dev/null +++ b/apps/desktop/test/shared/modelCatalog.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { CATALOG_SCHEMA_VERSION, catalogProblems, entriesForApp, meetsMinimumVersion } from '@shared/modelCatalog' + +/** The file released apps download from main: a change to it is live within minutes. */ +const shipped = JSON.parse(readFileSync(join(import.meta.dirname, '../../resources/models.json'), 'utf8')) as Record + +const part = (id: string, role: string, extra: Record = {}) => ({ + id, + name: id, + provider: 'hf', + repo: `org/${id}`, + revision: 'main', + role, + precision: 'bf16', + size_bytes: 10, + files: ['*.safetensors'], + license: 'MIT', + ...extra, +}) +const bundle = { id: 'b', name: 'B', description: '', components: { dit: 'd', textEncoder: 't', vae: 'v' }, support: 's' } +const valid = { + schema_version: CATALOG_SCHEMA_VERSION, + updated_at: '2026-11-10T00:00:00Z', + models: [part('d', 'dit'), part('t', 'text_encoder'), part('v', 'vae'), part('s', 'support')], + bundles: [bundle], +} + +describe('models.json', () => { + it('meets every rule, so released apps use it', () => { + expect(catalogProblems(shipped)).toEqual([]) + expect(shipped['schema_version']).toBe(CATALOG_SCHEMA_VERSION) + }) +}) + +describe('catalogProblems', () => { + it('accepts a complete catalog, with fields this app does not know', () => { + expect(catalogProblems({ ...valid, future_field: true, models: valid.models.map((m) => ({ ...m, someday: 1 })) })).toEqual([]) + }) + + it('names what is wrong', () => { + const problems = (patch: Record) => catalogProblems({ ...valid, ...patch }) + expect(problems({ schema_version: CATALOG_SCHEMA_VERSION + 1 })).toEqual([ + `schema_version ${CATALOG_SCHEMA_VERSION + 1} is newer than this app reads (${CATALOG_SCHEMA_VERSION})`, + ]) + expect(problems({ models: [...valid.models, part('d', 'dit')] })).toEqual(['models: "d" appears twice']) + expect(problems({ single_file_bundles: [bundle] })).toEqual(['bundles: "b" appears twice']) + expect(problems({ bundles: [{ ...bundle, components: { ...bundle.components, vae: 'x' } }] })).toEqual([ + 'bundle "b": vae "x" is not in models', + ]) + expect(problems({ bundles: [{ ...bundle, components: { ...bundle.components, vae: 't' } }] })).toEqual([ + 'bundle "b": vae "t" has role text_encoder, not vae', + ]) + expect(problems({ bundles: [{ ...bundle, support: 'v' }] })).toEqual(['bundle "b": support "v" has role vae, not support']) + expect(problems({ models: [part('d', 'dit', { revision: 'v1.0' }), ...valid.models.slice(1)] })).toEqual([ + 'models.0.revision: not a 40-character commit hash or "main"', + ]) + expect(problems({ models: [part('d', 'dit', { size_bytes: 0 }), ...valid.models.slice(1)] })[0]).toMatch(/^models\.0\.size_bytes: /) + expect(problems({ models: [part('d', 'dit', { min_app_version: '2.1' }), ...valid.models.slice(1)] })).toEqual([ + 'models.0.min_app_version: not a version like 2.1.0 or 2.1.0-beta.2', + ]) + expect(problems({ models: [part('d', 'dit', { repo: 'https://huggingface.co/org/d' }), ...valid.models.slice(1)] })).toEqual([ + 'models.0.repo: not an owner/name repository', + ]) + }) + + it('accepts a pinned commit', () => { + expect(catalogProblems({ ...valid, models: [part('d', 'dit', { revision: 'a'.repeat(40) }), ...valid.models.slice(1)] })).toEqual([]) + }) +}) + +describe('meetsMinimumVersion', () => { + it.each([ + ['2.1.0', undefined, true], + ['2.1.0', '2.1.0', true], + ['2.0.9', '2.1.0', false], + ['2.2.0', '2.1.0', true], + // A prerelease of 2.1.0 has the code 2.1.0 ships… + ['2.1.0-nightly.20261110', '2.1.0', true], + ['2.1.0-beta.1', '2.1.0', true], + // …unless the minimum names a prerelease itself. + ['2.1.0-beta.1', '2.1.0-beta.2', false], + ['2.1.0-beta.3', '2.1.0-beta.2', true], + ['2.1.0', '2.1.0-beta.2', true], + ['not a version', '2.1.0', false], + ])('app %s, minimum %s → %s', (app, minimum, expected) => { + expect(meetsMinimumVersion(app, minimum)).toBe(expected) + }) +}) + +describe('entriesForApp', () => { + it('returns the catalog as it is when everything fits', () => { + expect(entriesForApp(valid, '1.0.0')).toBe(valid) + }) + + it('leaves out a bundle whose part needs a newer app, in either bundle list', () => { + const catalog = { + models: [...valid.models, part('d2', 'dit', { min_app_version: '3.0.0' }), part('s2', 'support', { min_app_version: '3.0.0' })], + bundles: [bundle, { ...bundle, id: 'b2', components: { ...bundle.components, dit: 'd2' } }], + single_file_bundles: [{ ...bundle, id: 'b3', support: 's2' }], + adapters: [{ id: 'a', min_app_version: '2.0.0' }], + } + const result = entriesForApp(catalog, '2.0.0') + expect(result.models.map((m) => m.id)).toEqual(['d', 't', 'v', 's']) + expect(result.bundles.map((b) => b.id)).toEqual(['b']) + expect(result.single_file_bundles.map((b) => b.id)).toEqual([]) + expect(result.adapters.map((a) => a.id)).toEqual(['a']) + }) +}) diff --git a/docs/README.md b/docs/README.md index 3389fcc9..411ae1ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Developer documentation for MossStudio: an Electron + React + TypeScript desktop | Know the rules a reviewer applies to code | [reference/coding-standards.md](reference/coding-standards.md) | | Build or change a screen | [reference/ui-rules.md](reference/ui-rules.md) | | Write or change a doc | [reference/documentation-guidelines.md](reference/documentation-guidelines.md), [reference/glossary.md](reference/glossary.md) | +| Add a model to the catalog without a release | [how-to/add-a-model.md](how-to/add-a-model.md) | | Cut a release or rotate the update key | [how-to/release.md](how-to/release.md), then [how-to/release-qa-checklist.md](how-to/release-qa-checklist.md) | ## I want to understand... diff --git a/docs/how-to/add-a-model.md b/docs/how-to/add-a-model.md new file mode 100644 index 00000000..24b55fae --- /dev/null +++ b/docs/how-to/add-a-model.md @@ -0,0 +1,37 @@ +--- +status: current +last-verified: 2026-11-10 (ae28573) +code: apps/desktop/resources/models.json, apps/desktop/src/shared/modelCatalog.ts, apps/desktop/src/main/services/models.ts, .github/workflows/catalog.yml +--- + +# Add a model without a release + +Released apps read the model catalog from `main`, so a model is added by a pull request that edits `apps/desktop/resources/models.json`. It reaches every app within minutes of the merge, with no new version. +This page covers the steps and the two fields that keep an older app from offering what it cannot run. How the app picks and checks the catalog is in [models.md](/docs/reference/models.md#1-catalog-components-and-bundles). + +## Before you start + +The engine must load the model. Check the MossInfer [supported models](https://github.com/mossimage-cpp/mossimage.cpp#supported-models). A model the engine cannot load needs an engine release first, and the catalog cannot express that. + +## Steps + +1. **Add each new part to `models`.** Copy a similar entry and change it: + - `id`: lowercase, unique, never reused for different files. + - `repo` and `revision`: the Hugging Face repository and the full 40-character commit the files were tested at. `main` is accepted but follows whatever is pushed next. + - `role`: `dit`, `text_encoder`, `vae` or `support`. + - `files`, `subfolder` and `format` (`comfyui` for one `.safetensors` file, `diffusers` for a folder). + - `size_bytes`: the exact total of `files`. `https://huggingface.co/api/models//tree/?recursive=1` lists each file's size. + + Reuse a part that is already listed, such as `qwen35-comfy-int8_convrot` or `moss-runtime-config`, instead of adding it twice. +2. **Add the model to a bundle list.** A model made of single weight files goes in `single_file_bundles`; a diffusers folder model goes in `bundles`. The bundle names its `dit`, `textEncoder`, `vae` and `support` parts by id. The bundle fields are described in [models.md](/docs/reference/models.md#1-catalog-components-and-bundles). +3. **Set `min_app_version` if older apps cannot use it.** Do this when the model needs app code that is not in every released version, such as a new bundle field or tier. Use the first version that has that code, for example `"2.1.0"`. Leave it out when every supported version can run the model. +4. **Set `updated_at`** to the current time (ISO 8601, UTC). +5. **Check the file:** `pnpm test:unit -- test/shared/modelCatalog.test.ts`. It reports each problem on one line, such as a bundle naming a part that does not exist or a revision that is not a commit. +6. **Try it:** run `pnpm dev`. In development the app reads `models.json` from the source tree, so the model shows on the 模型 page at once. Download it and generate one image. +7. **Open a pull request** titled like `feat(models): add MOSS-Image SFT W4A8`, and merge it through the merge queue. CI runs the same check. + +After the merge, `.github/workflows/catalog.yml` clears jsDelivr's copy. Apps see the model the next time they refresh the catalog, for example when the 模型 page opens. + +## Changing the file's layout + +Leave `schema_version` at 3 when you add fields: older apps ignore fields they do not know. Raise it only when an older app would misread the file. Such an app then keeps its saved or packaged catalog and no longer sees new models, so raise `CATALOG_SCHEMA_VERSION` in `shared/modelCatalog.ts` in the same release, and expect old versions to stop getting new models. diff --git a/docs/reference/models.md b/docs/reference/models.md index e522a97d..76b71106 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -1,7 +1,7 @@ --- status: current last-verified: 2026-11-08 (d0883df) -code: apps/desktop/resources/models.json, apps/desktop/src/shared/types.ts, apps/desktop/src/shared/bundleFit.ts, apps/desktop/src/shared/localBundle.ts, apps/desktop/src/shared/adapters.ts, apps/desktop/src/main/lib/paths.ts, apps/desktop/src/main/services/models.ts, apps/desktop/src/main/services/modelBundles.ts, apps/desktop/src/main/services/modelScan.ts, apps/desktop/src/main/services/modelImports.ts, apps/desktop/src/main/services/modelFiles.ts, apps/desktop/src/main/services/controlAdapters.ts, apps/desktop/src/main/services/weightPrecision.ts, apps/desktop/src/renderer/src/pages/models/, apps/desktop/src/renderer/src/components/ModelDirectoriesCard.tsx +code: apps/desktop/resources/models.json, apps/desktop/src/shared/modelCatalog.ts, apps/desktop/src/main/lib/content.ts, apps/desktop/src/shared/types.ts, apps/desktop/src/shared/bundleFit.ts, apps/desktop/src/shared/localBundle.ts, apps/desktop/src/shared/adapters.ts, apps/desktop/src/main/lib/paths.ts, apps/desktop/src/main/services/models.ts, apps/desktop/src/main/services/modelBundles.ts, apps/desktop/src/main/services/modelScan.ts, apps/desktop/src/main/services/modelImports.ts, apps/desktop/src/main/services/modelFiles.ts, apps/desktop/src/main/services/controlAdapters.ts, apps/desktop/src/main/services/weightPrecision.ts, apps/desktop/src/renderer/src/pages/models/, apps/desktop/src/renderer/src/components/ModelDirectoriesCard.tsx --- # Models @@ -25,9 +25,17 @@ The catalog is the list of everything the app can download or has found. Its sch Where the catalog comes from (`getCatalog` in `main/services/models.ts`): -1. `apps/desktop/resources/models.json` is packaged with the app (`source: 'builtin'`). -2. On each full refresh the app fetches the same file from the manifest mirror the network probe picked (GitHub raw or jsDelivr, `main/services/network.ts`) and uses it if it parses (`source: 'remote'`). A remote catalog with `schema_version` below 2 is merged under the built-in one so it cannot reintroduce whole-repository downloads. -3. In development a `models.json` in the content directory wins (`source: 'local'`). +1. On each full refresh the app fetches `apps/desktop/resources/models.json` from `main`, through the manifest mirror the network probe picked (GitHub raw or jsDelivr, `main/services/network.ts`). It uses the file only when `catalogProblems` in [`shared/modelCatalog.ts`](/apps/desktop/src/shared/modelCatalog.ts) finds nothing wrong (`source: 'remote'`), and saves it as `/cache/models.json`. +2. When `main` is unreachable or its file is rejected, the saved copy is used (`source: 'cache'`), unless its `updated_at` is older than the packaged catalog's. +3. Otherwise the copy packaged with the app is used (`source: 'builtin'`). A packaged app's `models.json` next to `app.asar` is this fallback, never an override. +4. In development the source tree's `models.json` wins over all of these (`source: 'local'`, `catalogOverrideDir` in `main/lib/content.ts`), so an edit shows at once. + +So a model added to `models.json` on `main` reaches released apps without a release; [how-to/add-a-model.md](/docs/how-to/add-a-model.md) has the steps. Two rules keep an older app safe: + +- **`min_app_version`** on a model, bundle (either list) or adapter hides it from older apps (`entriesForApp`), and a bundle with a hidden part is hidden too. A prerelease counts as its release (a nightly of 2.1.0 meets `2.1.0`) unless the minimum names a prerelease itself (`2.1.0-beta.2`). +- **`schema_version`** above `CATALOG_SCHEMA_VERSION` (3) is rejected, and such an app keeps its saved or packaged catalog. Raise it only when an older app would misread the file. A new optional field does not raise it: unknown fields pass. A file with `schema_version` below 2 is merged under the packaged one so it cannot reintroduce whole-repository downloads. + +After a merge that changes the file, `.github/workflows/catalog.yml` purges jsDelivr's copy; GitHub raw caches it for about five minutes. The shipped catalog (`schema_version: 3`) has two kinds of bundles: