mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 13:38:33 +02:00
improved provider picker ui on resource
This commit is contained in:
+18
-1
@@ -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.",
|
||||||
|
|||||||
@@ -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] })]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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] })]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user