improve model picker

This commit is contained in:
miloschwartz
2026-08-14 11:38:11 -04:00
parent db2de4af89
commit c568251d8d
4 changed files with 95 additions and 145 deletions
@@ -11,6 +11,7 @@ import {
SettingsSectionTitle
} from "@app/components/Settings";
import {
persistPendingModelBudgets,
type AiProviderModelListItem,
type ModelListType
} from "@app/components/AiProviderModelListEditor";
@@ -21,7 +22,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { aiProviderQueries } from "@app/lib/queries";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
@@ -113,8 +116,7 @@ export default function AiProviderModelsPage() {
return;
}
const toCreate: { modelKey: string; listType: ModelListType }[] =
[];
const toCreate: AiProviderModelListItem[] = [];
const toUpdate: {
modelId: number;
modelKey: string;
@@ -159,7 +161,7 @@ export default function AiProviderModelsPage() {
continue;
}
toCreate.push({ modelKey, listType });
toCreate.push(item);
}
const toDelete = existing
@@ -167,13 +169,21 @@ export default function AiProviderModelsPage() {
.map((model) => model.modelId);
await Promise.all([
...toCreate.map(({ modelKey, listType }) =>
api.put(`/ai-provider/${provider.providerId}/model`, {
modelKey,
name: modelKey,
listType
})
),
...toCreate.map(async (item) => {
const res = await api.put<
AxiosResponse<CreateOrEditAiModelResponse>
>(`/ai-provider/${provider.providerId}/model`, {
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 }) =>
api.post(`/ai-model/${modelId}`, {
modelKey,
@@ -22,8 +22,8 @@ import HeaderTitle from "@app/components/SettingsSectionTitle";
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
import {
type AiProviderModelListItem,
type ModelListType
persistPendingModelBudgets,
type AiProviderModelListItem
} from "@app/components/AiProviderModelListEditor";
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
import {
@@ -59,7 +59,10 @@ import {
import { zodResolver } from "@hookform/resolvers/zod";
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
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 type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
@@ -157,14 +160,22 @@ export default function CreateAiProviderPage() {
async function createModels(
providerId: number,
items: { modelKey: string; listType: ModelListType }[]
items: AiProviderModelListItem[]
) {
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,
name: item.modelKey,
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]
.map((item) => ({
modelKey: item.modelKey.trim(),
listType: item.listType
...item,
modelKey: item.modelKey.trim()
}))
.filter((item) => item.modelKey);
+57 -126
View File
@@ -34,12 +34,6 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { cn } from "@app/lib/cn";
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
@@ -56,16 +50,8 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Asterisk,
BookMarked,
Check,
Globe,
Pencil,
Plus,
Wallet,
XIcon
} from "lucide-react";
import type { AxiosInstance } from "axios";
import { Globe, Plus, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -78,6 +64,7 @@ export type ModelSource = "catalog" | "custom" | "pattern" | "all";
export const ALL_MODELS_KEY = "*";
const COLLAPSED_ROWS = 5;
const GRID_COLUMNS = 2;
export type AiProviderModelListItem = {
clientId: string;
@@ -85,8 +72,32 @@ export type AiProviderModelListItem = {
modelKey: string;
listType: ModelListType;
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 = {
orgId: string;
listType: ModelListType;
@@ -138,27 +149,6 @@ function parseBulkKeys(raw: string): string[] {
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({
orgId,
listType,
@@ -178,8 +168,7 @@ export function AiProviderModelListEditor({
const [listExpanded, setListExpanded] = useState(false);
const [clipHeight, setClipHeight] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const columns = useModelGridColumns();
const collapsedLimit = columns * COLLAPSED_ROWS;
const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS;
const hasOverflow = items.length > collapsedLimit;
const isCollapsed = hasOverflow && !listExpanded;
@@ -358,7 +347,7 @@ export function AiProviderModelListEditor({
<div
ref={gridRef}
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"
)}
style={
@@ -672,24 +661,6 @@ function ModelCard({
}) {
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 (
<div
className={cn(
@@ -714,55 +685,15 @@ function ModelCard({
>
<div className="min-w-0 flex-1">
{source === "all" ? (
<>
<span className="block truncate text-xs font-medium">
{t("aiProviderModelsAllLabel")}
</span>
</>
<span className="block truncate text-xs font-medium">
{t("aiProviderModelsAllLabel")}
</span>
) : (
<span className="block truncate font-mono text-xs font-medium">
{item.modelKey}
</span>
)}
</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
type="button"
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;
form.reset({ modelKey: item.modelKey });
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(() => {
if (!open || !budgetsQuery.data) return;
@@ -896,7 +839,9 @@ function EditModelCredenza({
onSave({
...item,
modelKey: values.modelKey.trim()
modelKey: values.modelKey.trim(),
pendingBudgets: pendingBudgetRows,
hasBudget: pendingBudgetRows.length > 0
});
}
@@ -953,29 +898,15 @@ function EditModelCredenza({
/>
</div>
<div className="space-y-4 mt-4">
{budgetScope ? (
<>
<p className="text-sm text-muted-foreground">
{t(
"aiProviderModelsBudgetDescription"
)}
</p>
<BudgetRowsFields
rows={pendingBudgetRows}
onChange={setPendingBudgetRows}
disabled={
budgetsQuery.isLoading
}
attemptedSave={
attemptedBudgetsSave
}
/>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("aiProviderModelsBudgetUnsaved")}
</p>
)}
<BudgetRowsFields
rows={pendingBudgetRows}
onChange={setPendingBudgetRows}
disabled={
budgetScope !== null &&
budgetsQuery.isLoading
}
attemptedSave={attemptedBudgetsSave}
/>
</div>
</HorizontalTabs>
</CredenzaBody>