From 283f40892ac6b4ddb6efe1abf9208ac1c73100d8 Mon Sep 17 00:00:00 2001 From: jocay Date: Sun, 18 Oct 2026 02:23:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20API=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=9A=E4=B8=80=E4=B8=AA=E5=BC=B9=E7=AA=97=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E3=80=81Key=20=E4=B8=8E=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key - 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名 - 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头 - 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取 - 单模型测试与参数设置;手机端布局优化 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- app/pages/settings.vue | 422 ++++++++++-------- scripts/e2e.mjs | 26 +- scripts/mock-llm.mjs | 2 + server/api/providers/[id].put.ts | 27 +- .../api/providers/[id]/remote-models.get.ts | 13 - server/api/providers/index.post.ts | 26 +- server/api/providers/remote-models.post.ts | 27 ++ server/utils/llm.ts | 72 ++- server/utils/providers.ts | 40 ++ 10 files changed, 409 insertions(+), 250 deletions(-) delete mode 100644 server/api/providers/[id]/remote-models.get.ts create mode 100644 server/api/providers/remote-models.post.ts create mode 100644 server/utils/providers.ts diff --git a/README.md b/README.md index db0ed6a..e6b47cb 100644 --- a/README.md +++ b/README.md @@ -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)转为项目世界书;世界书支持常驻条目和关键词/正则触发 | | AI 团队 | 主编、摘要员、任意多位作家;每位成员可以选模型、设置自定义系统提示词;主编也可以用已配置的模型自行创建作家(可关闭) | @@ -31,7 +31,7 @@ pnpm install pnpm dev # http://localhost:3000 ``` -1. 打开「模型与 API」,添加提供方(可以用快速填写),再在提供方下添加模型并点击「测试」。 +1. 打开「模型与 API」→「添加 API」,选择接口类型,填写 Base URL(中转站一般带 `/v1`)和 API Key,点「从接口拉取模型」勾选需要的模型(也可以手动输入模型名),保存后点「测试」。 2. 新建作品,填写「故事设定」,在「角色与世界书」中新建角色或导入酒馆角色卡(**请使用原始 PNG 文件**:截图或经聊天软件压缩后,图片里的角色数据会丢失)。 3. 在「AI 团队」中给主编、摘要员、作家分配模型(例如主编用 Claude,作家用 Gemini),按需填写自定义系统提示词。 4. 回到「写作台」,点击右上角「继续下一章」。主编会先规划章节,再为第 1 章写简报并交给作家;写完后暂停,阅读后点击「确认并继续下一章」。 diff --git a/app/pages/settings.vue b/app/pages/settings.vue index 6127b60..892d5cb 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -4,140 +4,156 @@ import type { ProviderType } from '#shared/types' const api = useApi() const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get('/api/providers'), { default: () => [] }) const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get('/api/models'), { default: () => [] }) +const refreshAll = () => Promise.all([refreshProviders(), refreshModels()]) -const TYPE_LABEL: Record = { - '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 = { - 'openai-compatible': 'https://api.openai.com/v1', +const TYPE_LABEL = Object.fromEntries(TYPE_ITEMS.map(t => [t.value, t.label])) as Record +const URL_PLACEHOLDER: Record = { + '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(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([]) -const loadingRemote = ref(false) -const savingModel = ref(false) +const manualModel = ref('') +const remote = ref([]) +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(`/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(null) +async function confirmDelete() { + await api.del(`/api/providers/${deleting.value!.id}`) + deleting.value = null + await refreshAll() +} + +/* ================= 单个模型:测试与参数 ================= */ const testing = ref(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() +}