Files
ai-novel-studio/app/pages/settings.vue
T
jocay 283f40892a 优化 API 配置:一个弹窗完成接口、Key 与模型选择
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key
- 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名
- 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头
- 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取
- 单模型测试与参数设置;手机端布局优化

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:23:09 +08:00

359 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>