mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 16:59:35 +02:00
improve model picker
This commit is contained in:
@@ -1926,8 +1926,6 @@
|
|||||||
"aiProviderModelsEditTitle": "Edit Model",
|
"aiProviderModelsEditTitle": "Edit Model",
|
||||||
"aiProviderModelsEditDescription": "Update the model key or configure its budget.",
|
"aiProviderModelsEditDescription": "Update the model key or configure its budget.",
|
||||||
"aiProviderModelsBudgetTab": "Budget",
|
"aiProviderModelsBudgetTab": "Budget",
|
||||||
"aiProviderModelsBudgetDescription": "Configure how this model restricts usage based on spending or token limits",
|
|
||||||
"aiProviderModelsBudgetUnsaved": "Save this model first to configure its budget.",
|
|
||||||
"aiProviderModelsKeyLabel": "Model Key",
|
"aiProviderModelsKeyLabel": "Model Key",
|
||||||
"aiProviderModelsKeyRequired": "Enter a model key",
|
"aiProviderModelsKeyRequired": "Enter a model key",
|
||||||
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
|
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import {
|
import {
|
||||||
|
persistPendingModelBudgets,
|
||||||
type AiProviderModelListItem,
|
type AiProviderModelListItem,
|
||||||
type ModelListType
|
type ModelListType
|
||||||
} from "@app/components/AiProviderModelListEditor";
|
} from "@app/components/AiProviderModelListEditor";
|
||||||
@@ -21,7 +22,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { aiProviderQueries } from "@app/lib/queries";
|
import { aiProviderQueries } from "@app/lib/queries";
|
||||||
|
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
@@ -113,8 +116,7 @@ export default function AiProviderModelsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toCreate: { modelKey: string; listType: ModelListType }[] =
|
const toCreate: AiProviderModelListItem[] = [];
|
||||||
[];
|
|
||||||
const toUpdate: {
|
const toUpdate: {
|
||||||
modelId: number;
|
modelId: number;
|
||||||
modelKey: string;
|
modelKey: string;
|
||||||
@@ -159,7 +161,7 @@ export default function AiProviderModelsPage() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
toCreate.push({ modelKey, listType });
|
toCreate.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDelete = existing
|
const toDelete = existing
|
||||||
@@ -167,13 +169,21 @@ export default function AiProviderModelsPage() {
|
|||||||
.map((model) => model.modelId);
|
.map((model) => model.modelId);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...toCreate.map(({ modelKey, listType }) =>
|
...toCreate.map(async (item) => {
|
||||||
api.put(`/ai-provider/${provider.providerId}/model`, {
|
const res = await api.put<
|
||||||
modelKey,
|
AxiosResponse<CreateOrEditAiModelResponse>
|
||||||
name: modelKey,
|
>(`/ai-provider/${provider.providerId}/model`, {
|
||||||
listType
|
modelKey: item.modelKey,
|
||||||
})
|
name: item.modelKey,
|
||||||
),
|
listType: item.listType
|
||||||
|
});
|
||||||
|
await persistPendingModelBudgets({
|
||||||
|
api,
|
||||||
|
orgId: provider.orgId,
|
||||||
|
modelId: res.data.data.model.modelId,
|
||||||
|
pendingBudgets: item.pendingBudgets
|
||||||
|
});
|
||||||
|
}),
|
||||||
...toUpdate.map(({ modelId, modelKey, listType }) =>
|
...toUpdate.map(({ modelId, modelKey, listType }) =>
|
||||||
api.post(`/ai-model/${modelId}`, {
|
api.post(`/ai-model/${modelId}`, {
|
||||||
modelKey,
|
modelKey,
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import HeaderTitle from "@app/components/SettingsSectionTitle";
|
|||||||
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
||||||
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
|
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
|
||||||
import {
|
import {
|
||||||
type AiProviderModelListItem,
|
persistPendingModelBudgets,
|
||||||
type ModelListType
|
type AiProviderModelListItem
|
||||||
} from "@app/components/AiProviderModelListEditor";
|
} from "@app/components/AiProviderModelListEditor";
|
||||||
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
|
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +59,10 @@ import {
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
|
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
|
||||||
import { aiProviderQueries } from "@app/lib/queries";
|
import { aiProviderQueries } from "@app/lib/queries";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type {
|
||||||
|
CreateOrEditAiModelResponse,
|
||||||
|
CreateOrEditAiProviderResponse
|
||||||
|
} from "@server/routers/aiProvider/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -157,14 +160,22 @@ export default function CreateAiProviderPage() {
|
|||||||
|
|
||||||
async function createModels(
|
async function createModels(
|
||||||
providerId: number,
|
providerId: number,
|
||||||
items: { modelKey: string; listType: ModelListType }[]
|
items: AiProviderModelListItem[]
|
||||||
) {
|
) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await api.put(`/ai-provider/${providerId}/model`, {
|
const res = await api.put<
|
||||||
|
AxiosResponse<CreateOrEditAiModelResponse>
|
||||||
|
>(`/ai-provider/${providerId}/model`, {
|
||||||
modelKey: item.modelKey,
|
modelKey: item.modelKey,
|
||||||
name: item.modelKey,
|
name: item.modelKey,
|
||||||
listType: item.listType
|
listType: item.listType
|
||||||
});
|
});
|
||||||
|
await persistPendingModelBudgets({
|
||||||
|
api,
|
||||||
|
orgId,
|
||||||
|
modelId: res.data.data.model.modelId,
|
||||||
|
pendingBudgets: item.pendingBudgets
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +221,8 @@ export default function CreateAiProviderPage() {
|
|||||||
|
|
||||||
const modelItems = [...allowItems, ...blockItems]
|
const modelItems = [...allowItems, ...blockItems]
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
modelKey: item.modelKey.trim(),
|
...item,
|
||||||
listType: item.listType
|
modelKey: item.modelKey.trim()
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.modelKey);
|
.filter((item) => item.modelKey);
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "@app/components/ui/tooltip";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
|
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
@@ -56,16 +50,8 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import { aiBudgetQueries } from "@app/lib/queries";
|
import { aiBudgetQueries } from "@app/lib/queries";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import type { AxiosInstance } from "axios";
|
||||||
Asterisk,
|
import { Globe, Plus, XIcon } from "lucide-react";
|
||||||
BookMarked,
|
|
||||||
Check,
|
|
||||||
Globe,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Wallet,
|
|
||||||
XIcon
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -78,6 +64,7 @@ export type ModelSource = "catalog" | "custom" | "pattern" | "all";
|
|||||||
export const ALL_MODELS_KEY = "*";
|
export const ALL_MODELS_KEY = "*";
|
||||||
|
|
||||||
const COLLAPSED_ROWS = 5;
|
const COLLAPSED_ROWS = 5;
|
||||||
|
const GRID_COLUMNS = 2;
|
||||||
|
|
||||||
export type AiProviderModelListItem = {
|
export type AiProviderModelListItem = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -85,8 +72,32 @@ export type AiProviderModelListItem = {
|
|||||||
modelKey: string;
|
modelKey: string;
|
||||||
listType: ModelListType;
|
listType: ModelListType;
|
||||||
hasBudget?: boolean;
|
hasBudget?: boolean;
|
||||||
|
pendingBudgets?: BudgetRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function persistPendingModelBudgets({
|
||||||
|
api,
|
||||||
|
orgId,
|
||||||
|
modelId,
|
||||||
|
pendingBudgets
|
||||||
|
}: {
|
||||||
|
api: AxiosInstance;
|
||||||
|
orgId: string;
|
||||||
|
modelId: number;
|
||||||
|
pendingBudgets?: BudgetRow[];
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!pendingBudgets || pendingBudgets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveBudgetRows({
|
||||||
|
api,
|
||||||
|
orgId,
|
||||||
|
scope: { type: "model", id: modelId },
|
||||||
|
existingBudgets: [],
|
||||||
|
rows: pendingBudgets
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AiProviderModelListEditorProps = {
|
export type AiProviderModelListEditorProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
listType: ModelListType;
|
listType: ModelListType;
|
||||||
@@ -138,27 +149,6 @@ function parseBulkKeys(raw: string): string[] {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useModelGridColumns(): number {
|
|
||||||
const [columns, setColumns] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sm = window.matchMedia("(min-width: 640px)");
|
|
||||||
const xl = window.matchMedia("(min-width: 1280px)");
|
|
||||||
const update = () => {
|
|
||||||
setColumns(xl.matches ? 3 : sm.matches ? 2 : 1);
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
sm.addEventListener("change", update);
|
|
||||||
xl.addEventListener("change", update);
|
|
||||||
return () => {
|
|
||||||
sm.removeEventListener("change", update);
|
|
||||||
xl.removeEventListener("change", update);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiProviderModelListEditor({
|
export function AiProviderModelListEditor({
|
||||||
orgId,
|
orgId,
|
||||||
listType,
|
listType,
|
||||||
@@ -178,8 +168,7 @@ export function AiProviderModelListEditor({
|
|||||||
const [listExpanded, setListExpanded] = useState(false);
|
const [listExpanded, setListExpanded] = useState(false);
|
||||||
const [clipHeight, setClipHeight] = useState<number | null>(null);
|
const [clipHeight, setClipHeight] = useState<number | null>(null);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const columns = useModelGridColumns();
|
const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS;
|
||||||
const collapsedLimit = columns * COLLAPSED_ROWS;
|
|
||||||
const hasOverflow = items.length > collapsedLimit;
|
const hasOverflow = items.length > collapsedLimit;
|
||||||
const isCollapsed = hasOverflow && !listExpanded;
|
const isCollapsed = hasOverflow && !listExpanded;
|
||||||
|
|
||||||
@@ -358,7 +347,7 @@ export function AiProviderModelListEditor({
|
|||||||
<div
|
<div
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
|
"grid grid-cols-2 gap-2",
|
||||||
isCollapsed && "overflow-hidden"
|
isCollapsed && "overflow-hidden"
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
@@ -672,24 +661,6 @@ function ModelCard({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const sourceLabel =
|
|
||||||
source === "all"
|
|
||||||
? t("aiProviderModelsSourceAll")
|
|
||||||
: source === "catalog"
|
|
||||||
? t("aiProviderModelsSourceCatalog")
|
|
||||||
: source === "pattern"
|
|
||||||
? t("aiProviderModelsSourcePattern")
|
|
||||||
: t("aiProviderModelsSourceCustom");
|
|
||||||
|
|
||||||
const SourceIcon =
|
|
||||||
source === "all"
|
|
||||||
? Globe
|
|
||||||
: source === "catalog"
|
|
||||||
? BookMarked
|
|
||||||
: source === "pattern"
|
|
||||||
? Asterisk
|
|
||||||
: Pencil;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -714,55 +685,15 @@ function ModelCard({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{source === "all" ? (
|
{source === "all" ? (
|
||||||
<>
|
<span className="block truncate text-xs font-medium">
|
||||||
<span className="block truncate text-xs font-medium">
|
{t("aiProviderModelsAllLabel")}
|
||||||
{t("aiProviderModelsAllLabel")}
|
</span>
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="block truncate font-mono text-xs font-medium">
|
<span className="block truncate font-mono text-xs font-medium">
|
||||||
{item.modelKey}
|
{item.modelKey}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="inline-flex"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<SourceIcon
|
|
||||||
className="size-3.5"
|
|
||||||
aria-label={sourceLabel}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{sourceLabel}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{item.hasBudget ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="inline-flex"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Wallet
|
|
||||||
className="size-3.5"
|
|
||||||
aria-label={t(
|
|
||||||
"aiProviderModelsBudgetConfigured"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("aiProviderModelsBudgetConfigured")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
|
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
|
||||||
@@ -844,7 +775,19 @@ function EditModelCredenza({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
form.reset({ modelKey: item.modelKey });
|
form.reset({ modelKey: item.modelKey });
|
||||||
setAttemptedBudgetsSave(false);
|
setAttemptedBudgetsSave(false);
|
||||||
}, [form, item.clientId, item.modelKey, open]);
|
if (item.modelId === undefined) {
|
||||||
|
setPendingBudgetRows(item.pendingBudgets ?? []);
|
||||||
|
} else {
|
||||||
|
setPendingBudgetRows([]);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
form,
|
||||||
|
item.clientId,
|
||||||
|
item.modelId,
|
||||||
|
item.modelKey,
|
||||||
|
item.pendingBudgets,
|
||||||
|
open
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !budgetsQuery.data) return;
|
if (!open || !budgetsQuery.data) return;
|
||||||
@@ -896,7 +839,9 @@ function EditModelCredenza({
|
|||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
...item,
|
...item,
|
||||||
modelKey: values.modelKey.trim()
|
modelKey: values.modelKey.trim(),
|
||||||
|
pendingBudgets: pendingBudgetRows,
|
||||||
|
hasBudget: pendingBudgetRows.length > 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,29 +898,15 @@ function EditModelCredenza({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
{budgetScope ? (
|
<BudgetRowsFields
|
||||||
<>
|
rows={pendingBudgetRows}
|
||||||
<p className="text-sm text-muted-foreground">
|
onChange={setPendingBudgetRows}
|
||||||
{t(
|
disabled={
|
||||||
"aiProviderModelsBudgetDescription"
|
budgetScope !== null &&
|
||||||
)}
|
budgetsQuery.isLoading
|
||||||
</p>
|
}
|
||||||
<BudgetRowsFields
|
attemptedSave={attemptedBudgetsSave}
|
||||||
rows={pendingBudgetRows}
|
/>
|
||||||
onChange={setPendingBudgetRows}
|
|
||||||
disabled={
|
|
||||||
budgetsQuery.isLoading
|
|
||||||
}
|
|
||||||
attemptedSave={
|
|
||||||
attemptedBudgetsSave
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("aiProviderModelsBudgetUnsaved")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</HorizontalTabs>
|
</HorizontalTabs>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|||||||
Reference in New Issue
Block a user