Skip to content

Commit

Permalink
LocalAI: Model Gallery Admin panel. Fixes #411
Browse files Browse the repository at this point in the history
  • Loading branch information
enricoros committed Feb 13, 2024
1 parent 053aa12 commit aae3783
Show file tree
Hide file tree
Showing 4 changed files with 315 additions and 2 deletions.
33 changes: 33 additions & 0 deletions src/modules/llms/server/openai/localai.wiretypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from 'zod';


export const wireLocalAIModelsAvailableOutputSchema = z.array(z.object({
name: z.string(), // (e.g.) tinydream
url: z.string(), // (e.g.) github:go-skynet/model-gallery/tinydream.yaml
license: z.string(), // (e.g.) other
gallery: z.object({
url: z.string(), // (e.g.) github:go-skynet/model-gallery/index.yaml
name: z.string(), // (e.g.) model-gallery
}),
urls: z.array(z.string()).optional(),
files: z.array(z.object({
filename: z.string(), // voice-en-us-amy-low.tar.gz
uri: z.string(), // https://github.com/rhasspy/piper/releases/download/v0.0.2/voice-en-us-amy-low.tar.gz
sha256: z.string().optional(), // often empty
})).optional(),
})).nullable(); // null if galleries are not served

export const wilreLocalAIModelsApplyOutputSchema = z.object({
uuid: z.string().uuid(),
status: z.string().url(),
});

export const wireLocalAIModelsListOutputSchema = z.object({
file_name: z.string(),
error: z.string().nullable(),
processed: z.boolean(),
message: z.string().nullable(),
progress: z.number(),
file_size: z.string(),
downloaded_size: z.string(),
});
36 changes: 36 additions & 0 deletions src/modules/llms/server/openai/openai.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fixupHost } from '~/common/util/urlUtils';
import { OpenAIWire, WireOpenAICreateImageOutput, wireOpenAICreateImageOutputSchema, WireOpenAICreateImageRequest } from './openai.wiretypes';
import { azureModelToModelDescription, lmStudioModelToModelDescription, localAIModelToModelDescription, mistralModelsSort, mistralModelToModelDescription, oobaboogaModelToModelDescription, openAIModelToModelDescription, openRouterModelFamilySortFn, openRouterModelToModelDescription, togetherAIModelsToModelDescriptions } from './models.data';
import { llmsChatGenerateWithFunctionsOutputSchema, llmsListModelsOutputSchema, ModelDescriptionSchema } from '../llm.server.types';
import { wilreLocalAIModelsApplyOutputSchema, wireLocalAIModelsAvailableOutputSchema, wireLocalAIModelsListOutputSchema } from './localai.wiretypes';


const openAIDialects = z.enum([
Expand Down Expand Up @@ -326,6 +327,41 @@ export const llmOpenAIRouter = createTRPCRouter({
}
}),


/// Dialect-specific procedures ///

/* [LocalAI] List all Model Galleries */
dialectLocalAI_galleryModelsAvailable: publicProcedure
.input(listModelsInputSchema)
.query(async ({ input: { access } }) => {
const wireLocalAIModelsAvailable = await openaiGET(access, '/models/available');
return wireLocalAIModelsAvailableOutputSchema.parse(wireLocalAIModelsAvailable);
}),

/* [LocalAI] Download a model from a Model Gallery */
dialectLocalAI_galleryModelsApply: publicProcedure
.input(z.object({
access: openAIAccessSchema,
galleryName: z.string(),
modelName: z.string(),
}))
.mutation(async ({ input: { access, galleryName, modelName } }) => {
const galleryModelId = `${galleryName}@${modelName}`;
const wireLocalAIModelApply = await openaiPOST(access, null, { id: galleryModelId }, '/models/apply');
return wilreLocalAIModelsApplyOutputSchema.parse(wireLocalAIModelApply);
}),

/* [LocalAI] Poll for a Model download Job status */
dialectLocalAI_galleryModelsJob: publicProcedure
.input(z.object({
access: openAIAccessSchema,
jobId: z.string(),
}))
.query(async ({ input: { access, jobId } }) => {
const wireLocalAIModelsJobs = await openaiGET(access, `/models/jobs/${jobId}`);
return wireLocalAIModelsListOutputSchema.parse(wireLocalAIModelsJobs);
}),

});


Expand Down
230 changes: 230 additions & 0 deletions src/modules/llms/vendors/localai/LocalAIAdmin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import * as React from 'react';

