first class provider selector next to model selector

This commit is contained in:
bflatastic committed 2025-10-02 14:06:42 +07:00
1 parent 2d44b92bfc
commit eb0db3d67d
15 files changed
+350 -75

No files matched your search

@@ -16,9 +16,11 @@ import {
CANVAS_2D_PAGE_MODEL_LIST,
ClassyModelSelector,
ModelPage,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
useSelectedImageModel,
} from "@storyteller/ui-model-selector";
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
import { getSelectedImageModel } from "@storyteller/ui-model-selector";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -57,7 +59,7 @@ const PageDraw = () => {
const store = useSceneStore();
const selectedImageModel: ImageModel | undefined =
getSelectedImageModel(PAGE_ID);
useSelectedImageModel(PAGE_ID);
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
@@ -441,7 +443,7 @@ const PageDraw = () => {
/>
</ContextMenuContainer>
</div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={CANVAS_2D_PAGE_MODEL_LIST}
page={PAGE_ID}
@@ -451,6 +453,15 @@ const PageDraw = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -13,9 +13,11 @@ import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector";
import DrawToolControlBar from "./DrawToolControlBar";
import {
ClassyModelSelector,
getSelectedImageModel,
useSelectedImageModel,
IMAGE_EDITOR_PAGE_MODEL_LIST,
ModelPage,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
import { HistoryStack, ImageBundle } from "./HistoryStack";
@@ -27,7 +29,7 @@ const PageEdit = () => {
//useStateSceneLoader();
const selectedImageModel: ImageModel | undefined =
getSelectedImageModel(PAGE_ID);
useSelectedImageModel(PAGE_ID);
// State for canvas dimensions
const canvasWidth = useRef<number>(1024);
@@ -463,7 +465,7 @@ const PageEdit = () => {
/>
</ContextMenuContainer>
</div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_EDITOR_PAGE_MODEL_LIST}
page={PAGE_ID}
@@ -473,6 +475,15 @@ const PageEdit = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
triggerLabel="Provider"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -69,8 +69,10 @@ import {
import {
STAGE_3D_PAGE_MODEL_LIST,
ModelPage,
getSelectedImageModel,
useSelectedImageModel,
ClassyModelSelector,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
@@ -106,7 +108,7 @@ export const PageEditor = () => {
}, []);
const selectedImageModel: ImageModel | undefined =
getSelectedImageModel(PAGE_ID);
useSelectedImageModel(PAGE_ID);
const height =
dndTimelineHeight.value > -1
@@ -541,7 +543,7 @@ export const PageEditor = () => {
message={editorLoader.value.message}
/>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
@@ -551,6 +553,15 @@ export const PageEditor = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -7,7 +7,9 @@ import {
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
ModelPage,
ClassyModelSelector,
getSelectedImageModel,
useSelectedImageModel,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
interface TextToImageProps {
@@ -38,7 +40,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const [promptHeight, setPromptHeight] = useState<number>(138);
const selectedImageModel: ImageModel | undefined =
getSelectedImageModel(PAGE_ID);
useSelectedImageModel(PAGE_ID);
const jobContext: JobContextType = {
jobTokens: [],
@@ -213,7 +215,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
{!showPromptAtBottom && <BackgroundGallery />}
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
page={PAGE_ID}
@@ -224,6 +226,15 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -6,7 +6,9 @@ import {
ClassyModelSelector,
IMAGE_TO_VIDEO_PAGE_MODEL_LIST,
ModelPage,
getSelectedVideoModel,
useSelectedVideoModel,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { VideoModel } from "@storyteller/model-list";
import { animated, useSpring } from "@react-spring/web";
@@ -38,7 +40,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const [promptHeight, setPromptHeight] = useState<number>(138);
const selectedVideoModel: VideoModel | undefined =
getSelectedVideoModel(PAGE_ID);
useSelectedVideoModel(PAGE_ID);
const jobContext: JobContextType = {
jobTokens: [],
@@ -201,7 +203,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
<BackgroundGallery />
{/* } */}
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
page={PAGE_ID}
@@ -211,6 +213,15 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
page={PAGE_ID}
model={selectedVideoModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -208,6 +208,14 @@ img {
pointer-events: none;
}
/* Icon auto-contrast: invert only in dark themes */
.icon-auto-contrast {
filter: invert(1);
}
.theme-light .icon-auto-contrast {
filter: none;
}
/* Hide native scrollbars while preserving scrollability */
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
@@ -2,4 +2,6 @@ export * from "./lib/model-selectors-for-pages";
export * from "./lib/model-pages";
export * from "./lib/classy-model-selector";
export * from "./lib/classy-model-selector-store";
export * from "./lib/provider-selector";
export * from "./lib/provider-icons";
export * from "./lib/provider-lookup";
@@ -1,25 +1,47 @@
import { create } from "zustand";
import { ModelPage } from "./model-pages";
import { ImageModel, Model, VideoModel } from "@storyteller/model-list";
import { Provider } from "@storyteller/tauri-api";
interface ClassyModelSelectorState {
selectedModels: { [page in ModelPage]?: Model };
selectedProviders: { [page in ModelPage]?: { [modelId: string]: Provider } };
setSelectedModel: (page: ModelPage, model: Model) => void;
setSelectedProvider: (
page: ModelPage,
modelId: string,
provider: Provider
) => void;
}
export const useClassyModelSelectorStore = create<ClassyModelSelectorState>((set) => ({
selectedModels: {},
setSelectedModel: (page, model) =>
set((state) => ({
selectedModels: {
...state.selectedModels,
[page]: model,
},
})),
}));
export const useClassyModelSelectorStore = create<ClassyModelSelectorState>(
(set) => ({
selectedModels: {},
selectedProviders: {},
setSelectedModel: (page, model) =>
set((state) => ({
selectedModels: {
...state.selectedModels,
[page]: model,
},
})),
setSelectedProvider: (page, modelId, provider) =>
set((state) => ({
selectedProviders: {
...state.selectedProviders,
[page]: {
...(state.selectedProviders[page] ?? {}),
[modelId]: provider,
},
},
})),
})
);
export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined => {
const { selectedModels } = useClassyModelSelectorStore();
export const getSelectedImageModel = (
page: ModelPage
): ImageModel | undefined => {
const { selectedModels } = useClassyModelSelectorStore.getState();
const maybeModel = selectedModels[page];
if (!maybeModel) {
return undefined;
@@ -32,8 +54,10 @@ export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined
return undefined;
};
export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined => {
const { selectedModels } = useClassyModelSelectorStore();
export const getSelectedVideoModel = (
page: ModelPage
): VideoModel | undefined => {
const { selectedModels } = useClassyModelSelectorStore.getState();
const maybeModel = selectedModels[page];
if (!maybeModel) {
return undefined;
@@ -45,3 +69,45 @@ export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined
}
return maybeModel as VideoModel;
};
export const getSelectedProviderForModel = (
page: ModelPage,
modelId: string
): Provider | undefined => {
const { selectedProviders } = useClassyModelSelectorStore.getState();
const byPage = selectedProviders[page];
if (!byPage) return undefined;
return byPage[modelId];
};
// Reactive hooks for UI subscriptions
export const useSelectedModel = (page: ModelPage): Model | undefined =>
useClassyModelSelectorStore((s) => s.selectedModels[page]);
export const useSelectedImageModel = (
page: ModelPage
): ImageModel | undefined => {
const maybeModel = useSelectedModel(page);
if (!maybeModel) return undefined;
return maybeModel.kind === "image_model"
? (maybeModel as ImageModel)
: undefined;
};
export const useSelectedVideoModel = (
page: ModelPage
): VideoModel | undefined => {
const maybeModel = useSelectedModel(page);
if (!maybeModel) return undefined;
return maybeModel.kind === "video_model"
? (maybeModel as VideoModel)
: undefined;
};
export const useSelectedProviderForModel = (
page: ModelPage,
modelId: string | undefined
): Provider | undefined =>
useClassyModelSelectorStore((s) =>
modelId ? s.selectedProviders[page]?.[modelId] : undefined
);
@@ -0,0 +1,37 @@
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
import { Provider } from "@storyteller/tauri-api";
import { ReactNode } from "react";
const PROVIDER_TO_CREATOR: Partial<Record<Provider, ModelCreator>> = {
[Provider.ArtCraft]: ModelCreator.ArtCraft,
[Provider.Fal]: ModelCreator.Fal,
[Provider.Sora]: ModelCreator.OpenAi,
};
export const getProviderIcon = (
provider: Provider,
className = "h-4 w-4 icon-auto-contrast"
): ReactNode => {
const creator = PROVIDER_TO_CREATOR[provider];
if (creator) return getCreatorIcon(creator, className);
return (
<img
src="/resources/images/services/generic.svg"
alt="generic logo"
className={className}
/>
);
};
export const getProviderDisplayName = (provider: Provider): string => {
switch (provider) {
case Provider.ArtCraft:
return "ArtCraft";
case Provider.Fal:
return "Fal";
case Provider.Sora:
return "Sora / ChatGPT";
default:
return provider;
}
};
@@ -0,0 +1,24 @@
import { ModelPage } from "./model-pages";
import { Provider } from "@storyteller/tauri-api";
export type ProvidersByModel = Partial<Record<string, Provider[]>>;
// Centralized lookup: page (product/tab) → modelId → allowed providers
export const PROVIDER_LOOKUP_BY_PAGE: Record<ModelPage, ProvidersByModel> = {
[ModelPage.TextToImage]: {
midjourney: [Provider.ArtCraft],
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
gpt_image_1: [Provider.Sora],
},
[ModelPage.ImageToVideo]: {},
[ModelPage.Canvas2D]: {
gpt_image_1: [Provider.Sora],
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
midjourney: [Provider.ArtCraft],
},
[ModelPage.Stage3D]: {},
[ModelPage.ImageEditor]: {
gpt_image_1: [Provider.Sora],
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
},
};
@@ -0,0 +1,92 @@
import { useEffect, useMemo } from "react";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { ModelPage } from "./model-pages";
import { Model } from "@storyteller/model-list";
import { Provider } from "@storyteller/tauri-api";
import { getProviderDisplayName, getProviderIcon } from "./provider-icons";
import {
useClassyModelSelectorStore,
useSelectedProviderForModel,
} from "./classy-model-selector-store";
interface ProviderSelectorProps {
page: ModelPage;
model: Model | undefined;
providersByModel?: Partial<Record<string, Provider[]>>;
panelTitle?: string;
buttonClassName?: string;
panelClassName?: string;
triggerLabel?: string;
}
const DEFAULT_PROVIDER_OPTIONS: Provider[] = [
Provider.ArtCraft,
Provider.Fal,
Provider.Sora,
];
export function ProviderSelector({
page,
model,
providersByModel,
...popoverProps
}: ProviderSelectorProps) {
const { setSelectedProvider } = useClassyModelSelectorStore();
const modelId = model?.id;
const allowedProviders: Provider[] = useMemo(() => {
if (!modelId) return DEFAULT_PROVIDER_OPTIONS;
return providersByModel?.[modelId] ?? DEFAULT_PROVIDER_OPTIONS;
}, [providersByModel, modelId]);
const selectedProvider = useSelectedProviderForModel(page, modelId);
useEffect(() => {
if (!modelId) return;
if (!selectedProvider && allowedProviders.length > 0) {
setSelectedProvider(page, modelId, allowedProviders[0]);
}
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
const items: Omit<PopoverItem, "selected">[] = useMemo(
() =>
allowedProviders.map((p) => ({
label: getProviderDisplayName(p),
icon: getProviderIcon(p),
model: undefined,
provider: p,
})),
[allowedProviders]
);
const list = useMemo(
() =>
items.map((item) => ({
...item,
selected: (item as any).provider === selectedProvider,
})),
[items, selectedProvider]
);
const handleSelect = (item: PopoverItem) => {
if (!modelId) return;
const prov: Provider | undefined = (item as any).provider as
| Provider
| undefined;
if (!prov) return;
setSelectedProvider(page, modelId, prov);
};
return (
<PopoverMenu
items={list}
onSelect={handleSelect}
mode="hoverSelect"
showIconsInList
{...popoverProps}
buttonClassName="border-0 bg-transparent p-0 hover:bg-transparent text-lg hover:opacity-80 shadow-none"
/>
);
}
export default ProviderSelector;
@@ -1,2 +1 @@
export * from './lib/popover';
export * from "./lib/popover";
@@ -106,77 +106,64 @@ export const PopoverMenu = ({
end: "right-0",
};
// Track hover state and refs
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Hover timers and refs
const openTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
// Cleanup timeout on unmount
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
};
}, []);
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (!open && mode === "hoverSelect") {
hoverTimeoutRef.current = setTimeout(() => {
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = setTimeout(() => {
openFn();
}, 100);
}, 120);
}
};
const handleButtonMouseLeave = (closeFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
hoverTimeoutRef.current = setTimeout(
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(
() => {
if (!isHovering) {
closeFn();
}
closeFn();
},
mode === "hoverSelect" ? 300 : 150
mode === "hoverSelect" ? 200 : 120
);
};
const handlePanelMouseEnter = () => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
};
const handlePanelMouseLeave = (closeFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(
() => {
closeFn();
},
mode === "hoverSelect" ? 300 : 150
mode === "hoverSelect" ? 200 : 120
);
};
@@ -297,8 +284,8 @@ export const PopoverMenu = ({
className={twMerge(
"group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all",
item.selected
? "bg-black/40 border-l-4 border-primary"
: "hover:bg-black/20",
? "bg-ui-controls/70 border-l-4 border-primary"
: "hover:bg-ui-controls/50",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
@@ -14,7 +14,7 @@ export class Model {
// A unique frontend-only string for the model
readonly id: string;
// A unique identifier that Tauri uses for the model (this
// A unique identifier that Tauri uses for the model (this
// might differ from our backend or other systems)
readonly tauriId: string;
@@ -40,6 +40,9 @@ export class Model {
// A list of filterable "capabilities" that can be used to filter models.
readonly tags: ModelTag[];
// Optional provider preferences; two-to-many via page and providers.
readonly preferredProvidersByPage?: Partial<Record<string, string[]>>;
protected constructor(args: {
id: string;
tauriId: string;
@@ -50,6 +53,7 @@ export class Model {
selectorDescription: string;
selectorBadges: string[];
tags?: ModelTag[];
preferredProvidersByPage?: Partial<Record<string, string[]>>;
}) {
this.id = args.id;
this.tauriId = args.tauriId;
@@ -60,9 +64,10 @@ export class Model {
this.selectorDescription = args.selectorDescription;
this.selectorBadges = args.selectorBadges;
this.tags = args.tags ?? [];
this.preferredProvidersByPage = args.preferredProvidersByPage;
}
toLegacyBadges() : { label: string }[] {
toLegacyBadges(): { label: string }[] {
return this.selectorBadges.map((b) => ({ label: b }));
}
@@ -29,7 +29,7 @@ export const getCreatorIconPath = (creator: ModelCreator): string | undefined =>
export const getCreatorIcon = (
creator: ModelCreator,
className = "h-4 w-4 invert"
className = "h-4 w-4 icon-auto-contrast"
): ReactNode | null => {
const path = getCreatorIconPath(creator) ?? DEFAULT_ICON_PATH;
return React.createElement("img", {