mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-11 15:08:36 +02:00
add richer model editor on provider
This commit is contained in:
+40
-5
@@ -1779,14 +1779,49 @@
|
|||||||
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||||
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
||||||
"aiProviderModels": "Models",
|
"aiProviderModels": "Models",
|
||||||
"aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Pick from known models for this provider, or type a custom key or wildcard (for example gpt-4* or claude-?). An empty allow list denies all models.",
|
"aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.",
|
||||||
"aiProviderModelsPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)",
|
"aiProviderModelsPlaceholder": "Search models or type a custom key",
|
||||||
"aiProviderModelsAllow": "Allow List",
|
"aiProviderModelsAllow": "Allow List",
|
||||||
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
|
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
|
||||||
"aiProviderModelsAllowPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)",
|
"aiProviderModelsAllowPlaceholder": "Enter model key",
|
||||||
|
"aiProviderModelsAllowEmpty": "No models allowed. All requests will be denied.",
|
||||||
"aiProviderModelsBlock": "Block List",
|
"aiProviderModelsBlock": "Block List",
|
||||||
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.",
|
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.",
|
||||||
"aiProviderModelsBlockPlaceholder": "Select a model or type a pattern (e.g. gpt-4o-mini)",
|
"aiProviderModelsBlockPlaceholder": "Enter model key",
|
||||||
|
"aiProviderModelsBlockEmpty": "No blocked models.",
|
||||||
|
"aiProviderModelsAdd": "Add Models",
|
||||||
|
"aiProviderModelsClearAll": "Clear All",
|
||||||
|
"aiProviderModelsAddCustom": "Add \"{key}\"",
|
||||||
|
"aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.",
|
||||||
|
"aiProviderModelsAddBulk": "Add {count} custom keys",
|
||||||
|
"aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.",
|
||||||
|
"aiProviderModelsAddOne": "Add {key} now",
|
||||||
|
"aiProviderModelsAddSelected": "Add Selected",
|
||||||
|
"aiProviderModelsSelectedCount": "{count} selected",
|
||||||
|
"aiProviderModelsSelectAll": "Select all",
|
||||||
|
"aiProviderModelsClearSelected": "Clear",
|
||||||
|
"aiProviderModelsBulkHint": "Select known models or type a custom key.",
|
||||||
|
"aiProviderModelsCatalogEmpty": "No matching catalog models.",
|
||||||
|
"aiProviderModelsCatalogHeading": "Known Models",
|
||||||
|
"aiProviderModelsAllLabel": "All models",
|
||||||
|
"aiProviderModelsAllPatternHint": "Wildcard: *",
|
||||||
|
"aiProviderModelsAddAllAllow": "Allow all models",
|
||||||
|
"aiProviderModelsAddAllBlock": "Block all models",
|
||||||
|
"aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.",
|
||||||
|
"aiProviderModelsViewMore": "View more ({count})",
|
||||||
|
"aiProviderModelsViewLess": "View less",
|
||||||
|
"aiProviderModelsRemove": "Remove model",
|
||||||
|
"aiProviderModelsEditHint": "Click to edit model settings",
|
||||||
|
"aiProviderModelsSourceCatalog": "Known catalog model",
|
||||||
|
"aiProviderModelsSourceCustom": "Custom model key",
|
||||||
|
"aiProviderModelsSourcePattern": "Wildcard pattern",
|
||||||
|
"aiProviderModelsSourceAll": "Matches every model key",
|
||||||
|
"aiProviderModelsBudgetConfigured": "Budget configured",
|
||||||
|
"aiProviderModelsEditTitle": "Edit Model",
|
||||||
|
"aiProviderModelsEditDescription": "Update the model key. Additional settings such as budgets will appear here later.",
|
||||||
|
"aiProviderModelsKeyLabel": "Model Key",
|
||||||
|
"aiProviderModelsKeyRequired": "Enter a model key",
|
||||||
|
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
|
||||||
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
|
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
|
||||||
"aiProviderModelsUpdated": "Models updated",
|
"aiProviderModelsUpdated": "Models updated",
|
||||||
"aiProviderModelsErrorUpdate": "Failed to update models",
|
"aiProviderModelsErrorUpdate": "Failed to update models",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
import {
|
||||||
|
AiProviderModelListEditor,
|
||||||
|
type AiProviderModelListItem,
|
||||||
|
type ModelListType
|
||||||
|
} from "@app/components/AiProviderModelListEditor";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||||
@@ -22,7 +26,22 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
type ModelListType = "allow" | "block";
|
function toListItem(
|
||||||
|
model: {
|
||||||
|
modelId: number;
|
||||||
|
modelKey: string;
|
||||||
|
listType?: ModelListType | null;
|
||||||
|
},
|
||||||
|
listType: ModelListType
|
||||||
|
): AiProviderModelListItem {
|
||||||
|
return {
|
||||||
|
clientId: String(model.modelId),
|
||||||
|
modelId: model.modelId,
|
||||||
|
modelKey: model.modelKey,
|
||||||
|
listType,
|
||||||
|
hasBudget: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AiProviderModelsPage() {
|
export default function AiProviderModelsPage() {
|
||||||
const { provider } = useAiProviderContext();
|
const { provider } = useAiProviderContext();
|
||||||
@@ -31,14 +50,8 @@ export default function AiProviderModelsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [allowTags, setAllowTags] = useState<Tag[]>([]);
|
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
|
||||||
const [blockTags, setBlockTags] = useState<Tag[]>([]);
|
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
|
||||||
const [activeAllowTagIndex, setActiveAllowTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
const [activeBlockTagIndex, setActiveBlockTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const modelsQuery = useQuery(
|
const modelsQuery = useQuery(
|
||||||
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
||||||
@@ -47,32 +60,31 @@ export default function AiProviderModelsPage() {
|
|||||||
aiProviderQueries.catalogModels({ providerId: provider.providerId })
|
aiProviderQueries.catalogModels({ providerId: provider.providerId })
|
||||||
);
|
);
|
||||||
|
|
||||||
const catalogTags = useMemo(
|
const catalogModels = useMemo(
|
||||||
() =>
|
() => (catalogQuery.data ?? []).map((entry) => entry.model),
|
||||||
(catalogQuery.data ?? []).map((entry) => ({
|
|
||||||
id: entry.model,
|
|
||||||
text: entry.model
|
|
||||||
})),
|
|
||||||
[catalogQuery.data]
|
[catalogQuery.data]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const allowExcludeKeys = useMemo(
|
||||||
|
() => new Set(blockItems.map((item) => item.modelKey)),
|
||||||
|
[blockItems]
|
||||||
|
);
|
||||||
|
const blockExcludeKeys = useMemo(
|
||||||
|
() => new Set(allowItems.map((item) => item.modelKey)),
|
||||||
|
[allowItems]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelsQuery.data) return;
|
if (!modelsQuery.data) return;
|
||||||
setAllowTags(
|
setAllowItems(
|
||||||
modelsQuery.data
|
modelsQuery.data
|
||||||
.filter((model) => (model.listType ?? "allow") === "allow")
|
.filter((model) => (model.listType ?? "allow") === "allow")
|
||||||
.map((model) => ({
|
.map((model) => toListItem(model, "allow"))
|
||||||
id: String(model.modelId),
|
|
||||||
text: model.modelKey
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
setBlockTags(
|
setBlockItems(
|
||||||
modelsQuery.data
|
modelsQuery.data
|
||||||
.filter((model) => model.listType === "block")
|
.filter((model) => model.listType === "block")
|
||||||
.map((model) => ({
|
.map((model) => toListItem(model, "block"))
|
||||||
id: String(model.modelId),
|
|
||||||
text: model.modelKey
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
}, [modelsQuery.data]);
|
}, [modelsQuery.data]);
|
||||||
|
|
||||||
@@ -80,15 +92,23 @@ export default function AiProviderModelsPage() {
|
|||||||
setSaveLoading(true);
|
setSaveLoading(true);
|
||||||
try {
|
try {
|
||||||
const existing = modelsQuery.data ?? [];
|
const existing = modelsQuery.data ?? [];
|
||||||
|
const existingById = new Map(
|
||||||
|
existing.map((model) => [model.modelId, model])
|
||||||
|
);
|
||||||
const existingByKey = new Map(
|
const existingByKey = new Map(
|
||||||
existing.map((model) => [model.modelKey, model])
|
existing.map((model) => [model.modelKey, model])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const desiredItems = [...allowItems, ...blockItems].map((item) => ({
|
||||||
|
...item,
|
||||||
|
modelKey: item.modelKey.trim()
|
||||||
|
}));
|
||||||
|
|
||||||
const nextAllow = new Set(
|
const nextAllow = new Set(
|
||||||
allowTags.map((tag) => tag.text.trim()).filter(Boolean)
|
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||||
);
|
);
|
||||||
const nextBlock = new Set(
|
const nextBlock = new Set(
|
||||||
blockTags.map((tag) => tag.text.trim()).filter(Boolean)
|
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
|
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
|
||||||
@@ -103,41 +123,58 @@ export default function AiProviderModelsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const desired = new Map<string, ModelListType>();
|
|
||||||
for (const key of nextAllow) {
|
|
||||||
desired.set(key, "allow");
|
|
||||||
}
|
|
||||||
for (const key of nextBlock) {
|
|
||||||
desired.set(key, "block");
|
|
||||||
}
|
|
||||||
|
|
||||||
const toCreate: { modelKey: string; listType: ModelListType }[] =
|
const toCreate: { modelKey: string; listType: ModelListType }[] =
|
||||||
[];
|
[];
|
||||||
const toUpdate: {
|
const toUpdate: {
|
||||||
modelId: number;
|
modelId: number;
|
||||||
|
modelKey: string;
|
||||||
listType: ModelListType;
|
listType: ModelListType;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const toDelete: number[] = [];
|
const retainedIds = new Set<number>();
|
||||||
|
|
||||||
for (const [modelKey, listType] of desired) {
|
for (const item of desiredItems) {
|
||||||
const existingModel = existingByKey.get(modelKey);
|
const listType = item.listType;
|
||||||
if (!existingModel) {
|
const modelKey = item.modelKey;
|
||||||
toCreate.push({ modelKey, listType });
|
if (!modelKey) continue;
|
||||||
|
|
||||||
|
if (item.modelId != null && existingById.has(item.modelId)) {
|
||||||
|
retainedIds.add(item.modelId);
|
||||||
|
const existingModel = existingById.get(item.modelId)!;
|
||||||
|
if (
|
||||||
|
existingModel.modelKey !== modelKey ||
|
||||||
|
(existingModel.listType ?? "allow") !== listType
|
||||||
|
) {
|
||||||
|
toUpdate.push({
|
||||||
|
modelId: item.modelId,
|
||||||
|
modelKey,
|
||||||
|
listType
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((existingModel.listType ?? "allow") !== listType) {
|
|
||||||
toUpdate.push({
|
const existingBySameKey = existingByKey.get(modelKey);
|
||||||
modelId: existingModel.modelId,
|
if (
|
||||||
listType
|
existingBySameKey &&
|
||||||
});
|
!retainedIds.has(existingBySameKey.modelId)
|
||||||
|
) {
|
||||||
|
retainedIds.add(existingBySameKey.modelId);
|
||||||
|
if ((existingBySameKey.listType ?? "allow") !== listType) {
|
||||||
|
toUpdate.push({
|
||||||
|
modelId: existingBySameKey.modelId,
|
||||||
|
modelKey,
|
||||||
|
listType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toCreate.push({ modelKey, listType });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const model of existing) {
|
const toDelete = existing
|
||||||
if (!desired.has(model.modelKey)) {
|
.filter((model) => !retainedIds.has(model.modelId))
|
||||||
toDelete.push(model.modelId);
|
.map((model) => model.modelId);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...toCreate.map(({ modelKey, listType }) =>
|
...toCreate.map(({ modelKey, listType }) =>
|
||||||
@@ -147,8 +184,12 @@ export default function AiProviderModelsPage() {
|
|||||||
listType
|
listType
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
...toUpdate.map(({ modelId, listType }) =>
|
...toUpdate.map(({ modelId, modelKey, listType }) =>
|
||||||
api.post(`/ai-model/${modelId}`, { listType })
|
api.post(`/ai-model/${modelId}`, {
|
||||||
|
modelKey,
|
||||||
|
name: modelKey,
|
||||||
|
listType
|
||||||
|
})
|
||||||
),
|
),
|
||||||
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
|
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
|
||||||
]);
|
]);
|
||||||
@@ -177,8 +218,6 @@ export default function AiProviderModelsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputsDisabled = modelsQuery.isLoading || saveLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
@@ -192,30 +231,20 @@ export default function AiProviderModelsPage() {
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("aiProviderModelsAllow")}</Label>
|
<Label>{t("aiProviderModelsAllow")}</Label>
|
||||||
<TagInput
|
<AiProviderModelListEditor
|
||||||
activeTagIndex={activeAllowTagIndex}
|
listType="allow"
|
||||||
setActiveTagIndex={setActiveAllowTagIndex}
|
items={allowItems}
|
||||||
placeholder={t(
|
onChange={setAllowItems}
|
||||||
|
catalogModels={catalogModels}
|
||||||
|
excludeKeys={allowExcludeKeys}
|
||||||
|
disabled={modelsQuery.isLoading}
|
||||||
|
emptyMessage={t("aiProviderModelsAllowEmpty")}
|
||||||
|
addPlaceholder={t(
|
||||||
"aiProviderModelsAllowPlaceholder"
|
"aiProviderModelsAllowPlaceholder"
|
||||||
)}
|
)}
|
||||||
size="sm"
|
|
||||||
tags={allowTags}
|
|
||||||
setTags={(newTags) => {
|
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(allowTags)
|
|
||||||
: newTags;
|
|
||||||
setAllowTags(next as Tag[]);
|
|
||||||
}}
|
|
||||||
enableAutocomplete={catalogTags.length > 0}
|
|
||||||
autocompleteOptions={catalogTags}
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags
|
|
||||||
delimiterList={[",", "Enter"]}
|
|
||||||
disabled={inputsDisabled}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("aiProviderModelsAllowDescription")}
|
{t("aiProviderModelsAllowDescription")}
|
||||||
@@ -224,27 +253,17 @@ export default function AiProviderModelsPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("aiProviderModelsBlock")}</Label>
|
<Label>{t("aiProviderModelsBlock")}</Label>
|
||||||
<TagInput
|
<AiProviderModelListEditor
|
||||||
activeTagIndex={activeBlockTagIndex}
|
listType="block"
|
||||||
setActiveTagIndex={setActiveBlockTagIndex}
|
items={blockItems}
|
||||||
placeholder={t(
|
onChange={setBlockItems}
|
||||||
|
catalogModels={catalogModels}
|
||||||
|
excludeKeys={blockExcludeKeys}
|
||||||
|
disabled={modelsQuery.isLoading}
|
||||||
|
emptyMessage={t("aiProviderModelsBlockEmpty")}
|
||||||
|
addPlaceholder={t(
|
||||||
"aiProviderModelsBlockPlaceholder"
|
"aiProviderModelsBlockPlaceholder"
|
||||||
)}
|
)}
|
||||||
size="sm"
|
|
||||||
tags={blockTags}
|
|
||||||
setTags={(newTags) => {
|
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(blockTags)
|
|
||||||
: newTags;
|
|
||||||
setBlockTags(next as Tag[]);
|
|
||||||
}}
|
|
||||||
enableAutocomplete={catalogTags.length > 0}
|
|
||||||
autocompleteOptions={catalogTags}
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags
|
|
||||||
delimiterList={[",", "Enter"]}
|
|
||||||
disabled={inputsDisabled}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("aiProviderModelsBlockDescription")}
|
{t("aiProviderModelsBlockDescription")}
|
||||||
|
|||||||
@@ -0,0 +1,875 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Asterisk,
|
||||||
|
BookMarked,
|
||||||
|
Check,
|
||||||
|
Globe,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Wallet,
|
||||||
|
XIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type ModelListType = "allow" | "block";
|
||||||
|
export type ModelSource = "catalog" | "custom" | "pattern" | "all";
|
||||||
|
|
||||||
|
/** Matches every model key via the provider policy wildcard. */
|
||||||
|
export const ALL_MODELS_KEY = "*";
|
||||||
|
|
||||||
|
const COLLAPSED_ROWS = 5;
|
||||||
|
|
||||||
|
export type AiProviderModelListItem = {
|
||||||
|
clientId: string;
|
||||||
|
modelId?: number;
|
||||||
|
modelKey: string;
|
||||||
|
listType: ModelListType;
|
||||||
|
hasBudget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiProviderModelListEditorProps = {
|
||||||
|
listType: ModelListType;
|
||||||
|
items: AiProviderModelListItem[];
|
||||||
|
catalogModels: string[];
|
||||||
|
/** Keys already used on this list or the sibling list. */
|
||||||
|
excludeKeys?: ReadonlySet<string>;
|
||||||
|
onChange: (items: AiProviderModelListItem[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
addPlaceholder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAllModelsKey(modelKey: string): boolean {
|
||||||
|
return modelKey.trim() === ALL_MODELS_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveModelSource(
|
||||||
|
modelKey: string,
|
||||||
|
catalogModels: ReadonlySet<string> | readonly string[]
|
||||||
|
): ModelSource {
|
||||||
|
if (isAllModelsKey(modelKey)) {
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
if (isModelKeyPattern(modelKey)) {
|
||||||
|
return "pattern";
|
||||||
|
}
|
||||||
|
const set =
|
||||||
|
catalogModels instanceof Set ? catalogModels : new Set(catalogModels);
|
||||||
|
return set.has(modelKey) ? "catalog" : "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function newClientId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBulkKeys(raw: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const part of raw.split(/[\n,]+/)) {
|
||||||
|
const key = part.trim();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
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({
|
||||||
|
listType,
|
||||||
|
items,
|
||||||
|
catalogModels,
|
||||||
|
excludeKeys,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
emptyMessage,
|
||||||
|
addPlaceholder
|
||||||
|
}: AiProviderModelListEditorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addQuery, setAddQuery] = useState("");
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
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 hasOverflow = items.length > collapsedLimit;
|
||||||
|
const isCollapsed = hasOverflow && !listExpanded;
|
||||||
|
|
||||||
|
const catalogSet = useMemo(() => new Set(catalogModels), [catalogModels]);
|
||||||
|
|
||||||
|
const blockedKeys = useMemo(() => {
|
||||||
|
const set = new Set<string>(excludeKeys ? [...excludeKeys] : []);
|
||||||
|
for (const item of items) {
|
||||||
|
set.add(item.modelKey);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [excludeKeys, items]);
|
||||||
|
|
||||||
|
const availableCatalog = useMemo(() => {
|
||||||
|
const q = addQuery.trim().toLowerCase();
|
||||||
|
return catalogModels
|
||||||
|
.filter((model) => !blockedKeys.has(model))
|
||||||
|
.filter((model) => (q ? model.toLowerCase().includes(q) : true));
|
||||||
|
}, [addQuery, blockedKeys, catalogModels]);
|
||||||
|
|
||||||
|
const trimmedQuery = addQuery.trim();
|
||||||
|
const bulkKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
parseBulkKeys(addQuery).filter(
|
||||||
|
(key) => !blockedKeys.has(key) && !catalogSet.has(key)
|
||||||
|
),
|
||||||
|
[addQuery, blockedKeys, catalogSet]
|
||||||
|
);
|
||||||
|
const canAddCustom =
|
||||||
|
bulkKeys.length === 1 &&
|
||||||
|
!trimmedQuery.includes("\n") &&
|
||||||
|
!trimmedQuery.includes(",") &&
|
||||||
|
!isAllModelsKey(trimmedQuery) &&
|
||||||
|
!catalogSet.has(trimmedQuery) &&
|
||||||
|
!blockedKeys.has(trimmedQuery);
|
||||||
|
const canAddBulkCustom = bulkKeys.length > 1;
|
||||||
|
|
||||||
|
const allModelsLabel = t("aiProviderModelsAllLabel");
|
||||||
|
const showAllModelsOption =
|
||||||
|
!blockedKeys.has(ALL_MODELS_KEY) &&
|
||||||
|
(!trimmedQuery ||
|
||||||
|
trimmedQuery === ALL_MODELS_KEY ||
|
||||||
|
"all".includes(trimmedQuery.toLowerCase()) ||
|
||||||
|
allModelsLabel.toLowerCase().includes(trimmedQuery.toLowerCase()));
|
||||||
|
|
||||||
|
const editing = items.find((item) => item.clientId === editingClientId);
|
||||||
|
|
||||||
|
function appendModels(modelKeys: string[]) {
|
||||||
|
if (disabled) return;
|
||||||
|
const nextBlocked = new Set(blockedKeys);
|
||||||
|
const additions: AiProviderModelListItem[] = [];
|
||||||
|
for (const raw of modelKeys) {
|
||||||
|
const key = raw.trim();
|
||||||
|
if (!key || nextBlocked.has(key)) continue;
|
||||||
|
nextBlocked.add(key);
|
||||||
|
additions.push({
|
||||||
|
clientId: newClientId(),
|
||||||
|
modelKey: key,
|
||||||
|
listType,
|
||||||
|
hasBudget: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
onChange([...items, ...additions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModel(modelKey: string, options?: { keepOpen?: boolean }) {
|
||||||
|
appendModels([modelKey]);
|
||||||
|
setAddQuery("");
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
if (!options?.keepOpen) {
|
||||||
|
setAddOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSelected() {
|
||||||
|
appendModels([...selectedKeys]);
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
setAddQuery("");
|
||||||
|
// Keep open so more can be selected after filter refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBulkCustom() {
|
||||||
|
appendModels(bulkKeys);
|
||||||
|
setAddQuery("");
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(model: string) {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(model)) {
|
||||||
|
next.delete(model);
|
||||||
|
} else {
|
||||||
|
next.add(model);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllVisible() {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const model of availableCatalog) {
|
||||||
|
next.add(model);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelected() {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeModel(clientId: string) {
|
||||||
|
onChange(items.filter((item) => item.clientId !== clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModel(updated: AiProviderModelListItem) {
|
||||||
|
onChange(
|
||||||
|
items.map((item) =>
|
||||||
|
item.clientId === updated.clientId ? updated : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setEditingClientId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop selections that are no longer available (already added).
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const key of prev) {
|
||||||
|
if (blockedKeys.has(key)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [blockedKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasOverflow) {
|
||||||
|
setListExpanded(false);
|
||||||
|
}
|
||||||
|
}, [hasOverflow]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isCollapsed || !gridRef.current) {
|
||||||
|
setClipHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = Array.from(gridRef.current.children) as HTMLElement[];
|
||||||
|
const lastVisible = children[collapsedLimit - 1];
|
||||||
|
if (!lastVisible) {
|
||||||
|
setClipHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridTop = gridRef.current.getBoundingClientRect().top;
|
||||||
|
const cardBottom = lastVisible.getBoundingClientRect().bottom;
|
||||||
|
// Peek slightly into the next row so the fade has content to soften.
|
||||||
|
setClipHeight(cardBottom - gridTop + 12);
|
||||||
|
}, [isCollapsed, collapsedLimit, items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
|
||||||
|
isCollapsed && "overflow-hidden"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isCollapsed && clipHeight != null
|
||||||
|
? { maxHeight: clipHeight }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex min-w-0 items-center justify-center rounded-md border border-dashed border-input px-2.5 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<ModelCard
|
||||||
|
key={item.clientId}
|
||||||
|
item={item}
|
||||||
|
source={resolveModelSource(
|
||||||
|
item.modelKey,
|
||||||
|
catalogSet
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
onEdit={() =>
|
||||||
|
setEditingClientId(item.clientId)
|
||||||
|
}
|
||||||
|
onRemove={() => removeModel(item.clientId)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card from-25% via-card/80 to-transparent" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="relative z-10 flex justify-center pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="bg-card px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setListExpanded(true)}
|
||||||
|
>
|
||||||
|
{t("aiProviderModelsViewMore", {
|
||||||
|
count: items.length - collapsedLimit
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasOverflow && listExpanded ? (
|
||||||
|
<div className="flex justify-center pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setListExpanded(false)}
|
||||||
|
>
|
||||||
|
{t("aiProviderModelsViewLess")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Popover
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setAddOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setAddQuery("");
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t("aiProviderModelsAdd")}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
collisionPadding={8}
|
||||||
|
className="flex w-[min(100vw-2rem,24rem)] max-h-[min(24rem,var(--radix-popover-content-available-height))] flex-col overflow-hidden p-0"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={addPlaceholder}
|
||||||
|
value={addQuery}
|
||||||
|
onValueChange={setAddQuery}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
if (canAddBulkCustom) {
|
||||||
|
e.preventDefault();
|
||||||
|
addBulkCustom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canAddCustom) {
|
||||||
|
e.preventDefault();
|
||||||
|
addModel(trimmedQuery, {
|
||||||
|
keepOpen: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("aiProviderModelsBulkHint")}
|
||||||
|
</p>
|
||||||
|
{availableCatalog.length > 0 ? (
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-1 text-xs"
|
||||||
|
onClick={selectAllVisible}
|
||||||
|
>
|
||||||
|
{t("aiProviderModelsSelectAll")}
|
||||||
|
</Button>
|
||||||
|
{selectedKeys.size > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-1 text-xs"
|
||||||
|
onClick={clearSelected}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"aiProviderModelsClearSelected"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty>
|
||||||
|
{canAddBulkCustom
|
||||||
|
? t("aiProviderModelsAddBulkHint", {
|
||||||
|
count: bulkKeys.length
|
||||||
|
})
|
||||||
|
: canAddCustom
|
||||||
|
? t("aiProviderModelsAddCustomHint")
|
||||||
|
: t("aiProviderModelsCatalogEmpty")}
|
||||||
|
</CommandEmpty>
|
||||||
|
{showAllModelsOption ? (
|
||||||
|
<CommandGroup className="overflow-visible">
|
||||||
|
<CommandItem
|
||||||
|
value={`all:${ALL_MODELS_KEY}`}
|
||||||
|
onSelect={() =>
|
||||||
|
addModel(ALL_MODELS_KEY, {
|
||||||
|
keepOpen: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="items-start gap-2 py-2"
|
||||||
|
>
|
||||||
|
<Globe className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{listType === "allow"
|
||||||
|
? t(
|
||||||
|
"aiProviderModelsAddAllAllow"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"aiProviderModelsAddAllBlock"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"aiProviderModelsAddAllDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{canAddBulkCustom ? (
|
||||||
|
<CommandGroup className="overflow-visible">
|
||||||
|
<CommandItem
|
||||||
|
value={`bulk:${bulkKeys.join(",")}`}
|
||||||
|
onSelect={addBulkCustom}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
{t("aiProviderModelsAddBulk", {
|
||||||
|
count: bulkKeys.length
|
||||||
|
})}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{canAddCustom ? (
|
||||||
|
<CommandGroup className="overflow-visible">
|
||||||
|
<CommandItem
|
||||||
|
value={`custom:${trimmedQuery}`}
|
||||||
|
onSelect={() =>
|
||||||
|
addModel(trimmedQuery, {
|
||||||
|
keepOpen: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
{t("aiProviderModelsAddCustom", {
|
||||||
|
key: trimmedQuery
|
||||||
|
})}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{availableCatalog.length > 0 ? (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t(
|
||||||
|
"aiProviderModelsCatalogHeading"
|
||||||
|
)}
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
{availableCatalog.map((model) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedKeys.has(model);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={model}
|
||||||
|
value={model}
|
||||||
|
onSelect={() => {
|
||||||
|
// Toggle selection for bulk;
|
||||||
|
// double-purpose: shift-free multi-pick.
|
||||||
|
toggleSelected(model);
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
className="pointer-events-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{model}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
{selectedKeys.size > 0 ? (
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-t p-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("aiProviderModelsSelectedCount", {
|
||||||
|
count: selectedKeys.size
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={addSelected}
|
||||||
|
>
|
||||||
|
{t("aiProviderModelsAddSelected")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
>
|
||||||
|
{t("aiProviderModelsClearAll")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditModelCredenza
|
||||||
|
item={editing}
|
||||||
|
open={editingClientId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingClientId(null);
|
||||||
|
}}
|
||||||
|
existingKeys={blockedKeys}
|
||||||
|
onSave={updateModel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelCard({
|
||||||
|
item,
|
||||||
|
source,
|
||||||
|
disabled,
|
||||||
|
onEdit,
|
||||||
|
onRemove
|
||||||
|
}: {
|
||||||
|
item: AiProviderModelListItem;
|
||||||
|
source: ModelSource;
|
||||||
|
disabled?: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
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(
|
||||||
|
"flex min-w-0 items-center gap-2 rounded-md border border-input px-2.5 py-2",
|
||||||
|
disabled && "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}
|
||||||
|
title={t("aiProviderModelsEditHint")}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{source === "all" ? (
|
||||||
|
<>
|
||||||
|
<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"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("aiProviderModelsRemove")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditFormValues = {
|
||||||
|
modelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EditModelCredenza({
|
||||||
|
item,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
existingKeys,
|
||||||
|
onSave
|
||||||
|
}: {
|
||||||
|
item: AiProviderModelListItem;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
existingKeys: ReadonlySet<string>;
|
||||||
|
onSave: (item: AiProviderModelListItem) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const editSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
modelKey: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, t("aiProviderModelsKeyRequired"))
|
||||||
|
.refine(
|
||||||
|
(key) =>
|
||||||
|
key === item.modelKey || !existingKeys.has(key),
|
||||||
|
t("aiProviderModelsKeyDuplicate")
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[existingKeys, item.modelKey, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<EditFormValues>({
|
||||||
|
resolver: zodResolver(editSchema),
|
||||||
|
defaultValues: { modelKey: item.modelKey }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.reset({ modelKey: item.modelKey });
|
||||||
|
}, [form, item.clientId, item.modelKey, open]);
|
||||||
|
|
||||||
|
function handleSubmit(values: EditFormValues) {
|
||||||
|
onSave({
|
||||||
|
...item,
|
||||||
|
modelKey: values.modelKey.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("aiProviderModelsEditTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("aiProviderModelsEditDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="ai-provider-model-edit-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
>
|
||||||
|
<CredenzaBody className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modelKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("aiProviderModelsKeyLabel")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CredenzaBody>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="ai-provider-model-edit-form">
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user