mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { computed, reactive, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ElMessage } from 'element-plus'
|
|
import { mcConfirm } from '@/components/common/useConfirm'
|
|
import { modelApi } from '@/api'
|
|
import type { ProviderInfo } from '@/types'
|
|
import { safeParseJson } from '@/utils/safeJson'
|
|
import { chatModelToProtocol, protocolToChatModel } from '@/utils/modelProtocol'
|
|
|
|
// Provider IDs are used as path segments in DELETE / config endpoints.
|
|
// Slashes / spaces / # / ? would make `{providerId}` PathVariable miss
|
|
// the controller and fall through to the static-resource handler
|
|
// (see issue #39: "No static resource api/v1/models/custom-providers/...").
|
|
// Keep this in sync with the backend if a server-side guard is added.
|
|
const PROVIDER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
|
|
|
|
interface ListDeps {
|
|
loadProviders: () => Promise<void>
|
|
loadActiveModel: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* RFC-074 PR-1: provider create / edit / save / delete + the form reactive
|
|
* model and its derived placeholders. Refresh after mutation goes back to
|
|
* useProviderList via injected callbacks so this composable stays UI-only.
|
|
*/
|
|
export function useProviderForm(deps: ListDeps) {
|
|
const { t } = useI18n()
|
|
|
|
const editingProvider = ref<ProviderInfo | null>(null)
|
|
const showProviderModal = ref(false)
|
|
const advancedOpen = ref(false)
|
|
|
|
const providerForm = reactive({
|
|
id: '',
|
|
name: '',
|
|
baseUrl: '',
|
|
apiKey: '',
|
|
apiKeyPrefix: 'sk-',
|
|
protocol: 'openai-compatible',
|
|
chatModel: 'OpenAIChatModel',
|
|
generateKwargsText: '{}',
|
|
enableSearch: false,
|
|
searchStrategy: '',
|
|
// RFC-009 P3.5: position in the multi-model failover chain.
|
|
// 0 = excluded; positive int = ascending try-order.
|
|
fallbackPriority: 0,
|
|
})
|
|
|
|
const protocolOptions = computed(() => ([
|
|
{ value: 'openai-compatible', label: t('settings.model.protocolOpenAI') },
|
|
{ value: 'anthropic-messages', label: t('settings.model.protocolAnthropic') },
|
|
{ value: 'gemini-native', label: t('settings.model.protocolGemini') },
|
|
{ value: 'dashscope-native', label: t('settings.model.protocolDashScope') },
|
|
]))
|
|
|
|
const currentProviderForForm = computed(() => editingProvider.value ?? {
|
|
id: providerForm.id,
|
|
name: providerForm.name,
|
|
})
|
|
|
|
const providerBaseUrlPlaceholder = computed(() => {
|
|
const id = currentProviderForForm.value?.id
|
|
if (id === 'openai') return 'https://api.openai.com/v1'
|
|
if (id === 'azure-openai') return 'https://<resource>.openai.azure.com/openai/v1'
|
|
if (id === 'anthropic') return 'https://api.anthropic.com'
|
|
if (id === 'ollama') return 'http://localhost:11434'
|
|
if (id === 'lmstudio') return 'http://localhost:1234/v1'
|
|
if (id === 'gemini') return 'https://generativelanguage.googleapis.com'
|
|
if (id === 'openrouter') return 'https://openrouter.ai/api/v1'
|
|
if (id === 'zhipu-cn') return 'https://open.bigmodel.cn/api/paas/v4'
|
|
if (id === 'zhipu-intl') return 'https://open.z.ai/api/paas/v4'
|
|
if (id === 'zhipu-cn-codingplan') return 'https://open.bigmodel.cn/api/coding/paas/v4'
|
|
if (id === 'zhipu-intl-codingplan') return 'https://api.z.ai/api/coding/paas/v4'
|
|
if (id === 'volcengine') return 'https://ark.cn-beijing.volces.com/api/v3'
|
|
if (id === 'xiaomi-mimo') return 'https://api.xiaomimimo.com/v1'
|
|
return 'https://example.com/v1'
|
|
})
|
|
|
|
const providerBaseUrlHint = computed(() => {
|
|
const id = currentProviderForForm.value?.id
|
|
if (id === 'openai') return t('settings.model.hints.openai')
|
|
if (id === 'azure-openai') return t('settings.model.hints.azureOpenai')
|
|
if (id === 'anthropic') return t('settings.model.hints.anthropic')
|
|
if (id === 'ollama') return t('settings.model.hints.ollama')
|
|
if (id === 'lmstudio') return t('settings.model.hints.lmstudio')
|
|
if (id === 'gemini') return t('settings.model.hints.gemini')
|
|
if (id === 'openrouter') return t('settings.model.hints.openrouter')
|
|
if (id === 'zhipu-cn') return t('settings.model.hints.zhipu')
|
|
if (id === 'zhipu-intl') return t('settings.model.hints.zhipuIntl')
|
|
if (id === 'volcengine') return t('settings.model.hints.volcengine')
|
|
if (id === 'xiaomi-mimo') return t('settings.model.hints.xiaomiMimo')
|
|
return t('settings.model.hints.openaiCompatible')
|
|
})
|
|
|
|
const providerApiKeyPlaceholder = computed(() => {
|
|
return providerForm.apiKeyPrefix
|
|
? `${t('settings.model.apiKeyInput')} (${providerForm.apiKeyPrefix}...)`
|
|
: t('settings.model.apiKeyInput')
|
|
})
|
|
|
|
function openCreateProviderModal() {
|
|
editingProvider.value = null
|
|
advancedOpen.value = false
|
|
Object.assign(providerForm, {
|
|
id: '',
|
|
name: '',
|
|
baseUrl: '',
|
|
apiKey: '',
|
|
apiKeyPrefix: 'sk-',
|
|
protocol: 'openai-compatible',
|
|
chatModel: 'OpenAIChatModel',
|
|
generateKwargsText: '{}',
|
|
enableSearch: false,
|
|
searchStrategy: '',
|
|
fallbackPriority: 0,
|
|
})
|
|
showProviderModal.value = true
|
|
}
|
|
|
|
function openProviderConfigModal(provider: ProviderInfo) {
|
|
editingProvider.value = provider
|
|
advancedOpen.value = true
|
|
const kwargs = provider.generateKwargs || {}
|
|
const protocol = provider.protocol || chatModelToProtocol(provider.chatModel)
|
|
// DashScope opens search by default — only off when kwargs explicitly set false.
|
|
const isDashScope = protocol === 'dashscope-native'
|
|
const searchDefault = isDashScope ? kwargs.enableSearch !== false : !!kwargs.enableSearch
|
|
Object.assign(providerForm, {
|
|
id: provider.id,
|
|
name: provider.name,
|
|
baseUrl: provider.baseUrl || '',
|
|
apiKey: '',
|
|
apiKeyPrefix: provider.apiKeyPrefix || 'sk-',
|
|
protocol,
|
|
chatModel: provider.chatModel || 'OpenAIChatModel',
|
|
generateKwargsText: JSON.stringify(kwargs, null, 2),
|
|
enableSearch: searchDefault,
|
|
searchStrategy: (kwargs.searchStrategy as string) || '',
|
|
fallbackPriority: provider.fallbackPriority ?? 0,
|
|
})
|
|
showProviderModal.value = true
|
|
}
|
|
|
|
function closeProviderModal() {
|
|
showProviderModal.value = false
|
|
editingProvider.value = null
|
|
advancedOpen.value = false
|
|
}
|
|
|
|
async function saveProvider(): Promise<boolean> {
|
|
// RFC-074 / issue #39: provider id becomes a URL path segment, so a slash
|
|
// or other unsafe char makes the row impossible to delete later. Validate
|
|
// before hitting the API on the create path; editing is exempt because the
|
|
// id field is hidden and the existing value is reused untouched.
|
|
if (!editingProvider.value) {
|
|
const id = providerForm.id.trim()
|
|
if (!id || !PROVIDER_ID_PATTERN.test(id)) {
|
|
ElMessage.error(t('settings.model.providerIdInvalid'))
|
|
return false
|
|
}
|
|
providerForm.id = id
|
|
}
|
|
const kwargs = safeParseJson(providerForm.generateKwargsText)
|
|
if (providerForm.enableSearch) {
|
|
kwargs.enableSearch = true
|
|
if (providerForm.searchStrategy) {
|
|
kwargs.searchStrategy = providerForm.searchStrategy
|
|
} else {
|
|
delete kwargs.searchStrategy
|
|
}
|
|
} else {
|
|
delete kwargs.enableSearch
|
|
delete kwargs.searchStrategy
|
|
}
|
|
// RFC-009 P3.5: clamp to non-negative, coerce string input back to integer.
|
|
const fallbackPriority = Math.max(0, Math.floor(Number(providerForm.fallbackPriority) || 0))
|
|
if (editingProvider.value) {
|
|
await modelApi.updateProviderConfig(editingProvider.value.id, {
|
|
apiKey: providerForm.apiKey,
|
|
baseUrl: providerForm.baseUrl,
|
|
protocol: providerForm.protocol,
|
|
chatModel: protocolToChatModel(providerForm.protocol),
|
|
generateKwargs: kwargs,
|
|
fallbackPriority,
|
|
})
|
|
} else {
|
|
await modelApi.createCustomProvider({
|
|
id: providerForm.id,
|
|
name: providerForm.name,
|
|
defaultBaseUrl: providerForm.baseUrl,
|
|
apiKeyPrefix: providerForm.apiKeyPrefix,
|
|
protocol: providerForm.protocol,
|
|
chatModel: protocolToChatModel(providerForm.protocol),
|
|
models: [],
|
|
})
|
|
if (providerForm.apiKey || providerForm.generateKwargsText || fallbackPriority > 0) {
|
|
await modelApi.updateProviderConfig(providerForm.id, {
|
|
apiKey: providerForm.apiKey,
|
|
baseUrl: providerForm.baseUrl,
|
|
protocol: providerForm.protocol,
|
|
chatModel: protocolToChatModel(providerForm.protocol),
|
|
generateKwargs: kwargs,
|
|
fallbackPriority,
|
|
})
|
|
}
|
|
}
|
|
closeProviderModal()
|
|
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Inline API-key save from the provider card — bypasses the modal so the
|
|
* 90% case (paste a key, hit save) doesn't require opening a settings dialog.
|
|
*
|
|
* Backend updateProviderConfig is a PUT that overwrites baseUrl / chatModel /
|
|
* generateKwargs unconditionally, so we must echo the existing values to
|
|
* avoid clobbering them when we only want to change the key.
|
|
*/
|
|
async function saveProviderApiKey(provider: ProviderInfo, apiKey: string) {
|
|
const trimmed = apiKey.trim()
|
|
if (!trimmed) return
|
|
await modelApi.updateProviderConfig(provider.id, {
|
|
apiKey: trimmed,
|
|
baseUrl: provider.baseUrl ?? '',
|
|
protocol: provider.protocol || chatModelToProtocol(provider.chatModel),
|
|
chatModel: provider.chatModel,
|
|
generateKwargs: provider.generateKwargs ?? {},
|
|
// Omit fallbackPriority — backend treats null as "leave untouched".
|
|
})
|
|
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
|
|
}
|
|
|
|
async function deleteProvider(provider: ProviderInfo) {
|
|
const ok = await mcConfirm({
|
|
title: t('common.confirm'),
|
|
message: t('settings.model.deleteConfirm', { name: provider.name }),
|
|
confirmText: t('common.delete'),
|
|
tone: 'danger',
|
|
})
|
|
if (!ok) return false
|
|
await modelApi.deleteCustomProvider(provider.id)
|
|
await deps.loadProviders()
|
|
return true
|
|
}
|
|
|
|
return {
|
|
editingProvider,
|
|
showProviderModal,
|
|
advancedOpen,
|
|
providerForm,
|
|
protocolOptions,
|
|
providerBaseUrlPlaceholder,
|
|
providerBaseUrlHint,
|
|
providerApiKeyPlaceholder,
|
|
openCreateProviderModal,
|
|
openProviderConfigModal,
|
|
closeProviderModal,
|
|
saveProvider,
|
|
saveProviderApiKey,
|
|
deleteProvider,
|
|
}
|
|
}
|