448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
import { SvelteSet } from 'svelte/reactivity';
|
|
import { ModelsService } from '$lib/services/models';
|
|
import { ServerModelStatus } from '$lib/enums';
|
|
import type { ModelOption } from '$lib/types/models';
|
|
import type { ApiRouterModelMeta } from '$lib/types/api';
|
|
|
|
/**
|
|
* ModelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
|
*
|
|
* This store manages:
|
|
* - Available models list
|
|
* - Selected model for new conversations
|
|
* - Loaded models tracking (ROUTER mode)
|
|
* - Model usage tracking per conversation
|
|
* - Automatic unloading of unused models
|
|
*
|
|
* **Architecture & Relationships:**
|
|
* - **ModelsService**: Stateless service for API communication
|
|
* - **ModelsStore** (this class): Reactive store for model state
|
|
* - **PropsStore**: Provides server mode detection
|
|
* - **ConversationsStore**: Tracks which conversations use which models
|
|
*
|
|
* **Key Features:**
|
|
* - **MODEL mode**: Single model, always loaded
|
|
* - **ROUTER mode**: Multi-model with load/unload capability
|
|
* - **Auto-unload**: Automatically unloads models not used by any conversation
|
|
* - **Lazy loading**: ensureModelLoaded() loads models on demand
|
|
*/
|
|
class ModelsStore {
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// State
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
private _models = $state<ModelOption[]>([]);
|
|
private _routerModels = $state<ApiRouterModelMeta[]>([]);
|
|
private _loading = $state(false);
|
|
private _updating = $state(false);
|
|
private _error = $state<string | null>(null);
|
|
private _selectedModelId = $state<string | null>(null);
|
|
private _selectedModelName = $state<string | null>(null);
|
|
|
|
/** Maps modelId -> Set of conversationIds that use this model */
|
|
private _modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
|
|
|
/** Maps modelId -> loading state for load/unload operations */
|
|
private _modelLoadingStates = $state<Map<string, boolean>>(new Map());
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Getters - Basic
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
get models(): ModelOption[] {
|
|
return this._models;
|
|
}
|
|
|
|
get routerModels(): ApiRouterModelMeta[] {
|
|
return this._routerModels;
|
|
}
|
|
|
|
get loading(): boolean {
|
|
return this._loading;
|
|
}
|
|
|
|
get updating(): boolean {
|
|
return this._updating;
|
|
}
|
|
|
|
get error(): string | null {
|
|
return this._error;
|
|
}
|
|
|
|
get selectedModelId(): string | null {
|
|
return this._selectedModelId;
|
|
}
|
|
|
|
get selectedModelName(): string | null {
|
|
return this._selectedModelName;
|
|
}
|
|
|
|
get selectedModel(): ModelOption | null {
|
|
if (!this._selectedModelId) {
|
|
return null;
|
|
}
|
|
|
|
return this._models.find((model) => model.id === this._selectedModelId) ?? null;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Getters - Loaded Models (ROUTER mode)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get list of currently loaded model IDs
|
|
*/
|
|
get loadedModelIds(): string[] {
|
|
return this._routerModels
|
|
.filter((m) => m.status === ServerModelStatus.LOADED)
|
|
.map((m) => m.name);
|
|
}
|
|
|
|
/**
|
|
* Get list of models currently being loaded/unloaded
|
|
*/
|
|
get loadingModelIds(): string[] {
|
|
return Array.from(this._modelLoadingStates.entries())
|
|
.filter(([, loading]) => loading)
|
|
.map(([id]) => id);
|
|
}
|
|
|
|
/**
|
|
* Check if a specific model is loaded
|
|
*/
|
|
isModelLoaded(modelId: string): boolean {
|
|
const model = this._routerModels.find((m) => m.name === modelId);
|
|
return model?.status === ServerModelStatus.LOADED || false;
|
|
}
|
|
|
|
/**
|
|
* Check if a specific model is currently loading/unloading
|
|
*/
|
|
isModelOperationInProgress(modelId: string): boolean {
|
|
return this._modelLoadingStates.get(modelId) ?? false;
|
|
}
|
|
|
|
/**
|
|
* Get the status of a specific model
|
|
*/
|
|
getModelStatus(modelId: string): ServerModelStatus | null {
|
|
const model = this._routerModels.find((m) => m.name === modelId);
|
|
return model?.status ?? null;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Getters - Model Usage
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get set of conversation IDs using a specific model
|
|
*/
|
|
getModelUsage(modelId: string): SvelteSet<string> {
|
|
return this._modelUsage.get(modelId) ?? new SvelteSet<string>();
|
|
}
|
|
|
|
/**
|
|
* Check if a model is used by any conversation
|
|
*/
|
|
isModelInUse(modelId: string): boolean {
|
|
const usage = this._modelUsage.get(modelId);
|
|
return usage !== undefined && usage.size > 0;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Fetch Models
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch list of models from server
|
|
*/
|
|
async fetch(force = false): Promise<void> {
|
|
if (this._loading) return;
|
|
if (this._models.length > 0 && !force) return;
|
|
|
|
this._loading = true;
|
|
this._error = null;
|
|
|
|
try {
|
|
const response = await ModelsService.list();
|
|
|
|
const models: ModelOption[] = response.data.map((item, index) => {
|
|
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): value is string => Boolean(value)),
|
|
details: details?.details,
|
|
meta: item.meta ?? null
|
|
} satisfies ModelOption;
|
|
});
|
|
|
|
this._models = models;
|
|
|
|
// Don't auto-select any model - selection should come from:
|
|
// 1. User explicitly selecting a model in the UI
|
|
// 2. Conversation model (synced via ChatFormActions effect)
|
|
} catch (error) {
|
|
this._models = [];
|
|
this._error = error instanceof Error ? error.message : 'Failed to load models';
|
|
|
|
throw error;
|
|
} finally {
|
|
this._loading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch router models with full metadata (ROUTER mode only)
|
|
*/
|
|
async fetchRouterModels(): Promise<void> {
|
|
try {
|
|
const response = await ModelsService.listRouter();
|
|
this._routerModels = response.models;
|
|
} catch (error) {
|
|
console.warn('Failed to fetch router models:', error);
|
|
this._routerModels = [];
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Select Model
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Select a model for new conversations
|
|
*/
|
|
async select(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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select a model by its model name (used for syncing with conversation model)
|
|
* @param modelName - Model name to select (e.g., "unsloth/gemma-3-12b-it-GGUF:latest")
|
|
*/
|
|
selectModelByName(modelName: string): void {
|
|
const option = this._models.find((model) => model.model === modelName);
|
|
if (option) {
|
|
this._selectedModelId = option.id;
|
|
this._selectedModelName = option.model;
|
|
// Don't persist - this is just for syncing with conversation
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the current model selection
|
|
*/
|
|
clearSelection(): void {
|
|
this._selectedModelId = null;
|
|
this._selectedModelName = null;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Load/Unload Models (ROUTER mode)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load a model (ROUTER mode)
|
|
* @param modelId - Model identifier to load
|
|
*/
|
|
async loadModel(modelId: string): Promise<void> {
|
|
if (this.isModelLoaded(modelId)) {
|
|
return;
|
|
}
|
|
|
|
if (this._modelLoadingStates.get(modelId)) {
|
|
return; // Already loading
|
|
}
|
|
|
|
this._modelLoadingStates.set(modelId, true);
|
|
this._error = null;
|
|
|
|
try {
|
|
await ModelsService.load(modelId);
|
|
await this.fetchRouterModels(); // Refresh status
|
|
} catch (error) {
|
|
this._error = error instanceof Error ? error.message : 'Failed to load model';
|
|
throw error;
|
|
} finally {
|
|
this._modelLoadingStates.set(modelId, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unload a model (ROUTER mode)
|
|
* @param modelId - Model identifier to unload
|
|
*/
|
|
async unloadModel(modelId: string): Promise<void> {
|
|
if (!this.isModelLoaded(modelId)) {
|
|
return;
|
|
}
|
|
|
|
if (this._modelLoadingStates.get(modelId)) {
|
|
return; // Already unloading
|
|
}
|
|
|
|
this._modelLoadingStates.set(modelId, true);
|
|
this._error = null;
|
|
|
|
try {
|
|
await ModelsService.unload(modelId);
|
|
await this.fetchRouterModels(); // Refresh status
|
|
} catch (error) {
|
|
this._error = error instanceof Error ? error.message : 'Failed to unload model';
|
|
throw error;
|
|
} finally {
|
|
this._modelLoadingStates.set(modelId, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a model is loaded before use
|
|
* @param modelId - Model identifier to ensure is loaded
|
|
*/
|
|
async ensureModelLoaded(modelId: string): Promise<void> {
|
|
if (this.isModelLoaded(modelId)) {
|
|
return;
|
|
}
|
|
|
|
await this.loadModel(modelId);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Model Usage Tracking
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Register that a conversation is using a model
|
|
*/
|
|
registerModelUsage(modelId: string, conversationId: string): void {
|
|
const usage = this._modelUsage.get(modelId) ?? new SvelteSet<string>();
|
|
usage.add(conversationId);
|
|
this._modelUsage.set(modelId, usage);
|
|
}
|
|
|
|
/**
|
|
* Unregister that a conversation is using a model
|
|
* @param modelId - Model identifier
|
|
* @param conversationId - Conversation identifier
|
|
* @param autoUnload - Whether to automatically unload the model if no longer used
|
|
*/
|
|
async unregisterModelUsage(
|
|
modelId: string,
|
|
conversationId: string,
|
|
autoUnload = true
|
|
): Promise<void> {
|
|
const usage = this._modelUsage.get(modelId);
|
|
if (usage) {
|
|
usage.delete(conversationId);
|
|
|
|
if (usage.size === 0) {
|
|
this._modelUsage.delete(modelId);
|
|
|
|
// Auto-unload if model is not used by any conversation
|
|
if (autoUnload && this.isModelLoaded(modelId)) {
|
|
await this.unloadModel(modelId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all usage for a conversation (when conversation is deleted)
|
|
*/
|
|
async clearConversationUsage(conversationId: string): Promise<void> {
|
|
for (const [modelId, usage] of this._modelUsage.entries()) {
|
|
if (usage.has(conversationId)) {
|
|
await this.unregisterModelUsage(modelId, conversationId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Private Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
private toDisplayName(id: string): string {
|
|
const segments = id.split(/\\|\//);
|
|
const candidate = segments.pop();
|
|
|
|
return candidate && candidate.trim().length > 0 ? candidate : id;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Clear State
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
export const modelsStore = new ModelsStore();
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Reactive Getters
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Actions
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export const fetchModels = modelsStore.fetch.bind(modelsStore);
|
|
export const fetchRouterModels = modelsStore.fetchRouterModels.bind(modelsStore);
|
|
export const selectModel = modelsStore.select.bind(modelsStore);
|
|
export const loadModel = modelsStore.loadModel.bind(modelsStore);
|
|
export const unloadModel = modelsStore.unloadModel.bind(modelsStore);
|
|
export const ensureModelLoaded = modelsStore.ensureModelLoaded.bind(modelsStore);
|
|
export const registerModelUsage = modelsStore.registerModelUsage.bind(modelsStore);
|
|
export const unregisterModelUsage = modelsStore.unregisterModelUsage.bind(modelsStore);
|
|
export const clearConversationUsage = modelsStore.clearConversationUsage.bind(modelsStore);
|
|
export const selectModelByName = modelsStore.selectModelByName.bind(modelsStore);
|
|
export const clearModelSelection = modelsStore.clearSelection.bind(modelsStore);
|