优化 API 配置:一个弹窗完成接口、Key 与模型选择
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key - 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名 - 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头 - 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取 - 单模型测试与参数设置;手机端布局优化 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+235
-187
@@ -4,140 +4,156 @@ import type { ProviderType } from '#shared/types'
|
||||
const api = useApi()
|
||||
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
|
||||
const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
|
||||
const refreshAll = () => Promise.all([refreshProviders(), refreshModels()])
|
||||
|
||||
const TYPE_LABEL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'OpenAI 兼容',
|
||||
'anthropic': 'Anthropic (Claude)',
|
||||
'google': 'Google (Gemini)'
|
||||
}
|
||||
const typeItems = Object.entries(TYPE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const PRESETS: { label: string, name: string, type: ProviderType, baseUrl: string }[] = [
|
||||
{ label: 'Anthropic Claude', name: 'Claude', type: 'anthropic', baseUrl: '' },
|
||||
{ label: 'Google Gemini', name: 'Gemini', type: 'google', baseUrl: '' },
|
||||
{ label: 'OpenAI', name: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1' },
|
||||
{ label: 'DeepSeek', name: 'DeepSeek', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1' },
|
||||
{ label: 'OpenRouter', name: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1' },
|
||||
{ label: '硅基流动', name: '硅基流动', type: 'openai-compatible', baseUrl: 'https://api.siliconflow.cn/v1' },
|
||||
{ label: 'Ollama(本地)', name: 'Ollama', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }
|
||||
const TYPE_ITEMS: { value: ProviderType, label: string, hint: string }[] = [
|
||||
{ value: 'openai-compatible', label: 'OpenAI 兼容', hint: '绝大多数中转站、OpenAI、DeepSeek、OpenRouter、Ollama 等' },
|
||||
{ value: 'anthropic', label: 'Anthropic(Claude 原生)', hint: 'Claude 官方接口,或提供 Claude 原生格式的中转站' },
|
||||
{ value: 'google', label: 'Google Gemini(原生)', hint: 'Gemini 官方接口,或提供 Gemini 原生格式的中转站' }
|
||||
]
|
||||
|
||||
const DEFAULT_URL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'https://api.openai.com/v1',
|
||||
const TYPE_LABEL = Object.fromEntries(TYPE_ITEMS.map(t => [t.value, t.label])) as Record<ProviderType, string>
|
||||
const URL_PLACEHOLDER: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'https://你的中转站域名/v1',
|
||||
'anthropic': 'https://api.anthropic.com/v1',
|
||||
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
||||
}
|
||||
|
||||
/* ---------- 提供方 ---------- */
|
||||
const providerOpen = ref(false)
|
||||
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
|
||||
const savingProvider = ref(false)
|
||||
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
|
||||
|
||||
function openProvider(p?: ProviderItem) {
|
||||
Object.assign(providerForm, p
|
||||
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
|
||||
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
|
||||
providerOpen.value = true
|
||||
}
|
||||
|
||||
function applyPreset(label: string) {
|
||||
const p = PRESETS.find(x => x.label === label)
|
||||
if (!p) return
|
||||
providerForm.name = p.name
|
||||
providerForm.type = p.type
|
||||
providerForm.baseUrl = p.baseUrl
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
savingProvider.value = true
|
||||
try {
|
||||
const body = { name: providerForm.name, type: providerForm.type, baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey }
|
||||
if (providerForm.id) await api.put(`/api/providers/${providerForm.id}`, body)
|
||||
else await api.post('/api/providers', body)
|
||||
providerOpen.value = false
|
||||
await refreshProviders()
|
||||
}
|
||||
finally {
|
||||
savingProvider.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletingProvider = ref<ProviderItem | null>(null)
|
||||
async function deleteProvider() {
|
||||
await api.del(`/api/providers/${deletingProvider.value!.id}`)
|
||||
deletingProvider.value = null
|
||||
await Promise.all([refreshProviders(), refreshModels()])
|
||||
}
|
||||
|
||||
/* ---------- 模型 ---------- */
|
||||
const modelOpen = ref(false)
|
||||
const modelForm = reactive({
|
||||
id: '', providerId: '', label: '', modelId: '',
|
||||
temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null
|
||||
/* ================= 连接编辑(含模型) ================= */
|
||||
const open = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'openai-compatible' as ProviderType,
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
hasKey: false,
|
||||
models: [] as string[]
|
||||
})
|
||||
const remoteModels = ref<string[]>([])
|
||||
const loadingRemote = ref(false)
|
||||
const savingModel = ref(false)
|
||||
const manualModel = ref('')
|
||||
const remote = ref<string[]>([])
|
||||
const remoteQuery = ref('')
|
||||
const fetching = ref(false)
|
||||
const fetchError = ref('')
|
||||
|
||||
function openModel(providerId: string, m?: ModelItem) {
|
||||
Object.assign(modelForm, m
|
||||
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
|
||||
: { id: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
|
||||
remoteModels.value = []
|
||||
modelOpen.value = true
|
||||
function openEditor(p?: ProviderItem) {
|
||||
Object.assign(form, p
|
||||
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey, models: modelsOf(p.id).map(m => m.modelId) }
|
||||
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false, models: [] })
|
||||
manualModel.value = ''
|
||||
remote.value = []
|
||||
remoteQuery.value = ''
|
||||
fetchError.value = ''
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function addManual() {
|
||||
const names = manualModel.value.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
|
||||
for (const n of names) if (!form.models.includes(n)) form.models.push(n)
|
||||
manualModel.value = ''
|
||||
}
|
||||
function removeModel(id: string) {
|
||||
form.models = form.models.filter(m => m !== id)
|
||||
}
|
||||
function toggleModel(id: string, on: boolean) {
|
||||
if (on && !form.models.includes(id)) form.models.push(id)
|
||||
if (!on) removeModel(id)
|
||||
}
|
||||
|
||||
const filteredRemote = computed(() => {
|
||||
const q = remoteQuery.value.trim().toLowerCase()
|
||||
return q ? remote.value.filter(m => m.toLowerCase().includes(q)) : remote.value
|
||||
})
|
||||
function selectAllVisible() {
|
||||
for (const m of filteredRemote.value) if (!form.models.includes(m)) form.models.push(m)
|
||||
}
|
||||
function clearVisible() {
|
||||
const visible = new Set(filteredRemote.value)
|
||||
form.models = form.models.filter(m => !visible.has(m))
|
||||
}
|
||||
|
||||
async function fetchRemote() {
|
||||
loadingRemote.value = true
|
||||
fetching.value = true
|
||||
fetchError.value = ''
|
||||
try {
|
||||
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
|
||||
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
|
||||
}
|
||||
finally {
|
||||
loadingRemote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
savingModel.value = true
|
||||
try {
|
||||
const body = {
|
||||
providerId: modelForm.providerId,
|
||||
label: modelForm.label.trim() || modelForm.modelId.trim(),
|
||||
modelId: modelForm.modelId.trim(),
|
||||
temperature: modelForm.temperature,
|
||||
topP: modelForm.topP,
|
||||
maxOutputTokens: modelForm.maxOutputTokens
|
||||
const res = await api.post<{ models: string[], baseUrl: string }>('/api/providers/remote-models', {
|
||||
type: form.type,
|
||||
baseUrl: form.baseUrl,
|
||||
apiKey: form.apiKey,
|
||||
providerId: form.id || undefined
|
||||
}, { silent: true })
|
||||
remote.value = res.models
|
||||
if (res.baseUrl && res.baseUrl !== form.baseUrl.trim().replace(/\/+$/, '')) {
|
||||
form.baseUrl = res.baseUrl
|
||||
api.toast.add({ title: 'Base URL 已自动补全', description: res.baseUrl, color: 'info', icon: 'i-lucide-wand-sparkles' })
|
||||
}
|
||||
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
|
||||
else await api.post('/api/models', body)
|
||||
modelOpen.value = false
|
||||
await refreshModels()
|
||||
if (!res.models.length) fetchError.value = '接口返回了空列表,可以手动填写模型名'
|
||||
}
|
||||
catch (err) {
|
||||
fetchError.value = apiErrorMessage(err)
|
||||
}
|
||||
finally {
|
||||
savingModel.value = false
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModel(m: ModelItem) {
|
||||
await api.del(`/api/models/${m.id}`)
|
||||
await refreshModels()
|
||||
const canSave = computed(() => (form.apiKey.trim() || form.hasKey) && (form.type !== 'openai-compatible' || form.baseUrl.trim()))
|
||||
|
||||
async function save() {
|
||||
if (manualModel.value.trim()) addManual()
|
||||
saving.value = true
|
||||
try {
|
||||
const body = { name: form.name, type: form.type, baseUrl: form.baseUrl, apiKey: form.apiKey, models: form.models }
|
||||
if (form.id) await api.put(`/api/providers/${form.id}`, body)
|
||||
else await api.post('/api/providers', body)
|
||||
open.value = false
|
||||
await refreshAll()
|
||||
api.toast.add({ title: '已保存', description: `${form.models.length} 个模型`, color: 'success', icon: 'i-lucide-circle-check' })
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleting = ref<ProviderItem | null>(null)
|
||||
async function confirmDelete() {
|
||||
await api.del(`/api/providers/${deleting.value!.id}`)
|
||||
deleting.value = null
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
/* ================= 单个模型:测试与参数 ================= */
|
||||
const testing = ref<string | null>(null)
|
||||
async function testModel(m: ModelItem) {
|
||||
testing.value = m.id
|
||||
try {
|
||||
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
|
||||
if (res.ok) api.toast.add({ title: `${m.label} 连接成功`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
|
||||
else api.toast.add({ title: `${m.label} 连接失败`, description: res.error, color: 'error', duration: 10000 })
|
||||
if (res.ok) api.toast.add({ title: `${m.label} 可用`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
|
||||
else api.toast.add({ title: `${m.label} 调用失败`, description: res.error, color: 'error', duration: 12000 })
|
||||
}
|
||||
finally {
|
||||
testing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
|
||||
const paramOpen = ref(false)
|
||||
const paramForm = reactive({ id: '', modelId: '', label: '', temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null })
|
||||
function openParams(m: ModelItem) {
|
||||
Object.assign(paramForm, { id: m.id, modelId: m.modelId, label: m.label, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens })
|
||||
paramOpen.value = true
|
||||
}
|
||||
async function saveParams() {
|
||||
await api.put(`/api/models/${paramForm.id}`, {
|
||||
label: paramForm.label.trim() || paramForm.modelId,
|
||||
temperature: paramForm.temperature,
|
||||
topP: paramForm.topP,
|
||||
maxOutputTokens: paramForm.maxOutputTokens
|
||||
})
|
||||
paramOpen.value = false
|
||||
await refreshModels()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,73 +161,65 @@ const modelsOf = (providerId: string) => models.value.filter(m => m.providerId =
|
||||
<template #header>
|
||||
<UDashboardNavbar title="模型与 API">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
||||
<UButton icon="i-lucide-plus" label="添加 API" @click="openEditor()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
API Key 使用 AES-256-GCM 加密后保存在本地 <code>data/app.db</code>。添加提供方后,在其下添加具体模型,然后就可以在作品的「AI 团队」中把模型分配给主编和作家。
|
||||
每个 API 连接填写接口类型、地址和 Key,再从接口拉取或手动填写要用的模型。之后在作品的「AI 团队」中给主编和作家选择模型。API Key 加密保存在服务器上。
|
||||
</p>
|
||||
|
||||
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
|
||||
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
|
||||
<p class="text-muted">还没有 API 提供方</p>
|
||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
||||
<p class="text-muted">
|
||||
还没有 API 连接
|
||||
</p>
|
||||
<UButton icon="i-lucide-plus" label="添加 API" @click="openEditor()" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="p in providers" :key="p.id">
|
||||
<UCard v-for="p in providers" :key="p.id" :ui="{ body: 'p-0 sm:p-0' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-server" class="size-5 text-primary" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-server" class="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-semibold flex flex-wrap items-center gap-2">
|
||||
{{ p.name }}
|
||||
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
|
||||
<UBadge variant="subtle" color="neutral" size="sm">
|
||||
{{ TYPE_LABEL[p.type] }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="text-xs text-muted truncate">
|
||||
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
|
||||
<div class="text-xs text-muted break-all">
|
||||
{{ p.baseUrl || '官方地址' }} · <span class="whitespace-nowrap">Key:{{ p.apiKeyMasked || '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
|
||||
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openProvider(p)" />
|
||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<UButton icon="i-lucide-pencil" label="编辑" size="sm" color="neutral" variant="outline" @click="openEditor(p)" />
|
||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deleting = p" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!modelsOf(p.id).length" class="text-sm text-muted">
|
||||
还没有模型,点击「添加模型」。
|
||||
<div v-if="!modelsOf(p.id).length" class="px-4 py-3 text-sm text-muted">
|
||||
还没有模型,点击「编辑」从接口拉取或手动添加。
|
||||
</div>
|
||||
<div v-else class="divide-y divide-default">
|
||||
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">
|
||||
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-2 px-4 py-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm break-all">
|
||||
{{ m.label }}
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
{{ m.modelId }}
|
||||
<template v-if="m.temperature != null">
|
||||
· 温度 {{ m.temperature }}
|
||||
</template>
|
||||
<template v-if="m.topP != null">
|
||||
· top_p {{ m.topP }}
|
||||
</template>
|
||||
<template v-if="m.maxOutputTokens">
|
||||
· 最大输出 {{ m.maxOutputTokens }}
|
||||
</template>
|
||||
<div v-if="m.label !== m.modelId || m.temperature != null || m.topP != null || m.maxOutputTokens" class="text-xs text-muted break-all">
|
||||
<span v-if="m.label !== m.modelId">{{ m.modelId }}</span>
|
||||
<span v-if="m.temperature != null"> · 温度 {{ m.temperature }}</span>
|
||||
<span v-if="m.topP != null"> · top_p {{ m.topP }}</span>
|
||||
<span v-if="m.maxOutputTokens"> · 最大输出 {{ m.maxOutputTokens }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
|
||||
<UButton icon="i-lucide-pencil" size="xs" color="neutral" variant="ghost" @click="openModel(p.id, m)" />
|
||||
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteModel(m)" />
|
||||
</div>
|
||||
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
|
||||
<UButton icon="i-lucide-sliders-horizontal" label="参数" size="xs" color="neutral" variant="ghost" @click="openParams(m)" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
@@ -219,91 +227,131 @@ const modelsOf = (providerId: string) => models.value.filter(m => m.providerId =
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<!-- 提供方编辑 -->
|
||||
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
|
||||
<!-- API 连接 + 模型 -->
|
||||
<UModal v-model:open="open" :title="form.id ? '编辑 API' : '添加 API'" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField v-if="!providerForm.id" label="快速填写">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
|
||||
<UFormField label="接口类型" required :help="TYPE_ITEMS.find(t => t.value === form.type)?.hint">
|
||||
<USelect v-model="form.type" :items="TYPE_ITEMS" value-key="value" label-key="label" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Base URL"
|
||||
:required="form.type === 'openai-compatible'"
|
||||
:help="form.type === 'openai-compatible' ? '中转站一般要带 /v1;漏写的话,拉取模型时会自动补全' : '留空使用官方地址;中转站请填写其提供的地址'"
|
||||
>
|
||||
<UInput v-model="form.baseUrl" :placeholder="URL_PLACEHOLDER[form.type]" class="w-full" autocomplete="off" />
|
||||
</UFormField>
|
||||
<UFormField label="API Key" required :help="form.hasKey ? '已保存 Key,留空则保持不变' : undefined">
|
||||
<UInput v-model="form.apiKey" type="password" :placeholder="form.hasKey ? '••••••(不修改)' : 'sk-...'" class="w-full" autocomplete="new-password" />
|
||||
</UFormField>
|
||||
<UFormField label="名称" hint="可选,默认使用域名">
|
||||
<UInput v-model="form.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<USeparator label="模型" />
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
icon="i-lucide-cloud-download"
|
||||
label="从接口拉取模型"
|
||||
:loading="fetching"
|
||||
:disabled="!form.apiKey.trim() && !form.hasKey"
|
||||
@click="fetchRemote"
|
||||
/>
|
||||
<div class="flex flex-1 min-w-56 gap-2">
|
||||
<UInput v-model="manualModel" placeholder="或手动输入模型名,回车添加" class="flex-1" @keydown.enter.prevent="addManual" />
|
||||
<UButton icon="i-lucide-plus" color="neutral" variant="outline" :disabled="!manualModel.trim()" @click="addManual" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<UFormField label="名称" required>
|
||||
<UInput v-model="providerForm.name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="接口类型" required>
|
||||
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
|
||||
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
|
||||
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UAlert v-if="fetchError" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :description="fetchError" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
|
||||
|
||||
<!-- 已选模型 -->
|
||||
<div>
|
||||
<div class="text-sm font-medium mb-1.5">
|
||||
已选 {{ form.models.length }} 个模型
|
||||
</div>
|
||||
<div v-if="form.models.length" class="flex flex-wrap gap-1.5">
|
||||
<UBadge v-for="m in form.models" :key="m" color="primary" variant="soft" class="gap-1 pe-1 max-w-full">
|
||||
<span class="truncate">{{ m }}</span>
|
||||
<UButton icon="i-lucide-x" size="xs" color="primary" variant="link" class="p-0" @click="removeModel(m)" />
|
||||
</UBadge>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted">
|
||||
从下方列表勾选,或手动输入模型名
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 拉取到的模型列表 -->
|
||||
<div v-if="remote.length" class="rounded-lg border border-default">
|
||||
<div class="flex items-center gap-2 p-2 border-b border-default">
|
||||
<UInput v-model="remoteQuery" icon="i-lucide-search" placeholder="搜索模型" size="sm" class="flex-1" />
|
||||
<span class="text-xs text-muted shrink-0">{{ filteredRemote.length }}/{{ remote.length }}</span>
|
||||
<UButton label="全选" size="xs" color="neutral" variant="ghost" @click="selectAllVisible" />
|
||||
<UButton label="清空" size="xs" color="neutral" variant="ghost" @click="clearVisible" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto p-1">
|
||||
<label
|
||||
v-for="m in filteredRemote"
|
||||
:key="m"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-elevated cursor-pointer text-sm"
|
||||
>
|
||||
<UCheckbox :model-value="form.models.includes(m)" @update:model-value="(v: boolean | 'indeterminate') => toggleModel(m, v === true)" />
|
||||
<span class="break-all">{{ m }}</span>
|
||||
</label>
|
||||
<p v-if="!filteredRemote.length" class="text-xs text-muted text-center py-4">
|
||||
没有匹配的模型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :loading="savingProvider" :disabled="!providerForm.name" @click="saveProvider" />
|
||||
<UButton label="保存" icon="i-lucide-save" :loading="saving" :disabled="!canSave" @click="save" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- 模型编辑 -->
|
||||
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
|
||||
<!-- 模型参数 -->
|
||||
<UModal v-model:open="paramOpen" title="模型参数" :description="paramForm.modelId">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="modelForm.modelId" placeholder="例如 claude-opus-5-5 / gemini-3-pro" class="flex-1" />
|
||||
<UButton icon="i-lucide-download" label="获取列表" color="neutral" variant="outline" :loading="loadingRemote" @click="fetchRemote" />
|
||||
</div>
|
||||
<UFormField label="显示名称">
|
||||
<UInput v-model="paramForm.label" :placeholder="paramForm.modelId" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField v-if="remoteModels.length" label="从列表选择">
|
||||
<USelectMenu
|
||||
:items="remoteModels"
|
||||
:model-value="remoteModels.includes(modelForm.modelId) ? modelForm.modelId : undefined"
|
||||
placeholder="搜索模型…"
|
||||
class="w-full"
|
||||
@update:model-value="(v: string) => (modelForm.modelId = v)"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="显示名称" hint="可选">
|
||||
<UInput v-model="modelForm.label" :placeholder="modelForm.modelId" class="w-full" />
|
||||
</UFormField>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<UFormField label="温度" hint="留空=默认">
|
||||
<UInputNumber v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" />
|
||||
<UInputNumber v-model="paramForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="top_p">
|
||||
<UInputNumber v-model="modelForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" />
|
||||
<UInputNumber v-model="paramForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="最大输出 tokens">
|
||||
<UInputNumber v-model="modelForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" />
|
||||
<UInputNumber v-model="paramForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
|
||||
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);输出被截断时系统会自动续写。Claude 模型不要同时设置温度和 top_p。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
|
||||
<UButton label="保存" @click="saveParams" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal :open="!!deletingProvider" title="删除 API 提供方" @update:open="v => !v && (deletingProvider = null)">
|
||||
<UModal :open="!!deleting" title="删除 API" @update:open="v => !v && (deleting = null)">
|
||||
<template #body>
|
||||
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
|
||||
<p>删除「{{ deleting?.name }}」会同时删除它下面的所有模型,使用这些模型的 AI 成员需要重新选择模型。</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
|
||||
<UButton label="删除" color="error" @click="deleteProvider" />
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
|
||||
<UButton label="删除" color="error" @click="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
Reference in New Issue
Block a user