refactor(models): 重构模型页面列表与详情排版
把客户端模型改为单行账本列表,目录信息、价格范围与路由分组下沉到 可折叠详情,消除多套价格下重复渲染的模型元数据。 - 新增 ModelCollection / ModelDetailPanel / ModelCatalogFacts / ModelSummaryStrip 与 model-presenter,替换 ModelTree* 树状组件 - 目录元数据改为规格标签值加少量能力 chip,取代成排标签堆叠 - 上游层复用 catalog_summary 渲染一次目录信息,不再逐价格重复 - 概览改为指标卡片条,目录状态与未使用价格记录归入其中 - 价格单位说明由整块提示降级为页脚小字
This commit is contained in:
12 files changed
+1422
-1107
No files matched your search
@@ -235,7 +235,9 @@ onBeforeUnmount(clearRequest)
|
||||
<li v-for="group in affectedGroups" :key="group.id">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span :data-enabled="group.enabled">
|
||||
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
|
||||
{{
|
||||
group.enabled ? t('models.detail.groupEnabled') : t('models.detail.groupDisabled')
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ModelCatalogReferenceDto } from '@/app/resources/models'
|
||||
import { formatInteger } from '@/lib/format'
|
||||
|
||||
const props = defineProps<{
|
||||
reference: ModelCatalogReferenceDto
|
||||
}>()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
interface CatalogSpec {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const metadata = computed(() => props.reference.model)
|
||||
|
||||
const sourceLabel = computed(() =>
|
||||
t(`models.detail.catalogReference.${props.reference.source}`, {
|
||||
provider: props.reference.provider_name,
|
||||
}),
|
||||
)
|
||||
|
||||
const specs = computed<CatalogSpec[]>(() => {
|
||||
const model = metadata.value
|
||||
const entries: CatalogSpec[] = []
|
||||
const push = (key: string, value: string): void => {
|
||||
if (value) entries.push({ key, label: t(`models.detail.specs.${key}`), value })
|
||||
}
|
||||
push(
|
||||
'context',
|
||||
model.limits.context === null ? '' : formatInteger(model.limits.context, locale.value),
|
||||
)
|
||||
push(
|
||||
'maxOutput',
|
||||
model.limits.output === null ? '' : formatInteger(model.limits.output, locale.value),
|
||||
)
|
||||
push('modalities', formatModalities())
|
||||
push('knowledge', model.knowledge)
|
||||
push('released', model.release_date)
|
||||
push('family', model.family)
|
||||
return entries
|
||||
})
|
||||
|
||||
const capabilities = computed(() =>
|
||||
(['reasoning', 'tool_call', 'structured_output', 'attachment', 'temperature'] as const)
|
||||
.filter((capability) => metadata.value.capabilities[capability] === true)
|
||||
.map((capability) => t(`models.detail.capabilities.${capability}`))
|
||||
.concat(metadata.value.open_weights === true ? [t('models.detail.openWeights')] : []),
|
||||
)
|
||||
|
||||
function formatModalities(): string {
|
||||
const { input, output } = metadata.value.modalities
|
||||
if (input.length === 0 && output.length === 0) return ''
|
||||
const arrow = output.length > 0 ? `→ ${output.join(' / ')}` : ''
|
||||
return `${input.join(' / ')} ${arrow}`.trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-catalog">
|
||||
<p class="model-catalog__source">
|
||||
<span>{{ sourceLabel }}</span>
|
||||
<strong>{{ metadata.name }}</strong>
|
||||
</p>
|
||||
<p v-if="metadata.description" class="model-catalog__description">
|
||||
{{ metadata.description }}
|
||||
</p>
|
||||
<dl v-if="specs.length > 0" class="model-catalog__specs">
|
||||
<div v-for="spec in specs" :key="spec.key">
|
||||
<dt>{{ spec.label }}</dt>
|
||||
<dd>{{ spec.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ul v-if="capabilities.length > 0" class="model-catalog__capabilities">
|
||||
<li v-for="capability in capabilities" :key="capability">{{ capability }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-catalog {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-catalog__source {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-catalog__source span {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-catalog__source strong {
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-catalog__description {
|
||||
max-width: 92ch;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--line-normal);
|
||||
}
|
||||
|
||||
.model-catalog__specs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, max-content));
|
||||
gap: var(--space-2) var(--space-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-catalog__specs div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.model-catalog__specs dt {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-catalog__specs dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-catalog__capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.model-catalog__capabilities li {
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 7px;
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Pencil } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ClientModelDto, ModelPriceBranchDto } from '@/app/resources/models'
|
||||
import { groupDetailLocation } from '@/app/route-locations'
|
||||
import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
|
||||
import CopyButton from '@/components/ui/CopyButton.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
import ModelDetailPanel from './ModelDetailPanel.vue'
|
||||
import {
|
||||
presentClientModel,
|
||||
type ClientModelPresentation,
|
||||
type ModelPriceFieldSummary,
|
||||
} from './model-presenter'
|
||||
|
||||
const visibleGroupCount = 3
|
||||
|
||||
const props = defineProps<{
|
||||
items: ClientModelDto[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
edit: [branch: ModelPriceBranchDto, trigger: HTMLElement]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const expanded = ref(new Set<string>())
|
||||
const presentations = computed<ClientModelPresentation[]>(() =>
|
||||
props.items.map((model) => presentClientModel(model)),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(items) => {
|
||||
const available = new Set(items.map(({ client_model: model }) => model))
|
||||
expanded.value = new Set([...expanded.value].filter((model) => available.has(model)))
|
||||
},
|
||||
)
|
||||
|
||||
function toggle(clientModel: string): void {
|
||||
const next = new Set(expanded.value)
|
||||
if (!next.delete(clientModel)) next.add(clientModel)
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
function upstreamCaption(presentation: ClientModelPresentation): string {
|
||||
const { shape, modelID, count } = presentation.upstream
|
||||
if (shape === 'multi') return t('models.collection.multiUpstream', { count })
|
||||
if (shape === 'alias') return t('models.collection.alias', { model: modelID })
|
||||
return t('models.collection.direct')
|
||||
}
|
||||
|
||||
function priceText(summary: ModelPriceFieldSummary): string {
|
||||
if (summary.state === 'unavailable') return t('modelPrices.values.unavailable')
|
||||
if (summary.state === 'free') return t('modelPrices.values.free')
|
||||
const value = t('modelPrices.values.configured', { value: summary.value })
|
||||
if (summary.state === 'single') return value
|
||||
return t('models.collection.priceRange', {
|
||||
min: value,
|
||||
max: t('modelPrices.values.configured', { value: summary.upper }),
|
||||
})
|
||||
}
|
||||
|
||||
function scopeCaption(presentation: ClientModelPresentation): string | null {
|
||||
if (presentation.scopes.length < 2) return null
|
||||
return presentation.pendingCount > 0
|
||||
? t('models.collection.pendingScopes', {
|
||||
pending: presentation.pendingCount,
|
||||
count: presentation.scopes.length,
|
||||
})
|
||||
: t('models.collection.scopeCount', { count: presentation.scopes.length })
|
||||
}
|
||||
|
||||
function forwardEdit(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
|
||||
emit('edit', branch, trigger)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LedgerRecordList
|
||||
:label="t('models.collection.label')"
|
||||
grid-class="models-record-grid"
|
||||
:scroll-hint="t('models.collection.scrollHint')"
|
||||
>
|
||||
<template #header>
|
||||
<span role="columnheader">{{ t('models.collection.columns.model') }}</span>
|
||||
<span role="columnheader">{{ t('models.collection.columns.routing') }}</span>
|
||||
<span role="columnheader">{{ t('models.collection.columns.prices') }}</span>
|
||||
<span role="columnheader">{{ t('models.collection.columns.status') }}</span>
|
||||
<span role="columnheader">
|
||||
<span class="sr-only">{{ t('models.collection.columns.actions') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-for="presentation in presentations" :key="presentation.model.client_model">
|
||||
<article
|
||||
class="ledger-record-list__record model-record"
|
||||
:class="{ 'model-record--expanded': expanded.has(presentation.model.client_model) }"
|
||||
role="row"
|
||||
>
|
||||
<div class="ledger-record-list__cell model-record__identity" role="cell">
|
||||
<div class="model-record__name">
|
||||
<strong>{{ presentation.model.client_model }}</strong>
|
||||
<CopyButton
|
||||
class="model-record__copy"
|
||||
:value="presentation.model.client_model"
|
||||
:label="t('models.collection.copy', { model: presentation.model.client_model })"
|
||||
:success-label="t('models.collection.copySucceeded')"
|
||||
:failure-label="t('models.collection.copyFailed')"
|
||||
/>
|
||||
</div>
|
||||
<span class="model-record__caption">{{ upstreamCaption(presentation) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ledger-record-list__cell model-record__routing" role="cell">
|
||||
<ul class="model-record__groups">
|
||||
<li
|
||||
v-for="group in presentation.routeGroups.slice(0, visibleGroupCount)"
|
||||
:key="group.id"
|
||||
>
|
||||
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
|
||||
</li>
|
||||
<li
|
||||
v-if="presentation.routeGroups.length > visibleGroupCount"
|
||||
class="model-record__groups-more"
|
||||
>
|
||||
{{
|
||||
t('models.collection.moreGroups', {
|
||||
count: presentation.routeGroups.length - visibleGroupCount,
|
||||
})
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="model-record__protocols">
|
||||
<li v-for="protocol in presentation.model.protocols" :key="protocol">
|
||||
{{ t(`common.protocols.${protocol}`) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ledger-record-list__cell model-record__prices" role="cell">
|
||||
<div v-for="summary in [presentation.input, presentation.output]" :key="summary.field">
|
||||
<span>{{ t(`modelPrices.fields.${summary.field}`) }}</span>
|
||||
<strong :data-state="summary.state" :title="priceText(summary)">
|
||||
{{ priceText(summary) }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ledger-record-list__cell model-record__status" role="cell">
|
||||
<StatusBadge
|
||||
size="compact"
|
||||
:tone="presentation.status === 'configured' ? 'success' : 'warning'"
|
||||
>
|
||||
{{ t(`modelPrices.status.${presentation.status}`) }}
|
||||
</StatusBadge>
|
||||
<small v-if="scopeCaption(presentation)">{{ scopeCaption(presentation) }}</small>
|
||||
</div>
|
||||
|
||||
<div class="ledger-record-list__cell model-record__actions" role="cell">
|
||||
<IconButton
|
||||
v-if="presentation.soleBranch"
|
||||
variant="ghost"
|
||||
tone="action"
|
||||
size="compact"
|
||||
:label="t('modelPrices.edit.open', { model: presentation.soleBranch.price.model_id })"
|
||||
@click="forwardEdit(presentation.soleBranch, $event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<Pencil :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="model-record__toggle"
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
:pressed="expanded.has(presentation.model.client_model)"
|
||||
:label="
|
||||
expanded.has(presentation.model.client_model)
|
||||
? t('models.collection.collapse', { model: presentation.model.client_model })
|
||||
: t('models.collection.expand', { model: presentation.model.client_model })
|
||||
"
|
||||
@click="toggle(presentation.model.client_model)"
|
||||
>
|
||||
<ChevronDown :size="16" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-if="expanded.has(presentation.model.client_model)"
|
||||
class="model-record__detail"
|
||||
role="row"
|
||||
>
|
||||
<div class="model-record__detail-cell" role="cell">
|
||||
<ModelDetailPanel :presentation="presentation" @edit="forwardEdit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</LedgerRecordList>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.models-record-grid {
|
||||
--ledger-record-list-grid: minmax(210px, 1.5fr) minmax(180px, 1.35fr) minmax(190px, 1.15fr)
|
||||
minmax(112px, 132px) 84px;
|
||||
--ledger-record-list-column-gap: var(--space-4);
|
||||
--ledger-record-list-record-min-height: 64px;
|
||||
--ledger-record-list-record-padding: var(--space-2-5) 0;
|
||||
}
|
||||
|
||||
.model-record--expanded {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.model-record__identity {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.model-record__name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-record__name strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-record__copy {
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.model-record:hover .model-record__copy,
|
||||
.model-record__copy:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.model-record__caption {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-record__routing {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-record__groups,
|
||||
.model-record__protocols {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.model-record__groups {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.model-record__groups a {
|
||||
color: var(--color-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-record__groups-more {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-record__protocols {
|
||||
gap: 2px var(--space-1);
|
||||
}
|
||||
|
||||
.model-record__protocols li {
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-text-faint);
|
||||
padding: 1px 6px;
|
||||
font-size: var(--text-label-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-record__prices {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-record__prices > div {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
padding-left: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-record__prices > div:first-child {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-record__prices span {
|
||||
display: block;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-record__prices strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 1px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-record__prices strong[data-state='unavailable'] {
|
||||
color: var(--color-text-faint);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-record__prices strong[data-state='free'] {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.model-record__status {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.model-record__status small {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-record__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-0-5);
|
||||
}
|
||||
|
||||
.model-record__toggle :deep(svg) {
|
||||
transition: transform var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.model-record__toggle[aria-pressed='true'] :deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.model-record__detail {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.model-record__detail-cell {
|
||||
min-width: 0;
|
||||
border-top: 1px dashed var(--color-border-control);
|
||||
padding: var(--space-4) 0 var(--space-5);
|
||||
animation: model-record-detail var(--duration-normal) var(--easing-data);
|
||||
}
|
||||
|
||||
@keyframes model-record-detail {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.model-record__detail-cell {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.models-record-grid {
|
||||
--ledger-record-list-grid: minmax(180px, 1.4fr) minmax(150px, 1.2fr) minmax(170px, 1.1fr)
|
||||
minmax(104px, 120px) 76px;
|
||||
--ledger-record-list-column-gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.models-record-grid {
|
||||
--ledger-record-list-card-grid: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.model-record__identity {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.model-record__actions {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.model-record__copy {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.model-record__routing {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.model-record__prices {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.model-record__status {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
.model-record__detail {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.model-record__detail-cell {
|
||||
border-top: 0;
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ModelPriceBranchDto, ModelUpstreamDto } from '@/app/resources/models'
|
||||
import { groupDetailLocation } from '@/app/route-locations'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import ModelPriceResetDialog from '@/features/model-prices/ModelPriceResetDialog.vue'
|
||||
import { presentModelPrice } from '@/features/model-prices/model-price-presenter'
|
||||
|
||||
import ModelCatalogFacts from './ModelCatalogFacts.vue'
|
||||
import { hasWiderPriceImpact, type ClientModelPresentation } from './model-presenter'
|
||||
|
||||
const props = defineProps<{
|
||||
presentation: ClientModelPresentation
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
edit: [branch: ModelPriceBranchDto, trigger: HTMLElement]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
function slots(branch: ModelPriceBranchDto) {
|
||||
return presentModelPrice(branch.price, {
|
||||
unavailable: t('modelPrices.values.unavailable'),
|
||||
free: t('modelPrices.values.free'),
|
||||
configured: (value) => t('modelPrices.values.configured', { value }),
|
||||
}).slots
|
||||
}
|
||||
|
||||
function methodLabel(branch: ModelPriceBranchDto): string {
|
||||
const { method, matched_provider_id: provider } = branch.price
|
||||
if (method === null) return t('modelPrices.method.pending')
|
||||
if (method === 'auto_matched') return t('modelPrices.method.auto_matched', { provider })
|
||||
return t(`modelPrices.method.${method}`)
|
||||
}
|
||||
|
||||
function showUpstreamHeader(upstream: ModelUpstreamDto): boolean {
|
||||
return props.presentation.upstream.shape !== 'direct' || upstream.alias_applied
|
||||
}
|
||||
|
||||
function edit(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
|
||||
emit('edit', branch, trigger)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-detail">
|
||||
<section
|
||||
v-for="upstream in presentation.model.upstream_models"
|
||||
:key="upstream.model_id"
|
||||
class="model-detail__upstream"
|
||||
>
|
||||
<header v-if="showUpstreamHeader(upstream)" class="model-detail__upstream-head">
|
||||
<span class="model-detail__eyebrow">{{ t('models.detail.upstreamModel') }}</span>
|
||||
<strong>{{ upstream.model_id }}</strong>
|
||||
<span class="model-detail__chip">
|
||||
{{ upstream.alias_applied ? t('models.detail.aliasApplied') : t('models.detail.direct') }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ModelCatalogFacts v-if="upstream.catalog_summary" :reference="upstream.catalog_summary" />
|
||||
|
||||
<article v-for="branch in upstream.prices" :key="branch.price.id" class="model-detail__scope">
|
||||
<div class="model-detail__scope-head">
|
||||
<div class="model-detail__scope-identity">
|
||||
<span class="model-detail__eyebrow">
|
||||
{{ t(`models.detail.scope.${branch.price.scope.kind}`) }}
|
||||
</span>
|
||||
<strong>{{ branch.price.scope.label }}</strong>
|
||||
</div>
|
||||
<StatusBadge
|
||||
size="compact"
|
||||
:tone="branch.price.pricing_status === 'configured' ? 'success' : 'warning'"
|
||||
>
|
||||
{{ t(`modelPrices.status.${branch.price.pricing_status}`) }}
|
||||
</StatusBadge>
|
||||
<div class="model-detail__scope-actions">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tone="action"
|
||||
size="compact"
|
||||
:label="t('modelPrices.edit.open', { model: branch.price.model_id })"
|
||||
@click="edit(branch, $event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<Pencil :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
<ModelPriceResetDialog
|
||||
v-if="branch.price.can_reset"
|
||||
:row="branch.price"
|
||||
action="reset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelCatalogFacts
|
||||
v-if="!upstream.catalog_summary && branch.catalog_reference"
|
||||
:reference="branch.catalog_reference"
|
||||
/>
|
||||
|
||||
<dl class="model-detail__slots">
|
||||
<div v-for="slot in slots(branch)" :key="slot.field" :data-state="slot.state">
|
||||
<dt>{{ t(`modelPrices.fields.${slot.field}`) }}</dt>
|
||||
<dd>{{ slot.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="model-detail__facts">
|
||||
<span>{{ methodLabel(branch) }}</span>
|
||||
<span>
|
||||
{{
|
||||
t('modelPrices.references', {
|
||||
entries: branch.price.reference_count,
|
||||
groups: branch.price.reference_group_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="branch.price.partial" class="model-detail__facts-warning">
|
||||
{{ t('modelPrices.facts.partial') }}
|
||||
</span>
|
||||
<span v-if="branch.price.has_context_tiers">{{ t('modelPrices.facts.tiered') }}</span>
|
||||
</p>
|
||||
|
||||
<div class="model-detail__groups">
|
||||
<span class="model-detail__eyebrow">{{ t('models.detail.routeGroups') }}</span>
|
||||
<ul>
|
||||
<li v-for="group in branch.route_groups" :key="group.id">
|
||||
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
|
||||
<span v-if="!group.enabled">{{ t('models.detail.groupDisabled') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span v-if="hasWiderPriceImpact(branch)" class="model-detail__impact">
|
||||
{{ t('models.detail.globalImpact', { count: branch.affected_groups.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-detail {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.model-detail__upstream {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-detail__upstream-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.model-detail__upstream-head strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.model-detail__eyebrow {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.model-detail__chip {
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 7px;
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-detail__scope {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
}
|
||||
|
||||
.model-detail__scope-head {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-detail__scope-identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.model-detail__scope-identity strong {
|
||||
overflow: hidden;
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-detail__scope-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-0-5);
|
||||
}
|
||||
|
||||
.model-detail__slots {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
border-block: 1px solid var(--color-border-subtle);
|
||||
padding-block: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-detail__slots div {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
padding-left: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-detail__slots div:first-child {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-detail__slots dt {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-detail__slots dd {
|
||||
margin: var(--space-0-5) 0 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-detail__slots div[data-state='unavailable'] dd {
|
||||
color: var(--color-text-faint);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-detail__slots div[data-state='free'] dd {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.model-detail__facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2-5);
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.model-detail__facts span + span::before {
|
||||
margin-right: var(--space-2-5);
|
||||
color: var(--color-text-faint);
|
||||
content: '·';
|
||||
}
|
||||
|
||||
.model-detail__facts-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.model-detail__groups {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-detail__groups ul {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.model-detail__groups li {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.model-detail__groups a {
|
||||
color: var(--color-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-detail__groups li span {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-detail__impact {
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-label-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.model-detail__scope {
|
||||
padding: var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.model-detail__scope-head {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.model-detail__scope-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.model-detail__scope-head :deep(.status-badge) {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.model-detail__slots {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2-5) var(--space-2);
|
||||
}
|
||||
|
||||
.model-detail__slots div:nth-child(3) {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ModelCollectionCatalogDto, ModelCollectionSummaryDto } from '@/app/resources/models'
|
||||
import { modelPricesLocation } from '@/app/route-locations'
|
||||
import AppDateTime from '@/components/ui/AppDateTime.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { formatInteger } from '@/lib/format'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: ModelCollectionSummaryDto
|
||||
catalog: ModelCollectionCatalogDto
|
||||
}>()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
key: 'clientModels',
|
||||
value: props.summary.client_model_count,
|
||||
tone: 'accent' as const,
|
||||
},
|
||||
{
|
||||
key: 'upstreamModels',
|
||||
value: props.summary.upstream_model_count,
|
||||
tone: 'plain' as const,
|
||||
},
|
||||
{
|
||||
key: 'prices',
|
||||
value: props.summary.price_count,
|
||||
tone: 'plain' as const,
|
||||
},
|
||||
{
|
||||
key: 'pendingPrices',
|
||||
value: props.summary.pending_price_count,
|
||||
tone: props.summary.pending_price_count > 0 ? ('warning' as const) : ('plain' as const),
|
||||
},
|
||||
])
|
||||
|
||||
const catalogTone = computed(() => {
|
||||
if (!props.catalog.available) return 'neutral' as const
|
||||
return props.catalog.error_code ? ('warning' as const) : ('success' as const)
|
||||
})
|
||||
|
||||
const catalogLabel = computed(() => {
|
||||
if (!props.catalog.available) return t('models.catalog.unavailable')
|
||||
return props.catalog.error_code ? t('models.catalog.stale') : t('models.catalog.available')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="models-summary" :aria-label="t('models.summary.label')">
|
||||
<dl class="models-summary__stats">
|
||||
<div v-for="stat in stats" :key="stat.key" :data-tone="stat.tone">
|
||||
<dt>{{ t(`models.summary.${stat.key}`) }}</dt>
|
||||
<dd>{{ formatInteger(stat.value, locale) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="models-summary__catalog">
|
||||
<StatusBadge size="compact" :tone="catalogTone">{{ catalogLabel }}</StatusBadge>
|
||||
<p v-if="catalog.successful_fetch_at_ms > 0">
|
||||
<span>{{ t('models.catalog.lastSuccess') }}</span>
|
||||
<AppDateTime :instant="catalog.successful_fetch_at_ms" :locale="locale" />
|
||||
</p>
|
||||
<p v-if="catalog.error_code" class="models-summary__catalog-error">
|
||||
<code>{{ catalog.error_code }}</code>
|
||||
<template v-if="catalog.checked_at_ms > 0">
|
||||
<span>{{ t('models.catalog.lastCheck') }}</span>
|
||||
<AppDateTime :instant="catalog.checked_at_ms" :locale="locale" />
|
||||
</template>
|
||||
</p>
|
||||
<RouterLink
|
||||
v-if="summary.unreferenced_price_count > 0"
|
||||
class="models-summary__unreferenced"
|
||||
:to="modelPricesLocation()"
|
||||
>
|
||||
{{
|
||||
t('models.summary.unreferencedPrices', {
|
||||
count: formatInteger(summary.unreferenced_price_count, locale),
|
||||
})
|
||||
}}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.models-summary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.models-summary__stats {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.models-summary__stats > div {
|
||||
min-width: 0;
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.models-summary__stats dt {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
font-weight: 560;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.models-summary__stats dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
font-weight: 580;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.models-summary__stats > div[data-tone='accent'] dd {
|
||||
color: var(--color-action);
|
||||
}
|
||||
|
||||
.models-summary__stats > div[data-tone='warning'] dd {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.models-summary__catalog {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.models-summary__catalog p {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.models-summary__catalog-error code {
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.models-summary__unreferenced {
|
||||
color: var(--color-action);
|
||||
font-size: var(--text-label-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.models-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.models-summary__catalog {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.models-summary__stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,372 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ClientModelDto, ModelPriceBranchDto, ModelUpstreamDto } from '@/app/resources/models'
|
||||
import CopyChip from '@/components/ui/CopyChip.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
import ModelTreePriceRow from './ModelTreePriceRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ClientModelDto[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
edit: [scope: ModelPriceBranchDto, trigger: HTMLElement]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ClientModelPresentation {
|
||||
model: ClientModelDto
|
||||
scopes: ModelPriceBranchDto[]
|
||||
pending: number
|
||||
simpleScope: ModelPriceBranchDto | null
|
||||
showUpstreamLayer: boolean
|
||||
}
|
||||
|
||||
const presentations = computed<ClientModelPresentation[]>(() =>
|
||||
props.items.map((model) => {
|
||||
const scopes = model.upstream_models.flatMap(({ prices }) => prices)
|
||||
const pending = scopes.filter(({ price }) => price.pricing_status === 'pending').length
|
||||
const upstream = model.upstream_models[0]
|
||||
const simpleScope =
|
||||
model.upstream_models.length === 1 &&
|
||||
upstream !== undefined &&
|
||||
!upstream.alias_applied &&
|
||||
upstream.prices.length === 1
|
||||
? (upstream.prices[0] ?? null)
|
||||
: null
|
||||
return {
|
||||
model,
|
||||
scopes,
|
||||
pending,
|
||||
simpleScope,
|
||||
showUpstreamLayer:
|
||||
model.upstream_models.length > 1 ||
|
||||
model.upstream_models.some(({ alias_applied }) => alias_applied),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function priceSummary(presentation: ClientModelPresentation): string {
|
||||
return presentation.pending > 0
|
||||
? t('models.tree.pricesWithPending', {
|
||||
count: presentation.scopes.length,
|
||||
pending: presentation.pending,
|
||||
})
|
||||
: t('models.tree.prices', { count: presentation.scopes.length })
|
||||
}
|
||||
|
||||
function upstreamSummary(upstream: ModelUpstreamDto): string {
|
||||
const pending = upstream.prices.filter(({ price }) => price.pricing_status === 'pending').length
|
||||
return pending > 0
|
||||
? t('models.tree.pricesWithPending', {
|
||||
count: upstream.prices.length,
|
||||
pending,
|
||||
})
|
||||
: t('models.tree.prices', { count: upstream.prices.length })
|
||||
}
|
||||
|
||||
function forwardEdit(scope: ModelPriceBranchDto, trigger: HTMLElement): void {
|
||||
emit('edit', scope, trigger)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="model-tree-collection" :aria-label="t('models.tree.label')">
|
||||
<div class="model-tree-collection__header" aria-hidden="true">
|
||||
<span>{{ t('models.tree.columns.structure') }}</span>
|
||||
<span>{{ t('models.tree.columns.status') }}</span>
|
||||
<span>{{ t('models.tree.columns.prices') }}</span>
|
||||
<span>{{ t('models.tree.columns.facts') }}</span>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="presentation in presentations"
|
||||
:key="presentation.model.client_model"
|
||||
class="model-tree-client"
|
||||
>
|
||||
<ModelTreePriceRow
|
||||
v-if="presentation.simpleScope"
|
||||
:scope="presentation.simpleScope"
|
||||
:client-model="presentation.model.client_model"
|
||||
@edit="forwardEdit"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<header class="model-tree-client__header">
|
||||
<div class="model-tree-client__identity">
|
||||
<span>{{ t('models.tree.clientModel') }}</span>
|
||||
<CopyChip
|
||||
:value="presentation.model.client_model"
|
||||
:label="t('models.tree.copy', { model: presentation.model.client_model })"
|
||||
:success-label="t('models.tree.copySucceeded')"
|
||||
:failure-label="t('models.tree.copyFailed')"
|
||||
/>
|
||||
</div>
|
||||
<div class="model-tree-client__protocols">
|
||||
<span v-for="protocol in presentation.model.protocols" :key="protocol">
|
||||
{{ t(`common.protocols.${protocol}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge :tone="presentation.pending > 0 ? 'warning' : 'success'" size="compact">
|
||||
{{ priceSummary(presentation) }}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div class="model-tree-client__body">
|
||||
<section
|
||||
v-for="upstream in presentation.model.upstream_models"
|
||||
:key="upstream.model_id"
|
||||
class="model-tree-upstream"
|
||||
:class="{
|
||||
'model-tree-upstream--visible': presentation.showUpstreamLayer,
|
||||
'model-tree-upstream--direct': !presentation.showUpstreamLayer,
|
||||
}"
|
||||
>
|
||||
<header v-if="presentation.showUpstreamLayer" class="model-tree-upstream__header">
|
||||
<div>
|
||||
<span>{{ t('models.tree.upstreamModel') }}</span>
|
||||
<strong>{{ upstream.model_id }}</strong>
|
||||
<small v-if="!upstream.alias_applied">{{ t('models.tree.directModel') }}</small>
|
||||
</div>
|
||||
<span>{{ upstreamSummary(upstream) }}</span>
|
||||
</header>
|
||||
|
||||
<div class="model-tree-upstream__prices">
|
||||
<ModelTreePriceRow
|
||||
v-for="scope in upstream.prices"
|
||||
:key="scope.price.id"
|
||||
:scope="scope"
|
||||
@edit="forwardEdit"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-tree-collection {
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--color-border-control);
|
||||
}
|
||||
|
||||
.model-tree-collection__header {
|
||||
display: grid;
|
||||
min-height: 38px;
|
||||
grid-template-columns:
|
||||
minmax(300px, 1.45fr) minmax(92px, 0.38fr) minmax(300px, 1.2fr)
|
||||
minmax(170px, 0.62fr);
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-control);
|
||||
color: var(--color-text-faint);
|
||||
padding-inline: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.model-tree-client {
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--color-border-control);
|
||||
}
|
||||
|
||||
.model-tree-client:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.model-tree-client__header {
|
||||
display: grid;
|
||||
min-height: 58px;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
background: var(--color-surface-sunken);
|
||||
padding: var(--space-2-5) var(--space-2);
|
||||
}
|
||||
|
||||
.model-tree-client__identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.model-tree-client__identity > span,
|
||||
.model-tree-upstream__header span,
|
||||
.model-tree-upstream__header small {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-tree-client__protocols {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-tree-client__protocols span {
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-text-muted);
|
||||
padding: 3px 7px;
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-tree-client__body {
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.model-tree-upstream {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--color-border-control);
|
||||
}
|
||||
|
||||
.model-tree-upstream + .model-tree-upstream {
|
||||
border-top: 1px solid var(--color-border-control);
|
||||
}
|
||||
|
||||
.model-tree-upstream--direct {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.model-tree-upstream__header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-5);
|
||||
}
|
||||
|
||||
.model-tree-upstream__header::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-control);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.model-tree-upstream__header > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-tree-upstream__header strong {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-tree-upstream__header > span {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.model-tree-upstream__prices {
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.model-tree-upstream--direct .model-tree-upstream__prices {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.model-tree-collection__header {
|
||||
grid-template-columns:
|
||||
minmax(260px, 1.3fr) minmax(88px, 0.35fr) minmax(270px, 1.1fr)
|
||||
minmax(150px, 0.55fr);
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.model-tree-collection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.model-tree-collection__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-tree-client {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.model-tree-client:last-child {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.model-tree-client__header {
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
padding: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.model-tree-client__protocols {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.model-tree-client__body {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-tree-upstream,
|
||||
.model-tree-upstream--direct {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.model-tree-upstream + .model-tree-upstream {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.model-tree-upstream__header {
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2-5) 0;
|
||||
}
|
||||
|
||||
.model-tree-upstream__header::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-tree-upstream__header > div {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.model-tree-upstream__header small {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.model-tree-upstream__prices,
|
||||
.model-tree-upstream--direct .model-tree-upstream__prices {
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--color-border-control);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,501 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ModelCatalogMetadataDto, ModelPriceBranchDto } from '@/app/resources/models'
|
||||
import { groupDetailLocation } from '@/app/route-locations'
|
||||
import CopyChip from '@/components/ui/CopyChip.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import ModelPriceResetDialog from '@/features/model-prices/ModelPriceResetDialog.vue'
|
||||
import { presentModelPrice } from '@/features/model-prices/model-price-presenter'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
scope: ModelPriceBranchDto
|
||||
clientModel?: string
|
||||
}>(),
|
||||
{ clientModel: undefined },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
edit: [scope: ModelPriceBranchDto, trigger: HTMLElement]
|
||||
}>()
|
||||
const { n, t } = useI18n()
|
||||
|
||||
const presentation = computed(() =>
|
||||
presentModelPrice(props.scope.price, {
|
||||
unavailable: t('modelPrices.values.unavailable'),
|
||||
free: t('modelPrices.values.free'),
|
||||
configured: (value) => t('modelPrices.values.configured', { value }),
|
||||
}),
|
||||
)
|
||||
const catalogMetadata = computed(() => props.scope.catalog_reference?.model ?? null)
|
||||
const hasDistinctGlobalImpact = computed(() => {
|
||||
if (props.scope.route_groups.length !== props.scope.affected_groups.length) return true
|
||||
const routeGroupIDs = new Set(props.scope.route_groups.map(({ id }) => id))
|
||||
return props.scope.affected_groups.some(({ id }) => !routeGroupIDs.has(id))
|
||||
})
|
||||
|
||||
function metadataFacts(metadata: ModelCatalogMetadataDto): string[] {
|
||||
const result: string[] = []
|
||||
if (metadata.family) result.push(t('models.tree.metadata.family', { value: metadata.family }))
|
||||
if (metadata.status) result.push(t('models.tree.metadata.status', { value: metadata.status }))
|
||||
if (metadata.limits.context !== null) {
|
||||
result.push(t('models.tree.metadata.context', { value: n(metadata.limits.context) }))
|
||||
}
|
||||
if (metadata.limits.input !== null) {
|
||||
result.push(t('models.tree.metadata.inputLimit', { value: n(metadata.limits.input) }))
|
||||
}
|
||||
if (metadata.limits.output !== null) {
|
||||
result.push(t('models.tree.metadata.outputLimit', { value: n(metadata.limits.output) }))
|
||||
}
|
||||
if (metadata.modalities.input.length > 0) {
|
||||
result.push(
|
||||
t('models.tree.metadata.inputModalities', { value: metadata.modalities.input.join(', ') }),
|
||||
)
|
||||
}
|
||||
if (metadata.modalities.output.length > 0) {
|
||||
result.push(
|
||||
t('models.tree.metadata.outputModalities', { value: metadata.modalities.output.join(', ') }),
|
||||
)
|
||||
}
|
||||
if (metadata.release_date) {
|
||||
result.push(t('models.tree.metadata.releaseDate', { value: metadata.release_date }))
|
||||
}
|
||||
if (metadata.last_updated) {
|
||||
result.push(t('models.tree.metadata.lastUpdated', { value: metadata.last_updated }))
|
||||
}
|
||||
if (metadata.knowledge) {
|
||||
result.push(t('models.tree.metadata.knowledge', { value: metadata.knowledge }))
|
||||
}
|
||||
if (metadata.open_weights === true) result.push(t('models.tree.metadata.openWeights'))
|
||||
for (const capability of [
|
||||
'attachment',
|
||||
'reasoning',
|
||||
'tool_call',
|
||||
'structured_output',
|
||||
'temperature',
|
||||
] as const) {
|
||||
if (metadata.capabilities[capability] === true) {
|
||||
result.push(t(`models.tree.metadata.capabilities.${capability}`))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function methodLabel(): string {
|
||||
const row = props.scope.price
|
||||
if (row.method === null) return t('modelPrices.method.pending')
|
||||
if (row.method === 'auto_matched') {
|
||||
return t('modelPrices.method.auto_matched', { provider: row.matched_provider_id })
|
||||
}
|
||||
return t(`modelPrices.method.${row.method}`)
|
||||
}
|
||||
|
||||
function edit(trigger: HTMLElement): void {
|
||||
emit('edit', props.scope, trigger)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="model-tree-price-row" :class="{ 'model-tree-price-row--root': clientModel }">
|
||||
<div class="model-tree-price-row__structure">
|
||||
<div class="model-tree-price-row__identity">
|
||||
<span class="model-tree-price-row__kind">
|
||||
{{
|
||||
clientModel
|
||||
? t('models.tree.clientModel')
|
||||
: t(`models.tree.scope.${scope.price.scope.kind}`)
|
||||
}}
|
||||
</span>
|
||||
<CopyChip
|
||||
v-if="clientModel"
|
||||
:value="clientModel"
|
||||
:label="t('models.tree.copy', { model: clientModel })"
|
||||
:success-label="t('models.tree.copySucceeded')"
|
||||
:failure-label="t('models.tree.copyFailed')"
|
||||
/>
|
||||
<strong v-else>{{ scope.price.scope.label }}</strong>
|
||||
<small v-if="clientModel">
|
||||
{{ t(`models.tree.scope.${scope.price.scope.kind}`) }} ·
|
||||
{{ scope.price.scope.label }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="scope.catalog_reference && catalogMetadata" class="model-tree-price-row__catalog">
|
||||
<span class="model-tree-price-row__catalog-source">
|
||||
{{
|
||||
t(`models.tree.catalogReference.${scope.catalog_reference.source}`, {
|
||||
provider: scope.catalog_reference.provider_name,
|
||||
})
|
||||
}}
|
||||
· {{ catalogMetadata.name }}
|
||||
</span>
|
||||
<p v-if="catalogMetadata.description">{{ catalogMetadata.description }}</p>
|
||||
<div v-if="metadataFacts(catalogMetadata).length" class="model-tree-price-row__metadata">
|
||||
<span v-for="fact in metadataFacts(catalogMetadata)" :key="fact">{{ fact }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-tree-price-row__groups">
|
||||
<span>
|
||||
{{
|
||||
hasDistinctGlobalImpact
|
||||
? t('models.tree.routeGroups')
|
||||
: scope.route_groups.length > 1
|
||||
? t('models.tree.groupCount', { count: scope.route_groups.length })
|
||||
: t('models.tree.groups')
|
||||
}}
|
||||
</span>
|
||||
<ul
|
||||
:class="{
|
||||
'model-tree-price-row__group-list--shared': scope.route_groups.length > 1,
|
||||
}"
|
||||
>
|
||||
<li v-for="group in scope.route_groups" :key="group.id">
|
||||
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
|
||||
<small>{{ group.provider_id ?? t('models.tree.customProvider') }}</small>
|
||||
<small>{{
|
||||
group.protocols.map((value) => t(`common.protocols.${value}`)).join(' · ')
|
||||
}}</small>
|
||||
<span :data-enabled="group.enabled">
|
||||
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-if="hasDistinctGlobalImpact">
|
||||
<span class="model-tree-price-row__global-impact">
|
||||
{{ t('models.tree.globalImpact', { count: scope.affected_groups.length }) }}
|
||||
</span>
|
||||
<ul class="model-tree-price-row__group-list--shared">
|
||||
<li v-for="group in scope.affected_groups" :key="group.id">
|
||||
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
|
||||
<span :data-enabled="group.enabled">
|
||||
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-tree-price-row__status">
|
||||
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.status') }}</span>
|
||||
<StatusBadge
|
||||
size="compact"
|
||||
:tone="scope.price.pricing_status === 'configured' ? 'success' : 'warning'"
|
||||
>
|
||||
{{ t(`modelPrices.status.${scope.price.pricing_status}`) }}
|
||||
</StatusBadge>
|
||||
<small v-if="scope.price.partial">{{ t('modelPrices.facts.partial') }}</small>
|
||||
<small v-if="scope.price.has_context_tiers">{{ t('modelPrices.facts.tiered') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="model-tree-price-row__prices">
|
||||
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.prices') }}</span>
|
||||
<div
|
||||
v-for="slot in presentation.slots"
|
||||
:key="slot.field"
|
||||
class="model-tree-price-row__price-slot"
|
||||
:data-state="slot.state"
|
||||
>
|
||||
<span>{{ t(`modelPrices.fields.${slot.field}`) }}</span>
|
||||
<strong>{{ slot.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-tree-price-row__facts">
|
||||
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.facts') }}</span>
|
||||
<strong>{{ methodLabel() }}</strong>
|
||||
<small v-if="scope.price.method === 'auto_sync'">
|
||||
{{ t('modelPrices.reference', { provider: scope.price.matched_provider_id }) }}
|
||||
</small>
|
||||
<small>
|
||||
{{
|
||||
t('modelPrices.references', {
|
||||
entries: scope.price.reference_count,
|
||||
groups: scope.price.reference_group_count,
|
||||
})
|
||||
}}
|
||||
</small>
|
||||
<div class="model-tree-price-row__actions">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tone="action"
|
||||
size="compact"
|
||||
:label="t('modelPrices.edit.open', { model: scope.price.model_id })"
|
||||
@click="edit($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<Pencil :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
<ModelPriceResetDialog v-if="scope.price.can_reset" :row="scope.price" action="reset" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-tree-price-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns:
|
||||
minmax(300px, 1.45fr) minmax(92px, 0.38fr) minmax(300px, 1.2fr)
|
||||
minmax(170px, 0.62fr);
|
||||
align-items: stretch;
|
||||
gap: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-3-5) var(--space-2);
|
||||
}
|
||||
|
||||
.model-tree-price-row--root {
|
||||
border-top: 0;
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
|
||||
.model-tree-price-row__structure,
|
||||
.model-tree-price-row__identity,
|
||||
.model-tree-price-row__catalog,
|
||||
.model-tree-price-row__groups,
|
||||
.model-tree-price-row__status,
|
||||
.model-tree-price-row__facts {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.model-tree-price-row__structure {
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-tree-price-row__identity {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.model-tree-price-row__kind,
|
||||
.model-tree-price-row__catalog-source,
|
||||
.model-tree-price-row__groups > span,
|
||||
.model-tree-price-row__identity small,
|
||||
.model-tree-price-row__status small,
|
||||
.model-tree-price-row__facts small {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-tree-price-row__identity > strong {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-tree-price-row__catalog {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-tree-price-row__catalog p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.model-tree-price-row__metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.model-tree-price-row__metadata span,
|
||||
.model-tree-price-row__groups li > span {
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 6px;
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups > .model-tree-price-row__global-impact {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups ul {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups li {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 7px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups a {
|
||||
color: var(--color-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups small {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.model-tree-price-row__groups li > span[data-enabled='false'] {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.model-tree-price-row__status,
|
||||
.model-tree-price-row__facts {
|
||||
justify-items: start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-tree-price-row__prices {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot:first-of-type {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot > span,
|
||||
.model-tree-price-row__mobile-label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: var(--space-0-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot[data-state='unavailable'] strong {
|
||||
color: var(--color-text-faint);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-tree-price-row__price-slot[data-state='free'] strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.model-tree-price-row__facts > strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.model-tree-price-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-0-5);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.model-tree-price-row__mobile-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.model-tree-price-row {
|
||||
grid-template-columns:
|
||||
minmax(260px, 1.3fr) minmax(88px, 0.35fr) minmax(270px, 1.1fr)
|
||||
minmax(150px, 0.55fr);
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.model-tree-price-row,
|
||||
.model-tree-price-row--root {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-4) 0 0;
|
||||
}
|
||||
|
||||
.model-tree-price-row--root {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.model-tree-price-row__structure {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.model-tree-price-row__status {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.model-tree-price-row__prices {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.model-tree-price-row__facts {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding-top: var(--space-2-5);
|
||||
}
|
||||
|
||||
.model-tree-price-row__facts > strong,
|
||||
.model-tree-price-row__facts > small {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.model-tree-price-row__actions {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.model-tree-price-row__mobile-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.model-tree-price-row__prices > .model-tree-price-row__mobile-label {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,6 @@ import CollectionFilterBar from '@/components/collection/CollectionFilterBar.vue
|
||||
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
|
||||
import PageFrame from '@/components/layout/PageFrame.vue'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppDateTime from '@/components/ui/AppDateTime.vue'
|
||||
import AppSearchInput from '@/components/ui/AppSearchInput.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
@@ -29,13 +28,13 @@ import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
import PageHeader from '@/components/ui/PageHeader.vue'
|
||||
import PaginationBar from '@/components/ui/PaginationBar.vue'
|
||||
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import ModelPriceDrawer from '@/features/model-prices/ModelPriceDrawer.vue'
|
||||
|
||||
import ModelTreeCollection from './ModelTreeCollection.vue'
|
||||
import ModelCollection from './ModelCollection.vue'
|
||||
import ModelSummaryStrip from './ModelSummaryStrip.vue'
|
||||
|
||||
const client = useApiClient()
|
||||
const { locale, n, t } = useI18n()
|
||||
const { n, t } = useI18n()
|
||||
const searchDraft = ref('')
|
||||
const appliedSearch = ref<string | undefined>()
|
||||
const groupStatus = ref<ModelCollectionGroupStatus>('enabled')
|
||||
@@ -81,14 +80,6 @@ const pricingStatusOptions = computed(() =>
|
||||
label: t(`models.filters.pricingStatus.${value}`),
|
||||
})),
|
||||
)
|
||||
const catalogTone = computed(() => {
|
||||
if (!data.value?.catalog.available) return 'neutral' as const
|
||||
return data.value.catalog.error_code ? ('warning' as const) : ('success' as const)
|
||||
})
|
||||
const catalogLabel = computed(() => {
|
||||
if (!data.value?.catalog.available) return t('models.catalog.unavailable')
|
||||
return data.value.catalog.error_code ? t('models.catalog.stale') : t('models.catalog.available')
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => data.value?.pagination.total_pages, page, () => modelsQuery.isPlaceholderData.value],
|
||||
@@ -140,8 +131,8 @@ function resetConditions(): void {
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function editScope(scope: ModelPriceBranchDto, trigger: HTMLElement): void {
|
||||
selected.value = scope
|
||||
function editBranch(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
|
||||
selected.value = branch
|
||||
restoreFocus = trigger
|
||||
drawerOpen.value = true
|
||||
}
|
||||
@@ -211,46 +202,7 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
@retry="modelsQuery.refetch()"
|
||||
/>
|
||||
|
||||
<InlineFeedback appearance="ledger" tone="neutral">
|
||||
{{ t('models.context') }}
|
||||
</InlineFeedback>
|
||||
|
||||
<section class="models-page__overview" :aria-label="t('models.summary.label')">
|
||||
<dl class="models-page__summary">
|
||||
<div>
|
||||
<dt>{{ t('models.summary.clientModels') }}</dt>
|
||||
<dd>{{ n(data.summary.client_model_count) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('models.summary.upstreamModels') }}</dt>
|
||||
<dd>{{ n(data.summary.upstream_model_count) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('models.summary.prices') }}</dt>
|
||||
<dd>{{ n(data.summary.price_count) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('models.summary.pendingPrices') }}</dt>
|
||||
<dd>{{ n(data.summary.pending_price_count) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('models.summary.unreferencedPrices') }}</dt>
|
||||
<dd>{{ n(data.summary.unreferenced_price_count) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="models-page__catalog-status">
|
||||
<StatusBadge size="compact" :tone="catalogTone">{{ catalogLabel }}</StatusBadge>
|
||||
<span v-if="data.catalog.successful_fetch_at_ms > 0">
|
||||
{{ t('models.catalog.lastSuccess') }}
|
||||
<AppDateTime :instant="data.catalog.successful_fetch_at_ms" :locale="locale" />
|
||||
</span>
|
||||
<span v-if="data.catalog.checked_at_ms > 0">
|
||||
{{ t('models.catalog.lastCheck') }}
|
||||
<AppDateTime :instant="data.catalog.checked_at_ms" :locale="locale" />
|
||||
</span>
|
||||
<code v-if="data.catalog.error_code">{{ data.catalog.error_code }}</code>
|
||||
</div>
|
||||
</section>
|
||||
<ModelSummaryStrip :summary="data.summary" :catalog="data.catalog" />
|
||||
|
||||
<CollectionFilterBar
|
||||
class="models-page__filters"
|
||||
@@ -279,9 +231,9 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
/>
|
||||
</label>
|
||||
<label class="collection-filter-field">
|
||||
<span class="collection-filter-label">{{
|
||||
t('models.filters.pricingStatusLabel')
|
||||
}}</span>
|
||||
<span class="collection-filter-label">
|
||||
{{ t('models.filters.pricingStatusLabel') }}
|
||||
</span>
|
||||
<AppSelect
|
||||
size="compact"
|
||||
:label="t('models.filters.pricingStatusLabel')"
|
||||
@@ -333,7 +285,7 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
</EmptyState>
|
||||
|
||||
<template v-else>
|
||||
<ModelTreeCollection :items="data.items" @edit="editScope" />
|
||||
<ModelCollection :items="data.items" @edit="editBranch" />
|
||||
<PaginationBar
|
||||
:page="data.pagination.page"
|
||||
:page-size="data.pagination.page_size"
|
||||
@@ -345,6 +297,8 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
@update:page-size="setPageSize"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p class="models-page__footnote">{{ t('models.context') }}</p>
|
||||
</template>
|
||||
|
||||
<ModelPriceDrawer
|
||||
@@ -372,66 +326,18 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
font-size: var(--text-meta);
|
||||
}
|
||||
|
||||
.models-page__overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
border-block: 1px solid var(--color-border-subtle);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
.models-page__summary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(5, minmax(96px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.models-page__summary div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.models-page__summary dt,
|
||||
.models-page__catalog-status span {
|
||||
.models-page__footnote {
|
||||
margin: calc(var(--space-4-5) * -0.45) 0 0;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.models-page__summary dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.models-page__catalog-status {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.models-page__catalog-status code {
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.models-page :deep(.models-page__filters.collection-filter-bar) {
|
||||
grid-template-columns: minmax(240px, 1fr) repeat(2, minmax(142px, 0.42fr));
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.models-page__overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.models-page__catalog-status {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.models-page :deep(.models-page__filters.collection-filter-bar) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -442,10 +348,6 @@ onBeforeUnmount(() => searchDebounce.cancel())
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.models-page__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.models-page :deep(.page-header__actions) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ModelPriceBranchDto, ModelRouteGroupDto } from '@/app/resources/models'
|
||||
import type { ClientModelDto, ModelUpstreamDto } from '@/app/resources/models'
|
||||
import type { ModelPriceSlotsDto } from '@/app/resources/model-prices'
|
||||
|
||||
/** 列表中直接展示的两个价格槽位,其余槽位只在展开详情中出现。 */
|
||||
export type ModelSummaryField = Extract<keyof ModelPriceSlotsDto, 'input' | 'output'>
|
||||
|
||||
export type ModelPriceSummaryState = 'unavailable' | 'free' | 'single' | 'range'
|
||||
|
||||
export interface ModelPriceFieldSummary {
|
||||
field: ModelSummaryField
|
||||
state: ModelPriceSummaryState
|
||||
/** 原始 decimal 字符串,保持后端精度;range 时为区间下界。 */
|
||||
value: string | null
|
||||
/** range 时的区间上界。 */
|
||||
upper: string | null
|
||||
}
|
||||
|
||||
export type ModelUpstreamShape = 'direct' | 'alias' | 'multi'
|
||||
|
||||
export interface ModelUpstreamSummary {
|
||||
shape: ModelUpstreamShape
|
||||
/** shape 为 alias 时的上游模型 ID。 */
|
||||
modelID: string | null
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ModelScopePresentation {
|
||||
upstream: ModelUpstreamDto
|
||||
branch: ModelPriceBranchDto
|
||||
}
|
||||
|
||||
export interface ClientModelPresentation {
|
||||
model: ClientModelDto
|
||||
scopes: ModelScopePresentation[]
|
||||
pendingCount: number
|
||||
status: 'configured' | 'pending'
|
||||
upstream: ModelUpstreamSummary
|
||||
routeGroups: ModelRouteGroupDto[]
|
||||
input: ModelPriceFieldSummary
|
||||
output: ModelPriceFieldSummary
|
||||
/** 仅有一套价格时可以从列表直接编辑。 */
|
||||
soleBranch: ModelPriceBranchDto | null
|
||||
}
|
||||
|
||||
function summarizeUpstream(model: ClientModelDto): ModelUpstreamSummary {
|
||||
const count = model.upstream_models.length
|
||||
if (count !== 1) return { shape: 'multi', modelID: null, count }
|
||||
const upstream = model.upstream_models[0]
|
||||
if (upstream === undefined) return { shape: 'multi', modelID: null, count }
|
||||
return upstream.alias_applied
|
||||
? { shape: 'alias', modelID: upstream.model_id, count }
|
||||
: { shape: 'direct', modelID: upstream.model_id, count }
|
||||
}
|
||||
|
||||
function collectRouteGroups(scopes: ModelScopePresentation[]): ModelRouteGroupDto[] {
|
||||
const groups = new Map<number, ModelRouteGroupDto>()
|
||||
for (const { branch } of scopes) {
|
||||
for (const group of branch.route_groups) {
|
||||
if (!groups.has(group.id)) groups.set(group.id, group)
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((left, right) =>
|
||||
left.name === right.name ? left.id - right.id : left.name.localeCompare(right.name),
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeField(
|
||||
scopes: ModelScopePresentation[],
|
||||
field: ModelSummaryField,
|
||||
): ModelPriceFieldSummary {
|
||||
const priced = scopes
|
||||
.map(({ branch }) => branch.price.prices[field])
|
||||
.filter((value): value is string => value !== null)
|
||||
if (priced.length === 0) {
|
||||
return { field, state: 'unavailable', value: null, upper: null }
|
||||
}
|
||||
|
||||
let lower = priced[0] as string
|
||||
let upper = priced[0] as string
|
||||
for (const value of priced) {
|
||||
if (Number(value) < Number(lower)) lower = value
|
||||
if (Number(value) > Number(upper)) upper = value
|
||||
}
|
||||
if (Number(lower) !== Number(upper)) {
|
||||
return { field, state: 'range', value: lower, upper }
|
||||
}
|
||||
return { field, state: Number(lower) === 0 ? 'free' : 'single', value: lower, upper: null }
|
||||
}
|
||||
|
||||
export function presentClientModel(model: ClientModelDto): ClientModelPresentation {
|
||||
const scopes = model.upstream_models.flatMap((upstream) =>
|
||||
upstream.prices.map((branch) => ({ upstream, branch })),
|
||||
)
|
||||
const pendingCount = scopes.filter(
|
||||
({ branch }) => branch.price.pricing_status === 'pending',
|
||||
).length
|
||||
return {
|
||||
model,
|
||||
scopes,
|
||||
pendingCount,
|
||||
status: pendingCount > 0 ? 'pending' : 'configured',
|
||||
upstream: summarizeUpstream(model),
|
||||
routeGroups: collectRouteGroups(scopes),
|
||||
input: summarizeField(scopes, 'input'),
|
||||
output: summarizeField(scopes, 'output'),
|
||||
soleBranch: scopes.length === 1 ? ((scopes[0] as ModelScopePresentation).branch ?? null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 价格范围是否影响了当前路由分组之外的分组。 */
|
||||
export function hasWiderPriceImpact(branch: ModelPriceBranchDto): boolean {
|
||||
if (branch.route_groups.length !== branch.affected_groups.length) return true
|
||||
const routed = new Set(branch.route_groups.map(({ id }) => id))
|
||||
return branch.affected_groups.some(({ id }) => !routed.has(id))
|
||||
}
|
||||
@@ -23,13 +23,13 @@ export default {
|
||||
upstreamModels: 'Upstream models',
|
||||
prices: 'Price records',
|
||||
pendingPrices: 'Pending prices',
|
||||
unreferencedPrices: 'Unused prices',
|
||||
unreferencedPrices: '{count} unused price records',
|
||||
},
|
||||
catalog: {
|
||||
available: 'Models.dev catalog available',
|
||||
stale: 'Sync failed; using the last successful catalog',
|
||||
unavailable: 'Models.dev catalog unavailable',
|
||||
lastSuccess: 'Last success',
|
||||
lastSuccess: 'Last sync',
|
||||
lastCheck: 'Last check',
|
||||
},
|
||||
drawerImpact: {
|
||||
@@ -60,58 +60,61 @@ export default {
|
||||
noResultsTitle: 'No matching models',
|
||||
noResultsDescription: 'Adjust the search, Group status, or pricing status and try again',
|
||||
},
|
||||
tree: {
|
||||
label: 'Client model catalog',
|
||||
collection: {
|
||||
label: 'Client model list',
|
||||
scrollHint: 'Scroll horizontally for more columns',
|
||||
columns: {
|
||||
structure: 'Model and route structure',
|
||||
status: 'Status',
|
||||
model: 'Client model',
|
||||
routing: 'Route Groups / protocols',
|
||||
prices: 'Prices · USD / 1M',
|
||||
facts: 'Source / actions',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
clientModel: 'Client model',
|
||||
direct: 'Passed through by the same name',
|
||||
alias: 'Mapped to {model}',
|
||||
multiUpstream: '{count} upstream models',
|
||||
moreGroups: '+{count}',
|
||||
scopeCount: '{count} price scopes',
|
||||
pendingScopes: '{count} price scopes · {pending} pending',
|
||||
priceRange: '{min} – {max}',
|
||||
expand: 'Expand details for {model}',
|
||||
collapse: 'Collapse details for {model}',
|
||||
copy: 'Copy client model {model}',
|
||||
copySucceeded: 'Client model copied',
|
||||
copyFailed: 'Unable to copy the client model',
|
||||
},
|
||||
detail: {
|
||||
upstreamModel: 'Upstream model',
|
||||
directModel: 'Direct name',
|
||||
prices: '{count} price scopes',
|
||||
pricesWithPending: '{count} price scopes · {pending} pending',
|
||||
direct: 'Same name',
|
||||
aliasApplied: 'Alias',
|
||||
scope: {
|
||||
provider: 'Provider price',
|
||||
group: 'Group-specific price',
|
||||
},
|
||||
groups: 'Route Groups',
|
||||
routeGroups: 'Current route Groups',
|
||||
globalImpact: 'Global impact · {count} Groups',
|
||||
groupCount: '{count} Groups share this price',
|
||||
routeGroups: 'Route Groups',
|
||||
groupEnabled: 'Enabled',
|
||||
groupDisabled: 'Disabled',
|
||||
customProvider: 'Custom provider',
|
||||
copy: 'Copy client model {model}',
|
||||
copySucceeded: 'Client model copied',
|
||||
copyFailed: 'Unable to copy the client model',
|
||||
globalImpact: 'This price affects {count} Groups in total',
|
||||
catalogReference: {
|
||||
actual_provider: 'Models.dev actual provider · {provider}',
|
||||
actual_provider: 'Models.dev · {provider}',
|
||||
reference_provider: 'Models.dev reference provider · {provider}',
|
||||
},
|
||||
catalogUnavailable: 'No Models.dev model information',
|
||||
metadata: {
|
||||
family: 'Family {value}',
|
||||
status: 'Status {value}',
|
||||
context: 'Context {value}',
|
||||
inputLimit: 'Max input {value}',
|
||||
outputLimit: 'Max output {value}',
|
||||
inputModalities: 'Input {value}',
|
||||
outputModalities: 'Output {value}',
|
||||
releaseDate: 'Released {value}',
|
||||
lastUpdated: 'Updated {value}',
|
||||
knowledge: 'Knowledge cutoff {value}',
|
||||
openWeights: 'Open weights',
|
||||
capabilities: {
|
||||
attachment: 'Attachments',
|
||||
reasoning: 'Reasoning',
|
||||
tool_call: 'Tool calls',
|
||||
structured_output: 'Structured output',
|
||||
temperature: 'Temperature',
|
||||
},
|
||||
specs: {
|
||||
context: 'Context',
|
||||
maxOutput: 'Max output',
|
||||
modalities: 'Modalities',
|
||||
knowledge: 'Knowledge cutoff',
|
||||
released: 'Released',
|
||||
family: 'Family',
|
||||
},
|
||||
capabilities: {
|
||||
attachment: 'Attachments',
|
||||
reasoning: 'Reasoning',
|
||||
tool_call: 'Tool calls',
|
||||
structured_output: 'Structured output',
|
||||
temperature: 'Temperature',
|
||||
},
|
||||
openWeights: 'Open weights',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -23,13 +23,13 @@ export default {
|
||||
upstreamModels: '上流モデル',
|
||||
prices: '価格レコード',
|
||||
pendingPrices: '価格待ち',
|
||||
unreferencedPrices: '未使用価格',
|
||||
unreferencedPrices: '未使用の価格レコード {count} 件',
|
||||
},
|
||||
catalog: {
|
||||
available: 'Models.dev カタログ利用可能',
|
||||
stale: '同期失敗・前回成功したカタログを使用中',
|
||||
unavailable: 'Models.dev カタログ利用不可',
|
||||
lastSuccess: '最終成功',
|
||||
lastSuccess: '最終同期',
|
||||
lastCheck: '最終確認',
|
||||
},
|
||||
drawerImpact: {
|
||||
@@ -60,58 +60,61 @@ export default {
|
||||
noResultsTitle: '条件に一致するモデルがありません',
|
||||
noResultsDescription: '検索、Group の状態、価格状態を変更して再度お試しください',
|
||||
},
|
||||
tree: {
|
||||
label: 'クライアントモデルカタログ',
|
||||
collection: {
|
||||
label: 'クライアントモデル一覧',
|
||||
scrollHint: '横スクロールで他の列を表示できます',
|
||||
columns: {
|
||||
structure: 'モデルとルート構造',
|
||||
status: '状態',
|
||||
model: 'クライアントモデル',
|
||||
routing: 'ルート Group / プロトコル',
|
||||
prices: '価格 · USD / 1M',
|
||||
facts: '参照元 / 操作',
|
||||
status: '状態',
|
||||
actions: '操作',
|
||||
},
|
||||
clientModel: 'クライアントモデル',
|
||||
direct: '同名で直接転送',
|
||||
alias: '{model} にマッピング',
|
||||
multiUpstream: '上流モデル {count} 件',
|
||||
moreGroups: '+{count}',
|
||||
scopeCount: '価格範囲 {count} 件',
|
||||
pendingScopes: '価格範囲 {count} 件 · {pending} 件が価格待ち',
|
||||
priceRange: '{min} – {max}',
|
||||
expand: '{model} の詳細を開く',
|
||||
collapse: '{model} の詳細を閉じる',
|
||||
copy: 'クライアントモデル {model} をコピー',
|
||||
copySucceeded: 'クライアントモデルをコピーしました',
|
||||
copyFailed: 'クライアントモデルをコピーできません',
|
||||
},
|
||||
detail: {
|
||||
upstreamModel: '上流モデル',
|
||||
directModel: '同名で直接転送',
|
||||
prices: '{count} 件の価格範囲',
|
||||
pricesWithPending: '{count} 件の価格範囲 · {pending} 件が価格待ち',
|
||||
direct: '同名',
|
||||
aliasApplied: 'エイリアス',
|
||||
scope: {
|
||||
provider: 'プロバイダー価格',
|
||||
group: 'Group 固有価格',
|
||||
},
|
||||
groups: 'ルート Group',
|
||||
routeGroups: '現在のルート Group',
|
||||
globalImpact: '全体影響 · {count} Group',
|
||||
groupCount: '{count} Group がこの価格を共有',
|
||||
routeGroups: 'ルート Group',
|
||||
groupEnabled: '有効',
|
||||
groupDisabled: '無効',
|
||||
customProvider: 'カスタムプロバイダー',
|
||||
copy: 'クライアントモデル {model} をコピー',
|
||||
copySucceeded: 'クライアントモデルをコピーしました',
|
||||
copyFailed: 'クライアントモデルをコピーできません',
|
||||
globalImpact: 'この価格は合計 {count} Group に影響します',
|
||||
catalogReference: {
|
||||
actual_provider: 'Models.dev 実プロバイダー · {provider}',
|
||||
actual_provider: 'Models.dev · {provider}',
|
||||
reference_provider: 'Models.dev 参照プロバイダー · {provider}',
|
||||
},
|
||||
catalogUnavailable: 'Models.dev モデル情報はありません',
|
||||
metadata: {
|
||||
family: 'ファミリー {value}',
|
||||
status: '状態 {value}',
|
||||
context: 'コンテキスト {value}',
|
||||
inputLimit: '最大入力 {value}',
|
||||
outputLimit: '最大出力 {value}',
|
||||
inputModalities: '入力 {value}',
|
||||
outputModalities: '出力 {value}',
|
||||
releaseDate: 'リリース {value}',
|
||||
lastUpdated: '更新 {value}',
|
||||
knowledge: '知識の期限 {value}',
|
||||
openWeights: 'オープンウェイト',
|
||||
capabilities: {
|
||||
attachment: '添付',
|
||||
reasoning: '推論',
|
||||
tool_call: 'ツール呼び出し',
|
||||
structured_output: '構造化出力',
|
||||
temperature: 'Temperature',
|
||||
},
|
||||
specs: {
|
||||
context: 'コンテキスト',
|
||||
maxOutput: '最大出力',
|
||||
modalities: 'モダリティ',
|
||||
knowledge: '知識の期限',
|
||||
released: 'リリース',
|
||||
family: 'ファミリー',
|
||||
},
|
||||
capabilities: {
|
||||
attachment: '添付',
|
||||
reasoning: '推論',
|
||||
tool_call: 'ツール呼び出し',
|
||||
structured_output: '構造化出力',
|
||||
temperature: 'Temperature',
|
||||
},
|
||||
openWeights: 'オープンウェイト',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -21,13 +21,13 @@ export default {
|
||||
upstreamModels: '上游模型',
|
||||
prices: '价格记录',
|
||||
pendingPrices: '待定价',
|
||||
unreferencedPrices: '未使用价格',
|
||||
unreferencedPrices: '{count} 条未使用价格记录',
|
||||
},
|
||||
catalog: {
|
||||
available: 'Models.dev 目录可用',
|
||||
stale: '同步失败,使用上次成功目录',
|
||||
unavailable: 'Models.dev 目录不可用',
|
||||
lastSuccess: '最近成功',
|
||||
lastSuccess: '最近同步',
|
||||
lastCheck: '最近检查',
|
||||
},
|
||||
drawerImpact: {
|
||||
@@ -58,58 +58,61 @@ export default {
|
||||
noResultsTitle: '没有符合条件的模型',
|
||||
noResultsDescription: '调整搜索、分组状态或价格状态后再试',
|
||||
},
|
||||
tree: {
|
||||
label: '客户端模型目录',
|
||||
collection: {
|
||||
label: '客户端模型列表',
|
||||
scrollHint: '可横向滚动查看更多列',
|
||||
columns: {
|
||||
structure: '模型与路由结构',
|
||||
status: '状态',
|
||||
model: '客户端模型',
|
||||
routing: '路由分组 / 协议',
|
||||
prices: '价格 · USD / 1M',
|
||||
facts: '来源 / 操作',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
},
|
||||
clientModel: '客户端模型',
|
||||
direct: '同名直传上游',
|
||||
alias: '映射到 {model}',
|
||||
multiUpstream: '{count} 个上游模型',
|
||||
moreGroups: '+{count}',
|
||||
scopeCount: '{count} 套价格',
|
||||
pendingScopes: '{count} 套价格 · {pending} 套待定',
|
||||
priceRange: '{min} – {max}',
|
||||
expand: '展开 {model} 的模型详情',
|
||||
collapse: '收起 {model} 的模型详情',
|
||||
copy: '复制客户端模型 {model}',
|
||||
copySucceeded: '客户端模型已复制',
|
||||
copyFailed: '无法复制客户端模型',
|
||||
},
|
||||
detail: {
|
||||
upstreamModel: '上游模型',
|
||||
directModel: '同名直传',
|
||||
prices: '{count} 套价格',
|
||||
pricesWithPending: '{count} 套价格 · {pending} 套待定价',
|
||||
direct: '同名直传',
|
||||
aliasApplied: '别名映射',
|
||||
scope: {
|
||||
provider: '渠道价格',
|
||||
group: '分组独立价格',
|
||||
},
|
||||
groups: '路由分组',
|
||||
routeGroups: '当前路径分组',
|
||||
globalImpact: '全局影响 · {count} 个分组',
|
||||
groupCount: '{count} 个分组共享此价格',
|
||||
routeGroups: '路由分组',
|
||||
groupEnabled: '启用',
|
||||
groupDisabled: '停用',
|
||||
customProvider: '自定义渠道',
|
||||
copy: '复制客户端模型 {model}',
|
||||
copySucceeded: '客户端模型已复制',
|
||||
copyFailed: '无法复制客户端模型',
|
||||
groupDisabled: '已停用',
|
||||
globalImpact: '此价格共影响 {count} 个分组',
|
||||
catalogReference: {
|
||||
actual_provider: 'Models.dev 实际渠道 · {provider}',
|
||||
actual_provider: 'Models.dev · {provider}',
|
||||
reference_provider: 'Models.dev 参考渠道 · {provider}',
|
||||
},
|
||||
catalogUnavailable: '暂无 Models.dev 模型信息',
|
||||
metadata: {
|
||||
family: '系列 {value}',
|
||||
status: '状态 {value}',
|
||||
context: '上下文 {value}',
|
||||
inputLimit: '最大输入 {value}',
|
||||
outputLimit: '最大输出 {value}',
|
||||
inputModalities: '输入 {value}',
|
||||
outputModalities: '输出 {value}',
|
||||
releaseDate: '发布 {value}',
|
||||
lastUpdated: '更新 {value}',
|
||||
knowledge: '知识截止 {value}',
|
||||
openWeights: '开放权重',
|
||||
capabilities: {
|
||||
attachment: '附件',
|
||||
reasoning: '推理',
|
||||
tool_call: '工具调用',
|
||||
structured_output: '结构化输出',
|
||||
temperature: '温度参数',
|
||||
},
|
||||
specs: {
|
||||
context: '上下文',
|
||||
maxOutput: '最大输出',
|
||||
modalities: '模态',
|
||||
knowledge: '知识截止',
|
||||
released: '发布',
|
||||
family: '系列',
|
||||
},
|
||||
capabilities: {
|
||||
attachment: '附件',
|
||||
reasoning: '推理',
|
||||
tool_call: '工具调用',
|
||||
structured_output: '结构化输出',
|
||||
temperature: '温度参数',
|
||||
},
|
||||
openWeights: '开放权重',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
Reference in new issue
Block a user