import { Alert, Box, Button, Card, CircularProgress, IconButton, LinearProgress, List, ListItem, Switch, Typography } from '@mui/joy';
import CloseIcon from '@mui/icons-material/Close';

import { ExpanderAccordion } from '~/common/components/ExpanderAccordion';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineError } from '~/common/components/InlineError';
import { Link } from '~/common/components/Link';
import { apiQuery } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';

import type { OpenAIAccessSchema } from '../../server/openai/openai.router';


function ListItemSwitch(props: { title: string, checked: boolean, onChange: (checked: boolean) => void }) {
return (
<ListItem variant='soft'>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
{props.title}
<Switch
checked={props.checked}
onChange={event => props.onChange(event.target.checked)}
endDecorator={props.checked ? 'Show' : 'Hide'}
sx={{ ml: 'auto' }}
/>
</Box>
</ListItem>
);
}


/**
* Show the progress of a model install job by polling the server every 1 second until complete.
* - uses the LocalAI /models/jobs API
*/
function ModelJobStatusChecker(props: { access: OpenAIAccessSchema, jobId: string }) {

// local state
const [isPolling, setIsPolling] = React.useState(true);

// external state
const { data, error } = apiQuery.llmOpenAI.dialectLocalAI_galleryModelsJob.useQuery({ access: props.access, jobId: props.jobId }, {
enabled: isPolling,
refetchInterval: 1000,
});

// [effect] stop polling when job is done
const isDone = data?.processed === true || data?.progress === 100;
React.useEffect(() => {
if (isDone)
setIsPolling(false);
}, [isDone]);

return <>

{!!error && <InlineError error={error} />}

{data && <Box sx={{ display: 'grid', gap: 1, my: 1 }}>
{data.message && <Typography component='div' level='body-sm'>Message: {data.message}</Typography>}
{data.file_name && <Typography component='div' level='body-sm'>File: {data.file_name}</Typography>}
{data.file_size && <Typography component='div' level='body-sm'>File size: {data.file_size}</Typography>}
{data.downloaded_size && <Typography component='div' level='body-sm'>Downloaded: {data.downloaded_size}</Typography>}
</Box>}

{isPolling
? <Alert variant='soft' color='primary'>Installation has begun. This may take a very long time.</Alert>
: <Alert variant='soft' color={error ? 'warning' : 'success'}>
{error ? 'Installation failed' : 'Installation complete'}
</Alert>}

<LinearProgress determinate color={error ? 'warning' : isDone ? 'success' : 'primary'} value={data?.progress || 0} sx={{ mt: 1 }} />

</>;
}

/**
* Every model being installed has a panel showing the status.
* - uses the LocalAI /models/apply API
*/
function ModelInstallPanel(props: { access: OpenAIAccessSchema, modelName: string, galleryName: string }) {

// state
const [hideSelf, setHideSelf] = React.useState(false);

// external state
const { data, error, mutate } = apiQuery.llmOpenAI.dialectLocalAI_galleryModelsApply.useMutation();

// [effect] auto-install
React.useEffect(() => {
mutate({ access: props.access, galleryName: props.galleryName, modelName: props.modelName });
}, [mutate, props.access, props.galleryName, props.modelName]);

if (hideSelf)
return null;

return (
<Card sx={{ gap: 0, boxShadow: 'sm' }}>

<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography level='title-sm'>
Installing <strong>{props.modelName}</strong> from the <strong>{props.galleryName}</strong>
</Typography>
<IconButton size='sm' onClick={() => setHideSelf(true)} sx={{ ml: 'auto' }}>
<CloseIcon />
</IconButton>
</Box>

{!!error && <InlineError error={error} />}

{!!data?.uuid && <ModelJobStatusChecker access={props.access} jobId={data.uuid} />}

</Card>
);
}


