Files
tkmind_go/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.tsx
T

518 lines
18 KiB
TypeScript

import { useState, Fragment } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../../ui/dialog';
import DefaultProviderSetupForm, {
ConfigInput,
} from './subcomponents/forms/DefaultProviderSetupForm';
import ProviderSetupActions from './subcomponents/ProviderSetupActions';
import ProviderLogo from './subcomponents/ProviderLogo';
import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler';
import {
acpAuthenticateProvider,
acpDeleteCustomProvider,
acpDeleteProviderConfig,
acpSaveProviderConfig,
} from '../../../../acp/providers';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle, LogIn } from 'lucide-react';
import { ProviderDetails } from '../../../../api';
import { Button } from '../../../../components/ui/button';
import { errorMessage } from '../../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../../i18n';
import HuggingFaceSignInPrompt from '../../auth/HuggingFaceSignInPrompt';
const i18n = defineMessages({
deleteConfigHeader: {
id: 'providerConfigurationModal.deleteConfigHeader',
defaultMessage: 'Delete configuration for {providerName}',
},
configureHeader: {
id: 'providerConfigurationModal.configureHeader',
defaultMessage: 'Configure {providerName}',
},
cannotDeleteActive: {
id: 'providerConfigurationModal.cannotDeleteActive',
defaultMessage:
"You cannot delete this provider while it's currently in use. Please switch to a different model first.",
},
deleteConfirmation: {
id: 'providerConfigurationModal.deleteConfirmation',
defaultMessage: 'This will permanently delete the current provider configuration.',
},
oauthSignInDescription: {
id: 'providerConfigurationModal.oauthSignInDescription',
defaultMessage: 'Sign in with your {providerName} account to use this provider',
},
addApiKeyDescription: {
id: 'providerConfigurationModal.addApiKeyDescription',
defaultMessage: 'Add your API key(s) for this provider to integrate into goose',
},
oauthLoginFailed: {
id: 'providerConfigurationModal.oauthLoginFailed',
defaultMessage: 'OAuth login failed: {error}',
},
parameterRequired: {
id: 'providerConfigurationModal.parameterRequired',
defaultMessage: '{paramName} is required',
},
errorTitle: {
id: 'providerConfigurationModal.errorTitle',
defaultMessage: 'Error',
},
errorCheckingConfig: {
id: 'providerConfigurationModal.errorCheckingConfig',
defaultMessage: 'There was an error checking this provider configuration.',
},
checkConfigAgain: {
id: 'providerConfigurationModal.checkConfigAgain',
defaultMessage: 'Check your configuration again to use this provider.',
},
goBack: {
id: 'providerConfigurationModal.goBack',
defaultMessage: 'Go Back',
},
signingIn: {
id: 'providerConfigurationModal.signingIn',
defaultMessage: 'Signing in...',
},
signInWith: {
id: 'providerConfigurationModal.signInWith',
defaultMessage: 'Sign in with {providerName}',
},
browserWindowHint: {
id: 'providerConfigurationModal.browserWindowHint',
defaultMessage: 'A browser window will open for you to complete the login.',
},
deviceCodeFlowHint: {
id: 'providerConfigurationModal.deviceCodeFlowHint',
defaultMessage:
'A browser window will open and the verification code will be copied to your clipboard. Paste it in the browser to complete sign-in.',
},
externalSetupIntro: {
id: 'providerConfigurationModal.externalSetupIntro',
defaultMessage: 'This provider is configured outside of goose. Follow these steps:',
},
seeDocumentation: {
id: 'providerConfigurationModal.seeDocumentation',
defaultMessage: 'See the <link>documentation</link> for more details.',
},
cancel: {
id: 'providerConfigurationModal.cancel',
defaultMessage: 'Cancel',
},
removeConfiguration: {
id: 'providerConfigurationModal.removeConfiguration',
defaultMessage: 'Remove Configuration',
},
close: {
id: 'providerConfigurationModal.close',
defaultMessage: 'Close',
},
huggingFaceOAuthDescription: {
id: 'providerConfigurationModal.huggingFaceOAuthDescription',
defaultMessage:
'Sign in to use Hugging Face Inference Providers without manually entering an API token.',
},
});
/** Render a setup step string, turning `backtick` spans into <code> and newlines into <br/>. */
function renderSetupStep(text: string) {
// Split on backtick-wrapped segments
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code
key={i}
className="px-1 py-0.5 rounded bg-background-secondary text-xs font-mono break-all"
>
{part.slice(1, -1)}
</code>
);
}
// Handle newlines within text
const lines = part.split('\n');
return (
<Fragment key={i}>
{lines.map((line, j) => (
<Fragment key={j}>
{j > 0 && <br />}
{line}
</Fragment>
))}
</Fragment>
);
});
}
interface ProviderConfigurationModalProps {
provider: ProviderDetails;
onClose: () => void;
onConfigured?: (provider: ProviderDetails) => void;
}
export default function ProviderConfigurationModal({
provider,
onClose,
onConfigured,
}: ProviderConfigurationModalProps) {
const intl = useIntl();
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { getCurrentModelAndProvider } = useModelAndProvider();
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [isActiveProvider, setIsActiveProvider] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOAuthLoading, setIsOAuthLoading] = useState(false);
let primaryParameters = provider.metadata.config_keys.filter((param) => param.primary);
if (primaryParameters.length === 0) {
primaryParameters = provider.metadata.config_keys;
}
const configKeys = provider.metadata.config_keys.filter((key) => !key.oauth_flow);
const hasOAuth = provider.metadata.config_keys.some((key) => key.oauth_flow);
const hasConfig = configKeys.length > 0;
const hasDeviceCodeFlow = provider.metadata.config_keys.some((key) => key.device_code_flow);
const isHuggingFaceProvider = provider.name === 'huggingface';
const isConfigured = provider.is_configured;
const headerText = showDeleteConfirmation
? intl.formatMessage(i18n.deleteConfigHeader, { providerName: provider.metadata.display_name })
: intl.formatMessage(i18n.configureHeader, { providerName: provider.metadata.display_name });
const isExternalSetup =
provider.metadata.config_keys.length === 0 &&
provider.metadata.setup_steps &&
provider.metadata.setup_steps.length > 0;
const descriptionText = showDeleteConfirmation
? isActiveProvider
? intl.formatMessage(i18n.cannotDeleteActive)
: intl.formatMessage(i18n.deleteConfirmation)
: hasOAuth
? intl.formatMessage(i18n.oauthSignInDescription, {
providerName: provider.metadata.display_name,
})
: isExternalSetup
? provider.metadata.description
: intl.formatMessage(i18n.addApiKeyDescription);
const handleOAuthLogin = async () => {
setIsOAuthLoading(true);
setError(null);
try {
if (hasConfig) {
const fields: { key: string; value: string }[] = [];
for (const key of configKeys) {
const entry = configValues[key.name];
const value =
entry?.value ?? (typeof entry?.serverValue === 'string' ? entry.serverValue : null);
if (value) {
fields.push({ key: key.name, value });
}
}
if (fields.length > 0) {
await acpSaveProviderConfig(provider.name, fields);
}
}
await acpAuthenticateProvider(provider.name);
if (onConfigured) {
onConfigured(provider);
} else {
onClose();
}
} catch (err) {
setError(intl.formatMessage(i18n.oauthLoginFailed, { error: errorMessage(err) }));
} finally {
setIsOAuthLoading(false);
}
};
const handleSubmitForm = async (e: React.FormEvent) => {
e.preventDefault();
setValidationErrors({});
const parameters = provider.metadata.config_keys || [];
const errors: Record<string, string> = {};
parameters.forEach((parameter) => {
if (
parameter.required &&
!configValues[parameter.name]?.value &&
!configValues[parameter.name]?.serverValue
) {
errors[parameter.name] = intl.formatMessage(i18n.parameterRequired, {
paramName: parameter.name,
});
}
});
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
const toSubmit = Object.fromEntries(
Object.entries(configValues)
.filter(
([_k, entry]) =>
!!entry.value || (entry.serverValue != null && typeof entry.serverValue === 'string')
)
.map(([k, entry]) => [
k,
entry.value ?? (typeof entry.serverValue === 'string' ? entry.serverValue : ''),
])
);
try {
await providerConfigSubmitHandler(provider, toSubmit);
if (onConfigured) {
onConfigured(provider);
} else {
onClose();
}
} catch (error) {
setError(errorMessage(error));
}
};
const handleCancel = () => {
onClose();
};
const handleDelete = async () => {
try {
const providerModel = await getCurrentModelAndProvider();
if (provider.name === providerModel.provider) {
setIsActiveProvider(true);
setShowDeleteConfirmation(true);
return;
}
} catch (error) {
console.error('Failed to check current provider:', error);
}
setIsActiveProvider(false);
setShowDeleteConfirmation(true);
};
const handleConfirmDelete = async () => {
if (isActiveProvider) {
return;
}
const isCustomProvider = provider.provider_type === 'Custom';
if (isCustomProvider) {
await acpDeleteCustomProvider(provider.name);
} else {
// Deletes all config/secret fields and cleans up provider-specific cache
// (e.g. OAuth tokens) server-side.
await acpDeleteProviderConfig(provider.name);
}
onClose();
};
const getModalIcon = () => {
if (showDeleteConfirmation) {
return (
<AlertTriangle
className={isActiveProvider ? 'text-yellow-500' : 'text-red-500'}
size={24}
/>
);
}
return <ProviderLogo providerName={provider.name} />;
};
return (
<>
<Dialog open={!!error} onOpenChange={(open) => !open && setError(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogTitle className="flex items-center gap-2">
{intl.formatMessage(i18n.errorTitle)}
</DialogTitle>
<DialogDescription className="text-inherit text-base">
{intl.formatMessage(i18n.errorCheckingConfig)}
</DialogDescription>
<pre className="ml-2">{error}</pre>
<div>{intl.formatMessage(i18n.checkConfigAgain)}</div>
<DialogFooter>
<Button variant="outline" onClick={() => setError(null)}>
{intl.formatMessage(i18n.goBack)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!error} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{getModalIcon()}
{headerText}
</DialogTitle>
<DialogDescription>{descriptionText}</DialogDescription>
</DialogHeader>
<div className="py-4">
{/* Contains information used to set up each provider */}
{/* Only show the form when NOT in delete confirmation mode */}
{!showDeleteConfirmation && (
<>
{hasOAuth && (
<div className="flex flex-col items-center gap-4 py-6">
<Button
onClick={handleOAuthLogin}
disabled={isOAuthLoading}
className="flex items-center gap-2 px-6 py-3"
size="lg"
>
<LogIn size={20} />
{isOAuthLoading
? intl.formatMessage(i18n.signingIn)
: intl.formatMessage(i18n.signInWith, {
providerName: provider.metadata.display_name,
})}
</Button>
<p className="text-sm text-text-secondary text-center">
{hasDeviceCodeFlow
? intl.formatMessage(i18n.deviceCodeFlowHint)
: intl.formatMessage(i18n.browserWindowHint)}
</p>
</div>
)}
{hasOAuth && hasConfig && (
<DefaultProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
provider={{
...provider,
metadata: {
...provider.metadata,
config_keys: configKeys,
},
}}
validationErrors={validationErrors}
/>
)}
{isHuggingFaceProvider && !hasOAuth && (
<HuggingFaceSignInPrompt
className="mb-4"
description={intl.formatMessage(i18n.huggingFaceOAuthDescription)}
onSignedIn={() => {
if (onConfigured) {
onConfigured(provider);
} else {
onClose();
}
}}
/>
)}
{isExternalSetup && (
<div className="space-y-3">
<p className="text-sm text-text-secondary">
{intl.formatMessage(i18n.externalSetupIntro)}
</p>
<ol className="ml-5 list-decimal text-sm text-text-primary space-y-2">
{provider.metadata.setup_steps?.map((step, i) => (
<li key={i}>{renderSetupStep(step)}</li>
))}
</ol>
{provider.metadata.model_doc_link && (
<p className="text-sm text-text-secondary mt-4">
{intl.formatMessage(i18n.seeDocumentation, {
link: (chunks: React.ReactNode) => (
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.electron.openExternal(provider.metadata.model_doc_link);
}}
className="underline hover:text-text-primary"
>
{chunks}
</a>
),
})}
</p>
)}
</div>
)}
{!hasOAuth && !isExternalSetup && (
<>
<DefaultProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
provider={provider}
validationErrors={validationErrors}
/>
{primaryParameters.length > 0 && provider.metadata.config_keys.length > 0 && (
<SecureStorageNotice />
)}
</>
)}
</>
)}
</div>
<DialogFooter>
{hasOAuth && !showDeleteConfirmation ? (
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
{intl.formatMessage(i18n.cancel)}
</Button>
{isConfigured && (
<Button variant="destructive" onClick={handleDelete}>
{intl.formatMessage(i18n.removeConfiguration)}
</Button>
)}
</div>
) : isExternalSetup && !showDeleteConfirmation ? (
<div className="w-full">
<Button
type="button"
variant="ghost"
onClick={handleCancel}
className="w-full h-[60px] rounded-none border-t border-border-primary text-md hover:bg-background-secondary text-text-primary font-medium"
>
{intl.formatMessage(i18n.close)}
</Button>
</div>
) : (
<ProviderSetupActions
primaryParameters={primaryParameters}
onCancel={handleCancel}
onSubmit={handleSubmitForm}
onDelete={handleDelete}
showDeleteConfirmation={showDeleteConfirmation}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={() => {
setIsActiveProvider(false);
setShowDeleteConfirmation(false);
}}
canDelete={isConfigured && !isActiveProvider}
providerName={provider.metadata.display_name}
isActiveProvider={isActiveProvider}
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}