feat(web): redesign model card with colored capability icons and context window badges

Extract ModelCapabilities and ContextWindowBadge into shared components.
Model type badge moved to title row with icon, capabilities shown as
icon-only colored tags, context window formatted as colored badge (k/M).
Also add capability and context info to model select dropdown options.
This commit is contained in:
Acbox
2026-03-29 18:34:04 +08:00
parent c986c209ed
commit 716123d08d
4 changed files with 125 additions and 21 deletions
@@ -0,0 +1,34 @@
<template>
<span
v-if="contextWindow"
class="inline-flex items-center gap-1 rounded-md border-0 px-2 py-0.5 text-xs font-medium shrink-0"
:class="badgeClass"
>
{{ formatted }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
contextWindow: number | undefined
}>()
const formatted = computed(() => {
const ctx = props.contextWindow
if (!ctx) return ''
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
if (ctx >= 1000) return `${Math.round(ctx / 1000)}k`
return String(ctx)
})
const badgeClass = computed(() => {
const ctx = props.contextWindow ?? 0
if (ctx >= 1_000_000) return 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-300'
if (ctx >= 100_000) return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
if (ctx >= 32_000) return 'bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300'
if (ctx >= 8_000) return 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300'
return 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
})
</script>
@@ -0,0 +1,45 @@
<template>
<span
v-for="cap in compatibilities"
:key="cap"
:title="$t(`models.compatibility.${cap}`, cap)"
class="inline-flex items-center justify-center rounded-md border-0 size-5 shrink-0"
:class="styleOf(cap)"
>
<component
:is="iconOf(cap)"
class="size-3"
/>
</span>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Wrench, Eye, Image, Brain } from 'lucide-vue-next'
defineProps<{
compatibilities: string[]
}>()
const ICONS: Record<string, Component> = {
'tool-call': Wrench,
'vision': Eye,
'image-output': Image,
'reasoning': Brain,
}
const CLASSES: Record<string, string> = {
'tool-call': 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
'vision': 'bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300',
'image-output': 'bg-pink-50 text-pink-700 dark:bg-pink-950 dark:text-pink-300',
'reasoning': 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300',
}
function iconOf(cap: string): Component {
return ICONS[cap] ?? Wrench
}
function styleOf(cap: string): string {
return CLASSES[cap] ?? 'bg-accent text-foreground'
}
</script>
@@ -7,14 +7,32 @@
:search-placeholder="$t('bots.settings.searchModel')"
search-aria-label="Search models"
:empty-text="$t('bots.settings.noModel')"
/>
>
<template #option-suffix="{ option }">
<span class="ml-auto flex items-center gap-1.5">
<ModelCapabilities
v-if="optionMeta(option)?.compatibilities?.length"
:compatibilities="optionMeta(option)!.compatibilities!"
/>
<ContextWindowBadge :context-window="optionMeta(option)?.context_window" />
<span
v-if="option.description"
class="text-xs text-muted-foreground"
>
{{ option.description }}
</span>
</span>
</template>
</SearchableSelectPopover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ModelsGetResponse, ProvidersGetResponse } from '@memohai/sdk'
import type { ModelsGetResponse, ModelsModelConfig, ProvidersGetResponse } from '@memohai/sdk'
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
import type { SearchableSelectOption } from '@/components/searchable-select-popover/index.vue'
import ModelCapabilities from '@/components/model-capabilities/index.vue'
import ContextWindowBadge from '@/components/context-window-badge/index.vue'
const props = defineProps<{
models: ModelsGetResponse[]
@@ -37,6 +55,10 @@ const providerMap = computed(() => {
return map
})
function optionMeta(option: SearchableSelectOption): ModelsModelConfig | undefined {
return option.meta as ModelsModelConfig | undefined
}
const options = computed<SearchableSelectOption[]>(() =>
typeFilteredModels.value.map((model) => {
const providerId = model.llm_provider_id
@@ -47,6 +69,7 @@ const options = computed<SearchableSelectOption[]>(() =>
group: providerId,
groupLabel: providerMap.value.get(providerId) ?? providerId,
keywords: [model.model_id, model.name ?? ''],
meta: model.config,
}
}),
)
@@ -3,6 +3,18 @@
<ItemContent>
<ItemTitle class="flex items-center gap-2">
{{ model.name || model.model_id }}
<Badge
v-if="model.type"
variant="outline"
size="sm"
class="inline-flex items-center gap-1"
>
<component
:is="typeIcon"
class="size-3"
/>
{{ model.type }}
</Badge>
<span
v-if="testResult"
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
@@ -19,17 +31,7 @@
/>
</ItemTitle>
<ItemDescription class="gap-2 flex flex-wrap items-center mt-3">
<Badge variant="outline">
{{ model.type }}
</Badge>
<Badge
v-for="cap in (model.config?.compatibilities || [])"
:key="cap"
variant="secondary"
class="text-xs"
>
{{ $t(`models.compatibility.${cap}`, cap) }}
</Badge>
<ModelCapabilities :compatibilities="model.config?.compatibilities || []" />
<Badge
v-for="effort in reasoningEfforts"
:key="effort"
@@ -38,12 +40,7 @@
>
{{ effort }}
</Badge>
<span
v-if="model.config?.context_window"
class="text-xs text-muted-foreground"
>
ctx: {{ model.config.context_window.toLocaleString() }}
</span>
<ContextWindowBadge :context-window="model.config?.context_window" />
<span
v-if="testResult && testResult.status !== 'ok' && testResult.message"
class="text-destructive text-xs"
@@ -104,8 +101,10 @@ import {
Button,
Spinner,
} from '@memohai/ui'
import { RefreshCw, Settings, Trash2 } from 'lucide-vue-next'
import { RefreshCw, Settings, Trash2, MessageSquare, Binary } from 'lucide-vue-next'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ModelCapabilities from '@/components/model-capabilities/index.vue'
import ContextWindowBadge from '@/components/context-window-badge/index.vue'
import { postModelsByIdTest } from '@memohai/sdk'
import type { ModelsGetResponse, ModelsTestResponse } from '@memohai/sdk'
import { ref, computed } from 'vue'
@@ -128,6 +127,10 @@ const testLoading = ref(false)
const testResult = ref<ModelsTestResponse | null>(null)
const reasoningEfforts = computed(() => ((props.model.config as ModelConfigWithReasoning | undefined)?.reasoning_efforts ?? []))
const typeIcon = computed(() => {
return props.model.type === 'embedding' ? Binary : MessageSquare
})
const statusDotClass = computed(() => {
switch (testResult.value?.status) {
case 'ok': return 'bg-green-500'
@@ -153,5 +156,4 @@ async function runTest() {
testLoading.value = false
}
}
</script>