mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #21004 from BerriAI/litellm_ui_auto_router
[Fix] UI - Add Auto Router: Description Text Input Focus
This commit is contained in:
commit
9cee51abb9
5 changed files with 575 additions and 283 deletions
|
|
@ -0,0 +1,296 @@
|
|||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import RouterConfigBuilder from "./RouterConfigBuilder";
|
||||
|
||||
const MOCK_MODEL_INFO = [
|
||||
{ model_group: "gpt-4", mode: "chat" },
|
||||
{ model_group: "gpt-3.5-turbo", mode: "chat" },
|
||||
{ model_group: "claude-3-opus", mode: "chat" },
|
||||
];
|
||||
|
||||
describe("RouterConfigBuilder", () => {
|
||||
it("should render", () => {
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
expect(screen.getByText("Routes Configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Add Route button", () => {
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /add route/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state when no routes are configured", () => {
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
expect(screen.getByText(/no routes configured/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should add a route when Add Route is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add route/i }));
|
||||
|
||||
expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when a route is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add route/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
routes: [
|
||||
expect.objectContaining({
|
||||
name: "",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should initialize routes from value prop", async () => {
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: ["hello", "hi"],
|
||||
description: "For greetings",
|
||||
score_threshold: 0.7,
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should support both name and model fields in value prop", async () => {
|
||||
const value = {
|
||||
routes: [{ model: "gpt-3.5-turbo", utterances: [], description: "", score_threshold: 0.5 }],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-3.5-turbo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove a route when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: "delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Route 1: gpt-4")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/no routes configured/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onChange when route is removed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: "delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith({ routes: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
it("should update route when description is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const descriptionInput = screen.getByPlaceholderText("Describe when this route should be used...");
|
||||
await user.type(descriptionInput, "For code generation");
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(lastCall[0].routes[0].description).toBe("For code generation");
|
||||
});
|
||||
});
|
||||
|
||||
it("should update route when score threshold is changed", async () => {
|
||||
const onChange = vi.fn();
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const scoreInput = screen.getByRole("spinbutton");
|
||||
fireEvent.change(scoreInput, { target: { value: "0.9" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(lastCall[0].routes[0].score_threshold).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it("should add multiple routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add route/i }));
|
||||
await user.click(screen.getByRole("button", { name: /add route/i }));
|
||||
|
||||
expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Route 2: Unnamed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle JSON preview visibility", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} />);
|
||||
|
||||
expect(screen.getByText("JSON Preview")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument();
|
||||
expect(container.querySelector("pre")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Hide" })).toBeInTheDocument();
|
||||
expect(container.querySelector("pre")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Hide" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument();
|
||||
expect(container.querySelector("pre")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display JSON preview with route data when routes exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<RouterConfigBuilder
|
||||
modelInfo={MOCK_MODEL_INFO}
|
||||
value={{
|
||||
routes: [
|
||||
{ name: "gpt-4", utterances: ["hello"], description: "test", score_threshold: 0.8 },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show" }));
|
||||
|
||||
const preElement = container.querySelector("pre");
|
||||
expect(preElement).toBeInTheDocument();
|
||||
expect(preElement?.textContent).toContain("gpt-4");
|
||||
expect(preElement?.textContent).toContain("hello");
|
||||
expect(preElement?.textContent).toContain("0.8");
|
||||
});
|
||||
|
||||
it("should display model selector with options from modelInfo", async () => {
|
||||
const value = {
|
||||
routes: [
|
||||
{ name: "", utterances: [], description: "", score_threshold: 0.5 },
|
||||
],
|
||||
};
|
||||
render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
const comboboxes = screen.getAllByRole("combobox");
|
||||
expect(comboboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should clear routes when value prop changes to empty", async () => {
|
||||
const value = {
|
||||
routes: [
|
||||
{
|
||||
name: "gpt-4",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { rerender } = render(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={value} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<RouterConfigBuilder modelInfo={MOCK_MODEL_INFO} value={{ routes: [] }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no routes configured/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Select as AntdSelect, Button, Card, Collapse, Divider, Empty, Flex, Input, InputNumber, Space, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ModelGroup } from "../playground/llm_calls/fetch_models";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Route {
|
||||
id: string;
|
||||
model: string;
|
||||
utterances: string[];
|
||||
description: string;
|
||||
score_threshold: number;
|
||||
}
|
||||
|
||||
interface SavedRoute {
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
utterances?: string[];
|
||||
description?: string;
|
||||
score_threshold?: number;
|
||||
}
|
||||
|
||||
interface RouterConfig {
|
||||
routes?: SavedRoute[];
|
||||
}
|
||||
|
||||
interface RouterConfigBuilderProps {
|
||||
modelInfo: ModelGroup[];
|
||||
value?: RouterConfig;
|
||||
onChange?: (config: any) => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
const routeIds: string[] = [];
|
||||
setRoutes((prevRoutes) => {
|
||||
const initializedRoutes = routesFromValue.map((route: SavedRoute, index: number) => {
|
||||
const existingRoute = prevRoutes[index];
|
||||
const id = existingRoute?.id || route.id || `route-${index}-${Date.now()}`;
|
||||
routeIds.push(id);
|
||||
return {
|
||||
id,
|
||||
model: route.name || route.model || "", // handle both 'name' and 'model' fields
|
||||
utterances: route.utterances || [],
|
||||
description: route.description || "",
|
||||
score_threshold: route.score_threshold ?? 0.5,
|
||||
};
|
||||
});
|
||||
return initializedRoutes;
|
||||
});
|
||||
setExpandedRoutes(routeIds);
|
||||
} else {
|
||||
setRoutes([]);
|
||||
setExpandedRoutes([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Handle adding a new route
|
||||
const addRoute = () => {
|
||||
const newRouteId = `route-${Date.now()}`;
|
||||
const newRoute: Route = {
|
||||
id: newRouteId,
|
||||
model: "",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
};
|
||||
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) => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
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) => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
<Flex justify="space-between" align="center" gap="middle" style={{ width: "100%", marginBottom: 24 }}>
|
||||
<Space align="center">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>Routes Configuration</Typography.Title>
|
||||
<Tooltip title="Configure routing logic to automatically select the best model based on user input patterns">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addRoute} className="bg-blue-600 hover:bg-blue-700">
|
||||
Add Route
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Routes */}
|
||||
{routes.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="No routes configured. Click "Add Route" to get started." />
|
||||
</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: (
|
||||
<Text style={{ fontSize: 16 }}>
|
||||
Route {index + 1}: {route.model || "Unnamed"}
|
||||
</Text>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRoute(route.id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<Card key={route.id}>
|
||||
{/* Model Selection */}
|
||||
<div className="mb-4 w-full">
|
||||
<Text className="text-sm font-medium mb-2 block">Model</Text>
|
||||
<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">
|
||||
<Text className="text-sm font-medium mb-2 block">Description</Text>
|
||||
<TextArea
|
||||
value={route.description}
|
||||
onChange={(e) => updateRoute(route.id, "description", e.target.value)}
|
||||
placeholder="Describe when this route should be used..."
|
||||
rows={2}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Threshold */}
|
||||
<div className="mb-4 w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Text className="text-sm font-medium">Score Threshold</Text>
|
||||
<Tooltip title="Minimum similarity score to route to this model (0-1)">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</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">
|
||||
<Text className="text-sm font-medium">Example Utterances</Text>
|
||||
<Tooltip title="Training examples for this route. Type an utterance and press Enter to add it.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-500 mb-2">
|
||||
Type an utterance and press Enter to add it. You can also paste multiple lines.
|
||||
</Text>
|
||||
<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>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* JSON Preview */}
|
||||
<Divider />
|
||||
<div className="flex justify-between items-center mb-4 w-full">
|
||||
<Text className="text-lg font-semibold">JSON Preview</Text>
|
||||
<Button type="link" onClick={() => setShowJsonPreview(!showJsonPreview)} className="text-blue-600 p-0">
|
||||
{showJsonPreview ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showJsonPreview && (
|
||||
<Card className="bg-gray-50 w-full">
|
||||
<pre className="text-sm overflow-auto max-h-64 w-full">{JSON.stringify(generateConfig(), null, 2)}</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterConfigBuilder;
|
||||
|
|
@ -7,7 +7,7 @@ import ConnectionErrorDisplay from "./model_connection_test";
|
|||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "./router_config_builder";
|
||||
import RouterConfigBuilder from "./RouterConfigBuilder";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
|
||||
interface AddAutoRouterTabProps {
|
||||
|
|
|
|||
|
|
@ -1,281 +0,0 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, InputNumber, Select as AntdSelect, Tooltip, Collapse } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, InfoCircleOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { Text } from "@tremor/react";
|
||||
import { ModelGroup } from "../playground/llm_calls/fetch_models";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface Route {
|
||||
id: string;
|
||||
model: string;
|
||||
utterances: string[];
|
||||
description: string;
|
||||
score_threshold: number;
|
||||
}
|
||||
|
||||
interface SavedRoute {
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
utterances?: string[];
|
||||
description?: string;
|
||||
score_threshold?: number;
|
||||
}
|
||||
|
||||
interface RouterConfig {
|
||||
routes?: SavedRoute[];
|
||||
}
|
||||
|
||||
interface RouterConfigBuilderProps {
|
||||
modelInfo: ModelGroup[];
|
||||
value?: RouterConfig;
|
||||
onChange?: (config: any) => void;
|
||||
}
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (value?.routes) {
|
||||
const initializedRoutes = value.routes.map((route: SavedRoute, index: number) => ({
|
||||
id: route.id || `route-${index}-${Date.now()}`,
|
||||
model: route.name || route.model || "", // handle both 'name' and 'model' fields
|
||||
utterances: route.utterances || [],
|
||||
description: route.description || "",
|
||||
score_threshold: route.score_threshold || 0.5,
|
||||
}));
|
||||
setRoutes(initializedRoutes);
|
||||
|
||||
// Set expanded routes for existing routes
|
||||
const routeIds = initializedRoutes.map((route) => route.id);
|
||||
setExpandedRoutes(routeIds);
|
||||
} else {
|
||||
setRoutes([]);
|
||||
setExpandedRoutes([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Handle adding a new route
|
||||
const addRoute = () => {
|
||||
const newRouteId = `route-${Date.now()}`;
|
||||
const newRoute: Route = {
|
||||
id: newRouteId,
|
||||
model: "",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
};
|
||||
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) => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
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) => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
{/* Routes Configuration Header */}
|
||||
<div className="flex justify-between items-center mb-6 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="text-lg font-semibold">Routes Configuration</Text>
|
||||
<Tooltip title="Configure routing logic to automatically select the best model based on user input patterns">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addRoute} className="bg-blue-600 hover:bg-blue-700">
|
||||
Add Route
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Routes */}
|
||||
{routes.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200 mb-6">
|
||||
<Text>No routes configured. Click “Add Route” to get started.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-6 w-full">
|
||||
{routes.map((route, index) => (
|
||||
<Card key={route.id} className="border border-gray-200 shadow-sm w-full" bodyStyle={{ padding: 0 }}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIcon={({ isActive }) => <DownOutlined rotate={isActive ? 180 : 0} />}
|
||||
activeKey={expandedRoutes}
|
||||
onChange={(keys) => setExpandedRoutes(Array.isArray(keys) ? keys : [keys].filter(Boolean))}
|
||||
items={[
|
||||
{
|
||||
key: route.id,
|
||||
label: (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<Text className="font-medium text-base">
|
||||
Route {index + 1}: {route.model || "Unnamed"}
|
||||
</Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRoute(route.id);
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="px-6 pb-6 w-full">
|
||||
{/* Model Selection */}
|
||||
<div className="mb-4 w-full">
|
||||
<Text className="text-sm font-medium mb-2 block">Model</Text>
|
||||
<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">
|
||||
<Text className="text-sm font-medium mb-2 block">Description</Text>
|
||||
<TextArea
|
||||
value={route.description}
|
||||
onChange={(e) => updateRoute(route.id, "description", e.target.value)}
|
||||
placeholder="Describe when this route should be used..."
|
||||
rows={2}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Threshold */}
|
||||
<div className="mb-4 w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Text className="text-sm font-medium">Score Threshold</Text>
|
||||
<Tooltip title="Minimum similarity score to route to this model (0-1)">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</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">
|
||||
<Text className="text-sm font-medium">Example Utterances</Text>
|
||||
<Tooltip title="Training examples for this route. Type an utterance and press Enter to add it.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-500 mb-2">
|
||||
Type an utterance and press Enter to add it. You can also paste multiple lines.
|
||||
</Text>
|
||||
<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>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Preview */}
|
||||
<div className="border-t pt-6 w-full">
|
||||
<div className="flex justify-between items-center mb-4 w-full">
|
||||
<Text className="text-lg font-semibold">JSON Preview</Text>
|
||||
<Button type="link" onClick={() => setShowJsonPreview(!showJsonPreview)} className="text-blue-600 p-0">
|
||||
{showJsonPreview ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showJsonPreview && (
|
||||
<Card className="bg-gray-50 w-full">
|
||||
<pre className="text-sm overflow-auto max-h-64 w-full">{JSON.stringify(generateConfig(), null, 2)}</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterConfigBuilder;
|
||||
|
|
@ -3,7 +3,7 @@ import { Modal, Form, Button, Select as AntdSelect } from "antd";
|
|||
import { Text, TextInput } from "@tremor/react";
|
||||
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/router_config_builder";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
interface EditAutoRouterModalProps {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue