// SPDX-License-Identifier: MIT // Copyright contributors to the openassistant project import { Button, Input, Select, SelectItem, SelectSection, Slider, } from '@heroui/react'; import { GetAssistantModelByProvider } from '@openassistant/core'; import { Icon } from '@iconify/react'; import { ChangeEvent, useState, useEffect } from 'react'; import { MODEL_PROVIDERS } from '../config/constants'; // Add a type for valid providers type Provider = keyof typeof MODEL_PROVIDERS; /** * The configuration for the AI Assistant. */ export type AiAssistantConfig = { /** Whether the AI Assistant is ready to use. */ isReady: boolean; /** The AI provider. */ provider: Provider; /** The AI model. */ model: string; /** The API key for the AI provider. */ apiKey: string; /** The temperature for the AI model. */ temperature: number; /** The top P for the AI model. */ topP: number; /** The base URL for the AI provider. */ baseUrl?: string; /** The MapBox token. */ mapBoxToken?: string; }; /** * The props for the ConfigPanel component. */ export type ConfigPanelProps = { /** The default models for each provider. */ defaultProviderModels?: Record; /** The initial configuration for the AI Assistant. */ initialConfig?: AiAssistantConfig; /** Whether to show the start chat button. */ showStartChatButton?: boolean; /** Whether to show the parameters. */ showParameters?: boolean; /** The color of the button. */ color?: | 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; /** The function to call when the configuration changes. */ onConfigChange: (config: AiAssistantConfig) => void; /** The connection timeout. */ connectionTimeout?: number; /** Whether to show the error message. */ showErrorMessage?: boolean; /** Whether to show the base URL. */ showBaseUrl?: boolean; /** Whether to show the check connection button. */ showCheckConnectionButton?: boolean; /** Whether to show MapBox Token input */ showMapBoxToken?: boolean; }; /** * The AI Assistant configuration panel. * * This panel provides a select dropdown for the AI model from different AI providers, a text input for the API key, * (optional: showBaseUrl) a text input for the base URL, * (optional: showParameters) a slider for the temperature, and a slider for the top P. * (optional: showCheckConnectionButton) a button to check the connection and api key to the AI provider. * (optional: showErrorMessage) a message to show when the connection fails. * * @example * ```tsx * import { ConfigPanel, MODEL_PROVIDERS } from '@openassistant/ui'; * * * ``` * * The MODEL_PROVIDERS is a constant that contains the default models for each provider. * You can override the default models by providing a different object to the `defaultProviderModels` prop. * For example, if you only want to support the OpenAI models, you can do the following: * * @example * ```tsx * const MY_PROVIDER_MODELS = { * openai: { * name: 'OpenAI', * models: ['gpt-4.1', 'gpt-4o'], * }, * }; * * * ``` */ export function ConfigPanel(props: ConfigPanelProps) { const defaultProviderModels = props.defaultProviderModels || MODEL_PROVIDERS; const connectionTimeout = props.connectionTimeout || 10000; // Helper function to get API key from localStorage const getStoredApiKey = () => { if (typeof window !== 'undefined') { return localStorage.getItem('openassistant-api-key') || ''; } return ''; }; // Helper function to get MapBox token from localStorage const getStoredMapBoxToken = () => { if (typeof window !== 'undefined') { return localStorage.getItem('openassistant-mapbox-token') || ''; } return ''; }; const [provider, setProvider] = useState( props.initialConfig?.provider || 'openai' ); const [model, setModel] = useState( props.initialConfig?.model || defaultProviderModels[provider][0] ); const [apiKey, setApiKey] = useState( props.initialConfig?.apiKey || getStoredApiKey() ); const [temperature, setTemperature] = useState( props.initialConfig?.temperature || 0.8 ); const [topP, setTopP] = useState(props.initialConfig?.topP || 0.8); const [baseUrl, setBaseUrl] = useState(props.initialConfig?.baseUrl); const [defaultBaseUrl, setDefaultBaseUrl] = useState(''); const [mapBoxToken, setMapBoxToken] = useState( props.initialConfig?.mapBoxToken || getStoredMapBoxToken() ); const [connectionError, setConnectionError] = useState(false); const [keyError, setKeyError] = useState(true); const [errorMessage, setErrorMessage] = useState(''); const [isRunning, setIsRunning] = useState(false); // Store API key in localStorage whenever it changes useEffect(() => { if (typeof window !== 'undefined' && apiKey) { localStorage.setItem('openassistant-api-key', apiKey); } }, [apiKey]); // Load default base URL when provider changes useEffect(() => { const loadDefaultBaseUrl = async () => { try { const AssistantModel = await GetAssistantModelByProvider({ provider: provider, }); const defaultUrl = AssistantModel.getBaseURL?.() || ''; setDefaultBaseUrl(defaultUrl); } catch (error) { console.error('Failed to load default base URL:', error); setDefaultBaseUrl(''); } }; loadDefaultBaseUrl(); }, [provider]); // Store MapBox token in localStorage whenever it changes useEffect(() => { if (typeof window !== 'undefined' && mapBoxToken) { localStorage.setItem('openassistant-mapbox-token', mapBoxToken); } }, [mapBoxToken]); const generateConfig = ( overrides: Partial = {} ): AiAssistantConfig => ({ provider, model, apiKey, isReady: false, temperature, topP, ...(baseUrl && { baseUrl }), ...(mapBoxToken && { mapBoxToken }), ...overrides, }); const onLLMModelSelect = ( value: string | number | boolean | object | null ) => { if (value && typeof value === 'object' && 'currentKey' in value) { const selectedModel = value.currentKey as string; setModel(selectedModel); // find the provider for the selected model const selectedProvider = Object.keys(MODEL_PROVIDERS).find((p) => MODEL_PROVIDERS[p].models.includes(selectedModel) ); if (selectedProvider && selectedProvider !== provider) { setProvider(selectedProvider as Provider); setBaseUrl(undefined); setConnectionError(false); setErrorMessage(''); } } }; const onApiKeyChange = (e: ChangeEvent) => { const inputValue = e.target.value; setApiKey(inputValue); // Store in localStorage if not empty if (typeof window !== 'undefined') { if (inputValue.trim()) { localStorage.setItem('openassistant-api-key', inputValue); } else { localStorage.removeItem('openassistant-api-key'); } } // reset previous key error if any setConnectionError(false); setErrorMessage(''); setKeyError(true); props.onConfigChange?.(generateConfig({ apiKey: inputValue })); }; const onTemperatureChange = (value: number | number[]) => { const temperatureValue = typeof value === 'number' ? value : value[0]; setTemperature(temperatureValue); props.onConfigChange?.(generateConfig({ temperature: temperatureValue })); }; const onTopPChange = (value: number | number[]) => { const topPValue = typeof value === 'number' ? value : value[0]; setTopP(topPValue); props.onConfigChange?.(generateConfig({ topP: topPValue })); }; const onBaseUrlChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; setBaseUrl(inputValue); setConnectionError(false); setErrorMessage(''); props.onConfigChange?.(generateConfig({ baseUrl: inputValue })); }; const onMapBoxTokenChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; setMapBoxToken(inputValue); // Store in localStorage if not empty if (typeof window !== 'undefined') { if (inputValue.trim()) { localStorage.setItem('openassistant-mapbox-token', inputValue); } else { localStorage.removeItem('openassistant-mapbox-token'); } } props.onConfigChange?.(generateConfig({ mapBoxToken: inputValue })); }; const headingClasses = 'flex w-full sticky top-1 z-20 py-1.5 px-2 bg-default-100 shadow-small rounded-small'; const isOllama = provider === 'ollama'; const onCheckConnection = async () => { setIsRunning(true); try { const AssistantModel = await GetAssistantModelByProvider({ provider: provider, }); const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject( new Error(`Connection timed out after ${connectionTimeout}ms`) ), connectionTimeout ); }); const success = (await Promise.race([ AssistantModel.testConnection?.(apiKey, model), timeoutPromise, ])) as boolean; const errorMessage = !success ? isOllama ? 'Connection failed: maybe invalid Ollama Base URL' : 'Connection failed: maybe invalid API Key' : ''; setKeyError(!success); setConnectionError(!success); setErrorMessage(errorMessage); props.onConfigChange?.(generateConfig({ isReady: success })); } catch (error) { setConnectionError(true); setErrorMessage( error instanceof Error ? error.message : 'Connection failed' ); } finally { setIsRunning(false); } }; return (
{connectionError && props.showErrorMessage && (
{errorMessage}
)} } /> {isOllama && props.showBaseUrl && ( )} {props.showMapBoxToken && ( )} {props.showParameters && ( <> )} {props.showCheckConnectionButton && ( )}
); }