Wizard: support Local vendors

This commit is contained in:
Enrico Ros
2025-02-11 22:51:53 -08:00
parent 224a40dcb7
commit aebe64ef3d
3 changed files with 111 additions and 52 deletions
@@ -91,7 +91,7 @@ function ModelsConfiguratorModal(props: {
// start button
const startButton = React.useMemo(() => {
if (showWizard)
return <Button variant='outlined' color='neutral' onClick={handleShowAdvanced} sx={{ backgroundColor: 'background.popup' }}>{isMobile ? 'Advanced Setup' : 'More Services'}</Button>;
return <Button variant='outlined' color='neutral' onClick={handleShowAdvanced} sx={{ backgroundColor: 'background.popup' }}>{isMobile ? 'More Services' : 'More Services'}</Button>;
// return <Badge size='sm' badgeContent='14 Services' color='neutral' variant='outlined'><Button variant='outlined' color='neutral' onClick={handleShowAdvanced}>{isMobile ? 'Advanced' : 'Switch to Advanced'}</Button></Badge>;
if (!isMultiServices)
return <Button variant='outlined' color='neutral' onClick={handleShowWizard} sx={{ backgroundColor: 'background.popup' }}>{isMobile ? 'Quick Setup' : 'Quick Setup'}</Button>;
@@ -225,7 +225,7 @@ export function ModelsServiceSelector(props: {
<AddIcon />
</IconButton>
) : (
<Tooltip open={noServices && !vendorsMenuAnchor && !isMobile} variant='outlined' color='primary' placement='top' arrow title='Add your first AI service'>
<Tooltip open={noServices && !vendorsMenuAnchor && !isMobile} variant='outlined' color='neutral' placement='top' arrow title='Add your first AI service'>
<Button variant={noServices ? 'solid' : 'outlined'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />} sx={{ borderColor: 'neutral.outlinedBorder' }}>
Add
</Button>
+109 -50
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Avatar, Badge, Box, Button, CircularProgress, Input, Sheet, Typography } from '@mui/joy';
import { Avatar, Badge, Box, Button, Chip, CircularProgress, Input, Sheet, Typography } from '@mui/joy';
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
import { llmsStoreState, useModelsStore } from '~/common/stores/llms/store-llms';
@@ -10,18 +10,33 @@ import { useShallowStabilizer } from '~/common/util/hooks/useShallowObject';
import type { IModelVendor } from '../vendors/IModelVendor';
import { ModelVendorAnthropic } from '../vendors/anthropic/anthropic.vendor';
import { ModelVendorGemini } from '../vendors/gemini/gemini.vendor';
import { ModelVendorLMStudio } from '../vendors/lmstudio/lmstudio.vendor';
import { ModelVendorLocalAI } from '../vendors/localai/localai.vendor';
import { ModelVendorOllama } from '../vendors/ollama/ollama.vendor';
import { ModelVendorOpenAI } from '../vendors/openai/openai.vendor';
import { llmsUpdateModelsForServiceOrThrow } from '../llm.client';
// configuration
const WizardVendors = [
{ vendor: ModelVendorOpenAI, apiKeyField: 'oaiKey' },
{ vendor: ModelVendorAnthropic, apiKeyField: 'anthropicKey' },
{ vendor: ModelVendorGemini, apiKeyField: 'geminiKey' },
// { vendor: ModelVendorOpenRouter, apiKeyField: 'oaiKey' },
const WizardProviders: ReadonlyArray<WizardProvider> = [
{ cat: 'popular', vendor: ModelVendorOpenAI, settingsKey: 'oaiKey' } as const,
{ cat: 'popular', vendor: ModelVendorAnthropic, settingsKey: 'anthropicKey' } as const,
{ cat: 'popular', vendor: ModelVendorGemini, settingsKey: 'geminiKey' } as const,
{ cat: 'local', vendor: ModelVendorLocalAI, settingsKey: 'localAIHost' } as const,
{ cat: 'local', vendor: ModelVendorOllama, settingsKey: 'ollamaHost' } as const,
{ cat: 'local', vendor: ModelVendorLMStudio, settingsKey: 'oaiHost', omit: true } as const,
// { vendor: ModelVendorOpenRouter, settingsKey: 'oaiKey' } as const,
] as const;
type VendorCategory = 'popular' | 'local';
interface WizardProvider {
cat: VendorCategory,
vendor: IModelVendor<Record<string, any>, Record<string, any>>,
settingsKey: string,
omit?: boolean,
}
const _styles = {
@@ -43,6 +58,13 @@ const _styles = {
gap: 0.25,
} as const,
text1Mobile: {
mb: 2,
display: 'flex',
flexDirection: 'column',
gap: 0.25,
} as const,
text2: {
my: 1,
ml: 7.25,
@@ -50,63 +72,76 @@ const _styles = {
fontSize: 'sm',
} as const,
text2Mobile: {
mt: 2,
color: 'text.tertiary',
fontSize: 'sm',
} as const,
} as const;
function WizardProviderSetup(props: {
apiKeyField: string,
provider: WizardProvider,
isFirst: boolean,
vendor: IModelVendor<Record<string, any>, Record<string, any>>,
isHidden: boolean,
}) {
const { id: vendorId, name: vendorName, Icon: VendorIcon } = props.vendor;
const { cat: providerCat, vendor: providerVendor, settingsKey: providerSettingsKey, omit: providerOmit } = props.provider;
// state
const [localKey, setLocalKey] = React.useState<string | null>(null);
const [localValue, setLocalValue] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [updateError, setUpdateError] = React.useState<string | null>(null);
// external state
const stabilizeTransportAccess = useShallowStabilizer<Record<string, any>>();
const { serviceAPIKey, serviceLLMsCount } = useModelsStore(useShallow(({ llms, sources }) => {
const { serviceKeyValue, serviceLLMsCount } = useModelsStore(useShallow(({ llms, sources }) => {
// find the service | null
const vendorService = sources.find(s => s.vId === vendorId) ?? null;
const vendorService = sources.find(s => s.vId === providerVendor.id) ?? null;
// (safe) service-derived properties
const serviceLLMsCount = !vendorService ? null : llms.filter(llm => llm.sId === vendorService.id).length;
const serviceAccess = stabilizeTransportAccess(props.vendor.getTransportAccess(vendorService?.setup));
const serviceAPIKey = !serviceAccess ? null : serviceAccess[props.apiKeyField] ?? null;
const serviceAccess = stabilizeTransportAccess(providerVendor.getTransportAccess(vendorService?.setup));
const serviceKeyValue = !serviceAccess ? null : vendorService?.setup[providerSettingsKey] ?? null;
return {
serviceAPIKey,
serviceKeyValue,
serviceLLMsCount,
};
}));
// [effect] initialize the local key
const triggerValueLoad = localValue === null;
React.useEffect(() => {
if (localKey === null)
setLocalKey(serviceAPIKey || '');
}, [localKey, serviceAPIKey]);
if (triggerValueLoad)
setLocalValue(serviceKeyValue || '');
}, [serviceKeyValue, triggerValueLoad]);
// derived
const isLocal = providerCat === 'local';
const valueName = isLocal ? 'server address' : 'API Key';
const { name: vendorName, Icon: VendorIcon } = providerVendor;
// handlers
const handleTextChanged = React.useCallback((e: React.ChangeEvent) => {
setLocalKey((e.target as HTMLInputElement).value);
setLocalValue((e.target as HTMLInputElement).value);
}, []);
const handleSetServiceKey = React.useCallback(async () => {
const handleSetServiceKeyValue = React.useCallback(async () => {
// create the service if missing
const { sources: llmsServices, createModelsService, updateServiceSettings, setLLMs } = llmsStoreState();
const vendorService = llmsServices.find(s => s.vId === vendorId) || createModelsService(props.vendor);
const vendorService = llmsServices.find(s => s.vId === providerVendor.id) || createModelsService(providerVendor);
const vendorServiceId = vendorService.id;
// set the key
const newKey = localKey?.trim() ?? '';
updateServiceSettings(vendorServiceId, { [props.apiKeyField]: newKey });
const newKey = localValue?.trim() ?? '';
updateServiceSettings(vendorServiceId, { [providerSettingsKey]: newKey });
// if the key is empty, remove the models
if (!newKey) {
@@ -121,7 +156,7 @@ function WizardProviderSetup(props: {
try {
await llmsUpdateModelsForServiceOrThrow(vendorService.id, true);
} catch (error: any) {
let errorText = error.message || 'An error occurred';
let errorText = error.message || `An error occurred. Please check your ${valueName}.`;
if (errorText.includes('Incorrect API key'))
errorText = '[OpenAI issue] Unauthorized: Incorrect API key.';
setUpdateError(errorText);
@@ -129,12 +164,12 @@ function WizardProviderSetup(props: {
}
setIsLoading(false);
}, [localKey, props.apiKeyField, props.vendor, vendorId]);
}, [localValue, providerSettingsKey, providerVendor, valueName]);
// memoed components
const endButtons = React.useMemo(() => ((localKey || '') === (serviceAPIKey || '')) ? null : (
const endButtons = React.useMemo(() => ((localValue || '') === (serviceKeyValue || '')) ? null : (
<Box sx={{ display: 'flex', gap: 2 }}>
{/*<TooltipOutlined title='Clear Key'>*/}
{/* <IconButton variant='outlined' color='neutral' onClick={handleClear}>*/}
@@ -144,17 +179,26 @@ function WizardProviderSetup(props: {
{/*<TooltipOutlined title='Confirm'>*/}
<Button
variant='solid' color='primary'
onClick={handleSetServiceKey}
onClick={handleSetServiceKeyValue}
// endDecorator={<CheckRoundedIcon />}
>
{!serviceAPIKey ? 'Confirm' : !localKey?.trim() ? 'Clear' : 'Update'}
{!serviceKeyValue ? 'Confirm' : !localValue?.trim() ? 'Clear' : 'Update'}
</Button>
{/*</TooltipOutlined>*/}
</Box>
), [handleSetServiceKey, localKey, serviceAPIKey]);
), [handleSetServiceKeyValue, localValue, serviceKeyValue]);
return (
// heuristics for warnings
const isOnLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
return props.isHidden ? null : providerOmit ? (
<Box sx={{ ..._styles.text1, my: 0, minHeight: '2.5rem' /* to mimic the other items */ }}>
{!isOnLocalhost && <Typography level='body-xs'>
Please make sure the addresses can be reached from &quot;{typeof window !== 'undefined' ? window.location.hostname : 'this server'}&quot;. If you are using a local service, you may need to use a public URL.
</Typography>}
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
@@ -186,13 +230,13 @@ function WizardProviderSetup(props: {
{/* Line 2 */}
<Input
fullWidth
name={`wizard-api-key-${vendorId}`}
name={`wizard-settings-value-${providerVendor.id}`}
autoComplete='off'
variant='outlined'
value={localKey ?? ''}
value={localValue ?? ''}
onChange={handleTextChanged}
placeholder={`${vendorName} API Key`}
type='password'
placeholder={`${vendorName} ${valueName}`}
type={isLocal ? undefined : 'password'}
// error={!isValidKey}
// startDecorator={<props.vendorIcon />}
endDecorator={endButtons}
@@ -206,7 +250,7 @@ function WizardProviderSetup(props: {
{/*{!isLoading && !updateError && !!llmsCount && (*/}
{/* <Typography level='body-xs' sx={{ ml: 7, px: 0.5 }}>{llmsCount} models added.</Typography>*/}
{/*)}*/}
{!isLoading && !updateError && !serviceLLMsCount && !!serviceAPIKey && (
{!isLoading && !updateError && !serviceLLMsCount && !!serviceKeyValue && (
<Typography level='body-xs' color='warning' sx={{ ml: 7, px: 0.5 }}>No models found.</Typography>
)}
{!!updateError && <Typography level='body-xs' color='danger' sx={{ ml: 7, px: 0.5 }}>{updateError}</Typography>}
@@ -223,21 +267,24 @@ export function ModelsWizard(props: {
}) {
// state
// const [category, setCategory] = React.useState<'popular' | 'local'>('popular');
const [activeCategory, setActiveCategory] = React.useState<VendorCategory>('popular');
// derived
const isLocal = activeCategory === 'local';
return (
<Sheet variant='soft' sx={_styles.container}>
<Box sx={_styles.text1}>
<Typography level='title-sm'>
Enter API keys to connect your AI services.
{/*<Chip variant='outlined' sx={{ ml: 0.5, mr: 0.25 }} onClick={() => setCategory('popular')}>*/}
{/* popular*/}
{/*</Chip>*/}
{/*<Chip variant='outlined' sx={{ ml: 0.25, mr: 0.5 }} onClick={() => setCategory('popular')}>*/}
{/* local*/}
{/*</Chip>*/}
{/*AI services.*/}
<Box sx={props.isMobile ? _styles.text1Mobile : _styles.text1}>
<Typography component='div' level='title-sm'>
Enter {isLocal ? 'the addresses of ' : 'your API keys for '}
<Chip variant={!isLocal ? 'solid' : 'outlined'} sx={{ mx: 0.25 }} onClick={() => setActiveCategory('popular')}>
Popular
</Chip>
<Chip variant={isLocal ? 'solid' : 'outlined'} sx={{ mx: 0.25 }} onClick={() => setActiveCategory('local')}>
Local
</Chip>
{' '}AI services below.
</Typography>
{/*<Box sx={{ fontSize: 'sm', color: 'text.primary' }}>*/}
{/* Enter API keys to connect your AI services.{' '}*/}
@@ -245,13 +292,25 @@ export function ModelsWizard(props: {
{/*</Box>*/}
</Box>
{WizardVendors.map(({ vendor, apiKeyField }, index) => (
<WizardProviderSetup key={vendor.id} apiKeyField={apiKeyField} isFirst={!index} vendor={vendor} />
{WizardProviders.map((provider, index) => (
<WizardProviderSetup
key={provider.vendor.id}
provider={provider}
isFirst={!index}
isHidden={provider.cat !== activeCategory}
/>
))}
<Box sx={_styles.text2}>
<Box sx={props.isMobile ? _styles.text2Mobile : _styles.text2}>
{/*{!props.isMobile && <>Switch to <Box component='a' onClick={props.onSwitchToAdvanced} sx={{ textDecoration: 'underline', cursor: 'pointer' }}>Advanced</Box> to choose between {getModelVendorsCount()} services.</>}{' '}*/}
{!props.isMobile && <>Switch to <Box component='a' onClick={props.onSwitchToAdvanced} sx={{ textDecoration: 'underline', cursor: 'pointer' }}>Advanced</Box> for more services,</>}{' '}
{!props.isMobile && <>
Switch to{' '}
<Box component='a' onClick={props.onSwitchToAdvanced} sx={{ textDecoration: 'underline', cursor: 'pointer' }}>advanced configuration</Box>
{/*<Chip variant={isLocal ? 'solid' : 'outlined'} sx={{ ml: 0.25 }} onClick={props.onSwitchToAdvanced}>*/}
{/* more services*/}
{/*</Chip>*/}
{' '}for more services,
</>}{' '}
or <Box component='a' onClick={props.onSkip} sx={{ textDecoration: 'underline', cursor: 'pointer' }}>skip</Box> for now and do it later.
</Box>