mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 05:10:16 +08:00
feat: OpenAI 兼容 API 地址自动补全 /v1 并支持实时预览
This commit is contained in:
@@ -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<PiApi> {
|
||||
const providerDef = getBuiltinProviderById(config.provider)
|
||||
const providerInfo = getProviderInfo(config.provider)
|
||||
@@ -477,12 +499,18 @@ export function buildPiModel(config: AIServiceConfig): PiModel<PiApi> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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'],
|
||||
|
||||
@@ -46,6 +46,7 @@ const {
|
||||
canSave,
|
||||
apiFormatItems,
|
||||
modalTitle,
|
||||
resolvedApiUrl,
|
||||
selectProvider,
|
||||
onConnectionModeChange,
|
||||
openAddModelDialog,
|
||||
@@ -176,6 +177,18 @@ function closeModal() {
|
||||
<p class="mt-1 text-[10px] text-gray-400">
|
||||
{{ t('settings.aiConfig.modal.apiEndpointOverrideHint') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="resolvedApiUrl"
|
||||
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
|
||||
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
|
||||
<code
|
||||
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{{ resolvedApiUrl }}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
@@ -226,11 +239,23 @@ function closeModal() {
|
||||
{{ t('settings.aiConfig.modal.apiEndpoint') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434/v1" class="flex-1" />
|
||||
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434" class="flex-1" />
|
||||
<UButton :loading="isValidating" :disabled="!formData.baseUrl" variant="soft" @click="validateKey">
|
||||
{{ t('settings.aiConfig.modal.validate') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<p
|
||||
v-if="resolvedApiUrl"
|
||||
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
|
||||
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
|
||||
<code
|
||||
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{{ resolvedApiUrl }}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
@@ -312,8 +337,20 @@ function closeModal() {
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.aiConfig.modal.apiEndpoint') }}
|
||||
</label>
|
||||
<UInput v-model="formData.baseUrl" class="w-full" placeholder="https://api.example.com/v1" />
|
||||
<UInput v-model="formData.baseUrl" class="w-full" placeholder="https://api.example.com" />
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('settings.aiConfig.modal.apiEndpointHint') }}</p>
|
||||
<p
|
||||
v-if="resolvedApiUrl"
|
||||
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
|
||||
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
|
||||
<code
|
||||
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{{ resolvedApiUrl }}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
|
||||
@@ -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<string, string> = {
|
||||
gemini: 'google-generative-ai',
|
||||
anthropic: 'anthropic-messages',
|
||||
}
|
||||
|
||||
const API_PATH_SUFFIX: Record<string, string> = {
|
||||
'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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"modelNameHintLocal": "ローカルにデプロイしたモデル名を入力",
|
||||
"apiEndpoint": "API URL",
|
||||
"apiEndpointHint": "OpenAI 互換形式の API エンドポイント",
|
||||
"resolvedUrl": "実際のリクエストURL:",
|
||||
"apiEndpointOverrideHint": "デフォルトURLは入力済みです。必要に応じて変更してください",
|
||||
"disableThinking": "思考モードをオフ",
|
||||
"disableThinkingDesc": "Qwen3 や DeepSeek-R1 などのモデル向け。オフにすると標準のツール実行形式を使います",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"modelNameHintLocal": "输入本地部署的模型名称",
|
||||
"apiEndpoint": "API 地址",
|
||||
"apiEndpointHint": "兼容 OpenAI 格式的 API 端点",
|
||||
"resolvedUrl": "实际请求地址:",
|
||||
"apiEndpointOverrideHint": "默认地址已填入,可按需改为完整 URL",
|
||||
"disableThinking": "禁用思考模式",
|
||||
"disableThinkingDesc": "针对 Qwen3、DeepSeek-R1 等模型,禁用后使用标准工具调用格式",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"modelNameHintLocal": "輸入本機部署的模型名稱",
|
||||
"apiEndpoint": "API 位址",
|
||||
"apiEndpointHint": "相容 OpenAI 格式的 API 端點",
|
||||
"resolvedUrl": "實際請求地址:",
|
||||
"apiEndpointOverrideHint": "預設地址已填入,可按需改為完整 URL",
|
||||
"disableThinking": "關閉思考模式",
|
||||
"disableThinkingDesc": "針對 Qwen3、DeepSeek-R1 等模型,停用後使用標準工具呼叫格式",
|
||||
|
||||
Reference in New Issue
Block a user