mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-09 05:58:17 +02:00
add basic ui for private inference resource
This commit is contained in:
@@ -69,6 +69,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
|
||||
title: t("aiProviderNetworkSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{providerId}/network"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderModels"),
|
||||
href: "/{orgId}/settings/ai-providers/{providerId}/models"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderAuthSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AiProviderModelsPage() {
|
||||
const { provider } = useAiProviderContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations();
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
|
||||
const modelsQuery = useQuery(
|
||||
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelsQuery.data) return;
|
||||
setTags(
|
||||
modelsQuery.data.map((model) => ({
|
||||
id: String(model.modelId),
|
||||
text: model.modelKey
|
||||
}))
|
||||
);
|
||||
}, [modelsQuery.data]);
|
||||
|
||||
async function onSave() {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const existing = modelsQuery.data ?? [];
|
||||
const existingByKey = new Map(
|
||||
existing.map((model) => [model.modelKey, model])
|
||||
);
|
||||
const nextKeys = new Set(
|
||||
tags.map((tag) => tag.text.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
const toCreate = [...nextKeys].filter(
|
||||
(key) => !existingByKey.has(key)
|
||||
);
|
||||
const toDelete = existing.filter(
|
||||
(model) => !nextKeys.has(model.modelKey)
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...toCreate.map((modelKey) =>
|
||||
api.put(`/ai-provider/${provider.providerId}/model`, {
|
||||
modelKey,
|
||||
name: modelKey
|
||||
})
|
||||
),
|
||||
...toDelete.map((model) =>
|
||||
api.delete(`/ai-model/${model.modelId}`)
|
||||
)
|
||||
]);
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
aiProviderQueries.providerModels({
|
||||
providerId: provider.providerId
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderModelsUpdated")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderModelsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("aiProviderModelsErrorUpdate")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderModels")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderModelsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<TagInput
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={t("aiProviderModelsPlaceholder")}
|
||||
size="sm"
|
||||
tags={tags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(tags)
|
||||
: newTags;
|
||||
setTags(next as Tag[]);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
delimiterList={[",", "Enter"]}
|
||||
disabled={modelsQuery.isLoading || saveLoading}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="button"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || modelsQuery.isLoading}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { asAnyControl, asAnyWatch } from "@app/lib/formControlUtils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
@@ -35,8 +37,12 @@ import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const isInference = siteResource.mode === "inference";
|
||||
|
||||
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
||||
const formSchema = useMemo(
|
||||
() => createGeneralFormSchema(t, { requireAlias: isInference }),
|
||||
[t, isInference]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
@@ -44,7 +50,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled
|
||||
enabled: siteResource.enabled,
|
||||
alias: siteResource.alias ?? null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,7 +63,13 @@ export default function PrivateResourceGeneralPage() {
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId,
|
||||
enabled: data.enabled
|
||||
enabled: data.enabled,
|
||||
...(isInference
|
||||
? {
|
||||
mode: "inference" as const,
|
||||
alias: data.alias
|
||||
}
|
||||
: {})
|
||||
});
|
||||
}, null);
|
||||
|
||||
@@ -152,6 +165,17 @@ export default function PrivateResourceGeneralPage() {
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{isInference && (
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="edit"
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,105 +1,15 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export default function PrivateResourceInferencePage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
mode: "inference",
|
||||
alias: siteResource.alias ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
mode: "inference",
|
||||
alias: data.alias
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-host-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceInferenceDestinationFields
|
||||
control={asAnyControl(form.control)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-host-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
export default async function PrivateResourceInferencePage(props: {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/private/${params.niceId}/providers`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,20 +55,37 @@ export default async function PrivateResourceLayout(
|
||||
| "sshSettings"
|
||||
| "inferenceSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
const isInference = siteResource.mode === "inference";
|
||||
|
||||
const navItems = isInference
|
||||
? [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t("aiResourceProviders"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/providers`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
AiProvidersSelector,
|
||||
type SelectedAiProvider
|
||||
} from "@app/components/AiProvidersSelector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function PrivateResourceProvidersPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { siteResource } = useSiteResourceContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (siteResource.mode !== "inference") {
|
||||
router.replace(
|
||||
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
|
||||
);
|
||||
}
|
||||
}, [router, siteResource.mode, siteResource.niceId, siteResource.orgId]);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
providerIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, t("aiResourceProvidersRequired"))
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
SelectedAiProvider[]
|
||||
>([]);
|
||||
|
||||
const attachedQuery = useQuery({
|
||||
...resourceQueries.siteResourceAiProviders({
|
||||
siteResourceId: siteResource.id
|
||||
}),
|
||||
enabled: siteResource.mode === "inference"
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerIds: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachedQuery.data) return;
|
||||
const providers = attachedQuery.data.map((provider) => ({
|
||||
id: String(provider.providerId),
|
||||
text: provider.name
|
||||
}));
|
||||
setSelectedProviders(providers);
|
||||
form.reset({
|
||||
providerIds: attachedQuery.data.map((p) => p.providerId)
|
||||
});
|
||||
}, [attachedQuery.data, form]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
|
||||
providers: data.providerIds.map((providerId) => ({
|
||||
providerId,
|
||||
modelAccessMode: "catalog"
|
||||
}))
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceAiProviders({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiResourceProvidersUpdated")
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiResourceProvidersErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("aiResourceProvidersErrorUpdate")
|
||||
)
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
if (siteResource.mode !== "inference") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiResourceProvidersDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-providers-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProvidersSelector
|
||||
orgId={
|
||||
siteResource.orgId
|
||||
}
|
||||
selectedProviders={
|
||||
selectedProviders
|
||||
}
|
||||
disabled={
|
||||
attachedQuery.isLoading ||
|
||||
saveLoading
|
||||
}
|
||||
onSelectProviders={(
|
||||
providers
|
||||
) => {
|
||||
setSelectedProviders(
|
||||
providers
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
providers.map(
|
||||
(p) =>
|
||||
parseInt(
|
||||
p.id,
|
||||
10
|
||||
)
|
||||
),
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiResourceProvidersHelp"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-providers-form"
|
||||
loading={saveLoading}
|
||||
disabled={attachedQuery.isLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
import {
|
||||
AiProvidersSelector,
|
||||
type SelectedAiProvider
|
||||
} from "@app/components/AiProvidersSelector";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -88,6 +92,9 @@ export default function CreatePrivateResourcePage() {
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
SelectedAiProvider[]
|
||||
>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -112,7 +119,8 @@ export default function CreatePrivateResourcePage() {
|
||||
pamMode: "passthrough",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false
|
||||
disableIcmp: false,
|
||||
providerIds: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -196,7 +204,9 @@ export default function CreatePrivateResourcePage() {
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
created.mode === "inference"
|
||||
? `/${orgId}/settings/resources/private/${created.niceId}/general`
|
||||
: `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -336,6 +346,13 @@ export default function CreatePrivateResourcePage() {
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
[]
|
||||
);
|
||||
setSelectedProviders(
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
@@ -637,6 +654,76 @@ export default function CreatePrivateResourcePage() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{mode === "inference" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiResourceProvidersDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProvidersSelector
|
||||
orgId={orgId}
|
||||
selectedProviders={
|
||||
selectedProviders
|
||||
}
|
||||
onSelectProviders={(
|
||||
providers
|
||||
) => {
|
||||
setSelectedProviders(
|
||||
providers
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
providers.map(
|
||||
(
|
||||
p
|
||||
) =>
|
||||
parseInt(
|
||||
p.id,
|
||||
10
|
||||
)
|
||||
),
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiResourceProvidersHelp"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export type SelectedAiProvider = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AiProvidersSelectorProps = {
|
||||
orgId: string;
|
||||
selectedProviders?: SelectedAiProvider[];
|
||||
onSelectProviders: (providers: SelectedAiProvider[]) => void;
|
||||
disabled?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export function AiProvidersSelector({
|
||||
orgId,
|
||||
selectedProviders = [],
|
||||
onSelectProviders,
|
||||
disabled,
|
||||
buttonText
|
||||
}: AiProvidersSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedValue] = useDebounce(searchQuery, 150);
|
||||
|
||||
const { data: providers = [] } = useQuery(
|
||||
aiProviderQueries.orgProviders({
|
||||
orgId,
|
||||
query: debouncedValue || undefined
|
||||
})
|
||||
);
|
||||
|
||||
const options: SelectedAiProvider[] = providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.map((provider) => ({
|
||||
id: String(provider.providerId),
|
||||
text: provider.name
|
||||
}));
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={buttonText ?? t("aiResourceProvidersSelect")}
|
||||
emptyPlaceholder={t("aiResourceProvidersEmpty")}
|
||||
searchPlaceholder={t("aiProvidersSearch")}
|
||||
searchQuery={searchQuery}
|
||||
options={options}
|
||||
value={selectedProviders}
|
||||
onChange={onSelectProviders}
|
||||
onSearch={setSearchQuery}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type PrivateResourceFormValues = {
|
||||
roles?: PrivateResourceAccessTag[];
|
||||
users?: PrivateResourceAccessTag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
providerIds?: number[];
|
||||
};
|
||||
|
||||
export type SiteResourceAccess = {
|
||||
@@ -220,7 +221,11 @@ export function buildCreateSiteResourcePayload(
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined
|
||||
: undefined,
|
||||
aiProviders: (data.providerIds ?? []).map((providerId) => ({
|
||||
providerId,
|
||||
modelAccessMode: "catalog" as const
|
||||
}))
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
@@ -353,19 +358,33 @@ export function siteResourceToFormValues(
|
||||
};
|
||||
}
|
||||
|
||||
export function createGeneralFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
export function createGeneralFormSchema(
|
||||
t: TranslateFn,
|
||||
options?: { requireAlias?: boolean }
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/),
|
||||
enabled: z.boolean(),
|
||||
alias: z.string().nullish()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (options?.requireAlias && !data.alias?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("aiResourceAliasRequired"),
|
||||
path: ["alias"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createAccessFormSchema() {
|
||||
@@ -422,7 +441,8 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||
udpPortRangeString: createPortRangeStringSchema(t),
|
||||
disableIcmp: z.boolean().optional()
|
||||
disableIcmp: z.boolean().optional(),
|
||||
providerIds: z.array(z.number().int().positive()).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const isNativeSsh =
|
||||
@@ -434,12 +454,27 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t(
|
||||
"createInternalResourceDialogPleaseSelectSite"
|
||||
),
|
||||
message: t("createInternalResourceDialogPleaseSelectSite"),
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
if (data.mode === "inference") {
|
||||
const trimmedAlias = data.alias?.trim();
|
||||
if (!trimmedAlias) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("aiResourceAliasRequired"),
|
||||
path: ["alias"]
|
||||
});
|
||||
}
|
||||
if (!data.providerIds || data.providerIds.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("aiResourceProvidersRequired"),
|
||||
path: ["providerIds"]
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
data.mode !== "ssh" &&
|
||||
data.mode !== "inference" &&
|
||||
@@ -592,9 +627,26 @@ export function createInferenceFormSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
mode: z.literal("inference"),
|
||||
alias: z.string().nullish()
|
||||
alias: z.string().nullish(),
|
||||
providerIds: z.array(z.number().int().positive()).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||
.superRefine((data, ctx) => {
|
||||
const trimmedAlias = data.alias?.trim();
|
||||
if (!trimmedAlias) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("aiResourceAliasRequired"),
|
||||
path: ["alias"]
|
||||
});
|
||||
}
|
||||
if (!data.providerIds || data.providerIds.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("aiResourceProvidersRequired"),
|
||||
path: ["providerIds"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createCidrFormSchema(t: TranslateFn) {
|
||||
|
||||
@@ -57,6 +57,10 @@ import type {
|
||||
} from "@server/routers/siteResource";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
import type { ListTargetsResponse } from "@server/routers/target";
|
||||
import type {
|
||||
ListAiModelsResponse,
|
||||
ListAiProvidersResponse
|
||||
} from "@server/routers/aiProvider/types";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import {
|
||||
@@ -1174,6 +1178,36 @@ export const aiProviderQueries = {
|
||||
|
||||
return res.data.data.targets;
|
||||
}
|
||||
}),
|
||||
providerModels: ({ providerId }: { providerId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_PROVIDERS", providerId, "MODELS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListAiModelsResponse>
|
||||
>(`/ai-provider/${providerId}/models`, {
|
||||
params: { page: 1, pageSize: 1000 },
|
||||
signal
|
||||
});
|
||||
return res.data.data.models;
|
||||
}
|
||||
}),
|
||||
orgProviders: ({ orgId, query }: { orgId: string; query?: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListAiProvidersResponse>
|
||||
>(`/org/${orgId}/ai-providers`, {
|
||||
params: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
...(query ? { query } : {})
|
||||
},
|
||||
signal
|
||||
});
|
||||
return res.data.data.providers;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1242,6 +1276,30 @@ export const resourceQueries = {
|
||||
return res.data.data.clients;
|
||||
}
|
||||
}),
|
||||
siteResourceAiProviders: ({ siteResourceId }: { siteResourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"SITE_RESOURCES",
|
||||
siteResourceId,
|
||||
"AI_PROVIDERS"
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<{
|
||||
providers: Array<{
|
||||
providerId: number;
|
||||
modelAccessMode: "catalog" | "allowlist";
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}>
|
||||
>(`/site-resource/${siteResourceId}/ai-providers`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data.providers;
|
||||
}
|
||||
}),
|
||||
resourceTargets: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,
|
||||
|
||||
Reference in New Issue
Block a user