import { computed, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' import { modelApi } from '@/api' import type { ProviderInfo } from '@/types' import { safeParseJson } from '@/utils/safeJson' import { chatModelToProtocol, protocolToChatModel } from '@/utils/modelProtocol' interface ListDeps { loadProviders: () => Promise loadActiveModel: () => Promise } /** * 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(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://.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 === 'volcengine') return 'https://ark.cn-beijing.volces.com/api/v3' 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') 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() { 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()]) } async function deleteProvider(provider: ProviderInfo) { if (!confirm(t('settings.model.deleteConfirm', { name: provider.name }))) { 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, deleteProvider, } }