优化 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:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 模型与 API | OpenAI 兼容(OpenAI / DeepSeek / OpenRouter / 硅基流动 / Ollama…)、Anthropic、Google Gemini;拉取模型列表、连接测试;API Key 用 AES-256-GCM 加密存储 |
|
| 模型与 API | 一个弹窗完成配置:接口类型(OpenAI 兼容 / Anthropic 原生 / Gemini 原生)、Base URL、API Key,模型可从接口拉取后勾选或手动填写;适配中转站(漏写 `/v1` 自动补全);单模型测试与参数;API Key 用 AES-256-GCM 加密存储 |
|
||||||
| 故事设定 | 故事背景/世界观、故事大纲、写作要求(语言、文风、视角、叙述方式、字数、其他要求)、写作流程开关 |
|
| 故事设定 | 故事背景/世界观、故事大纲、写作要求(语言、文风、视角、叙述方式、字数、其他要求)、写作流程开关 |
|
||||||
| 角色与世界书 | 手动管理角色;**导入酒馆角色卡**(PNG 的 `ccv3`/`chara` 文本块,或 JSON;兼容 V1/V2/V3),自动替换 `{{char}}`/`{{user}}`,内置世界书(character_book)转为项目世界书;世界书支持常驻条目和关键词/正则触发 |
|
| 角色与世界书 | 手动管理角色;**导入酒馆角色卡**(PNG 的 `ccv3`/`chara` 文本块,或 JSON;兼容 V1/V2/V3),自动替换 `{{char}}`/`{{user}}`,内置世界书(character_book)转为项目世界书;世界书支持常驻条目和关键词/正则触发 |
|
||||||
| AI 团队 | 主编、摘要员、任意多位作家;每位成员可以选模型、设置自定义系统提示词;主编也可以用已配置的模型自行创建作家(可关闭) |
|
| AI 团队 | 主编、摘要员、任意多位作家;每位成员可以选模型、设置自定义系统提示词;主编也可以用已配置的模型自行创建作家(可关闭) |
|
||||||
@@ -31,7 +31,7 @@ pnpm install
|
|||||||
pnpm dev # http://localhost:3000
|
pnpm dev # http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
1. 打开「模型与 API」,添加提供方(可以用快速填写),再在提供方下添加模型并点击「测试」。
|
1. 打开「模型与 API」→「添加 API」,选择接口类型,填写 Base URL(中转站一般带 `/v1`)和 API Key,点「从接口拉取模型」勾选需要的模型(也可以手动输入模型名),保存后点「测试」。
|
||||||
2. 新建作品,填写「故事设定」,在「角色与世界书」中新建角色或导入酒馆角色卡(**请使用原始 PNG 文件**:截图或经聊天软件压缩后,图片里的角色数据会丢失)。
|
2. 新建作品,填写「故事设定」,在「角色与世界书」中新建角色或导入酒馆角色卡(**请使用原始 PNG 文件**:截图或经聊天软件压缩后,图片里的角色数据会丢失)。
|
||||||
3. 在「AI 团队」中给主编、摘要员、作家分配模型(例如主编用 Claude,作家用 Gemini),按需填写自定义系统提示词。
|
3. 在「AI 团队」中给主编、摘要员、作家分配模型(例如主编用 Claude,作家用 Gemini),按需填写自定义系统提示词。
|
||||||
4. 回到「写作台」,点击右上角「继续下一章」。主编会先规划章节,再为第 1 章写简报并交给作家;写完后暂停,阅读后点击「确认并继续下一章」。
|
4. 回到「写作台」,点击右上角「继续下一章」。主编会先规划章节,再为第 1 章写简报并交给作家;写完后暂停,阅读后点击「确认并继续下一章」。
|
||||||
|
|||||||
+235
-187
@@ -4,140 +4,156 @@ import type { ProviderType } from '#shared/types'
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
|
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 { 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> = {
|
const TYPE_ITEMS: { value: ProviderType, label: string, hint: string }[] = [
|
||||||
'openai-compatible': 'OpenAI 兼容',
|
{ value: 'openai-compatible', label: 'OpenAI 兼容', hint: '绝大多数中转站、OpenAI、DeepSeek、OpenRouter、Ollama 等' },
|
||||||
'anthropic': 'Anthropic (Claude)',
|
{ value: 'anthropic', label: 'Anthropic(Claude 原生)', hint: 'Claude 官方接口,或提供 Claude 原生格式的中转站' },
|
||||||
'google': 'Google (Gemini)'
|
{ value: 'google', label: 'Google Gemini(原生)', hint: 'Gemini 官方接口,或提供 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_LABEL = Object.fromEntries(TYPE_ITEMS.map(t => [t.value, t.label])) as Record<ProviderType, string>
|
||||||
const DEFAULT_URL: Record<ProviderType, string> = {
|
const URL_PLACEHOLDER: Record<ProviderType, string> = {
|
||||||
'openai-compatible': 'https://api.openai.com/v1',
|
'openai-compatible': 'https://你的中转站域名/v1',
|
||||||
'anthropic': 'https://api.anthropic.com/v1',
|
'anthropic': 'https://api.anthropic.com/v1',
|
||||||
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 提供方 ---------- */
|
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
|
||||||
const providerOpen = ref(false)
|
|
||||||
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
|
|
||||||
const savingProvider = ref(false)
|
|
||||||
|
|
||||||
function openProvider(p?: ProviderItem) {
|
/* ================= 连接编辑(含模型) ================= */
|
||||||
Object.assign(providerForm, p
|
const open = ref(false)
|
||||||
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
|
const saving = ref(false)
|
||||||
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
|
const form = reactive({
|
||||||
providerOpen.value = true
|
id: '',
|
||||||
}
|
name: '',
|
||||||
|
type: 'openai-compatible' as ProviderType,
|
||||||
function applyPreset(label: string) {
|
baseUrl: '',
|
||||||
const p = PRESETS.find(x => x.label === label)
|
apiKey: '',
|
||||||
if (!p) return
|
hasKey: false,
|
||||||
providerForm.name = p.name
|
models: [] as string[]
|
||||||
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 remoteModels = ref<string[]>([])
|
const manualModel = ref('')
|
||||||
const loadingRemote = ref(false)
|
const remote = ref<string[]>([])
|
||||||
const savingModel = ref(false)
|
const remoteQuery = ref('')
|
||||||
|
const fetching = ref(false)
|
||||||
|
const fetchError = ref('')
|
||||||
|
|
||||||
function openModel(providerId: string, m?: ModelItem) {
|
function openEditor(p?: ProviderItem) {
|
||||||
Object.assign(modelForm, m
|
Object.assign(form, p
|
||||||
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
|
? { 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: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
|
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false, models: [] })
|
||||||
remoteModels.value = []
|
manualModel.value = ''
|
||||||
modelOpen.value = true
|
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() {
|
async function fetchRemote() {
|
||||||
loadingRemote.value = true
|
fetching.value = true
|
||||||
|
fetchError.value = ''
|
||||||
try {
|
try {
|
||||||
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
|
const res = await api.post<{ models: string[], baseUrl: string }>('/api/providers/remote-models', {
|
||||||
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
|
type: form.type,
|
||||||
}
|
baseUrl: form.baseUrl,
|
||||||
finally {
|
apiKey: form.apiKey,
|
||||||
loadingRemote.value = false
|
providerId: form.id || undefined
|
||||||
}
|
}, { silent: true })
|
||||||
}
|
remote.value = res.models
|
||||||
|
if (res.baseUrl && res.baseUrl !== form.baseUrl.trim().replace(/\/+$/, '')) {
|
||||||
async function saveModel() {
|
form.baseUrl = res.baseUrl
|
||||||
savingModel.value = true
|
api.toast.add({ title: 'Base URL 已自动补全', description: res.baseUrl, color: 'info', icon: 'i-lucide-wand-sparkles' })
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
|
if (!res.models.length) fetchError.value = '接口返回了空列表,可以手动填写模型名'
|
||||||
else await api.post('/api/models', body)
|
}
|
||||||
modelOpen.value = false
|
catch (err) {
|
||||||
await refreshModels()
|
fetchError.value = apiErrorMessage(err)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
savingModel.value = false
|
fetching.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteModel(m: ModelItem) {
|
const canSave = computed(() => (form.apiKey.trim() || form.hasKey) && (form.type !== 'openai-compatible' || form.baseUrl.trim()))
|
||||||
await api.del(`/api/models/${m.id}`)
|
|
||||||
await refreshModels()
|
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)
|
const testing = ref<string | null>(null)
|
||||||
async function testModel(m: ModelItem) {
|
async function testModel(m: ModelItem) {
|
||||||
testing.value = m.id
|
testing.value = m.id
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
|
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' })
|
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 })
|
else api.toast.add({ title: `${m.label} 调用失败`, description: res.error, color: 'error', duration: 12000 })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
testing.value = null
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,73 +161,65 @@ const modelsOf = (providerId: string) => models.value.filter(m => m.providerId =
|
|||||||
<template #header>
|
<template #header>
|
||||||
<UDashboardNavbar title="模型与 API">
|
<UDashboardNavbar title="模型与 API">
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
<UButton icon="i-lucide-plus" label="添加 API" @click="openEditor()" />
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<p class="text-sm text-muted mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
|
<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" />
|
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
|
||||||
<p class="text-muted">还没有 API 提供方</p>
|
<p class="text-muted">
|
||||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
还没有 API 连接
|
||||||
|
</p>
|
||||||
|
<UButton icon="i-lucide-plus" label="添加 API" @click="openEditor()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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>
|
<template #header>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<UIcon name="i-lucide-server" class="size-5 text-primary" />
|
<UIcon name="i-lucide-server" class="size-5 text-primary mt-0.5 shrink-0" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="font-semibold">
|
<div class="font-semibold flex flex-wrap items-center gap-2">
|
||||||
{{ p.name }}
|
{{ p.name }}
|
||||||
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
|
<UBadge variant="subtle" color="neutral" size="sm">
|
||||||
{{ TYPE_LABEL[p.type] }}
|
{{ TYPE_LABEL[p.type] }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted truncate">
|
<div class="text-xs text-muted break-all">
|
||||||
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
|
{{ p.baseUrl || '官方地址' }} · <span class="whitespace-nowrap">Key:{{ p.apiKeyMasked || '未设置' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-auto flex gap-1">
|
<div class="flex gap-1 shrink-0">
|
||||||
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
|
<UButton icon="i-lucide-pencil" label="编辑" size="sm" color="neutral" variant="outline" @click="openEditor(p)" />
|
||||||
<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="deleting = p" />
|
||||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
<div v-else class="divide-y divide-default">
|
<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 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">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="font-medium">
|
<div class="font-medium text-sm break-all">
|
||||||
{{ m.label }}
|
{{ m.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted">
|
<div v-if="m.label !== m.modelId || m.temperature != null || m.topP != null || m.maxOutputTokens" class="text-xs text-muted break-all">
|
||||||
{{ m.modelId }}
|
<span v-if="m.label !== m.modelId">{{ m.modelId }}</span>
|
||||||
<template v-if="m.temperature != null">
|
<span v-if="m.temperature != null"> · 温度 {{ m.temperature }}</span>
|
||||||
· 温度 {{ m.temperature }}
|
<span v-if="m.topP != null"> · top_p {{ m.topP }}</span>
|
||||||
</template>
|
<span v-if="m.maxOutputTokens"> · 最大输出 {{ m.maxOutputTokens }}</span>
|
||||||
<template v-if="m.topP != null">
|
|
||||||
· top_p {{ m.topP }}
|
|
||||||
</template>
|
|
||||||
<template v-if="m.maxOutputTokens">
|
|
||||||
· 最大输出 {{ m.maxOutputTokens }}
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</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-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)" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
@@ -219,91 +227,131 @@ const modelsOf = (providerId: string) => models.value.filter(m => m.providerId =
|
|||||||
</template>
|
</template>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<!-- 提供方编辑 -->
|
<!-- API 连接 + 模型 -->
|
||||||
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
|
<UModal v-model:open="open" :title="form.id ? '编辑 API' : '添加 API'" :ui="{ content: 'max-w-2xl' }">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormField v-if="!providerForm.id" label="快速填写">
|
<UFormField label="接口类型" required :help="TYPE_ITEMS.find(t => t.value === form.type)?.hint">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<USelect v-model="form.type" :items="TYPE_ITEMS" value-key="value" label-key="label" class="w-full" />
|
||||||
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
|
</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>
|
||||||
</UFormField>
|
</div>
|
||||||
<UFormField label="名称" required>
|
<UAlert v-if="fetchError" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :description="fetchError" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
|
||||||
<UInput v-model="providerForm.name" class="w-full" />
|
|
||||||
</UFormField>
|
<!-- 已选模型 -->
|
||||||
<UFormField label="接口类型" required>
|
<div>
|
||||||
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
|
<div class="text-sm font-medium mb-1.5">
|
||||||
</UFormField>
|
已选 {{ form.models.length }} 个模型
|
||||||
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
|
</div>
|
||||||
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
|
<div v-if="form.models.length" class="flex flex-wrap gap-1.5">
|
||||||
</UFormField>
|
<UBadge v-for="m in form.models" :key="m" color="primary" variant="soft" class="gap-1 pe-1 max-w-full">
|
||||||
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
|
<span class="truncate">{{ m }}</span>
|
||||||
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
|
<UButton icon="i-lucide-x" size="xs" color="primary" variant="link" class="p-0" @click="removeModel(m)" />
|
||||||
</UFormField>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer="{ close }">
|
<template #footer="{ close }">
|
||||||
<div class="flex justify-end gap-2 w-full">
|
<div class="flex justify-end gap-2 w-full">
|
||||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
<!-- 模型编辑 -->
|
<!-- 模型参数 -->
|
||||||
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
|
<UModal v-model:open="paramOpen" title="模型参数" :description="paramForm.modelId">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
|
<UFormField label="显示名称">
|
||||||
<div class="flex gap-2">
|
<UInput v-model="paramForm.label" :placeholder="paramForm.modelId" class="w-full" />
|
||||||
<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>
|
</UFormField>
|
||||||
<UFormField v-if="remoteModels.length" label="从列表选择">
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<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">
|
|
||||||
<UFormField label="温度" hint="留空=默认">
|
<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>
|
||||||
<UFormField label="top_p">
|
<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>
|
||||||
<UFormField label="最大输出 tokens">
|
<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>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">
|
<p class="text-xs text-muted">
|
||||||
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
|
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);输出被截断时系统会自动续写。Claude 模型不要同时设置温度和 top_p。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer="{ close }">
|
<template #footer="{ close }">
|
||||||
<div class="flex justify-end gap-2 w-full">
|
<div class="flex justify-end gap-2 w-full">
|
||||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||||
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
|
<UButton label="保存" @click="saveParams" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</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>
|
<template #body>
|
||||||
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
|
<p>删除「{{ deleting?.name }}」会同时删除它下面的所有模型,使用这些模型的 AI 成员需要重新选择模型。</p>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2 w-full">
|
<div class="flex justify-end gap-2 w-full">
|
||||||
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
|
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
|
||||||
<UButton label="删除" color="error" @click="deleteProvider" />
|
<UButton label="删除" color="error" @click="confirmDelete" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|||||||
+18
-8
@@ -30,18 +30,28 @@ async function waitIdle(pid, timeout = 30000) {
|
|||||||
}
|
}
|
||||||
const step = msg => console.log(`✔ ${msg}`)
|
const step = msg => console.log(`✔ ${msg}`)
|
||||||
|
|
||||||
// 1. 提供方与模型
|
// 1. API 连接与模型
|
||||||
const provider = await api('/api/providers', 'POST', { name: 'Mock', type: 'openai-compatible', baseUrl: mock, apiKey: 'test-key' })
|
const mockRoot = mock.replace(/\/v1$/, '')
|
||||||
|
const pulled = await api('/api/providers/remote-models', 'POST', { type: 'openai-compatible', baseUrl: mockRoot, apiKey: 'test-key' })
|
||||||
|
assert.deepEqual(pulled.models, ['mock-editor', 'mock-writer-trunc'])
|
||||||
|
assert.equal(pulled.baseUrl, mockRoot + '/v1', '漏写 /v1 时应自动补全')
|
||||||
|
const provider = await api('/api/providers', 'POST', { type: 'openai-compatible', baseUrl: pulled.baseUrl, apiKey: 'test-key', models: [...pulled.models, 'extra-model'] })
|
||||||
const providers = await api('/api/providers')
|
const providers = await api('/api/providers')
|
||||||
assert.ok(providers.find(p => p.id === provider.id).apiKeyMasked.length > 0)
|
const savedProvider = providers.find(p => p.id === provider.id)
|
||||||
|
assert.equal(savedProvider.name, new URL(mock).host, '未填名称时使用域名')
|
||||||
assert.ok(!JSON.stringify(providers).includes('test-key'), '接口不应返回明文 Key')
|
assert.ok(!JSON.stringify(providers).includes('test-key'), '接口不应返回明文 Key')
|
||||||
const remote = await api(`/api/providers/${provider.id}/remote-models`)
|
const pulledAgain = await api('/api/providers/remote-models', 'POST', { type: 'openai-compatible', baseUrl: pulled.baseUrl, providerId: provider.id })
|
||||||
assert.deepEqual(remote, ['mock-editor', 'mock-writer-trunc'])
|
assert.equal(pulledAgain.models.length, 2, '编辑时不填 Key 应使用已保存的 Key')
|
||||||
const editorModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-editor', label: '模拟主编' })
|
const synced = await api(`/api/providers/${provider.id}`, 'PUT', { models: pulled.models })
|
||||||
const writerModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-writer-trunc', label: '模拟作家', maxOutputTokens: 4096 })
|
assert.equal(synced.removed, 1)
|
||||||
|
let allModels = (await api('/api/models')).filter(m => m.providerId === provider.id)
|
||||||
|
assert.deepEqual(allModels.map(m => m.modelId).sort(), ['mock-editor', 'mock-writer-trunc'])
|
||||||
|
const editorModel = allModels.find(m => m.modelId === 'mock-editor')
|
||||||
|
const writerModel = allModels.find(m => m.modelId === 'mock-writer-trunc')
|
||||||
|
await api(`/api/models/${writerModel.id}`, 'PUT', { label: '模拟作家', maxOutputTokens: 4096 })
|
||||||
const test = await api(`/api/models/${editorModel.id}/test`, 'POST', {})
|
const test = await api(`/api/models/${editorModel.id}/test`, 'POST', {})
|
||||||
assert.equal(test.ok, true, JSON.stringify(test))
|
assert.equal(test.ok, true, JSON.stringify(test))
|
||||||
step('提供方/模型/连接测试')
|
step('API 连接:拉取模型(自动补全 /v1)、批量保存、同步、连接测试')
|
||||||
|
|
||||||
// 2. 项目与设定
|
// 2. 项目与设定
|
||||||
const { id: pid } = await api('/api/projects', 'POST', { title: 'E2E 测试作品' })
|
const { id: pid } = await api('/api/projects', 'POST', { title: 'E2E 测试作品' })
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function streamChapter(res, body) {
|
|||||||
|
|
||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, `http://localhost:${port}`)
|
const url = new URL(req.url, `http://localhost:${port}`)
|
||||||
|
// 与真实接口一样要求 /v1 前缀,用于测试 Base URL 自动补全
|
||||||
|
if (!url.pathname.startsWith('/v1/')) return json(res, 404, { error: { message: 'not found' } })
|
||||||
if (req.method === 'GET' && url.pathname.endsWith('/models')) {
|
if (req.method === 'GET' && url.pathname.endsWith('/models')) {
|
||||||
return json(res, 200, { object: 'list', data: [{ id: 'mock-editor' }, { id: 'mock-writer-trunc' }] })
|
return json(res, 200, { object: 'list', data: [{ id: 'mock-editor' }, { id: 'mock-writer-trunc' }] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,32 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().optional(),
|
||||||
type: z.enum(['openai-compatible', 'anthropic', 'google']).optional(),
|
type: z.enum(['openai-compatible', 'anthropic', 'google']).optional(),
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
// 不传或传空字符串表示保留原有密钥
|
// 不传或传空字符串表示保留原有密钥
|
||||||
apiKey: z.string().optional()
|
apiKey: z.string().optional(),
|
||||||
|
// 传入时把该连接下的模型同步为这个列表
|
||||||
|
models: z.array(z.string()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const id = param(event)
|
const id = param(event)
|
||||||
const body = await readJson(event, schema)
|
const body = await readJson(event, schema)
|
||||||
const patch: Partial<typeof tables.providers.$inferInsert> = {}
|
const db = useDb()
|
||||||
if (body.name !== undefined) patch.name = body.name
|
const current = db.select().from(tables.providers).where(eq(tables.providers.id, id)).get()
|
||||||
if (body.type !== undefined) patch.type = body.type
|
if (!current) throw createError({ statusCode: 404, message: 'API 连接不存在' })
|
||||||
if (body.baseUrl !== undefined) patch.baseUrl = body.baseUrl.trim()
|
|
||||||
|
const type = body.type ?? current.type
|
||||||
|
const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : current.baseUrl
|
||||||
|
const patch: Partial<typeof tables.providers.$inferInsert> = { type, baseUrl }
|
||||||
|
if (body.name !== undefined) patch.name = body.name.trim() || defaultProviderName(type, baseUrl)
|
||||||
if (body.apiKey) patch.apiKeyEnc = encryptSecret(body.apiKey.trim())
|
if (body.apiKey) patch.apiKeyEnc = encryptSecret(body.apiKey.trim())
|
||||||
useDb().update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
|
|
||||||
return { ok: true }
|
let result = { added: 0, removed: 0 }
|
||||||
|
db.transaction(() => {
|
||||||
|
db.update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
|
||||||
|
if (body.models) result = syncProviderModels(id, body.models)
|
||||||
|
})
|
||||||
|
return { ok: true, ...result }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
|
||||||
|
|
||||||
/** 拉取提供方的可用模型列表 */
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, param(event))).get()
|
|
||||||
if (!p) throw createError({ statusCode: 404, message: '提供方不存在' })
|
|
||||||
try {
|
|
||||||
return await listRemoteModels(p.type, p.baseUrl, decryptSecret(p.apiKeyEnc))
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
throw createError({ statusCode: 502, message: `获取模型列表失败:${describeError(err)}` })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().default(''),
|
||||||
type: z.enum(['openai-compatible', 'anthropic', 'google']),
|
type: z.enum(['openai-compatible', 'anthropic', 'google']),
|
||||||
baseUrl: z.string().default(''),
|
baseUrl: z.string().default(''),
|
||||||
apiKey: z.string().default('')
|
apiKey: z.string().default(''),
|
||||||
|
models: z.array(z.string()).default([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 新建 API 连接,并一次性添加所选模型 */
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readJson(event, schema)
|
const body = await readJson(event, schema)
|
||||||
const row = useDb().insert(tables.providers).values({
|
const id = newId()
|
||||||
id: newId(),
|
useDb().transaction(() => {
|
||||||
name: body.name,
|
useDb().insert(tables.providers).values({
|
||||||
type: body.type,
|
id,
|
||||||
baseUrl: body.baseUrl.trim(),
|
name: body.name.trim() || defaultProviderName(body.type, body.baseUrl),
|
||||||
apiKeyEnc: encryptSecret(body.apiKey.trim())
|
type: body.type,
|
||||||
}).returning().get()
|
baseUrl: body.baseUrl.trim(),
|
||||||
return { id: row.id }
|
apiKeyEnc: encryptSecret(body.apiKey.trim())
|
||||||
|
}).run()
|
||||||
|
syncProviderModels(id, body.models)
|
||||||
|
})
|
||||||
|
return { id }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(['openai-compatible', 'anthropic', 'google']),
|
||||||
|
baseUrl: z.string().default(''),
|
||||||
|
apiKey: z.string().default(''),
|
||||||
|
/** 编辑已有连接且未重新填写 Key 时,使用已保存的 Key */
|
||||||
|
providerId: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 用(可能尚未保存的)连接配置拉取模型列表 */
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readJson(event, schema)
|
||||||
|
let apiKey = body.apiKey.trim()
|
||||||
|
if (!apiKey && body.providerId) {
|
||||||
|
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, body.providerId)).get()
|
||||||
|
if (p) apiKey = decryptSecret(p.apiKeyEnc)
|
||||||
|
}
|
||||||
|
if (!apiKey) throw createError({ statusCode: 400, message: '请先填写 API Key' })
|
||||||
|
try {
|
||||||
|
return await listRemoteModels(body.type, body.baseUrl, apiKey)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw createError({ statusCode: 502, message: `拉取模型列表失败:${describeError(err)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
+50
-22
@@ -59,29 +59,57 @@ export function modelSettings(row: ModelRow) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从提供方拉取可用模型列表 */
|
/** 各接口类型的版本路径:中转站地址经常漏填,拉取失败时自动补上再试 */
|
||||||
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
|
const VERSION_SUFFIX: Record<ProviderType, string> = {
|
||||||
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
'openai-compatible': '/v1',
|
||||||
if (type === 'anthropic') {
|
'anthropic': '/v1',
|
||||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
'google': '/v1beta'
|
||||||
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
}
|
||||||
query: { limit: 1000 }
|
|
||||||
})
|
type ModelListResponse = {
|
||||||
return res.data.map(m => m.id)
|
data?: { id?: string, name?: string }[]
|
||||||
}
|
models?: { name?: string, id?: string, supportedGenerationMethods?: string[] }[]
|
||||||
if (type === 'google') {
|
}
|
||||||
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
|
|
||||||
headers: { 'x-goog-api-key': apiKey },
|
async function fetchModelIds(type: ProviderType, base: string, apiKey: string): Promise<string[]> {
|
||||||
query: { pageSize: 1000 }
|
// 同时带上官方鉴权头与 Bearer,兼容各种中转站
|
||||||
})
|
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
|
||||||
return res.models
|
if (type === 'anthropic') Object.assign(headers, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' })
|
||||||
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
if (type === 'google') headers['x-goog-api-key'] = apiKey
|
||||||
.map(m => m.name.replace(/^models\//, ''))
|
const res = await $fetch<ModelListResponse>(`${base}/models`, {
|
||||||
}
|
headers,
|
||||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
query: type === 'google' ? { pageSize: 1000 } : type === 'anthropic' ? { limit: 1000 } : undefined,
|
||||||
headers: { Authorization: `Bearer ${apiKey}` }
|
timeout: 20_000
|
||||||
})
|
})
|
||||||
return res.data.map(m => m.id).sort()
|
const ids = [
|
||||||
|
...(res.data ?? []).map(m => m.id || m.name || ''),
|
||||||
|
...(res.models ?? [])
|
||||||
|
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
||||||
|
.map(m => (m.name || m.id || '').replace(/^models\//, ''))
|
||||||
|
].filter(Boolean)
|
||||||
|
if (!Array.isArray(res.data) && !Array.isArray(res.models)) throw new Error('接口返回的不是模型列表,请检查 Base URL')
|
||||||
|
return [...new Set(ids)].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从接口拉取可用模型列表。
|
||||||
|
* 返回实际可用的 baseUrl:用户填的地址缺少 /v1 等版本路径时,会自动补全并返回补全后的地址。
|
||||||
|
*/
|
||||||
|
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<{ models: string[], baseUrl: string }> {
|
||||||
|
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
||||||
|
try {
|
||||||
|
return { models: await fetchModelIds(type, base, apiKey), baseUrl: trimUrl(baseUrl) }
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const suffix = VERSION_SUFFIX[type]
|
||||||
|
if (base.endsWith(suffix) || /\/v\d+(beta)?$/.test(base)) throw err
|
||||||
|
try {
|
||||||
|
return { models: await fetchModelIds(type, base + suffix, apiKey), baseUrl: base + suffix }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
|
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
|
import type { ProviderType } from '#shared/types'
|
||||||
|
|
||||||
|
const TYPE_NAME: Record<ProviderType, string> = {
|
||||||
|
'openai-compatible': 'OpenAI 兼容',
|
||||||
|
'anthropic': 'Anthropic',
|
||||||
|
'google': 'Gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未填写名称时,用 Base URL 的域名作为连接名称 */
|
||||||
|
export function defaultProviderName(type: ProviderType, baseUrl: string) {
|
||||||
|
try {
|
||||||
|
if (baseUrl.trim()) return new URL(baseUrl.trim()).host
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
return TYPE_NAME[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeModelIds(ids: string[]) {
|
||||||
|
return [...new Set(ids.map(s => s.trim()).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把连接下的模型同步为给定列表:新增缺少的,删除不在列表中的。
|
||||||
|
* 被删除模型的 AI 成员会因外键 ON DELETE SET NULL 变为“未指定模型”。
|
||||||
|
*/
|
||||||
|
export function syncProviderModels(providerId: string, modelIds: string[]) {
|
||||||
|
const db = useDb()
|
||||||
|
const wanted = normalizeModelIds(modelIds)
|
||||||
|
const existing = db.select().from(tables.models).where(eq(tables.models.providerId, providerId)).all()
|
||||||
|
const toDelete = existing.filter(m => !wanted.includes(m.modelId)).map(m => m.id)
|
||||||
|
const toAdd = wanted.filter(id => !existing.some(m => m.modelId === id))
|
||||||
|
if (toDelete.length) {
|
||||||
|
db.delete(tables.models).where(and(eq(tables.models.providerId, providerId), inArray(tables.models.id, toDelete))).run()
|
||||||
|
}
|
||||||
|
if (toAdd.length) {
|
||||||
|
db.insert(tables.models).values(toAdd.map(modelId => ({ id: newId(), providerId, label: modelId, modelId }))).run()
|
||||||
|
}
|
||||||
|
return { added: toAdd.length, removed: toDelete.length }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user