mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user