From 97de1e9439c25d8c34b51c1eaf2ca14299ec7ced Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 28 Apr 2026 20:10:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OpenAI=20=E5=85=BC=E5=AE=B9=20API=20?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E8=87=AA=E5=8A=A8=E8=A1=A5=E5=85=A8=20/v1=20?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ai/llm/index.ts | 30 +++++++++++- .../common/Settings/AI/AIModelEditModal.vue | 41 +++++++++++++++- .../common/Settings/AI/useAIConfigForm.ts | 47 ++++++++++++++++++- src/i18n/locales/en-US/settings.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + 7 files changed, 118 insertions(+), 4 deletions(-) diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index 2c25fca3..92499c44 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -432,6 +432,28 @@ function normalizeAnthropicBaseUrl(url: string): string { return url.replace(/\/v1\/?$/, '') } +/** + * 规范化 OpenAI Compatible baseUrl: + * 用户经常忘记在域名后加 /v1,OpenAI SDK 不会自动补全。 + * 如果 URL 没有以 /v1 结尾且路径部分为空或仅有 /,自动补上。 + * 已有具体路径(如 /api/v1、/proxy)的不做修改。 + */ +function normalizeOpenAICompatibleBaseUrl(url: string): string { + if (!url) return url + const trimmed = url.replace(/\/+$/, '') + if (trimmed.endsWith('/v1')) return trimmed + try { + const parsed = new URL(trimmed) + // 仅当路径为空或 "/" 时补全 /v1,避免破坏已有的自定义路径 + if (parsed.pathname === '/' || parsed.pathname === '') { + return trimmed + '/v1' + } + } catch { + // URL 解析失败,不做处理 + } + return trimmed +} + export function buildPiModel(config: AIServiceConfig): PiModel { const providerDef = getBuiltinProviderById(config.provider) const providerInfo = getProviderInfo(config.provider) @@ -477,12 +499,18 @@ export function buildPiModel(config: AIServiceConfig): PiModel { } } + // openai-compatible + openai-completions:自动补全 /v1(用户经常忘记) + const resolvedBaseUrl = + config.provider === 'openai-compatible' && apiFormat === 'openai-completions' + ? normalizeOpenAICompatibleBaseUrl(baseUrl) + : baseUrl + return { id: modelId, name: modelId, api: apiFormat, provider: config.provider, - baseUrl, + baseUrl: resolvedBaseUrl, headers: config.provider === 'openai-compatible' ? buildChatLabUserAgentHeaders() : undefined, reasoning: config.isReasoningModel ?? false, input: ['text'], diff --git a/src/components/common/Settings/AI/AIModelEditModal.vue b/src/components/common/Settings/AI/AIModelEditModal.vue index 886cb08d..693051ce 100644 --- a/src/components/common/Settings/AI/AIModelEditModal.vue +++ b/src/components/common/Settings/AI/AIModelEditModal.vue @@ -46,6 +46,7 @@ const { canSave, apiFormatItems, modalTitle, + resolvedApiUrl, selectProvider, onConnectionModeChange, openAddModelDialog, @@ -176,6 +177,18 @@ function closeModal() {

{{ t('settings.aiConfig.modal.apiEndpointOverrideHint') }}

+

+ + {{ t('settings.aiConfig.modal.resolvedUrl') }} + + {{ resolvedApiUrl }} + +

@@ -226,11 +239,23 @@ function closeModal() { {{ t('settings.aiConfig.modal.apiEndpoint') }}
- + {{ t('settings.aiConfig.modal.validate') }}
+

+ + {{ t('settings.aiConfig.modal.resolvedUrl') }} + + {{ resolvedApiUrl }} + +

@@ -312,8 +337,20 @@ function closeModal() { - +

{{ t('settings.aiConfig.modal.apiEndpointHint') }}

+

+ + {{ t('settings.aiConfig.modal.resolvedUrl') }} + + {{ resolvedApiUrl }} + +

diff --git a/src/components/common/Settings/AI/useAIConfigForm.ts b/src/components/common/Settings/AI/useAIConfigForm.ts index 6a7dfeca..0109c065 100644 --- a/src/components/common/Settings/AI/useAIConfigForm.ts +++ b/src/components/common/Settings/AI/useAIConfigForm.ts @@ -179,6 +179,50 @@ export function useAIConfigForm(props: { props.mode.value === 'add' ? t('settings.aiConfig.modal.addConfig') : t('settings.aiConfig.modal.editConfig') ) + const BUILTIN_PROVIDER_API: Record = { + gemini: 'google-generative-ai', + anthropic: 'anthropic-messages', + } + + const API_PATH_SUFFIX: Record = { + 'openai-completions': '/chat/completions', + 'openai-responses': '/responses', + 'anthropic-messages': '/v1/messages', + } + + const resolvedApiUrl = computed(() => { + const rawInput = formData.value.baseUrl?.trim() + const raw = rawInput || (isPresetMode.value ? currentProviderDef.value?.defaultBaseUrl : '') + if (!raw) return '' + + const effectiveApiFormat = isPresetMode.value + ? BUILTIN_PROVIDER_API[formData.value.provider] || API_FORMAT_DEFAULT + : formData.value.apiFormat || API_FORMAT_DEFAULT + + const trimmed = raw.replace(/\/+$/, '') + + let baseUrl: string + if (effectiveApiFormat === 'anthropic-messages') { + baseUrl = trimmed.replace(/\/v1\/?$/, '') + } else if (effectiveApiFormat === 'openai-completions' || effectiveApiFormat === 'openai-responses') { + try { + const parsed = new URL(trimmed) + if (!trimmed.endsWith('/v1') && (parsed.pathname === '/' || parsed.pathname === '')) { + baseUrl = trimmed + '/v1' + } else { + baseUrl = trimmed + } + } catch { + baseUrl = trimmed + } + } else { + baseUrl = trimmed + } + + const suffix = API_PATH_SUFFIX[effectiveApiFormat] + return suffix ? baseUrl + suffix : baseUrl + }) + // ============ 表单操作 ============ function resetForm() { @@ -253,7 +297,7 @@ export function useAIConfigForm(props: { if (mode === 'local') { formData.value.provider = 'openai-compatible' - formData.value.baseUrl = 'http://localhost:11434/v1' + formData.value.baseUrl = 'http://localhost:11434' formData.value.model = compatModels.value[0]?.id || '' formData.value.apiKey = '' } else if (mode === 'openai-compat') { @@ -575,6 +619,7 @@ export function useAIConfigForm(props: { canSave, apiFormatItems, modalTitle, + resolvedApiUrl, // 方法 selectProvider, diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 568e63ac..264a7536 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -127,6 +127,7 @@ "modelNameHintLocal": "Enter the locally deployed model name", "apiEndpoint": "API URL", "apiEndpointHint": "OpenAI-compatible API endpoint", + "resolvedUrl": "Resolved URL:", "apiEndpointOverrideHint": "Default URL is prefilled. Replace if needed", "disableThinking": "Disable Thinking Mode", "disableThinkingDesc": "For models like Qwen3, DeepSeek-R1. Uses standard tool calling format when disabled.", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 009d6091..ee7c1116 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -127,6 +127,7 @@ "modelNameHintLocal": "ローカルにデプロイしたモデル名を入力", "apiEndpoint": "API URL", "apiEndpointHint": "OpenAI 互換形式の API エンドポイント", + "resolvedUrl": "実際のリクエストURL:", "apiEndpointOverrideHint": "デフォルトURLは入力済みです。必要に応じて変更してください", "disableThinking": "思考モードをオフ", "disableThinkingDesc": "Qwen3 や DeepSeek-R1 などのモデル向け。オフにすると標準のツール実行形式を使います", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 51fc1296..d43152b7 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -127,6 +127,7 @@ "modelNameHintLocal": "输入本地部署的模型名称", "apiEndpoint": "API 地址", "apiEndpointHint": "兼容 OpenAI 格式的 API 端点", + "resolvedUrl": "实际请求地址:", "apiEndpointOverrideHint": "默认地址已填入,可按需改为完整 URL", "disableThinking": "禁用思考模式", "disableThinkingDesc": "针对 Qwen3、DeepSeek-R1 等模型,禁用后使用标准工具调用格式", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d9553de5..368a923e 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -127,6 +127,7 @@ "modelNameHintLocal": "輸入本機部署的模型名稱", "apiEndpoint": "API 位址", "apiEndpointHint": "相容 OpenAI 格式的 API 端點", + "resolvedUrl": "實際請求地址:", "apiEndpointOverrideHint": "預設地址已填入,可按需改為完整 URL", "disableThinking": "關閉思考模式", "disableThinkingDesc": "針對 Qwen3、DeepSeek-R1 等模型,停用後使用標準工具呼叫格式",