283f40892a
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key - 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名 - 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头 - 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取 - 单模型测试与参数设置;手机端布局优化 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
16 KiB
Vue
359 lines
16 KiB
Vue
<script setup lang="ts">
|
||
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_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 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 modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
|
||
|
||
/* ================= 连接编辑(含模型) ================= */
|
||
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 manualModel = ref('')
|
||
const remote = ref<string[]>([])
|
||
const remoteQuery = ref('')
|
||
const fetching = ref(false)
|
||
const fetchError = ref('')
|
||
|
||
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() {
|
||
fetching.value = true
|
||
fetchError.value = ''
|
||
try {
|
||
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 (!res.models.length) fetchError.value = '接口返回了空列表,可以手动填写模型名'
|
||
}
|
||
catch (err) {
|
||
fetchError.value = apiErrorMessage(err)
|
||
}
|
||
finally {
|
||
fetching.value = false
|
||
}
|
||
}
|
||
|
||
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: 12000 })
|
||
}
|
||
finally {
|
||
testing.value = null
|
||
}
|
||
}
|
||
|
||
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>
|
||
<UDashboardPanel id="settings">
|
||
<template #header>
|
||
<UDashboardNavbar title="模型与 API">
|
||
<template #right>
|
||
<UButton icon="i-lucide-plus" label="添加 API" @click="openEditor()" />
|
||
</template>
|
||
</UDashboardNavbar>
|
||
</template>
|
||
|
||
<template #body>
|
||
<p class="text-sm text-muted mb-4">
|
||
每个 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="openEditor()" />
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<UCard v-for="p in providers" :key="p.id" :ui="{ body: 'p-0 sm:p-0' }">
|
||
<template #header>
|
||
<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">
|
||
{{ TYPE_LABEL[p.type] }}
|
||
</UBadge>
|
||
</div>
|
||
<div class="text-xs text-muted break-all">
|
||
{{ p.baseUrl || '官方地址' }} · <span class="whitespace-nowrap">Key:{{ p.apiKeyMasked || '未设置' }}</span>
|
||
</div>
|
||
</div>
|
||
<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="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-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 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>
|
||
<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>
|
||
</div>
|
||
</template>
|
||
</UDashboardPanel>
|
||
|
||
<!-- API 连接 + 模型 -->
|
||
<UModal v-model:open="open" :title="form.id ? '编辑 API' : '添加 API'" :ui="{ content: 'max-w-2xl' }">
|
||
<template #body>
|
||
<div class="space-y-4">
|
||
<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>
|
||
</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="保存" icon="i-lucide-save" :loading="saving" :disabled="!canSave" @click="save" />
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<!-- 模型参数 -->
|
||
<UModal v-model:open="paramOpen" title="模型参数" :description="paramForm.modelId">
|
||
<template #body>
|
||
<div class="space-y-4">
|
||
<UFormField label="显示名称">
|
||
<UInput v-model="paramForm.label" :placeholder="paramForm.modelId" class="w-full" />
|
||
</UFormField>
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<UFormField label="温度" hint="留空=默认">
|
||
<UInputNumber v-model="paramForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" class="w-full" />
|
||
</UFormField>
|
||
<UFormField label="top_p">
|
||
<UInputNumber v-model="paramForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" class="w-full" />
|
||
</UFormField>
|
||
<UFormField label="最大输出 tokens">
|
||
<UInputNumber v-model="paramForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" class="w-full" />
|
||
</UFormField>
|
||
</div>
|
||
<p class="text-xs text-muted">
|
||
写长章节时建议把「最大输出 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="保存" @click="saveParams" />
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<UModal :open="!!deleting" title="删除 API" @update:open="v => !v && (deleting = null)">
|
||
<template #body>
|
||
<p>删除「{{ deleting?.name }}」会同时删除它下面的所有模型,使用这些模型的 AI 成员需要重新选择模型。</p>
|
||
</template>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2 w-full">
|
||
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
|
||
<UButton label="删除" color="error" @click="confirmDelete" />
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
</template>
|