123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116 |
- import { useEffect, useState, useRef, useCallback } from 'react';
- import type { KeyboardEvent } from 'react';
- import { Button } from '@extension/ui';
- import {
- llmProviderStore,
- agentModelStore,
- AgentNameEnum,
- llmProviderModelNames,
- ProviderTypeEnum,
- getDefaultDisplayNameFromProviderId,
- getDefaultProviderConfig,
- getDefaultAgentModelParams,
- } from '@extension/storage';
- interface ModelSettingsProps {
- isDarkMode?: boolean;
- }
- export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
- const [providers, setProviders] = useState<
- Record<
- string,
- {
- apiKey: string;
- baseUrl?: string;
- name?: string;
- modelNames?: string[];
- type?: ProviderTypeEnum;
- createdAt?: number;
- }
- >
- >({});
- const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
- const [providersFromStorage, setProvidersFromStorage] = useState<Set<string>>(new Set());
- const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
- [AgentNameEnum.Navigator]: '',
- [AgentNameEnum.Planner]: '',
- [AgentNameEnum.Validator]: '',
- });
- const [modelParameters, setModelParameters] = useState<Record<AgentNameEnum, { temperature: number; topP: number }>>({
- [AgentNameEnum.Navigator]: { temperature: 0, topP: 0 },
- [AgentNameEnum.Planner]: { temperature: 0, topP: 0 },
- [AgentNameEnum.Validator]: { temperature: 0, topP: 0 },
- });
- const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
- const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false);
- const newlyAddedProviderRef = useRef<string | null>(null);
- const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
- // Create a non-async wrapper for use in render functions
- const [availableModels, setAvailableModels] = useState<
- Array<{ provider: string; providerName: string; model: string }>
- >([]);
- useEffect(() => {
- const loadProviders = async () => {
- try {
- const allProviders = await llmProviderStore.getAllProviders();
- console.log('allProviders', allProviders);
- // Track which providers are from storage
- const fromStorage = new Set(Object.keys(allProviders));
- setProvidersFromStorage(fromStorage);
- // Only use providers from storage, don't add default ones
- setProviders(allProviders);
- } catch (error) {
- console.error('Error loading providers:', error);
- // Set empty providers on error
- setProviders({});
- // No providers from storage on error
- setProvidersFromStorage(new Set());
- }
- };
- loadProviders();
- }, []);
- // Load existing agent models and parameters on mount
- useEffect(() => {
- const loadAgentModels = async () => {
- try {
- const models: Record<AgentNameEnum, string> = {
- [AgentNameEnum.Planner]: '',
- [AgentNameEnum.Navigator]: '',
- [AgentNameEnum.Validator]: '',
- };
- for (const agent of Object.values(AgentNameEnum)) {
- const config = await agentModelStore.getAgentModel(agent);
- if (config) {
- models[agent] = config.modelName;
- if (config.parameters?.temperature !== undefined || config.parameters?.topP !== undefined) {
- setModelParameters(prev => ({
- ...prev,
- [agent]: {
- temperature: config.parameters?.temperature ?? prev[agent].temperature,
- topP: config.parameters?.topP ?? prev[agent].topP,
- },
- }));
- }
- }
- }
- setSelectedModels(models);
- } catch (error) {
- console.error('Error loading agent models:', error);
- }
- };
- loadAgentModels();
- }, []);
- // Auto-focus the input field when a new provider is added
- useEffect(() => {
- // Only focus if we have a newly added provider reference
- if (newlyAddedProviderRef.current && providers[newlyAddedProviderRef.current]) {
- const providerId = newlyAddedProviderRef.current;
- const config = providers[providerId];
- // For custom providers, focus on the name input
- if (config.type === ProviderTypeEnum.CustomOpenAI) {
- const nameInput = document.getElementById(`${providerId}-name`);
- if (nameInput) {
- nameInput.focus();
- }
- } else {
- // For default providers, focus on the API key input
- const apiKeyInput = document.getElementById(`${providerId}-api-key`);
- if (apiKeyInput) {
- apiKeyInput.focus();
- }
- }
- // Clear the ref after focusing
- newlyAddedProviderRef.current = null;
- }
- }, [providers]);
- // Add a click outside handler to close the dropdown
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- const target = event.target as HTMLElement;
- if (isProviderSelectorOpen && !target.closest('.provider-selector-container')) {
- setIsProviderSelectorOpen(false);
- }
- };
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [isProviderSelectorOpen]);
- // Create a memoized version of getAvailableModels
- const getAvailableModelsCallback = useCallback(async () => {
- const models: Array<{ provider: string; providerName: string; model: string }> = [];
- try {
- // Load providers directly from storage
- const storedProviders = await llmProviderStore.getAllProviders();
- // Only use providers that are actually in storage
- for (const [provider, config] of Object.entries(storedProviders)) {
- const providerModels =
- config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
- models.push(
- ...providerModels.map(model => ({
- provider,
- providerName: config.name || provider,
- model,
- })),
- );
- }
- } catch (error) {
- console.error('Error loading providers for model selection:', error);
- }
- return models;
- }, []);
- // Update available models whenever providers change
- useEffect(() => {
- const updateAvailableModels = async () => {
- const models = await getAvailableModelsCallback();
- setAvailableModels(models);
- };
- updateAvailableModels();
- }, [getAvailableModelsCallback]); // Only depends on the callback
- const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => {
- setModifiedProviders(prev => new Set(prev).add(provider));
- setProviders(prev => ({
- ...prev,
- [provider]: {
- ...prev[provider],
- apiKey: apiKey.trim(),
- baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
- },
- }));
- };
- const handleNameChange = (provider: string, name: string) => {
- setModifiedProviders(prev => new Set(prev).add(provider));
- setProviders(prev => {
- const updated = {
- ...prev,
- [provider]: {
- ...prev[provider],
- name: name.trim(),
- },
- };
- return updated;
- });
- };
- const handleModelsChange = (provider: string, modelsString: string) => {
- setNewModelInputs(prev => ({
- ...prev,
- [provider]: modelsString,
- }));
- };
- const addModel = (provider: string, model: string) => {
- if (!model.trim()) return;
- setModifiedProviders(prev => new Set(prev).add(provider));
- setProviders(prev => {
- const providerData = prev[provider] || {};
- // Get current models - either from provider config or default models
- let currentModels = providerData.modelNames;
- if (currentModels === undefined) {
- currentModels = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
- }
- // Don't add duplicates
- if (currentModels.includes(model.trim())) return prev;
- return {
- ...prev,
- [provider]: {
- ...providerData,
- modelNames: [...currentModels, model.trim()],
- },
- };
- });
- // Clear the input
- setNewModelInputs(prev => ({
- ...prev,
- [provider]: '',
- }));
- };
- const removeModel = (provider: string, modelToRemove: string) => {
- setModifiedProviders(prev => new Set(prev).add(provider));
- setProviders(prev => {
- const providerData = prev[provider] || {};
- // If modelNames doesn't exist in the provider data yet, we need to initialize it
- // with the default models from llmProviderModelNames first
- if (!providerData.modelNames) {
- const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
- const filteredModels = defaultModels.filter(model => model !== modelToRemove);
- return {
- ...prev,
- [provider]: {
- ...providerData,
- modelNames: filteredModels,
- },
- };
- }
- // If modelNames already exists, just filter out the model to remove
- return {
- ...prev,
- [provider]: {
- ...providerData,
- modelNames: providerData.modelNames.filter(model => model !== modelToRemove),
- },
- };
- });
- };
- const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, provider: string) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- const value = newModelInputs[provider] || '';
- addModel(provider, value);
- }
- };
- const getButtonProps = (provider: string) => {
- const isInStorage = providersFromStorage.has(provider);
- const isModified = modifiedProviders.has(provider);
- const isCustom = providers[provider]?.type === ProviderTypeEnum.CustomOpenAI;
- // For deletion, we only care if it's in storage and not modified
- if (isInStorage && !isModified) {
- return {
- theme: isDarkMode ? 'dark' : 'light',
- variant: 'danger' as const,
- children: 'Delete',
- disabled: false,
- };
- }
- // For saving, we need to check if it has the required inputs
- // Only custom providers can be saved without an API key
- const hasInput = isCustom || Boolean(providers[provider]?.apiKey?.trim());
- return {
- theme: isDarkMode ? 'dark' : 'light',
- variant: 'primary' as const,
- children: 'Save',
- disabled: !hasInput || !isModified,
- };
- };
- const handleSave = async (provider: string) => {
- try {
- // Check if name contains spaces for custom providers
- if (providers[provider].type === ProviderTypeEnum.CustomOpenAI && providers[provider].name?.includes(' ')) {
- setNameErrors(prev => ({
- ...prev,
- [provider]: 'Spaces are not allowed in provider names. Please use underscores or other characters instead.',
- }));
- return;
- }
- // Check if base URL is required but missing for custom_openai
- if (
- providers[provider].type === ProviderTypeEnum.CustomOpenAI &&
- (!providers[provider].baseUrl || !providers[provider].baseUrl.trim())
- ) {
- alert('Base URL is required for custom OpenAI-compatible API providers');
- return;
- }
- // Check if API key is required but empty for built-in providers (except custom)
- const isCustom = providers[provider].type === ProviderTypeEnum.CustomOpenAI;
- if (!isCustom && (!providers[provider].apiKey || !providers[provider].apiKey.trim())) {
- alert('API key is required for this provider');
- return;
- }
- // Ensure modelNames is provided
- let modelNames = providers[provider].modelNames;
- if (!modelNames) {
- // Use default model names if not explicitly set
- modelNames = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
- }
- // The provider store will handle filling in the missing fields
- await llmProviderStore.setProvider(provider, {
- apiKey: providers[provider].apiKey || '',
- baseUrl: providers[provider].baseUrl,
- name: providers[provider].name,
- modelNames: modelNames,
- type: providers[provider].type,
- createdAt: providers[provider].createdAt,
- });
- // Clear any name errors on successful save
- setNameErrors(prev => {
- const newErrors = { ...prev };
- delete newErrors[provider];
- return newErrors;
- });
- // Add to providersFromStorage since it's now saved
- setProvidersFromStorage(prev => new Set(prev).add(provider));
- setModifiedProviders(prev => {
- const next = new Set(prev);
- next.delete(provider);
- return next;
- });
- // Refresh available models
- const models = await getAvailableModelsCallback();
- setAvailableModels(models);
- } catch (error) {
- console.error('Error saving API key:', error);
- }
- };
- const handleDelete = async (provider: string) => {
- try {
- // Delete the provider from storage regardless of its API key value
- await llmProviderStore.removeProvider(provider);
- // Remove from providersFromStorage
- setProvidersFromStorage(prev => {
- const next = new Set(prev);
- next.delete(provider);
- return next;
- });
- // Remove from providers state
- setProviders(prev => {
- const next = { ...prev };
- delete next[provider];
- return next;
- });
- // Also remove from modifiedProviders if it's there
- setModifiedProviders(prev => {
- const next = new Set(prev);
- next.delete(provider);
- return next;
- });
- // Refresh available models
- const models = await getAvailableModelsCallback();
- setAvailableModels(models);
- } catch (error) {
- console.error('Error deleting provider:', error);
- }
- };
- const handleCancelProvider = (providerId: string) => {
- // Remove the provider from the state
- setProviders(prev => {
- const next = { ...prev };
- delete next[providerId];
- return next;
- });
- // Remove from modified providers
- setModifiedProviders(prev => {
- const next = new Set(prev);
- next.delete(providerId);
- return next;
- });
- };
- const handleModelChange = async (agentName: AgentNameEnum, modelValue: string) => {
- // modelValue will be in format "provider>model"
- const [provider, model] = modelValue.split('>');
- // Set parameters based on provider type
- const newParameters = getDefaultAgentModelParams(provider, agentName);
- setModelParameters(prev => ({
- ...prev,
- [agentName]: newParameters,
- }));
- setSelectedModels(prev => ({
- ...prev,
- [agentName]: model,
- }));
- try {
- if (model) {
- await agentModelStore.setAgentModel(agentName, {
- provider,
- modelName: model,
- parameters: newParameters,
- });
- } else {
- // Reset storage if no model is selected
- await agentModelStore.resetAgentModel(agentName);
- }
- } catch (error) {
- console.error('Error saving agent model:', error);
- }
- };
- const handleParameterChange = async (agentName: AgentNameEnum, paramName: 'temperature' | 'topP', value: number) => {
- const newParameters = {
- ...modelParameters[agentName],
- [paramName]: value,
- };
- setModelParameters(prev => ({
- ...prev,
- [agentName]: newParameters,
- }));
- // Only update if we have a selected model
- if (selectedModels[agentName]) {
- try {
- // Find provider
- let provider: string | undefined;
- for (const [providerKey, providerConfig] of Object.entries(providers)) {
- const modelNames =
- providerConfig.modelNames || llmProviderModelNames[providerKey as keyof typeof llmProviderModelNames] || [];
- if (modelNames.includes(selectedModels[agentName])) {
- provider = providerKey;
- break;
- }
- }
- if (provider) {
- await agentModelStore.setAgentModel(agentName, {
- provider,
- modelName: selectedModels[agentName],
- parameters: newParameters,
- });
- }
- } catch (error) {
- console.error('Error saving agent parameters:', error);
- }
- }
- };
- const renderModelSelect = (agentName: AgentNameEnum) => (
- <div
- className={`rounded-lg border ${isDarkMode ? 'border-gray-700 bg-slate-800' : 'border-gray-200 bg-gray-50'} p-4`}>
- <h3 className={`mb-2 text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- {agentName.charAt(0).toUpperCase() + agentName.slice(1)}
- </h3>
- <p className={`mb-4 text-sm font-normal ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
- {getAgentDescription(agentName)}
- </p>
- <div className="space-y-4">
- {/* Model Selection */}
- <div className="flex items-center">
- <label
- htmlFor={`${agentName}-model`}
- className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Model
- </label>
- <select
- id={`${agentName}-model`}
- className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
- disabled={availableModels.length === 0}
- value={
- selectedModels[agentName]
- ? `${getProviderForModel(selectedModels[agentName])}>${selectedModels[agentName]}`
- : ''
- }
- onChange={e => handleModelChange(agentName, e.target.value)}>
- <option key="default" value="">
- Choose model
- </option>
- {availableModels.map(({ provider, providerName, model }) => (
- <option key={`${provider}>${model}`} value={`${provider}>${model}`}>
- {`${providerName} > ${model}`}
- </option>
- ))}
- </select>
- </div>
- {/* Temperature Slider */}
- <div className="flex items-center">
- <label
- htmlFor={`${agentName}-temperature`}
- className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Temperature
- </label>
- <div className="flex flex-1 items-center space-x-2">
- <input
- id={`${agentName}-temperature`}
- type="range"
- min="0"
- max="2"
- step="0.01"
- value={modelParameters[agentName].temperature}
- onChange={e => handleParameterChange(agentName, 'temperature', Number.parseFloat(e.target.value))}
- style={{
- background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`,
- }}
- className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
- />
- <div className="flex items-center space-x-2">
- <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
- {modelParameters[agentName].temperature.toFixed(2)}
- </span>
- <input
- type="number"
- min="0"
- max="2"
- step="0.01"
- value={modelParameters[agentName].temperature}
- onChange={e => {
- const value = Number.parseFloat(e.target.value);
- if (!Number.isNaN(value) && value >= 0 && value <= 2) {
- handleParameterChange(agentName, 'temperature', value);
- }
- }}
- className={`w-20 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} px-2 py-1 text-sm`}
- aria-label={`${agentName} temperature number input`}
- />
- </div>
- </div>
- </div>
- {/* Top P Slider */}
- <div className="flex items-center">
- <label
- htmlFor={`${agentName}-topP`}
- className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Top P
- </label>
- <div className="flex flex-1 items-center space-x-2">
- <input
- id={`${agentName}-topP`}
- type="range"
- min="0"
- max="1"
- step="0.001"
- value={modelParameters[agentName].topP}
- onChange={e => handleParameterChange(agentName, 'topP', Number.parseFloat(e.target.value))}
- style={{
- background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`,
- }}
- className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
- />
- <div className="flex items-center space-x-2">
- <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
- {modelParameters[agentName].topP.toFixed(3)}
- </span>
- <input
- type="number"
- min="0"
- max="1"
- step="0.001"
- value={modelParameters[agentName].topP}
- onChange={e => {
- const value = Number.parseFloat(e.target.value);
- if (!Number.isNaN(value) && value >= 0 && value <= 1) {
- handleParameterChange(agentName, 'topP', value);
- }
- }}
- className={`w-20 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} px-2 py-1 text-sm`}
- aria-label={`${agentName} top P number input`}
- />
- </div>
- </div>
- </div>
- </div>
- </div>
- );
- const getAgentDescription = (agentName: AgentNameEnum) => {
- switch (agentName) {
- case AgentNameEnum.Navigator:
- return 'Navigates websites and performs actions';
- case AgentNameEnum.Planner:
- return 'Develops and refines strategies to complete tasks';
- case AgentNameEnum.Validator:
- return 'Checks if tasks are completed successfully';
- default:
- return '';
- }
- };
- const getMaxCustomProviderNumber = () => {
- let maxNumber = 0;
- for (const providerId of Object.keys(providers)) {
- if (providerId.startsWith('custom_openai_')) {
- const match = providerId.match(/custom_openai_(\d+)/);
- if (match) {
- const number = Number.parseInt(match[1], 10);
- maxNumber = Math.max(maxNumber, number);
- }
- }
- }
- return maxNumber;
- };
- const addCustomProvider = () => {
- const nextNumber = getMaxCustomProviderNumber() + 1;
- const providerId = `custom_openai_${nextNumber}`;
- setProviders(prev => ({
- ...prev,
- [providerId]: {
- apiKey: '',
- name: `CustomProvider${nextNumber}`,
- type: ProviderTypeEnum.CustomOpenAI,
- baseUrl: '',
- modelNames: [],
- createdAt: Date.now(),
- },
- }));
- setModifiedProviders(prev => new Set(prev).add(providerId));
- // Set the newly added provider ref
- newlyAddedProviderRef.current = providerId;
- // Scroll to the newly added provider after render
- setTimeout(() => {
- const providerElement = document.getElementById(`provider-${providerId}`);
- if (providerElement) {
- providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }, 100);
- };
- const addBuiltInProvider = (provider: string) => {
- // Get the default provider configuration
- const config = getDefaultProviderConfig(provider);
- // Add the provider to the state
- setProviders(prev => ({
- ...prev,
- [provider]: config,
- }));
- // Mark as modified so it shows up in the UI
- setModifiedProviders(prev => new Set(prev).add(provider));
- // Set the newly added provider ref
- newlyAddedProviderRef.current = provider;
- // Scroll to the newly added provider after render
- setTimeout(() => {
- const providerElement = document.getElementById(`provider-${provider}`);
- if (providerElement) {
- providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }, 100);
- };
- // Sort providers to ensure newly added providers appear at the bottom
- const getSortedProviders = () => {
- // Filter providers to only include those from storage and newly added providers
- const filteredProviders = Object.entries(providers).filter(([providerId]) => {
- // Include if it's from storage
- if (providersFromStorage.has(providerId)) {
- return true;
- }
- // Include if it's a newly added provider (has been modified)
- if (modifiedProviders.has(providerId)) {
- return true;
- }
- // Exclude providers that aren't from storage and haven't been modified
- return false;
- });
- // Sort the filtered providers
- return filteredProviders.sort(([keyA, configA], [keyB, configB]) => {
- // First, separate newly added providers from stored providers
- const isNewA = !providersFromStorage.has(keyA) && modifiedProviders.has(keyA);
- const isNewB = !providersFromStorage.has(keyB) && modifiedProviders.has(keyB);
- // If one is new and one is stored, new ones go to the end
- if (isNewA && !isNewB) return 1;
- if (!isNewA && isNewB) return -1;
- // If both are new or both are stored, sort by createdAt
- if (configA.createdAt && configB.createdAt) {
- return configA.createdAt - configB.createdAt; // Sort in ascending order (oldest first)
- }
- // If only one has createdAt, put the one without createdAt at the end
- if (configA.createdAt) return -1;
- if (configB.createdAt) return 1;
- // If neither has createdAt, sort by type and then name
- const isCustomA = configA.type === ProviderTypeEnum.CustomOpenAI;
- const isCustomB = configB.type === ProviderTypeEnum.CustomOpenAI;
- if (isCustomA && !isCustomB) {
- return 1; // Custom providers come after non-custom
- }
- if (!isCustomA && isCustomB) {
- return -1; // Non-custom providers come before custom
- }
- // Sort alphabetically by name within each group
- return (configA.name || keyA).localeCompare(configB.name || keyB);
- });
- };
- const handleProviderSelection = (providerType: string) => {
- // Close the dropdown immediately
- setIsProviderSelectorOpen(false);
- // Handle custom provider
- if (providerType === ProviderTypeEnum.CustomOpenAI) {
- addCustomProvider();
- return;
- }
- // Handle built-in supported providers
- addBuiltInProvider(providerType);
- };
- const getProviderForModel = (modelName: string): string => {
- for (const [provider, config] of Object.entries(providers)) {
- const modelNames =
- config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
- if (modelNames.includes(modelName)) {
- return provider;
- }
- }
- return '';
- };
- return (
- <section className="space-y-6">
- {/* LLM Providers Section */}
- <div
- className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
- <h2 className={`mb-4 text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
- LLM Providers
- </h2>
- <div className="space-y-6">
- {getSortedProviders().length === 0 ? (
- <div className="py-8 text-center text-gray-500">
- <p className="mb-4">No providers configured yet. Add a provider to get started.</p>
- </div>
- ) : (
- getSortedProviders().map(([providerId, providerConfig]) => (
- <div
- key={providerId}
- id={`provider-${providerId}`}
- className={`space-y-4 ${modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) ? `rounded-lg border p-4 ${isDarkMode ? 'border-blue-700 bg-slate-700' : 'border-blue-200 bg-blue-50/70'}` : ''}`}>
- <div className="flex items-center justify-between">
- <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- {providerConfig.name || providerId}
- </h3>
- <div className="flex space-x-2">
- {/* Show Cancel button for newly added providers */}
- {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
- <Button variant="secondary" onClick={() => handleCancelProvider(providerId)}>
- Cancel
- </Button>
- )}
- <Button
- variant={getButtonProps(providerId).variant}
- disabled={getButtonProps(providerId).disabled}
- onClick={() =>
- providersFromStorage.has(providerId) && !modifiedProviders.has(providerId)
- ? handleDelete(providerId)
- : handleSave(providerId)
- }>
- {getButtonProps(providerId).children}
- </Button>
- </div>
- </div>
- {/* Show message for newly added providers */}
- {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
- <div className={`mb-2 text-sm ${isDarkMode ? 'text-teal-300' : 'text-teal-700'}`}>
- <p>This provider is newly added. Enter your API key and click Save to configure it.</p>
- </div>
- )}
- <div className="space-y-3">
- {/* Name input (only for custom_openai) - moved to top for prominence */}
- {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
- <div className="flex flex-col">
- <div className="flex items-center">
- <label
- htmlFor={`${providerId}-name`}
- className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Name
- </label>
- <input
- id={`${providerId}-name`}
- type="text"
- placeholder="Provider name"
- value={providerConfig.name || ''}
- onChange={e => {
- console.log('Name input changed:', e.target.value);
- handleNameChange(providerId, e.target.value);
- }}
- className={`flex-1 rounded-md border p-2 text-sm ${
- nameErrors[providerId]
- ? isDarkMode
- ? 'border-red-700 bg-slate-700 text-gray-200 focus:border-red-600 focus:ring-2 focus:ring-red-900'
- : 'border-red-300 bg-gray-50 focus:border-red-400 focus:ring-2 focus:ring-red-200'
- : isDarkMode
- ? 'border-blue-700 bg-slate-700 text-gray-200 focus:border-blue-600 focus:ring-2 focus:ring-blue-900'
- : 'border-blue-300 bg-gray-50 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'
- } outline-none`}
- />
- </div>
- {nameErrors[providerId] ? (
- <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-red-400' : 'text-red-500'}`}>
- {nameErrors[providerId]}
- </p>
- ) : (
- <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
- Provider name (spaces are not allowed when saving)
- </p>
- )}
- </div>
- )}
- {/* API Key input with label */}
- <div className="flex items-center">
- <label
- htmlFor={`${providerId}-api-key`}
- className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Key{providerConfig.type !== ProviderTypeEnum.CustomOpenAI ? '*' : ''}
- </label>
- <input
- id={`${providerId}-api-key`}
- type="password"
- placeholder={
- providerConfig.type === ProviderTypeEnum.CustomOpenAI
- ? `${providerConfig.name || providerId} API key (optional)`
- : `${providerConfig.name || providerId} API key (required)`
- }
- value={providerConfig.apiKey || ''}
- onChange={e => handleApiKeyChange(providerId, e.target.value, providerConfig.baseUrl)}
- className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} p-2 outline-none`}
- />
- </div>
- {/* Display API key for newly added providers */}
- {modifiedProviders.has(providerId) &&
- !providersFromStorage.has(providerId) &&
- providerConfig.apiKey && (
- <div className="ml-20 mt-1">
- <p
- className={`font-mono text-sm break-words ${isDarkMode ? 'text-emerald-400' : 'text-emerald-600'}`}>
- {providerConfig.apiKey}
- </p>
- </div>
- )}
- {/* Base URL input (for custom_openai and ollama) */}
- {(providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
- providerConfig.type === ProviderTypeEnum.Ollama) && (
- <div className="flex flex-col">
- <div className="flex items-center">
- <label
- htmlFor={`${providerId}-base-url`}
- className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Base URL
- {providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
- providerConfig.type === ProviderTypeEnum.Ollama
- ? '*'
- : ''}
- </label>
- <input
- id={`${providerId}-base-url`}
- type="text"
- placeholder={
- providerConfig.type === ProviderTypeEnum.CustomOpenAI
- ? 'Required for custom OpenAI-compatible API providers'
- : 'Ollama base URL'
- }
- value={providerConfig.baseUrl || ''}
- onChange={e => handleApiKeyChange(providerId, providerConfig.apiKey || '', e.target.value)}
- className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} p-2 outline-none`}
- />
- </div>
- </div>
- )}
- {/* Models input field with tags */}
- <div className="flex items-start">
- <label
- htmlFor={`${providerId}-models`}
- className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
- Models
- </label>
- <div className="flex-1">
- <div
- className={`flex min-h-[42px] flex-wrap items-center gap-2 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} p-2`}>
- {/* Display existing models as tags */}
- {(() => {
- // Get models from provider config or default models
- const models =
- providerConfig.modelNames !== undefined
- ? providerConfig.modelNames
- : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
- return models.map(model => (
- <div
- key={model}
- className={`flex items-center rounded-full ${isDarkMode ? 'bg-blue-900 text-blue-100' : 'bg-blue-100 text-blue-800'} px-2 py-1 text-sm`}>
- <span>{model}</span>
- <button
- type="button"
- onClick={() => removeModel(providerId, model)}
- className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
- aria-label={`Remove ${model}`}>
- ×
- </button>
- </div>
- ));
- })()}
- {/* Input for new models */}
- <input
- id={`${providerId}-models`}
- type="text"
- placeholder=""
- value={newModelInputs[providerId] || ''}
- onChange={e => handleModelsChange(providerId, e.target.value)}
- onKeyDown={e => handleKeyDown(e, providerId)}
- className={`min-w-[150px] flex-1 border-none text-sm ${isDarkMode ? 'bg-transparent text-gray-200' : 'bg-transparent text-gray-700'} p-1 outline-none`}
- />
- </div>
- <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
- Type and Press Enter or Space to add a model
- </p>
- </div>
- </div>
- {/* Ollama reminder at the bottom of the section */}
- {providerConfig.type === ProviderTypeEnum.Ollama && (
- <div
- className={`mt-4 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700' : 'border-blue-100 bg-blue-50'} p-3`}>
- <p className={`text-sm ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
- <strong>Remember:</strong> Add{' '}
- <code
- className={`rounded italic ${isDarkMode ? 'bg-slate-600 px-1 py-0.5' : 'bg-blue-100 px-1 py-0.5'}`}>
- OLLAMA_ORIGINS=chrome-extension://*
- </code>{' '}
- environment variable for the Ollama server.
- <a
- href="https://github.com/ollama/ollama/issues/6489"
- target="_blank"
- rel="noopener noreferrer"
- className={`ml-1 ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800'}`}>
- Learn more
- </a>
- </p>
- </div>
- )}
- </div>
- {/* Add divider except for the last item */}
- {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
- <div className={`mt-4 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`} />
- )}
- </div>
- ))
- )}
- {/* Add Provider button and dropdown */}
- <div className="provider-selector-container relative pt-4">
- <Button
- variant="secondary"
- onClick={() => setIsProviderSelectorOpen(prev => !prev)}
- className={`flex w-full items-center justify-center font-medium ${
- isDarkMode
- ? 'border-blue-700 bg-blue-600 text-white hover:bg-blue-500'
- : 'border-blue-200 bg-blue-100 text-blue-800 hover:bg-blue-200'
- }`}>
- <span className="mr-2 text-sm">+</span> <span className="text-sm">Add New Provider</span>
- </Button>
- {isProviderSelectorOpen && (
- <div
- className={`absolute z-10 mt-2 w-full overflow-hidden rounded-md border ${
- isDarkMode
- ? 'border-blue-600 bg-slate-700 shadow-lg shadow-slate-900/50'
- : 'border-blue-200 bg-white shadow-xl shadow-blue-100/50'
- }`}>
- <div className="py-1">
- {/* Map through provider types to create buttons */}
- {Object.values(ProviderTypeEnum)
- // Filter out CustomOpenAI and already added providers
- .filter(
- type =>
- type !== ProviderTypeEnum.CustomOpenAI &&
- !providersFromStorage.has(type) &&
- !modifiedProviders.has(type),
- )
- .map(type => (
- <button
- key={type}
- type="button"
- className={`flex w-full items-center px-4 py-3 text-left text-sm ${
- isDarkMode
- ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
- : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
- } transition-colors duration-150`}
- onClick={() => handleProviderSelection(type)}>
- <span className="font-medium">{getDefaultDisplayNameFromProviderId(type)}</span>
- </button>
- ))}
- {/* Custom provider button (always shown) */}
- <button
- type="button"
- className={`flex w-full items-center px-4 py-3 text-left text-sm ${
- isDarkMode
- ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
- : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
- } transition-colors duration-150`}
- onClick={() => handleProviderSelection(ProviderTypeEnum.CustomOpenAI)}>
- <span className="font-medium">OpenAI-compatible API Provider</span>
- </button>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- {/* Updated Agent Models Section */}
- <div
- className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
- <h2 className={`mb-4 text-left text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
- Model Selection
- </h2>
- <div className="space-y-4">
- {[AgentNameEnum.Planner, AgentNameEnum.Navigator, AgentNameEnum.Validator].map(agentName => (
- <div key={agentName}>{renderModelSelect(agentName)}</div>
- ))}
- </div>
- </div>
- </section>
- );
- };
|