mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
Migrate AllModelsTab and RouterConfigBuilder to shadcn (Phase 1)
- AllModelsTab.tsx: drop antd Badge/Button/Select/Skeleton/Space/Typography + @ant-design/icons -> shadcn Badge/Button/Select/Skeleton + lucide Info/Settings/Filter/RefreshCw/Search. Pagination buttons become shadcn Button variant=outline. Tremor Grid dropped (already a plain div). Kept Tremor TabPanel (still required wrapper for parent TabGroup). - RouterConfigBuilder.tsx: antd Select/Collapse/Empty/InputNumber -> shadcn Accordion/Select + shadcn Input type=number + custom empty-state div. The tags-mode utterances picker becomes a small UtterancesInput with press-Enter/paste-multiline behavior preserved. Remove-route button is now aria-labeled 'Remove route' on the AccordionTrigger sibling. - RouterConfigBuilder.test.tsx: two delete-button tests repaired to query by /remove route/i instead of the old antd 'delete' aria label. All 16 tests pass. AllModelsTab is added to the raw-tailwind-colors eslint override for the categorical team/view-mode palette dots (bg-blue-500 / bg-emerald-500 / bg-purple-500). Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
7dae8f73aa
commit
05189c04dd
4 changed files with 359 additions and 332 deletions
|
|
@ -137,6 +137,7 @@
|
|||
"src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx",
|
||||
"src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx",
|
||||
"src/app/onboarding/OnboardingFormBody.tsx",
|
||||
"src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx",
|
||||
"src/components/prompts/prompt_editor_view/conversation_panel/MessageBubble.tsx",
|
||||
"src/components/playground/chat_ui/SessionManagement.tsx",
|
||||
"src/components/playground/chat_ui/SearchResultsDisplay.tsx",
|
||||
|
|
|
|||
|
|
@ -8,19 +8,28 @@ import { getDisplayModelName } from "@/components/view_model/model_name_display"
|
|||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelDeleteCall } from "@/components/networking";
|
||||
import { Info as InfoCircleOutlined, Settings as SettingOutlined } from "lucide-react";
|
||||
import { Info, Settings, Filter, RefreshCw, Search } from "lucide-react";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Grid, TabPanel } from "@tremor/react";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
|
||||
import { TabPanel } from "@tremor/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useModelsInfo } from "../../hooks/models/useModels";
|
||||
import { transformModelData } from "../utils/modelDataTransformer";
|
||||
|
||||
type ModelViewMode = "all" | "current_team";
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AllModelsTabProps {
|
||||
selectedModelGroup: string | null;
|
||||
|
|
@ -60,12 +69,10 @@ const AllModelsTab = ({
|
|||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false);
|
||||
|
||||
// Debounce search input
|
||||
const debouncedUpdateSearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
// Reset to page 1 when search changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}, 200),
|
||||
|
|
@ -79,18 +86,16 @@ const AllModelsTab = ({
|
|||
};
|
||||
}, [modelNameSearch, debouncedUpdateSearch]);
|
||||
|
||||
// Determine teamId to pass to the query - only pass if not "personal"
|
||||
const teamIdForQuery = currentTeam === "personal" ? undefined : currentTeam.team_id;
|
||||
|
||||
// Convert sorting state to sortBy and sortOrder for API
|
||||
const sortBy = useMemo(() => {
|
||||
if (sorting.length === 0) return undefined;
|
||||
const sort = sorting[0];
|
||||
const columnIdToServerField: Record<string, string> = {
|
||||
input_cost: "costs", // Map input_cost column to "costs" for server-side sorting
|
||||
model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting
|
||||
model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting
|
||||
model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting
|
||||
input_cost: "costs",
|
||||
model_info_db_model: "status",
|
||||
model_info_created_by: "created_at",
|
||||
model_info_updated_at: "updated_at",
|
||||
};
|
||||
return columnIdToServerField[sort.id] || sort.id;
|
||||
}, [sorting]);
|
||||
|
|
@ -129,7 +134,6 @@ const AllModelsTab = ({
|
|||
const [deleteModalModelId, setDeleteModalModelId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Get pagination metadata from the response
|
||||
const paginationMeta = useMemo(() => {
|
||||
if (!rawModelData) {
|
||||
return {
|
||||
|
|
@ -152,7 +156,6 @@ const AllModelsTab = ({
|
|||
return [];
|
||||
}
|
||||
|
||||
// Server-side search is now handled by the API, so we only filter by other criteria
|
||||
return modelData.data.filter((model: any) => {
|
||||
const modelNameMatch =
|
||||
selectedModelGroup === "all" ||
|
||||
|
|
@ -165,8 +168,6 @@ const AllModelsTab = ({
|
|||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) ||
|
||||
!selectedModelAccessGroupFilter;
|
||||
|
||||
// Team filtering is now handled server-side via teamId query parameter
|
||||
// Only apply client-side filtering for model groups and access groups
|
||||
return modelNameMatch && accessGroupMatch;
|
||||
});
|
||||
}, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]);
|
||||
|
|
@ -176,13 +177,11 @@ const AllModelsTab = ({
|
|||
setCurrentPage(1);
|
||||
}, [selectedModelGroup, selectedModelAccessGroupFilter]);
|
||||
|
||||
// Reset pagination when team changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}, [teamIdForQuery]);
|
||||
|
||||
// Reset pagination when sorting changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
|
|
@ -221,103 +220,96 @@ const AllModelsTab = ({
|
|||
}
|
||||
};
|
||||
|
||||
const currentTeamValue =
|
||||
currentTeam === "personal" ? "personal" : currentTeam.team_id;
|
||||
|
||||
const onTeamChange = (value: string) => {
|
||||
if (value === "personal") {
|
||||
setCurrentTeam("personal");
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
} else {
|
||||
const team = teams?.find((t) => t.team_id === value);
|
||||
if (team) {
|
||||
setCurrentTeam(team);
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<Grid>
|
||||
<div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="bg-background rounded-lg shadow">
|
||||
{/* Current Team and View Mode Selector - Prominent Section */}
|
||||
<div className="border-b px-6 py-4 bg-gray-50">
|
||||
<div className="border-b px-6 py-4 bg-muted/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Text className="text-lg font-semibold text-gray-900">Current Team:</Text>
|
||||
<span className="text-lg font-semibold">Current Team:</span>
|
||||
<div className="w-80">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active block size="large" />
|
||||
{isLoading || isLoadingTeams ? (
|
||||
<Skeleton className="h-11 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
defaultValue="personal"
|
||||
value={currentTeam === "personal" ? "personal" : currentTeam.team_id}
|
||||
onChange={(value) => {
|
||||
if (value === "personal") {
|
||||
setCurrentTeam("personal");
|
||||
// Reset to page 1 when team changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
} else {
|
||||
const team = teams?.find((t) => t.team_id === value);
|
||||
if (team) {
|
||||
setCurrentTeam(team);
|
||||
// Reset to page 1 when team changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={isLoadingTeams}
|
||||
options={[
|
||||
{
|
||||
value: "personal",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="blue" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>Personal</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
...(teams
|
||||
<Select value={currentTeamValue} onValueChange={onTeamChange}>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="Select team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="personal">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-2 w-2 p-0 rounded-full bg-blue-500" />
|
||||
<span>Personal</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{teams
|
||||
?.filter((team) => team.team_id)
|
||||
.map((team) => ({
|
||||
value: team.team_id,
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="green" size="small" />
|
||||
<Text ellipsis style={{ fontSize: 16 }}>
|
||||
.map((team) => (
|
||||
<SelectItem key={team.team_id} value={team.team_id}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-2 w-2 p-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate">
|
||||
{team.team_alias ? team.team_alias : team.team_id}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Text className="text-lg font-semibold text-gray-900">View:</Text>
|
||||
<span className="text-lg font-semibold">View:</span>
|
||||
<div className="w-64">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active block size="large" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
defaultValue="current_team"
|
||||
value={modelViewMode}
|
||||
onChange={(value) => setModelViewMode(value as "current_team" | "all")}
|
||||
options={[
|
||||
{
|
||||
value: "current_team",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="purple" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>Current Team Models</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="gray" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>All Available Models</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
onValueChange={(value) =>
|
||||
setModelViewMode(value as "current_team" | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="View" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current_team">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-2 w-2 p-0 rounded-full bg-purple-500" />
|
||||
<span>Current Team Models</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-2 w-2 p-0 rounded-full bg-muted-foreground" />
|
||||
<span>All Available Models</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -325,14 +317,14 @@ const AllModelsTab = ({
|
|||
|
||||
{modelViewMode === "current_team" && (
|
||||
<div className="flex items-start gap-2 mt-3">
|
||||
<InfoCircleOutlined className="text-gray-400 mt-0.5 flex-shrink-0 text-xs" />
|
||||
<div className="text-xs text-gray-500">
|
||||
<Info className="text-muted-foreground mt-0.5 flex-shrink-0 h-3 w-3" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{currentTeam === "personal" ? (
|
||||
<span>
|
||||
To access these models: Create a Virtual Key without selecting a team on the{" "}
|
||||
<a
|
||||
href="/public?login=success&page=api-keys"
|
||||
className="text-gray-600 hover:text-gray-800 underline"
|
||||
className="text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
Virtual Keys page
|
||||
</a>
|
||||
|
|
@ -344,7 +336,7 @@ const AllModelsTab = ({
|
|||
the{" "}
|
||||
<a
|
||||
href="/public?login=success&page=api-keys"
|
||||
className="text-gray-600 hover:text-gray-800 underline"
|
||||
className="text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
Virtual Keys page
|
||||
</a>
|
||||
|
|
@ -358,7 +350,6 @@ const AllModelsTab = ({
|
|||
{/* Search and Filter Controls */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Model Name Search */}
|
||||
|
|
@ -367,104 +358,94 @@ const AllModelsTab = ({
|
|||
type="text"
|
||||
placeholder="Search model names..."
|
||||
data-testid="model-search-input"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 pl-8 border border-input bg-background rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
value={modelNameSearch}
|
||||
onChange={(e) => setModelNameSearch(e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={showFilters ? "bg-muted" : ""}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Reset Filters
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Model Settings Button */}
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsModelSettingsModalVisible(true)}
|
||||
title="Model Settings"
|
||||
/>
|
||||
aria-label="Model Settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Additional Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
{/* Model Name Filter */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedModelGroup ?? "all"}
|
||||
onChange={(value) => setSelectedModelGroup(value === "all" ? "all" : value)}
|
||||
placeholder="Filter by Public Model Name"
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "All Models" },
|
||||
{ value: "wildcard", label: "Wildcard Models (*)" },
|
||||
...availableModelGroups.map((group, idx) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
onValueChange={(value) =>
|
||||
setSelectedModelGroup(value === "all" ? "all" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filter by Public Model Name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Models</SelectItem>
|
||||
<SelectItem value="wildcard">Wildcard Models (*)</SelectItem>
|
||||
{availableModelGroups.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
{group}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Access Group Filter */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedModelAccessGroupFilter ?? "all"}
|
||||
onChange={(value) => setSelectedModelAccessGroupFilter(value === "all" ? null : value)}
|
||||
placeholder="Filter by Model Access Group"
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "All Model Access Groups" },
|
||||
...availableModelAccessGroups.map((accessGroup, idx) => ({
|
||||
value: accessGroup,
|
||||
label: accessGroup,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
onValueChange={(value) =>
|
||||
setSelectedModelAccessGroupFilter(value === "all" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filter by Model Access Group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Model Access Groups</SelectItem>
|
||||
{availableModelAccessGroups.map((accessGroup) => (
|
||||
<SelectItem key={accessGroup} value={accessGroup}>
|
||||
{accessGroup}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -472,9 +453,9 @@ const AllModelsTab = ({
|
|||
{/* Results Count and Pagination Controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active style={{ width: 184, height: 20 }} />
|
||||
<Skeleton className="h-5 w-[184px]" />
|
||||
) : (
|
||||
<span data-testid="models-results-count" className="text-sm text-gray-700">
|
||||
<span data-testid="models-results-count" className="text-sm text-foreground">
|
||||
{paginationMeta.total_count > 0
|
||||
? `Showing ${((currentPage - 1) * pageSize) + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results`
|
||||
: "Showing 0 results"}
|
||||
|
|
@ -483,41 +464,39 @@ const AllModelsTab = ({
|
|||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active style={{ width: 84, height: 30 }} />
|
||||
<Skeleton className="h-8 w-[84px]" />
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage - 1;
|
||||
setCurrentPage(newPage);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${currentPage === 1
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active style={{ width: 56, height: 30 }} />
|
||||
<Skeleton className="h-8 w-[56px]" />
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage + 1;
|
||||
setCurrentPage(newPage);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
disabled={currentPage >= paginationMeta.total_pages}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${currentPage >= paginationMeta.total_pages
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -549,7 +528,7 @@ const AllModelsTab = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={!!deleteModalModelId}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ describe("RouterConfigBuilder", () => {
|
|||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: "delete" });
|
||||
const deleteButton = screen.getByRole("button", { name: /remove route/i });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -131,7 +131,7 @@ describe("RouterConfigBuilder", () => {
|
|||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: "delete" });
|
||||
const deleteButton = screen.getByRole("button", { name: /remove route/i });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { Info, Plus, Trash2 } from "lucide-react";
|
||||
import { Select as AntdSelect, Collapse, Empty, InputNumber } from "antd";
|
||||
import { Info, Plus, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -50,12 +64,77 @@ interface RouterConfigBuilderProps {
|
|||
onChange?: (config: any) => void;
|
||||
}
|
||||
|
||||
function UtterancesInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const addValues = (input: string) => {
|
||||
const parts = input
|
||||
.split(/[\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) return;
|
||||
const next = [...value];
|
||||
for (const part of parts) {
|
||||
if (!next.includes(part)) {
|
||||
next.push(part);
|
||||
}
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={draft}
|
||||
placeholder="Type an utterance and press Enter..."
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addValues(draft);
|
||||
setDraft("");
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (text.includes("\n")) {
|
||||
e.preventDefault();
|
||||
addValues(text);
|
||||
setDraft("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((u) => (
|
||||
<Badge key={u} variant="secondary" className="gap-1">
|
||||
<span>{u}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${u}`}
|
||||
onClick={() => onChange(value.filter((x) => x !== u))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, value, onChange }) => {
|
||||
const [routes, setRoutes] = useState<Route[]>([]);
|
||||
const [showJsonPreview, setShowJsonPreview] = useState<boolean>(false);
|
||||
const [expandedRoutes, setExpandedRoutes] = useState<string[]>([]);
|
||||
|
||||
// Initialize routes from value prop - preserve existing route IDs to avoid focus loss when parent re-renders
|
||||
useEffect(() => {
|
||||
const routesFromValue = value?.routes;
|
||||
if (routesFromValue) {
|
||||
|
|
@ -67,7 +146,7 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
routeIds.push(id);
|
||||
return {
|
||||
id,
|
||||
model: route.name || route.model || "", // handle both 'name' and 'model' fields
|
||||
model: route.name || route.model || "",
|
||||
utterances: route.utterances || [],
|
||||
description: route.description || "",
|
||||
score_threshold: route.score_threshold ?? 0.5,
|
||||
|
|
@ -82,7 +161,6 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
}
|
||||
}, [value]);
|
||||
|
||||
// Handle adding a new route
|
||||
const addRoute = () => {
|
||||
const newRouteId = `route-${Date.now()}`;
|
||||
const newRoute: Route = {
|
||||
|
|
@ -95,27 +173,22 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
const updatedRoutes = [...routes, newRoute];
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
// Automatically expand the new route
|
||||
setExpandedRoutes((prev) => [...prev, newRouteId]);
|
||||
};
|
||||
|
||||
// Handle removing a route
|
||||
const removeRoute = (routeId: string) => {
|
||||
const updatedRoutes = routes.filter((route) => route.id !== routeId);
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
// Remove from expanded routes as well
|
||||
setExpandedRoutes((prev) => prev.filter((id) => id !== routeId));
|
||||
};
|
||||
|
||||
// Handle updating a route
|
||||
const updateRoute = (routeId: string, field: keyof Route, value: any) => {
|
||||
const updatedRoutes = routes.map((route) => (route.id === routeId ? { ...route, [field]: value } : route));
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
};
|
||||
|
||||
// Update the overall configuration
|
||||
const updateConfig = (updatedRoutes: Route[]) => {
|
||||
const config = {
|
||||
routes: updatedRoutes.map((route) => ({
|
||||
|
|
@ -128,21 +201,6 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
onChange?.(config);
|
||||
};
|
||||
|
||||
// Handle utterances change (convert textarea string to array)
|
||||
const handleUtterancesChange = (routeId: string, utterancesText: string) => {
|
||||
const utterancesArray = utterancesText
|
||||
.split("\n")
|
||||
.map((line) => line.trim()) // Only trims leading/trailing whitespace, preserves internal spaces
|
||||
.filter((line) => line.length > 0);
|
||||
updateRoute(routeId, "utterances", utterancesArray);
|
||||
};
|
||||
|
||||
// Prepare model options for dropdowns
|
||||
const modelOptions = modelInfo.map((model) => ({
|
||||
value: model.model_group,
|
||||
label: model.model_group,
|
||||
}));
|
||||
|
||||
const generateConfig = () => {
|
||||
return {
|
||||
routes: routes.map((route) => ({
|
||||
|
|
@ -173,133 +231,122 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
{/* Routes */}
|
||||
{routes.length === 0 ? (
|
||||
<Card className="p-6">
|
||||
<Empty description='No routes configured. Click "Add Route" to get started.' />
|
||||
<div className="py-8 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="text-sm">
|
||||
No routes configured. Click "Add Route" to get started.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={expandedRoutes}
|
||||
onChange={(keys) =>
|
||||
setExpandedRoutes(
|
||||
Array.isArray(keys) ? keys : [keys].filter(Boolean),
|
||||
)
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
items={routes.map((route, index) => ({
|
||||
key: route.id,
|
||||
label: (
|
||||
<span className="text-base">
|
||||
Route {index + 1}: {route.model || "Unnamed"}
|
||||
</span>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRoute(route.id);
|
||||
}}
|
||||
aria-label="Remove route"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Card key={route.id} className="p-4">
|
||||
{/* Model Selection */}
|
||||
<div className="mb-4 w-full">
|
||||
<span className="text-sm font-medium mb-2 block">
|
||||
Model
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedRoutes}
|
||||
onValueChange={(next) => setExpandedRoutes(next)}
|
||||
className="w-full"
|
||||
>
|
||||
{routes.map((route, index) => (
|
||||
<AccordionItem key={route.id} value={route.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AccordionTrigger className="flex-1">
|
||||
<span className="text-base">
|
||||
Route {index + 1}: {route.model || "Unnamed"}
|
||||
</span>
|
||||
<AntdSelect
|
||||
value={route.model}
|
||||
onChange={(value) =>
|
||||
updateRoute(route.id, "model", value)
|
||||
}
|
||||
placeholder="Select model"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
options={modelOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-4 w-full">
|
||||
<span className="text-sm font-medium mb-2 block">
|
||||
Description
|
||||
</span>
|
||||
<Textarea
|
||||
value={route.description}
|
||||
onChange={(e) =>
|
||||
updateRoute(
|
||||
route.id,
|
||||
"description",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Describe when this route should be used..."
|
||||
rows={2}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Threshold */}
|
||||
<div className="mb-4 w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
Score Threshold
|
||||
</span>
|
||||
<InfoTip>
|
||||
Minimum similarity score to route to this model (0-1)
|
||||
</InfoTip>
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRoute(route.id);
|
||||
}}
|
||||
aria-label="Remove route"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<Card key={route.id} className="p-4">
|
||||
<div className="mb-4 w-full">
|
||||
<span className="text-sm font-medium mb-2 block">Model</span>
|
||||
<Select
|
||||
value={route.model || undefined}
|
||||
onValueChange={(value) => updateRoute(route.id, "model", value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelInfo.map((model) => (
|
||||
<SelectItem key={model.model_group} value={model.model_group}>
|
||||
{model.model_group}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<InputNumber
|
||||
value={route.score_threshold}
|
||||
onChange={(value) =>
|
||||
updateRoute(route.id, "score_threshold", value || 0)
|
||||
}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Example Utterances */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
Example Utterances
|
||||
</span>
|
||||
<InfoTip>
|
||||
Training examples for this route. Type an utterance
|
||||
and press Enter to add it.
|
||||
</InfoTip>
|
||||
<div className="mb-4 w-full">
|
||||
<span className="text-sm font-medium mb-2 block">Description</span>
|
||||
<Textarea
|
||||
value={route.description}
|
||||
onChange={(e) => updateRoute(route.id, "description", e.target.value)}
|
||||
placeholder="Describe when this route should be used..."
|
||||
rows={2}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Type an utterance and press Enter to add it. You can
|
||||
also paste multiple lines.
|
||||
</p>
|
||||
<AntdSelect
|
||||
mode="tags"
|
||||
value={route.utterances}
|
||||
onChange={(utterances) =>
|
||||
updateRoute(route.id, "utterances", utterances)
|
||||
}
|
||||
placeholder="Type an utterance and press Enter..."
|
||||
style={{ width: "100%" }}
|
||||
tokenSeparators={["\n"]}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium">Score Threshold</span>
|
||||
<InfoTip>
|
||||
Minimum similarity score to route to this model (0-1)
|
||||
</InfoTip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={route.score_threshold}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
updateRoute(route.id, "score_threshold", 0);
|
||||
return;
|
||||
}
|
||||
const num = Number(raw);
|
||||
updateRoute(route.id, "score_threshold", Number.isNaN(num) ? 0 : num);
|
||||
}}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
placeholder="0.5"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium">Example Utterances</span>
|
||||
<InfoTip>
|
||||
Training examples for this route. Type an utterance and press
|
||||
Enter to add it.
|
||||
</InfoTip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Type an utterance and press Enter to add it. You can also paste
|
||||
multiple lines.
|
||||
</p>
|
||||
<UtterancesInput
|
||||
value={route.utterances}
|
||||
onChange={(next) => updateRoute(route.id, "utterances", next)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* JSON Preview */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue