ui: Refactor models store, MCP service, and gate logs behind VITE_DEBUG (#23236)
* refactor: Scope console logs to `DEV` + `VITE_DEBUG` env vars * refactor: skip MCP proxy probe when no server requires it * refactor: suppress expected disconnect errors during MCP client shutdown * refactor: Deduplicate requests * refactor: deduplicate model fetching across ROUTER and MODEL modes * refactor: Clean up models logic * chore: Add `.env.example` file * refactor: replace client-side CORS proxy probe with server status flag * refactor: Post-review fixes * test: add vitest client setup with API fetch mocks
This commit is contained in:
@@ -3885,6 +3885,7 @@ void server_routes::init_routes() {
|
|||||||
{ "eos_token", meta->eos_token_str },
|
{ "eos_token", meta->eos_token_str },
|
||||||
{ "build_info", meta->build_info },
|
{ "build_info", meta->build_info },
|
||||||
{ "is_sleeping", queue_tasks.is_sleeping() },
|
{ "is_sleeping", queue_tasks.is_sleeping() },
|
||||||
|
{ "cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy },
|
||||||
};
|
};
|
||||||
if (params.use_jinja) {
|
if (params.use_jinja) {
|
||||||
if (!tmpl_tools.empty()) {
|
if (!tmpl_tools.empty()) {
|
||||||
|
|||||||
@@ -1165,6 +1165,7 @@ void server_models_routes::init_routes() {
|
|||||||
// Deprecated: use ui_settings instead (kept for backward compat)
|
// Deprecated: use ui_settings instead (kept for backward compat)
|
||||||
{"webui_settings", webui_settings},
|
{"webui_settings", webui_settings},
|
||||||
{"build_info", std::string(llama_build_info())},
|
{"build_info", std::string(llama_build_info())},
|
||||||
|
{"cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy},
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_PUBLIC_APP_NAME='llama-ui'
|
||||||
|
# VITE_DEBUG='true'
|
||||||
+20
-8
@@ -7,7 +7,6 @@
|
|||||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentModel?: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
forceForegroundText?: boolean;
|
forceForegroundText?: boolean;
|
||||||
hasAudioModality?: boolean;
|
hasAudioModality?: boolean;
|
||||||
@@ -20,7 +19,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
currentModel,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
forceForegroundText = false,
|
forceForegroundText = false,
|
||||||
hasAudioModality = $bindable(false),
|
hasAudioModality = $bindable(false),
|
||||||
@@ -41,14 +39,28 @@
|
|||||||
|
|
||||||
let lastSyncedConversationModel: string | null = null;
|
let lastSyncedConversationModel: string | null = null;
|
||||||
|
|
||||||
|
let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
||||||
lastSyncedConversationModel = conversationModel;
|
if (modelOptions().some((m) => m.model === conversationModel)) {
|
||||||
|
modelsStore.selectedModelName = conversationModel;
|
||||||
modelsStore.selectModelByName(conversationModel);
|
modelsStore.selectModelByName(conversationModel);
|
||||||
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
|
} else {
|
||||||
|
modelsStore.selectedModelName = null;
|
||||||
|
modelsStore.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncedConversationModel = conversationModel;
|
||||||
|
} else if (
|
||||||
|
isRouter &&
|
||||||
|
!modelsStore.selectedModelId &&
|
||||||
|
modelsStore.loadedModelIds.length > 0 &&
|
||||||
|
activeMessages().length > 0 &&
|
||||||
|
!conversationModel
|
||||||
|
) {
|
||||||
lastSyncedConversationModel = null;
|
lastSyncedConversationModel = null;
|
||||||
// auto-select the first loaded model only when nothing is selected yet
|
|
||||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||||
|
|
||||||
if (first) modelsStore.selectModelById(first.id);
|
if (first) modelsStore.selectModelById(first.id);
|
||||||
@@ -151,7 +163,7 @@
|
|||||||
<ModelsSelectorSheet
|
<ModelsSelectorSheet
|
||||||
disabled={disabled || isOffline}
|
disabled={disabled || isOffline}
|
||||||
bind:this={selectorModelRef}
|
bind:this={selectorModelRef}
|
||||||
{currentModel}
|
currentModel={selectorModel}
|
||||||
{forceForegroundText}
|
{forceForegroundText}
|
||||||
{useGlobalSelection}
|
{useGlobalSelection}
|
||||||
/>
|
/>
|
||||||
@@ -159,7 +171,7 @@
|
|||||||
<ModelsSelectorDropdown
|
<ModelsSelectorDropdown
|
||||||
disabled={disabled || isOffline}
|
disabled={disabled || isOffline}
|
||||||
bind:this={selectorModelRef}
|
bind:this={selectorModelRef}
|
||||||
{currentModel}
|
currentModel={selectorModel}
|
||||||
{forceForegroundText}
|
{forceForegroundText}
|
||||||
{useGlobalSelection}
|
{useGlobalSelection}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
-2
@@ -162,7 +162,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
|
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
|
||||||
serverName: selectedPrompt.serverName,
|
serverName: selectedPrompt.serverName,
|
||||||
promptName: selectedPrompt.name,
|
promptName: selectedPrompt.name,
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
value
|
value
|
||||||
);
|
);
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
|
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
|
||||||
argName,
|
argName,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
const serverModel = $derived(singleModelName());
|
const serverModel = $derived(singleModelName());
|
||||||
|
|
||||||
const currentModel = $derived(opts.currentModel());
|
const currentModel = $derived(opts.currentModel());
|
||||||
const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false);
|
|
||||||
const onModelChange = $derived(opts.onModelChange?.());
|
const onModelChange = $derived(opts.onModelChange?.());
|
||||||
|
|
||||||
const isHighlightedCurrentModelActive = $derived.by(() => {
|
const isHighlightedCurrentModelActive = $derived.by(() => {
|
||||||
@@ -128,6 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
|
|
||||||
if (onModelChange) {
|
if (onModelChange) {
|
||||||
const result = await onModelChange(option.id, option.model);
|
const result = await onModelChange(option.id, option.model);
|
||||||
|
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
shouldCloseMenu = false;
|
shouldCloseMenu = false;
|
||||||
}
|
}
|
||||||
@@ -142,12 +142,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||||
'[data-slot="chat-form"] textarea'
|
'[data-slot="chat-form"] textarea'
|
||||||
);
|
);
|
||||||
|
|
||||||
textarea?.focus();
|
textarea?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
|
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
|
||||||
isLoadingModel = true;
|
isLoadingModel = true;
|
||||||
|
|
||||||
modelsStore
|
modelsStore
|
||||||
.loadModel(option.model)
|
.loadModel(option.model)
|
||||||
.catch((error) => console.error('Failed to load model:', error))
|
.catch((error) => console.error('Failed to load model:', error))
|
||||||
@@ -158,6 +160,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
function getDisplayOption(): ModelOption | undefined {
|
function getDisplayOption(): ModelOption | undefined {
|
||||||
if (!isRouter) {
|
if (!isRouter) {
|
||||||
const displayModel = serverModel || currentModel;
|
const displayModel = serverModel || currentModel;
|
||||||
|
|
||||||
if (displayModel) {
|
if (displayModel) {
|
||||||
return {
|
return {
|
||||||
id: serverModel ? 'current' : 'offline-current',
|
id: serverModel ? 'current' : 'offline-current',
|
||||||
@@ -166,12 +169,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
capabilities: []
|
capabilities: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useGlobalSelection && activeId) {
|
return undefined;
|
||||||
const selected = options.find((option) => option.id === activeId);
|
|
||||||
if (selected) return selected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentModel) {
|
if (currentModel) {
|
||||||
@@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
capabilities: []
|
capabilities: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return options.find((option) => option.model === currentModel);
|
return options.find((option) => option.model === currentModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,57 +197,77 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
get options() {
|
get options() {
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
|
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
},
|
},
|
||||||
|
|
||||||
get updating() {
|
get updating() {
|
||||||
return updating;
|
return updating;
|
||||||
},
|
},
|
||||||
|
|
||||||
get activeId() {
|
get activeId() {
|
||||||
return activeId;
|
return activeId;
|
||||||
},
|
},
|
||||||
|
|
||||||
get isRouter() {
|
get isRouter() {
|
||||||
return isRouter;
|
return isRouter;
|
||||||
},
|
},
|
||||||
|
|
||||||
get serverModel() {
|
get serverModel() {
|
||||||
return serverModel;
|
return serverModel;
|
||||||
},
|
},
|
||||||
|
|
||||||
get isHighlightedCurrentModelActive() {
|
get isHighlightedCurrentModelActive() {
|
||||||
return isHighlightedCurrentModelActive;
|
return isHighlightedCurrentModelActive;
|
||||||
},
|
},
|
||||||
|
|
||||||
get isCurrentModelInCache() {
|
get isCurrentModelInCache() {
|
||||||
return isCurrentModelInCache;
|
return isCurrentModelInCache;
|
||||||
},
|
},
|
||||||
|
|
||||||
get filteredOptions() {
|
get filteredOptions() {
|
||||||
return filteredOptions;
|
return filteredOptions;
|
||||||
},
|
},
|
||||||
|
|
||||||
get groupedFilteredOptions() {
|
get groupedFilteredOptions() {
|
||||||
return groupedFilteredOptions;
|
return groupedFilteredOptions;
|
||||||
},
|
},
|
||||||
|
|
||||||
get isLoadingModel() {
|
get isLoadingModel() {
|
||||||
return isLoadingModel;
|
return isLoadingModel;
|
||||||
},
|
},
|
||||||
|
|
||||||
get searchTerm() {
|
get searchTerm() {
|
||||||
return searchTerm;
|
return searchTerm;
|
||||||
},
|
},
|
||||||
|
|
||||||
get showModelDialog() {
|
get showModelDialog() {
|
||||||
return showModelDialog;
|
return showModelDialog;
|
||||||
},
|
},
|
||||||
|
|
||||||
get infoModelId() {
|
get infoModelId() {
|
||||||
return infoModelId;
|
return infoModelId;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSearchTerm(value: string) {
|
setSearchTerm(value: string) {
|
||||||
searchTerm = value;
|
searchTerm = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
setShowModelDialog(value: boolean) {
|
setShowModelDialog(value: boolean) {
|
||||||
showModelDialog = value;
|
showModelDialog = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
handleInfoClick,
|
handleInfoClick,
|
||||||
|
|
||||||
handleSelect,
|
handleSelect,
|
||||||
|
|
||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
|
|
||||||
isFavorite(model: string) {
|
isFavorite(model: string) {
|
||||||
return modelsStore.favoriteModelIds.has(model);
|
return modelsStore.favoriteModelIds.has(model);
|
||||||
},
|
},
|
||||||
|
|
||||||
getDisplayOption
|
getDisplayOption
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export class MCPService {
|
|||||||
|
|
||||||
const url = new URL(config.url);
|
const url = new URL(config.url);
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
|
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,12 +413,12 @@ export class MCPService {
|
|||||||
onLog
|
onLog
|
||||||
);
|
);
|
||||||
|
|
||||||
if (useProxy && import.meta.env.DEV) {
|
if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
|
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
|
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ export class MCPService {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService][${serverName}] Creating transport...`);
|
console.log(`[MCPService][${serverName}] Creating transport...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +560,22 @@ export class MCPService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runtimeErrorHandler = (error: Error) => {
|
const runtimeErrorHandler = (error: Error) => {
|
||||||
|
// Ignore errors that are expected when the SDK's transport is closed,
|
||||||
|
// or when connecting to servers that don't support SSE (stateless-only
|
||||||
|
// endpoints returning 405). The SDK wraps the original AbortError in
|
||||||
|
// a new Error with the message "SSE stream disconnected: AbortError",
|
||||||
|
// and also produces "Cannot cancel a stream locked by a reader".
|
||||||
|
// DOMException is thrown by the browser when aborting fetch requests.
|
||||||
|
const msg = error.message || String(error);
|
||||||
|
if (
|
||||||
|
error.name === 'AbortError' ||
|
||||||
|
error instanceof DOMException ||
|
||||||
|
msg.includes('SSE stream disconnected') ||
|
||||||
|
msg.includes('stream locked by a reader') ||
|
||||||
|
msg.includes('The operation was aborted')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
|
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -658,7 +674,10 @@ export class MCPService {
|
|||||||
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
|
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
||||||
|
}
|
||||||
|
|
||||||
const tools = await this.listTools({
|
const tools = await this.listTools({
|
||||||
client,
|
client,
|
||||||
transport,
|
transport,
|
||||||
@@ -680,10 +699,11 @@ export class MCPService {
|
|||||||
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
|
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
|
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
@@ -709,9 +729,22 @@ export class MCPService {
|
|||||||
* @param connection - The active MCP connection to close
|
* @param connection - The active MCP connection to close
|
||||||
*/
|
*/
|
||||||
static async disconnect(connection: MCPConnection): Promise<void> {
|
static async disconnect(connection: MCPConnection): Promise<void> {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prevent reconnection on voluntary disconnect
|
// Terminate the session first for streamable-http transports to cleanly
|
||||||
|
// close streams, matching the inspector's disconnect flow.
|
||||||
|
if (connection.transport instanceof StreamableHTTPClientTransport) {
|
||||||
|
await connection.transport.terminateSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error handlers before closing to prevent noise from expected
|
||||||
|
// abort errors during shutdown. The inspector avoids this entirely
|
||||||
|
// by not setting onerror, but since we use it for protocol logging,
|
||||||
|
// we must clear it before disconnect.
|
||||||
|
connection.client.onerror = undefined;
|
||||||
if (connection.transport.onclose) {
|
if (connection.transport.onclose) {
|
||||||
connection.transport.onclose = undefined;
|
connection.transport.onclose = undefined;
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1111,9 @@ export class MCPService {
|
|||||||
try {
|
try {
|
||||||
await connection.client.unsubscribeResource({ uri });
|
await connection.client.unsubscribeResource({ uri });
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
|
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
|
`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const localStorageMigration: Migration = {
|
|||||||
// Only migrate if new key doesn't already exist
|
// Only migrate if new key doesn't already exist
|
||||||
const newValue = localStorage.getItem(newKey);
|
const newValue = localStorage.getItem(newKey);
|
||||||
if (newValue !== null) {
|
if (newValue !== null) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
|
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -127,12 +128,14 @@ const localStorageMigration: Migration = {
|
|||||||
if (oldValue !== null) {
|
if (oldValue !== null) {
|
||||||
localStorage.setItem(newKey, oldValue);
|
localStorage.setItem(newKey, oldValue);
|
||||||
// Keep old key for downgrade compatibility - DO NOT DELETE
|
// Keep old key for downgrade compatibility - DO NOT DELETE
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
|
`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Migration 2: IndexedDB Database Name (Non-Destructive)
|
// Migration 2: IndexedDB Database Name (Non-Destructive)
|
||||||
@@ -146,6 +149,7 @@ const idxdbMigration: Migration = {
|
|||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
const oldDbNames = await Dexie.getDatabaseNames();
|
const oldDbNames = await Dexie.getDatabaseNames();
|
||||||
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
|
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] IndexedDB: no old database found, skipping');
|
console.log('[Migration] IndexedDB: no old database found, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,10 +159,12 @@ const idxdbMigration: Migration = {
|
|||||||
newDb.version(1).stores(IDXDB_STORES);
|
newDb.version(1).stores(IDXDB_STORES);
|
||||||
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
|
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
|
||||||
if (existingConvs > 0) {
|
if (existingConvs > 0) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] IndexedDB: new database already has data, skipping');
|
console.log('[Migration] IndexedDB: new database already has data, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
|
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
|
||||||
|
|
||||||
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
|
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
|
||||||
@@ -169,14 +175,17 @@ const idxdbMigration: Migration = {
|
|||||||
|
|
||||||
if (conversations.length > 0) {
|
if (conversations.length > 0) {
|
||||||
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
|
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
|
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
|
||||||
}
|
}
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
|
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
|
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
|
// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
|
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -419,6 +428,7 @@ const legacyMessageMigration: Migration = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
|
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -434,6 +444,7 @@ const themeMigration: Migration = {
|
|||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
const legacyTheme = localStorage.getItem('theme');
|
const legacyTheme = localStorage.getItem('theme');
|
||||||
if (legacyTheme === null) {
|
if (legacyTheme === null) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] Theme: no legacy theme key found, skipping');
|
console.log('[Migration] Theme: no legacy theme key found, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -443,6 +454,7 @@ const themeMigration: Migration = {
|
|||||||
const config = configRaw ? JSON.parse(configRaw) : {};
|
const config = configRaw ? JSON.parse(configRaw) : {};
|
||||||
|
|
||||||
if (SETTINGS_KEYS.THEME in config) {
|
if (SETTINGS_KEYS.THEME in config) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] Theme: config already has theme, skipping');
|
console.log('[Migration] Theme: config already has theme, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -451,6 +463,7 @@ const themeMigration: Migration = {
|
|||||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
||||||
|
|
||||||
// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
|
// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
|
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -491,6 +504,7 @@ export const MigrationService = {
|
|||||||
*/
|
*/
|
||||||
resetState(): void {
|
resetState(): void {
|
||||||
localStorage.removeItem(MIGRATION_STATE_KEY);
|
localStorage.removeItem(MIGRATION_STATE_KEY);
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] State reset - all migrations will run again');
|
console.log('[Migration] State reset - all migrations will run again');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -500,18 +514,22 @@ export const MigrationService = {
|
|||||||
*/
|
*/
|
||||||
async runAllMigrations(): Promise<void> {
|
async runAllMigrations(): Promise<void> {
|
||||||
const state = getMigrationState();
|
const state = getMigrationState();
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] Starting migration run, state:', state);
|
console.log('[Migration] Starting migration run, state:', state);
|
||||||
|
|
||||||
for (const migration of migrations) {
|
for (const migration of migrations) {
|
||||||
if (isMigrationCompleted(migration.id)) {
|
if (isMigrationCompleted(migration.id)) {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] ${migration.id}: already completed, skipping`);
|
console.log(`[Migration] ${migration.id}: already completed, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] ${migration.id}: running...`);
|
console.log(`[Migration] ${migration.id}: running...`);
|
||||||
await migration.run();
|
await migration.run();
|
||||||
markMigrationCompleted(migration.id);
|
markMigrationCompleted(migration.id);
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log(`[Migration] ${migration.id}: completed successfully`);
|
console.log(`[Migration] ${migration.id}: completed successfully`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Migration] ${migration.id}: failed`, error);
|
console.error(`[Migration] ${migration.id}: failed`, error);
|
||||||
@@ -519,6 +537,7 @@ export const MigrationService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] All migrations complete');
|
console.log('[Migration] All migrations complete');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { base } from '$app/paths';
|
|
||||||
import { SETTINGS_KEYS } from '$lib/constants';
|
import { SETTINGS_KEYS } from '$lib/constants';
|
||||||
import { MCPService } from '$lib/services/mcp.service';
|
import { MCPService } from '$lib/services/mcp.service';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||||
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import {
|
import {
|
||||||
parseMcpServerSettings,
|
parseMcpServerSettings,
|
||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
ToolCallType
|
ToolCallType
|
||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import {
|
import {
|
||||||
CORS_PROXY_ENDPOINT,
|
|
||||||
DEFAULT_CACHE_TTL_MS,
|
DEFAULT_CACHE_TTL_MS,
|
||||||
DEFAULT_MCP_CONFIG,
|
DEFAULT_MCP_CONFIG,
|
||||||
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
||||||
@@ -86,7 +85,6 @@ class MCPStore {
|
|||||||
private _toolCount = $state(0);
|
private _toolCount = $state(0);
|
||||||
private _connectedServers = $state<string[]>([]);
|
private _connectedServers = $state<string[]>([]);
|
||||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||||
private _proxyAvailable = $state(false);
|
|
||||||
|
|
||||||
private connections = new Map<string, MCPConnection>();
|
private connections = new Map<string, MCPConnection>();
|
||||||
private toolsIndex = new Map<string, string>();
|
private toolsIndex = new Map<string, string>();
|
||||||
@@ -96,27 +94,8 @@ class MCPStore {
|
|||||||
private initPromise: Promise<boolean> | null = null;
|
private initPromise: Promise<boolean> | null = null;
|
||||||
private activeFlowCount = 0;
|
private activeFlowCount = 0;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (browser) {
|
|
||||||
this.probeProxy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probes the CORS proxy endpoint to determine availability.
|
|
||||||
* The endpoint is only registered when llama-server runs with --ui-mcp-proxy.
|
|
||||||
*/
|
|
||||||
async probeProxy(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
|
|
||||||
this._proxyAvailable = response.status !== 404;
|
|
||||||
} catch {
|
|
||||||
this._proxyAvailable = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get isProxyAvailable(): boolean {
|
get isProxyAvailable(): boolean {
|
||||||
return this._proxyAvailable;
|
return serverStore.props?.cors_proxy_enabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { toast } from 'svelte-sonner';
|
|||||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||||
import { ModelsService } from '$lib/services/models.service';
|
import { ModelsService } from '$lib/services/models.service';
|
||||||
import { PropsService } from '$lib/services/props.service';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||||
import { TTLCache } from '$lib/utils';
|
import { TTLCache } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
MODEL_PROPS_CACHE_TTL_MS,
|
MODEL_PROPS_CACHE_TTL_MS,
|
||||||
@@ -14,14 +14,7 @@ import {
|
|||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
* 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:**
|
* **Architecture & Relationships:**
|
||||||
* - **ModelsService**: Stateless service for model API communication
|
* - **ModelsService**: Stateless service for model API communication
|
||||||
@@ -31,14 +24,8 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
|
|||||||
*
|
*
|
||||||
* **API Inconsistency Workaround:**
|
* **API Inconsistency Workaround:**
|
||||||
* In MODEL mode, `/props` returns modalities for the single model.
|
* In MODEL mode, `/props` returns modalities for the single model.
|
||||||
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
|
* In ROUTER mode, `/props` has no modalities — must use `/props?model=<id>` per model.
|
||||||
* This store normalizes this behavior so consumers don't need to know the server mode.
|
* This store normalizes this behavior so consumers don't need to know the server mode.
|
||||||
*
|
|
||||||
* **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 {
|
class ModelsStore {
|
||||||
/**
|
/**
|
||||||
@@ -57,8 +44,8 @@ class ModelsStore {
|
|||||||
selectedModelId = $state<string | null>(null);
|
selectedModelId = $state<string | null>(null);
|
||||||
selectedModelName = $state<string | null>(null);
|
selectedModelName = $state<string | null>(null);
|
||||||
|
|
||||||
// dedup concurrent fetch() callers, all awaiters share the same inflight promise
|
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
|
||||||
// without this, ?model=<name> URL handler raced an in-progress fetch and saw an empty list
|
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
|
||||||
private inflightFetch: Promise<void> | null = null;
|
private inflightFetch: Promise<void> | null = null;
|
||||||
|
|
||||||
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
||||||
@@ -67,9 +54,9 @@ class ModelsStore {
|
|||||||
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
|
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model-specific props cache with TTL
|
* Model-specific props cache with TTL.
|
||||||
* Key: modelId, Value: props data including modalities
|
* Key: modelId, Value: props data including modalities.
|
||||||
* TTL: 10 minutes - props don't change frequently
|
* TTL: 10 minutes — props don't change frequently.
|
||||||
*/
|
*/
|
||||||
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
|
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
|
||||||
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
|
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
|
||||||
@@ -78,7 +65,7 @@ class ModelsStore {
|
|||||||
private modelPropsFetching = $state<Set<string>>(new Set());
|
private modelPropsFetching = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version counter for props cache - used to trigger reactivity when props are updated
|
* Version counter for props cache — used to trigger reactivity when props are updated.
|
||||||
*/
|
*/
|
||||||
propsCacheVersion = $state(0);
|
propsCacheVersion = $state(0);
|
||||||
|
|
||||||
@@ -92,7 +79,7 @@ class ModelsStore {
|
|||||||
|
|
||||||
get selectedModel(): ModelOption | null {
|
get selectedModel(): ModelOption | null {
|
||||||
if (!this.selectedModelId) return null;
|
if (!this.selectedModelId) return null;
|
||||||
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
|
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get loadedModelIds(): string[] {
|
get loadedModelIds(): string[] {
|
||||||
@@ -117,7 +104,7 @@ class ModelsStore {
|
|||||||
* In ROUTER mode, returns null (model is per-conversation).
|
* In ROUTER mode, returns null (model is per-conversation).
|
||||||
*/
|
*/
|
||||||
get singleModelName(): string | null {
|
get singleModelName(): string | null {
|
||||||
if (serverStore.isRouterMode) return null;
|
if (isRouterMode()) return null;
|
||||||
|
|
||||||
const props = serverStore.props;
|
const props = serverStore.props;
|
||||||
if (props?.model_alias) return props.model_alias;
|
if (props?.model_alias) return props.model_alias;
|
||||||
@@ -126,6 +113,11 @@ class ModelsStore {
|
|||||||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get selectedModelContextSize(): number | null {
|
||||||
|
if (!this.selectedModelName) return null;
|
||||||
|
return this.getModelContextSize(this.selectedModelName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@@ -134,10 +126,6 @@ class ModelsStore {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Get modalities for a specific model
|
|
||||||
* Returns cached modalities from model props
|
|
||||||
*/
|
|
||||||
getModelModalities(modelId: string): ModelModalities | null {
|
getModelModalities(modelId: string): ModelModalities | null {
|
||||||
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
||||||
if (model?.modalities) {
|
if (model?.modalities) {
|
||||||
@@ -146,46 +134,29 @@ class ModelsStore {
|
|||||||
|
|
||||||
const props = this.modelPropsCache.get(modelId);
|
const props = this.modelPropsCache.get(modelId);
|
||||||
if (props?.modalities) {
|
if (props?.modalities) {
|
||||||
return {
|
return this.buildModalities(props.modalities);
|
||||||
vision: props.modalities.vision ?? false,
|
|
||||||
audio: props.modalities.audio ?? false,
|
|
||||||
video: props.modalities.video ?? false
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model supports vision modality
|
|
||||||
*/
|
|
||||||
modelSupportsVision(modelId: string): boolean {
|
modelSupportsVision(modelId: string): boolean {
|
||||||
return this.getModelModalities(modelId)?.vision ?? false;
|
return this.getModelModalities(modelId)?.vision ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model supports audio modality
|
|
||||||
*/
|
|
||||||
modelSupportsAudio(modelId: string): boolean {
|
modelSupportsAudio(modelId: string): boolean {
|
||||||
return this.getModelModalities(modelId)?.audio ?? false;
|
return this.getModelModalities(modelId)?.audio ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model supports video modality
|
|
||||||
*/
|
|
||||||
modelSupportsVideo(modelId: string): boolean {
|
modelSupportsVideo(modelId: string): boolean {
|
||||||
return this.getModelModalities(modelId)?.video ?? false;
|
return this.getModelModalities(modelId)?.video ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get model modalities as an array of ModelModality enum values
|
|
||||||
*/
|
|
||||||
getModelModalitiesArray(modelId: string): ModelModality[] {
|
getModelModalitiesArray(modelId: string): ModelModality[] {
|
||||||
const modalities = this.getModelModalities(modelId);
|
const modalities = this.getModelModalities(modelId);
|
||||||
if (!modalities) return [];
|
if (!modalities) return [];
|
||||||
|
|
||||||
const result: ModelModality[] = [];
|
const result: ModelModality[] = [];
|
||||||
|
|
||||||
if (modalities.vision) result.push(ModelModality.VISION);
|
if (modalities.vision) result.push(ModelModality.VISION);
|
||||||
if (modalities.audio) result.push(ModelModality.AUDIO);
|
if (modalities.audio) result.push(ModelModality.AUDIO);
|
||||||
if (modalities.video) result.push(ModelModality.VIDEO);
|
if (modalities.video) result.push(ModelModality.VIDEO);
|
||||||
@@ -193,16 +164,10 @@ class ModelsStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get props for a specific model (from cache)
|
|
||||||
*/
|
|
||||||
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
|
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
|
||||||
return this.modelPropsCache.get(modelId);
|
return this.modelPropsCache.get(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get context size (n_ctx) for a specific model from cached props
|
|
||||||
*/
|
|
||||||
getModelContextSize(modelId: string): number | null {
|
getModelContextSize(modelId: string): number | null {
|
||||||
const props = this.getModelProps(modelId);
|
const props = this.getModelProps(modelId);
|
||||||
const nCtx = props?.default_generation_settings?.n_ctx;
|
const nCtx = props?.default_generation_settings?.n_ctx;
|
||||||
@@ -210,17 +175,6 @@ class ModelsStore {
|
|||||||
return typeof nCtx === 'number' ? nCtx : null;
|
return typeof nCtx === 'number' ? nCtx : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get context size for the currently selected model or null if no model is selected
|
|
||||||
*/
|
|
||||||
get selectedModelContextSize(): number | null {
|
|
||||||
if (!this.selectedModelName) return null;
|
|
||||||
return this.getModelContextSize(this.selectedModelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if props are being fetched for a model
|
|
||||||
*/
|
|
||||||
isModelPropsFetching(modelId: string): boolean {
|
isModelPropsFetching(modelId: string): boolean {
|
||||||
return this.modelPropsFetching.has(modelId);
|
return this.modelPropsFetching.has(modelId);
|
||||||
}
|
}
|
||||||
@@ -235,10 +189,10 @@ class ModelsStore {
|
|||||||
|
|
||||||
isModelLoaded(modelId: string): boolean {
|
isModelLoaded(modelId: string): boolean {
|
||||||
const model = this.routerModels.find((m) => m.id === modelId);
|
const model = this.routerModels.find((m) => m.id === modelId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
model?.status.value === ServerModelStatus.LOADED ||
|
model?.status.value === ServerModelStatus.LOADED ||
|
||||||
model?.status.value === ServerModelStatus.SLEEPING ||
|
model?.status.value === ServerModelStatus.SLEEPING
|
||||||
false
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +202,7 @@ class ModelsStore {
|
|||||||
|
|
||||||
getModelStatus(modelId: string): ServerModelStatus | null {
|
getModelStatus(modelId: string): ServerModelStatus | null {
|
||||||
const model = this.routerModels.find((m) => m.id === modelId);
|
const model = this.routerModels.find((m) => m.id === modelId);
|
||||||
|
|
||||||
return model?.status.value ?? null;
|
return model?.status.value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +212,7 @@ class ModelsStore {
|
|||||||
|
|
||||||
isModelInUse(modelId: string): boolean {
|
isModelInUse(modelId: string): boolean {
|
||||||
const usage = this.modelUsage.get(modelId);
|
const usage = this.modelUsage.get(modelId);
|
||||||
|
|
||||||
return usage !== undefined && usage.size > 0;
|
return usage !== undefined && usage.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +225,8 @@ class ModelsStore {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch list of models from server and detect server role
|
* Fetch list of models from server and detect server role.
|
||||||
* Also fetches modalities for MODEL mode (single model)
|
* Also fetches modalities for MODEL mode (single model).
|
||||||
*/
|
*/
|
||||||
async fetch(force = false): Promise<void> {
|
async fetch(force = false): Promise<void> {
|
||||||
if (this.inflightFetch) return this.inflightFetch;
|
if (this.inflightFetch) return this.inflightFetch;
|
||||||
@@ -293,19 +249,59 @@ class ModelsStore {
|
|||||||
await serverStore.fetch();
|
await serverStore.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const router = isRouterMode();
|
||||||
|
|
||||||
|
if (router) {
|
||||||
|
const response = await ModelsService.listRouter();
|
||||||
|
|
||||||
|
this.routerModels = response.data;
|
||||||
|
this.models = this.buildModelOptions(response);
|
||||||
|
|
||||||
|
await this.fetchModalitiesForLoadedModels();
|
||||||
|
|
||||||
|
const visible = this.getVisibleModels();
|
||||||
|
|
||||||
|
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||||
|
this.selectModelById(visible[0].id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.models = await this.fetchModelModeInternal();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.models = [];
|
||||||
|
this.error = error instanceof Error ? error.message : 'Failed to load models';
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
|
||||||
|
private async fetchModelModeInternal(): Promise<ModelOption[]> {
|
||||||
const response = await ModelsService.list();
|
const response = await ModelsService.list();
|
||||||
|
|
||||||
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
|
return this.buildModelOptions(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build ModelOption[] from an API response.
|
||||||
|
* Both MODEL and ROUTER modes share the same mapping logic;
|
||||||
|
* they differ only in which endpoint is called.
|
||||||
|
*/
|
||||||
|
private buildModelOptions(
|
||||||
|
response: ApiModelListResponse | ApiRouterModelsListResponse
|
||||||
|
): ModelOption[] {
|
||||||
|
return response.data.map((item: ApiModelDataEntry, index: number) => {
|
||||||
const details = response.models?.[index];
|
const details = response.models?.[index];
|
||||||
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
||||||
const displayNameSource =
|
const displayNameSource =
|
||||||
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
||||||
const displayName = this.toDisplayName(displayNameSource);
|
|
||||||
const modelId = details?.model || item.id;
|
const modelId = details?.model || item.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: displayName,
|
name: this.toDisplayName(displayNameSource),
|
||||||
model: modelId,
|
model: modelId,
|
||||||
description: details?.description,
|
description: details?.description,
|
||||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||||
@@ -314,48 +310,26 @@ class ModelsStore {
|
|||||||
parsedId: ModelsService.parseModelId(modelId),
|
parsedId: ModelsService.parseModelId(modelId),
|
||||||
aliases: item.aliases ?? [],
|
aliases: item.aliases ?? [],
|
||||||
tags: item.tags ?? []
|
tags: item.tags ?? []
|
||||||
} satisfies ModelOption;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.models = models;
|
|
||||||
|
|
||||||
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
|
|
||||||
// but /v1/models doesn't include modalities. We bridge this gap here.
|
|
||||||
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,
|
|
||||||
video: serverProps.modalities.video ?? 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch router models with full metadata (ROUTER mode only)
|
* Fetch router models with full metadata (ROUTER mode only).
|
||||||
* This fetches the /models endpoint which returns status info for each model
|
* No-op in router mode — fetch() already calls listRouter() internally.
|
||||||
|
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
|
||||||
*/
|
*/
|
||||||
async fetchRouterModels(): Promise<void> {
|
async fetchRouterModels(): Promise<void> {
|
||||||
|
if (!isRouterMode()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ModelsService.listRouter();
|
const response = await ModelsService.listRouter();
|
||||||
this.routerModels = response.data;
|
this.routerModels = response.data;
|
||||||
await this.fetchModalitiesForLoadedModels();
|
await this.fetchModalitiesForLoadedModels();
|
||||||
|
|
||||||
const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
const visible = this.getVisibleModels();
|
||||||
|
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||||
if (o.length === 1 && this.isModelLoaded(o[0].model)) {
|
this.selectModelById(visible[0].id);
|
||||||
this.selectModelById(o[0].id);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch router models:', error);
|
console.warn('Failed to fetch router models:', error);
|
||||||
@@ -364,10 +338,10 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch props for a specific model from /props endpoint
|
* Fetch props for a specific model from /props endpoint.
|
||||||
* Uses caching to avoid redundant requests
|
* Uses caching to avoid redundant requests.
|
||||||
*
|
*
|
||||||
* In ROUTER mode, this will only fetch props if the model is loaded,
|
* In ROUTER mode, this only fetches props if the model is loaded,
|
||||||
* since unloaded models return 400 from /props endpoint.
|
* since unloaded models return 400 from /props endpoint.
|
||||||
*
|
*
|
||||||
* @param modelId - Model identifier to fetch props for
|
* @param modelId - Model identifier to fetch props for
|
||||||
@@ -397,10 +371,7 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Fetch modalities for all loaded models from /props endpoint. */
|
||||||
* Fetch modalities for all loaded models from /props endpoint
|
|
||||||
* This updates the modalities field in models array
|
|
||||||
*/
|
|
||||||
async fetchModalitiesForLoadedModels(): Promise<void> {
|
async fetchModalitiesForLoadedModels(): Promise<void> {
|
||||||
const loadedModelIds = this.loadedModelIds;
|
const loadedModelIds = this.loadedModelIds;
|
||||||
if (loadedModelIds.length === 0) return;
|
if (loadedModelIds.length === 0) return;
|
||||||
@@ -410,7 +381,6 @@ class ModelsStore {
|
|||||||
try {
|
try {
|
||||||
const results = await Promise.all(propsPromises);
|
const results = await Promise.all(propsPromises);
|
||||||
|
|
||||||
// Update models with modalities
|
|
||||||
this.models = this.models.map((model) => {
|
this.models = this.models.map((model) => {
|
||||||
const modelIndex = loadedModelIds.indexOf(model.model);
|
const modelIndex = loadedModelIds.indexOf(model.model);
|
||||||
if (modelIndex === -1) return model;
|
if (modelIndex === -1) return model;
|
||||||
@@ -418,13 +388,7 @@ class ModelsStore {
|
|||||||
const props = results[modelIndex];
|
const props = results[modelIndex];
|
||||||
if (!props?.modalities) return model;
|
if (!props?.modalities) return model;
|
||||||
|
|
||||||
const modalities: ModelModalities = {
|
return { ...model, modalities: this.buildModalities(props.modalities) };
|
||||||
vision: props.modalities.vision ?? false,
|
|
||||||
audio: props.modalities.audio ?? false,
|
|
||||||
video: props.modalities.video ?? false
|
|
||||||
};
|
|
||||||
|
|
||||||
return { ...model, modalities };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.propsCacheVersion++;
|
this.propsCacheVersion++;
|
||||||
@@ -433,17 +397,38 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update modalities for a specific model.
|
||||||
|
* Called when a model is loaded or when we need fresh modality data.
|
||||||
|
*/
|
||||||
|
async updateModelModalities(modelId: string): Promise<void> {
|
||||||
|
const props = await this.fetchModelProps(modelId);
|
||||||
|
if (!props?.modalities) return;
|
||||||
|
|
||||||
|
this.models = this.models.map((model) =>
|
||||||
|
model.model === modelId
|
||||||
|
? { ...model, modalities: this.buildModalities(props.modalities!) }
|
||||||
|
: model
|
||||||
|
);
|
||||||
|
|
||||||
|
this.propsCacheVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to models visible in the UI (ui !== false).
|
||||||
|
*/
|
||||||
|
private getVisibleModels(): ModelOption[] {
|
||||||
|
return this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the model name from the last assistant message in the active conversation.
|
* Gets the model name from the last assistant message in the active conversation.
|
||||||
* Iterates backward through messages to find the most recent message with a model.
|
|
||||||
* Used by both the chat page and settings page to maintain model consistency.
|
* Used by both the chat page and settings page to maintain model consistency.
|
||||||
* @returns The model name or null if not found
|
|
||||||
*/
|
*/
|
||||||
getModelFromLastAssistantResponse(): string | null {
|
getModelFromLastAssistantResponse(): string | null {
|
||||||
const messages = conversationsStore.activeMessages;
|
const messages = conversationsStore.activeMessages;
|
||||||
if (!messages || messages.length === 0) return null;
|
if (!messages || messages.length === 0) return null;
|
||||||
|
|
||||||
// Iterate backward to find the last message with a model
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
if (messages[i].model) {
|
if (messages[i].model) {
|
||||||
return messages[i].model;
|
return messages[i].model;
|
||||||
@@ -456,22 +441,13 @@ class ModelsStore {
|
|||||||
/**
|
/**
|
||||||
* Auto-selects the model from the last assistant response if available and loaded.
|
* Auto-selects the model from the last assistant response if available and loaded.
|
||||||
* Returns true if a model was selected, false otherwise.
|
* Returns true if a model was selected, false otherwise.
|
||||||
* This is used by the chat page to maintain model consistency across page navigation.
|
|
||||||
*/
|
*/
|
||||||
async selectModelFromLastAssistantResponse(): Promise<boolean> {
|
async selectModelFromLastAssistantResponse(): Promise<boolean> {
|
||||||
const lastModel = this.getModelFromLastAssistantResponse();
|
const lastModel = this.getModelFromLastAssistantResponse();
|
||||||
if (!lastModel) return false;
|
if (!lastModel || this.selectedModelName === lastModel) return false;
|
||||||
|
|
||||||
// Skip if already selected
|
|
||||||
if (this.selectedModelName === lastModel) return false;
|
|
||||||
|
|
||||||
const matchingModel = this.models.find((option) => option.model === lastModel);
|
const matchingModel = this.models.find((option) => option.model === lastModel);
|
||||||
if (!matchingModel) return false;
|
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
|
||||||
|
|
||||||
if (!this.isModelLoaded(lastModel)) {
|
|
||||||
console.log('[modelsStore] last assistant model not loaded:', lastModel);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.selectModelById(matchingModel.id);
|
await this.selectModelById(matchingModel.id);
|
||||||
@@ -484,22 +460,17 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-selects the first available model if none is selected, and fetches its props.
|
* Auto-selects the first available model if none is selected.
|
||||||
* Prioritizes:
|
* Prioritizes:
|
||||||
* 1. Model from active conversation's last assistant response (if loaded)
|
* 1. Model from active conversation's last assistant response (if loaded)
|
||||||
* 2. Model from active conversation's last assistant response (if not loaded)
|
* 2. Model from active conversation's last assistant response (if not loaded)
|
||||||
* 3. First loaded model (not from active conversation)
|
* 3. First loaded model (not from active conversation)
|
||||||
* 4. First available model
|
* 4. First available model
|
||||||
* This is used to ensure default values are populated in settings pages.
|
|
||||||
*/
|
*/
|
||||||
async ensureFirstModelSelected(): Promise<void> {
|
async ensureFirstModelSelected(): Promise<void> {
|
||||||
if (this.selectedModelName) return;
|
if (this.selectedModelName) return;
|
||||||
|
|
||||||
// Filter models that are visible in the UI
|
const availableModels = this.getVisibleModels();
|
||||||
const availableModels = this.models.filter(
|
|
||||||
(option) => this.getModelProps(option.model)?.ui !== false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (availableModels.length === 0) return;
|
if (availableModels.length === 0) return;
|
||||||
|
|
||||||
// Try to select model from last assistant response first
|
// Try to select model from last assistant response first
|
||||||
@@ -515,7 +486,7 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find a loaded model first
|
// Try a loaded model first
|
||||||
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
|
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
|
||||||
if (loadedModel) {
|
if (loadedModel) {
|
||||||
await this.selectModelById(loadedModel.id);
|
await this.selectModelById(loadedModel.id);
|
||||||
@@ -524,34 +495,7 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to the first available model
|
// Fall back to the first available model
|
||||||
const firstModel = availableModels[0];
|
await this.selectModelById(availableModels[0].id);
|
||||||
await this.selectModelById(firstModel.id);
|
|
||||||
// Don't fetch props for unloaded models (will fail in ROUTER mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update modalities for a specific model
|
|
||||||
* Called when a model is loaded or when we need fresh modality data
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
video: props.modalities.video ?? 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -562,9 +506,6 @@ class ModelsStore {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a model for new conversations
|
|
||||||
*/
|
|
||||||
async selectModelById(modelId: string): Promise<void> {
|
async selectModelById(modelId: string): Promise<void> {
|
||||||
if (!modelId || this.updating) return;
|
if (!modelId || this.updating) return;
|
||||||
if (this.selectedModelId === modelId) return;
|
if (this.selectedModelId === modelId) return;
|
||||||
@@ -584,8 +525,7 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a model by its model name (used for syncing with conversation model)
|
* Select a model by its model name (used for syncing with conversation model).
|
||||||
* @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF")
|
|
||||||
*/
|
*/
|
||||||
selectModelByName(modelName: string): void {
|
selectModelByName(modelName: string): void {
|
||||||
const option = this.models.find((model) => model.model === modelName);
|
const option = this.models.find((model) => model.model === modelName);
|
||||||
@@ -623,27 +563,18 @@ class ModelsStore {
|
|||||||
/**
|
/**
|
||||||
* WORKAROUND: Polling for model status after load/unload operations.
|
* WORKAROUND: Polling for model status after load/unload operations.
|
||||||
*
|
*
|
||||||
* Currently, the `/models/load` and `/models/unload` endpoints return success
|
* Currently, `/models/load` and `/models/unload` return success before
|
||||||
* before the operation actually completes on the server. This means an immediate
|
* the operation actually completes on the server.
|
||||||
* request to `/models` returns stale status (e.g., "loading" after load request,
|
|
||||||
* "loaded" after unload request).
|
|
||||||
*
|
*
|
||||||
* TODO: Remove this polling once llama-server properly waits for the operation
|
* TODO: Remove polling once llama-server properly waits for the operation
|
||||||
* to complete before returning success from `/load` and `/unload` endpoints.
|
* to complete before returning success.
|
||||||
* At that point, a single `fetchRouterModels()` call after the operation will
|
|
||||||
* be sufficient to get the correct status.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Polling interval in ms for checking model status */
|
|
||||||
private static readonly STATUS_POLL_INTERVAL = 500;
|
private static readonly STATUS_POLL_INTERVAL = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll for expected model status after load/unload operation.
|
* Poll for expected model status after load/unload operation.
|
||||||
* Keeps polling indefinitely until the model reaches the expected status or fails.
|
* Keeps polling until the model reaches the expected status or fails.
|
||||||
*
|
|
||||||
* @param modelId - Model identifier to check
|
|
||||||
* @param expectedStatus - Expected status to wait for
|
|
||||||
* @throws Error if model reaches FAILED status
|
|
||||||
*/
|
*/
|
||||||
private async pollForModelStatus(
|
private async pollForModelStatus(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
@@ -654,9 +585,7 @@ class ModelsStore {
|
|||||||
await this.fetchRouterModels();
|
await this.fetchRouterModels();
|
||||||
|
|
||||||
const currentStatus = this.getModelStatus(modelId);
|
const currentStatus = this.getModelStatus(modelId);
|
||||||
if (currentStatus === expectedStatus) {
|
if (currentStatus === expectedStatus) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStatus === ServerModelStatus.FAILED) {
|
if (currentStatus === ServerModelStatus.FAILED) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -677,15 +606,8 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a model (ROUTER mode)
|
|
||||||
* @param modelId - Model identifier to load
|
|
||||||
*/
|
|
||||||
async loadModel(modelId: string): Promise<void> {
|
async loadModel(modelId: string): Promise<void> {
|
||||||
if (this.isModelLoaded(modelId)) {
|
if (this.isModelLoaded(modelId)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.modelLoadingStates.get(modelId)) return;
|
if (this.modelLoadingStates.get(modelId)) return;
|
||||||
|
|
||||||
this.modelLoadingStates.set(modelId, true);
|
this.modelLoadingStates.set(modelId, true);
|
||||||
@@ -694,7 +616,6 @@ class ModelsStore {
|
|||||||
try {
|
try {
|
||||||
await ModelsService.load(modelId);
|
await ModelsService.load(modelId);
|
||||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
||||||
|
|
||||||
await this.updateModelModalities(modelId);
|
await this.updateModelModalities(modelId);
|
||||||
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -706,15 +627,8 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unload a model (ROUTER mode)
|
|
||||||
* @param modelId - Model identifier to unload
|
|
||||||
*/
|
|
||||||
async unloadModel(modelId: string): Promise<void> {
|
async unloadModel(modelId: string): Promise<void> {
|
||||||
if (!this.isModelLoaded(modelId)) {
|
if (!this.isModelLoaded(modelId)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.modelLoadingStates.get(modelId)) return;
|
if (this.modelLoadingStates.get(modelId)) return;
|
||||||
|
|
||||||
this.modelLoadingStates.set(modelId, true);
|
this.modelLoadingStates.set(modelId, true);
|
||||||
@@ -722,7 +636,6 @@ class ModelsStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ModelsService.unload(modelId);
|
await ModelsService.unload(modelId);
|
||||||
|
|
||||||
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
||||||
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -734,15 +647,8 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a model is loaded before use
|
|
||||||
* @param modelId - Model identifier to ensure is loaded
|
|
||||||
*/
|
|
||||||
async ensureModelLoaded(modelId: string): Promise<void> {
|
async ensureModelLoaded(modelId: string): Promise<void> {
|
||||||
if (this.isModelLoaded(modelId)) {
|
if (this.isModelLoaded(modelId)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.loadModel(modelId);
|
await this.loadModel(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,11 +685,9 @@ class ModelsStore {
|
|||||||
private loadFavoritesFromStorage(): Set<string> {
|
private loadFavoritesFromStorage(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
|
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
|
||||||
|
|
||||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load favorite models from local storage');
|
toast.error('Failed to load favorite models from local storage');
|
||||||
|
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,10 +703,19 @@ class ModelsStore {
|
|||||||
private toDisplayName(id: string): string {
|
private toDisplayName(id: string): string {
|
||||||
const segments = id.split(/\\|\//);
|
const segments = id.split(/\\|\//);
|
||||||
const candidate = segments.pop();
|
const candidate = segments.pop();
|
||||||
|
|
||||||
return candidate && candidate.trim().length > 0 ? candidate : id;
|
return candidate && candidate.trim().length > 0 ? candidate : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildModalities(
|
||||||
|
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
|
||||||
|
): ModelModalities {
|
||||||
|
return {
|
||||||
|
vision: modalities.vision ?? false,
|
||||||
|
audio: modalities.audio ?? false,
|
||||||
|
video: modalities.video ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.models = [];
|
this.models = [];
|
||||||
this.routerModels = [];
|
this.routerModels = [];
|
||||||
|
|||||||
Vendored
+1
@@ -203,6 +203,7 @@ export interface ApiLlamaCppServerProps {
|
|||||||
/** @deprecated Use {@link ui_settings} instead */
|
/** @deprecated Use {@link ui_settings} instead */
|
||||||
webui_settings?: Record<string, string | number | boolean>;
|
webui_settings?: Record<string, string | number | boolean>;
|
||||||
ui_settings?: Record<string, string | number | boolean>;
|
ui_settings?: Record<string, string | number | boolean>;
|
||||||
|
cors_proxy_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiChatCompletionRequest {
|
export interface ApiChatCompletionRequest {
|
||||||
|
|||||||
@@ -12,17 +12,21 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = config().apiKey;
|
const apiKey = config().apiKey;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
// No API key configured — server doesn't require auth, skip the request entirely.
|
||||||
'Content-Type': 'application/json'
|
// The /props endpoint is only protected when the server has API keys configured,
|
||||||
};
|
// and in that case the client always has one set (from settings).
|
||||||
|
if (!apiKey) {
|
||||||
if (apiKey) {
|
return;
|
||||||
headers.Authorization = `Bearer ${apiKey}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch(`${base}/props`, { headers });
|
const response = await fetch(`${base}/props`, { headers });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ async function migrateConversation(convId: string): Promise<number> {
|
|||||||
export async function runLegacyMigration(): Promise<void> {
|
export async function runLegacyMigration(): Promise<void> {
|
||||||
if (!isMigrationNeeded()) return;
|
if (!isMigrationNeeded()) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||||
console.log('[Migration] Starting legacy message format migration...');
|
console.log('[Migration] Starting legacy message format migration...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -344,6 +345,7 @@ export async function runLegacyMigration(): Promise<void> {
|
|||||||
totalMigrated += count;
|
totalMigrated += count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||||
if (totalMigrated > 0) {
|
if (totalMigrated > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
||||||
@@ -351,6 +353,7 @@ export async function runLegacyMigration(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
console.log('[Migration] No legacy messages found, marking as done');
|
console.log('[Migration] No legacy messages found, marking as done');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
markMigrationDone();
|
markMigrationDone();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
||||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
@@ -72,23 +71,13 @@
|
|||||||
conversationsStore.clearActiveConversation();
|
conversationsStore.clearActiveConversation();
|
||||||
chatStore.clearUIState();
|
chatStore.clearUIState();
|
||||||
|
|
||||||
if (
|
await modelsStore.fetch();
|
||||||
isRouterMode() &&
|
|
||||||
modelsStore.selectedModelName &&
|
|
||||||
!modelsStore.isModelLoaded(modelsStore.selectedModelName)
|
|
||||||
) {
|
|
||||||
modelsStore.clearSelection();
|
|
||||||
|
|
||||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
|
||||||
if (first) {
|
|
||||||
await modelsStore.selectModelById(first.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true
|
|
||||||
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
|
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
|
||||||
await handleUrlParams();
|
await handleUrlParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await modelsStore.ensureFirstModelSelected();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -84,19 +84,23 @@
|
|||||||
function checkApiKey() {
|
function checkApiKey() {
|
||||||
const apiKey = config().apiKey;
|
const apiKey = config().apiKey;
|
||||||
|
|
||||||
|
// No API key configured — server doesn't require auth, no need to validate.
|
||||||
|
// This mirrors the early return in validateApiKey() to avoid redundant /props requests.
|
||||||
|
if (!apiKey || apiKey.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
if (
|
if (
|
||||||
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
||||||
page.status !== 401 &&
|
page.status !== 401 &&
|
||||||
page.status !== 403
|
page.status !== 403
|
||||||
) {
|
) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (apiKey && apiKey.trim() !== '') {
|
|
||||||
headers.Authorization = `Bearer ${apiKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(`${base}/props`, { headers })
|
fetch(`${base}/props`, { headers })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -107,6 +111,7 @@
|
|||||||
console.error('Error checking API key:', e);
|
console.error('Error checking API key:', e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTitleUpdateCancel() {
|
function handleTitleUpdateCancel() {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import TestWrapper from './components/TestWrapper.svelte';
|
|||||||
|
|
||||||
describe('/+page.svelte', () => {
|
describe('/+page.svelte', () => {
|
||||||
it('should render page without throwing', async () => {
|
it('should render page without throwing', async () => {
|
||||||
// Basic smoke test - page should render without throwing errors
|
// Basic smoke test - page should render without throwing errors.
|
||||||
// API calls will fail in test environment but component should still mount
|
// API calls are mocked in vitest-setup-client.ts.
|
||||||
expect(() => render(TestWrapper)).not.toThrow();
|
await render(TestWrapper);
|
||||||
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,78 @@
|
|||||||
/// <reference types="@vitest/browser/matchers" />
|
/// <reference types="@vitest/browser/matchers" />
|
||||||
/// <reference types="@vitest/browser/providers/playwright" />
|
/// <reference types="@vitest/browser/providers/playwright" />
|
||||||
|
|
||||||
|
import { beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock fetch for API calls during client tests.
|
||||||
|
// In test environment there is no backend server, so we intercept
|
||||||
|
// the specific endpoints the app uses and return valid mock data.
|
||||||
|
beforeEach(() => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
|
||||||
|
// Mock server props endpoint
|
||||||
|
if (url.includes('/server')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
mode: 'router',
|
||||||
|
version: 'test',
|
||||||
|
git_commit: 'test',
|
||||||
|
git_branch: 'test'
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock models list endpoint
|
||||||
|
if (/\/v1\/models|\/models\b/.test(url)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'test-model.gguf',
|
||||||
|
object: 'model',
|
||||||
|
owned_by: 'llamacpp',
|
||||||
|
created: 0,
|
||||||
|
in_cache: false,
|
||||||
|
path: 'models/test-model.gguf',
|
||||||
|
status: { value: 'unloaded' },
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: 'test-model.gguf',
|
||||||
|
name: 'Test Model',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock /props endpoint (used for modalities)
|
||||||
|
if (url.includes('/props')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
default_generation_settings: { n_ctx: 2048 }
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock /tools endpoint (used for built-in tools list)
|
||||||
|
if (url.includes('/tools')) {
|
||||||
|
return new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: use real fetch
|
||||||
|
return originalFetch(input, init);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user