/**
* Administration panel for LocalAI. Mainly to install models from the Gallery.
*/
export function LocalAIAdmin(props: { access: OpenAIAccessSchema, onClose: () => void }) {

// state
const [installModels, setInstallModels] = React.useState<{ galleryName: string; modelName: string; }[]>([]);
const [showVoiceModels, setShowVoiceModels] = React.useState(false);

// external state
const { data, error } = apiQuery.llmOpenAI.dialectLocalAI_galleryModelsAvailable.useQuery({ access: props.access }, {
staleTime: 1000 * 60,
refetchOnWindowFocus: false,
});

// derived state
const galleryNotConfigured = data === null;


const handleAppendInstall = React.useCallback((galleryName: string, modelName: string) => {
setInstallModels(prev => {
// if already in list, do not add
if (prev.some(p => p.galleryName === galleryName && p.modelName === modelName))
return prev;
return [...prev, { galleryName, modelName }];
});
}, []);


return (
<GoodModal title='LocalAI Administration' dividers open onClose={props.onClose}>
<Box sx={{ display: 'grid', gap: 'var(--Card-padding)' }}>

<Typography level='body-sm'>
Install models from your LocalAI Model Gallery. We assume your LocalAI server is correcly
configured and running.
</Typography>

{/* Models being Installed */}
{installModels.length > 0 && <>

<Typography level='title-lg'>
Model Installation
</Typography>

<List sx={{ gap: 1 }}>
{installModels.map((params, index) =>
<ModelInstallPanel key={'install-' + index} access={props.access} {...params} />,
)}
</List>

</>}


<Typography level='title-md'>
Available Models List
</Typography>

{/* Errors */}
{!!error && <InlineError error={error} />}
{galleryNotConfigured && <InlineError error={<>
Model galleries do not seem to be configured (null response).
Please refer to the <Link href='https://localai.io/models/' target='_blank'>documentation</Link> for
how to configure model galleries.
</>} />}

{/* List loading */}
{!data ? (
<CircularProgress color='success' />
) : (
<List
variant='outlined'
sx={{
'--ListItem-minHeight': '2.75rem',
borderRadius: 'md',
p: 0,
}}
>
{data
.filter(model => showVoiceModels || !model.name.startsWith('voice-'))
.map((model) => (
<ListItem key={model.name}>

{capitalizeFirstLetter(model.name)}

<Button
color='neutral'
size='sm'
disabled={installModels.some(p => p.galleryName === model.gallery.name && p.modelName === model.name)}
onClick={() => handleAppendInstall(model.gallery.name, model.name)}
sx={{
ml: 'auto',
}}
>
Install
</Button>
</ListItem>
))}

<ListItemSwitch title='Show Voice Models' checked={showVoiceModels} onChange={setShowVoiceModels} />

<ExpanderAccordion title='Debug: show JSON' startCollapsed sx={{ fontSize: 'sm' }}>
<Box sx={{ whiteSpace: 'break-spaces' }}>
{JSON.stringify(data, null, 2)}
</Box>
</ExpanderAccordion>
</List>
)}

</Box>
</GoodModal>
);
}
18 changes: 16 additions & 2 deletions src/modules/llms/vendors/localai/LocalAISourceSetup.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as React from 'react';
import { z } from 'zod';

import { Typography } from '@mui/joy';
import { Button, Typography } from '@mui/joy';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';

import { ExpanderAccordion } from '~/common/components/ExpanderAccordion';
import { FormInputKey } from '~/common/components/forms/FormInputKey';
import { InlineError } from '~/common/components/InlineError';
Expand All @@ -13,11 +14,15 @@ import { DModelSourceId } from '../../store-llms';
import { useLlmUpdateModels } from '../useLlmUpdateModels';
import { useSourceSetup } from '../useSourceSetup';

import { LocalAIAdmin } from './LocalAIAdmin';
import { ModelVendorLocalAI } from './localai.vendor';


export function LocalAISourceSetup(props: { sourceId: DModelSourceId }) {

// state
const [adminOpen, setAdminOpen] = React.useState(false);

// external state
const { source, access, updateSetup } =
useSourceSetup(props.sourceId, ModelVendorLocalAI);
Expand Down Expand Up @@ -69,9 +74,18 @@ export function LocalAISourceSetup(props: { sourceId: DModelSourceId }) {
value={oaiHost} onChange={value => updateSetup({ oaiHost: value })}
/>

<SetupFormRefetchButton refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError} />
<SetupFormRefetchButton
refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError}
leftButton={
<Button color='neutral' variant='solid' disabled={adminOpen} onClick={() => setAdminOpen(true)}>
LocalAI Admin
</Button>
}
/>

{isError && <InlineError error={error} />}

{adminOpen && <LocalAIAdmin access={access} onClose={() => setAdminOpen(false)} />}

</>;
}

0 comments on commit aae3783

Please sign in to comment.