improved provider picker ui on resource

This commit is contained in:
miloschwartz
2026-08-07 16:13:51 -04:00
parent 5e5e172d39
commit 9eafa067b9
17 changed files with 874 additions and 149 deletions
+18 -1
View File
@@ -1772,11 +1772,28 @@
"aiProviderModelsErrorUpdate": "Failed to update models", "aiProviderModelsErrorUpdate": "Failed to update models",
"aiResourceProviders": "Providers", "aiResourceProviders": "Providers",
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use", "aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
"aiResourceProvidersHelp": "Each attached provider uses its own allow and block lists. Allow patterns that conflict across attached providers are not allowed.", "aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.",
"aiResourceProvidersSelect": "Select providers", "aiResourceProvidersSelect": "Select providers",
"aiResourceProvidersEmpty": "No AI providers found", "aiResourceProvidersEmpty": "No AI providers found",
"aiResourceProvidersNoneAttached": "No providers attached yet.",
"aiResourceProvidersAdd": "Add provider",
"aiResourceProvidersRemove": "Remove provider",
"aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource",
"aiResourceProviderDisabled": "Disabled",
"aiResourceProvidersUpdated": "Providers updated", "aiResourceProvidersUpdated": "Providers updated",
"aiResourceProvidersErrorUpdate": "Failed to update providers", "aiResourceProvidersErrorUpdate": "Failed to update providers",
"aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.",
"aiResourceProviderMode": "Access mode",
"aiResourceProviderModeInherit": "Inherit",
"aiResourceProviderModeSelect": "Select",
"aiResourceProviderModeSelectSummary": "Select · {count} models",
"aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.",
"aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.",
"aiResourceProviderAllowModels": "Allow list",
"aiResourceProviderAllowModelsSelect": "Select models",
"aiResourceProviderAllowModelsSearch": "Search models...",
"aiResourceProviderAllowModelsEmpty": "No models found",
"aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.",
"aiResourceAliasRequired": "Alias is required for inference resources", "aiResourceAliasRequired": "Alias is required for inference resources",
"aiResourceDomainConfiguration": "Domain configuration", "aiResourceDomainConfiguration": "Domain configuration",
"aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.", "aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.",
+4 -2
View File
@@ -234,7 +234,8 @@ export const resourceAiProviders = pgTable(
accessMode: varchar("accessMode") accessMode: varchar("accessMode")
.$type<"inherit" | "select">() .$type<"inherit" | "select">()
.notNull() .notNull()
.default("inherit") .default("inherit"),
enabled: boolean("enabled").notNull().default(true)
}, },
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
); );
@@ -529,7 +530,8 @@ export const siteResourceAiProviders = pgTable(
accessMode: varchar("accessMode") accessMode: varchar("accessMode")
.$type<"inherit" | "select">() .$type<"inherit" | "select">()
.notNull() .notNull()
.default("inherit") .default("inherit"),
enabled: boolean("enabled").notNull().default(true)
}, },
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
); );
+4 -2
View File
@@ -231,7 +231,8 @@ export const resourceAiProviders = sqliteTable(
accessMode: text("accessMode") accessMode: text("accessMode")
.$type<"inherit" | "select">() .$type<"inherit" | "select">()
.notNull() .notNull()
.default("inherit") .default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
}, },
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
); );
@@ -514,7 +515,8 @@ export const siteResourceAiProviders = sqliteTable(
accessMode: text("accessMode") accessMode: text("accessMode")
.$type<"inherit" | "select">() .$type<"inherit" | "select">()
.notNull() .notNull()
.default("inherit") .default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
}, },
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
); );
+35 -16
View File
@@ -24,7 +24,8 @@ export type AccessMode = z.infer<typeof accessModeSchema>;
export const resourceAiProviderAttachmentSchema = z.strictObject({ export const resourceAiProviderAttachmentSchema = z.strictObject({
providerId: z.number().int().positive(), providerId: z.number().int().positive(),
accessMode: accessModeSchema.optional().default("inherit") accessMode: accessModeSchema.optional().default("inherit"),
enabled: z.boolean().optional().default(true)
}); });
export type ResourceAiProviderInput = z.infer< export type ResourceAiProviderInput = z.infer<
@@ -34,6 +35,7 @@ export type ResourceAiProviderInput = z.infer<
export type ResourceAiProviderAttachment = { export type ResourceAiProviderAttachment = {
providerId: number; providerId: number;
accessMode: AccessMode; accessMode: AccessMode;
enabled: boolean;
}; };
export const resourceAiModelEntrySchema = z.strictObject({ export const resourceAiModelEntrySchema = z.strictObject({
@@ -79,14 +81,23 @@ export function resolveEffectiveLists(input: {
function normalizeAttachments( function normalizeAttachments(
inputs: ResourceAiProviderInput[] inputs: ResourceAiProviderInput[]
): ResourceAiProviderAttachment[] { ): ResourceAiProviderAttachment[] {
const byProviderId = new Map<number, AccessMode>(); const byProviderId = new Map<
number,
{ accessMode: AccessMode; enabled: boolean }
>();
for (const input of inputs) { for (const input of inputs) {
byProviderId.set(input.providerId, input.accessMode ?? "inherit"); byProviderId.set(input.providerId, {
accessMode: input.accessMode ?? "inherit",
enabled: input.enabled ?? true
});
} }
return [...byProviderId.entries()].map(([providerId, accessMode]) => ({ return [...byProviderId.entries()].map(
providerId, ([providerId, { accessMode, enabled }]) => ({
accessMode providerId,
})); accessMode,
enabled
})
);
} }
type EffectiveAllowRow = { type EffectiveAllowRow = {
@@ -110,14 +121,16 @@ export async function assertNoOverlappingModelKeys(
): Promise<InferenceFieldsError | null> { ): Promise<InferenceFieldsError | null> {
const trx = options.trx ?? db; const trx = options.trx ?? db;
if (attachments.length < 2) { const activeAttachments = attachments.filter((a) => a.enabled);
if (activeAttachments.length < 2) {
return null; return null;
} }
const inheritProviderIds = attachments const inheritProviderIds = activeAttachments
.filter((a) => a.accessMode === "inherit") .filter((a) => a.accessMode === "inherit")
.map((a) => a.providerId); .map((a) => a.providerId);
const selectProviderIds = attachments const selectProviderIds = activeAttachments
.filter((a) => a.accessMode === "select") .filter((a) => a.accessMode === "select")
.map((a) => a.providerId); .map((a) => a.providerId);
@@ -318,7 +331,8 @@ export async function setPublicResourceAiProviders(
attachments.map((a) => ({ attachments.map((a) => ({
resourceId, resourceId,
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})) }))
); );
} }
@@ -344,7 +358,8 @@ export async function setSiteResourceAiProviders(
attachments.map((a) => ({ attachments.map((a) => ({
siteResourceId, siteResourceId,
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})) }))
); );
} }
@@ -473,7 +488,8 @@ export async function listPublicResourceAiProviders(resourceId: number) {
providerId: resourceAiProviders.providerId, providerId: resourceAiProviders.providerId,
name: aiProviders.name, name: aiProviders.name,
type: aiProviders.type, type: aiProviders.type,
enabled: aiProviders.enabled, enabled: resourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: resourceAiProviders.accessMode accessMode: resourceAiProviders.accessMode
}) })
.from(resourceAiProviders) .from(resourceAiProviders)
@@ -490,7 +506,8 @@ export async function listSiteResourceAiProviders(siteResourceId: number) {
providerId: siteResourceAiProviders.providerId, providerId: siteResourceAiProviders.providerId,
name: aiProviders.name, name: aiProviders.name,
type: aiProviders.type, type: aiProviders.type,
enabled: aiProviders.enabled, enabled: siteResourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: siteResourceAiProviders.accessMode accessMode: siteResourceAiProviders.accessMode
}) })
.from(siteResourceAiProviders) .from(siteResourceAiProviders)
@@ -575,7 +592,8 @@ export async function assertPublicResourceModelEntriesValid(input: {
const attachments = await db const attachments = await db
.select({ .select({
providerId: resourceAiProviders.providerId, providerId: resourceAiProviders.providerId,
accessMode: resourceAiProviders.accessMode accessMode: resourceAiProviders.accessMode,
enabled: resourceAiProviders.enabled
}) })
.from(resourceAiProviders) .from(resourceAiProviders)
.innerJoin( .innerJoin(
@@ -610,7 +628,8 @@ export async function assertSiteResourceModelEntriesValid(input: {
const attachments = await db const attachments = await db
.select({ .select({
providerId: siteResourceAiProviders.providerId, providerId: siteResourceAiProviders.providerId,
accessMode: siteResourceAiProviders.accessMode accessMode: siteResourceAiProviders.accessMode,
enabled: siteResourceAiProviders.enabled
}) })
.from(siteResourceAiProviders) .from(siteResourceAiProviders)
.innerJoin( .innerJoin(
+13 -4
View File
@@ -287,7 +287,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
resourceAiProviders.resourceId, resourceAiProviders.resourceId,
resourceRow.resourceId resourceRow.resourceId
), ),
eq(aiProviders.enabled, true) eq(aiProviders.enabled, true),
eq(resourceAiProviders.enabled, true)
) )
), ),
db db
@@ -342,7 +343,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
siteResourceAiProviders.siteResourceId, siteResourceAiProviders.siteResourceId,
siteResourceRow.siteResourceId siteResourceRow.siteResourceId
), ),
eq(aiProviders.enabled, true) eq(aiProviders.enabled, true),
eq(siteResourceAiProviders.enabled, true)
) )
), ),
db db
@@ -525,8 +527,15 @@ function logAiUsageAndCost(args: {
isStream: boolean; isStream: boolean;
headers: Headers; headers: Headers;
}): void { }): void {
const { capability, provider, requestedModel, requestBody, responseText, isStream, headers } = const {
args; capability,
provider,
requestedModel,
requestBody,
responseText,
isStream,
headers
} = args;
let usage: AiUsage | null = extractUsage( let usage: AiUsage | null = extractUsage(
capability, capability,
@@ -118,9 +118,14 @@ export async function addAiProviderToResource(
.filter((a) => a.providerId !== providerId) .filter((a) => a.providerId !== providerId)
.map((a) => ({ .map((a) => ({
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})), })),
{ providerId, accessMode: "inherit" as const } {
providerId,
accessMode: "inherit" as const,
enabled: true as const
}
]; ];
const attachments = await resolveProviderAttachments({ const attachments = await resolveProviderAttachments({
+2 -1
View File
@@ -397,7 +397,8 @@ async function createHttpResource(
orgId, orgId,
attachments: (aiProviderInputs ?? []).map((p) => ({ attachments: (aiProviderInputs ?? []).map((p) => ({
providerId: p.providerId, providerId: p.providerId,
accessMode: "inherit" as const accessMode: "inherit" as const,
enabled: true as const
})), })),
requireAtLeastOne: false requireAtLeastOne: false
}); });
@@ -127,7 +127,8 @@ export async function removeAiProviderFromResource(
.filter((a) => a.providerId !== providerId) .filter((a) => a.providerId !== providerId)
.map((a) => ({ .map((a) => ({
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})); }));
const attachments = await resolveProviderAttachments({ const attachments = await resolveProviderAttachments({
@@ -118,9 +118,14 @@ export async function addAiProviderToSiteResource(
.filter((a) => a.providerId !== providerId) .filter((a) => a.providerId !== providerId)
.map((a) => ({ .map((a) => ({
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})), })),
{ providerId, accessMode: "inherit" as const } {
providerId,
accessMode: "inherit" as const,
enabled: true as const
}
]; ];
const attachments = await resolveProviderAttachments({ const attachments = await resolveProviderAttachments({
@@ -356,7 +356,8 @@ export async function createSiteResource(
orgId, orgId,
attachments: (aiProviderInputs ?? []).map((p) => ({ attachments: (aiProviderInputs ?? []).map((p) => ({
providerId: p.providerId, providerId: p.providerId,
accessMode: "inherit" as const accessMode: "inherit" as const,
enabled: true as const
})), })),
requireAtLeastOne: false requireAtLeastOne: false
}); });
@@ -126,7 +126,8 @@ export async function removeAiProviderFromSiteResource(
.filter((a) => a.providerId !== providerId) .filter((a) => a.providerId !== providerId)
.map((a) => ({ .map((a) => ({
providerId: a.providerId, providerId: a.providerId,
accessMode: a.accessMode accessMode: a.accessMode,
enabled: a.enabled
})); }));
const attachments = await resolveProviderAttachments({ const attachments = await resolveProviderAttachments({
@@ -16,9 +16,9 @@ import {
SettingsSubsectionTitle SettingsSubsectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { import {
AiProvidersSelector, AiProviderAttachments,
type SelectedAiProvider type AiProviderAttachmentValue
} from "@app/components/AiProvidersSelector"; } from "@app/components/AiProviderAttachments";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
@@ -41,7 +41,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo, useState } from "react"; import { useActionState, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -65,7 +65,15 @@ export default function PrivateResourceInferencePage() {
const formSchema = useMemo( const formSchema = useMemo(
() => () =>
z.object({ z.object({
providerIds: z.array(z.number().int().positive()), providers: z.array(
z.object({
providerId: z.number().int().positive(),
name: z.string(),
accessMode: z.enum(["inherit", "select"]),
enabled: z.boolean(),
selectedModelIds: z.array(z.number().int().positive())
})
),
httpConfigSubdomain: z.string().nullish(), httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(), httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish(), httpConfigFullDomain: z.string().nullish(),
@@ -75,10 +83,6 @@ export default function PrivateResourceInferencePage() {
); );
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const attachedQuery = useQuery({ const attachedQuery = useQuery({
...resourceQueries.siteResourceAiProviders({ ...resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id siteResourceId: siteResource.id
@@ -86,10 +90,17 @@ export default function PrivateResourceInferencePage() {
enabled: siteResource.mode === "inference" enabled: siteResource.mode === "inference"
}); });
const modelsQuery = useQuery({
...resourceQueries.siteResourceAiModels({
siteResourceId: siteResource.id
}),
enabled: siteResource.mode === "inference"
});
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
providerIds: [], providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null, httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null, httpConfigDomainId: siteResource.domainId ?? null,
httpConfigFullDomain: siteResource.fullDomain ?? null, httpConfigFullDomain: siteResource.fullDomain ?? null,
@@ -103,16 +114,33 @@ export default function PrivateResourceInferencePage() {
useEffect(() => { useEffect(() => {
if (!attachedQuery.data) return; if (!attachedQuery.data) return;
const providers = attachedQuery.data.map((provider) => ({ const hasSelect = attachedQuery.data.some(
id: String(provider.providerId), (provider) => provider.accessMode === "select"
text: provider.name
}));
setSelectedProviders(providers);
form.setValue(
"providerIds",
attachedQuery.data.map((p) => p.providerId)
); );
}, [attachedQuery.data, form]); if (hasSelect && modelsQuery.isLoading) return;
const modelsByProvider = new Map<number, number[]>();
for (const model of modelsQuery.data ?? []) {
if (model.listType !== "allow") continue;
const existing = modelsByProvider.get(model.providerId) ?? [];
existing.push(model.modelId);
modelsByProvider.set(model.providerId, existing);
}
form.setValue(
"providers",
attachedQuery.data.map((provider) => ({
providerId: provider.providerId,
name: provider.name,
accessMode: provider.accessMode,
enabled: provider.enabled,
selectedModelIds:
provider.accessMode === "select"
? (modelsByProvider.get(provider.providerId) ?? [])
: []
}))
);
}, [attachedQuery.data, modelsQuery.data, modelsQuery.isLoading, form]);
const [, formAction, saveLoading] = useActionState(async () => { const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger(); const isValid = await form.trigger();
@@ -129,16 +157,37 @@ export default function PrivateResourceInferencePage() {
}); });
await api.post(`/site-resource/${siteResource.id}/ai-providers`, { await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
providers: data.providerIds.map((providerId) => ({ providers: data.providers.map((provider) => ({
providerId providerId: provider.providerId,
accessMode: provider.accessMode,
enabled: provider.enabled
})) }))
}); });
const selectProviders = data.providers.filter(
(provider) => provider.accessMode === "select"
);
if (selectProviders.length > 0) {
await api.post(`/site-resource/${siteResource.id}/ai-models`, {
models: selectProviders.flatMap((provider) =>
provider.selectedModelIds.map((modelId) => ({
modelId,
listType: "allow" as const
}))
)
});
}
await queryClient.invalidateQueries( await queryClient.invalidateQueries(
resourceQueries.siteResourceAiProviders({ resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id siteResourceId: siteResource.id
}) })
); );
await queryClient.invalidateQueries(
resourceQueries.siteResourceAiModels({
siteResourceId: siteResource.id
})
);
toast({ toast({
title: t("success"), title: t("success"),
@@ -160,6 +209,11 @@ export default function PrivateResourceInferencePage() {
return null; return null;
} }
const providersLoading =
attachedQuery.isLoading ||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
modelsQuery.isLoading);
return ( return (
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
@@ -183,8 +237,8 @@ export default function PrivateResourceInferencePage() {
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField <FormField
control={form.control} control={form.control}
name="providerIds" name="providers"
render={() => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
@@ -192,44 +246,21 @@ export default function PrivateResourceInferencePage() {
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<AiProvidersSelector <AiProviderAttachments
orgId={ orgId={
siteResource.orgId siteResource.orgId
} }
selectedProviders={ value={
selectedProviders field.value as AiProviderAttachmentValue[]
} }
disabled={ disabled={
attachedQuery.isLoading || providersLoading
saveLoading }
onChange={
field.onChange
} }
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
form.setValue(
"providerIds",
providers.map(
(p) =>
parseInt(
p.id,
10
)
),
{
shouldValidate: true
}
);
}}
/> />
</FormControl> </FormControl>
<FormDescription>
{t(
"aiResourceProvidersHelp"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -335,7 +366,7 @@ export default function PrivateResourceInferencePage() {
type="submit" type="submit"
form="private-resource-providers-form" form="private-resource-providers-form"
loading={saveLoading} loading={saveLoading}
disabled={attachedQuery.isLoading} disabled={providersLoading || saveLoading}
> >
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
@@ -710,11 +710,6 @@ export default function CreatePrivateResourcePage() {
}} }}
/> />
</FormControl> </FormControl>
<FormDescription>
{t(
"aiResourceProvidersHelp"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -13,9 +13,9 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { import {
AiProvidersSelector, AiProviderAttachments,
type SelectedAiProvider type AiProviderAttachmentValue
} from "@app/components/AiProvidersSelector"; } from "@app/components/AiProviderAttachments";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
@@ -35,7 +35,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo, useState } from "react"; import { useActionState, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -58,16 +58,20 @@ export default function PublicResourceInferencePage() {
const formSchema = useMemo( const formSchema = useMemo(
() => () =>
z.object({ z.object({
providerIds: z.array(z.number().int().positive()) providers: z.array(
z.object({
providerId: z.number().int().positive(),
name: z.string(),
accessMode: z.enum(["inherit", "select"]),
enabled: z.boolean(),
selectedModelIds: z.array(z.number().int().positive())
})
)
}), }),
[] []
); );
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const attachedQuery = useQuery({ const attachedQuery = useQuery({
...resourceQueries.resourceAiProviders({ ...resourceQueries.resourceAiProviders({
resourceId: resource.resourceId resourceId: resource.resourceId
@@ -75,24 +79,53 @@ export default function PublicResourceInferencePage() {
enabled: resource.mode === "inference" enabled: resource.mode === "inference"
}); });
const modelsQuery = useQuery({
...resourceQueries.resourceAiModels({
resourceId: resource.resourceId
}),
enabled: resource.mode === "inference"
});
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
providerIds: [] providers: []
} }
}); });
useEffect(() => { useEffect(() => {
if (!attachedQuery.data) return; if (!attachedQuery.data) return;
const providers = attachedQuery.data.map((provider) => ({ const hasSelect = attachedQuery.data.some(
id: String(provider.providerId), (provider) => provider.accessMode === "select"
text: provider.name );
})); if (hasSelect && modelsQuery.isLoading) return;
setSelectedProviders(providers);
const modelsByProvider = new Map<number, number[]>();
for (const model of modelsQuery.data ?? []) {
if (model.listType !== "allow") continue;
const existing = modelsByProvider.get(model.providerId) ?? [];
existing.push(model.modelId);
modelsByProvider.set(model.providerId, existing);
}
form.reset({ form.reset({
providerIds: attachedQuery.data.map((p) => p.providerId) providers: attachedQuery.data.map((provider) => ({
providerId: provider.providerId,
name: provider.name,
accessMode: provider.accessMode,
enabled: provider.enabled,
selectedModelIds:
provider.accessMode === "select"
? (modelsByProvider.get(provider.providerId) ?? [])
: []
}))
}); });
}, [attachedQuery.data, form]); }, [
attachedQuery.data,
modelsQuery.data,
modelsQuery.isLoading,
form
]);
const [, formAction, saveLoading] = useActionState(async () => { const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger(); const isValid = await form.trigger();
@@ -101,16 +134,37 @@ export default function PublicResourceInferencePage() {
const data = form.getValues(); const data = form.getValues();
try { try {
await api.post(`/resource/${resource.resourceId}/ai-providers`, { await api.post(`/resource/${resource.resourceId}/ai-providers`, {
providers: data.providerIds.map((providerId) => ({ providers: data.providers.map((provider) => ({
providerId providerId: provider.providerId,
accessMode: provider.accessMode,
enabled: provider.enabled
})) }))
}); });
const selectProviders = data.providers.filter(
(provider) => provider.accessMode === "select"
);
if (selectProviders.length > 0) {
await api.post(`/resource/${resource.resourceId}/ai-models`, {
models: selectProviders.flatMap((provider) =>
provider.selectedModelIds.map((modelId) => ({
modelId,
listType: "allow" as const
}))
)
});
}
await queryClient.invalidateQueries( await queryClient.invalidateQueries(
resourceQueries.resourceAiProviders({ resourceQueries.resourceAiProviders({
resourceId: resource.resourceId resourceId: resource.resourceId
}) })
); );
await queryClient.invalidateQueries(
resourceQueries.resourceAiModels({
resourceId: resource.resourceId
})
);
toast({ toast({
title: t("success"), title: t("success"),
@@ -132,6 +186,11 @@ export default function PublicResourceInferencePage() {
return null; return null;
} }
const providersLoading =
attachedQuery.isLoading ||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
modelsQuery.isLoading);
return ( return (
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
@@ -155,8 +214,8 @@ export default function PublicResourceInferencePage() {
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField <FormField
control={form.control} control={form.control}
name="providerIds" name="providers"
render={() => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
@@ -164,44 +223,21 @@ export default function PublicResourceInferencePage() {
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<AiProvidersSelector <AiProviderAttachments
orgId={ orgId={
resource.orgId resource.orgId
} }
selectedProviders={ value={
selectedProviders field.value as AiProviderAttachmentValue[]
} }
disabled={ disabled={
attachedQuery.isLoading || providersLoading
saveLoading }
onChange={
field.onChange
} }
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
form.setValue(
"providerIds",
providers.map(
(p) =>
parseInt(
p.id,
10
)
),
{
shouldValidate: true
}
);
}}
/> />
</FormControl> </FormControl>
<FormDescription>
{t(
"aiResourceProvidersHelp"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -218,7 +254,7 @@ export default function PublicResourceInferencePage() {
type="submit" type="submit"
form="public-resource-providers-form" form="public-resource-providers-form"
loading={saveLoading} loading={saveLoading}
disabled={attachedQuery.isLoading} disabled={providersLoading || saveLoading}
> >
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
@@ -1437,11 +1437,6 @@ export default function Page() {
); );
}} }}
/> />
<p className="text-sm text-muted-foreground">
{t(
"aiResourceProvidersHelp"
)}
</p>
</div> </div>
</SettingsFormCell> </SettingsFormCell>
</SettingsFormGrid> </SettingsFormGrid>
+581
View File
@@ -0,0 +1,581 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { type TagValue } from "@app/components/multi-select/multi-select-content";
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { cn } from "@app/lib/cn";
import { aiProviderQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Plus, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export type AiProviderAttachmentValue = {
providerId: number;
name: string;
accessMode: "inherit" | "select";
enabled: boolean;
selectedModelIds: number[];
};
export type AiProviderAttachmentsProps = {
orgId: string;
value: AiProviderAttachmentValue[];
onChange: (value: AiProviderAttachmentValue[]) => void;
disabled?: boolean;
};
export function AiProviderAttachments({
orgId,
value,
onChange,
disabled
}: AiProviderAttachmentsProps) {
const t = useTranslations();
const [editingProviderId, setEditingProviderId] = useState<number | null>(
null
);
const { data: providers = [] } = useQuery(
aiProviderQueries.orgProviders({ orgId })
);
const attachedIds = useMemo(
() => new Set(value.map((v) => v.providerId)),
[value]
);
const availableProviders = providers
.filter((provider) => provider.enabled)
.filter((provider) => !attachedIds.has(provider.providerId));
const editing = value.find((v) => v.providerId === editingProviderId);
function addProvider(providerId: number, name: string) {
if (value.some((v) => v.providerId === providerId)) {
return;
}
onChange([
...value,
{
providerId,
name,
accessMode: "inherit",
enabled: true,
selectedModelIds: []
}
]);
}
function removeProvider(providerId: number) {
onChange(value.filter((v) => v.providerId !== providerId));
}
function updateProvider(updated: AiProviderAttachmentValue) {
onChange(
value.map((v) =>
v.providerId === updated.providerId ? updated : v
)
);
setEditingProviderId(null);
}
return (
<div className="flex flex-col gap-3">
{value.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("aiResourceProvidersNoneAttached")}
</p>
) : (
<div className="flex flex-col gap-2">
{value.map((attachment) => (
<AttachmentRow
key={attachment.providerId}
attachment={attachment}
disabled={disabled}
onEdit={() =>
setEditingProviderId(attachment.providerId)
}
onRemove={() =>
removeProvider(attachment.providerId)
}
onToggleEnabled={(enabled) => {
onChange(
value.map((v) =>
v.providerId === attachment.providerId
? { ...v, enabled }
: v
)
);
}}
/>
))}
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled || availableProviders.length === 0}
>
<Plus className="size-4" />
{t("aiResourceProvidersAdd")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{availableProviders.map((provider) => (
<DropdownMenuItem
key={provider.providerId}
onSelect={() =>
addProvider(provider.providerId, provider.name)
}
>
{provider.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{editing && (
<EditAttachmentCredenza
attachment={editing}
open={editingProviderId !== null}
onOpenChange={(open) => {
if (!open) setEditingProviderId(null);
}}
onSave={updateProvider}
/>
)}
</div>
);
}
function AttachmentRow({
attachment,
disabled,
onEdit,
onRemove,
onToggleEnabled
}: {
attachment: AiProviderAttachmentValue;
disabled?: boolean;
onEdit: () => void;
onRemove: () => void;
onToggleEnabled: (enabled: boolean) => void;
}) {
const t = useTranslations();
const summary =
attachment.accessMode === "inherit"
? t("aiResourceProviderModeInherit")
: t("aiResourceProviderModeSelectSummary", {
count: attachment.selectedModelIds.length
});
return (
<div
className={cn(
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
(disabled || !attachment.enabled) && "opacity-60",
!disabled && "cursor-pointer hover:bg-muted/50"
)}
onClick={disabled ? undefined : onEdit}
onKeyDown={
disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onEdit();
}
}
}
role={disabled ? undefined : "button"}
tabIndex={disabled ? undefined : 0}
>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium truncate">
{attachment.name}
</span>
<p className="truncate text-sm text-muted-foreground">
{attachment.enabled
? summary
: t("aiResourceProviderDisabled")}
</p>
</div>
<div
className="flex shrink-0 items-center gap-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-0"
disabled={disabled}
onClick={onEdit}
>
{t("edit")}
</Button>
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
disabled={disabled}
aria-label={t("aiResourceProvidersRemove")}
onClick={onRemove}
>
<XIcon className="size-4" />
</button>
<Switch
checked={attachment.enabled}
disabled={disabled}
aria-label={t("aiResourceProviderToggleEnabled")}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
);
}
type EditFormValues = {
accessMode: "inherit" | "select";
selectedModels: TagValue[];
};
function EditAttachmentCredenza({
attachment,
open,
onOpenChange,
onSave
}: {
attachment: AiProviderAttachmentValue;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (value: AiProviderAttachmentValue) => void;
}) {
const t = useTranslations();
const [modelSearch, setModelSearch] = useState("");
const editSchema = useMemo(
() =>
z.object({
accessMode: z.enum(["inherit", "select"]),
selectedModels: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
}),
[]
);
const modelsQuery = useQuery({
...aiProviderQueries.providerModels({
providerId: attachment.providerId
}),
enabled: open
});
const allowCatalog = useMemo(() => {
const models = modelsQuery.data ?? [];
return models.filter(
(model) => model.enabled && (model.listType ?? "allow") === "allow"
);
}, [modelsQuery.data]);
const allowOptions: TagValue[] = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
return allowCatalog
.filter((model) => {
if (!query) return true;
return (
model.modelKey.toLowerCase().includes(query) ||
model.name.toLowerCase().includes(query)
);
})
.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}));
}, [allowCatalog, modelSearch]);
const form = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: {
accessMode: attachment.accessMode,
selectedModels: []
}
});
const accessMode = form.watch("accessMode");
const pendingSeedRef = useRef(false);
useEffect(() => {
if (!open) return;
form.reset({
accessMode: attachment.accessMode,
selectedModels: attachment.selectedModelIds.map((modelId) => {
const catalog = (modelsQuery.data ?? []).find(
(model) => model.modelId === modelId
);
return {
id: String(modelId),
text: catalog?.modelKey ?? String(modelId)
};
})
});
setModelSearch("");
pendingSeedRef.current = false;
// Only re-init when opening or switching which attachment is edited.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, attachment.providerId]);
useEffect(() => {
if (!open || allowCatalog.length === 0) return;
const current = form.getValues("selectedModels");
const upgraded = current.map((model) => {
const catalog = allowCatalog.find(
(entry) => String(entry.modelId) === model.id
);
return catalog ? { id: model.id, text: catalog.modelKey } : model;
});
const changed = upgraded.some(
(model, index) => model.text !== current[index]?.text
);
if (changed) {
form.setValue("selectedModels", upgraded);
}
if (pendingSeedRef.current) {
form.setValue(
"selectedModels",
allowCatalog.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
);
pendingSeedRef.current = false;
}
}, [open, allowCatalog, form]);
function handleAccessModeChange(next: "inherit" | "select") {
form.setValue("accessMode", next);
if (next === "inherit") {
form.setValue("selectedModels", []);
pendingSeedRef.current = false;
return;
}
if (attachment.accessMode === "select") {
form.setValue(
"selectedModels",
attachment.selectedModelIds.map((modelId) => {
const catalog = allowCatalog.find(
(model) => model.modelId === modelId
);
return {
id: String(modelId),
text: catalog?.modelKey ?? String(modelId)
};
})
);
pendingSeedRef.current = false;
return;
}
if (allowCatalog.length > 0) {
form.setValue(
"selectedModels",
allowCatalog.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
);
pendingSeedRef.current = false;
return;
}
form.setValue("selectedModels", []);
pendingSeedRef.current = true;
}
function onSubmit(values: EditFormValues) {
onSave({
providerId: attachment.providerId,
name: attachment.name,
accessMode: values.accessMode,
enabled: attachment.enabled,
selectedModelIds:
values.accessMode === "select"
? values.selectedModels.map((model) =>
parseInt(model.id, 10)
)
: []
});
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{attachment.name}</CredenzaTitle>
<CredenzaDescription>
{t("aiResourceProviderEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
id="ai-provider-attachment-edit-form"
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="accessMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiResourceProviderMode")}
</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
handleAccessModeChange(
value as
| "inherit"
| "select"
)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="inherit">
{t(
"aiResourceProviderModeInherit"
)}
</SelectItem>
<SelectItem value="select">
{t(
"aiResourceProviderModeSelect"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{field.value === "inherit"
? t(
"aiResourceProviderModeInheritHelp"
)
: t(
"aiResourceProviderModeSelectHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{accessMode === "select" && (
<FormField
control={form.control}
name="selectedModels"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviderAllowModels"
)}
</FormLabel>
<FormControl>
<MultiSelectTagInput
buttonText={t(
"aiResourceProviderAllowModelsSelect"
)}
emptyPlaceholder={t(
"aiResourceProviderAllowModelsEmpty"
)}
searchPlaceholder={t(
"aiResourceProviderAllowModelsSearch"
)}
searchQuery={modelSearch}
options={allowOptions}
value={field.value}
onChange={field.onChange}
onSearch={setModelSearch}
disabled={
modelsQuery.isLoading
}
/>
</FormControl>
<FormDescription>
{t(
"aiResourceProviderAllowModelsHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="ai-provider-attachment-edit-form"
>
{t("done")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+24
View File
@@ -38,6 +38,7 @@ import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { import type {
GetResourcePoliciesResponse, GetResourcePoliciesResponse,
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceAiModelsResponse,
ListResourceNamesResponse, ListResourceNamesResponse,
ListResourceRolesResponse, ListResourceRolesResponse,
ListResourceRulesResponse, ListResourceRulesResponse,
@@ -51,6 +52,7 @@ import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
import type { import type {
ListAllSiteResourcesByOrgResponse, ListAllSiteResourcesByOrgResponse,
ListSiteResourceAiModelsResponse,
ListSiteResourceClientsResponse, ListSiteResourceClientsResponse,
ListSiteResourceRolesResponse, ListSiteResourceRolesResponse,
ListSiteResourceUsersResponse ListSiteResourceUsersResponse
@@ -1291,6 +1293,7 @@ export const resourceQueries = {
name: string; name: string;
type: string; type: string;
enabled: boolean; enabled: boolean;
providerEnabled: boolean;
accessMode: "inherit" | "select"; accessMode: "inherit" | "select";
}>; }>;
}> }>
@@ -1311,6 +1314,7 @@ export const resourceQueries = {
name: string; name: string;
type: string; type: string;
enabled: boolean; enabled: boolean;
providerEnabled: boolean;
accessMode: "inherit" | "select"; accessMode: "inherit" | "select";
}>; }>;
}> }>
@@ -1320,6 +1324,26 @@ export const resourceQueries = {
return res.data.data.providers; return res.data.data.providers;
} }
}), }),
resourceAiModels: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "AI_MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourceAiModelsResponse>
>(`/resource/${resourceId}/ai-models`, { signal });
return res.data.data.models;
}
}),
siteResourceAiModels: ({ siteResourceId }: { siteResourceId: number }) =>
queryOptions({
queryKey: ["SITE_RESOURCES", siteResourceId, "AI_MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListSiteResourceAiModelsResponse>
>(`/site-resource/${siteResourceId}/ai-models`, { signal });
return res.data.data.models;
}
}),
resourceTargets: ({ resourceId }: { resourceId: number }) => resourceTargets: ({ resourceId }: { resourceId: number }) =>
queryOptions({ queryOptions({
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,