Files
goose/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx
2025-04-10 08:54:10 -07:00

313 lines
10 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import { ArrowLeftRight, ExternalLink } from 'lucide-react';
import Modal from '../../../Modal';
import { Button } from '../../../ui/button';
import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants';
import { Input } from '../../../ui/input';
import { Select } from '../../../ui/Select';
import { useConfig } from '../../../ConfigContext';
import { changeModel } from '../index';
import type { View } from '../../../../App';
import Model, { getProviderMetadata } from '../modelInterface';
const ModalButtons = ({ onSubmit, onCancel, _isValid, _validationErrors }) => (
<div>
<Button
type="submit"
variant="ghost"
onClick={onSubmit}
className="w-full h-[60px] rounded-none border-borderSubtle text-base hover:bg-bgSubtle text-textProminent font-regular"
>
Select model
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="w-full h-[60px] rounded-none border-t border-borderSubtle hover:text-textStandard text-textSubtle hover:bg-bgSubtle text-base font-regular"
>
Cancel
</Button>
</div>
);
type AddModelModalProps = {
onClose: () => void;
setView: (view: View) => void;
};
export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const { getProviders, upsert, getExtensions, addExtension } = useConfig();
const [providerOptions, setProviderOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const [provider, setProvider] = useState<string | null>(null);
const [model, setModel] = useState<string>('');
const [isCustomModel, setIsCustomModel] = useState(false);
const [validationErrors, setValidationErrors] = useState({
provider: '',
model: '',
});
const [isValid, setIsValid] = useState(true);
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
// Validate form data
const validateForm = useCallback(() => {
const errors = {
provider: '',
model: '',
};
let formIsValid = true;
if (!provider) {
errors.provider = 'Please select a provider';
formIsValid = false;
}
if (!model) {
errors.model = 'Please select or enter a model';
formIsValid = false;
}
setValidationErrors(errors);
setIsValid(formIsValid);
return formIsValid;
}, [model, provider]);
const onSubmit = async () => {
setAttemptedSubmit(true);
const isFormValid = validateForm();
if (isFormValid) {
const providerMetaData = await getProviderMetadata(provider, getProviders);
const providerDisplayName = providerMetaData.display_name;
await changeModel({
model: { name: model, provider: provider, subtext: providerDisplayName } as Model, // pass in a Model object
writeToConfig: upsert,
getExtensions,
addExtension,
});
onClose();
}
};
// Re-validate when inputs change and after attempted submission
useEffect(() => {
if (attemptedSubmit) {
validateForm();
}
}, [attemptedSubmit, validateForm]);
useEffect(() => {
(async () => {
try {
const providersResponse = await getProviders(false);
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
// Create provider options and add "Use other provider" option
setProviderOptions([
...activeProviders.map(({ metadata, name }) => ({
value: name,
label: metadata.display_name,
})),
{
value: 'configure_providers',
label: 'Use other provider',
},
]);
// Format model options by provider
const formattedModelOptions = [];
activeProviders.forEach(({ metadata, name }) => {
if (metadata.known_models && metadata.known_models.length > 0) {
formattedModelOptions.push({
options: metadata.known_models.map((modelName) => ({
value: modelName,
label: modelName,
provider: name,
})),
});
}
});
// Add the "Custom model" option to each provider group
formattedModelOptions.forEach((group) => {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: group.options[0]?.provider,
});
});
setModelOptions(formattedModelOptions);
setOriginalModelOptions(formattedModelOptions);
} catch (error) {
console.error('Failed to load providers:', error);
}
})();
}, [getProviders]);
// Filter model options based on selected provider
const filteredModelOptions = provider
? modelOptions.filter((group) => group.options[0]?.provider === provider)
: [];
// Handle model selection change
const handleModelChange = (selectedOption) => {
if (selectedOption?.value === 'custom') {
setIsCustomModel(true);
setModel('');
} else {
setIsCustomModel(false);
setModel(selectedOption?.value || '');
}
};
// Store the original model options in state, initialized from modelOptions
const [originalModelOptions, setOriginalModelOptions] = useState(modelOptions);
const handleInputChange = (inputValue: string) => {
if (!provider) return;
const trimmedInput = inputValue.trim();
if (trimmedInput === '') {
// Reset to original model options when input is cleared
setModelOptions([...originalModelOptions]); // Create new array to ensure state update
return;
}
// Filter through the original model options to find matches
const matchingOptions = originalModelOptions
.map((group) => ({
options: group.options.filter(
(option) =>
option.value.toLowerCase().includes(trimmedInput.toLowerCase()) &&
option.value !== 'custom' // Exclude the "Use custom model" option from search
),
}))
.filter((group) => group.options.length > 0);
if (matchingOptions.length > 0) {
// If we found matches in the existing options, show those
setModelOptions(matchingOptions);
} else {
// If no matches, show the "Use: " option
const customOption = [
{
options: [
{
value: trimmedInput,
label: `Use: "${trimmedInput}"`,
provider: provider,
},
],
},
];
setModelOptions(customOption);
}
};
return (
<div className="z-10">
<Modal
onClose={onClose}
footer={
<ModalButtons
onSubmit={onSubmit}
onCancel={onClose}
isValid={isValid}
validationErrors={validationErrors}
/>
}
>
<div className="flex flex-col items-center gap-8">
<div className="flex flex-col gap-3">
<ArrowLeftRight size={24} className="text-textStandard" />
<div className="text-textStandard font-medium text-base">Switch models</div>
<div className="text-textSubtle text-md">
Configure your AI model providers by adding their API keys. Your keys are stored
securely and encrypted locally.
</div>
<div>
<a
href={QUICKSTART_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-textStandard font-medium text-sm"
>
<ExternalLink size={16} className="mr-1" />
View quick start guide
</a>
</div>
</div>
<div className="w-full flex flex-col gap-4">
<div>
<Select
options={providerOptions}
value={providerOptions.find((option) => option.value === provider) || null}
onChange={(option) => {
if (option?.value === 'configure_providers') {
// Navigate to ConfigureProviders view
setView('ConfigureProviders');
onClose(); // Close the current modal
} else {
setProvider(option?.value || null);
setModel('');
setIsCustomModel(false);
}
}}
placeholder="Provider"
isClearable
/>
{attemptedSubmit && validationErrors.provider && (
<div className="text-red-500 text-sm mt-1">{validationErrors.provider}</div>
)}
</div>
{provider && (
<>
{!isCustomModel ? (
<div>
<Select
options={filteredModelOptions}
onChange={handleModelChange}
onInputChange={handleInputChange} // Added for input handling
value={model ? { value: model, label: model } : null}
placeholder="Select a model"
/>
{attemptedSubmit && validationErrors.model && (
<div className="text-red-500 text-sm mt-1">{validationErrors.model}</div>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<label className="text-sm text-textSubtle">Custom model name</label>
<button
onClick={() => setIsCustomModel(false)}
className="text-sm text-textSubtle"
>
Back to model list
</button>
</div>
<Input
className="border-2 px-4 py-5"
placeholder="Type model name here"
onChange={(event) => setModel(event.target.value)}
value={model}
/>
{attemptedSubmit && validationErrors.model && (
<div className="text-red-500 text-sm mt-1">{validationErrors.model}</div>
)}
</div>
)}
</>
)}
</div>
</div>
</Modal>
</div>
);
};