feat(channel-monitor): redesign user dashboard as card grid

Reference check-cx UI: INTELLIGENCE MONITOR hero + 3-column card grid
with 60-point timeline bars.

Backend:
- Add PrimaryPingLatencyMs + Timeline[60] to UserMonitorView
- ListRecentHistoryForMonitors: batch CTE + ROW_NUMBER() window query
- indexLatestByModel / indexAvailabilityByModel helpers

Frontend:
- 7 new components: ProviderIcon, MonitorMetricPair, MonitorAvailabilityRow,
  MonitorTimeline, MonitorHero, MonitorCard, MonitorCardGrid
- ChannelStatusView 381→~180 lines (delegated to subcomponents)
- AbortController reload concurrency protection
- HSL 0-120° availability color mapping
- Replace emoji with Icon component (bolt / globe)
- i18n: monitorCommon.* shared namespace, channelStatus.hero.*

Bump VERSION to 0.1.114.24
This commit is contained in:
erio
2026-04-20 23:38:59 +08:00
parent 20a4e41872
commit a1425b457d
19 changed files with 1134 additions and 278 deletions

View File

@@ -1,71 +0,0 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<template #trigger>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="statusBadgeClass(row.primary_status)"
>
{{ statusLabel(row.primary_status) }}
</span>
</template>
<div class="space-y-2">
<div class="text-xs font-semibold text-gray-100">
{{ row.primary_model }}
<span
class="ml-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium"
:class="statusBadgeClass(row.primary_status)"
>
{{ statusLabel(row.primary_status) }}
</span>
</div>
<div v-if="(row.extra_models?.length ?? 0) === 0" class="text-[11px] text-gray-300">
{{ t('monitorCommon.extraModelsEmpty') }}
</div>
<div v-else class="space-y-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-400">
{{ t('monitorCommon.extraModelsHeader') }}
</div>
<table class="w-full text-left text-[11px]">
<thead>
<tr class="text-gray-400">
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.model') }}</th>
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.latestStatus') }}</th>
<th class="py-0.5 font-medium">{{ t('channelStatus.detailColumns.latestLatency') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in row.extra_models" :key="m.model">
<td class="py-0.5 pr-2 text-gray-100">{{ m.model }}</td>
<td class="py-0.5 pr-2">
<span
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px]"
:class="statusBadgeClass(m.status)"
>
{{ statusLabel(m.status) }}
</span>
</td>
<td class="py-0.5 text-gray-100">{{ formatLatency(m.latency_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</HelpTooltip>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { UserMonitorView } from '@/api/channelMonitor'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
row: UserMonitorView
}>()
const { t } = useI18n()
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="mt-3 flex items-end justify-between">
<div class="text-[11px] uppercase tracking-widest text-gray-400">
{{ windowLabel }}
</div>
<div class="flex items-baseline gap-0.5">
<span
class="text-3xl font-bold tabular-nums leading-none"
:style="colorStyle"
>
{{ displayValue }}
</span>
<span
class="text-base font-semibold leading-none"
:style="colorStyle"
>%</span>
</div>
</div>
<div
v-if="samplesLabel"
class="mt-1 text-[11px] text-gray-400 text-right"
>
{{ samplesLabel }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hslForPct } from '@/composables/useChannelMonitorFormat'
const props = defineProps<{
windowLabel: string
value: number | null
samplesLabel?: string
}>()
const { t } = useI18n()
const displayValue = computed(() => {
if (props.value === null || Number.isNaN(props.value)) return t('monitorCommon.latencyEmpty')
return props.value.toFixed(2)
})
const colorStyle = computed(() => {
const colour = hslForPct(props.value)
return colour ? { color: colour } : { color: 'rgb(156 163 175)' }
})
</script>

View File

@@ -0,0 +1,128 @@
<template>
<button
type="button"
class="group text-left p-5 rounded-2xl min-h-[280px] w-full bg-white/70 backdrop-blur-xl border border-gray-200/80 shadow-card dark:bg-dark-800/60 dark:border-dark-700/70 hover:-translate-y-1 hover:shadow-card-hover dark:hover:border-primary-500/30 hover:border-gray-300 transition-all duration-300 ease-out flex flex-col"
@click="emit('click')"
>
<!-- Header: icon + name/model + status chip -->
<div class="flex items-start gap-3">
<span
class="w-9 h-9 rounded-xl ring-1 ring-black/5 dark:ring-white/10 grid place-items-center flex-shrink-0"
:class="[providerGradient(item.provider), providerTintClass]"
>
<ProviderIcon :provider="item.provider" :size="20" />
</span>
<div class="flex-1 min-w-0">
<div class="text-base font-semibold truncate text-gray-900 dark:text-gray-100">
{{ item.name }}
</div>
<div class="mt-0.5 flex items-center gap-1.5 min-w-0">
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0"
:class="providerBadgeClass(item.provider)"
>
{{ providerLabel(item.provider) }}
</span>
<span class="font-mono text-xs truncate text-gray-500 dark:text-gray-400">
{{ item.primary_model }}
</span>
<span
v-if="item.group_name"
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300 flex-shrink-0"
>
{{ item.group_name }}
</span>
</div>
</div>
<span
class="px-2.5 py-1 rounded-full text-xs font-semibold flex-shrink-0"
:class="statusBadgeClass(item.primary_status)"
>
{{ statusLabel(item.primary_status) }}
</span>
</div>
<!-- Metrics -->
<MonitorMetricPair
primary-icon="bolt"
:primary-label="t('monitorCommon.dialogLatency')"
:primary-value="formatLatency(item.primary_latency_ms)"
primary-unit="ms"
secondary-icon="globe"
:secondary-label="t('monitorCommon.endpointPing')"
:secondary-value="formatLatency(item.primary_ping_latency_ms)"
secondary-unit="ms"
/>
<!-- Divider -->
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
<!-- Availability row -->
<MonitorAvailabilityRow
:window-label="availabilityLabel"
:value="availabilityValue"
:samples-label="extraModelsCountLabel"
/>
<!-- Timeline -->
<MonitorTimeline
:buckets="item.timeline"
:countdown-seconds="countdownSeconds"
/>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { UserMonitorView } from '@/api/channelMonitor'
import {
useChannelMonitorFormat,
providerGradient,
} from '@/composables/useChannelMonitorFormat'
import ProviderIcon from './ProviderIcon.vue'
import MonitorMetricPair from './MonitorMetricPair.vue'
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
import MonitorTimeline from './MonitorTimeline.vue'
const PROVIDER_TINT: Record<string, string> = {
openai: 'text-emerald-600 dark:text-emerald-300',
anthropic: 'text-orange-600 dark:text-orange-300',
gemini: 'text-sky-600 dark:text-sky-300',
}
const props = defineProps<{
item: UserMonitorView
window: '7d' | '15d' | '30d'
availabilityValue: number | null
countdownSeconds: number
}>()
const emit = defineEmits<{
(e: 'click'): void
}>()
const { t } = useI18n()
const {
statusLabel,
statusBadgeClass,
providerLabel,
providerBadgeClass,
formatLatency,
} = useChannelMonitorFormat()
const providerTintClass = computed(() =>
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
)
const availabilityLabel = computed(() => {
const win = t(`channelStatus.windowTab.${props.window}`)
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
})
const extraModelsCountLabel = computed(() => {
const count = props.item.extra_models?.length ?? 0
if (count === 0) return undefined
return t('monitorCommon.extraModelsCount', { n: count })
})
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div>
<div
v-if="loading && items.length === 0"
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<div
v-for="i in 6"
:key="i"
class="p-5 rounded-2xl min-h-[280px] bg-white/70 dark:bg-dark-800/60 border border-gray-200/80 dark:border-dark-700/70 animate-pulse"
>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-2/3 rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-1/2 rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
<div class="h-6 w-16 rounded-full bg-gray-200 dark:bg-dark-700"></div>
</div>
<div class="mt-5 grid grid-cols-2 gap-2">
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
</div>
<div class="mt-6 h-5 w-full rounded bg-gray-100 dark:bg-dark-900/40"></div>
</div>
</div>
<EmptyState
v-else-if="items.length === 0"
:title="t('channelStatus.empty.title')"
:description="t('channelStatus.empty.description')"
/>
<div
v-else
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<MonitorCard
v-for="item in items"
:key="item.id"
:item="item"
:window="window"
:availability-value="resolveAvailability(item)"
:countdown-seconds="countdownSeconds"
@click="emit('cardClick', item)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { UserMonitorView, UserMonitorDetail } from '@/api/channelMonitor'
import EmptyState from '@/components/common/EmptyState.vue'
import MonitorCard from './MonitorCard.vue'
const props = defineProps<{
items: UserMonitorView[]
window: '7d' | '15d' | '30d'
countdownSeconds: number
loading: boolean
detailCache: Record<number, UserMonitorDetail>
}>()
const emit = defineEmits<{
(e: 'cardClick', item: UserMonitorView): void
}>()
const { t } = useI18n()
function resolveAvailability(item: UserMonitorView): number | null {
if (props.window === '7d') {
return item.availability_7d ?? null
}
const detail = props.detailCache[item.id]
if (!detail) return null
const primary = detail.models.find(m => m.model === item.primary_model)
if (!primary) return null
return props.window === '15d' ? primary.availability_15d ?? null : primary.availability_30d ?? null
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<section class="pt-8 pb-10 md:pb-14">
<div class="text-xs font-medium tracking-widest uppercase text-gray-400 dark:text-gray-500 mb-4">
{{ t('channelStatus.hero.breadcrumb') }}
</div>
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<h1
class="text-5xl md:text-6xl xl:text-7xl font-bold leading-[1.05] tracking-tight text-gray-900 dark:text-gray-50"
>
{{ t('channelStatus.hero.title') }}
</h1>
<p class="mt-4 text-sm md:text-base text-gray-500 dark:text-gray-400 max-w-xl">
{{ t('channelStatus.hero.subtitleZh') }}
</p>
<p class="mt-1 text-xs md:text-sm italic opacity-80 text-gray-500 dark:text-gray-400 max-w-xl">
{{ t('channelStatus.hero.subtitleEn') }}
</p>
</div>
<div class="flex flex-col items-start md:items-end gap-2.5">
<div
role="tablist"
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
>
<button
v-for="opt in windowOptions"
:key="opt.value"
type="button"
role="tab"
:aria-selected="window === opt.value"
class="px-3 py-1.5 rounded-lg transition-colors"
:class="window === opt.value
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:window', opt.value)"
>
{{ opt.label }}
</button>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
:class="overallChipClass"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="overallDotClass"
></span>
{{ overallLabel }}
</span>
<button
type="button"
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
:disabled="loading"
:title="t('common.refresh')"
@click="emit('refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums text-right">
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
export type MonitorWindow = '7d' | '15d' | '30d'
export type OverallStatus = 'operational' | 'degraded' | 'unavailable'
const props = defineProps<{
overallStatus: OverallStatus
updatedAt: string | null
intervalSeconds: number
window: MonitorWindow
loading: boolean
}>()
const emit = defineEmits<{
(e: 'update:window', value: MonitorWindow): void
(e: 'refresh'): void
}>()
const { t } = useI18n()
const { formatRelativeTime } = useChannelMonitorFormat()
const windowOptions = computed<{ value: MonitorWindow; label: string }[]>(() => [
{ value: '7d', label: t('channelStatus.windowTab.7d') },
{ value: '15d', label: t('channelStatus.windowTab.15d') },
{ value: '30d', label: t('channelStatus.windowTab.30d') },
])
const overallLabel = computed(() => t(`channelStatus.overall.${props.overallStatus}`))
const overallChipClass = computed(() => {
switch (props.overallStatus) {
case 'operational':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
case 'degraded':
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
case 'unavailable':
default:
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
}
})
const overallDotClass = computed(() => {
switch (props.overallStatus) {
case 'operational':
return 'bg-emerald-500 animate-pulse'
case 'degraded':
return 'bg-amber-500 animate-pulse'
case 'unavailable':
default:
return 'bg-red-500 animate-pulse'
}
})
const updatedLabel = computed(() => {
if (!props.updatedAt) return t('monitorCommon.updatedAt', { time: '--' })
return t('monitorCommon.updatedAt', { time: formatRelativeTime(props.updatedAt) })
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="mt-5 grid grid-cols-2 gap-2">
<div
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
>
<Icon :name="primaryIcon" size="xs" />
<span>{{ primaryLabel }}</span>
</div>
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
{{ primaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ primaryUnit }}</span>
</div>
</div>
<div
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
>
<Icon :name="secondaryIcon" size="xs" />
<span>{{ secondaryLabel }}</span>
</div>
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
{{ secondaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ secondaryUnit }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Icon from '@/components/icons/Icon.vue'
defineProps<{
primaryLabel: string
primaryValue: string
primaryUnit: string
primaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
secondaryLabel: string
secondaryValue: string
secondaryUnit: string
secondaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
}>()
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="mt-4 pt-3 border-t border-gray-100 dark:border-dark-700/60">
<div
class="flex justify-between text-[10px] font-semibold uppercase tracking-widest text-gray-400 mb-2"
>
<span>{{ t('monitorCommon.history60pts', { n: length }) }}</span>
<span class="tabular-nums">{{ t('monitorCommon.nextUpdateIn', { n: countdownSeconds }) }}</span>
</div>
<div
v-if="maintenance"
class="flex h-5 w-full items-center justify-center rounded border border-dashed border-gray-300 dark:border-dark-600 text-[10px] uppercase tracking-widest text-gray-400"
>
{{ t('monitorCommon.maintenancePaused') }}
</div>
<div v-else class="flex items-end gap-[2px] h-5 w-full">
<div
v-for="(bar, idx) in displayBars"
:key="idx"
class="flex-1 min-w-[3px] rounded-sm"
:class="bar.colorClass"
:style="{ height: bar.heightPct + '%' }"
:title="bar.title"
></div>
</div>
<div
class="mt-1 flex justify-between text-[9px] uppercase tracking-widest text-gray-400"
>
<span>{{ t('monitorCommon.past') }}</span>
<span>{{ t('monitorCommon.now') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MonitorTimelinePoint } from '@/api/channelMonitor'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
const props = withDefaults(defineProps<{
buckets?: MonitorTimelinePoint[]
countdownSeconds: number
length?: number
maintenance?: boolean
}>(), {
buckets: () => [],
length: 60,
maintenance: false,
})
const { t } = useI18n()
const { statusLabel, formatLatency, formatRelativeTime } = useChannelMonitorFormat()
interface Bar {
colorClass: string
heightPct: number
title: string
}
const STATUS_HEIGHT: Record<string, number> = {
operational: 100,
degraded: 70,
failed: 55,
error: 35,
empty: 20,
}
const STATUS_COLOR: Record<string, string> = {
operational: 'bg-emerald-500',
degraded: 'bg-amber-500',
failed: 'bg-red-500',
error: 'bg-gray-400 dark:bg-dark-500',
empty: 'bg-gray-300 dark:bg-dark-600',
}
const displayBars = computed<Bar[]>(() => {
// Real points come newest-first; convert to oldest-first so the rightmost
// bar represents "now". Pad the left with empty placeholders to keep the
// bar count stable at `length`.
const real = [...(props.buckets ?? [])]
.slice(0, props.length)
.reverse()
const padCount = Math.max(0, props.length - real.length)
const bars: Bar[] = []
for (let i = 0; i < padCount; i += 1) {
bars.push({
colorClass: STATUS_COLOR.empty,
heightPct: STATUS_HEIGHT.empty,
title: '',
})
}
for (const point of real) {
const status = point.status as keyof typeof STATUS_HEIGHT
const colorClass = STATUS_COLOR[status] ?? STATUS_COLOR.empty
const heightPct = STATUS_HEIGHT[status] ?? STATUS_HEIGHT.empty
const latency = formatLatency(point.latency_ms)
const relative = formatRelativeTime(point.checked_at)
const label = statusLabel(point.status)
bars.push({
colorClass,
heightPct,
title: `${relative} · ${label} · ${latency}ms`,
})
}
return bars
})
</script>

View File

@@ -0,0 +1,71 @@
<template>
<svg
v-if="iconInfo"
:width="size"
:height="size"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
fill-rule="evenodd"
aria-hidden="true"
>
<path
v-for="(p, idx) in iconInfo.paths"
:key="idx"
:d="p"
/>
</svg>
<span
v-else
class="inline-flex items-center justify-center font-bold text-gray-500"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
>
{{ fallbackText }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Provider } from '@/api/admin/channelMonitor'
interface IconData {
paths: string[]
}
// Provider SVG paths extracted from src/components/common/ModelIcon.vue (which
// in turn pulls from @lobehub/icons Mono.js). Keep in sync if upstream changes.
// SVG uses fill="currentColor" so the wrapper controls the icon tint.
const PROVIDER_ICONS: Record<Provider, IconData> = {
openai: {
paths: [
'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z',
],
},
anthropic: {
paths: [
'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z',
],
},
gemini: {
paths: [
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
],
},
}
const props = withDefaults(defineProps<{
provider: Provider | string
size?: number
}>(), {
size: 20,
})
const iconInfo = computed<IconData | null>(() => {
const key = props.provider as Provider
return PROVIDER_ICONS[key] ?? null
})
const fallbackText = computed(() =>
(props.provider || '?').charAt(0).toUpperCase()
)
</script>