mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-28 17:07:31 -05:00
ui : mount the configuration pane as a drawer in the manager
A row click slides the pane in as a fixed-width drawer: the toolbar's calls to action leave first, the pane is laid out before its first open, and a model switch fades one out and the next in. The model information dialog folds into the pane's information tab, and a chat started from the pane closes the manager and focuses the composer. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -133,7 +133,7 @@
|
||||
|
||||
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
|
||||
{#if shownView === 'manage'}
|
||||
<ModelsManager class="h-full">
|
||||
<ModelsManager class="h-full" onClose={() => handleOpenChange(false)}>
|
||||
{#snippet toolbarEnd()}
|
||||
<Button
|
||||
class="gap-1.5"
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ApiLlamaCppServerProps } from '$lib/types';
|
||||
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
// when set, fetch props from the child process (router mode)
|
||||
modelId?: string | null;
|
||||
}
|
||||
|
||||
let { modelId = null, onOpenChange, open = $bindable() }: Props = $props();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
|
||||
// per-model props fetched from the child process
|
||||
let routerModelProps = $state<ApiLlamaCppServerProps | null>(null);
|
||||
let isLoadingRouterProps = $state(false);
|
||||
|
||||
// in router mode use per-model props, otherwise use global props
|
||||
let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
|
||||
|
||||
let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
|
||||
let models = $derived(modelsStore.models);
|
||||
let isLoadingModels = $derived(modelsStore.loading);
|
||||
|
||||
// in router mode, find the model option matching modelId
|
||||
// in single mode, use the first model as before
|
||||
let firstModel = $derived.by(() => {
|
||||
if (isRouter && modelId) {
|
||||
return models.find((m) => m.model === modelId) ?? null;
|
||||
}
|
||||
|
||||
return models[0] ?? null;
|
||||
});
|
||||
|
||||
// Get modalities from modelStore using the model ID from the first model
|
||||
let modalities = $derived.by(() => {
|
||||
if (!firstModel?.id) return [];
|
||||
|
||||
return modelsStore.props.getModelModalitiesArray(firstModel.id);
|
||||
});
|
||||
|
||||
// Ensure models are fetched when dialog opens
|
||||
$effect(() => {
|
||||
if (open && models.length === 0) {
|
||||
modelsStore.fetch();
|
||||
}
|
||||
});
|
||||
|
||||
// fetch per-model props from child process when dialog opens in router mode
|
||||
$effect(() => {
|
||||
if (open && isRouter && modelId) {
|
||||
isLoadingRouterProps = true;
|
||||
modelsStore.props
|
||||
.fetchModelProps(modelId)
|
||||
.then((props) => {
|
||||
routerModelProps = props;
|
||||
})
|
||||
.catch(() => {
|
||||
routerModelProps = null;
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRouterProps = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
routerModelProps = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content
|
||||
class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
|
||||
>
|
||||
<!-- sticky header holds only the close button; the title scrolls with the body -->
|
||||
<Dialog.Header />
|
||||
|
||||
<div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<Dialog.Title>Model Information</Dialog.Title>
|
||||
|
||||
<Dialog.Description>Current model details and capabilities</Dialog.Description>
|
||||
</div>
|
||||
|
||||
{#if isLoadingModels || isLoadingRouterProps}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">Loading model information...</div>
|
||||
</div>
|
||||
{:else if firstModel}
|
||||
{@const modelMeta = firstModel.meta}
|
||||
|
||||
{#if serverProps}
|
||||
<!-- Desktop: fixed-layout table, long values scroll inside their cell -->
|
||||
<Table.Root class="hidden table-fixed md:table">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[10rem]">Model</Table.Head>
|
||||
|
||||
<Table.Head>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{modelName}
|
||||
</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
canCopy={!!modelName}
|
||||
text={modelName || ''}
|
||||
/>
|
||||
</div>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<!-- Model Path -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
|
||||
|
||||
<Table.Cell class="h-10 align-middle font-mono text-xs">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{serverProps.model_path}
|
||||
</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model path to clipboard"
|
||||
text={serverProps.model_path}
|
||||
/>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Context Size -->
|
||||
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||
|
||||
<Table.Cell
|
||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium text-red-500"
|
||||
>Context Size</Table.Cell
|
||||
>
|
||||
|
||||
<Table.Cell class="text-red-500">Not available</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Training Context -->
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Model Size -->
|
||||
{#if modelMeta?.size}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if modelMeta?.n_params}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Embedding Size -->
|
||||
{#if modelMeta?.n_embd}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Size -->
|
||||
{#if modelMeta?.n_vocab}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Type -->
|
||||
{#if modelMeta?.vocab_type}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
|
||||
|
||||
<Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Total Slots -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
|
||||
|
||||
<Table.Cell>{serverProps.total_slots}</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Modalities -->
|
||||
{#if modalities.length > 0}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<BadgesModality {modalities} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Build Info -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
|
||||
|
||||
<Table.Cell class="align-middle font-mono text-xs"
|
||||
>{serverProps.build_info}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Chat Template -->
|
||||
{#if serverProps.chat_template}
|
||||
<Table.Row>
|
||||
<Table.Cell class="py-4" colspan={2}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium">Chat Template</span>
|
||||
|
||||
<div class="overflow-x-auto rounded-md bg-muted p-4">
|
||||
<pre
|
||||
class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
|
||||
<div class="flex min-w-0 flex-col gap-4 md:hidden">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Model</div>
|
||||
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
canCopy={!!modelName}
|
||||
text={modelName || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">File Path</div>
|
||||
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<span class="min-w-0 flex-1 break-all font-mono text-xs"
|
||||
>{serverProps.model_path}</span
|
||||
>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model path to clipboard"
|
||||
text={serverProps.model_path}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||
{@render infoRow(
|
||||
'Context Size',
|
||||
`${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
|
||||
)}
|
||||
{:else}
|
||||
{@render infoRow('Context Size', 'Not available', 'text-red-500')}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
{@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.size}
|
||||
{@render infoRow('Model Size', formatFileSize(modelMeta.size))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_params}
|
||||
{@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_embd}
|
||||
{@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_vocab}
|
||||
{@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.vocab_type}
|
||||
{@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
|
||||
{/if}
|
||||
|
||||
{@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
|
||||
|
||||
{#if modalities.length > 0}
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Modalities</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<BadgesModality {modalities} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Build Info</div>
|
||||
|
||||
<span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
|
||||
</div>
|
||||
|
||||
{#if serverProps.chat_template}
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">Chat Template</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-md bg-muted p-4">
|
||||
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !isLoadingModels}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">No model information available</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{#snippet infoRow(label: string, value: string, valueClass: string = '')}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
|
||||
|
||||
<span class="text-sm {valueClass}">{value}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -19,7 +19,7 @@
|
||||
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpServers** - MCP servers dialog shown from the chat form
|
||||
* **DialogMcpServers** - MCP servers dialog shown from the sidebar
|
||||
*
|
||||
* Shows the same MCP server list as the `/mcp-servers` route inside a modal
|
||||
* dialog.
|
||||
@@ -106,6 +106,10 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* **DialogManageModels** - shell for model management, still to be designed.
|
||||
*/
|
||||
export { default as DialogManageModels } from './DialogManageModels.svelte';
|
||||
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
|
||||
|
||||
/**
|
||||
@@ -450,7 +454,6 @@ export { default as DialogConversationSelection } from './DialogConversationSele
|
||||
* <DialogModelInformation bind:open={showModelInfo} />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpResourcesBrowser** - MCP resources browser dialog
|
||||
@@ -537,38 +540,12 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
|
||||
|
||||
/**
|
||||
* **DialogConfirmation** - Generic confirmation dialog
|
||||
* **DialogModelsDiscover** - full-screen model discovery dialog.
|
||||
*
|
||||
* Reusable confirmation dialog with customizable title, description,
|
||||
* and action buttons. Supports destructive action styling and custom icons.
|
||||
* Used for delete confirmations, irreversible actions, and important decisions.
|
||||
* Two-pane layout: searchable model list (Hugging Face + llama.app catalog)
|
||||
* on the left, model details with download options on the right.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog
|
||||
* - Supports variant styling (default, destructive)
|
||||
* - Customizable button labels and callbacks
|
||||
*
|
||||
* **Features:**
|
||||
* - Customizable title and description text
|
||||
* - Destructive variant with red styling for dangerous actions
|
||||
* - Custom icon support in header
|
||||
* - Cancel and confirm button callbacks
|
||||
* - Keyboard accessible (Escape to cancel, Enter to confirm)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogConfirmation
|
||||
* bind:open={showDelete}
|
||||
* title="Delete conversation?"
|
||||
* description="This action cannot be undone."
|
||||
* variant="destructive"
|
||||
* onConfirm={handleDelete}
|
||||
* onCancel={() => showDelete = false}
|
||||
* />
|
||||
* ```
|
||||
* @see ModelsDiscover in $lib/components/app/models/discover
|
||||
*/
|
||||
/**
|
||||
* **DialogManageModels** - shell for model management, still to be designed.
|
||||
*/
|
||||
export { default as DialogManageModels } from './DialogManageModels.svelte';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
|
||||
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
|
||||
import {
|
||||
groupModelQuants,
|
||||
isCustomized,
|
||||
loadExtraArgs,
|
||||
loadOverrides,
|
||||
type ModalityKey,
|
||||
modelContextLength,
|
||||
@@ -14,21 +17,22 @@
|
||||
} from './utils';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { backendsStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import { backendsStore, conversationsStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import { getBackendCapabilities } from '$lib/utils/backend';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { type Snippet, untrack } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onClose?: () => void;
|
||||
/** Forwarded to the table's toolbar right end. */
|
||||
toolbarEnd?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className, toolbarEnd }: Props = $props();
|
||||
let { class: className, onClose, toolbarEnd }: Props = $props();
|
||||
|
||||
let filter = $state('');
|
||||
let providerFilter = $state<string[]>([]);
|
||||
@@ -93,7 +97,6 @@
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
// recently used models lead their section, the rest keep the server's order
|
||||
const rank = new SvelteMap<string, number>();
|
||||
|
||||
@@ -220,6 +223,67 @@
|
||||
|
||||
return ordered;
|
||||
});
|
||||
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
|
||||
// The pane is laid out before it is ever opened, so the first open only slides a
|
||||
// finished panel in. It renders the selection, else the model it last showed, else
|
||||
// the first model in the list.
|
||||
let lastPicked = $state<ModelOption | null>(null);
|
||||
let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
|
||||
let shownId = $state<string | null>(null);
|
||||
let isSwapping = $state(false);
|
||||
let fade = $state<'open' | 'swap'>('open');
|
||||
let shownOption = $derived(allModels.find((option) => option.id === shownId) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const id = selectedId;
|
||||
|
||||
if (!id) return;
|
||||
|
||||
// untracked: the effect must not track the state it writes
|
||||
untrack(() => {
|
||||
lastPicked = allModels.find((option) => option.id === id) ?? null;
|
||||
});
|
||||
});
|
||||
|
||||
// Another model fades the panel out, swaps it, then fades it back in. Reopening the
|
||||
// same one only fades it, so the panel keeps its tab.
|
||||
$effect(() => {
|
||||
const next = target?.id ?? null;
|
||||
const isOpen = selected !== null;
|
||||
|
||||
if (!next) return;
|
||||
|
||||
if (shownId === null) {
|
||||
untrack(() => (shownId = next));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === shownId) {
|
||||
if (isOpen) {
|
||||
untrack(() => {
|
||||
isSwapping = false;
|
||||
fade = 'open';
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
isSwapping = true;
|
||||
fade = 'swap';
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
shownId = next;
|
||||
isSwapping = false;
|
||||
});
|
||||
}, SWAP_FADE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
// a caller can ask for one model to be revealed, the download rows do
|
||||
$effect(() => {
|
||||
@@ -234,6 +298,64 @@
|
||||
uiStore.manageModelFocus = null;
|
||||
});
|
||||
|
||||
/** How long the panel takes to fade out before it swaps to another model. */
|
||||
const SWAP_FADE_MS = 120;
|
||||
/** How long the calls to action take to leave the toolbar, matching their fade. */
|
||||
const CTA_LEAVE_MS = 120;
|
||||
/** How long the panel takes to slide out before the calls to action come back. */
|
||||
const PANE_LEAVE_MS = 120;
|
||||
|
||||
// The calls to action leave first, then the panel takes the space they gave up.
|
||||
// Closing runs the same order backwards.
|
||||
let ctasVisible = $state(true);
|
||||
// once faded the row leaves the flow, so the filters keep the room it was holding
|
||||
let ctasGone = $state(false);
|
||||
let paneOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (selected !== null) {
|
||||
untrack(() => (ctasVisible = false));
|
||||
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
untrack(() => {
|
||||
ctasGone = true;
|
||||
paneOpen = true;
|
||||
}),
|
||||
CTA_LEAVE_MS
|
||||
);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
paneOpen = false;
|
||||
ctasGone = false;
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => untrack(() => (ctasVisible = true)), PANE_LEAVE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
async function toggleLoad(option: ModelOption): Promise<void> {
|
||||
if (modelsStore.isModelLoaded(option.model)) {
|
||||
await modelsStore.status.unload(option.model);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await modelsStore.status.load(option.model, loadExtraArgs(overrides[option.id]));
|
||||
}
|
||||
|
||||
async function useInNewChat(option: ModelOption): Promise<void> {
|
||||
await modelsStore.selectModelById(option.id);
|
||||
await conversationsStore.openNewChat();
|
||||
// the chat is behind the dialog, so it takes focus once the dialog is out of the way
|
||||
uiStore.requestComposerFocus();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
/** Point the selected model's load settings at another model as its draft. */
|
||||
function useAsDraft(draft: ModelOption, targetId: string): void {
|
||||
const target = modelsStore.models.find((option) => option.id === targetId);
|
||||
@@ -253,6 +375,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet toolbarEndRegion()}
|
||||
<!-- the calls to action fade in place; the pane waits for them to be gone -->
|
||||
<div
|
||||
class="transition-[opacity,visibility] duration-[120ms] ease-[cubic-bezier(0.23,1,0.32,1)] {ctasGone
|
||||
? 'hidden'
|
||||
: 'flex items-center gap-2'} {ctasVisible ? 'visible opacity-100' : 'invisible opacity-0'}"
|
||||
>
|
||||
{@render toolbarEnd?.()}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class={['flex min-h-0 flex-1', className]}>
|
||||
<div class="min-h-0 min-w-0 flex-1">
|
||||
<ModelsManagerModelsTable
|
||||
@@ -269,7 +402,87 @@
|
||||
{overrides}
|
||||
{providerCounts}
|
||||
{selectedId}
|
||||
{toolbarEnd}
|
||||
toolbarEnd={toolbarEndRegion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pane-drawer shrink-0" data-open={paneOpen}>
|
||||
<!-- the content box keeps the open width, so it never reflows with the drawer -->
|
||||
<div
|
||||
class="pane-content flex h-full min-h-0 w-[30rem] max-w-[30rem] flex-col border-l border-border/40"
|
||||
data-fade={fade}
|
||||
data-visible={paneOpen && !isSwapping}
|
||||
>
|
||||
{#if shownOption}
|
||||
{#key shownId}
|
||||
<ModelsManagerModelConfiguration
|
||||
isCustomized={isCustomized(overrides[shownOption.id])}
|
||||
onClose={() => (selectedId = null)}
|
||||
onSave={(override) => saveOverride(shownOption, override)}
|
||||
onToggleLoad={() => void toggleLoad(shownOption)}
|
||||
onUseInNewChat={() => void useInNewChat(shownOption)}
|
||||
option={shownOption}
|
||||
override={overrides[shownOption.id]}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The drawer moves by width because the table behind it gets that space back, so
|
||||
* the content box inside holds the open width and only the container changes.
|
||||
* Opening takes the iOS-like drawer curve; closing is the system responding, so
|
||||
* it snaps back on the stronger ease-out.
|
||||
*/
|
||||
.pane-drawer {
|
||||
width: 0;
|
||||
overflow: clip;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
width 120ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 120ms;
|
||||
}
|
||||
|
||||
.pane-drawer[data-open='true'] {
|
||||
width: 30rem;
|
||||
visibility: visible;
|
||||
transition:
|
||||
width 200ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.pane-content[data-visible='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* opening: the fade waits for the drawer to move */
|
||||
.pane-content[data-visible='true'][data-fade='open'] {
|
||||
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1) 80ms;
|
||||
}
|
||||
|
||||
/* swapping models: out, then in, with no pause */
|
||||
.pane-content[data-visible='true'][data-fade='swap'] {
|
||||
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* reduced motion keeps the fades and drops the slide */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pane-drawer,
|
||||
.pane-drawer[data-open='true'] {
|
||||
transition: visibility 120ms;
|
||||
}
|
||||
|
||||
.pane-content,
|
||||
.pane-content[data-visible='true'][data-fade='open'] {
|
||||
transition: opacity 100ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user