| | import { SvelteSet } from 'svelte/reactivity';
|
| | import { ServerModelStatus, ModelModality } from '$lib/enums';
|
| | import { ModelsService, PropsService } from '$lib/services';
|
| | import { serverStore } from '$lib/stores/server.svelte';
|
| | import { TTLCache } from '$lib/utils';
|
| | import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | class ModelsStore {
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | models = $state<ModelOption[]>([]);
|
| | routerModels = $state<ApiModelDataEntry[]>([]);
|
| | loading = $state(false);
|
| | updating = $state(false);
|
| | error = $state<string | null>(null);
|
| | selectedModelId = $state<string | null>(null);
|
| | selectedModelName = $state<string | null>(null);
|
| |
|
| | private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
| | private modelLoadingStates = $state<Map<string, boolean>>(new Map());
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
|
| | ttlMs: MODEL_PROPS_CACHE_TTL_MS,
|
| | maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES
|
| | });
|
| | private modelPropsFetching = $state<Set<string>>(new Set());
|
| |
|
| | |
| | |
| |
|
| | propsCacheVersion = $state(0);
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | get selectedModel(): ModelOption | null {
|
| | if (!this.selectedModelId) return null;
|
| | return this.models.find((model) => model.id === this.selectedModelId) ?? null;
|
| | }
|
| |
|
| | get loadedModelIds(): string[] {
|
| | return this.routerModels
|
| | .filter((m) => m.status.value === ServerModelStatus.LOADED)
|
| | .map((m) => m.id);
|
| | }
|
| |
|
| | get loadingModelIds(): string[] {
|
| | return Array.from(this.modelLoadingStates.entries())
|
| | .filter(([, loading]) => loading)
|
| | .map(([id]) => id);
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | get singleModelName(): string | null {
|
| | if (serverStore.isRouterMode) return null;
|
| |
|
| | const props = serverStore.props;
|
| | if (props?.model_alias) return props.model_alias;
|
| | if (!props?.model_path) return null;
|
| |
|
| | return props.model_path.split(/(\\|\/)/).pop() || null;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | getModelModalities(modelId: string): ModelModalities | null {
|
| | const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
| | if (model?.modalities) {
|
| | return model.modalities;
|
| | }
|
| |
|
| | const props = this.modelPropsCache.get(modelId);
|
| | if (props?.modalities) {
|
| | return {
|
| | vision: props.modalities.vision ?? false,
|
| | audio: props.modalities.audio ?? false
|
| | };
|
| | }
|
| |
|
| | return null;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | modelSupportsVision(modelId: string): boolean {
|
| | return this.getModelModalities(modelId)?.vision ?? false;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | modelSupportsAudio(modelId: string): boolean {
|
| | return this.getModelModalities(modelId)?.audio ?? false;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | getModelModalitiesArray(modelId: string): ModelModality[] {
|
| | const modalities = this.getModelModalities(modelId);
|
| | if (!modalities) return [];
|
| |
|
| | const result: ModelModality[] = [];
|
| |
|
| | if (modalities.vision) result.push(ModelModality.VISION);
|
| | if (modalities.audio) result.push(ModelModality.AUDIO);
|
| |
|
| | return result;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | getModelProps(modelId: string): ApiLlamaCppServerProps | null {
|
| | return this.modelPropsCache.get(modelId);
|
| | }
|
| |
|
| | |
| | |
| |
|
| | getModelContextSize(modelId: string): number | null {
|
| | const props = this.getModelProps(modelId);
|
| | const nCtx = props?.default_generation_settings?.n_ctx;
|
| |
|
| | return typeof nCtx === 'number' ? nCtx : null;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | get selectedModelContextSize(): number | null {
|
| | if (!this.selectedModelName) return null;
|
| | return this.getModelContextSize(this.selectedModelName);
|
| | }
|
| |
|
| | |
| | |
| |
|
| | isModelPropsFetching(modelId: string): boolean {
|
| | return this.modelPropsFetching.has(modelId);
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | isModelLoaded(modelId: string): boolean {
|
| | const model = this.routerModels.find((m) => m.id === modelId);
|
| | return model?.status.value === ServerModelStatus.LOADED || false;
|
| | }
|
| |
|
| | isModelOperationInProgress(modelId: string): boolean {
|
| | return this.modelLoadingStates.get(modelId) ?? false;
|
| | }
|
| |
|
| | getModelStatus(modelId: string): ServerModelStatus | null {
|
| | const model = this.routerModels.find((m) => m.id === modelId);
|
| | return model?.status.value ?? null;
|
| | }
|
| |
|
| | getModelUsage(modelId: string): SvelteSet<string> {
|
| | return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
|
| | }
|
| |
|
| | isModelInUse(modelId: string): boolean {
|
| | const usage = this.modelUsage.get(modelId);
|
| | return usage !== undefined && usage.size > 0;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | async fetch(force = false): Promise<void> {
|
| | if (this.loading) return;
|
| | if (this.models.length > 0 && !force) return;
|
| |
|
| | this.loading = true;
|
| | this.error = null;
|
| |
|
| | try {
|
| | if (!serverStore.props) {
|
| | await serverStore.fetch();
|
| | }
|
| |
|
| | const response = await ModelsService.list();
|
| |
|
| | const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
|
| | const details = response.models?.[index];
|
| | const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
| | const displayNameSource =
|
| | details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
| | const displayName = this.toDisplayName(displayNameSource);
|
| |
|
| | return {
|
| | id: item.id,
|
| | name: displayName,
|
| | model: details?.model || item.id,
|
| | description: details?.description,
|
| | capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
| | details: details?.details,
|
| | meta: item.meta ?? null
|
| | } satisfies ModelOption;
|
| | });
|
| |
|
| | this.models = models;
|
| |
|
| |
|
| |
|
| | const serverProps = serverStore.props;
|
| | if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
|
| | const modalities: ModelModalities = {
|
| | vision: serverProps.modalities.vision ?? false,
|
| | audio: serverProps.modalities.audio ?? false
|
| | };
|
| | this.modelPropsCache.set(this.models[0].model, serverProps);
|
| | this.models = this.models.map((model, index) =>
|
| | index === 0 ? { ...model, modalities } : model
|
| | );
|
| | }
|
| | } catch (error) {
|
| | this.models = [];
|
| | this.error = error instanceof Error ? error.message : 'Failed to load models';
|
| | throw error;
|
| | } finally {
|
| | this.loading = false;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async fetchRouterModels(): Promise<void> {
|
| | try {
|
| | const response = await ModelsService.listRouter();
|
| | this.routerModels = response.data;
|
| | await this.fetchModalitiesForLoadedModels();
|
| |
|
| | const o = this.models.filter((option) => {
|
| | const modelProps = this.getModelProps(option.model);
|
| |
|
| | return modelProps?.webui !== false;
|
| | });
|
| |
|
| | if (o.length === 1 && this.isModelLoaded(o[0].model)) {
|
| | this.selectModelById(o[0].id);
|
| | }
|
| | } catch (error) {
|
| | console.warn('Failed to fetch router models:', error);
|
| | this.routerModels = [];
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
|
| | const cached = this.modelPropsCache.get(modelId);
|
| | if (cached) return cached;
|
| |
|
| | if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
|
| | return null;
|
| | }
|
| |
|
| | if (this.modelPropsFetching.has(modelId)) return null;
|
| |
|
| | this.modelPropsFetching.add(modelId);
|
| |
|
| | try {
|
| | const props = await PropsService.fetchForModel(modelId);
|
| | this.modelPropsCache.set(modelId, props);
|
| | return props;
|
| | } catch (error) {
|
| | console.warn(`Failed to fetch props for model ${modelId}:`, error);
|
| | return null;
|
| | } finally {
|
| | this.modelPropsFetching.delete(modelId);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async fetchModalitiesForLoadedModels(): Promise<void> {
|
| | const loadedModelIds = this.loadedModelIds;
|
| | if (loadedModelIds.length === 0) return;
|
| |
|
| | const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
|
| |
|
| | try {
|
| | const results = await Promise.all(propsPromises);
|
| |
|
| |
|
| | this.models = this.models.map((model) => {
|
| | const modelIndex = loadedModelIds.indexOf(model.model);
|
| | if (modelIndex === -1) return model;
|
| |
|
| | const props = results[modelIndex];
|
| | if (!props?.modalities) return model;
|
| |
|
| | const modalities: ModelModalities = {
|
| | vision: props.modalities.vision ?? false,
|
| | audio: props.modalities.audio ?? false
|
| | };
|
| |
|
| | return { ...model, modalities };
|
| | });
|
| |
|
| | this.propsCacheVersion++;
|
| | } catch (error) {
|
| | console.warn('Failed to fetch modalities for loaded models:', error);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async updateModelModalities(modelId: string): Promise<void> {
|
| | try {
|
| | const props = await this.fetchModelProps(modelId);
|
| | if (!props?.modalities) return;
|
| |
|
| | const modalities: ModelModalities = {
|
| | vision: props.modalities.vision ?? false,
|
| | audio: props.modalities.audio ?? false
|
| | };
|
| |
|
| | this.models = this.models.map((model) =>
|
| | model.model === modelId ? { ...model, modalities } : model
|
| | );
|
| |
|
| | this.propsCacheVersion++;
|
| | } catch (error) {
|
| | console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| |
|
| | async selectModelById(modelId: string): Promise<void> {
|
| | if (!modelId || this.updating) return;
|
| | if (this.selectedModelId === modelId) return;
|
| |
|
| | const option = this.models.find((model) => model.id === modelId);
|
| | if (!option) throw new Error('Selected model is not available');
|
| |
|
| | this.updating = true;
|
| | this.error = null;
|
| |
|
| | try {
|
| | this.selectedModelId = option.id;
|
| | this.selectedModelName = option.model;
|
| | } finally {
|
| | this.updating = false;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | selectModelByName(modelName: string): void {
|
| | const option = this.models.find((model) => model.model === modelName);
|
| | if (option) {
|
| | this.selectedModelId = option.id;
|
| | this.selectedModelName = option.model;
|
| | }
|
| | }
|
| |
|
| | clearSelection(): void {
|
| | this.selectedModelId = null;
|
| | this.selectedModelName = null;
|
| | }
|
| |
|
| | findModelByName(modelName: string): ModelOption | null {
|
| | return this.models.find((model) => model.model === modelName) ?? null;
|
| | }
|
| |
|
| | findModelById(modelId: string): ModelOption | null {
|
| | return this.models.find((model) => model.id === modelId) ?? null;
|
| | }
|
| |
|
| | hasModel(modelName: string): boolean {
|
| | return this.models.some((model) => model.model === modelName);
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| |
|
| | private static readonly STATUS_POLL_INTERVAL = 500;
|
| |
|
| | private static readonly STATUS_POLL_MAX_ATTEMPTS = 60;
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private async pollForModelStatus(
|
| | modelId: string,
|
| | expectedStatus: ServerModelStatus
|
| | ): Promise<void> {
|
| | for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
|
| | await this.fetchRouterModels();
|
| |
|
| | const currentStatus = this.getModelStatus(modelId);
|
| | if (currentStatus === expectedStatus) {
|
| | return;
|
| | }
|
| |
|
| | await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
|
| | }
|
| |
|
| | console.warn(
|
| | `Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts`
|
| | );
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async loadModel(modelId: string): Promise<void> {
|
| | if (this.isModelLoaded(modelId)) {
|
| | return;
|
| | }
|
| |
|
| | if (this.modelLoadingStates.get(modelId)) return;
|
| |
|
| | this.modelLoadingStates.set(modelId, true);
|
| | this.error = null;
|
| |
|
| | try {
|
| | await ModelsService.load(modelId);
|
| | await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
| |
|
| | await this.updateModelModalities(modelId);
|
| | } catch (error) {
|
| | this.error = error instanceof Error ? error.message : 'Failed to load model';
|
| | throw error;
|
| | } finally {
|
| | this.modelLoadingStates.set(modelId, false);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async unloadModel(modelId: string): Promise<void> {
|
| | if (!this.isModelLoaded(modelId)) {
|
| | return;
|
| | }
|
| |
|
| | if (this.modelLoadingStates.get(modelId)) return;
|
| |
|
| | this.modelLoadingStates.set(modelId, true);
|
| | this.error = null;
|
| |
|
| | try {
|
| | await ModelsService.unload(modelId);
|
| |
|
| | await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
| | } catch (error) {
|
| | this.error = error instanceof Error ? error.message : 'Failed to unload model';
|
| | throw error;
|
| | } finally {
|
| | this.modelLoadingStates.set(modelId, false);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async ensureModelLoaded(modelId: string): Promise<void> {
|
| | if (this.isModelLoaded(modelId)) {
|
| | return;
|
| | }
|
| |
|
| | await this.loadModel(modelId);
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | private toDisplayName(id: string): string {
|
| | const segments = id.split(/\\|\//);
|
| | const candidate = segments.pop();
|
| |
|
| | return candidate && candidate.trim().length > 0 ? candidate : id;
|
| | }
|
| |
|
| | clear(): void {
|
| | this.models = [];
|
| | this.routerModels = [];
|
| | this.loading = false;
|
| | this.updating = false;
|
| | this.error = null;
|
| | this.selectedModelId = null;
|
| | this.selectedModelName = null;
|
| | this.modelUsage.clear();
|
| | this.modelLoadingStates.clear();
|
| | this.modelPropsCache.clear();
|
| | this.modelPropsFetching.clear();
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | pruneExpiredCache(): number {
|
| | return this.modelPropsCache.prune();
|
| | }
|
| | }
|
| |
|
| | export const modelsStore = new ModelsStore();
|
| |
|
| | export const modelOptions = () => modelsStore.models;
|
| | export const routerModels = () => modelsStore.routerModels;
|
| | export const modelsLoading = () => modelsStore.loading;
|
| | export const modelsUpdating = () => modelsStore.updating;
|
| | export const modelsError = () => modelsStore.error;
|
| | export const selectedModelId = () => modelsStore.selectedModelId;
|
| | export const selectedModelName = () => modelsStore.selectedModelName;
|
| | export const selectedModelOption = () => modelsStore.selectedModel;
|
| | export const loadedModelIds = () => modelsStore.loadedModelIds;
|
| | export const loadingModelIds = () => modelsStore.loadingModelIds;
|
| | export const propsCacheVersion = () => modelsStore.propsCacheVersion;
|
| | export const singleModelName = () => modelsStore.singleModelName;
|
| | export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
|
| |
|