gerbil/src/components/settings/FrontendInterfaceSelector.tsx

271 lines
8.1 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { Text, Box, Anchor, rem } from '@mantine/core';
import { Monitor } from 'lucide-react';
import { usePreferencesStore } from '@/stores/preferences';
import type { FrontendPreference } from '@/types';
import { FRONTENDS } from '@/constants';
import { Select } from '@/components/Select';
interface FrontendRequirement {
id: string;
name: string;
url: string;
}
interface FrontendConfig {
value: string;
label: string;
badges: string[];
requirements?: FrontendRequirement[];
requirementCheck?: () => Promise<boolean>;
}
interface FrontendInterfaceSelectorProps {
isOnInterfaceScreen?: boolean;
}
export const FrontendInterfaceSelector = ({
isOnInterfaceScreen = false,
}: FrontendInterfaceSelectorProps) => {
const { frontendPreference, setFrontendPreference } = usePreferencesStore();
const [frontendRequirements, setFrontendRequirements] = useState<
Map<string, boolean>
>(new Map());
const frontendConfigs: FrontendConfig[] = useMemo(
() => [
{
value: 'koboldcpp',
label: 'Built-in',
badges: ['Text', 'Image'],
},
{
value: 'sillytavern',
label: FRONTENDS.SILLYTAVERN,
badges: ['Text', 'Image'],
requirements: [
{
id: 'nodejs',
name: 'Node.js',
url: 'https://nodejs.org/',
},
],
requirementCheck: () =>
window.electronAPI.dependencies.isNpxAvailable(),
},
{
value: 'openwebui',
label: FRONTENDS.OPENWEBUI,
badges: ['Text', 'Image'],
requirements: [
{
id: 'uv',
name: 'uv',
url: 'https://docs.astral.sh/uv/getting-started/installation/',
},
],
requirementCheck: () => window.electronAPI.dependencies.isUvAvailable(),
},
{
value: 'comfyui',
label: FRONTENDS.COMFYUI,
badges: ['Image'],
requirements: [
{
id: 'uv',
name: 'uv',
url: 'https://docs.astral.sh/uv/getting-started/installation/',
},
],
requirementCheck: () => window.electronAPI.dependencies.isUvAvailable(),
},
],
[]
);
const checkAllFrontendRequirements = useCallback(async () => {
const requirementResults = new Map<string, boolean>();
for (const config of frontendConfigs) {
if (config.requirementCheck) {
const isAvailable = await config.requirementCheck();
requirementResults.set(config.value, isAvailable);
} else {
requirementResults.set(config.value, true);
}
}
setFrontendRequirements(requirementResults);
const currentFrontendConfig = frontendConfigs.find(
(config) => config.value === frontendPreference
);
if (currentFrontendConfig && !requirementResults.get(frontendPreference)) {
setFrontendPreference('koboldcpp');
}
}, [frontendConfigs, frontendPreference, setFrontendPreference]);
const getSelectedFrontendConfig = () =>
frontendConfigs.find((config) => config.value === frontendPreference);
const getUnmetRequirements = () => {
const selectedConfig = getSelectedFrontendConfig();
if (!selectedConfig || !selectedConfig.requirements) return [];
const isAvailable = frontendRequirements.get(selectedConfig.value) ?? true;
return isAvailable ? [] : selectedConfig.requirements;
};
const getUnmetRequirementsForFrontend = (frontendValue: string) => {
const config = frontendConfigs.find((c) => c.value === frontendValue);
if (!config || !config.requirements) return [];
const isAvailable = frontendRequirements.get(frontendValue) ?? true;
return isAvailable ? [] : config.requirements;
};
const isFrontendAvailable = (frontendValue: string) =>
frontendRequirements.get(frontendValue) ?? true;
const handleFrontendPreferenceChange = (value: string | null) => {
setFrontendPreference(value as FrontendPreference);
};
const renderDisabledFrontendWarnings = () => {
const disabledFrontends = frontendConfigs.filter(
(config) => !isFrontendAvailable(config.value)
);
if (disabledFrontends.length === 0) {
return null;
}
const requirementGroups = new Map<string, string[]>();
disabledFrontends.forEach((config) => {
const unmetReqs = getUnmetRequirementsForFrontend(config.value);
const reqKey = unmetReqs.map((req) => req.id).join(',');
if (!requirementGroups.has(reqKey)) {
requirementGroups.set(reqKey, []);
}
requirementGroups.get(reqKey)!.push(config.label);
});
return (
<Box mt="sm">
{Array.from(requirementGroups.entries()).map(
([reqKey, frontendLabels]) => {
const firstDisabledFrontend = disabledFrontends.find(
(config) =>
getUnmetRequirementsForFrontend(config.value)
.map((req) => req.id)
.join(',') === reqKey
);
const unmetReqs = firstDisabledFrontend
? getUnmetRequirementsForFrontend(firstDisabledFrontend.value)
: [];
const frontendText =
frontendLabels.length === 1
? frontendLabels[0]
: frontendLabels.length === 2
? `${frontendLabels[0]} and ${frontendLabels[1]}`
: `${frontendLabels.slice(0, -1).join(', ')}, and ${frontendLabels[frontendLabels.length - 1]}`;
const isAre = frontendLabels.length === 1 ? 'is' : 'are';
return (
<Text key={reqKey} size="sm" c="orange">
{frontendText} {isAre} disabled - requires{' '}
{unmetReqs.map((req, index) => (
<span key={req.id}>
<Anchor
href={req.url}
target="_blank"
rel="noopener noreferrer"
td="underline"
>
{req.name}
</Anchor>
{index < unmetReqs.length - 1 ? ', ' : ''}
</span>
))}
</Text>
);
}
)}
</Box>
);
};
useEffect(() => {
const initialize = async () => {
await checkAllFrontendRequirements();
};
initialize();
const handleFocus = () => {
checkAllFrontendRequirements();
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [checkAllFrontendRequirements]);
useEffect(() => {
if (frontendPreference) {
// eslint-disable-next-line react-hooks/set-state-in-effect
checkAllFrontendRequirements();
}
}, [frontendPreference, checkAllFrontendRequirements]);
return (
<>
<Text fw={500} mb="sm">
Frontend Interface
</Text>
{getUnmetRequirements().length === 0 && (
<Text size="sm" c="dimmed" mb="md">
Choose which frontend interface to use for interacting with AI models
</Text>
)}
{getUnmetRequirements().length > 0 && (
<Text size="sm" c="red" mb="md">
{getSelectedFrontendConfig()?.label} requires{' '}
{getUnmetRequirements().map((req, index) => (
<span key={req.id}>
<Anchor
href={req.url}
target="_blank"
rel="noopener noreferrer"
c="red"
td="underline"
>
{req.name}
</Anchor>
{index < getUnmetRequirements().length - 1 ? ', ' : ''}
</span>
))}{' '}
to be installed on your system
</Text>
)}
<Select
value={frontendPreference}
onChange={handleFrontendPreferenceChange}
disabled={isOnInterfaceScreen}
data={frontendConfigs.map((config) => ({
value: config.value,
label: config.label,
disabled: !isFrontendAvailable(config.value),
}))}
leftSection={<Monitor style={{ width: rem(16), height: rem(16) }} />}
/>
{renderDisabledFrontendWarnings()}
</>
);
};