mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge remote-tracking branch 'origin/aether-rust-pioneer' into aether-rust-pioneer
# Conflicts: # crates/aether-data-contracts/src/repository/usage/mod.rs # crates/aether-data/src/repository/global_models/postgres.rs # crates/aether-data/src/repository/usage/postgres/mod.rs
This commit is contained in:
@@ -692,6 +692,7 @@ interface ModelProviderDisplay {
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
supports_extended_thinking?: boolean | null
|
||||
supports_embedding?: boolean | null
|
||||
}
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
@@ -766,6 +767,7 @@ const editingProviderModel = computed<Model | null>(() => {
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_extended_thinking: p.supports_extended_thinking,
|
||||
supports_embedding: p.supports_embedding,
|
||||
is_active: p.is_active,
|
||||
global_model_display_name: selectedModel.value?.display_name,
|
||||
} as Model
|
||||
@@ -1180,7 +1182,8 @@ async function loadModelProviders(_globalModelId: string) {
|
||||
// 能力信息
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
supports_streaming: p.supports_streaming,
|
||||
supports_embedding: p.supports_embedding
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== modelProvidersRequestId) return
|
||||
|
||||
@@ -491,6 +491,183 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="space-y-4 p-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Provider 性能
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ providerPerformanceSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
Top {{ providerPerformanceRows.length || 0 }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="providerPerformanceLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="card in providerPerformanceSummaryCards"
|
||||
:key="card.title"
|
||||
class="rounded-xl border border-border/70 bg-card/70 px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">{{ card.title }}</span>
|
||||
<component
|
||||
:is="card.icon"
|
||||
class="h-4 w-4"
|
||||
:class="card.iconClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-2xl font-semibold tracking-tight">
|
||||
{{ card.value }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
{{ card.hint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="providerPerformanceRows.length"
|
||||
class="overflow-x-auto rounded-lg border border-border/70"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-border/70 text-sm">
|
||||
<thead class="bg-muted/30 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
Provider
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
请求
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
成功率
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
输出 TPS
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
平均首字
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
平均响应
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
P90 响应 / 首字
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
样本
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr
|
||||
v-for="provider in providerPerformanceRows"
|
||||
:key="provider.provider_id"
|
||||
class="bg-background/40"
|
||||
>
|
||||
<td class="max-w-[220px] px-3 py-2">
|
||||
<div class="truncate font-medium">
|
||||
{{ provider.provider }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ provider.provider_id }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatMetricNumber(provider.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.success_rate, '%') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.avg_output_tps, '/s') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.avg_response_time_ms, 'ms') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.p90_response_time_ms, 'ms', 0) }}
|
||||
/
|
||||
{{ formatProviderPerformanceMetric(provider.p90_first_byte_time_ms, 'ms', 0) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-xs text-muted-foreground">
|
||||
{{ formatMetricNumber(provider.tps_sample_count) }} /
|
||||
{{ formatMetricNumber(provider.first_byte_sample_count) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有 Provider 性能数据。
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="space-y-3 p-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
输出 TPS 趋势
|
||||
</h3>
|
||||
<div
|
||||
v-if="providerPerformanceLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
>
|
||||
<LineChart
|
||||
:data="providerTpsChartData"
|
||||
:options="providerTpsChartOptions"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="space-y-3 p-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
平均首字趋势
|
||||
</h3>
|
||||
<div
|
||||
v-if="providerPerformanceLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
>
|
||||
<LineChart
|
||||
:data="providerFirstByteChartData"
|
||||
:options="providerLatencyChartOptions"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="p-4">
|
||||
<ErrorDistributionChart
|
||||
@@ -562,11 +739,20 @@ import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Cable,
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
Gauge,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
|
||||
import {
|
||||
adminApi,
|
||||
type ErrorDistributionResponse,
|
||||
type PercentileItem,
|
||||
type ProviderPerformanceResponse,
|
||||
} from '@/api/admin'
|
||||
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
|
||||
import {
|
||||
monitoringApi,
|
||||
@@ -586,6 +772,10 @@ import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import { formatDate, formatNumber, formatTokens } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
buildProviderPerformanceChartData,
|
||||
formatProviderPerformanceMetric,
|
||||
} from './performanceAnalysisHelpers'
|
||||
|
||||
const LIVE_REFRESH_INTERVAL_MS = 10_000
|
||||
|
||||
@@ -601,6 +791,8 @@ const errorLoading = ref(false)
|
||||
|
||||
const providerStatus = ref<ProviderStatus[]>([])
|
||||
const providerLoading = ref(false)
|
||||
const providerPerformance = ref<ProviderPerformanceResponse | null>(null)
|
||||
const providerPerformanceLoading = ref(false)
|
||||
|
||||
const systemStatus = ref<AdminMonitoringSystemStatus | null>(null)
|
||||
const resilienceStatus = ref<AdminMonitoringResilienceStatus | null>(null)
|
||||
@@ -615,6 +807,7 @@ const liveLastUpdatedAt = ref<string | null>(null)
|
||||
let percentilesRequestId = 0
|
||||
let errorsRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let providerPerformanceRequestId = 0
|
||||
let liveRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
@@ -699,6 +892,28 @@ async function loadProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderPerformance() {
|
||||
const requestId = ++providerPerformanceRequestId
|
||||
providerPerformanceLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.getProviderPerformance({
|
||||
...buildTimeRangeParams(),
|
||||
granularity: 'day',
|
||||
limit: 8,
|
||||
})
|
||||
if (requestId !== providerPerformanceRequestId) return
|
||||
providerPerformance.value = data
|
||||
} catch (error) {
|
||||
if (requestId !== providerPerformanceRequestId) return
|
||||
providerPerformance.value = null
|
||||
log.error('加载 Provider 性能统计失败', error)
|
||||
} finally {
|
||||
if (requestId === providerPerformanceRequestId) {
|
||||
providerPerformanceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLiveData(options: { silent?: boolean } = {}) {
|
||||
const requestId = ++liveRequestId
|
||||
const initialLoad = !liveReady.value
|
||||
@@ -866,6 +1081,83 @@ const fallbackRows = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const providerPerformanceRows = computed(() => providerPerformance.value?.providers ?? [])
|
||||
|
||||
const providerPerformanceSubtitle = computed(() => {
|
||||
const requests = providerPerformance.value?.summary.request_count ?? 0
|
||||
return `完成窗口内 ${formatMetricNumber(requests)} 个 Provider 请求样本`
|
||||
})
|
||||
|
||||
const providerPerformanceSummaryCards = computed(() => {
|
||||
const summary = providerPerformance.value?.summary
|
||||
return [
|
||||
{
|
||||
title: '输出 TPS',
|
||||
value: formatProviderPerformanceMetric(summary?.avg_output_tps, '/s'),
|
||||
hint: `请求 ${formatMetricNumber(summary?.request_count)}`,
|
||||
icon: Zap,
|
||||
iconClass: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
title: '平均首字',
|
||||
value: formatProviderPerformanceMetric(summary?.avg_first_byte_time_ms, 'ms'),
|
||||
hint: '成功请求首字样本',
|
||||
icon: Timer,
|
||||
iconClass: 'text-sky-500',
|
||||
},
|
||||
{
|
||||
title: '平均响应',
|
||||
value: formatProviderPerformanceMetric(summary?.avg_response_time_ms, 'ms'),
|
||||
hint: '成功请求响应耗时',
|
||||
icon: Gauge,
|
||||
iconClass: 'text-violet-500',
|
||||
},
|
||||
{
|
||||
title: '成功率',
|
||||
value: formatProviderPerformanceMetric(summary?.success_rate, '%'),
|
||||
hint: `${formatMetricNumber(providerPerformanceRows.value.length)} 个 Provider`,
|
||||
icon: CheckCircle2,
|
||||
iconClass: 'text-emerald-500',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const providerTpsChartData = computed(() => (
|
||||
buildProviderPerformanceChartData(
|
||||
providerPerformance.value?.timeline ?? [],
|
||||
'avg_output_tps',
|
||||
providerPerformanceRows.value,
|
||||
)
|
||||
))
|
||||
|
||||
const providerFirstByteChartData = computed(() => (
|
||||
buildProviderPerformanceChartData(
|
||||
providerPerformance.value?.timeline ?? [],
|
||||
'avg_first_byte_time_ms',
|
||||
providerPerformanceRows.value,
|
||||
)
|
||||
))
|
||||
|
||||
const providerTpsChartOptions = computed(() => ({
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value}/s`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const providerLatencyChartOptions = computed(() => ({
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value}ms`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const liveSummaryCards = computed(() => [
|
||||
{
|
||||
title: '系统健康',
|
||||
@@ -920,7 +1212,8 @@ const isRefreshing = computed(() => (
|
||||
liveRefreshing.value ||
|
||||
percentileLoading.value ||
|
||||
errorLoading.value ||
|
||||
providerLoading.value
|
||||
providerLoading.value ||
|
||||
providerPerformanceLoading.value
|
||||
))
|
||||
|
||||
async function loadAll() {
|
||||
@@ -929,7 +1222,12 @@ async function loadAll() {
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||
loadAllPromise = Promise.all([
|
||||
loadPercentiles(),
|
||||
loadErrors(),
|
||||
loadProviders(),
|
||||
loadProviderPerformance(),
|
||||
])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
@@ -983,6 +1281,7 @@ onUnmounted(() => {
|
||||
percentilesRequestId += 1
|
||||
errorsRequestId += 1
|
||||
providersRequestId += 1
|
||||
providerPerformanceRequestId += 1
|
||||
liveRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildProviderPerformanceChartData,
|
||||
formatProviderPerformanceMetric,
|
||||
} from '../performanceAnalysisHelpers'
|
||||
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
|
||||
|
||||
describe('performanceAnalysisHelpers', () => {
|
||||
it('formats null provider metrics as placeholders', () => {
|
||||
expect(formatProviderPerformanceMetric(null, 'ms')).toBe('-')
|
||||
expect(formatProviderPerformanceMetric(undefined, '/s')).toBe('-')
|
||||
expect(formatProviderPerformanceMetric(Number.NaN)).toBe('-')
|
||||
expect(formatProviderPerformanceMetric(18.456, '/s')).toBe('18.46/s')
|
||||
})
|
||||
|
||||
it('builds stable provider trend datasets with null gaps', () => {
|
||||
const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [
|
||||
{ provider_id: 'provider-a', provider: 'OpenAI' },
|
||||
{ provider_id: 'provider-b', provider: 'Anthropic' },
|
||||
]
|
||||
const timeline: ProviderPerformanceTimelineItem[] = [
|
||||
{
|
||||
date: '2024-03-21',
|
||||
provider_id: 'provider-a',
|
||||
provider: 'OpenAI',
|
||||
request_count: 2,
|
||||
output_tokens: 100,
|
||||
avg_output_tps: 25,
|
||||
avg_first_byte_time_ms: 120,
|
||||
avg_response_time_ms: 1000,
|
||||
success_rate: 100,
|
||||
},
|
||||
{
|
||||
date: '2024-03-22',
|
||||
provider_id: 'provider-b',
|
||||
provider: 'Anthropic',
|
||||
request_count: 1,
|
||||
output_tokens: 40,
|
||||
avg_output_tps: null,
|
||||
avg_first_byte_time_ms: 220,
|
||||
avg_response_time_ms: 1500,
|
||||
success_rate: 100,
|
||||
},
|
||||
]
|
||||
|
||||
const chart = buildProviderPerformanceChartData(timeline, 'avg_output_tps', providers)
|
||||
|
||||
expect(chart.labels).toEqual(['2024-03-21', '2024-03-22'])
|
||||
expect(chart.datasets.map(dataset => dataset.label)).toEqual(['OpenAI', 'Anthropic'])
|
||||
expect(chart.datasets[0].data).toEqual([25, null])
|
||||
expect(chart.datasets[1].data).toEqual([null, null])
|
||||
})
|
||||
})
|
||||
64
frontend/src/views/admin/performanceAnalysisHelpers.ts
Normal file
64
frontend/src/views/admin/performanceAnalysisHelpers.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ChartData } from 'chart.js'
|
||||
import type {
|
||||
ProviderPerformanceItem,
|
||||
ProviderPerformanceTimelineItem,
|
||||
} from '@/api/admin'
|
||||
|
||||
export type ProviderPerformanceMetricKey = 'avg_output_tps' | 'avg_first_byte_time_ms'
|
||||
|
||||
const PROVIDER_CHART_COLORS = [
|
||||
'rgb(59, 130, 246)',
|
||||
'rgb(16, 185, 129)',
|
||||
'rgb(234, 179, 8)',
|
||||
'rgb(239, 68, 68)',
|
||||
'rgb(139, 92, 246)',
|
||||
'rgb(14, 165, 233)',
|
||||
'rgb(249, 115, 22)',
|
||||
'rgb(20, 184, 166)',
|
||||
]
|
||||
|
||||
export function formatProviderPerformanceMetric(
|
||||
value: number | null | undefined,
|
||||
suffix = '',
|
||||
decimals = 2
|
||||
): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return '-'
|
||||
}
|
||||
return `${value.toFixed(decimals)}${suffix}`
|
||||
}
|
||||
|
||||
export function buildProviderPerformanceChartData(
|
||||
timeline: ProviderPerformanceTimelineItem[],
|
||||
metric: ProviderPerformanceMetricKey,
|
||||
providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = []
|
||||
): ChartData<'line'> {
|
||||
const labels = Array.from(new Set(timeline.map(item => item.date)))
|
||||
const providerMap = new Map<string, string>()
|
||||
for (const provider of providers) {
|
||||
providerMap.set(provider.provider_id, provider.provider)
|
||||
}
|
||||
for (const item of timeline) {
|
||||
if (!providerMap.has(item.provider_id)) {
|
||||
providerMap.set(item.provider_id, item.provider)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: Array.from(providerMap.entries()).map(([providerId, provider], index) => {
|
||||
const byDate = new Map(
|
||||
timeline
|
||||
.filter(item => item.provider_id === providerId)
|
||||
.map(item => [item.date, item[metric]] as const)
|
||||
)
|
||||
return {
|
||||
label: provider,
|
||||
data: labels.map(label => byDate.get(label) ?? null),
|
||||
borderColor: PROVIDER_CHART_COLORS[index % PROVIDER_CHART_COLORS.length],
|
||||
tension: 0.25,
|
||||
pointRadius: 2,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,14 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium hover:text-primary transition-colors">{{ model.display_name || model.name }}</span>
|
||||
<Badge
|
||||
v-for="capability in getModelCapabilityLabels(model)"
|
||||
:key="capability"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ capability }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<span>{{ model.name }}</span>
|
||||
@@ -150,6 +158,16 @@
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium truncate block">{{ model.display_name || model.name }}</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<Badge
|
||||
v-for="capability in getModelCapabilityLabels(model)"
|
||||
:key="capability"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ capability }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<span class="truncate">{{ model.name }}</span>
|
||||
<button
|
||||
@@ -228,6 +246,7 @@ import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { getModelCapabilityLabels } from './model-catalog-helpers'
|
||||
|
||||
const { error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import { getModelCapabilityLabels, supportsEmbedding, supportsRerank } from '../model-catalog-helpers'
|
||||
|
||||
function model(overrides: Partial<PublicGlobalModel>): PublicGlobalModel {
|
||||
return {
|
||||
id: 'gm-test',
|
||||
name: 'model-test',
|
||||
display_name: 'Model Test',
|
||||
is_active: true,
|
||||
default_tiered_pricing: null,
|
||||
default_price_per_request: null,
|
||||
supported_capabilities: null,
|
||||
config: null,
|
||||
usage_count: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('model catalog embedding helpers', () => {
|
||||
it('labels embedding models distinctly from chat models', () => {
|
||||
expect(getModelCapabilityLabels(model({
|
||||
supported_capabilities: ['embedding'],
|
||||
config: { streaming: false, api_formats: ['openai:embedding'] },
|
||||
}))).toEqual(['Embedding'])
|
||||
|
||||
expect(getModelCapabilityLabels(model({
|
||||
config: { streaming: true },
|
||||
}))).toEqual(['Chat'])
|
||||
})
|
||||
|
||||
it('detects embedding metadata from explicit and config-derived frontend fields', () => {
|
||||
expect(supportsEmbedding(model({ supports_embedding: true }))).toBe(true)
|
||||
expect(supportsEmbedding(model({ config: { embedding: true } }))).toBe(true)
|
||||
expect(supportsEmbedding(model({ config: { model_type: 'embedding' } }))).toBe(true)
|
||||
expect(supportsEmbedding(model({ config: { api_formats: ['jina:embedding'] } }))).toBe(true)
|
||||
expect(supportsEmbedding(model({ config: { api_formats: ['openai:chat'] } }))).toBe(false)
|
||||
})
|
||||
|
||||
it('labels rerank models distinctly from chat and embedding models', () => {
|
||||
const rerank = model({
|
||||
supported_capabilities: ['rerank'],
|
||||
config: { streaming: false, api_formats: ['jina:rerank'] },
|
||||
})
|
||||
|
||||
expect(supportsRerank(rerank)).toBe(true)
|
||||
expect(getModelCapabilityLabels(rerank)).toEqual(['Rerank'])
|
||||
expect(supportsRerank(model({ config: { api_formats: ['openai:embedding'] } }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,23 @@
|
||||
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Database class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
Embedding
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
向量嵌入
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="supportsEmbedding(model) ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ supportsEmbedding(model) ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Eye class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
@@ -303,6 +320,7 @@ import {
|
||||
Zap,
|
||||
Copy,
|
||||
Layers,
|
||||
Database,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
@@ -376,6 +394,14 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
|
||||
function supportsEmbedding(model: PublicGlobalModel): boolean {
|
||||
return model.supports_embedding === true
|
||||
|| model.supported_capabilities?.includes('embedding') === true
|
||||
|| model.config?.embedding === true
|
||||
|| model.config?.model_type === 'embedding'
|
||||
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':embedding')))
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
|
||||
41
frontend/src/views/user/model-catalog-helpers.ts
Normal file
41
frontend/src/views/user/model-catalog-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
|
||||
export function supportsEmbedding(model: PublicGlobalModel): boolean {
|
||||
return model.supports_embedding === true
|
||||
|| model.supported_capabilities?.includes('embedding') === true
|
||||
|| model.config?.embedding === true
|
||||
|| model.config?.model_type === 'embedding'
|
||||
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':embedding')))
|
||||
}
|
||||
|
||||
export function supportsRerank(model: PublicGlobalModel): boolean {
|
||||
return model.supported_capabilities?.includes('rerank') === true
|
||||
|| model.config?.rerank === true
|
||||
|| model.config?.model_type === 'rerank'
|
||||
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':rerank')))
|
||||
}
|
||||
|
||||
export function hasVideoPricing(model: PublicGlobalModel): boolean {
|
||||
const billing = model.config?.billing
|
||||
const video = billing && typeof billing === 'object' && !Array.isArray(billing)
|
||||
? (billing as Record<string, unknown>).video
|
||||
: null
|
||||
const priceByResolution = video && typeof video === 'object' && !Array.isArray(video)
|
||||
? (video as Record<string, unknown>).price_per_second_by_resolution
|
||||
: null
|
||||
return !!priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
||||
}
|
||||
|
||||
export function getModelCapabilityLabels(model: PublicGlobalModel): string[] {
|
||||
const labels: string[] = []
|
||||
if (supportsRerank(model)) {
|
||||
labels.push('Rerank')
|
||||
} else if (supportsEmbedding(model)) {
|
||||
labels.push('Embedding')
|
||||
} else {
|
||||
labels.push('Chat')
|
||||
}
|
||||
if (model.config?.image_generation === true) labels.push('Image')
|
||||
if (hasVideoPricing(model)) labels.push('Video')
|
||||
return labels
|
||||
}
|
||||
Reference in New Issue
Block a user