mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
feat(ui): simplify LiteAdmin on the homepage (#46301)
* feat(ui): simplify LiteAdmin on the homepage * fix(ui): preserve contrast on LiteAdmin primary actions
This commit is contained in:
parent
0ebec123fd
commit
b77108a409
5 changed files with 224 additions and 54 deletions
|
|
@ -150,7 +150,7 @@ describe("HomePage", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
const entry = screen.getByRole("region", { name: "Ask LiteAdmin" });
|
||||
expect(await within(entry).findByPlaceholderText("Ask LiteAdmin…")).toBeInTheDocument();
|
||||
expect(await within(entry).findByPlaceholderText("What would you like to do?")).toBeInTheDocument();
|
||||
expect(within(entry).getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
expect(await screen.findByText("$501.74")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,11 +29,14 @@ export default function HomePage() {
|
|||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageHeader className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageHeaderTitle>Home</PageHeaderTitle>
|
||||
{!isViewOnly && (
|
||||
<PageHeaderControls>
|
||||
<Button render={<a href={uiHref("api-keys?create=true")} />}>
|
||||
<PageHeaderControls className="mt-0">
|
||||
<Button
|
||||
render={<a href={uiHref("api-keys?create=true")} />}
|
||||
className="bg-info text-info-foreground shadow-xs hover:bg-info/90"
|
||||
>
|
||||
<Plus />
|
||||
Create New Virtual Key
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ async function openWidget() {
|
|||
}
|
||||
|
||||
function send(text: string) {
|
||||
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: text } });
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: text } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ describe("LiteAdmin in the gateway", () => {
|
|||
renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
|
||||
await selectModel();
|
||||
const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
|
||||
const input = within(home).getByPlaceholderText("What would you like to do?");
|
||||
fireEvent.change(input, { target: { value: "First question" } });
|
||||
if (method === "keyboard") fireEvent.keyDown(input, { key: "Enter" });
|
||||
else fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
|
||||
|
|
@ -235,6 +235,31 @@ describe("LiteAdmin in the gateway", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("prefills each Home suggestion without sending until the edited draft is submitted", async () => {
|
||||
const requests = gateway([answer("Budget details received.")]);
|
||||
renderWidget(undefined, true);
|
||||
await selectModel();
|
||||
const input = screen.getByRole("textbox", { name: "Message LiteAdmin" });
|
||||
for (const label of ["Create a key", "Add a model", "Set a budget"]) {
|
||||
fireEvent.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
expect(input).toHaveValue(label);
|
||||
expect(input).toHaveFocus();
|
||||
expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
}
|
||||
fireEvent.change(input, { target: { value: "Set a budget of $10 for my team" } });
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
||||
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
|
||||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(await screen.findByText("Budget details received.")).toBeInTheDocument();
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].body.model).toBe("chat-model");
|
||||
expect(completions[0].body.messages).toContainEqual({ role: "user", content: "Set a budget of $10 for my team" });
|
||||
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves the draft and chosen model when opening, closing and leaving Home", async () => {
|
||||
const requests = gateway([answer("Draft received.")], {
|
||||
models: [
|
||||
|
|
@ -244,12 +269,14 @@ describe("LiteAdmin in the gateway", () => {
|
|||
});
|
||||
const view = renderWidget(undefined, true);
|
||||
await selectModel();
|
||||
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Keep this draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
|
||||
target: { value: "Keep this draft" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
|
||||
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
|
||||
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
|
||||
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
|
||||
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Keep this draft");
|
||||
view.refresh(false);
|
||||
expect(home).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
|
||||
|
|
@ -293,16 +320,16 @@ describe("LiteAdmin in the gateway", () => {
|
|||
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "First draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
|
||||
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
|
||||
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("First draft");
|
||||
expect(screen.getAllByPlaceholderText("Ask LiteAdmin…")).toHaveLength(1);
|
||||
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("First draft");
|
||||
expect(screen.getAllByPlaceholderText("What would you like to do?")).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
|
||||
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
|
||||
expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("other-chat-model");
|
||||
expect(screen.getAllByLabelText("LiteAdmin conversation")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
|
||||
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
|
||||
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Second draft");
|
||||
fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
|
||||
await screen.findByText("Second follow-up.");
|
||||
expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
|
||||
|
|
@ -531,13 +558,13 @@ describe("LiteAdmin in the gateway", () => {
|
|||
const { client } = renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
|
||||
expect(await within(home).findByText("Add a chat model to your gateway to use LiteAdmin.")).toBeInTheDocument();
|
||||
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toBeDisabled();
|
||||
expect(within(home).getByPlaceholderText("What would you like to do?")).toBeDisabled();
|
||||
expect(within(home).getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
options.models = [{ model_group: "chat-model", mode: "chat" }];
|
||||
await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] }));
|
||||
await selectModel();
|
||||
for (const text of [" ", "x".repeat(MAX_INPUT_LENGTH + 1)]) {
|
||||
const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
|
||||
const input = within(home).getByPlaceholderText("What would you like to do?");
|
||||
fireEvent.change(input, { target: { value: text } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
const submit = within(home).getByRole("button", { name: "Send message" });
|
||||
|
|
@ -566,15 +593,17 @@ describe("LiteAdmin in the gateway", () => {
|
|||
await act(async () => settingsReady.resolve());
|
||||
const consent = await within(home).findByRole("button", { name: "Use configured gateway" });
|
||||
expect(within(home).getByText(EXTERNAL_INFERENCE)).toBeInTheDocument();
|
||||
expect(within(home).queryByPlaceholderText("Ask LiteAdmin…")).not.toBeInTheDocument();
|
||||
expect(within(home).queryByPlaceholderText("What would you like to do?")).not.toBeInTheDocument();
|
||||
fireEvent.click(consent);
|
||||
await selectModel();
|
||||
fireEvent.change(within(home).getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Old gateway draft" } });
|
||||
fireEvent.change(within(home).getByPlaceholderText("What would you like to do?"), {
|
||||
target: { value: "Old gateway draft" },
|
||||
});
|
||||
settings.target = `${EXTERNAL_INFERENCE}/changed`;
|
||||
await act(async () => client.invalidateQueries({ queryKey: ["proxySettings"] }));
|
||||
fireEvent.click(await within(home).findByRole("button", { name: "Use configured gateway" }));
|
||||
await selectModel();
|
||||
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
|
||||
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("");
|
||||
expect(home).toBeVisible();
|
||||
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import {
|
|||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { Plus, Sparkles, X } from "lucide-react";
|
||||
import { History, Plus, Sparkles, X } from "lucide-react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
|
||||
import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
|
||||
|
|
@ -32,6 +33,7 @@ import { cn } from "@/lib/cva.config";
|
|||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
|
||||
import { LiteAdminConversation } from "./LiteAdminConversation";
|
||||
import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
|
||||
import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
|
||||
|
||||
type ManagementSession = Omit<LiteAdminSession, "inferenceBaseUrl">;
|
||||
|
|
@ -100,17 +102,18 @@ export function LiteAdminHome() {
|
|||
if (!state) return null;
|
||||
const { open, show, setHomeContainer } = state;
|
||||
return (
|
||||
<section aria-label="Ask LiteAdmin" hidden={open} className="rounded-2xl border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<section aria-label="Ask LiteAdmin" hidden={open} className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Sparkles className="size-4 text-info" />
|
||||
LiteAdmin
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Ask about your gateway. Review changes in chat.</p>
|
||||
<p className="border-l pl-3 text-sm text-muted-foreground">Control your entire gateway.</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={show}>
|
||||
Open chat
|
||||
<Button variant="ghost" size="sm" onClick={show} className="text-muted-foreground">
|
||||
<History className="size-3.5" />
|
||||
Recent chats
|
||||
</Button>
|
||||
</div>
|
||||
<div ref={setHomeContainer} />
|
||||
|
|
@ -417,22 +420,24 @@ function LiteAdminChat({
|
|||
};
|
||||
if (!active) return null;
|
||||
const homeContainer = !open ? state?.homeContainer : null;
|
||||
const composerProps = {
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
onSubmit: send,
|
||||
onCancel: chat.phase === "applying" ? undefined : chat.stop,
|
||||
disabled: busy || !selectedModel,
|
||||
isLoading: busy,
|
||||
submitDisabled,
|
||||
};
|
||||
const composer = (
|
||||
<div className="space-y-3 border-t p-3">
|
||||
<ModelPicker models={models} value={selectedModel} onChange={setModel} disabled={busy} />
|
||||
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
|
||||
<ChatComposer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={send}
|
||||
onCancel={chat.phase === "applying" ? undefined : chat.stop}
|
||||
placeholder="Ask LiteAdmin…"
|
||||
disabled={busy || !selectedModel}
|
||||
isLoading={busy}
|
||||
submitDisabled={submitDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Use a model you trust with your gateway data.</p>
|
||||
</div>
|
||||
<LiteAdminComposer
|
||||
{...composerProps}
|
||||
models={models}
|
||||
model={selectedModel}
|
||||
onModelChange={setModel}
|
||||
home={Boolean(homeContainer)}
|
||||
tooLong={tooLong}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TabsContent value={id} className="flex min-h-0 flex-1 flex-col">
|
||||
|
|
@ -448,32 +453,73 @@ function LiteAdminChat({
|
|||
);
|
||||
}
|
||||
|
||||
function LiteAdminComposer({
|
||||
models,
|
||||
model,
|
||||
onModelChange,
|
||||
home,
|
||||
tooLong,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof LiteAdminHomeComposer>, "modelPicker"> & {
|
||||
models: UseQueryResult<ModelGroup[]>;
|
||||
model: string | null;
|
||||
onModelChange: (value: string | null) => void;
|
||||
home: boolean;
|
||||
tooLong: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-3", !home && "border-t p-3")}>
|
||||
{!home && <ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} />}
|
||||
<ModelStatus models={models} />
|
||||
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
|
||||
{home ? (
|
||||
<LiteAdminHomeComposer
|
||||
{...props}
|
||||
modelPicker={
|
||||
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ChatComposer {...props} placeholder="Ask LiteAdmin…" />
|
||||
<p className="text-xs text-muted-foreground">Use a model you trust with your gateway data.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
compact = false,
|
||||
}: {
|
||||
models: UseQueryResult<ModelGroup[]>;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
disabled: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (models.isPending) return <Skeleton className="h-8 w-full" aria-label="Loading models" />;
|
||||
return (
|
||||
<SearchSelect
|
||||
aria-label="LiteAdmin model"
|
||||
options={(models.data ?? []).map((item) => ({ value: item.model_group, label: item.model_group }))}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Choose a chat model"
|
||||
disabled={disabled}
|
||||
allowClear={false}
|
||||
className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelStatus({ models }: { models: UseQueryResult<ModelGroup[]> }) {
|
||||
return (
|
||||
<>
|
||||
{models.isPending ? (
|
||||
<Skeleton className="h-8" aria-label="Loading models" />
|
||||
) : (
|
||||
<SearchSelect
|
||||
aria-label="LiteAdmin model"
|
||||
options={(models.data ?? []).map((item) => ({ value: item.model_group, label: item.model_group }))}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Choose a chat model"
|
||||
disabled={disabled}
|
||||
allowClear={false}
|
||||
/>
|
||||
)}
|
||||
{models.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { useRef, type ReactNode } from "react";
|
||||
import { ArrowUp, Box, CreditCard, KeyRound, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
|
||||
const suggestions = [
|
||||
{ label: "Create a key", icon: KeyRound },
|
||||
{ label: "Add a model", icon: Box },
|
||||
{ label: "Set a budget", icon: CreditCard },
|
||||
];
|
||||
|
||||
export function LiteAdminHomeComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disabled,
|
||||
isLoading,
|
||||
submitDisabled,
|
||||
modelPicker,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
submitDisabled: boolean;
|
||||
modelPicker: ReactNode;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
return (
|
||||
<InputGroup className="h-auto flex-col rounded-xl border-border bg-card shadow-xs">
|
||||
<InputGroupTextarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (!submitDisabled && !isLoading) onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="What would you like to do?"
|
||||
aria-label="Message LiteAdmin"
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
className="min-h-24 max-h-48 w-full rounded-t-xl px-5 pt-5 pb-2 text-sm placeholder:text-muted-foreground [field-sizing:content]"
|
||||
/>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map(({ label, icon: Icon }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="gap-2 bg-muted/30 font-normal text-muted-foreground shadow-none"
|
||||
onClick={() => {
|
||||
onChange(label);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 max-w-full items-center gap-2">
|
||||
<div className="w-48 min-w-0 text-muted-foreground">{modelPicker}</div>
|
||||
{isLoading && onCancel ? (
|
||||
<Button type="button" size="icon-sm" aria-label="Stop request" onClick={onCancel}>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Send message"
|
||||
disabled={submitDisabled || isLoading}
|
||||
onClick={onSubmit}
|
||||
className="rounded-lg bg-info text-info-foreground shadow-xs hover:bg-info/90"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue