mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-28 17:56:13 +08:00
ui : fix models page navigation and Discover gating
Record the models page's way back after every navigation, so Back, a typed URL or a reload no longer return to a stale chat. Escape closes the detail pane, then leaves the page. Gate every Discover entry point on one shared check, recheck the API key on /models, skip the duplicate router fetch on a direct load, and share the route load between pages. Assisted by: Claude (Opus 5.5 Medium)
This commit is contained in:
1 parent
a206ae5706
commit
f64baf3a36
15 files changed
+115
-77
No files matched your search
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { DISCOVER_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import { DISCOVER_ICON } from '$lib/constants';
|
||||
import { uiStore } from '$lib/stores';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
// the dialog exists only while its setting is on
|
||||
let discoverEnabled = $derived(
|
||||
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
|
||||
);
|
||||
|
||||
// the setting can be turned off while the dialog is open: it closes and drops the
|
||||
// repo it was asked to show
|
||||
$effect(() => {
|
||||
if (open && !discoverEnabled) {
|
||||
untrack(() => handleOpenChange(false));
|
||||
if (open && !uiStore.isDiscoverEnabled) {
|
||||
untrack(() => {
|
||||
uiStore.discoverModelFocus = null;
|
||||
handleOpenChange(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -260,6 +260,32 @@
|
||||
void uiStore.closeModelsManager();
|
||||
}
|
||||
|
||||
/** Overlays that handle their own Escape, so the page leaves them alone. */
|
||||
const OPEN_OVERLAY_SELECTOR = [
|
||||
'[role="dialog"][data-state="open"]',
|
||||
'[role="alertdialog"][data-state="open"]',
|
||||
'[role="menu"][data-state="open"]',
|
||||
'[role="listbox"][data-state="open"]'
|
||||
].join(', ');
|
||||
|
||||
// Escape steps back the way the dialog's did: it closes the pane, then the page
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
|
||||
if (document.querySelector(OPEN_OVERLAY_SELECTOR)) return;
|
||||
|
||||
// a typed filter is not thrown away by a stray Escape
|
||||
if (event.target instanceof HTMLInputElement && event.target.value) return;
|
||||
|
||||
if (selectedId !== null) {
|
||||
selectedId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
returnToChat();
|
||||
}
|
||||
|
||||
/** Load the model when the server can take load requests, without waiting for it. */
|
||||
function loadInBackground(option: ModelOption): void {
|
||||
// only the built-in server loads on request, and only in router mode
|
||||
@@ -292,6 +318,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class={['relative flex min-h-0 flex-1', className]}>
|
||||
<div class="min-h-0 min-w-0 flex-1">
|
||||
<ModelsManagerModelsTable
|
||||
|
||||
+2
-6
@@ -11,8 +11,7 @@
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { conversationsStore, deviceStore, settingsStore } from '$lib/stores';
|
||||
import { conversationsStore, deviceStore, uiStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
/** A provider that cannot load or unload has no button to offer. */
|
||||
@@ -48,10 +47,7 @@
|
||||
size = 'sm'
|
||||
}: Props = $props();
|
||||
|
||||
// the discover view exists only while its setting is on
|
||||
let discoverEnabled = $derived(
|
||||
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
|
||||
);
|
||||
let discoverEnabled = $derived(uiStore.isDiscoverEnabled);
|
||||
|
||||
// a chat with anything in it takes the model, a new chat is not what its user asked for
|
||||
let hasChat = $derived(
|
||||
|
||||
+1
-3
@@ -6,7 +6,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { type ModalityKey } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { deviceStore, uiStore } from '$lib/stores';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -36,8 +36,6 @@
|
||||
// or two after the page (the toolbar renders below the table in the DOM), so the
|
||||
// poll retries until the ref lands or gives up.
|
||||
$effect(() => {
|
||||
if (!uiStore.isModelsPageOpen) return;
|
||||
|
||||
let frames = 0;
|
||||
let handle = requestAnimationFrame(function focusFilter() {
|
||||
if (filterInput) {
|
||||
|
||||
@@ -13,13 +13,18 @@ export function modelRowActions(
|
||||
/** Download state, when the row stands for a tracked download. */
|
||||
download?: ModelRowDownloadState | null
|
||||
) {
|
||||
const viewInDiscover = {
|
||||
icon: Compass,
|
||||
label: 'See in Discover',
|
||||
// the details pane is keyed by repo, not by `<repo>:<quant>`
|
||||
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
|
||||
separator: true
|
||||
};
|
||||
// the details pane is keyed by repo, not by `<repo>:<quant>`; the entry exists only
|
||||
// while Discover is on
|
||||
const viewInDiscover = uiStore.isDiscoverEnabled
|
||||
? [
|
||||
{
|
||||
icon: Compass,
|
||||
label: 'See in Discover',
|
||||
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
|
||||
separator: true
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
// a tracked download is paused, resumed or dropped: it is not loaded, hidden or
|
||||
// drafted. An in-flight row cancels, which stops the download and removes the
|
||||
@@ -40,7 +45,7 @@ export function modelRowActions(
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
},
|
||||
viewInDiscover
|
||||
...viewInDiscover
|
||||
];
|
||||
}
|
||||
|
||||
@@ -63,6 +68,6 @@ export function modelRowActions(
|
||||
onclick: () => modelsStore.toggleHidden(option.id),
|
||||
separator: true
|
||||
},
|
||||
viewInDiscover
|
||||
...viewInDiscover
|
||||
];
|
||||
}
|
||||
@@ -20,3 +20,6 @@ export const ROUTES = {
|
||||
/** Root — start of the app. */
|
||||
START: '#/'
|
||||
} as const;
|
||||
|
||||
/** SvelteKit route id of the models manager, as `page.route.id` reports it. */
|
||||
export const MODELS_ROUTE_ID = '/models';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MODEL_ICON } from './icons.constants';
|
||||
import { MODELS_ROUTE_ID } from './routes.constants';
|
||||
import { Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import { SidebarAction, ToolSource } from '$lib/enums';
|
||||
@@ -80,7 +81,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
||||
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
||||
{
|
||||
action: SidebarAction.MANAGE_MODELS,
|
||||
activeRouteId: '/models',
|
||||
activeRouteId: MODELS_ROUTE_ID,
|
||||
icon: MODEL_ICON,
|
||||
tooltip: 'Models'
|
||||
},
|
||||
|
||||
@@ -114,13 +114,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
);
|
||||
const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.');
|
||||
|
||||
// the manager takes the focus, so the selector closes instead of sitting behind it
|
||||
$effect(() => {
|
||||
if (!uiStore.isModelsPageOpen) return;
|
||||
|
||||
opts.onOpenChange?.(false);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
modelsStore.fetch().catch((error) => {
|
||||
console.error('Unable to load models:', error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toolsStore } from './tools.svelte';
|
||||
import { versionStore } from './version.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { validateApiKey } from '$lib/utils/api-key-validation';
|
||||
|
||||
let startup: Promise<void> | null = null;
|
||||
|
||||
@@ -30,3 +31,11 @@ export function initStores(): Promise<void> {
|
||||
|
||||
return startup;
|
||||
}
|
||||
|
||||
/** Load shared by the app's pages: start the stores, then check the API key. */
|
||||
export async function loadAppRoute(fetch: typeof globalThis.fetch): Promise<void> {
|
||||
// loads run before the root layout script, so the stored API key reaches
|
||||
// the probe only once the settings store has read localStorage
|
||||
await initStores();
|
||||
await validateApiKey(fetch);
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { MODELS_ROUTE_ID, ROUTES, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
class UiStore {
|
||||
@@ -26,12 +27,17 @@ class UiStore {
|
||||
manageModelFocus = $state<string | null>(null);
|
||||
/** Model the information dialog shows; a phone opens it instead of the manager. */
|
||||
modelInformation = $state<ModelOption | null>(null);
|
||||
/** Route the models page returns to, the one it was opened from. */
|
||||
/** Route the models page returns to: the last page visited before it. */
|
||||
modelsReturnRoute: string = ROUTES.START;
|
||||
|
||||
/** Whether Discover is turned on; every Discover entry point hides without it. */
|
||||
get isDiscoverEnabled(): boolean {
|
||||
return Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS));
|
||||
}
|
||||
|
||||
/** Whether the models manager route is the current page. */
|
||||
get isModelsPageOpen(): boolean {
|
||||
return page.route.id === '/models';
|
||||
return page.route.id === MODELS_ROUTE_ID;
|
||||
}
|
||||
|
||||
/** Leave the models page for the route it was opened from. */
|
||||
@@ -46,8 +52,10 @@ class UiStore {
|
||||
this.modelInformation = option;
|
||||
}
|
||||
|
||||
/** Open the Discover dialog, focused on one repo. */
|
||||
/** Open the Discover dialog, focused on one repo; a no-op while Discover is off. */
|
||||
openModelsDiscover(focus?: string): void {
|
||||
if (!this.isDiscoverEnabled) return;
|
||||
|
||||
this.discoverModelFocus = focus ?? null;
|
||||
this.discoverModelsOpen = true;
|
||||
}
|
||||
@@ -58,10 +66,16 @@ class UiStore {
|
||||
|
||||
if (this.isModelsPageOpen) return;
|
||||
|
||||
this.modelsReturnRoute = page.url.hash || ROUTES.START;
|
||||
void goto(ROUTES.MODELS);
|
||||
}
|
||||
|
||||
/** Remember the current route as the models page's way back, unless it is that page. */
|
||||
rememberModelsReturnRoute(): void {
|
||||
if (this.isModelsPageOpen) return;
|
||||
|
||||
this.modelsReturnRoute = page.url.hash || ROUTES.START;
|
||||
}
|
||||
|
||||
/** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */
|
||||
requestComposerFocus(): void {
|
||||
this.composerFocusRequested = true;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
import { validateApiKey } from '$lib/utils';
|
||||
import { loadAppRoute } from '$lib/stores/init';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
// loads run before the root layout script, so the stored API key reaches
|
||||
// the probe only once the settings store has read localStorage
|
||||
await initStores();
|
||||
await validateApiKey(fetch);
|
||||
};
|
||||
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
import { validateApiKey } from '$lib/utils';
|
||||
import { loadAppRoute } from '$lib/stores/init';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
// loads run before the root layout script, so the stored API key reaches
|
||||
// the probe only once the settings store has read localStorage
|
||||
await initStores();
|
||||
await validateApiKey(fetch);
|
||||
};
|
||||
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { SidebarNavigation } from '$lib/components/app';
|
||||
@@ -11,6 +11,7 @@
|
||||
FAVICON_PATHS,
|
||||
FAVICON_SELECTORS,
|
||||
HEADERS,
|
||||
MODELS_ROUTE_ID,
|
||||
NEW_CHAT_TAB_ID,
|
||||
ROUTES,
|
||||
SETTINGS_KEYS,
|
||||
@@ -28,6 +29,7 @@
|
||||
serverStore,
|
||||
settingsStore,
|
||||
tabsStore,
|
||||
uiStore,
|
||||
versionStore
|
||||
} from '$lib/stores';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
@@ -137,6 +139,9 @@
|
||||
untrack(() => tabsStore.removeTabs([NEW_CHAT_TAB_ID]));
|
||||
}
|
||||
});
|
||||
// the models page returns to wherever it was reached from, by any kind of navigation
|
||||
afterNavigate(() => uiStore.rememberModelsReturnRoute());
|
||||
|
||||
// Global keyboard shortcuts
|
||||
const { handleKeydown } = useKeyboardShortcuts({
|
||||
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
|
||||
@@ -158,7 +163,9 @@
|
||||
|
||||
untrack(() => {
|
||||
if (
|
||||
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
||||
(page.route.id === '/(chat)' ||
|
||||
page.route.id === '/(chat)/chat/[id]' ||
|
||||
page.route.id === MODELS_ROUTE_ID) &&
|
||||
page.status !== 401 &&
|
||||
page.status !== 403
|
||||
) {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { APP_NAME, DISCOVER_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
|
||||
import { APP_NAME, DISCOVER_ICON } from '$lib/constants';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let discoverEnabled = $derived(
|
||||
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
|
||||
);
|
||||
|
||||
// a direct load lands here before any chat has listed the models; the router
|
||||
// fetch is the sync point for an already loaded list and a no-op outside router mode
|
||||
// a direct load lands here before any chat has listed the models, and the root
|
||||
// layout fetches the router details once that list arrives. An already loaded
|
||||
// list only needs the router fetch as a sync point (a no-op outside router mode)
|
||||
onMount(() => {
|
||||
modelsStore
|
||||
.fetch()
|
||||
.then(() => modelsStore.fetchRouterModels())
|
||||
.catch((error) => console.error('Unable to load models:', error));
|
||||
if (modelsStore.models.length > 0) {
|
||||
void modelsStore.fetchRouterModels();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
modelsStore.fetch().catch((error) => console.error('Unable to load models:', error));
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if discoverEnabled}
|
||||
{#if uiStore.isDiscoverEnabled}
|
||||
<Button
|
||||
class="ml-auto shrink-0 gap-1.5"
|
||||
onclick={() => uiStore.openModelsDiscover()}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
import { validateApiKey } from '$lib/utils';
|
||||
import { loadAppRoute } from '$lib/stores/init';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
await initStores();
|
||||
await validateApiKey(fetch);
|
||||
};
|
||||
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
|
||||
Reference in new issue
Block a user