ui : shared model display primitives

Extract ModelCapabilityIcons (canonical Tools/Reasoning/Vision/Video/Audio
order) out of ModelId and reuse it there, add the shared DialogConfirmDownload
for destructive download actions, the discover org avatar with dark-mode
inversion and the thin download progress bar, and rework ModelId badges to take
thinking/tool-use support directly.

Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-09-07 22:10:09 +02:00
parent 7f59290fd0
commit ca5c3be7f6
12 changed files with 492 additions and 96 deletions
@@ -0,0 +1,60 @@
<script lang="ts">
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
interface Props {
/** Action being confirmed; drives the wording. */
action: ModelDownloadConfirmAction;
/** `<repo>:<tag>` the action targets. */
repoWithTag: string;
onClose: () => void;
/** Overrides the default store removal; defaults to removing the entry. */
onConfirm?: (repoWithTag: string) => void;
open?: boolean;
}
let { action, onClose, onConfirm, open = true, repoWithTag }: Props = $props();
// Both actions resolve through the same store removal (cancelDownload drops a
// running download's partial files or a cached model's files); only the copy
// differs. One component so the discover chips and the selector rows word the
// destructive confirmations identically.
const COPY = {
[ModelDownloadConfirmAction.CANCEL]: {
cancelText: 'Keep downloading',
confirmText: 'Cancel download',
description: (name: string) =>
`This stops the download of ${name} and removes the partial files. Pause it instead to keep the progress.`,
title: 'Cancel download'
},
[ModelDownloadConfirmAction.DELETE]: {
cancelText: 'Keep model',
confirmText: 'Delete',
description: (name: string) =>
`This permanently removes ${name} from disk. You can download it again later.`,
title: 'Delete model'
}
} as const;
let copy = $derived(COPY[action]);
let displayName = $derived(modelsStore.toDisplayName(repoWithTag));
function confirm() {
if (onConfirm) onConfirm(repoWithTag);
else void modelsStore.status.cancelDownload(repoWithTag);
onClose();
}
</script>
<DialogConfirmation
cancelText={copy.cancelText}
confirmText={copy.confirmText}
description={copy.description(displayName)}
onCancel={onClose}
onConfirm={confirm}
{open}
title={copy.title}
variant="destructive"
/>
@@ -108,6 +108,17 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
*/
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
* **DialogConfirmDownload** - Confirm a destructive download action
*
* Shared confirmation for stopping/cancelling an in-flight download or deleting
* a downloaded model, used by the discover quant chips and the model selector's
* download rows so both word the action identically. Owns the copy and the
* default store removal; render one instance per surface keyed by the acted-on
* repo:tag.
*/
export { default as DialogConfirmDownload } from './DialogConfirmDownload.svelte';
/**
* **DialogConversationRename** - Rename a conversation
*
@@ -0,0 +1,93 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ModelModalities } from '$lib/types/models';
interface Props {
modalities?: ModelModalities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
hideCapabilities?: boolean;
hideModalities?: boolean;
iconSize?: string;
gapClass?: string;
}
let {
gapClass = 'gap-1.25',
hideCapabilities = false,
hideModalities = false,
iconSize = 'h-3 w-3',
modalities,
supportsThinking = false,
supportsToolUse = false
}: Props = $props();
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
</script>
<span class="inline-flex items-center {gapClass}">
{#if supportsToolUse && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if supportsThinking && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasModalityIcons && !hideModalities}
<span class="inline-flex items-center text-muted-foreground">
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
@@ -1,45 +1,66 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { Database, ScrollText } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { type ModelSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { ModelModalities } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
hideOrgName?: boolean;
hideName?: boolean;
hideModalities?: boolean;
hideCapabilities?: boolean;
hideParameters?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
/** Render the capability/modality/context icons on a second row. */
iconsOnNewLine?: boolean;
modalities?: ModelModalities;
capabilities?: ModelCapabilities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
/** Context length in tokens; renders a context icon when set. */
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
draftSidecars?: ModelSidecar[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
}
let {
aliases,
capabilities,
class: className = '',
contextLength,
draftSidecars = [],
hideCapabilities = false,
hideModalities = false,
hideName = false,
hideOrgName = false,
hideParameters = false,
hideQuantization,
hideTags,
iconsOnNewLine = false,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
supportsThinking = false,
supportsToolUse = false,
tags,
wrap = false,
...rest
}: Props = $props();
@@ -47,6 +68,11 @@
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
const variantBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
/** Alias badges beyond this many collapse into a single `+x more` badge. */
const MAX_ALIAS_BADGES = 2;
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(
@@ -59,104 +85,147 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
let hasBadges = $derived(
parsed.sidecar ||
(parsed.params && !hideParameters) ||
(parsed.quantization && !resolvedHideQuantization) ||
primaryAlias ||
uniqueAliases.length > 1 ||
(uniqueTags.length > 0 && !resolvedHideTags)
);
</script>
{#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{#if !hideName}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{/if}
{#if activeCapabilities.length > 0 || activeModalities.length > 0}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#each activeCapabilities as capability (capability)}
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
{#if hasBadges}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
{#if parsed.sidecar}
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
{parsed.sidecar}
</span>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if parsed.params && !hideParameters}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{#each uniqueDraftSidecars as sidecar (sidecar)}
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
{sidecar}
</span>
{/each}
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
{parsed.modelName ?? modelId}
</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases.slice(0, MAX_ALIAS_BADGES) as alias (alias)}
<span class="{badgeClass} max-w-32 truncate" title={alias}>
{alias}
</span>
{/each}
<Tooltip.Content>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#if uniqueAliases.length > MAX_ALIAS_BADGES}
<span class={badgeClass} title={uniqueAliases.slice(MAX_ALIAS_BADGES).join(', ')}>
+{uniqueAliases.length - MAX_ALIAS_BADGES} more
</span>
{/if}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/if}
{/snippet}
<span
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
? 'flex-col items-start'
: ''} {className}"
{...rest}
>
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if !iconsOnNewLine}
<ModelCapabilityIcons
{hideCapabilities}
{hideModalities}
{modalities}
{supportsThinking}
{supportsToolUse}
/>
{/if}
</span>
{#if iconsOnNewLine || contextLength || sizeRange}
<span class="inline-flex items-center gap-1.5">
{#if iconsOnNewLine}
<ModelCapabilityIcons
{hideCapabilities}
{hideModalities}
{modalities}
{supportsThinking}
{supportsToolUse}
/>
{/if}
{#if contextLength}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<ScrollText class="h-3 w-3" />
<span class="text-xs">{formatParameters(contextLength)}</span>
</span>
{/if}
{#if sizeRange}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3" />
<span class="text-xs"
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
>
</span>
{/if}
</span>
{/if}
</span>
@@ -86,12 +86,13 @@
>
<ModelId
aliases={option.aliases}
{capabilities}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
supportsThinking={capabilities.reasoning}
supportsToolUse={capabilities.tools}
tags={option.tags}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
downloadedBytes: number;
totalBytes: number;
overlay?: boolean;
}
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
let fraction = $derived.by(() => {
if (totalBytes <= 0) return 0;
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
});
let percent = $derived(Math.round(fraction * 100));
</script>
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -0,0 +1,105 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
interface Props {
class?: string;
org: string;
quantOrg?: string;
size?: string;
baseImageClass?: string;
quantImageClass?: string;
quantPositionClass?: string;
quantSize?: string;
}
let {
baseImageClass = '',
class: className = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
quantPositionClass = '-bottom-0.75 -right-0.75',
quantSize = 'h-4.25 w-4.25',
size = 'h-9 w-9'
}: Props = $props();
let avatarError = $state(false);
let quantError = $state(false);
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
// Monogram fallback: org initial on a hue derived from its name, so each org
// gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
return h % 360;
});
let quantHue = $derived.by(() => {
const name = quantOrg ?? '';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
{#if avatarError}
<span
aria-hidden="true"
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<div class="rounded-md">
<img
alt=""
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
loading="lazy"
onerror={() => (avatarError = true)}
src={HuggingFaceService.getAvatarUrl(org)}
/>
</div>
{/if}
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
aria-hidden="true"
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
style="background-color: hsl({quantHue} 60% 45%)"
>
{quantOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
alt=""
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
loading="lazy"
onerror={() => (quantError = true)}
src={HuggingFaceService.getAvatarUrl(quantOrg)}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{quantOrg}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
@@ -109,3 +109,12 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelId } from './ModelId.svelte';
/**
* **ModelCapabilityIcons** - Capability and modality icon row
*
* The shared tool-use / reasoning / vision / video / audio icon cluster with
* tooltips, used by ModelId and the discover details header so the order and
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
@@ -44,6 +44,9 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
+1 -1
View File
@@ -77,7 +77,7 @@ export {
ModelSelectableFileKind
} from './model.enums';
export { ModelDownloadStopRequest } from './model.enums';
export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+10
View File
@@ -55,3 +55,13 @@ export enum ModelDownloadStopRequest {
CANCEL = 'cancel',
PAUSE = 'pause'
}
/**
* Destructive download action the user is asked to confirm: stop and discard an
* in-flight download, or delete an already-downloaded model from disk. Both
* resolve through the same store removal call, differing only in the copy.
*/
export enum ModelDownloadConfirmAction {
CANCEL = 'cancel',
DELETE = 'delete'
}
+4 -1
View File
@@ -28,6 +28,9 @@ export function formatFileSize(bytes: number | unknown): string {
/**
* Format parameter count to human-readable format (B, M, K)
*
* Billions render as whole numbers (`176.94e9` -> `177B`): the decimals are
* noise at badge sizes and id-parsed counts are whole anyway (`Qwen3-8B`).
*
* @param params - Parameter count
* @returns Human-readable parameter count
*/
@@ -35,7 +38,7 @@ export function formatParameters(params: number | unknown): string {
if (typeof params !== 'number') return 'Unknown';
if (params >= 1e9) {
return `${(params / 1e9).toFixed(2)}B`;
return `${Math.round(params / 1e9)}B`;
}
if (params >= 1e6) {