import type { components } from '../generated/backend-openapi' import type { ApiSuccessOf } from './api-client' import type { LoraCatalogListItem } from '../hooks/use-catalog' type InstructionSection = NonNullable[number] type LoraCatalogItem = components['schemas']['LoraCatalogItem'] // One row the library renders, normalized across both kinds and across catalog vs. // on-disk-only (manually-placed) items. Catalog-only fields (instructions/repoId/ // requiresHfLogin/sizeBytes) are absent for on-disk-only files → card degrades to // filename + "Use". installedPath is set once an item is on disk (enables "Use"). export interface LibraryEntry { id: string name: string description?: string sizeBytes?: number downloaded: boolean installedPath?: string // variant id → absolute/installed path for each on-disk checkpoint. variantInstalledPaths?: Record recommendedStrength?: number instructions?: InstructionSection[] repoId?: string requiresHfLogin?: boolean thumbnailUrl?: string demoVideoUrl?: string author?: { name: string; url?: string | null; affiliation?: 'ltx' | 'community' } license?: { name: string; url?: string | null } // When length > 1, the library card shows a variant picker (download per checkpoint). variants?: { id: string; label: string; filename: string; sizeBytes: number }[] defaultVariantId?: string // Subset of variants[].id present on disk (API ∪ on-disk filename match). downloadedVariantIds?: string[] } // Map a catalog item (plain or IC — IcLoraCatalogItem extends the base) to the shared // LibraryEntry fields. Callers add the per-kind bits (downloaded, installedPath). export function catalogItemToEntry(item: LoraCatalogItem): Omit { // First variant is the catalog default (SSOT with backend DownloadSpec.default_variant()). const variants = item.download.variants.map(v => ({ id: v.id, label: v.label, filename: v.filename, sizeBytes: v.size_bytes, })) const defaultVariant = variants[0] return { id: item.id, name: item.name, description: item.description || undefined, sizeBytes: defaultVariant.sizeBytes, recommendedStrength: item.recommended_strength ?? undefined, instructions: item.instructions ?? undefined, repoId: item.download.repo_id, requiresHfLogin: item.requires_hf_login, thumbnailUrl: item.media?.thumbnail ?? undefined, demoVideoUrl: item.media?.demo_video ?? undefined, author: item.author ?? undefined, license: item.license ?? undefined, variants, defaultVariantId: defaultVariant.id, } } // Shared catalog id ↔ variant encoding for selector values and download session keys. export const CATALOG_VARIANT_SEP = '::' export function catalogVariantKey(catalogId: string, variantId?: string | null): string { return variantId ? `${catalogId}${CATALOG_VARIANT_SEP}${variantId}` : catalogId } export function parseCatalogVariantKey(value: string): { catalogId: string; variantId?: string } { const sep = value.indexOf(CATALOG_VARIANT_SEP) if (sep === -1) return { catalogId: value } return { catalogId: value.slice(0, sep), variantId: value.slice(sep + CATALOG_VARIANT_SEP.length) } } /** True when the selector should emit one option per installed variant. */ export function hasMultiVariantSelector( variants: { id: string }[] | undefined, downloadedVariantIds: string[] | undefined, ): boolean { return (variants?.length ?? 0) > 1 && (downloadedVariantIds?.length ?? 0) > 0 } /** Encode selector value using the same rule as buildIcLoraSelectorOptions. */ export function encodeIcLoraSelectorValue( catalogId: string, variantId: string | null | undefined, opts?: { variants?: { id: string }[]; downloadedVariantIds?: string[] }, ): string { if (opts && hasMultiVariantSelector(opts.variants, opts.downloadedVariantIds) && variantId) { return catalogVariantKey(catalogId, variantId) } return catalogId } export function parseIcLoraSelectorValue(value: string): { catalogId: string; variantId?: string } { return parseCatalogVariantKey(value) } export function buildIcLoraSelectorOptions( entries: { id: string name: string downloaded: boolean downloadedVariantIds?: string[] variants?: { id: string; label: string }[] }[], baseTypes: readonly { value: string; label: string }[], opts: { includeCatalog: boolean; includeCustom: boolean }, ): { value: string; label: string }[] { const custom = baseTypes.find(t => t.value === 'custom') const catalogOpts: { value: string; label: string }[] = [] if (opts.includeCatalog) { for (const e of entries) { if (!e.downloaded) continue if (hasMultiVariantSelector(e.variants, e.downloadedVariantIds)) { const ready = new Set(e.downloadedVariantIds) for (const v of e.variants!) { if (!ready.has(v.id)) continue catalogOpts.push({ value: catalogVariantKey(e.id, v.id), label: `${e.name} — ${v.label}`, }) } } else { catalogOpts.push({ value: e.id, label: e.name }) } } } return [ ...baseTypes.filter(t => t.value !== 'custom').map(t => ({ value: t.value, label: t.label })), ...catalogOpts, ...(opts.includeCustom && custom ? [{ value: 'custom', label: custom.label }] : []), ] } const baseName = (p: string): string => p.split(/[\\/]/).pop() ?? p const normalizeSlashes = (p: string): string => p.replace(/\\/g, '/') /** Prefer API-reported variant ids; also mark any variant whose filename is among installed basenames. */ export function resolveDownloadedVariantIds( variants: { id: string; filename: string }[] | undefined, apiIds: string[] | undefined, installedPaths: string[], ): string[] { const fromApi = apiIds ?? [] if (!variants?.length) return fromApi const installedNames = new Set(installedPaths.map(baseName)) const fromDisk = variants.filter(v => installedNames.has(v.filename)).map(v => v.id) return [...new Set([...fromApi, ...fromDisk])] } /** Prefer the catalog default when installed; otherwise the first installed variant. Never an undownloaded id. */ export function preferredVariantId( variants: { id: string }[] | undefined, defaultVariantId: string | undefined, downloadedVariantIds: string[] | undefined, ): string | undefined { if (!variants?.length) return undefined const ready = new Set(downloadedVariantIds ?? []) if (ready.size === 0) return undefined if (defaultVariantId && ready.has(defaultVariantId)) return defaultVariantId return variants.find(v => ready.has(v.id))?.id } /** Name for UI chips/pickers. Append ` — {label}` only when the catalog item has multiple variants. */ export function variantDisplayName( name: string, variantLabel?: string | null, variantCount?: number, ): string { if (!variantLabel || (variantCount !== undefined && variantCount <= 1)) return name return `${name} — ${variantLabel}` } // Never substitutes a different variant's path: a caller asking for a specific variant // must get exactly that file or nothing (see use-lora-library's useEntry for the retry path). export function resolveInstalledPath( entry: LibraryEntry, variantId?: string | null, ): string | undefined { if (!variantId) return entry.installedPath return entry.variantInstalledPaths?.[variantId] } // Persisted LoRA refs (generationParams) are stored relative to modelsDir instead of // absolute so a project survives a reinstall or a move to another machine — modelsDir // varies per install. The backend resolves relative refs against modelsDir directly // (resolve_lora_ref), so no conversion back to absolute is needed to use one. export function toModelsDirRelativeRef(absPath: string, modelsDir: string): string { if (!modelsDir) return absPath const base = normalizeSlashes(modelsDir).replace(/\/+$/, '') const norm = normalizeSlashes(absPath) if (!norm.toLowerCase().startsWith(`${base.toLowerCase()}/`)) return absPath return norm.slice(base.length + 1) } // Merge the plain-LoRA catalog with on-disk files into the library gallery list. // Cross-reference by download filename: a catalog entry carries its info + installed // path (if present). Installed catalog LoRAs appear once (enriched), never duplicated — // all variant files are marked matched. On-disk files with no catalog match are omitted // from the gallery; they remain available in the generation selector via listModels. export function mergeLoraLibrary( catalog: LoraCatalogListItem[], installed: ApiSuccessOf<'listModels'>['models'], ): LibraryEntry[] { const byFilename = new Map(installed.map(m => [baseName(m.path), m])) const installedPaths = installed.map(m => m.path) return catalog.map(c => { const base = catalogItemToEntry(c.lora) const downloadedVariantIds = resolveDownloadedVariantIds( base.variants, c.downloaded_variant_ids, installedPaths, ) const variantInstalledPaths: Record = {} for (const v of base.variants ?? []) { const m = byFilename.get(v.filename) if (!m) continue variantInstalledPaths[v.id] = m.path } const preferredId = preferredVariantId(base.variants, base.defaultVariantId, downloadedVariantIds) const installedPath = (preferredId ? variantInstalledPaths[preferredId] : undefined) ?? Object.values(variantInstalledPaths)[0] return { ...base, downloaded: c.downloaded || downloadedVariantIds.length > 0 || Boolean(installedPath), downloadedVariantIds, variantInstalledPaths: Object.keys(variantInstalledPaths).length > 0 ? variantInstalledPaths : undefined, installedPath, sizeBytes: (installedPath ? byFilename.get(baseName(installedPath))?.size_bytes : undefined) ?? base.sizeBytes, } }) }