e761074fb9
CI / docker (push) Successful in 3m46s
Merge model_id/alias in the image provider UI, and ensure ComfyUI never receives seed -1 after cloneMap type coercion. Co-authored-by: Cursor <cursoragent@cursor.com>
745 lines
30 KiB
Vue
745 lines
30 KiB
Vue
<template>
|
|
<div class="page">
|
|
<PageHeader
|
|
:title="t('providers.title')"
|
|
:description="t('providers.description')"
|
|
>
|
|
<template #actions>
|
|
<el-button type="primary" :disabled="!currentCategorySupported" @click="openCreate">
|
|
{{ t('providers.addProvider') }}
|
|
</el-button>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<AppCard>
|
|
<div class="tabs-bar">
|
|
<el-tabs v-model="activeCategory" @tab-change="load">
|
|
<el-tab-pane v-for="cat in categories" :key="cat.key" :name="cat.key">
|
|
<template #label>
|
|
{{ cat.label }}
|
|
<el-tag v-if="!cat.supported" size="small" type="info" class="soon-tag">{{ t('common.soon') }}</el-tag>
|
|
</template>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</div>
|
|
|
|
<el-table :data="providers" v-loading="loading" row-key="id" size="small">
|
|
<el-table-column type="expand">
|
|
<template #default="{ row }">
|
|
<div class="models-expand">
|
|
<div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div>
|
|
<el-table v-else :data="row.models" size="small" class="models-table">
|
|
<template v-if="row.category === 'image'">
|
|
<el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
|
|
<template #default="{ row: m }">{{ modelIngressId(m) }}</template>
|
|
</el-table-column>
|
|
</template>
|
|
<template v-else>
|
|
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
|
|
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
|
|
<template #default="{ row: m }">{{ m.alias || '—' }}</template>
|
|
</el-table-column>
|
|
</template>
|
|
<el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
|
|
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
|
|
<el-table-column :label="t('common.enabled')" width="80">
|
|
<template #default="{ row: m }">
|
|
<el-switch
|
|
:model-value="m.enabled"
|
|
size="small"
|
|
@change="(v: boolean) => toggleModel(m, v, row)"
|
|
/>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
|
|
<template #default="{ row: m }">{{ formatDateTime(m.created_at) }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
|
<el-table-column prop="type" :label="t('providers.protocolType')" width="130">
|
|
<template #default="{ row }">{{ protocolTypeLabel(row.type) }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.category')" width="100">
|
|
<template #default="{ row }">{{ categoryLabel(row.category) }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('providers.models')" width="120">
|
|
<template #default="{ row }">
|
|
<el-tag size="small" :type="enabledModelCount(row) > 0 ? 'success' : 'info'">
|
|
{{ enabledModelCount(row) }}/{{ row.models?.length || 0 }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="base_url" label="Base URL" show-overflow-tooltip min-width="160" />
|
|
<el-table-column :label="t('providers.enabledEndpoints')" min-width="180">
|
|
<template #default="{ row }">
|
|
<el-tag v-for="ep in parseList(row.allowed_endpoints_json)" :key="ep" size="small" class="ep-tag">{{ ep }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="status" :label="t('common.status')" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.status === 'healthy' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.enabled')" width="80">
|
|
<template #default="{ row }">
|
|
<el-switch :model-value="row.enabled !== false" size="small" @change="(v: boolean) => toggleProvider(row, v)" />
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.actions')" width="320" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button size="small" link @click="openModels(row)">{{ t('providers.modelsAction') }}</el-button>
|
|
<el-button size="small" link @click="openEndpoints(row)">{{ t('providers.endpointsAction') }}</el-button>
|
|
<el-button size="small" link @click="openKeys(row)">{{ t('providers.keysAction') }}</el-button>
|
|
<el-button v-if="row.category !== 'image' && providerHasListModels(row)" size="small" link @click="syncModels(row)">{{ t('providers.syncModels') }}</el-button>
|
|
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</AppCard>
|
|
|
|
<el-dialog v-model="showCreate" :title="t('providers.addDialogTitle')" width="640px" destroy-on-close>
|
|
<el-form label-width="110px">
|
|
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
|
|
<el-form-item :label="t('common.category')">
|
|
<el-select v-model="form.category" @change="onCategoryChange">
|
|
<el-option v-for="cat in categories" :key="cat.key" :label="cat.label" :value="cat.key" :disabled="!cat.supported" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item :label="t('providers.protocolType')">
|
|
<el-select v-model="form.type" @change="onProtocolTypeChange">
|
|
<el-option
|
|
v-for="pt in createProviderTypes"
|
|
:key="pt.key"
|
|
:label="pt.label"
|
|
:value="pt.key"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="form.base_url" :placeholder="t('providers.baseUrlPlaceholder')" /></el-form-item>
|
|
<template v-if="form.category !== 'image'">
|
|
<el-divider>{{ t('providers.endpointDivider') }}</el-divider>
|
|
<div v-for="ep in createEndpointMeta" :key="ep.key" class="endpoint-row">
|
|
<el-checkbox v-model="form.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
|
|
<el-input v-model="form.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
|
|
</div>
|
|
</template>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showCreate = false">{{ t('common.cancel') }}</el-button>
|
|
<el-button type="primary" @click="create">{{ t('common.create') }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-drawer v-model="endpointVisible" :title="t('providers.endpointConfigTitle', { name: current?.name || '' })" size="50%">
|
|
<el-form label-width="110px">
|
|
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="editForm.base_url" /></el-form-item>
|
|
<template v-if="current?.category !== 'image'">
|
|
<el-divider>{{ t('providers.endpointEditDivider') }}</el-divider>
|
|
<p class="form-hint">{{ t('providers.pathHint') }}</p>
|
|
<div v-for="ep in editEndpointMeta" :key="ep.key" class="endpoint-row">
|
|
<el-checkbox v-model="editForm.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
|
|
<el-input v-model="editForm.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
|
|
</div>
|
|
</template>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button type="primary" @click="saveEndpoints">{{ t('common.save') }}</el-button>
|
|
</template>
|
|
</el-drawer>
|
|
|
|
<el-drawer v-model="keysVisible" :title="t('providers.keysTitle', { name: current?.name || '' })" size="50%">
|
|
<div class="drawer-toolbar">
|
|
<el-button type="primary" size="small" @click="showKeyForm = true">{{ t('providers.addKey') }}</el-button>
|
|
</div>
|
|
<el-table :data="keys" size="small">
|
|
<el-table-column prop="name" :label="t('common.name')" />
|
|
<el-table-column prop="weight" :label="t('common.weight')" width="80" />
|
|
<el-table-column prop="enabled" :label="t('common.enabled')" width="80">
|
|
<template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.yes') : t('common.no') }}</el-tag></template>
|
|
</el-table-column>
|
|
<el-table-column prop="requests_today" :label="t('providers.requestsToday')" />
|
|
<el-table-column :label="t('common.actions')" width="100">
|
|
<template #default="{ row }">
|
|
<el-button size="small" type="danger" link @click="deleteKey(row)">{{ t('common.delete') }}</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-drawer>
|
|
|
|
<el-dialog v-model="showKeyForm" :title="t('providers.addKeyDialogTitle')" width="480px" destroy-on-close>
|
|
<el-form label-width="120px">
|
|
<el-form-item :label="t('common.name')"><el-input v-model="keyForm.name" /></el-form-item>
|
|
<el-form-item :label="t('providers.apiKey')"><el-input v-model="keyForm.api_key" type="password" show-password /></el-form-item>
|
|
<el-form-item :label="t('common.weight')"><el-input-number v-model="keyForm.weight" :min="0.1" :step="0.1" /></el-form-item>
|
|
<el-form-item :label="t('providers.modelWhitelist')"><el-input v-model="keyForm.models" :placeholder="t('providers.modelWhitelistPlaceholder')" /></el-form-item>
|
|
<el-form-item :label="t('providers.maxRequestsPerDay')"><el-input-number v-model="keyForm.max_requests_per_day" :min="0" /></el-form-item>
|
|
<el-form-item :label="t('providers.maxTokensPerDay')"><el-input-number v-model="keyForm.max_tokens_per_day" :min="0" /></el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showKeyForm = false">{{ t('common.cancel') }}</el-button>
|
|
<el-button type="primary" @click="createKey">{{ t('common.add') }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-drawer v-model="modelsVisible" :title="t('providers.modelsTitle', { name: current?.name || '' })" size="50%">
|
|
<div class="drawer-toolbar">
|
|
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="showWorkflowDialog = true">
|
|
{{ t('providers.addWorkflowModel') }}
|
|
</el-button>
|
|
<template v-else-if="current">
|
|
<el-button v-if="providerHasListModels(current)" type="primary" size="small" @click="syncModels(current)">
|
|
{{ t('providers.syncModels') }}
|
|
</el-button>
|
|
<el-button type="primary" size="small" plain @click="openAddModel">{{ t('providers.addModel') }}</el-button>
|
|
</template>
|
|
<span class="form-hint u-mt-0">
|
|
{{ t('providers.modelsSummary', { total: providerModels.length, enabled: enabledProviderModelCount }) }}
|
|
</span>
|
|
</div>
|
|
<el-table :data="providerModels" size="small" v-loading="modelsLoading">
|
|
<template v-if="current?.category === 'image'">
|
|
<el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
|
|
<template #default="{ row }">{{ modelIngressId(row) }}</template>
|
|
</el-table-column>
|
|
</template>
|
|
<template v-else>
|
|
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
|
|
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
|
|
<template #default="{ row }">{{ row.alias || '—' }}</template>
|
|
</el-table-column>
|
|
</template>
|
|
<el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
|
|
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
|
|
<el-table-column :label="t('common.enabled')" width="80">
|
|
<template #default="{ row }">
|
|
<el-switch
|
|
:model-value="row.enabled"
|
|
size="small"
|
|
@change="(v: boolean) => toggleModel(row, v)"
|
|
/>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
|
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.actions')" width="120">
|
|
<template #default="{ row }">
|
|
<el-button size="small" link @click="openEditModel(row)">{{ t('common.edit') }}</el-button>
|
|
<el-button size="small" type="danger" link @click="deleteModel(row)">{{ t('common.delete') }}</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-drawer>
|
|
|
|
<el-dialog v-model="showAddModel" :title="t('providers.addModelDialogTitle')" width="480px" destroy-on-close>
|
|
<el-form label-width="100px">
|
|
<el-form-item :label="t('providers.modelId')" required>
|
|
<el-input v-model="addModelForm.model_id" placeholder="text-embedding-3-small" />
|
|
</el-form-item>
|
|
<el-form-item :label="t('providers.modelAlias')">
|
|
<el-input v-model="addModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
|
|
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
|
|
</el-form-item>
|
|
<el-form-item :label="t('providers.displayName')">
|
|
<el-input v-model="addModelForm.display_name" :placeholder="t('providers.modelId')" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showAddModel = false">{{ t('common.cancel') }}</el-button>
|
|
<el-button type="primary" @click="createModel">{{ t('common.create') }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
|
|
<el-form label-width="110px">
|
|
<template v-if="current?.category === 'image'">
|
|
<el-form-item :label="t('providers.ingressModelId')" required>
|
|
<el-input v-model="editModelForm.model_id" placeholder="luminary/flux-sdxl" />
|
|
<p class="form-hint">{{ t('providers.ingressModelIdHint') }}</p>
|
|
</el-form-item>
|
|
</template>
|
|
<template v-else>
|
|
<el-form-item :label="t('providers.upstreamModelId')">
|
|
<el-input v-model="editModelForm.model_id" />
|
|
</el-form-item>
|
|
<el-form-item :label="t('providers.modelAlias')">
|
|
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
|
|
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
|
|
</el-form-item>
|
|
</template>
|
|
<el-form-item :label="t('providers.displayName')">
|
|
<el-input v-model="editModelForm.display_name" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showEditModel = false">{{ t('common.cancel') }}</el-button>
|
|
<el-button type="primary" @click="saveEditModel">{{ t('common.save') }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<WorkflowModelDialog
|
|
v-if="current"
|
|
v-model="showWorkflowDialog"
|
|
:provider-id="current.id"
|
|
@saved="current && openModels(current)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import api, { type Provider, type ProviderKey, type ModelEntry } from '@/api/client'
|
|
import PageHeader from '@/components/PageHeader.vue'
|
|
import AppCard from '@/components/AppCard.vue'
|
|
import WorkflowModelDialog from '@/components/WorkflowModelDialog.vue'
|
|
import { formatDateTime } from '@/utils/format'
|
|
|
|
const { t } = useI18n()
|
|
|
|
interface CategoryMeta { key: string; label: string; supported: boolean }
|
|
interface ProviderTypeMeta { key: string; label: string }
|
|
interface EndpointMetaItem { key: string; label: string; method: string; ingress_path: string; default_path: string }
|
|
|
|
const categories = ref<CategoryMeta[]>([])
|
|
const createProviderTypes = ref<ProviderTypeMeta[]>([])
|
|
const createEndpointMeta = ref<EndpointMetaItem[]>([])
|
|
const editEndpointMeta = ref<EndpointMetaItem[]>([])
|
|
const providerTypeLabels = ref<Record<string, string>>({})
|
|
const activeCategory = ref('llm')
|
|
const providers = ref<Provider[]>([])
|
|
const loading = ref(false)
|
|
const showCreate = ref(false)
|
|
const form = ref({
|
|
name: '', type: 'openai', category: 'llm', base_url: '',
|
|
allowedMap: {} as Record<string, boolean>,
|
|
pathMap: {} as Record<string, string>,
|
|
})
|
|
const editForm = ref({
|
|
base_url: '',
|
|
allowedMap: {} as Record<string, boolean>,
|
|
pathMap: {} as Record<string, string>,
|
|
})
|
|
const endpointVisible = ref(false)
|
|
const current = ref<Provider | null>(null)
|
|
const keysVisible = ref(false)
|
|
const keys = ref<ProviderKey[]>([])
|
|
const showKeyForm = ref(false)
|
|
const modelsVisible = ref(false)
|
|
const modelsLoading = ref(false)
|
|
const providerModels = ref<ModelEntry[]>([])
|
|
|
|
const enabledProviderModelCount = computed(() =>
|
|
providerModels.value.filter(m => m.enabled).length,
|
|
)
|
|
const showWorkflowDialog = ref(false)
|
|
const showAddModel = ref(false)
|
|
const showEditModel = ref(false)
|
|
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
|
|
const editModelForm = ref({ id: 0, model_id: '', alias: '', display_name: '' })
|
|
const keyForm = ref({ name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 })
|
|
|
|
let createMetaSeq = 0
|
|
let editMetaSeq = 0
|
|
|
|
const currentCategorySupported = computed(() =>
|
|
categories.value.find(c => c.key === activeCategory.value)?.supported ?? true,
|
|
)
|
|
|
|
function categoryLabel(key: string) {
|
|
return categories.value.find(c => c.key === key)?.label || key
|
|
}
|
|
|
|
function protocolTypeLabel(key: string) {
|
|
return providerTypeLabels.value[key] || key
|
|
}
|
|
|
|
function defaultProviderType(category: string, types: ProviderTypeMeta[]) {
|
|
if (types.length > 0) return types[0].key
|
|
return category === 'image' ? 'comfyui' : 'openai'
|
|
}
|
|
|
|
function apiErrorMessage(error: string | undefined, fallback: string) {
|
|
if (error === 'provider name already exists') return t('providers.providerNameExists')
|
|
if (error === 'model alias already exists') return t('providers.modelAliasExists')
|
|
return error || fallback
|
|
}
|
|
|
|
function providerHasListModels(row: Provider) {
|
|
return parseList(row.allowed_endpoints_json).includes('list_models')
|
|
}
|
|
|
|
async function fetchEndpointMeta(category: string, type?: string) {
|
|
const res = await api.get('/endpoint-meta', { params: { category, type: type || undefined } })
|
|
return {
|
|
categories: (res.data.categories || []) as CategoryMeta[],
|
|
providerTypes: (res.data.provider_types || []) as ProviderTypeMeta[],
|
|
allProviderTypes: (res.data.all_provider_types || []) as ProviderTypeMeta[],
|
|
endpoints: (res.data.endpoints || []) as EndpointMetaItem[],
|
|
}
|
|
}
|
|
|
|
function applyDefaultEndpoints(
|
|
category: string,
|
|
endpoints: EndpointMetaItem[],
|
|
allowedMap: Record<string, boolean>,
|
|
pathMap: Record<string, string>,
|
|
reset: boolean,
|
|
) {
|
|
const defaultKeys = defaultEndpointsForCategory(category)
|
|
for (const ep of endpoints) {
|
|
if (reset || allowedMap[ep.key] === undefined) {
|
|
allowedMap[ep.key] = defaultKeys.includes(ep.key)
|
|
}
|
|
if (reset || pathMap[ep.key] === undefined) {
|
|
pathMap[ep.key] = pathMap[ep.key] || ''
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadCreateEndpointMeta(category: string, type: string, reset = false) {
|
|
const seq = ++createMetaSeq
|
|
const data = await fetchEndpointMeta(category, type)
|
|
if (seq !== createMetaSeq) return data
|
|
if (!categories.value.length) categories.value = data.categories
|
|
createProviderTypes.value = data.providerTypes
|
|
for (const pt of data.providerTypes) providerTypeLabels.value[pt.key] = pt.label
|
|
createEndpointMeta.value = data.endpoints
|
|
applyDefaultEndpoints(category, data.endpoints, form.value.allowedMap, form.value.pathMap, reset)
|
|
return data
|
|
}
|
|
|
|
async function loadEditEndpointMeta(category: string, type: string) {
|
|
const seq = ++editMetaSeq
|
|
const data = await fetchEndpointMeta(category, type)
|
|
if (seq !== editMetaSeq) return data
|
|
editEndpointMeta.value = data.endpoints
|
|
return data
|
|
}
|
|
|
|
function parseList(json: string) {
|
|
try { return JSON.parse(json || '[]') } catch { return [] }
|
|
}
|
|
|
|
function parsePaths(json: string): Record<string, string> {
|
|
try { return JSON.parse(json || '{}') } catch { return {} }
|
|
}
|
|
|
|
function enabledModelCount(row: Provider) {
|
|
return (row.models ?? []).filter(m => m.enabled).length
|
|
}
|
|
|
|
function syncModelState(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
|
|
m.enabled = enabled
|
|
if (providerRow?.models) {
|
|
const item = providerRow.models.find(x => x.id === m.id)
|
|
if (item) item.enabled = enabled
|
|
}
|
|
if (current.value?.id === m.provider_id) {
|
|
const item = providerModels.value.find(x => x.id === m.id)
|
|
if (item) item.enabled = enabled
|
|
}
|
|
const provider = providers.value.find(p => p.id === m.provider_id)
|
|
if (provider?.models) {
|
|
const item = provider.models.find(x => x.id === m.id)
|
|
if (item) item.enabled = enabled
|
|
}
|
|
}
|
|
|
|
async function toggleModel(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
|
|
if (!m.id || !m.provider_id) {
|
|
ElMessage.warning(t('providers.modelNotLoaded'))
|
|
return
|
|
}
|
|
const prev = m.enabled
|
|
syncModelState(m, enabled, providerRow)
|
|
try {
|
|
await api.put(`/providers/${m.provider_id}/models/${m.id}`, { enabled, model_id: m.model_id })
|
|
ElMessage.success(enabled ? t('providers.modelEnabled') : t('providers.modelDisabled'))
|
|
} catch (e: any) {
|
|
syncModelState(m, prev, providerRow)
|
|
const msg = e.response?.data?.error
|
|
ElMessage.error(msg === 'not found' || msg === 'model not found' ? t('providers.modelNotFound') : (msg || t('providers.updateFailed')))
|
|
}
|
|
}
|
|
|
|
async function onProtocolTypeChange() {
|
|
await nextTick()
|
|
const allowedMap = { ...form.value.allowedMap }
|
|
const pathMap = { ...form.value.pathMap }
|
|
await loadCreateEndpointMeta(form.value.category, form.value.type)
|
|
for (const ep of createEndpointMeta.value) {
|
|
if (allowedMap[ep.key] !== undefined) form.value.allowedMap[ep.key] = allowedMap[ep.key]
|
|
if (pathMap[ep.key] !== undefined) form.value.pathMap[ep.key] = pathMap[ep.key]
|
|
}
|
|
}
|
|
|
|
function defaultEndpointsForCategory(category: string) {
|
|
switch (category) {
|
|
case 'embedding':
|
|
return ['list_models', 'embeddings']
|
|
case 'rerank':
|
|
return ['list_models', 'rerank']
|
|
case 'image':
|
|
return []
|
|
default:
|
|
return ['list_models', 'chat_completion']
|
|
}
|
|
}
|
|
|
|
function onCategoryChange() {
|
|
void (async () => {
|
|
form.value.allowedMap = {}
|
|
form.value.pathMap = {}
|
|
const meta = await fetchEndpointMeta(form.value.category)
|
|
form.value.type = defaultProviderType(form.value.category, meta.providerTypes)
|
|
await loadCreateEndpointMeta(form.value.category, form.value.type, true)
|
|
})()
|
|
}
|
|
|
|
async function toggleProvider(row: Provider, enabled: boolean) {
|
|
await api.put(`/providers/${row.id}`, { enabled })
|
|
row.enabled = enabled
|
|
ElMessage.success(enabled ? t('providers.providerEnabled') : t('providers.providerDisabled'))
|
|
}
|
|
|
|
function buildPayload(baseURL: string, allowedMap: Record<string, boolean>, pathMap: Record<string, string>) {
|
|
const allowed_endpoints = Object.keys(allowedMap).filter(k => allowedMap[k])
|
|
const endpoint_paths: Record<string, string> = {}
|
|
for (const [k, v] of Object.entries(pathMap)) {
|
|
if (v.trim()) endpoint_paths[k] = v.trim()
|
|
}
|
|
return { base_url: baseURL, allowed_endpoints, endpoint_paths }
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const res = await api.get('/providers', { params: { category: activeCategory.value } })
|
|
providers.value = (res.data.data ?? []).map((p: Provider) => ({
|
|
...p,
|
|
models: p.models ?? [],
|
|
}))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function openCreate() {
|
|
form.value = {
|
|
name: '',
|
|
type: 'openai',
|
|
category: activeCategory.value,
|
|
base_url: '',
|
|
allowedMap: {},
|
|
pathMap: {},
|
|
}
|
|
const meta = await fetchEndpointMeta(activeCategory.value)
|
|
if (!categories.value.length) categories.value = meta.categories
|
|
for (const pt of meta.providerTypes) providerTypeLabels.value[pt.key] = pt.label
|
|
form.value.type = defaultProviderType(activeCategory.value, meta.providerTypes)
|
|
await loadCreateEndpointMeta(activeCategory.value, form.value.type, true)
|
|
showCreate.value = true
|
|
}
|
|
|
|
async function create() {
|
|
const payload = {
|
|
name: form.value.name,
|
|
type: form.value.type,
|
|
category: form.value.category,
|
|
...buildPayload(form.value.base_url, form.value.allowedMap, form.value.pathMap),
|
|
}
|
|
try {
|
|
await api.post('/providers', payload)
|
|
} catch (e: any) {
|
|
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
|
|
return
|
|
}
|
|
showCreate.value = false
|
|
ElMessage.success(t('providers.createSuccess'))
|
|
load()
|
|
}
|
|
|
|
async function openEndpoints(row: Provider) {
|
|
current.value = row
|
|
await loadEditEndpointMeta(row.category || 'llm', row.type)
|
|
const allowed = parseList(row.allowed_endpoints_json)
|
|
const paths = parsePaths(row.endpoint_paths_json)
|
|
const allowedMap: Record<string, boolean> = {}
|
|
const pathMap: Record<string, string> = {}
|
|
for (const ep of editEndpointMeta.value) {
|
|
allowedMap[ep.key] = allowed.includes(ep.key)
|
|
pathMap[ep.key] = paths[ep.key] || ''
|
|
}
|
|
editForm.value = { base_url: row.base_url, allowedMap, pathMap }
|
|
endpointVisible.value = true
|
|
}
|
|
|
|
async function saveEndpoints() {
|
|
if (!current.value) return
|
|
await api.put(`/providers/${current.value.id}`, buildPayload(
|
|
editForm.value.base_url,
|
|
editForm.value.allowedMap,
|
|
editForm.value.pathMap,
|
|
))
|
|
endpointVisible.value = false
|
|
ElMessage.success(t('providers.saved'))
|
|
load()
|
|
}
|
|
|
|
async function remove(row: Provider) {
|
|
await ElMessageBox.confirm(t('common.confirmDelete'))
|
|
await api.delete(`/providers/${row.id}`)
|
|
ElMessage.success(t('providers.deleted'))
|
|
load()
|
|
}
|
|
|
|
async function openKeys(row: Provider) {
|
|
current.value = row
|
|
const res = await api.get(`/providers/${row.id}/keys`)
|
|
keys.value = res.data.data
|
|
keysVisible.value = true
|
|
}
|
|
|
|
async function openModels(row: Provider) {
|
|
current.value = row
|
|
modelsVisible.value = true
|
|
modelsLoading.value = true
|
|
providerModels.value = []
|
|
try {
|
|
const res = await api.get(`/providers/${row.id}/models`)
|
|
const models = res.data.data ?? []
|
|
providerModels.value = models
|
|
row.models = models
|
|
} finally {
|
|
modelsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function syncModels(row: Provider) {
|
|
try {
|
|
const res = await api.post(`/providers/${row.id}/sync-models`)
|
|
ElMessage.success(t('providers.syncedCount', { count: res.data.synced }))
|
|
await load()
|
|
const updated = providers.value.find(p => p.id === row.id)
|
|
if (updated) {
|
|
Object.assign(row, updated)
|
|
if (current.value?.id === row.id) {
|
|
current.value = updated
|
|
}
|
|
}
|
|
if (modelsVisible.value && current.value?.id === row.id) {
|
|
await openModels(row)
|
|
}
|
|
} catch (e: any) {
|
|
ElMessage.error(e.response?.data?.error || t('providers.syncFailed'))
|
|
}
|
|
}
|
|
|
|
async function createKey() {
|
|
if (!current.value) return
|
|
let models: string[] = ['*']
|
|
try { models = JSON.parse(keyForm.value.models) } catch { /* keep */ }
|
|
await api.post(`/providers/${current.value.id}/keys`, { ...keyForm.value, models })
|
|
showKeyForm.value = false
|
|
keyForm.value = { name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 }
|
|
openKeys(current.value)
|
|
ElMessage.success(t('providers.keyAdded'))
|
|
}
|
|
|
|
async function deleteKey(row: ProviderKey) {
|
|
await api.delete(`/provider-keys/${row.id}`)
|
|
if (current.value) openKeys(current.value)
|
|
}
|
|
|
|
function openAddModel() {
|
|
addModelForm.value = { model_id: '', alias: '', display_name: '' }
|
|
showAddModel.value = true
|
|
}
|
|
|
|
function modelIngressId(m: ModelEntry) {
|
|
return m.alias || m.model_id
|
|
}
|
|
|
|
function openEditModel(m: ModelEntry) {
|
|
const isImage = current.value?.category === 'image'
|
|
editModelForm.value = {
|
|
id: m.id,
|
|
model_id: isImage ? modelIngressId(m) : m.model_id,
|
|
alias: isImage ? '' : (m.alias || ''),
|
|
display_name: m.display_name || '',
|
|
}
|
|
showEditModel.value = true
|
|
}
|
|
|
|
async function saveEditModel() {
|
|
if (!current.value || !editModelForm.value.id) return
|
|
const isImage = current.value.category === 'image'
|
|
try {
|
|
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
|
|
model_id: editModelForm.value.model_id.trim(),
|
|
...(isImage ? { alias: '' } : { alias: editModelForm.value.alias.trim() }),
|
|
display_name: editModelForm.value.display_name.trim(),
|
|
})
|
|
} catch (e: any) {
|
|
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
|
|
return
|
|
}
|
|
showEditModel.value = false
|
|
ElMessage.success(t('providers.saved'))
|
|
await openModels(current.value)
|
|
await load()
|
|
}
|
|
|
|
async function createModel() {
|
|
if (!current.value) return
|
|
const modelId = addModelForm.value.model_id.trim()
|
|
if (!modelId) {
|
|
ElMessage.warning(t('providers.modelIdRequired'))
|
|
return
|
|
}
|
|
const displayName = addModelForm.value.display_name.trim() || modelId
|
|
try {
|
|
await api.post(`/providers/${current.value.id}/models`, {
|
|
model_id: modelId,
|
|
alias: addModelForm.value.alias.trim(),
|
|
display_name: displayName,
|
|
})
|
|
} catch (e: any) {
|
|
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
|
|
return
|
|
}
|
|
showAddModel.value = false
|
|
ElMessage.success(t('providers.modelAdded'))
|
|
await openModels(current.value)
|
|
await load()
|
|
}
|
|
|
|
async function deleteModel(m: ModelEntry) {
|
|
if (!current.value || !m.id) return
|
|
await ElMessageBox.confirm(t('providers.confirmDeleteModel'))
|
|
try {
|
|
await api.delete(`/providers/${current.value.id}/models/${m.id}`)
|
|
} catch (e: any) {
|
|
ElMessage.error(e.response?.data?.error || t('providers.updateFailed'))
|
|
return
|
|
}
|
|
ElMessage.success(t('providers.modelDeleted'))
|
|
await openModels(current.value)
|
|
await load()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const meta = await fetchEndpointMeta('llm')
|
|
categories.value = meta.categories
|
|
for (const pt of (meta.allProviderTypes?.length ? meta.allProviderTypes : meta.providerTypes) ?? []) {
|
|
providerTypeLabels.value[pt.key] = pt.label
|
|
}
|
|
load()
|
|
})
|
|
</script>
|