Files
vessel/frontend/src/lib/components/models/ModelCard.svelte
vikingowl 802db229a6
Some checks failed
Create Release / release (push) Has been cancelled
feat: add model filters and last updated display
- Add size filter (≤3B, 4-13B, 14-70B, >70B) based on model tags
- Add model family filter dropdown with dynamic family list
- Display last updated date on model cards (scraped from ollama.com)
- Add /api/v1/models/remote/families endpoint
- Convert relative time strings ("2 weeks ago") to timestamps during sync
2026-01-02 21:54:50 +01:00

140 lines
5.1 KiB
Svelte

<script lang="ts">
/**
* ModelCard - Displays a remote model from ollama.com
*/
import type { RemoteModel } from '$lib/api/model-registry';
import { formatPullCount, formatContextLength } from '$lib/api/model-registry';
interface Props {
model: RemoteModel;
onSelect?: (model: RemoteModel) => void;
}
let { model, onSelect }: Props = $props();
/**
* Format a date as relative time (e.g., "2d ago", "3w ago")
*/
function formatRelativeTime(date: string | Date | undefined): string {
if (!date) return '';
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
if (weeks < 4) return `${weeks}w ago`;
return `${months}mo ago`;
}
// Capability badges config (matches ollama.com capabilities)
const capabilityBadges: Record<string, { icon: string; color: string; label: string }> = {
vision: { icon: '👁', color: 'bg-purple-900/50 text-purple-300', label: 'Vision' },
tools: { icon: '🔧', color: 'bg-blue-900/50 text-blue-300', label: 'Tools' },
thinking: { icon: '🧠', color: 'bg-pink-900/50 text-pink-300', label: 'Thinking' },
embedding: { icon: '📊', color: 'bg-amber-900/50 text-amber-300', label: 'Embedding' },
cloud: { icon: '☁️', color: 'bg-cyan-900/50 text-cyan-300', label: 'Cloud' }
};
</script>
<button
type="button"
onclick={() => onSelect?.(model)}
class="group w-full rounded-lg border border-theme bg-theme-secondary p-4 text-left transition-all hover:border-theme-subtle hover:bg-theme-tertiary"
>
<!-- Header: Name and Type Badge -->
<div class="flex items-start justify-between gap-2">
<h3 class="font-medium text-theme-primary group-hover:text-blue-400">
{model.name}
</h3>
<span
class="shrink-0 rounded px-2 py-0.5 text-xs {model.modelType === 'official'
? 'bg-blue-900/50 text-blue-300'
: 'bg-theme-tertiary text-theme-muted'}"
>
{model.modelType}
</span>
</div>
<!-- Description -->
{#if model.description}
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">
{model.description}
</p>
{/if}
<!-- Capabilities -->
{#if model.capabilities.length > 0}
<div class="mt-3 flex flex-wrap gap-1.5">
{#each model.capabilities as capability}
{@const badge = capabilityBadges[capability]}
{#if badge}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs {badge.color}">
<span>{badge.icon}</span>
<span>{badge.label}</span>
</span>
{/if}
{/each}
</div>
{/if}
<!-- Stats Row -->
<div class="mt-3 flex items-center gap-4 text-xs text-theme-muted">
<!-- Pull Count -->
<div class="flex items-center gap-1" title="{model.pullCount.toLocaleString()} pulls">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>{formatPullCount(model.pullCount)}</span>
</div>
<!-- Available Sizes (from tags) -->
{#if model.tags.length > 0}
<div class="flex items-center gap-1" title="Available parameter sizes">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span>{model.tags.length} size{model.tags.length !== 1 ? 's' : ''}</span>
</div>
{/if}
<!-- Context Length (if fetched from ollama show) -->
{#if model.contextLength}
<div class="flex items-center gap-1" title="Context length">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
<span>{formatContextLength(model.contextLength)}</span>
</div>
{/if}
<!-- Last Updated -->
{#if model.ollamaUpdatedAt}
<div class="flex items-center gap-1" title="Last updated on Ollama: {new Date(model.ollamaUpdatedAt).toLocaleDateString()}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{formatRelativeTime(model.ollamaUpdatedAt)}</span>
</div>
{/if}
</div>
<!-- Size Tags -->
{#if model.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each model.tags as tag}
<span class="rounded bg-blue-900/30 px-1.5 py-0.5 text-xs font-medium text-blue-300">
{tag}
</span>
{/each}
</div>
{/if}
</button>