diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 39f4017f4d..1a73a1f6fa 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -227,6 +227,8 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。', + fetchModels: '获取模型', fetchingModels: '正在获取模型…', discoveredModels: '已获取的模型', chooseDiscoveredModel: '选择一个已获取的模型', + modelsFetchFailed: '未能获取模型', modelsFetchFallback: '你仍可在上方手动填写模型 ID。', stepsAria: '添加连接步骤', stepCredentials: '密钥', stepModels: '选择模型', onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…', onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改', @@ -392,6 +394,8 @@ const zhTwCopy = { saving: '儲存中…', save: '儲存供應商', keyRequired: (name: string) => `請填寫 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服務地址', defaultModel: '預設模型', defaultModelPlaceholder: '留空即可,儲存後自動拉取', defaultModelHelp: '儲存後 Maka 會向該端點拉取模型目錄。只有當端點不提供目錄時,才需要在這裡手填一個模型 ID。', + fetchModels: '取得模型', fetchingModels: '正在取得模型…', discoveredModels: '已取得的模型', chooseDiscoveredModel: '選擇一個已取得的模型', + modelsFetchFailed: '無法取得模型', modelsFetchFallback: '你仍可在上方手動填寫模型 ID。', stepsAria: '新增連線步驟', stepCredentials: '金鑰', stepModels: '選擇模型', onboardingVerifyAndChoose: '驗證並選擇模型', onboardingVerifying: '正在驗證金鑰並取得模型…', onboardingChooseModels: '選擇此連線使用的模型', onboardingChooseModelsHelp: '新增後仍可在連線詳細資料中啟用其他模型。', onboardingEnabledModels: '啟用的模型', onboardingSearchModels: '搜尋模型', onboardingAddConnection: '新增連線', onboardingBack: '返回修改', @@ -558,6 +562,8 @@ const enCopy: ProviderSettingsCopy = { saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`, apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL', defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.', + fetchModels: 'Fetch models', fetchingModels: 'Fetching models…', discoveredModels: 'Discovered models', chooseDiscoveredModel: 'Choose a discovered model', + modelsFetchFailed: 'Could not fetch models', modelsFetchFallback: 'You can still enter a model ID manually above.', stepsAria: 'Steps to add the connection', stepCredentials: 'Key', stepModels: 'Choose models', onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…', onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit', diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 339cebc7a6..781b6f91d0 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,11 +18,12 @@ */ import { useState, type FormEvent } from 'react'; -import type { ProviderType } from '@maka/core/llm-connections'; +import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, + providerSupportsModelDiscovery, } from '@maka/core/llm-connections'; import { Banner, @@ -75,9 +76,16 @@ import { /* No `defaultModel`: the creation gate has no rule that can fail on the model id, so an error could never be reported against that field. The union is - kept aligned with `AddProviderIssue` plus the two form-local fields the + kept aligned with `AddProviderIssue` plus the three form-local fields the gate does not own. */ -type ProviderFormField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'advancedRequest' | 'form'; +type ProviderFormField = + | 'slug' + | 'apiKey' + | 'accountId' + | 'baseUrl' + | 'modelDiscovery' + | 'advancedRequest' + | 'form'; type ProviderFormError = { field: ProviderFormField; @@ -130,19 +138,25 @@ export function AddProviderForm(props: { const [formState, setFormState] = useState<{ readonly managedPhase: ManagedOnboardingPhase; readonly error: ProviderFormError | null; + readonly fetchingModels: boolean; + readonly discoveredModels: ModelInfo[] | null; }>(() => ({ managedPhase: { kind: 'input' }, error: null, + fetchingModels: false, + discoveredModels: null, })); - const { managedPhase, error } = formState; + const { managedPhase, error, fetchingModels, discoveredModels } = formState; const [busy, setBusy] = useState(false); - const submitGuard = useActionGuard<'submit'>(); + const submitGuard = useActionGuard<'submit' | 'fetch-models'>(); const addProviderMountedRef = useMountedRef(); const isCloudflareWorkersAi = props.providerType === 'cloudflare-workers-ai'; const requiresBaseUrl = !defaults.baseUrl && !isCloudflareWorkersAi; const showsDefaultModel = recommendedDefaultModel.trim() === ''; + const isCustomRelay = defaults.category === 'custom'; const isExperimental = defaults.status === 'phase3-experimental'; + const supportsRemoteDiscovery = providerSupportsModelDiscovery(props.providerType); const supportsApiKey = providerAuthSupportsApiKey(props.providerType); const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey; const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType); @@ -174,6 +188,14 @@ export function AddProviderForm(props: { ); } + function invalidateDiscoveredModels() { + setFormState((current) => ({ + ...current, + discoveredModels: null, + error: current.error?.field === 'modelDiscovery' ? null : current.error, + })); + } + // The localized sentence for one field gate. The gate itself is in // provider-add-submission, so the order and the rules are testable without // a locale in the assertion. @@ -210,6 +232,67 @@ export function AddProviderForm(props: { return copy.onboardingUnavailable; } + async function fetchModelOptions() { + const onboarding = props.apiKeyOnboardingBridge; + if (!onboarding || submitGuard.current !== null) return; + setError(null); + const normalizedApiKey = apiKey.trim(); + if (requiresApiKey && !normalizedApiKey) { + return setError({ field: 'apiKey', message: copy.keyRequired(display.name) }); + } + const normalizedBaseUrl = baseUrl.trim(); + if (requiresBaseUrl && !normalizedBaseUrl) { + return setError({ field: 'baseUrl', message: copy.endpointRequired }); + } + let normalizedRequestHeaders: Readonly>; + try { + normalizedRequestHeaders = newRequestHeaders(requestHeaders); + } catch { + setAdvancedOpen(true); + return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); + } + submitGuard.begin('fetch-models'); + setFormState((current) => ({ + ...current, + fetchingModels: true, + discoveredModels: null, + })); + try { + const result = await onboarding.verify({ + target: { kind: 'create', providerType: props.providerType }, + apiKey: normalizedApiKey || null, + baseUrl: normalizedBaseUrl || null, + ...(Object.keys(normalizedRequestHeaders).length > 0 + ? { requestHeaders: normalizedRequestHeaders } + : {}), + }); + if (!addProviderMountedRef.current) return; + if (result.kind !== 'verified') { + setError({ field: 'modelDiscovery', message: onboardingFailureMessage(result) }); + return; + } + const models = stableOnboardingModels(result.models); + if (models.length === 0) { + setError({ field: 'modelDiscovery', message: copy.onboardingNoModels }); + return; + } + setFormState((current) => ({ ...current, discoveredModels: models })); + setDefaultModel((current) => current.trim() || models[0]!.id); + } catch (fetchError) { + if (!addProviderMountedRef.current) return; + setFormState((current) => ({ ...current, discoveredModels: null })); + setError({ + field: 'modelDiscovery', + message: providerPanelActionErrorMessage(fetchError, locale), + }); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) { + setFormState((current) => ({ ...current, fetchingModels: false })); + } + } + } + async function verifyManagedApiKey(normalizedApiKey: string) { const onboarding = props.apiKeyOnboardingBridge; if (!onboarding) return; @@ -412,6 +495,7 @@ export function AddProviderForm(props: { onHeadersChange={(headers) => { setRequestHeaders(headers); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('advancedRequest'); }} bodyText={requestBodyText} @@ -420,7 +504,7 @@ export function AddProviderForm(props: { resetManagedVerification(); clearFieldError('advancedRequest'); }} - disabled={busy} + disabled={busy || fetchingModels} copy={{ headers: copy.requestHeaders, headerName: copy.headerName, @@ -629,6 +713,7 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('apiKey'); }} placeholder={copy.apiKeyPlaceholder} @@ -695,7 +780,7 @@ export function AddProviderForm(props: { label={copy.apiKeyLabel} isRequired={requiresApiKey} isOptional={!requiresApiKey} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} status={ error?.field === 'apiKey' ? { type: 'error', message: error.message } @@ -753,10 +838,11 @@ export function AddProviderForm(props: { onChange={(value) => { setBaseUrl(value); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('baseUrl'); }} placeholder={defaults.baseUrl || 'https://…'} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} label={copy.endpointLabel} isRequired={requiresBaseUrl} status={ @@ -767,17 +853,53 @@ export function AddProviderForm(props: { /> )} {showsDefaultModel && ( - { - setDefaultModel(value); - resetManagedVerification(); - }} - placeholder={copy.defaultModelPlaceholder} - isDisabled={isExperimental || busy} - label={copy.defaultModel} - description={copy.defaultModelHelp} - /> + + { + setDefaultModel(value); + resetManagedVerification(); + }} + placeholder={copy.defaultModelPlaceholder} + isDisabled={isExperimental || busy || fetchingModels} + label={copy.defaultModel} + description={copy.defaultModelHelp} + /> + {isCustomRelay && supportsRemoteDiscovery && props.apiKeyOnboardingBridge && ( + <> +