ForcePilot/web/src/views/ModelConfigView.vue
2026-04-25 23:58:01 +08:00

1676 lines
44 KiB
Vue

<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
Globe,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
CheckCircle2,
Edit3
} from 'lucide-vue-next'
import { modelProviderApi } from '@/apis/system_api'
import { modelIcons } from '@/utils/modelIcon'
// ============ State ============
const loading = ref(false)
const remoteLoading = ref(false)
const saving = ref(false)
const providers = ref([])
const searchQuery = ref('')
// Provider form state
const showProviderModal = ref(false)
const editingProviderId = ref(null) // null = creating, string = editing
const providerForm = reactive({
provider_id: '',
display_name: '',
provider_type: 'openai',
default_protocol: 'openai_compatible',
base_url: '',
embedding_base_url: '',
rerank_base_url: '',
models_endpoint: '/models',
embedding_models_endpoint: '/embeddings/models',
rerank_models_endpoint: '',
api_key_env: '',
api_key: '',
capabilities: ['chat'],
is_enabled: true,
headers_text: '{}',
extra_text: '{}'
})
// Model form state
const showModelModal = ref(false)
const editingModel = reactive({
id: '',
display_name: '',
type: 'chat',
protocol_override: null,
base_url_override: null,
context_length: null,
dimension: null,
batch_size: null,
supported_parameters: [],
extra: {}
})
// Models modal state (per provider)
const showModelsModal = ref(false)
const currentProviderForModels = ref(null)
// Remote models per provider
const remoteModelsMap = ref({})
// Remote model loading state per provider
const remoteModelsLoaded = ref({})
// Remote model search state per provider
const remoteModelSearch = ref({})
const remoteModelTypeFilter = ref({})
// ============ Computed ============
const filteredProviders = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
const filtered = keyword
? providers.value.filter(
(p) =>
p.provider_id.toLowerCase().includes(keyword) ||
p.display_name.toLowerCase().includes(keyword)
)
: providers.value
return [...filtered].sort((a, b) => {
if (a.is_enabled !== b.is_enabled) return a.is_enabled ? -1 : 1
if (a.is_enabled && b.is_enabled && a.credential_status !== b.credential_status) {
return a.credential_status === 'warning' ? 1 : -1
}
return a.provider_id.localeCompare(b.provider_id)
})
})
const providerStats = computed(() => ({
total: providers.value.length,
enabled: providers.value.filter((p) => p.is_enabled).length,
warning: providers.value.filter((p) => p.is_enabled && p.credential_status === 'warning').length,
models: providers.value.reduce((sum, p) => sum + (p.enabled_models?.length || 0), 0)
}))
// ============ Helpers ============
const getProviderIcon = (provider) => {
const providerId = provider?.provider_id?.toLowerCase()
const providerType = provider?.provider_type?.toLowerCase()
return modelIcons[providerId] || modelIcons[providerType] || modelIcons.default
}
const getIconUrl = (icon) => {
if (!icon) return modelIcons.default
if (typeof icon === 'string') return icon
if (icon instanceof URL || icon?.default) return icon.default || icon
return modelIcons.default
}
const formatContextLength = (len) => {
if (!len) return '-'
if (len >= 1000000) return `${(len / 1000000).toFixed(1)}M`
if (len >= 1000) return `${(len / 1000).toFixed(0)}K`
return len.toString()
}
const formatMtokenPrice = (pricing) => {
if (!pricing) return null
const prompt = parseFloat(pricing.prompt || pricing.prompt_price || 0)
const completion = parseFloat(pricing.completion || pricing.completion_price || 0)
if (prompt < 0 || completion < 0) return null
if (prompt === 0 && completion === 0) return null
return {
prompt: prompt * 100000,
completion: completion * 100000
}
}
const formatPriceDisplay = (pricing) => {
const p = formatMtokenPrice(pricing)
if (!p) return null
return `$${p.prompt.toFixed(2)} / $${p.completion.toFixed(2)}`
}
const getModelDisplayName = (model) => {
return model.name || model.display_name || model.id
}
const getModelId = (model) => {
return model.id
}
const getInputModalities = (model) => {
if (!model) return []
if (model.input_modalities) return model.input_modalities
if (model.architecture?.input_modalities) return model.architecture.input_modalities
if (model.raw_metadata?.architecture?.input_modalities)
return model.raw_metadata.architecture.input_modalities
return []
}
const isModelStale = (model, providerId) => {
// Only check stale if remote models have been loaded
if (!remoteModelsLoaded.value[providerId]) return false
const remoteIds = new Set((remoteModelsMap.value[providerId] || []).map((m) => m.id))
return model.enabled && !remoteIds.has(model.id)
}
// Remote models filtered by search query per provider
const filteredRemoteModels = computed(() => {
if (!currentProviderForModels.value) return []
const providerId = currentProviderForModels.value.provider_id
const query = (remoteModelSearch.value[providerId] || '').trim().toLowerCase()
const typeFilter = remoteModelTypeFilter.value[providerId] || 'all'
const models = remoteModelsMap.value[providerId] || []
return models.filter((m) => {
const matchesType = typeFilter === 'all' || (m.type || 'chat') === typeFilter
const matchesQuery = !query || m.id.toLowerCase().includes(query)
return matchesType && matchesQuery
})
})
const remoteModelTypeOptions = computed(() => {
if (!currentProviderForModels.value) return [{ label: '全部', value: 'all' }]
const providerId = currentProviderForModels.value.provider_id
const models = remoteModelsMap.value[providerId] || []
const counts = models.reduce((acc, model) => {
const type = model.type || 'chat'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
return [
{ label: `全部 ${models.length}`, value: 'all' },
{ label: `Chat ${counts.chat || 0}`, value: 'chat' },
{ label: `Embedding ${counts.embedding || 0}`, value: 'embedding' },
{ label: `Rerank ${counts.rerank || 0}`, value: 'rerank' }
]
})
const parseJsonObject = (text, label) => {
try {
const parsed = JSON.parse(text || '{}')
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${label} 必须是 JSON 对象`)
}
return parsed
} catch {
throw new Error(`${label} 格式不正确`)
}
}
const formatJsonText = (value) => JSON.stringify(value || {}, null, 2)
// ============ Provider Operations ============
const loadProviders = async () => {
loading.value = true
try {
const result = await modelProviderApi.getProviders()
providers.value = result.data || []
} catch (error) {
message.error(error.message || '加载模型供应商失败')
} finally {
loading.value = false
}
}
const openCreateProviderModal = () => {
editingProviderId.value = null
Object.assign(providerForm, {
provider_id: '',
display_name: '',
provider_type: 'openai',
default_protocol: '',
base_url: '',
embedding_base_url: '',
rerank_base_url: '',
models_endpoint: '/models',
embedding_models_endpoint: '/embeddings/models',
rerank_models_endpoint: '',
api_key_env: '',
api_key: '',
capabilities: ['chat'],
is_enabled: true,
headers_text: '{}',
extra_text: '{}'
})
showProviderModal.value = true
}
const openEditProviderModal = (provider) => {
editingProviderId.value = provider.provider_id
Object.assign(providerForm, {
provider_id: provider.provider_id,
display_name: provider.display_name,
provider_type: provider.provider_type || 'openai',
default_protocol: '',
base_url: provider.base_url || '',
embedding_base_url: provider.embedding_base_url || '',
rerank_base_url: provider.rerank_base_url || '',
models_endpoint: provider.models_endpoint || '/models',
embedding_models_endpoint: provider.embedding_models_endpoint || '/embeddings/models',
rerank_models_endpoint: provider.rerank_models_endpoint || '',
api_key_env: provider.api_key_env || '',
api_key: provider.api_key || '',
capabilities: provider.capabilities?.length ? provider.capabilities : ['chat'],
is_enabled: provider.is_enabled !== false,
headers_text: formatJsonText(provider.headers_json),
extra_text: formatJsonText(provider.extra_json)
})
showProviderModal.value = true
}
const buildProviderPayload = () => ({
provider_id: providerForm.provider_id || undefined,
display_name: providerForm.display_name,
provider_type: providerForm.provider_type,
default_protocol: null,
base_url: providerForm.base_url,
embedding_base_url: providerForm.embedding_base_url || null,
rerank_base_url: providerForm.rerank_base_url || null,
models_endpoint: providerForm.models_endpoint || null,
embedding_models_endpoint: providerForm.embedding_models_endpoint || null,
rerank_models_endpoint: providerForm.rerank_models_endpoint || null,
api_key_env: providerForm.api_key_env || null,
api_key: providerForm.api_key || null,
capabilities: providerForm.capabilities,
is_enabled: providerForm.is_enabled,
headers_json: parseJsonObject(providerForm.headers_text, '请求头'),
extra_json: parseJsonObject(providerForm.extra_text, '扩展配置')
})
const createProvider = async () => {
saving.value = true
try {
await modelProviderApi.createProvider(buildProviderPayload())
message.success('供应商已创建')
showProviderModal.value = false
await loadProviders()
} catch (error) {
message.error(error.message || '创建失败')
} finally {
saving.value = false
}
}
const saveProvider = async () => {
saving.value = true
try {
await modelProviderApi.updateProvider(providerForm.provider_id, buildProviderPayload())
message.success('供应商已保存')
showProviderModal.value = false
await loadProviders()
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
const deleteProvider = async (provider) => {
Modal.confirm({
title: `删除 ${provider.display_name}`,
content: '删除后不会影响当前系统正在使用的旧模型配置。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await modelProviderApi.deleteProvider(provider.provider_id)
message.success('已删除')
if (currentProviderForModels.value?.provider_id === provider.provider_id) {
showModelsModal.value = false
currentProviderForModels.value = null
}
if (editingProviderId.value === provider.provider_id) {
showProviderModal.value = false
editingProviderId.value = null
}
await loadProviders()
} catch (error) {
message.error(error.message || '删除失败')
}
}
})
}
const deleteProviderFromEdit = async () => {
const provider = providers.value.find((p) => p.provider_id === editingProviderId.value)
if (provider) {
deleteProvider(provider)
}
}
// ============ Models Modal Operations ============
const openModelsModal = (provider) => {
currentProviderForModels.value = provider
if (!remoteModelsLoaded.value[provider.provider_id]) {
remoteModelsMap.value[provider.provider_id] = []
}
remoteModelSearch.value[provider.provider_id] = remoteModelSearch.value[provider.provider_id] || ''
remoteModelTypeFilter.value[provider.provider_id] = 'all'
showModelsModal.value = true
}
// ============ Remote Models Operations ============
const fetchRemoteModels = async (providerId) => {
remoteLoading.value = true
try {
const result = await modelProviderApi.fetchRemoteModels(providerId)
remoteModelsMap.value = {
...remoteModelsMap.value,
[providerId]: result.data || []
}
remoteModelsLoaded.value[providerId] = true
message.success(`已获取 ${result.data?.length || 0} 个远端模型`)
} catch (error) {
message.error(error.message || '获取远端模型失败')
} finally {
remoteLoading.value = false
}
}
// ============ Model Operations ============
const normalizeModel = (model = {}) => ({
id: model.id || '',
display_name: model.display_name || model.name || model.id || '',
type: model.type && model.type !== 'unknown' ? model.type : 'chat',
protocol_override: model.protocol_override || null,
base_url_override: model.base_url_override || null,
context_length: model.context_length || null,
dimension: model.dimension || null,
batch_size: model.batch_size || null,
supported_parameters: model.supported_parameters || [],
extra: model.extra || {}
})
const addModelFromRemote = async (providerId, remoteModel) => {
const provider = providers.value.find((p) => p.provider_id === providerId)
if (!provider) return
const enabledModels = provider.enabled_models || []
if (enabledModels.some((m) => m.id === remoteModel.id)) {
message.info('模型已存在')
return
}
const newModel = normalizeModel(remoteModel)
newModel.enabled = true
const newEnabledModels = [...enabledModels, newModel]
try {
await modelProviderApi.updateProvider(providerId, { enabled_models: newEnabledModels })
message.success(`已添加模型 ${remoteModel.id}`)
await loadProviders()
// Refresh current provider reference if modal is open
if (currentProviderForModels.value?.provider_id === providerId) {
currentProviderForModels.value = providers.value.find((p) => p.provider_id === providerId)
}
} catch (error) {
message.error(error.message || '添加模型失败')
}
}
const openModelConfigModal = (model) => {
Object.assign(editingModel, { ...model })
showModelModal.value = true
}
const saveModelConfig = async () => {
if (!currentProviderForModels.value) return
saving.value = true
try {
const provider = providers.value.find(
(p) => p.provider_id === currentProviderForModels.value.provider_id
)
if (!provider) return
const enabledModels = (provider.enabled_models || []).map((m) =>
m.id === editingModel.id ? { ...editingModel } : m
)
await modelProviderApi.updateProvider(currentProviderForModels.value.provider_id, {
enabled_models: enabledModels
})
message.success('模型配置已保存')
showModelModal.value = false
await loadProviders()
// Refresh current provider reference
currentProviderForModels.value = providers.value.find(
(p) => p.provider_id === currentProviderForModels.value.provider_id
)
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
const removeModel = async (providerId, modelId) => {
const provider = providers.value.find((p) => p.provider_id === providerId)
if (!provider) return
Modal.confirm({
title: '移除模型',
content: `确定要移除模型 ${modelId} 吗?`,
okText: '移除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const enabledModels = (provider.enabled_models || []).filter((m) => m.id !== modelId)
await modelProviderApi.updateProvider(providerId, { enabled_models: enabledModels })
message.success('模型已移除')
await loadProviders()
// Refresh current provider reference if modal is open
if (currentProviderForModels.value?.provider_id === providerId) {
currentProviderForModels.value = providers.value.find((p) => p.provider_id === providerId)
}
} catch (error) {
message.error(error.message || '移除失败')
}
}
})
}
// ============ Lifecycle ============
onMounted(loadProviders)
</script>
<template>
<div class="model-config-view">
<!-- Page Header -->
<div class="page-header">
<div>
<p class="eyebrow">Model Registry</p>
<h1>模型配置</h1>
</div>
<div class="summary-strip">
<span>{{ providerStats.total }} 个供应商</span>
<span>{{ providerStats.enabled }} 个启用</span>
<span v-if="providerStats.warning > 0" class="warning-count"
>{{ providerStats.warning }} 个凭证缺失</span
>
<span>{{ providerStats.models }} 个模型</span>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<a-input
v-model:value="searchQuery"
class="search-input"
placeholder="搜索供应商..."
allow-clear
>
<template #prefix><Search :size="14" /></template>
</a-input>
<div class="toolbar-actions">
<a-button type="primary" class="lucide-icon-btn" @click="openCreateProviderModal">
<Plus :size="14" />
新增供应商
</a-button>
<a-tooltip title="刷新供应商列表">
<a-button class="lucide-icon-btn" :disabled="loading" @click="loadProviders">
<RefreshCw :size="14" :class="{ spinning: loading }" />
</a-button>
</a-tooltip>
</div>
</div>
<!-- Provider Card Grid -->
<div class="provider-grid">
<div
v-for="provider in filteredProviders"
:key="provider.provider_id"
class="provider-card"
:class="{ disabled: !provider.is_enabled }"
>
<!-- Card Header -->
<div class="card-header" @click="openEditProviderModal(provider)">
<div class="provider-icon">
<img
v-if="getProviderIcon(provider)"
:src="getIconUrl(getProviderIcon(provider))"
:alt="provider.display_name"
/>
<Globe v-else :size="20" />
</div>
<div class="provider-info">
<span class="provider-name">{{ provider.display_name }}</span>
<span class="provider-id">{{ provider.provider_id }}</span>
</div>
<span v-if="!provider.is_enabled" class="provider-status">未启用</span>
<span v-else-if="provider.credential_status === 'warning'" class="provider-status warning"
>凭证缺失</span
>
<span
class="status-dot"
:class="{
off: !provider.is_enabled,
warning: provider.credential_status === 'warning' && provider.is_enabled
}"
></span>
</div>
<!-- Card Body -->
<div class="card-body">
<div class="info-row">
<span class="info-label">Base URL</span>
<span class="info-value truncate">{{ provider.base_url || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">类型</span>
<span class="info-value">{{ provider.provider_type || 'openai' }}</span>
</div>
<div class="info-row">
<span class="info-label">已启用</span>
<span class="info-value clickable" @click.stop="openModelsModal(provider)">
{{ provider.enabled_models?.length || 0 }} 个模型
</span>
</div>
</div>
<!-- Card Footer -->
<div class="card-footer">
<button class="view-models-btn" type="button" @click="openModelsModal(provider)">
<Settings2 :size="14" />
管理模型
</button>
<div class="card-actions" @click.stop>
<a-tooltip title="编辑供应商">
<a-button
size="small"
class="lucide-icon-btn"
@click="openEditProviderModal(provider)"
>
<Edit3 :size="14" />
</a-button>
</a-tooltip>
</div>
</div>
</div>
<!-- Empty State -->
<a-empty v-if="!filteredProviders.length" :image="false" description="暂无供应商">
<a-button type="primary" class="lucide-icon-btn" @click="openCreateProviderModal">
<Plus :size="14" />
新增供应商
</a-button>
</a-empty>
</div>
<!-- Provider Edit Modal -->
<a-modal
v-model:open="showProviderModal"
:title="editingProviderId ? '编辑供应商' : '新增供应商'"
:width="560"
:confirm-loading="saving"
>
<template #footer>
<div class="provider-modal-footer">
<a-button
v-if="editingProviderId"
danger
class="lucide-icon-btn"
@click="deleteProviderFromEdit"
>
<Trash2 :size="14" />
删除供应商
</a-button>
<span v-else></span>
<div class="provider-modal-footer-actions">
<a-button @click="showProviderModal = false">取消</a-button>
<a-button
type="primary"
:loading="saving"
@click="editingProviderId ? saveProvider() : createProvider()"
>
确认
</a-button>
</div>
</div>
</template>
<div class="modal-form">
<div class="form-row">
<label class="form-label">
<span>Provider ID</span>
<a-input
v-model:value="providerForm.provider_id"
:disabled="!!editingProviderId"
placeholder="my-provider"
/>
</label>
<label class="form-label">
<span>展示名称</span>
<a-input v-model:value="providerForm.display_name" placeholder="My Provider" />
</label>
</div>
<div class="form-row">
<label class="form-label">
<span>Base URL</span>
<a-input
v-model:value="providerForm.base_url"
placeholder="https://api.example.com/v1"
/>
</label>
<label class="form-label">
<span>Provider Type</span>
<a-select v-model:value="providerForm.provider_type">
<a-select-option value="openai">openai</a-select-option>
<a-select-option value="anthropic">anthropic</a-select-option>
<a-select-option value="gemini">gemini</a-select-option>
<a-select-option value="ollama">ollama</a-select-option>
<a-select-option value="openrouter">openrouter</a-select-option>
<a-select-option value="lmstudio">lmstudio</a-select-option>
</a-select>
</label>
</div>
<div class="form-row">
<label class="form-label">
<span>API Key Env</span>
<a-input v-model:value="providerForm.api_key_env" placeholder="环境变量名" />
</label>
<label class="form-label">
<span>API Key</span>
<a-input-password v-model:value="providerForm.api_key" />
</label>
</div>
<div class="form-row">
<label class="form-label">
<span>Models Endpoint</span>
<a-input v-model:value="providerForm.models_endpoint" placeholder="/models" />
</label>
</div>
<template v-if="providerForm.capabilities.includes('embedding')">
<div class="form-row">
<label class="form-label">
<span>Embedding Base URL</span>
<a-input
v-model:value="providerForm.embedding_base_url"
placeholder="https://api.example.com/v1/embeddings"
/>
</label>
<label class="form-label">
<span>Embedding Endpoint</span>
<a-input
v-model:value="providerForm.embedding_models_endpoint"
placeholder="/embeddings/models"
/>
</label>
</div>
</template>
<template v-if="providerForm.capabilities.includes('rerank')">
<div class="form-row">
<label class="form-label">
<span>Rerank Base URL</span>
<a-input
v-model:value="providerForm.rerank_base_url"
placeholder="https://api.example.com/v1/rerank"
/>
</label>
<label class="form-label">
<span>Rerank Endpoint</span>
<a-input
v-model:value="providerForm.rerank_models_endpoint"
placeholder="按供应商文档填写,留空则不自动加载"
/>
</label>
</div>
</template>
<label class="form-label full-width">
<span>能力</span>
<a-select v-model:value="providerForm.capabilities" mode="multiple">
<a-select-option value="chat">chat</a-select-option>
<a-select-option value="embedding">embedding</a-select-option>
<a-select-option value="rerank">rerank</a-select-option>
</a-select>
</label>
<div class="form-switch">
<span>状态</span>
<a-switch
v-model:checked="providerForm.is_enabled"
checked-children="启用"
un-checked-children="停用"
/>
</div>
<a-collapse expand-icon-position="end" :ghost="true" class="advanced-collapse">
<a-collapse-panel key="advanced" header="高级配置">
<label class="form-label full-width">
<span>请求头 JSON</span>
<a-textarea v-model:value="providerForm.headers_text" :rows="4" placeholder="{}" />
</label>
<label class="form-label full-width">
<span>扩展配置 JSON</span>
<a-textarea v-model:value="providerForm.extra_text" :rows="4" placeholder="{}" />
</label>
</a-collapse-panel>
</a-collapse>
</div>
</a-modal>
<!-- Models Management Modal -->
<a-modal
v-model:open="showModelsModal"
:title="
currentProviderForModels
? `${currentProviderForModels.display_name} - 模型配置`
: '模型配置'
"
:width="800"
:footer="null"
>
<div v-if="currentProviderForModels" class="models-modal-content">
<!-- Enabled Models Section -->
<div class="models-section">
<h4 class="models-section-title">
已启用模型 ({{ currentProviderForModels.enabled_models?.length || 0 }})
</h4>
<div class="models-table" v-if="currentProviderForModels.enabled_models?.length">
<div class="table-head">
<span class="col-name">模型</span>
<span class="col-type">类型</span>
<span class="col-context">上下文</span>
<span class="col-dim">维度</span>
<span class="col-ops">操作</span>
</div>
<div
v-for="model in currentProviderForModels.enabled_models"
:key="model.id"
class="table-row"
:class="{ stale: isModelStale(model, currentProviderForModels.provider_id) }"
>
<div class="model-info">
<span class="model-name">{{ getModelDisplayName(model) }}</span>
<span class="model-id">{{ getModelId(model) }}</span>
</div>
<span class="col-type">
<span class="type-tag" :class="model.type">{{ model.type }}</span>
</span>
<span class="col-context">{{ formatContextLength(model.context_length) }}</span>
<span class="col-dim">
<span
v-if="model.type === 'embedding' && !model.dimension"
class="dim-warning"
title="缺少维度配置"
>⚠</span
>
<span v-else>{{ model.dimension || '-' }}</span>
</span>
<span class="col-ops">
<a-button size="small" class="lucide-icon-btn" @click="openModelConfigModal(model)">
<Settings2 :size="13" />
</a-button>
<a-button
size="small"
danger
class="lucide-icon-btn"
@click="removeModel(currentProviderForModels.provider_id, model.id)"
>
<Trash2 :size="13" />
</a-button>
</span>
</div>
</div>
<a-empty v-else :image="false" description="暂无已启用模型" />
</div>
<!-- Remote Models Section -->
<div class="models-section">
<div class="remote-header">
<h4 class="models-section-title">远端候选模型 ({{ filteredRemoteModels.length }})</h4>
<a-input
v-if="remoteModelsMap[currentProviderForModels.provider_id]?.length"
v-model:value="remoteModelSearch[currentProviderForModels.provider_id]"
class="remote-search-input"
placeholder="搜索模型..."
allow-clear
>
<template #prefix><Search :size="12" /></template>
</a-input>
<a-segmented
v-if="remoteModelsMap[currentProviderForModels.provider_id]?.length"
v-model:value="remoteModelTypeFilter[currentProviderForModels.provider_id]"
:options="remoteModelTypeOptions"
class="remote-type-filter"
/>
</div>
<div
class="remote-list"
v-if="remoteModelsMap[currentProviderForModels.provider_id]?.length"
>
<div
v-for="remoteModel in filteredRemoteModels"
:key="remoteModel.id"
class="remote-row"
>
<span class="remote-name">{{ getModelDisplayName(remoteModel) }}</span>
<div class="remote-tags">
<span class="type-tag" :class="remoteModel.type || 'chat'">
{{ remoteModel.type || 'chat' }}
</span>
<template
v-for="mod in (getInputModalities(remoteModel) || [])"
:key="mod"
>
<span class="modality-tag">{{ mod }}</span>
</template>
</div>
<span class="remote-context">{{
formatContextLength(remoteModel.context_length)
}}</span>
<span class="remote-price" v-if="formatMtokenPrice(remoteModel.pricing)">
{{ formatPriceDisplay(remoteModel.pricing) }}
</span>
<span class="remote-price placeholder" v-else>N/A</span>
<a-button
size="small"
:type="
currentProviderForModels.enabled_models?.some((m) => m.id === remoteModel.id)
? 'primary'
: 'default'
"
class="lucide-icon-btn"
:disabled="
currentProviderForModels.enabled_models?.some((m) => m.id === remoteModel.id)
"
@click="addModelFromRemote(currentProviderForModels.provider_id, remoteModel)"
>
<CheckCircle2
:size="13"
v-if="
currentProviderForModels.enabled_models?.some((m) => m.id === remoteModel.id)
"
/>
<Plus :size="13" v-else />
</a-button>
</div>
</div>
<div class="remote-fetch-actions">
<a-button
type="primary"
class="lucide-icon-btn"
:loading="remoteLoading"
@click="fetchRemoteModels(currentProviderForModels.provider_id)"
>
<RefreshCw :size="14" v-if="!remoteLoading" />
获取远程模型
</a-button>
</div>
</div>
</div>
</a-modal>
<!-- Model Config Modal -->
<a-modal
v-model:open="showModelModal"
title="模型配置"
:width="520"
:confirm-loading="saving"
@ok="saveModelConfig"
>
<div class="modal-form">
<div class="model-id-display">
<span class="info-label">模型 ID</span>
<code>{{ editingModel.id }}</code>
</div>
<div class="form-row">
<label class="form-label">
<span>展示名称</span>
<a-input v-model:value="editingModel.display_name" />
</label>
<label class="form-label">
<span>模型类型</span>
<a-select v-model:value="editingModel.type">
<a-select-option value="chat">chat</a-select-option>
<a-select-option value="embedding">embedding</a-select-option>
<a-select-option value="rerank">rerank</a-select-option>
</a-select>
</label>
</div>
<div class="form-row">
<label class="form-label">
<span>协议覆盖</span>
<a-input v-model:value="editingModel.protocol_override" placeholder="可选" />
</label>
<label class="form-label">
<span>Base URL 覆盖</span>
<a-input v-model:value="editingModel.base_url_override" placeholder="可选" />
</label>
</div>
<div class="form-row">
<label class="form-label" v-if="editingModel.type === 'embedding'">
<span>维度</span>
<a-input-number v-model:value="editingModel.dimension" :min="1" />
</label>
<label
class="form-label"
v-if="editingModel.type === 'embedding' || editingModel.type === 'rerank'"
>
<span>Batch Size</span>
<a-input-number v-model:value="editingModel.batch_size" :min="1" />
</label>
</div>
</div>
</a-modal>
</div>
</template>
<style lang="less" scoped>
.model-config-view {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--gray-0);
color: var(--gray-1000);
}
// ============ Page Header ============
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px 18px;
border-bottom: 1px solid var(--gray-100);
h1 {
margin: 0;
font-size: 28px;
font-weight: 720;
line-height: 34px;
}
}
.eyebrow {
margin: 0 0 4px;
color: var(--main-700);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.summary-strip {
display: flex;
gap: 8px;
span {
padding: 6px 10px;
border: 1px solid var(--gray-100);
border-radius: 7px;
background: var(--gray-10);
color: var(--gray-700);
font-size: 12px;
line-height: 18px;
}
.warning-count {
background: var(--color-warning-50);
border-color: var(--color-warning-100);
color: var(--color-warning-700);
}
}
// ============ Toolbar ============
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 32px;
border-bottom: 1px solid var(--gray-100);
}
.search-input {
width: 280px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// ============ Provider Grid ============
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px 32px;
}
// ============ Provider Card ============
.provider-card {
display: flex;
flex-direction: column;
background: var(--gray-0);
border: 1px solid var(--gray-150);
border-radius: 8px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: var(--main-100);
}
&.expanded {
border-color: var(--main-500);
box-shadow: 0 0 0 2px var(--main-50);
}
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
cursor: pointer;
}
.provider-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--main-50);
overflow: hidden;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
:deep(svg) {
color: var(--main-700);
}
}
.provider-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.provider-name {
font-size: 15px;
font-weight: 600;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-id {
font-size: 12px;
color: var(--gray-600);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success-500);
flex-shrink: 0;
&.off {
background: var(--gray-300);
}
&.warning {
background: var(--color-warning-500);
}
}
.provider-status {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 4px;
background: var(--gray-50);
color: var(--gray-500);
font-size: 11px;
line-height: 16px;
&.warning {
background: var(--color-warning-50);
color: var(--color-warning-700);
}
}
.card-body {
padding: 0 16px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
.info-label {
color: var(--gray-500);
min-width: 72px;
flex-shrink: 0;
}
.info-value {
color: var(--gray-700);
min-width: 0;
&.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--gray-100);
background: var(--gray-10);
border-radius: 0 0 7px 7px;
}
.view-models-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--main-700);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
&:hover {
background: var(--main-50);
}
}
.clickable {
color: var(--main-700);
cursor: pointer;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.card-actions {
display: flex;
gap: 4px;
}
// ============ Models Modal ============
.models-modal-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.models-modal-actions {
display: flex;
gap: 8px;
}
.models-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.models-section-title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--gray-700);
}
.models-actions {
display: flex;
gap: 8px;
}
// ============ Models Table ============
.models-table {
display: flex;
flex-direction: column;
border: 1px solid var(--gray-150);
border-radius: 6px;
overflow: hidden;
}
.table-head {
display: grid;
grid-template-columns: 1fr 80px 70px 60px 110px;
gap: 8px;
padding: 10px 12px;
background: var(--gray-50);
font-size: 11px;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table-row {
display: grid;
grid-template-columns: 1fr 80px 70px 60px 110px;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--gray-100);
font-size: 13px;
align-items: center;
transition: background 0.1s;
&:hover {
background: var(--gray-10);
}
&.stale {
background: var(--color-warning-50);
}
}
.model-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.model-name {
font-weight: 500;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-id {
font-size: 11px;
color: var(--gray-500);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 12px;
}
.col-type {
display: flex;
align-items: center;
}
.col-context,
.col-dim {
color: var(--gray-600);
font-size: 12px;
}
.col-status {
display: flex;
align-items: center;
}
.col-ops {
display: flex;
gap: 4px;
justify-content: flex-end;
}
// ============ Type Tags ============
.type-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
&.chat {
background: var(--color-info-50);
color: var(--color-info-700);
}
&.embedding {
background: var(--color-success-50);
color: var(--color-success-700);
}
&.rerank {
background: var(--color-warning-50);
color: var(--color-warning-900);
}
}
// ============ Status Badges ============
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
&.active {
color: var(--color-success-700);
}
&.stale {
color: var(--color-warning-700);
}
}
// ============ Remote Section ============
.remote-section {
margin-top: 16px;
border: 1px solid var(--gray-150);
border-radius: 6px;
overflow: hidden;
}
.remote-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: var(--gray-50);
color: var(--gray-700);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: var(--gray-100);
}
}
.remote-list {
border-top: 1px solid var(--gray-100);
}
.remote-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.models-section-title {
margin: 0;
flex-shrink: 0;
}
}
.remote-search-input {
width: 180px;
}
.remote-type-filter {
flex-shrink: 0;
}
.remote-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--gray-100);
font-size: 13px;
&:first-child {
border-top: none;
}
}
.remote-name {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-tags {
display: flex;
align-items: center;
gap: 4px;
}
.modality-tag {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
background: var(--color-accent-50);
color: var(--color-accent-700);
font-size: 10px;
font-weight: 500;
}
.dim-warning {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
background: var(--color-warning-50);
color: var(--color-warning-700);
font-size: 10px;
font-weight: 500;
}
.remote-context {
width: 60px;
color: var(--gray-500);
font-size: 12px;
}
.remote-price {
width: 100px;
font-size: 11px;
color: var(--gray-600);
font-family: monospace;
&.placeholder {
color: var(--gray-400);
}
}
.models-empty {
padding: 24px;
}
.remote-fetch-actions {
display: flex;
justify-content: flex-start;
padding-top: 8px;
border-top: 1px solid var(--gray-100);
}
// ============ Modal Form ============
.modal-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-label {
display: flex;
flex-direction: column;
gap: 6px;
> span {
color: var(--gray-700);
font-size: 12px;
font-weight: 500;
}
}
.full-width {
grid-column: 1 / -1;
}
.form-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
> span {
color: var(--gray-700);
font-size: 12px;
font-weight: 500;
}
}
.advanced-collapse {
:deep(.ant-collapse-content-box) {
padding-inline: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
:deep(.ant-collapse-header) {
padding-inline: 0;
}
}
.provider-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.provider-modal-footer-actions {
display: flex;
gap: 8px;
}
.model-id-display {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: var(--gray-50);
border-radius: 6px;
margin-bottom: 4px;
.info-label {
color: var(--gray-500);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
code {
font-family: monospace;
font-size: 13px;
color: var(--gray-800);
}
}
// ============ Responsive ============
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 20px 16px;
}
.toolbar {
padding: 12px 16px;
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
.provider-grid {
grid-template-columns: 1fr;
padding: 16px;
}
.table-head,
.table-row {
grid-template-columns: 1fr 60px 60px;
font-size: 12px;
}
.col-context,
.col-dim {
display: none;
}
}
</style>