populate model listing

This commit is contained in:
Brandon Thomas committed 2026-07-01 19:13:34 -04:00
1 parent e561889fc0
commit 5ca570e8af
1 file changed
+223 -79
@@ -1,16 +1,24 @@
// Builds the picker model lists FROM the backend omni listing (the Tauri
// command response), mirroring how the web apps render `res.models`. Membership
// and order come from the backend; the frontend overlay only supplies richer UI
// metadata (selectorName, badges, tags, size icons) when it has an entry for a
// model. Backend models with no overlay entry still appear via a minimal model
// built straight from the response, so NEW backend models show up immediately.
// command response). Membership + order come from the backend, and every
// capability the API expresses is mapped 1:1 — the static overlay lists
// (`IMAGE_MODELS` / `VIDEO_MODELS`) only contribute what the API does not
// return:
// - presentation (selectorName/Description/Badges, tags, progressBarTime)
// - page-subsetting flags (canTextToImage, canEditImages, canEditAngles, …)
// - desktop-native provider knowledge (`providers`)
// - models the backend registry doesn't know (switch_x, inpaint models, …)
//
// Pure module (no tauri-api import). The store in `@storyteller/tauri-api` feeds
// it the command payload's `models[]`; the DTO is accepted structurally.
// API capability fields ALWAYS win over the overlay's copies, so the overlay's
// capability data is dead at runtime for backend-known models and can be
// deleted from the lists.
//
// Pure module (no tauri-api import). The store in `@storyteller/tauri-api`
// feeds it the command payload's `models[]`; the DTO is accepted structurally.
import { ImageModel } from "../classes/ImageModel.js";
import { VideoModel } from "../classes/VideoModel.js";
import { ModelCreator } from "../classes/metadata/ModelCreator.js";
import { SizeIconOption, SizeOption } from "../classes/metadata/SizeOption.js";
import { CommonAspectRatio } from "../classes/properties/CommonAspectRatio.js";
import { CommonResolution } from "../classes/properties/CommonResolution.js";
import { CommonQuality } from "../classes/properties/CommonQuality.js";
@@ -26,34 +34,41 @@ export interface ListingModelBase {
model: string;
model_creator?: string | null;
full_name?: string | null;
is_disabled?: boolean | null;
}
export interface ListingImageModel extends ListingModelBase {
text_prompt_supported?: boolean | null;
image_refs_supported?: boolean | null;
image_refs_max?: number | null;
text_prompt_max_length?: number | null;
batch_size_max?: number | null;
batch_size_default?: number | null;
batch_size_options?: number[] | null;
aspect_ratio_options?: string[] | null;
aspect_ratio_default?: string | null;
resolution_options?: string[] | null;
resolution_default?: string | null;
quality_options?: string[] | null;
default_quality?: string | null;
batch_size_max?: number | null;
batch_size_default?: number | null;
is_disabled?: boolean | null;
}
export interface ListingImageModel extends ListingModelBase {
image_refs_supported?: boolean | null;
image_refs_max?: number | null;
}
export interface ListingVideoModel extends ListingModelBase {
extra_info?: string | null;
extra_info_short?: string | null;
text_to_video_supported?: boolean | null;
starting_keyframe_supported?: boolean | null;
starting_keyframe_required?: boolean | null;
ending_keyframe_supported?: boolean | null;
resolution_options?: string[] | null;
resolution_default?: string | null;
image_references_supported?: boolean | null;
image_references_max?: number | null;
video_references_max?: number | null;
video_references_max_total_duration_seconds?: number | null;
audio_references_max?: number | null;
audio_references_max_total_duration_seconds?: number | null;
show_generate_with_sound_toggle?: boolean | null;
duration_seconds_options?: number[] | null;
duration_seconds_default?: number | null;
show_generate_with_sound_toggle?: boolean | null;
}
// ── Public builders ────────────────────────────────────────────────────────
@@ -62,13 +77,13 @@ export const buildImageModelsFromListing = (
overlay: ImageModel[],
listing: ListingImageModel[],
): ImageModel[] =>
build(overlay, listing, BACKEND_TO_TAURI_IMAGE_ID, minimalImageModel);
build(overlay, listing, BACKEND_TO_TAURI_IMAGE_ID, mergedImageModel);
export const buildVideoModelsFromListing = (
overlay: VideoModel[],
listing: ListingVideoModel[],
): VideoModel[] =>
build(overlay, listing, BACKEND_TO_TAURI_VIDEO_ID, minimalVideoModel);
build(overlay, listing, BACKEND_TO_TAURI_VIDEO_ID, mergedVideoModel);
// ── Core assembly ──────────────────────────────────────────────────────────
@@ -76,99 +91,167 @@ const build = <T extends { tauriId: string }, L extends ListingModelBase>(
overlay: T[],
listing: L[],
alias: Record<string, string>,
makeMinimal: (m: L, tauriId: string) => T,
merge: (m: L, tauriId: string, overlayEntry: T | undefined) => T,
): T[] => {
const overlayByTauriId = new Map(overlay.map((m) => [m.tauriId, m]));
const knownTauriIds = new Set(listing.map((m) => alias[m.model] ?? m.model));
const result: T[] = [];
const seenTauriIds = new Set<string>();
// Backend drives membership + order.
// Backend drives membership + order; every entry is rebuilt with API-first
// capabilities.
for (const m of listing) {
if (m.is_disabled === true) continue;
const tauriId = alias[m.model] ?? m.model;
// Several backend ids can alias to one frontend model (e.g. the three
// Midjourney versions all map to the single "midjourney" entry) — only
// surface the first.
// Guard: several backend ids aliasing to one frontend model would render
// duplicates — only surface the first.
if (seenTauriIds.has(tauriId)) continue;
seenTauriIds.add(tauriId);
result.push(overlayByTauriId.get(tauriId) ?? makeMinimal(m, tauriId));
result.push(merge(m, tauriId, overlayByTauriId.get(tauriId)));
}
// Append frontend-only models the backend has never heard of (switch_x,
// inpaint, angle models, …).
// inpaint models, …).
for (const m of overlay) {
if (!knownTauriIds.has(m.tauriId)) result.push(m);
}
return result;
};
// ── Minimal models (backend model with no overlay entry) ───────────────────
// ── 1:1 merge constructors (API capability > overlay; presentation ← overlay)
const minimalImageModel = (m: ListingImageModel, tauriId: string): ImageModel => {
// Backend option strings share the CommonAspectRatio / CommonResolution /
// CommonQuality serde spellings; filter out any values this build doesn't
// know (forward-compat with newer servers).
const mergedImageModel = (
m: ListingImageModel,
tauriId: string,
o: ImageModel | undefined,
): ImageModel => {
const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES);
const resolutions = knownValues(m.resolution_options, COMMON_RESOLUTION_VALUES);
const qualityOptions = knownValues(m.quality_options, COMMON_QUALITY_VALUES);
const fullName = m.full_name ?? o?.fullName ?? m.model;
return new ImageModel({
id: tauriId,
// Identity. Keep the overlay's `id` (BY_ID lookups / history) when known.
id: o?.id ?? tauriId,
tauriId,
fullName: displayName(m),
fullName,
category: "image",
creator: creatorFor(m.model_creator, m.model),
selectorName: displayName(m),
selectorDescription: "",
selectorBadges: [],
maxGenerationCount: m.batch_size_max ?? 4,
defaultGenerationCount: m.batch_size_default ?? 1,
canTextToImage: m.text_prompt_supported !== false,
canUseImagePrompt: m.image_refs_supported === true,
maxImagePromptCount: m.image_refs_max ?? 1,
canChangeAspectRatio: aspectRatios.length > 0,
aspectRatios: aspectRatios as CommonAspectRatio[],
defaultAspectRatio: knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as
| CommonAspectRatio
| undefined,
canChangeResolution: resolutions.length > 0,
resolutions: resolutions as CommonResolution[],
defaultResolution: knownValue(m.resolution_default, COMMON_RESOLUTION_VALUES) as
| CommonResolution
| undefined,
qualityOptions: qualityOptions as CommonQuality[],
defaultQuality: knownValue(m.default_quality, COMMON_QUALITY_VALUES) as
| CommonQuality
| undefined,
creator: creatorFor(m.model_creator, m.model, o?.creator),
// Presentation — the omni image API has no display fields yet.
selectorName: o?.selectorName ?? fullName,
selectorDescription: o?.selectorDescription ?? "",
selectorBadges: o?.selectorBadges ?? [],
tags: o?.tags ?? [],
progressBarTime: o?.progressBarTime,
// Page-subsetting flags — frontend-owned concepts.
canTextToImage: o ? o.canTextToImage : m.text_prompt_supported !== false,
canEditImages: o?.canEditImages ?? false,
usesInpaintingMask: o?.usesInpaintingMask ?? false,
editingIsInpainting: o?.editingIsInpainting ?? false,
canEditAngles: o?.canEditAngles ?? false,
// Desktop-native provider knowledge.
providers: o?.getProviders(),
// Capabilities — served by the API, overlay only as a transitional
// fallback where the backend config leaves a field unset.
maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength,
maxGenerationCount: m.batch_size_max ?? o?.maxGenerationCount ?? 4,
defaultGenerationCount: m.batch_size_default ?? o?.defaultGenerationCount ?? 1,
predefinedGenerationCounts: m.batch_size_options ?? o?.predefinedGenerationCounts,
canUseImagePrompt: m.image_refs_supported ?? o?.canUseImagePrompt ?? false,
maxImagePromptCount: m.image_refs_max ?? o?.maxImagePromptCount ?? 1,
canChangeAspectRatio:
aspectRatios.length > 0 || (o?.canChangeAspectRatio ?? false),
aspectRatios:
aspectRatios.length > 0 ? (aspectRatios as CommonAspectRatio[]) : o?.aspectRatios,
defaultAspectRatio:
(knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as
| CommonAspectRatio
| undefined) ?? o?.defaultAspectRatio,
canChangeResolution:
resolutions.length > 0 || (o?.canChangeResolution ?? false),
resolutions:
resolutions.length > 0 ? (resolutions as CommonResolution[]) : o?.resolutions,
defaultResolution:
(knownValue(m.resolution_default, COMMON_RESOLUTION_VALUES) as
| CommonResolution
| undefined) ?? o?.defaultResolution,
qualityOptions:
qualityOptions.length > 0 ? (qualityOptions as CommonQuality[]) : o?.qualityOptions,
defaultQuality:
(knownValue(m.default_quality, COMMON_QUALITY_VALUES) as
| CommonQuality
| undefined) ?? o?.defaultQuality,
});
};
const minimalVideoModel = (m: ListingVideoModel, tauriId: string): VideoModel =>
new VideoModel({
id: tauriId,
const mergedVideoModel = (
m: ListingVideoModel,
tauriId: string,
o: VideoModel | undefined,
): VideoModel => {
const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES);
const fullName = m.full_name ?? o?.fullName ?? m.model;
return new VideoModel({
// Identity. Keep the overlay's `id` (BY_ID lookups / history) when known.
id: o?.id ?? tauriId,
tauriId,
fullName: displayName(m),
fullName,
category: "video",
creator: creatorFor(m.model_creator, m.model),
selectorName: displayName(m),
selectorDescription: m.extra_info_short ?? "",
selectorBadges: [],
startFrame: m.starting_keyframe_supported === true,
endFrame: m.ending_keyframe_supported === true,
requiresImage:
m.starting_keyframe_required === true || m.text_to_video_supported === false,
textToVideoSupported: m.text_to_video_supported !== false,
resolutionOptions: m.resolution_options ?? undefined,
defaultResolution: m.resolution_default ?? undefined,
durationOptions: m.duration_seconds_options ?? undefined,
defaultDuration: m.duration_seconds_default ?? undefined,
generateWithSound: m.show_generate_with_sound_toggle === true,
creator: creatorFor(m.model_creator, m.model, o?.creator),
// Presentation. The video API DOES have descriptions (extra_info_short /
// extra_info) — prefer them so backend copy reaches the picker.
selectorName: o?.selectorName ?? fullName,
selectorDescription:
m.extra_info_short ?? m.extra_info ?? o?.selectorDescription ?? "",
selectorBadges: o?.selectorBadges ?? [],
tags: o?.tags ?? [],
progressBarTime: o?.progressBarTime,
supportsSystemPrompt: o?.supportsSystemPrompt,
// Desktop-native provider knowledge.
providers: o?.getProviders(),
// Capabilities — served by the API, overlay only as a transitional
// fallback where the backend config leaves a field unset.
maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength,
startFrame: m.starting_keyframe_supported ?? o?.startFrame ?? false,
endFrame: m.ending_keyframe_supported ?? o?.endFrame ?? false,
requiresImage: m.starting_keyframe_required ?? o?.requiresImage ?? false,
textToVideoSupported: m.text_to_video_supported ?? o?.textToVideoSupported,
generateWithSound: m.show_generate_with_sound_toggle ?? o?.generateWithSound,
durationOptions: m.duration_seconds_options ?? o?.durationOptions,
defaultDuration: m.duration_seconds_default ?? o?.defaultDuration,
supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode,
maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages,
maxReferenceVideos: m.video_references_max ?? o?.maxReferenceVideos,
maxVideoRefDuration:
m.video_references_max_total_duration_seconds ?? o?.maxVideoRefDuration,
maxReferenceAudios: m.audio_references_max ?? o?.maxReferenceAudios,
maxAudioRefDuration:
m.audio_references_max_total_duration_seconds ?? o?.maxAudioRefDuration,
resolutionOptions:
m.resolution_options?.map(resolutionLabel) ?? o?.resolutionOptions,
defaultResolution: m.resolution_default
? resolutionLabel(m.resolution_default)
: o?.defaultResolution,
// Aspect handling: API aspect ratios drive the modern picker; legacy
// models (grok/sora native size UI) keep their overlay sizeOptions.
sizeOptions:
aspectRatios.length > 0
? aspectRatios.map(sizeOptionForAspectRatio)
: o?.sizeOptions,
supportsCommonAspectRatio:
aspectRatios.length > 0 || (o?.supportsCommonAspectRatio ?? false),
});
};
// ── Helpers ────────────────────────────────────────────────────────────────
const displayName = (m: ListingModelBase): string => m.full_name || m.model;
const COMMON_ASPECT_RATIO_VALUES: Set<string> = new Set(Object.values(CommonAspectRatio));
const COMMON_RESOLUTION_VALUES: Set<string> = new Set(Object.values(CommonResolution));
const COMMON_QUALITY_VALUES: Set<string> = new Set(Object.values(CommonQuality));
@@ -181,8 +264,67 @@ const knownValue = (
known: Set<string>,
): string | undefined => (value != null && known.has(value) ? value : undefined);
// Guess a creator from the `model_creator` field, falling back to the model-id
// prefix, then to ArtCraft.
/**
* Derive a video size picker option from a CommonAspectRatio value.
* `tauriValue` is the CommonAspectRatio serde string sent in the request.
*/
const sizeOptionForAspectRatio = (value: string): SizeOption => {
switch (value) {
case "square":
return { tauriValue: value, textLabel: "1:1", icon: SizeIconOption.Square };
case "square_hd":
return { tauriValue: value, textLabel: "1:1 HD", icon: SizeIconOption.Square };
case "wide_sixteen_by_nine":
return { tauriValue: value, textLabel: "16:9", icon: SizeIconOption.Landscape16x9 };
case "tall_nine_by_sixteen":
return { tauriValue: value, textLabel: "9:16", icon: SizeIconOption.Portrait9x16 };
case "wide_four_by_three":
return { tauriValue: value, textLabel: "4:3", icon: SizeIconOption.Standard4x3 };
case "tall_three_by_four":
return { tauriValue: value, textLabel: "3:4", icon: SizeIconOption.Portrait3x4 };
case "wide_three_by_two":
return { tauriValue: value, textLabel: "3:2", icon: SizeIconOption.Landscape };
case "tall_two_by_three":
return { tauriValue: value, textLabel: "2:3", icon: SizeIconOption.Portrait };
case "wide_five_by_four":
return { tauriValue: value, textLabel: "5:4", icon: SizeIconOption.Landscape };
case "tall_four_by_five":
return { tauriValue: value, textLabel: "4:5", icon: SizeIconOption.Portrait };
case "wide_twenty_one_by_nine":
return { tauriValue: value, textLabel: "21:9", icon: SizeIconOption.Landscape16x9 };
case "tall_nine_by_twenty_one":
return { tauriValue: value, textLabel: "9:21", icon: SizeIconOption.Portrait9x16 };
case "wide":
return { tauriValue: value, textLabel: "Landscape", icon: SizeIconOption.Landscape };
case "tall":
return { tauriValue: value, textLabel: "Portrait", icon: SizeIconOption.Portrait };
case "auto":
return { tauriValue: value, textLabel: "Auto", icon: SizeIconOption.Square };
default:
return { tauriValue: value, textLabel: value, icon: SizeIconOption.Square };
}
};
/**
* CommonResolution serde value -> the UI label form the video promptbox uses.
* (PromptBoxVideo's RESOLUTION_STRING_TO_COMMON accepts both forms; labels
* display better.)
*/
const resolutionLabel = (value: string): string => {
switch (value) {
case "four_eighty_p":
return "480p";
case "seven_twenty_p":
return "720p";
case "ten_eighty_p":
return "1080p";
default:
return value; // Raw enum value; still request-mappable.
}
};
// Guess a creator from the API's `model_creator`, then the overlay, then the
// model-id prefix, then ArtCraft.
const CREATOR_BY_PREFIX: Array<[string, ModelCreator]> = [
["flux", ModelCreator.BlackForestLabs],
["nano_banana", ModelCreator.Google],
@@ -201,9 +343,11 @@ const CREATOR_BY_PREFIX: Array<[string, ModelCreator]> = [
const creatorFor = (
raw: string | null | undefined,
modelId: string,
overlayCreator: ModelCreator | undefined,
): ModelCreator => {
const mapped = modelCreatorFromBackend(raw ?? undefined);
if (mapped) return mapped;
if (overlayCreator) return overlayCreator;
for (const [prefix, creator] of CREATOR_BY_PREFIX) {
if (modelId.startsWith(prefix)) return creator;
}