1676 lines
44 KiB
Vue
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>
|