"use client"; import { closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVerticalIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; import { toast } from "sonner"; import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar, } from "@/components/dashboard-page"; import { ListPagination } from "@/components/list-pagination"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { createAIConfig, deleteAIConfig, fetchAIConfigs, updateAIConfig, updateAIConfigSort, updateAIConfigStatus, type AIConfig, type CreateAIConfigPayload, type PageResult, } from "@/lib/api/admin"; import { AIModelType, AIProvider, Status, } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider"; import { cn } from "@/lib/utils"; import { EditDialog } from "./_components/edit"; import { OptionCombobox } from "./_components/option-combobox"; type TFunction = (key: string, values?: Record) => string; function getStatusOptions(t: TFunction) { return [ { value: "all", label: t("aiConfig.allStatuses") }, { value: String(Status.Ok), label: t("aiConfig.enabled") }, { value: String(Status.Disabled), label: t("aiConfig.disabled") }, { value: String(Status.Deleted), label: t("aiConfig.deletedStatus") }, ]; } function getProviderOptions(t: TFunction, includeAll = true) { const options = [ { value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }, ]; return includeAll ? [{ value: "all", label: t("aiConfig.allProviders") }, ...options] : options; } function getModelTypeOptions(t: TFunction, includeAll = true) { const options = [ { value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") }, { value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") }, { value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") }, ]; return includeAll ? [{ value: "all", label: t("aiConfig.allTypes") }, ...options] : options; } function getStatusLabel(value: Status, t: TFunction) { return getStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value); } function getProviderLabel(value: AIProvider, t: TFunction) { return getProviderOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value); } function getModelTypeLabel(value: AIModelType, t: TFunction) { return getModelTypeOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value); } function maskAPIKey(value: string) { const text = value.trim(); if (!text) { return "-"; } if (text.length <= 8) { return "****"; } return `${text.slice(0, 4)}****${text.slice(-4)}`; } type SortableAIConfigRowProps = { item: AIConfig; disabled: boolean; actionLoadingId: number | null; t: TFunction; openEditDialog: (item: AIConfig) => void; handleToggleStatus: (item: AIConfig) => void; handleDelete: (item: AIConfig) => void; }; function SortableAIConfigRow({ item, disabled, actionLoadingId, t, openEditDialog, handleToggleStatus, handleDelete, }: SortableAIConfigRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item.id, disabled, }); const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, }; return (
{item.name}
{getProviderLabel(item.provider as AIProvider, t)}
{getModelTypeLabel(item.modelType as AIModelType, t)}
{item.modelName}
{item.dimension > 0 && (
{t("aiConfig.dimension", { count: item.dimension })}
)}
{item.baseUrl}
{t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })}
{t("aiConfig.contextTokens", { count: item.maxContextTokens || 0 })}
{t("aiConfig.outputTokens", { count: item.maxOutputTokens || 0 })}
{t("aiConfig.timeoutRetry", { timeout: item.timeoutMs, retries: item.maxRetryCount, })}
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
void handleToggleStatus(item)} aria-label={t("aiConfig.toggleStatus", { name: item.name })} /> {getStatusLabel(item.status as Status, t)}
} aria-label={t("aiConfig.moreActions", { name: item.name })} > void handleDelete(item)} className="text-destructive focus:text-destructive" > {item.status === Status.Ok ? t("aiConfig.deleteDisabledActive") : actionLoadingId === item.id ? t("aiConfig.deleting") : t("aiConfig.delete")}
); } export default function DashboardAIConfigsPage() { const t = useI18n(); const listStatusOptions = useMemo(() => getStatusOptions(t), [t]); const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]); const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]); const [keywordInput, setKeywordInput] = useState(""); const [statusFilterInput, setStatusFilterInput] = useState("all"); const [providerFilterInput, setProviderFilterInput] = useState("all"); const [modelTypeFilterInput, setModelTypeFilterInput] = useState("all"); const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [providerFilter, setProviderFilter] = useState("all"); const [modelTypeFilter, setModelTypeFilter] = useState("all"); const [page, setPage] = useState(1); const [limit, setLimit] = useState(20); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [actionLoadingId, setActionLoadingId] = useState(null); const [sorting, setSorting] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletingItem, setDeletingItem] = useState(null); const [result, setResult] = useState>({ results: [], page: { page: 1, limit: 20, total: 0 }, }); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const loadData = useCallback(async () => { setLoading(true); try { const data = await fetchAIConfigs({ name: keyword.trim() || undefined, status: statusFilter === "all" ? undefined : statusFilter, provider: providerFilter === "all" ? undefined : providerFilter, modelType: modelTypeFilter === "all" ? undefined : modelTypeFilter, page, limit, }); setResult(data); } catch (error) { toast.error(error instanceof Error ? error.message : t("aiConfig.loadFailed")); } finally { setLoading(false); } }, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit, t]); useEffect(() => { void loadData(); }, [loadData]); function applyFilters() { setKeyword(keywordInput); setStatusFilter(statusFilterInput); setProviderFilter(providerFilterInput); setModelTypeFilter(modelTypeFilterInput); setPage(1); } function handleFilterKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") { return; } event.preventDefault(); applyFilters(); } function handlePageChange(nextPage: number) { if (nextPage < 1 || nextPage === page) { return; } setPage(nextPage); } function handleLimitChange(nextLimit: number) { if (nextLimit <= 0 || nextLimit === limit) { return; } setLimit(nextLimit); setPage(1); } function openCreateDialog() { setEditingItem(null); setDialogOpen(true); } function openEditDialog(item: AIConfig) { setEditingItem(item); setDialogOpen(true); } function handleDialogOpenChange(open: boolean) { if (saving) { return; } if (!open) { setEditingItem(null); } setDialogOpen(open); } async function handleSubmit(payload: CreateAIConfigPayload) { if (saving) { return; } setSaving(true); try { if (editingItem) { await updateAIConfig({ id: editingItem.id, ...payload }); toast.success(t("aiConfig.updated", { name: editingItem.name })); } else { await createAIConfig(payload); toast.success(t("aiConfig.created", { name: payload.name })); } setDialogOpen(false); setEditingItem(null); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("aiConfig.saveFailed")); } finally { setSaving(false); } } async function handleToggleStatus(item: AIConfig) { setActionLoadingId(item.id); try { const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok; await updateAIConfigStatus(item.id, nextStatus); toast.success( t("aiConfig.statusChanged", { name: item.name, status: nextStatus === Status.Ok ? t("aiConfig.enabled") : t("aiConfig.disabled"), }), ); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("aiConfig.statusUpdateFailed")); } finally { setActionLoadingId(null); } } async function handleDelete(item: AIConfig) { if (item.status === Status.Ok) { toast.error(t("aiConfig.activeDeleteBlocked")); return; } setDeletingItem(item); setDeleteDialogOpen(true); } async function handleConfirmDelete() { if (!deletingItem) { return; } const item = deletingItem; setActionLoadingId(item.id); try { await deleteAIConfig(item.id); toast.success(t("aiConfig.deleted", { name: item.name })); setDeleteDialogOpen(false); setDeletingItem(null); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("aiConfig.deleteFailed")); } finally { setActionLoadingId(null); } } async function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id || sorting) { return; } const previousResults = result.results; const oldIndex = previousResults.findIndex((item) => item.id === active.id); const newIndex = previousResults.findIndex((item) => item.id === over.id); if (oldIndex < 0 || newIndex < 0) { return; } const nextResults = arrayMove(previousResults, oldIndex, newIndex); setResult((current) => ({ ...current, results: nextResults, })); setSorting(true); try { await updateAIConfigSort(nextResults.map((item) => item.id)); toast.success(t("aiConfig.sortUpdated")); await loadData(); } catch (error) { setResult((current) => ({ ...current, results: previousResults, })); toast.error(error instanceof Error ? error.message : t("aiConfig.sortUpdateFailed")); } finally { setSorting(false); } } return ( <> } >
setKeywordInput(event.target.value)} onKeyDown={handleFilterKeyDown} placeholder={t("aiConfig.filterName")} className="pl-9" />
} > {t("aiConfig.columnConfig")} {t("aiConfig.columnProvider")} {t("aiConfig.columnModel")} {t("aiConfig.columnAccess")} {t("aiConfig.columnLimits")} {t("aiConfig.columnStatus")} {t("aiConfig.columnActions")} {loading || result.results.length === 0 ? ( ) : ( item.id)} strategy={verticalListSortingStrategy} > {result.results.map((item) => ( ))} )}
{ if (actionLoadingId) { return; } setDeleteDialogOpen(open); if (!open) { setDeletingItem(null); } }} > {t("aiConfig.confirmDeleteTitle")} {deletingItem ? t("aiConfig.confirmDeleteDescription", { name: deletingItem.name }) : t("aiConfig.deleteIrreversible")} ); }