Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,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: '返回修改',
Expand Down Expand Up @@ -400,6 +402,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: '返回修改',
Expand Down Expand Up @@ -570,6 +574,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',
Expand Down
162 changes: 142 additions & 20 deletions apps/desktop/src/renderer/settings/provider-add-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -214,6 +236,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<Record<string, string>>;
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;
Expand Down Expand Up @@ -416,6 +499,7 @@ export function AddProviderForm(props: {
onHeadersChange={(headers) => {
setRequestHeaders(headers);
resetManagedVerification();
invalidateDiscoveredModels();
clearFieldError('advancedRequest');
}}
bodyText={requestBodyText}
Expand All @@ -424,7 +508,7 @@ export function AddProviderForm(props: {
resetManagedVerification();
clearFieldError('advancedRequest');
}}
disabled={busy}
disabled={busy || fetchingModels}
copy={{
headers: copy.requestHeaders,
headerName: copy.headerName,
Expand Down Expand Up @@ -633,6 +717,7 @@ export function AddProviderForm(props: {
onChange={(next) => {
setApiKey(next);
resetManagedVerification();
invalidateDiscoveredModels();
clearFieldError('apiKey');
}}
placeholder={copy.apiKeyPlaceholder}
Expand Down Expand Up @@ -699,7 +784,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 }
Expand Down Expand Up @@ -757,10 +842,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={
Expand All @@ -771,17 +857,53 @@ export function AddProviderForm(props: {
/>
)}
{showsDefaultModel && (
<TextInput
value={defaultModel}
onChange={(value) => {
setDefaultModel(value);
resetManagedVerification();
}}
placeholder={copy.defaultModelPlaceholder}
isDisabled={isExperimental || busy}
label={copy.defaultModel}
description={copy.defaultModelHelp}
/>
<VStack gap={1.5}>
<TextInput
value={defaultModel}
onChange={(value) => {
setDefaultModel(value);
resetManagedVerification();
}}
placeholder={copy.defaultModelPlaceholder}
isDisabled={isExperimental || busy || fetchingModels}
label={copy.defaultModel}
description={copy.defaultModelHelp}
/>
{isCustomRelay && supportsRemoteDiscovery && props.apiKeyOnboardingBridge && (
<>
<Button
variant="secondary"
size="sm"
isDisabled={busy || fetchingModels}
onClick={() => void fetchModelOptions()}
label={fetchingModels ? copy.fetchingModels : copy.fetchModels}
/>
{discoveredModels && (
<Selector
label={copy.discoveredModels}
value={discoveredModels.some((model) => model.id === defaultModel)
? defaultModel
: ''}
options={discoveredModels.map((model) => ({
value: model.id,
label: model.displayName?.trim() || model.id,
}))}
placeholder={copy.chooseDiscoveredModel}
onChange={setDefaultModel}
isDisabled={busy || fetchingModels}
width="100%"
/>
)}
{error?.field === 'modelDiscovery' && (
<Banner
status="warning"
title={copy.modelsFetchFailed}
description={`${error.message} ${copy.modelsFetchFallback}`}
/>
)}
</>
)}
</VStack>
)}
{advancedRequestEditor}
</FormLayout>
Expand All @@ -790,7 +912,7 @@ export function AddProviderForm(props: {
)}
<HStack gap={2} justify="end">
<Button variant="ghost" isDisabled={busy} onClick={props.onCancel} label={copy.cancel} />
<Button variant="primary" isDisabled={busy || isExperimental} onClick={submit} label={busy ? copy.saving : copy.save} />
<Button variant="primary" isDisabled={busy || fetchingModels || isExperimental} onClick={submit} label={busy ? copy.saving : copy.save} />
</HStack>
</VStack>
);
Expand Down
Loading