diff --git a/webui/native/src/app.css b/webui/native/src/app.css index df994638a..96e962bc7 100644 --- a/webui/native/src/app.css +++ b/webui/native/src/app.css @@ -156,7 +156,7 @@ button:disabled { opacity: .45; cursor: not-allowed; } button.primary, button.run { color: var(--text-invert); background: linear-gradient(135deg, var(--cyan), #5ac7ff); border: 0; font-weight: 800; } .button-row button { flex: 1; } .studio-package-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 11px; } -.studio-package-buttons button, .single-model-toggle { position: relative; min-width: 0; min-height: 38px; padding: 6px 4px; font-size: 9px; line-height: 1.15; } +.studio-package-buttons button, .single-model-toggle { position: relative; min-width: 0; min-height: 38px; padding: 6px 4px; overflow: hidden; font-size: 9px; line-height: 1.15; overflow-wrap: anywhere; } .studio-package-buttons button.selected-package:not(:disabled) { border-color: var(--active-border); color: var(--cyan); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cyan) 10%, transparent); } .studio-package-buttons button:disabled { color: var(--control-disabled); background: var(--panel-2); opacity: .48; } .studio-package-buttons button.resident, .single-model-toggle.resident { padding-right: 12px; border-color: var(--active-border); color: var(--text-strong); background: var(--active-bg); opacity: 1; } @@ -268,16 +268,22 @@ pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: .model-actions { display: grid; gap: 6px; width: clamp(280px, 17vw, 324px); min-width: 0; }.model-actions small { text-align: right; } .model-actions button { padding: 7px; font-size: 11px; } .package-buttons { display: grid; grid-template-columns: repeat(3, minmax(76px, 1fr)); gap: 5px; } -.package-buttons.wide-package-set { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.package-buttons.wide-package-set .package-size { line-height: 1.15; } +/* Labels are the build only ('Turbo BF16'), so even a six-package set stays on + the three-column grid instead of widening to two half-width columns. */ +.package-buttons.wide-package-set { grid-template-columns: repeat(3, minmax(0, 1fr)); } .package-buttons:has(> .package-choice:only-child) { grid-template-columns: minmax(0, 1fr); } .package-buttons:has(> .package-choice:nth-child(2):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.package-choice { position: relative; height: 48px; min-width: 0; } -.package-install { display: flex; width: 100%; height: 48px; min-width: 0; min-height: 0; padding-top: 4px; padding-bottom: 4px; flex-direction: column; align-items: center; justify-content: center; gap: 2px; white-space: nowrap; } -.package-install > span:first-child { line-height: 1.1; } +.package-choice { position: relative; height: 56px; min-width: 0; } +.package-install { display: flex; width: 100%; height: 56px; min-width: 0; min-height: 0; padding: 4px 6px; flex-direction: column; align-items: center; justify-content: center; gap: 2px; overflow: hidden; text-align: center; } +/* Both lines clamp instead of overflowing: the package label falls back to the + full model name when the short build name would be ambiguous, and the size + line carries a status as well as the byte count. */ +.package-install > span { display: -webkit-box; max-width: 100%; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow-wrap: anywhere; } +.package-install > span:first-child { line-height: 1.15; } .package-install.preferred { border-color: var(--active-border); color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.1); } -.package-install.downloaded { padding-right: 23px; border-color: var(--button-hover-border); background: color-mix(in srgb, var(--button-bg) 80%, white 20%); color: var(--blue); white-space: normal; } -.package-install.downloaded > span:first-child { white-space: nowrap; } +.package-install.downloaded { padding-right: 23px; border-color: var(--button-hover-border); background: color-mix(in srgb, var(--button-bg) 80%, white 20%); color: var(--blue); } +/* Keep the label clear of the corner badges that overlay the button. */ +.package-choice:has(.package-update) .package-install { padding-left: 26px; } .package-install.downloaded:hover:not(:disabled) { border-color: var(--button-hover-border); background: color-mix(in srgb, var(--button-hover-bg) 76%, white 24%); } .package-install.downloaded.preferred { border-color: var(--active-border); color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.16); } .model-actions .package-delete { position: absolute; top: 3px; right: 3px; z-index: 1; display: grid; width: 18px; height: 18px; min-height: 0; padding: 2px; place-items: center; border-color: transparent; background: transparent; color: var(--muted); } @@ -285,7 +291,10 @@ pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: .package-delete svg { width: 12px; height: 12px; fill: currentColor; } .model-actions .package-update { position: absolute; top: 3px; left: 3px; z-index: 1; width: auto; min-height: 18px; padding: 2px 4px; border-color: rgba(66,232,213,.3); background: var(--active-bg); color: var(--cyan); font-size: 7px; } .shared-package-note { padding: 7px 9px; border: 1px dashed var(--line); border-radius: 7px; color: var(--muted); font-size: 9px; text-align: center; } -.package-size { color: var(--muted); font-size: 8px; font-weight: 500; letter-spacing: 0; text-transform: none; } +.package-size { color: var(--muted); font-size: 8px; font-weight: 500; line-height: 1.2; letter-spacing: 0; text-transform: none; } +/* The size line carries a status word as well as the byte count, so it gets a + line more than the label above it. */ +.package-install > span.package-size { -webkit-line-clamp: 3; line-clamp: 3; } .install-progress { grid-column: 1/-1; width: 100%; min-width: 0; max-width: 100%; } .install-progress-head { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 9px; } .install-progress-head strong { color: var(--blue); text-transform: uppercase; }.install-progress.complete .install-progress-head strong, .install-progress.cleaned .install-progress-head strong { color: var(--cyan); }.install-progress.failed .install-progress-head strong, .install-progress.cancelled .install-progress-head strong { color: var(--danger); } diff --git a/webui/native/src/lib/catalog.ts b/webui/native/src/lib/catalog.ts index baaf51af4..2fbb8a11e 100644 --- a/webui/native/src/lib/catalog.ts +++ b/webui/native/src/lib/catalog.ts @@ -1,6 +1,11 @@ import rawCatalog from '../../../configs/models_catalog.json'; import rawParams from '../../../configs/model_params.json'; -import type { CatalogEntry, InstallPackageChoice, ParamSpec } from './types'; +import type { + CatalogEntry, + InstallPackageChoice, + InstallPackageSlot, + ParamSpec +} from './types'; interface PackageEntry { family: string; @@ -23,6 +28,7 @@ interface PackageSpec { ui?: { builtin_voices?: string[]; default_voice?: string; + recommended_package?: string; }; } @@ -39,14 +45,15 @@ const packages: PackageEntry[] = Object.values(specModules).flatMap((spec) => ); const specsByFamily = new Map(Object.values(specModules).map((spec) => [spec.family, spec])); -const exposeAllGgufPackageFamilies = new Set([ - 'audiosr', - 'controlfoley', - 'firered_audio', - 'fireredtts3', - 'meanvc2', - 'midashenglm_gen' -]); + +// Package ids carry a trailing format/precision tag. Stripping it yields the +// "stem" that identifies the model itself, so a catalog download id written +// before (or after) a requantisation can still be matched to its package. Keep +// longer tags ahead of their prefixes (`q8_0` before `q8`, `q4_0` before `q4`) +// so the alternation strips the whole tag. This list is the single source of +// truth for suffix stripping anywhere in this module. +const packageIdSuffix = + /_(?:q2_k|q3_k|q4_0|q4_k|q4|q5_0|q5_k|q6_k|q8_0|q8|f16|fp16|bf16|f32|safetensors|orig)$/i; const hanCharacters = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u; @@ -70,81 +77,103 @@ const cleanPath = (value: string) => value const cleanId = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); -function preferredPackage(entries: PackageEntry[]): PackageEntry | undefined { - return entries.find((entry) => entry.default) || +// The id that identifies the model behind a package, independent of how that +// package was quantised. +const packageIdStem = (value: string) => cleanId(value.replace(packageIdSuffix, '')); + +const isGguf = (entry: PackageEntry) => entry.format === 'gguf'; + +const familyPackages = (family: string) => + packages.filter((candidate) => candidate.family === family); + +const recommendedPackageId = (family: string) => + specsByFamily.get(family)?.ui?.recommended_package; + +// Picks one package out of a candidate set. `ui.recommended_package` is the +// spec's own answer to "which build should a new user get", so it outranks the +// structural fallbacks; `default` and q8_0 remain for candidate sets that do +// not contain the recommendation (a second model in the same family). +function preferredPackage(entries: PackageEntry[], recommended?: string): PackageEntry | undefined { + return (recommended ? entries.find((entry) => entry.id === recommended) : undefined) || + entries.find((entry) => entry.default) || entries.find((entry) => entry.precision === 'q8_0') || entries[0]; } -function relatedPackages(entry: CatalogEntry): PackageEntry[] { - const family = packages.filter((candidate) => candidate.family === entry.family); - if (!family.length) return []; - if (entry.family === 'ace_step' || entry.family === 'minimax_music3') return family; - if (!entry.download_id) return family; - const exact = family.find((candidate) => candidate.id === entry.download_id); - if (exact) { - const stem = exact.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''); - const matches = family.filter((candidate) => - candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, '') === stem); - return matches.length ? matches : [exact]; - } +// Resolves the single package a catalog entry points at. Precedence, most +// specific first: +// 1. an exact package id — the catalog names the package outright; +// 2. the same id stem — the catalog names the model but a stale (or absent) +// precision tag, e.g. `omnivoice` -> `omnivoice_q8_0`. Stems are compared +// for equality rather than by prefix on purpose: a prefix test makes +// `index_tts2` swallow the separate `index_tts2_5_*` model; +// 3. a GGUF package installing into the catalog `path`. Restricted to GGUF +// because a legacy path such as models/pocket-tts names the upstream +// Safetensors drop, not the GGUF build the entry actually wants; +// 4. the family's `ui.recommended_package`, for entries whose download id is +// a bare family name that never matched a package; +// 5. anything GGUF in the family, so a not-yet-migrated entry still resolves. +// Steps 3-5 exist only for catalog entries that have not been migrated to real +// package ids; steps 1-2 cover every migrated entry. +function resolvedPackage(entry: CatalogEntry): PackageEntry | undefined { + const family = familyPackages(entry.family); + if (!family.length) return undefined; + const recommended = recommendedPackageId(entry.family); + if (entry.download_id) { + const exact = family.find((candidate) => candidate.id === entry.download_id); + if (exact) return exact; - // Resolve a legacy family/variant id before considering its old target - // directory. A directory such as models/pocket-tts may identify the gated - // upstream safetensors package, while the family default is the public GGUF - // package intended by the catalog's generic `pocket_tts` download id. - const legacyId = cleanId(entry.download_id); - const legacyMatches = family.filter((candidate) => { - const currentId = cleanId(candidate.id); - return currentId.startsWith(legacyId) || legacyId.startsWith(currentId); - }); - if (legacyMatches.length) return legacyMatches; - - const target = cleanPath(entry.path); - const targetMatches = family.filter((candidate) => cleanPath(candidate.target_directory) === target); - if (targetMatches.length) { - const stems = new Set(targetMatches.map((candidate) => - candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); - const matches = family.filter((candidate) => stems.has( - candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); - return matches.length ? matches : targetMatches; + const stem = packageIdStem(entry.download_id); + const stemMatches = family.filter((candidate) => packageIdStem(candidate.id) === stem); + if (stemMatches.length) { + return preferredPackage(stemMatches.filter(isGguf), recommended) || + preferredPackage(stemMatches, recommended); + } + + const target = cleanPath(entry.path); + const targetMatches = family.filter((candidate) => + isGguf(candidate) && cleanPath(candidate.target_directory) === target); + if (targetMatches.length) return preferredPackage(targetMatches, recommended); } - return family; + const recommendedMatch = recommended + ? family.find((candidate) => candidate.id === recommended) + : undefined; + if (recommendedMatch) return recommendedMatch; + return preferredPackage(family.filter(isGguf), recommended) || + preferredPackage(family, recommended); } -function relatedExposeAllGgufPackages(entry: CatalogEntry): PackageEntry[] { - const family = packages.filter((candidate) => - candidate.family === entry.family && candidate.format === 'gguf'); - if (!family.length) return []; - if (!entry.download_id) return family; - const exact = family.find((candidate) => candidate.id === entry.download_id); - if (!exact) return relatedPackages(entry); - const matches = family.filter((candidate) => candidate.target_directory === exact.target_directory); - return matches.length ? matches : [exact]; +// Packages that are precision variants of one model share a target directory; +// packages that are different models install into different ones. Grouping on +// that field is what the old hand-maintained family allowlist was approximating. +function relatedPackages(entry: CatalogEntry): PackageEntry[] { + const resolved = resolvedPackage(entry); + if (!resolved) return []; + return familyPackages(entry.family).filter((candidate) => + candidate.target_directory === resolved.target_directory); } +// Every quantised precision, kept in step with the tags in `packageIdSuffix` so +// a newly published quantisation sorts with its peers instead of falling +// through to the unknown bucket behind `orig`. +const quantisedPrecisions = [ + 'q2_k', 'q3_k', 'q4', 'q4_0', 'q4_k', 'q5_0', 'q5_k', 'q6_k', 'q8_0', 'q8' +]; + function exposedPackageRank(entry: PackageEntry, selectedId?: string): number { if (selectedId && entry.id === selectedId) return 0; if (entry.default) return 1; - if (['q4_k', 'q4_0', 'q8_0', 'q8'].includes(entry.precision)) return 2; + if (quantisedPrecisions.includes(entry.precision)) return 2; if (['f16', 'fp16', 'bf16'].includes(entry.precision)) return 3; if (entry.precision === 'f32') return 4; if (entry.precision === 'orig') return 5; return 6; } -function packageLabel(entry: PackageEntry): string { - if (entry.family === 'ace_step') { - const precision = entry.precision === 'bf16' - ? 'BF16' - : ['q8_0', 'q8'].includes(entry.precision) - ? 'Q8' - : entry.precision.toUpperCase(); - if (entry.id.includes('_xl_turbo_')) return `GGUF Turbo XL ${precision}`; - if (entry.id.includes('_xl_sft_')) return `GGUF Turbo XL SFT ${precision}`; - if (entry.id.includes('_turbo_')) return `GGUF Turbo ${precision}`; - return `GGUF ${precision}`; - } +// Derived label, used only when a package has no display_name. It describes the +// build, not the model, so it cannot tell two models in one target directory +// apart — see packageLabel. +function derivedPackageLabel(entry: PackageEntry): string { if (entry.format === 'safetensors') return 'Safetensors'; if (entry.id.includes('int8_dit')) return 'GGUF Q4 ConvRot'; if (entry.precision === 'q4_k' || entry.precision === 'q4_0') return 'GGUF Q4'; @@ -154,6 +183,14 @@ function packageLabel(entry: PackageEntry): string { return `GGUF ${entry.precision.toUpperCase()}`; } +// display_name is unique per package and already distinguishes both the model +// and its precision ("ACE-Step 1.5 XL Turbo BF16 GGUF"), which the derived +// label cannot: a target directory may hold several distinct models, and those +// would otherwise all render as "GGUF Q8". +function packageLabel(entry: PackageEntry): string { + return englishUiText(entry.display_name) || derivedPackageLabel(entry); +} + function packageModelPath(entry: PackageEntry): string { let modelFile: string | undefined; if (entry.format === 'gguf' && entry.family === 'minimax_h3') { @@ -199,60 +236,97 @@ function packageSessionOptions(entry: PackageEntry): Record | un return undefined; } +// The build alone, without the model name or the "GGUF" every exposed package +// shares. Install buttons are around 90px wide, so the full display_name +// overflowed them; the precision is what actually distinguishes the buttons. +function shortPackageLabel(entry: PackageEntry): string { + if (entry.format !== 'gguf') return derivedPackageLabel(entry); + const precision = entry.precision === 'orig' ? 'Original' : entry.precision.toUpperCase(); + return entry.id.includes('int8_dit') ? `${precision} ConvRot` : precision; +} + +const installChoice = (candidate: PackageEntry, shortLabel: string): InstallPackageChoice => ({ + id: candidate.id, + label: packageLabel(candidate), + short_label: shortLabel, + path: packageModelPath(candidate), + format: candidate.format, + precision: candidate.precision, + session_options: packageSessionOptions(candidate) +}); + function installChoices(entry: CatalogEntry): InstallPackageChoice[] { - const exposesAllGguf = exposeAllGgufPackageFamilies.has(entry.family); - const related = exposesAllGguf ? relatedExposeAllGgufPackages(entry) : relatedPackages(entry); - if (entry.family === 'ace_step' || entry.family === 'minimax_music3' || - exposesAllGguf) { - return related - .filter((candidate) => candidate.format === 'gguf') - .sort((left, right) => exposesAllGguf - ? exposedPackageRank(left, entry.download_id) - exposedPackageRank(right, entry.download_id) - : Number(right.default === true) - Number(left.default === true)) - .map((candidate) => ({ - id: candidate.id, - label: packageLabel(candidate), - path: packageModelPath(candidate), - format: candidate.format, - precision: candidate.precision, - session_options: packageSessionOptions(candidate) - })); - } - const q8 = preferredPackage(related.filter((candidate) => - candidate.format === 'gguf' && ['q8_0', 'q8'].includes(candidate.precision))); - const fp16 = preferredPackage(related.filter((candidate) => - candidate.format === 'gguf' && ['f16', 'fp16'].includes(candidate.precision))) || - preferredPackage(related.filter((candidate) => - candidate.format === 'gguf' && candidate.precision === 'bf16')); - const otherGguf = !q8 && !fp16 - ? related.filter((candidate) => candidate.format === 'gguf') - : []; // The native model manager intentionally exposes complete GGUF packages // only. Safetensors packages frequently depend on source-tree sidecars and - // are not yet reliable as one-click UI installs. - return [q8, fp16, ...otherGguf] - .filter((candidate): candidate is PackageEntry => candidate !== undefined) - .map((candidate) => ({ - id: candidate.id, - label: packageLabel(candidate), - path: packageModelPath(candidate), - format: candidate.format, - precision: candidate.precision, - session_options: packageSessionOptions(candidate) - })); + // are not yet reliable as one-click UI installs. Every other build of the + // resolved model is offered: they share a target directory, so switching + // between them is a requantisation rather than a different model. + const exposed = relatedPackages(entry).filter(isGguf); + if (!exposed.length) return []; + // An entry that names a package explicitly gets that package, even when the + // family recommends another build of it: the catalog id is the per-entry + // decision (a deliberate low-VRAM pick, say) and the recommendation is only + // the family-wide default for entries that express no preference. + const explicit = exposed.find((candidate) => candidate.id === entry.download_id); + const selected = explicit || preferredPackage(exposed, recommendedPackageId(entry.family)); + const ordered = exposed + .slice() + .sort((left, right) => + exposedPackageRank(left, selected?.id) - exposedPackageRank(right, selected?.id)); + // Two distinct models can share a target directory, and the short label + // describes the build only, so it cannot tell those apart. Keep the full + // display_name on every button of such a set rather than render one + // ambiguous button. + const labels = distinctLabels(ordered.map(shortPackageLabel)) || + distinctLabels(trimmedPackageLabels(ordered.map(packageLabel))) || + ordered.map(packageLabel); + return ordered.map((candidate, index) => installChoice(candidate, labels[index])); +} + +function distinctLabels(labels: string[]): string[] | undefined { + const seen = new Set(labels.map((value) => value.toLowerCase())); + return seen.size === labels.length && labels.every(Boolean) ? labels : undefined; +} + +// Second try when precisions alone collide, which happens when one target +// directory holds several models ("ACE-Step 1.5 Turbo BF16 GGUF" beside +// "ACE-Step 1.5 XL SFT BF16 GGUF"). Drop the leading words every package of the +// set shares and the trailing format word, leaving what actually differs. +function trimmedPackageLabels(labels: string[]): string[] { + if (labels.length < 2) return labels; + const words = labels.map((label) => label.split(' ').filter(Boolean)); + let shared = 0; + while (words.every((entry) => shared < entry.length - 1 && entry[shared] === words[0][shared])) { + shared++; + } + return words.map((entry) => { + const rest = entry.slice(shared); + if (rest.length > 1 && rest[rest.length - 1].toLowerCase() === 'gguf') rest.pop(); + return rest.join(' '); + }); +} + +// Why a managed entry ended up with no installable package. Returned as display +// text so the entry can stay listed instead of vanishing from the whole UI. +function unavailableReason(entry: CatalogEntry): string { + const family = familyPackages(entry.family); + if (!family.length) { + return `No model_specs package definition for family "${entry.family}".`; + } + return 'Only Safetensors packages are published for this model. The native ' + + 'model manager installs GGUF packages only.'; } -export const catalog = (rawCatalog.models as CatalogEntry[]).flatMap((entry) => { +export const catalog = (rawCatalog.models as CatalogEntry[]).map((entry) => { const choices = installChoices(entry); // A managed catalog entry with no remaining GGUF choice is Safetensors-only - // (or otherwise not installable by the native manager). Do not expose it as - // an apparently available Studio model after Safetensors UI support is - // disabled. Entries without a download id are bundled or locally managed - // and must remain visible. - if (entry.download_id && choices.length === 0) return []; + // (or has no spec yet). Keep it listed and flag it: dropping it here made a + // packaging gap look like the model had never existed. Entries without a + // download id are bundled or locally managed and are never flagged. + const unavailable = Boolean(entry.download_id) && choices.length === 0; const installPackage = choices[0]; const spec = specsByFamily.get(entry.family); - return [{ + return { ...entry, display_name: englishUiText(entry.display_name_en, entry.display_name) || entry.id, input_hint: englishUiText(entry.input_hint_en, entry.input_hint), @@ -264,10 +338,24 @@ export const catalog = (rawCatalog.models as CatalogEntry[]).flatMap((entry) => ?.filter((option) => option.required === true) .map((option) => option.name), builtin_voices: spec?.ui?.builtin_voices, - default_voice: spec?.ui?.default_voice - }]; + default_voice: spec?.ui?.default_voice, + unavailable: unavailable || undefined, + unavailable_reason: unavailable ? unavailableReason(entry) : undefined + }; }); +// The install buttons an entry renders. Every exposed package gets its own +// slot: catalog.ts already decided which packages belong together, so the UI +// must not re-derive that from family names or collapse the list to fixed +// q8/fp16 slots. +export function installPackageSlots(entry: CatalogEntry): InstallPackageSlot[] { + return (entry.install_packages || []).map((choice) => ({ + key: choice.id, + label: choice.short_label, + choice + })); +} + export const parameterCatalog = Object.fromEntries( Object.entries(rawParams as unknown as Record) .filter((entry): entry is [string, ParamSpec[]] => Array.isArray(entry[1])) diff --git a/webui/native/src/lib/types.ts b/webui/native/src/lib/types.ts index 061ef0977..799965ae4 100644 --- a/webui/native/src/lib/types.ts +++ b/webui/native/src/lib/types.ts @@ -3,12 +3,26 @@ export type StringMap = Record; export interface InstallPackageChoice { id: string; label: string; + // Button text. `label` names the model as well as the build ("IndexTTS2.5 + // Original-Dtype GGUF"), which is wider than an install button; the model + // name is already printed beside the buttons, so the short form keeps the + // build only. It falls back to `label` when that would not be unique. + short_label: string; path: string; format: string; precision: string; session_options?: StringMap; } +// One rendered install button. The catalog decides which packages an entry +// exposes, so the UI renders every slot it is given rather than re-deriving the +// set from family names. +export interface InstallPackageSlot { + key: string; + label: string; + choice: InstallPackageChoice; +} + export interface CatalogEntry { id: string; display_name: string; @@ -29,6 +43,11 @@ export interface CatalogEntry { required_request_options?: string[]; builtin_voices?: string[]; default_voice?: string; + // Set when a managed entry resolves to no installable package. The entry is + // still listed so the gap is visible instead of the model disappearing from + // the UI; `unavailable_reason` explains why it cannot be installed. + unavailable?: boolean; + unavailable_reason?: string; } export interface ParamSpec { diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte index 777984830..8395b01e2 100644 --- a/webui/native/src/routes/+page.svelte +++ b/webui/native/src/routes/+page.svelte @@ -29,7 +29,7 @@ type ModelPackageSize, type DirectoryBrowserResponse } from '$lib/api'; - import { catalog, parameterCatalog, taskLabels } from '$lib/catalog'; + import { catalog, installPackageSlots, parameterCatalog, taskLabels } from '$lib/catalog'; import { createTranslator, resolveUiLanguage, uiLanguages } from '$lib/i18n'; import MediaPreview from '$lib/MediaPreview.svelte'; import { defaultChunkBudget, splitTtsChunks } from '$lib/text'; @@ -142,14 +142,6 @@ demo_3_woman: 'demo_3_woman', demo_4_woman: 'demo_4_woman' }; - const exposeAllStudioPackageFamilies = new Set([ - 'audiosr', - 'controlfoley', - 'firered_audio', - 'fireredtts3', - 'meanvc2', - 'midashenglm_gen' - ]); function chooseUiLanguage(code: string) { uiLanguage = resolveUiLanguage([code]); @@ -518,32 +510,7 @@ return packageIsResident(entry, choice, models) || sizes[choice.id]?.installed === true; } - function studioPackageSlots(entry: CatalogEntry) { - const choices = entry.install_packages || []; - if (entry.family === 'ace_step' || entry.family === 'minimax_music3' || - exposeAllStudioPackageFamilies.has(entry.family)) { - return choices.map((choice) => ({ key: choice.id, label: choice.label, choice })); - } - const q8 = choices.find((choice) => choice.format === 'gguf' && - ['q8', 'q8_0'].includes(choice.precision)); - const fp16 = choices.find((choice) => choice.format === 'gguf' && - ['f16', 'fp16', 'bf16'].includes(choice.precision)); - if (!q8 && !fp16) { - return choices.map((choice) => ({ key: choice.id, label: choice.label, choice })); - } - return [ - { - key: 'q8', - label: q8?.label || 'GGUF Q8', - choice: q8 - }, - { - key: 'fp16', - label: fp16?.label || 'GGUF FP16', - choice: fp16 - }, - ]; - } + const studioPackageSlots = installPackageSlots; function resolveRequestSeed(value: number) { if (!Number.isInteger(value) || value < -1 || value > 0xffffffff) { @@ -735,10 +702,13 @@ job: ModelInstallJob | undefined, translate = tr ) { - if (job?.state === 'running') return `${choice.label}…`; - if (job?.state === 'queued') return `${choice.label} ${translate('models.queued')}`; - if (job?.state === 'cancelling') return `${choice.label} ${translate('models.stopping')}`; - return choice.label; + // short_label, not label: the model name is already printed beside the + // buttons and would not fit inside one. The full name stays on the title + // and aria-label. + if (job?.state === 'running') return `${choice.short_label}…`; + if (job?.state === 'queued') return `${choice.short_label} ${translate('models.queued')}`; + if (job?.state === 'cancelling') return `${choice.short_label} ${translate('models.stopping')}`; + return choice.short_label; } function packageSizeLabel( @@ -751,8 +721,10 @@ ? formatBytes(size.size_bytes) : ''; if (size?.installed) { - const version = packageVersionLabel(size, translate); - return `${selected ? translate('models.selected') : translate('models.downloaded')}${version ? ` · ${version}` : ''}${bytes ? ` · ${bytes}` : ''}`; + // The version state lives on the button title now: an update has its own + // corner badge, the other states are not actionable, and the three-part + // line did not fit the button. + return `${selected ? translate('models.selected') : translate('models.downloaded')}${bytes ? ` · ${bytes}` : ''}`; } if (bytes) return bytes; if (size?.state === 'pending') return translate('models.checkingSize'); @@ -2134,8 +2106,9 @@ disabled={loadingModel || !available} title={resident ? `Unload ${choice?.label}` : available ? `Load ${choice?.label}` : `${choice?.label || slot.label} is not downloaded`} + aria-label={choice?.label || slot.label} on:click={() => choice && toggleStudioPackage(choice)}> - {choice?.label || slot.label} + {choice?.short_label || slot.label} {/each} @@ -2570,7 +2543,10 @@ aria-pressed={packageIsSelected(entry, choice)} disabled={groupInstallBusy(group, installJobs) || (packageSizeState === 'running' && Object.keys(packageSizes).length === 0)} - title={`${choice.format.toUpperCase()} ${choice.precision}: ${resolveCatalogPath(choice.path)}`} + title={`${choice.label} — ${choice.format.toUpperCase()} ${choice.precision}${ + packageVersionLabel(packageSizes[choice.id]) ? ` · ${packageVersionLabel(packageSizes[choice.id])}` : '' + }: ${resolveCatalogPath(choice.path)}`} + aria-label={choice.label} on:click={() => useOrInstallPackage(entry, choice)}> {installButtonLabel(choice, installJobs[choice.id], tr)} {#if packageSizeLabel(packageSizes[choice.id], packageSizeState,