mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
feat(ui): redesign the dashboard top bar and give Aria its own page (#50009)
* feat(ui): rebuild dashboard navigation and Aria workspace * refactor(ui): make Aria a single workspace and clarify dashboard controls * fix(ui): preserve link semantics for the Aria shortcut * style(ui): use Sparkle for Aria * style(ui): simplify workspace menu rows * fix(ui): keep worker controls compact on narrow headers * style(ui): remove workspace selector dark fill
This commit is contained in:
parent
bd8eeedfc2
commit
664241188f
33 changed files with 776 additions and 1262 deletions
5
ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx
Normal file
5
ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { LiteAdminPage } from "@/components/liteadmin/LiteAdmin";
|
||||
|
||||
export default function AriaPage() {
|
||||
return <LiteAdminPage />;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActiv
|
|||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { LiteAdminFrame } from "@/components/liteadmin/LiteAdmin";
|
||||
import HomePage from "./HomePage";
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
accessToken: "sk-test",
|
||||
|
|
@ -144,7 +145,7 @@ describe("HomePage", () => {
|
|||
expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should offer Aria Agent on Home inside the dashboard frame", async () => {
|
||||
it("keeps Home focused on usage without an embedded Aria composer", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<LiteAdminFrame>
|
||||
|
|
@ -152,9 +153,8 @@ describe("HomePage", () => {
|
|||
</LiteAdminFrame>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const entry = screen.getByRole("region", { name: "Ask Aria Agent" });
|
||||
expect(await within(entry).findByPlaceholderText("What would you like to do?")).toBeInTheDocument();
|
||||
expect(within(entry).getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("$501.74")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Page } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { LiteAdminHome } from "@/components/liteadmin/LiteAdmin";
|
||||
import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
|
||||
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
||||
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
|
||||
|
|
@ -41,8 +40,6 @@ export default function HomePage() {
|
|||
)}
|
||||
</PageHeader>
|
||||
|
||||
<LiteAdminHome />
|
||||
|
||||
<section aria-label={`Usage, last ${HOME_USAGE_DAYS} days`}>
|
||||
{usage.failed ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3 text-sm">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
createDeploymentHealthHandle,
|
||||
DeploymentHealthBanner,
|
||||
DeploymentHealthIcon,
|
||||
DeploymentHealthLink,
|
||||
} from "@/components/DeploymentHealth/DeploymentHealth";
|
||||
import { DeploymentHealthPanel } from "@/components/DeploymentHealth/DeploymentHealthPanel";
|
||||
import { UserBanner } from "@/components/UserBanner";
|
||||
|
|
@ -151,6 +152,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
accessToken={accessToken}
|
||||
isPublicPage={false}
|
||||
deploymentHealth={<DeploymentHealthIcon handle={deploymentHealthHandle} />}
|
||||
deploymentHealthLink={<DeploymentHealthLink handle={deploymentHealthHandle} />}
|
||||
/>
|
||||
<DeploymentHealthBanner handle={deploymentHealthHandle} />
|
||||
<DeploymentHealthPanel handle={deploymentHealthHandle} />
|
||||
|
|
@ -166,28 +168,28 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
// sidebar owns its own scroll and the content column scrolls independently,
|
||||
// so the page can't be dragged past the end of the nav.
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
|
||||
<div className="hidden h-full md:flex">
|
||||
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
|
||||
</div>
|
||||
<Sheet
|
||||
handle={mobileNavigationHandle}
|
||||
open={mobileNavigationOpen}
|
||||
onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
|
||||
onClickCapture={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
|
||||
}}
|
||||
<LiteAdminFrame>
|
||||
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
|
||||
<div className="hidden h-full md:flex">
|
||||
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
|
||||
</div>
|
||||
<Sheet
|
||||
handle={mobileNavigationHandle}
|
||||
open={mobileNavigationOpen}
|
||||
onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<LiteAdminFrame>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
|
||||
onClickCapture={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
|
||||
}}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<CommandPaletteProvider>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader
|
||||
|
|
@ -203,18 +205,25 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
</SheetTrigger>
|
||||
}
|
||||
deploymentHealth={<DeploymentHealthIcon handle={deploymentHealthHandle} />}
|
||||
deploymentHealthLink={<DeploymentHealthLink handle={deploymentHealthHandle} />}
|
||||
/>
|
||||
<DeploymentHealthBanner handle={deploymentHealthHandle} />
|
||||
<DeploymentHealthPanel handle={deploymentHealthHandle} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
<main
|
||||
className={
|
||||
routeSegment === "aria"
|
||||
? "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
: "min-h-0 min-w-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
<TelemetryEnvBanner />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</CommandPaletteProvider>
|
||||
</LiteAdminFrame>
|
||||
</div>
|
||||
</div>
|
||||
</LiteAdminFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,8 +248,7 @@ vi.mock("@/components/EntityUsageExport", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/liteadmin/LiteAdmin", () => ({
|
||||
AriaUsageTrigger: () => <button aria-label="Open Aria Agent">Aria</button>,
|
||||
AriaUsageComposer: () => <section aria-label="Ask Aria Agent">Shared Aria composer</section>,
|
||||
AriaUsageTrigger: () => <a href="/aria">Ask Aria</a>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({
|
||||
|
|
@ -1451,19 +1450,19 @@ describe("UsagePage", () => {
|
|||
});
|
||||
|
||||
describe("Aria Agent", () => {
|
||||
it("should render the shared Aria prompt and entry point in global view", async () => {
|
||||
it("offers a link to Aria without a second composer in global view", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Open Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Ask Aria" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Ask AI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the shared Aria prompt available in entity usage views", async () => {
|
||||
it("does not embed a second Aria composer in entity usage views", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -1475,8 +1474,8 @@ describe("UsagePage", () => {
|
|||
});
|
||||
|
||||
expect(await screen.findByText("Entity Usage")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("region", { name: "Ask Aria Agent" })).toHaveLength(1);
|
||||
expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
|
||||
expect(screen.queryByPlaceholderText(/Ask Aria/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
|||
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
|
||||
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
|
||||
import { AriaUsageComposer, AriaUsageTrigger } from "@/components/liteadmin/LiteAdmin";
|
||||
import { AriaUsageTrigger } from "@/components/liteadmin/LiteAdmin";
|
||||
import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import UserRecordLink from "./UserRecordLink";
|
||||
import {
|
||||
|
|
@ -489,7 +489,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AriaUsageComposer />
|
||||
{aggregatedFailed && (
|
||||
<ErrorState title="Failed to load spend data" message={aggregatedError} onRetry={reloadAggregated} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Search } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -9,7 +10,7 @@ import { paletteScopeForRoute } from "./utils";
|
|||
import { useCommandPalette } from "./CommandPaletteProvider";
|
||||
import { useShortcutLabel } from "./useShortcutLabel";
|
||||
|
||||
export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) {
|
||||
export function CommandPaletteTrigger({ mobile = false, className }: { mobile?: boolean; className?: string }) {
|
||||
const pathname = usePathname();
|
||||
const { open, setOpen } = useCommandPalette();
|
||||
const shortcut = useShortcutLabel();
|
||||
|
|
@ -21,7 +22,7 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 md:hidden"
|
||||
className={cn("text-muted-foreground", className ?? "size-11 md:hidden")}
|
||||
aria-label="Search"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
|
|
@ -33,8 +34,11 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
|
|||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden w-64 justify-between md:flex lg:w-80"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"justify-between bg-muted/50 font-normal text-muted-foreground hover:bg-muted",
|
||||
className ?? "hidden w-64 md:flex lg:w-80",
|
||||
)}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
|
|
@ -43,7 +47,7 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
|
|||
<Search />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<Kbd>{shortcut}</Kbd>
|
||||
<Kbd className="bg-background/70 font-normal">{shortcut}</Kbd>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { PluginModeProvider } from "@/contexts/PluginModeContext";
|
|||
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/ui/logs",
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
|
@ -52,7 +56,7 @@ afterEach(() => {
|
|||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function openTools() {
|
||||
function renderHeader() {
|
||||
const user = userEvent.setup();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
|
|
@ -64,49 +68,53 @@ async function openTools() {
|
|||
</PluginModeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "More options" }));
|
||||
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
|
||||
return { user, tools };
|
||||
return { user };
|
||||
}
|
||||
|
||||
describe("mobile header nested controls", () => {
|
||||
it.each(["mouse", "touch"] as const)(
|
||||
"selects a portalled gateway item with %s without dismissing More",
|
||||
async (pointer) => {
|
||||
const { user, tools } = await openTools();
|
||||
await user.click(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
|
||||
const item = await screen.findByRole("menuitem", { name: "Metrics" });
|
||||
if (pointer === "touch") {
|
||||
await user.pointer([
|
||||
{ keys: "[TouchA>]", target: item },
|
||||
{ keys: "[/TouchA]", target: item },
|
||||
]);
|
||||
} else {
|
||||
await user.click(item);
|
||||
}
|
||||
expect(within(tools).getByRole("button", { name: "Switch workspace, current: Metrics" })).toBeVisible();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a nested blog link receive the click before closing its menu", async () => {
|
||||
const { user, tools } = await openTools();
|
||||
await user.click(within(tools).getByRole("button", { name: "Blog" }));
|
||||
const link = await screen.findByRole("link", { name: /Gateway update/ });
|
||||
expect(link).toHaveAttribute("href", "https://example.com/update");
|
||||
await user.click(link);
|
||||
expect(screen.queryByRole("menu", { name: "Blog" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
describe("dashboard header controls", () => {
|
||||
it("opens search from both header controls", async () => {
|
||||
const { user } = renderHeader();
|
||||
await user.click(screen.getByRole("button", { name: "Search or jump to…" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "Search", exact: true }));
|
||||
expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("allows filtering and selecting a worker from the nested combobox", async () => {
|
||||
it("offers docs, community and latest posts under the bell", async () => {
|
||||
const { user } = renderHeader();
|
||||
await user.click(screen.getByRole("button", { name: /Notifications/ }));
|
||||
const tools = await screen.findByRole("dialog", { name: "What's new" });
|
||||
expect(await within(tools).findByRole("link", { name: /Gateway update/ })).toHaveAttribute(
|
||||
"href",
|
||||
"https://example.com/update",
|
||||
);
|
||||
expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/",
|
||||
);
|
||||
expect(within(tools).getByRole("link", { name: "Discord", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
it("respects the saved blog and community visibility settings", async () => {
|
||||
localStorage.setItem("disableBlogPosts", "true");
|
||||
localStorage.setItem("disableShowPrompts", "true");
|
||||
const { user } = renderHeader();
|
||||
expect(screen.queryByRole("link", { name: "Join Slack" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Notifications/ }));
|
||||
const tools = await screen.findByRole("dialog", { name: "What's new" });
|
||||
expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toBeVisible();
|
||||
expect(within(tools).queryByRole("link", { name: "Blog", exact: true })).not.toBeInTheDocument();
|
||||
expect(within(tools).queryByRole("link", { name: "Discord", exact: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows filtering and selecting a worker", async () => {
|
||||
localStorage.setItem("litellm_selected_worker_id", "primary");
|
||||
const { user, tools } = await openTools();
|
||||
const worker = await within(tools).findByRole("combobox", { name: "Worker" });
|
||||
const { user } = renderHeader();
|
||||
const worker = await screen.findByRole("combobox", { name: "Worker" });
|
||||
await user.clear(worker);
|
||||
await user.type(worker, "Backup");
|
||||
await user.click(await screen.findByRole("option", { name: "Backup worker" }));
|
||||
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
|
||||
|
||||
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
|
||||
|
|
@ -66,87 +65,15 @@ describe("DashboardHeader breadcrumb", () => {
|
|||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {
|
||||
state.enableChatUI = true;
|
||||
it("names the Aria workspace in the header", () => {
|
||||
state.pathname = "/ui/aria";
|
||||
renderDashboardHeader();
|
||||
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
|
||||
|
||||
const selector = screen.getByRole("button", { name: /AI Gateway/i });
|
||||
act(() => {
|
||||
fireEvent.click(selector);
|
||||
});
|
||||
expect(await screen.findByText("Chat")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => {
|
||||
it("keeps the scoped key search available", () => {
|
||||
state.pathname = "/ui/api-keys";
|
||||
renderDashboardHeader();
|
||||
|
||||
expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("styles Docs with the shared product-link class instead of a muted toolbar button", () => {
|
||||
renderDashboardHeader();
|
||||
|
||||
const docs = screen.getByRole("link", { name: "Docs" });
|
||||
for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) {
|
||||
expect(docs).toHaveClass(cls);
|
||||
}
|
||||
expect(docs).not.toHaveClass("text-muted-foreground");
|
||||
});
|
||||
|
||||
it("renders the tools divider centered rather than stretched to the top of the row", () => {
|
||||
const { container } = renderDashboardHeader();
|
||||
|
||||
const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]');
|
||||
expect(separators).toHaveLength(1);
|
||||
expect(separators[0].className).not.toMatch(/self-stretch/);
|
||||
expect(separators[0].className).toContain("data-vertical:self-center");
|
||||
});
|
||||
|
||||
it("should place Aria Agent in the header tools ahead of Docs", () => {
|
||||
renderDashboardHeader();
|
||||
|
||||
const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" });
|
||||
expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep the gateway selector and tools available from the compact header menu", async () => {
|
||||
renderDashboardHeader();
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "More options" }));
|
||||
});
|
||||
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
|
||||
expect(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" })).toBeInTheDocument();
|
||||
expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: "Aria Agent" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => {
|
||||
const { rerender } = renderDashboardHeader();
|
||||
fireEvent.click(screen.getByRole("button", { name: "More options" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument();
|
||||
|
||||
state.isDesktop = true;
|
||||
rerender(
|
||||
<CommandPaletteProvider>
|
||||
<DashboardHeader accessToken={null} />
|
||||
</CommandPaletteProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
|
||||
|
||||
state.isDesktop = false;
|
||||
rerender(
|
||||
<CommandPaletteProvider>
|
||||
<DashboardHeader accessToken={null} />
|
||||
</CommandPaletteProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Search keys…" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,55 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
import { getBreadcrumb } from "@/components/leftnav";
|
||||
import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
|
||||
import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink";
|
||||
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
|
||||
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
|
||||
import LiteAdmin from "@/components/liteadmin/LiteAdmin";
|
||||
import type { ReactNode } from "react";
|
||||
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { getBreadcrumb } from "@/components/leftnav";
|
||||
import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger";
|
||||
import { DashboardCommunityLinks } from "@/components/Navbar/DashboardResources";
|
||||
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
|
||||
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger";
|
||||
|
||||
// Top bar for the dashboard shell. Sits only over the content column (the brand
|
||||
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
|
||||
export function DashboardHeader({
|
||||
accessToken,
|
||||
navigationTrigger,
|
||||
deploymentHealth,
|
||||
deploymentHealthLink,
|
||||
}: {
|
||||
accessToken: string | null;
|
||||
navigationTrigger?: ReactNode;
|
||||
deploymentHealth?: ReactNode;
|
||||
deploymentHealthLink?: ReactNode;
|
||||
}) {
|
||||
const term = useTerminology();
|
||||
const { title } = getBreadcrumb(usePathname());
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
if (isDesktop && mobileToolsOpen) setMobileToolsOpen(false);
|
||||
const { isControlPlane, selectedWorker } = useWorker();
|
||||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
const hideCommunityLinks = useDisableShowPrompts();
|
||||
|
||||
const showWorkerSelector = isControlPlane && selectedWorker !== null;
|
||||
const handleWorkerSwitch = (workerId: string) => {
|
||||
clearTokenCookies();
|
||||
clearStoredReturnUrl();
|
||||
|
|
@ -57,67 +35,34 @@ export function DashboardHeader({
|
|||
localStorage.removeItem("litellm_worker_url");
|
||||
window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4 max-md:gap-2 max-md:px-2">
|
||||
{navigationTrigger}
|
||||
<Breadcrumb className="min-w-0 max-md:flex-1">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="flex-none max-md:hidden">
|
||||
<ViewSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="max-md:hidden" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{term(title)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<CommandPaletteTrigger />
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<LiteAdmin />
|
||||
<div className="flex flex-none items-center gap-1 max-md:hidden">
|
||||
{showWorkerSwitch && (
|
||||
<header className="@container h-14 shrink-0 border-b border-border bg-background px-3 sm:px-5">
|
||||
<div className="flex h-full items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{navigationTrigger}
|
||||
<h1 className="truncate text-sm font-medium">{term(title)}</h1>
|
||||
</div>
|
||||
<CommandPaletteTrigger className="hidden w-56 @min-[640px]:flex @min-[1000px]:w-80" />
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
<CommandPaletteTrigger mobile className="size-9 @min-[640px]:hidden" />
|
||||
{showWorkerSelector && (
|
||||
<>
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
<ToolbarSeparator />
|
||||
<div className="@min-[840px]:hidden">
|
||||
<WorkerDropdown compact onWorkerSwitch={handleWorkerSwitch} />
|
||||
</div>
|
||||
<div className="hidden @min-[840px]:block">
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DocsLink />
|
||||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
<ToolbarSeparator />
|
||||
<DashboardCommunityLinks
|
||||
className={showWorkerSelector ? "hidden @min-[840px]:flex" : "hidden @min-[400px]:flex"}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
{deploymentHealth}
|
||||
<NotificationsBell accessToken={accessToken} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 md:hidden">
|
||||
<CommandPaletteTrigger mobile />
|
||||
{deploymentHealth}
|
||||
<NotificationsBell accessToken={accessToken} />
|
||||
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
|
||||
<PopoverTrigger
|
||||
render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}
|
||||
>
|
||||
<Ellipsis />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-w-[calc(100vw-2rem)]">
|
||||
<PopoverTitle>Gateway tools</PopoverTitle>
|
||||
<ViewSwitcher />
|
||||
{showWorkerSwitch && <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<DocsLink />
|
||||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<NotificationsBell accessToken={accessToken} deploymentHealthLink={deploymentHealthLink} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardHeader;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { NotificationsBellView } from "@/components/Navbar/NotificationsBell/NotificationsBell";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { DeploymentWarning } from "@/app/(dashboard)/hooks/deploymentWarnings/useDeploymentWarnings";
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils";
|
||||
import { createDeploymentHealthHandle, DeploymentHealthBanner, DeploymentHealthIcon } from "./DeploymentHealth";
|
||||
import {
|
||||
createDeploymentHealthHandle,
|
||||
DeploymentHealthBanner,
|
||||
DeploymentHealthIcon,
|
||||
DeploymentHealthLink,
|
||||
} from "./DeploymentHealth";
|
||||
import { DeploymentHealthPanel } from "./DeploymentHealthPanel";
|
||||
|
||||
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
|
||||
|
|
@ -84,6 +90,11 @@ const Harness = () => {
|
|||
<>
|
||||
<header>
|
||||
<DeploymentHealthIcon handle={handle} />
|
||||
<NotificationsBellView
|
||||
currentVersion={null}
|
||||
latestRelease={null}
|
||||
deploymentHealthLink={<DeploymentHealthLink handle={handle} />}
|
||||
/>
|
||||
</header>
|
||||
<DeploymentHealthBanner handle={handle} />
|
||||
<DeploymentHealthPanel handle={handle} />
|
||||
|
|
@ -97,7 +108,13 @@ const renderHealth = (userRole = "proxy_admin") => {
|
|||
};
|
||||
|
||||
const openPanel = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(await screen.findByRole("button", { name: /^Deployment health/ }));
|
||||
await waitFor(() => expect(proxy.gets).toBeGreaterThan(0));
|
||||
const icon = screen.queryByRole("button", { name: /^Deployment health/ });
|
||||
if (icon) await user.click(icon);
|
||||
else {
|
||||
await user.click(screen.getByRole("button", { name: /^Notifications/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Deployment health", exact: true }));
|
||||
}
|
||||
return screen.findByRole("dialog", { name: "Deployment health" });
|
||||
};
|
||||
|
||||
|
|
@ -124,15 +141,17 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("deployment health icon", () => {
|
||||
it("should show a plain shield with no count when every warning is ignored", async () => {
|
||||
proxy.warnings = [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }];
|
||||
renderHealth();
|
||||
it.each([{ warnings: [] }, { warnings: [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }] }])(
|
||||
"hides the shield without active warnings: $warnings",
|
||||
async ({ warnings }) => {
|
||||
proxy.warnings = warnings;
|
||||
renderHealth();
|
||||
|
||||
await waitFor(() => expect(proxy.gets).toBe(1));
|
||||
const icon = await screen.findByRole("button", { name: "Deployment health" });
|
||||
expect(icon).toHaveTextContent(/^$/);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => expect(proxy.gets).toBe(1));
|
||||
expect(screen.queryByRole("button", { name: /^Deployment health/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl
|
|||
if (!canView) return null;
|
||||
|
||||
const active = activeWarnings(data?.warnings ?? []);
|
||||
if (active.length === 0) return null;
|
||||
const hasCritical = active.some((warning) => warning.severity === "critical");
|
||||
|
||||
return (
|
||||
|
|
@ -50,6 +51,26 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl
|
|||
);
|
||||
};
|
||||
|
||||
export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandle }) => {
|
||||
const { canView } = useDeploymentHealthAccess();
|
||||
if (!canView) return null;
|
||||
return (
|
||||
<SheetTrigger
|
||||
handle={handle}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-xs font-normal text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Shield className="size-4" aria-hidden />
|
||||
Deployment health
|
||||
</SheetTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHandle }) => {
|
||||
const { canView } = useDeploymentHealthAccess();
|
||||
const { data } = useDeploymentWarnings();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { BookOpen, Github, Newspaper, ScrollText, Slack } from "lucide-react";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useBlogPosts } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function DiscordIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.32 4.37a19.8 19.8 0 0 0-4.89-1.52.07.07 0 0 0-.08.04c-.21.38-.44.87-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.6 12.6 0 0 0-.62-1.25.08.08 0 0 0-.08-.04 19.74 19.74 0 0 0-4.89 1.52.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .08-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13l.37-.29a.07.07 0 0 1 .08-.01c3.93 1.79 8.18 1.79 12.06 0a.07.07 0 0 1 .08.01l.37.29a.08.08 0 0 1-.01.13c-.6.35-1.22.65-1.87.89a.08.08 0 0 0-.04.11c.36.7.78 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .03-.05c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.03-.03ZM8.02 15.33c-1.18 0-2.16-1.08-2.16-2.42 0-1.33.96-2.42 2.16-2.42 1.21 0 2.18 1.1 2.16 2.42 0 1.34-.96 2.42-2.16 2.42Zm7.97 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.21 0 2.18 1.1 2.16 2.42 0 1.34-.95 2.42-2.16 2.42Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const COMMUNITY = [
|
||||
{ href: "https://www.litellm.ai/support", label: "Slack", accessibleLabel: "Join Slack", Icon: Slack },
|
||||
{ href: "https://discord.gg/wuPM9dRgDw", label: "Discord", accessibleLabel: "Join Discord", Icon: DiscordIcon },
|
||||
{ href: "https://github.com/BerriAI/litellm", label: "GitHub", accessibleLabel: "LiteLLM on GitHub", Icon: Github },
|
||||
];
|
||||
const DOCS = [
|
||||
{ href: "https://docs.litellm.ai/docs/", label: "Docs", Icon: BookOpen },
|
||||
{ href: "https://docs.litellm.ai/release_notes", label: "Releases", Icon: ScrollText },
|
||||
];
|
||||
const BLOG = { href: "https://docs.litellm.ai/blog", label: "Blog", Icon: Newspaper };
|
||||
|
||||
export function DashboardCommunityLinks({ className }: { className?: string }) {
|
||||
const hidden = useDisableShowPrompts();
|
||||
if (hidden) return null;
|
||||
return (
|
||||
<nav aria-label="Community links" className={cn("items-center gap-0.5", className)}>
|
||||
<TooltipProvider>
|
||||
{COMMUNITY.map(({ href, label, accessibleLabel, Icon }) => (
|
||||
<Tooltip key={label}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "text-muted-foreground")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<ToolbarSeparator className="mx-1.5 h-4 bg-foreground/15" />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardResources({ children }: { children?: ReactNode }) {
|
||||
const hideBlog = useDisableBlogPosts();
|
||||
const hideCommunity = useDisableShowPrompts();
|
||||
const { data, isPending, isError, refetch } = useBlogPosts();
|
||||
const links = [...DOCS, ...(hideBlog ? [] : [BLOG]), ...(hideCommunity ? [] : COMMUNITY)];
|
||||
return (
|
||||
<>
|
||||
{!hideBlog && (
|
||||
<section aria-label="Latest posts" className="border-t border-border px-2 py-2">
|
||||
{isPending && <p className="px-2 py-3 text-xs text-muted-foreground">Loading posts…</p>}
|
||||
{isError && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
|
||||
Retry loading posts
|
||||
</Button>
|
||||
)}
|
||||
{data?.posts.slice(0, 3).map((post) => (
|
||||
<a
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-baseline justify-between gap-4 rounded-md px-2 py-2 text-[13px] leading-5 hover:bg-muted focus-visible:outline-ring"
|
||||
>
|
||||
<span className="line-clamp-2">{post.title}</span>
|
||||
<time dateTime={post.date} className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{new Date(`${post.date}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</time>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<div className="border-t border-border bg-muted/25 p-2">
|
||||
<nav aria-label="Help and resources" className="grid grid-cols-3 gap-1">
|
||||
{links.map(({ href, label, Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||
"justify-start gap-2 text-xs font-normal text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,11 +12,12 @@ import {
|
|||
} from "@/app/(dashboard)/hooks/useAnnouncementRead";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { DashboardResources } from "@/components/Navbar/DashboardResources";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { isNewerVersion } from "@/utils/versionUtils";
|
||||
import { Bell } from "lucide-react";
|
||||
import { ArrowUpRight, Bell } from "lucide-react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
|
||||
|
|
@ -68,18 +69,27 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
|
|||
return (
|
||||
<article aria-label={announcement.title} className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">{announcement.title}</p>
|
||||
<p className="text-sm leading-snug text-muted-foreground">{announcement.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{announcement.description}</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link", size: "sm" }),
|
||||
"h-auto gap-1 p-0 text-xs font-normal text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
href={announcement.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{announcement.linkLabel}
|
||||
<ArrowUpRight className="size-3" aria-hidden />
|
||||
</a>
|
||||
{read ? null : (
|
||||
<Button variant="link" size="sm" className="px-1!" onClick={() => markAnnouncementRead(announcement.readKey)}>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-0! text-xs font-normal text-muted-foreground"
|
||||
onClick={() => markAnnouncementRead(announcement.readKey)}
|
||||
>
|
||||
Mark as read
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -89,11 +99,16 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
|
|||
};
|
||||
|
||||
interface NotificationsBellViewProps {
|
||||
deploymentHealthLink?: React.ReactNode;
|
||||
currentVersion: string | null | undefined;
|
||||
latestRelease: LatestReleaseInfo | null | undefined;
|
||||
}
|
||||
|
||||
export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ currentVersion, latestRelease }) => {
|
||||
export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
|
||||
currentVersion,
|
||||
latestRelease,
|
||||
deploymentHealthLink,
|
||||
}) => {
|
||||
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
|
||||
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
|
||||
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
|
||||
|
|
@ -101,7 +116,7 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ cu
|
|||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className="flex! h-9! w-9! items-center justify-center rounded-md! text-muted-foreground transition-colors hover:bg-accent! hover:text-foreground!"
|
||||
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
|
|
@ -109,20 +124,39 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ cu
|
|||
{unreadCount > 0 ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" aria-label="Notifications">
|
||||
{announcements.map((announcement, index) => (
|
||||
<Fragment key={announcement.readKey}>
|
||||
{index > 0 ? <Separator /> : null}
|
||||
<AnnouncementItem announcement={announcement} />
|
||||
</Fragment>
|
||||
))}
|
||||
<PopoverContent
|
||||
align="end"
|
||||
aria-label="Notifications"
|
||||
className="w-96 max-w-[calc(100vw-1rem)] gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<PopoverTitle className="border-b border-border px-4 py-3 text-sm font-semibold">What's new</PopoverTitle>
|
||||
<div className="max-h-[min(32rem,calc(100dvh-10rem))] overflow-y-auto">
|
||||
<div className="space-y-4 p-4">
|
||||
{announcements.map((announcement, index) => (
|
||||
<Fragment key={announcement.readKey}>
|
||||
{index > 0 ? <Separator /> : null}
|
||||
<AnnouncementItem announcement={announcement} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<DashboardResources>{deploymentHealthLink}</DashboardResources>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
|
||||
export const NotificationsBell: React.FC<{ accessToken: string | null; deploymentHealthLink?: React.ReactNode }> = ({
|
||||
accessToken,
|
||||
deploymentHealthLink,
|
||||
}) => {
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
|
||||
return <NotificationsBellView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
|
||||
return (
|
||||
<NotificationsBellView
|
||||
currentVersion={healthData?.litellm_version}
|
||||
latestRelease={latestRelease}
|
||||
deploymentHealthLink={deploymentHealthLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ describe("ViewSwitcher", () => {
|
|||
);
|
||||
|
||||
it.each(["/ui/moyai", "/ui/moyai/sessions/one"])(
|
||||
"should show Moyai's icon at %s and let users switch to Chat",
|
||||
"should identify Moyai at %s and let users switch to Chat",
|
||||
async (pathname) => {
|
||||
state.pathname = pathname;
|
||||
getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } });
|
||||
|
|
@ -170,7 +170,7 @@ describe("ViewSwitcher", () => {
|
|||
renderSwitcher();
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "Switch workspace, current: Moyai" });
|
||||
expect(within(trigger).getByAltText("")).toHaveAttribute("src", expect.stringContaining("moyai-head.svg"));
|
||||
expect(trigger).toHaveTextContent("Moyai");
|
||||
await user.click(trigger);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" }));
|
||||
expect(assignSpy).toHaveBeenCalledWith("/ui/chat");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -9,17 +8,15 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronsUpDown, LayoutGrid, MessageCircle } from "lucide-react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { usePluginMode } from "@/contexts/PluginModeContext";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
|
||||
|
||||
const GATEWAY = "ai-gateway";
|
||||
const CHAT = "chat";
|
||||
const MOYAI = "moyai";
|
||||
type WorkspaceIconName = typeof GATEWAY | typeof CHAT | typeof MOYAI;
|
||||
|
||||
function isWorkspaceRoute(pathname: string, workspace: string): boolean {
|
||||
const routes = [uiHref(workspace)];
|
||||
|
|
@ -27,45 +24,15 @@ function isWorkspaceRoute(pathname: string, workspace: string): boolean {
|
|||
return routes.some((route) => pathname === route || pathname.startsWith(`${route}/`));
|
||||
}
|
||||
|
||||
function activeWorkspaceFor(
|
||||
function activeWorkspaceLabel(
|
||||
isChatRoute: boolean,
|
||||
isMoyaiRoute: boolean,
|
||||
pluginLabel: string | undefined,
|
||||
gatewayLabel: string,
|
||||
): { label: string; icon: WorkspaceIconName } {
|
||||
if (isChatRoute) return { label: "Chat", icon: CHAT };
|
||||
if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI };
|
||||
return { label: pluginLabel ?? gatewayLabel, icon: GATEWAY };
|
||||
}
|
||||
|
||||
function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) {
|
||||
if (workspace === CHAT) {
|
||||
return (
|
||||
<span
|
||||
data-slot="workspace-icon"
|
||||
aria-hidden="true"
|
||||
className="flex size-6 flex-none items-center justify-center rounded-md bg-primary/10 text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (workspace === MOYAI) {
|
||||
return (
|
||||
<span data-slot="workspace-icon" aria-hidden="true" className="flex size-6 flex-none items-center justify-center">
|
||||
<Image src={moyaiHead.src} alt="" width={20} height={20} unoptimized className="size-5 object-contain" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-slot="workspace-icon"
|
||||
aria-hidden="true"
|
||||
className="flex size-6 flex-none items-center justify-center rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</span>
|
||||
);
|
||||
): string {
|
||||
if (isChatRoute) return "Chat";
|
||||
if (isMoyaiRoute) return "Moyai";
|
||||
return pluginLabel ?? gatewayLabel;
|
||||
}
|
||||
|
||||
interface ViewSwitcherItem {
|
||||
|
|
@ -95,7 +62,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
|
||||
|
||||
const gatewayLabel = term("AI Gateway");
|
||||
const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor(
|
||||
const activeLabel = activeWorkspaceLabel(
|
||||
isChatRoute,
|
||||
isMoyaiRoute,
|
||||
plugins.find((p) => p.name === mode)?.display_name,
|
||||
|
|
@ -119,17 +86,14 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
disabled: !chatEnabled,
|
||||
label: (
|
||||
<div className="flex w-full items-center justify-between gap-6 py-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<WorkspaceIcon workspace={CHAT} />
|
||||
<span className="flex max-w-[220px] flex-col">
|
||||
<span className="font-medium">Chat</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
|
||||
{!chatEnabled && (
|
||||
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
|
||||
Admins can enable in Settings
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex max-w-[220px] flex-col">
|
||||
<span className="font-medium">Chat</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
|
||||
{!chatEnabled && (
|
||||
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
|
||||
Admins can enable in Settings
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isChatRoute && <Check className="size-4 text-info" />}
|
||||
</div>
|
||||
|
|
@ -142,10 +106,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
key: e.key,
|
||||
label: (
|
||||
<div className="flex w-full items-center justify-between gap-6 py-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<WorkspaceIcon workspace={GATEWAY} />
|
||||
<span className="font-medium">{e.label}</span>
|
||||
</span>
|
||||
<span className="font-medium">{e.label}</span>
|
||||
{!isStandaloneRoute && e.key === mode && <Check className="size-4 text-info" />}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -156,12 +117,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
key: MOYAI,
|
||||
label: (
|
||||
<div className="flex w-full items-center justify-between gap-6 py-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<WorkspaceIcon workspace={MOYAI} />
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Moyai</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Moyai</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
|
||||
</span>
|
||||
{isMoyaiRoute && <Check className="size-4 text-info" />}
|
||||
</div>
|
||||
|
|
@ -179,11 +137,10 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("max-w-[220px] gap-1.5 pl-1.5 pr-2", className)}
|
||||
className={cn("max-w-[220px] gap-1.5 px-3 dark:bg-transparent", className)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<WorkspaceIcon workspace={activeIcon} />
|
||||
<span data-slot="workspace-label" className="truncate">
|
||||
{activeLabel}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -163,4 +163,22 @@ describe("WorkerDropdown", () => {
|
|||
});
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets users search and switch workers from the compact picker", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<WorkerDropdown compact onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
await user.click(screen.getByRole("combobox", { name: "Switch worker, current: Worker 2" }));
|
||||
expect(await screen.findByRole("option", { name: "Worker 2" })).toHaveAttribute("aria-disabled", "true");
|
||||
await user.type(screen.getByRole("combobox", { name: "Search workers" }), "Worker 3");
|
||||
expect(screen.queryByRole("option", { name: "Worker 1" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("option", { name: "Worker 3" }));
|
||||
|
||||
expect(mockOnWorkerSwitch).toHaveBeenCalledExactlyOnceWith("w3");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import React from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
|
|
@ -15,6 +17,7 @@ import { useWorker } from "@/hooks/useWorker";
|
|||
|
||||
interface WorkerDropdownProps {
|
||||
onWorkerSwitch: (workerId: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface WorkerOption {
|
||||
|
|
@ -23,7 +26,7 @@ interface WorkerOption {
|
|||
disabled: boolean;
|
||||
}
|
||||
|
||||
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
|
||||
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch, compact = false }) => {
|
||||
const { isControlPlane, selectedWorker, workers } = useWorker();
|
||||
|
||||
if (!isControlPlane || !selectedWorker) return null;
|
||||
|
|
@ -45,12 +48,23 @@ const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Server className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent>
|
||||
{compact ? (
|
||||
<ComboboxPrimitive.Trigger
|
||||
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
|
||||
aria-label={`Switch worker, current: ${selectedWorker.name}`}
|
||||
title={selectedWorker.name}
|
||||
>
|
||||
<Server className="size-4" aria-hidden />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
) : (
|
||||
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Server className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
)}
|
||||
<ComboboxContent align={compact ? "end" : "start"} className={compact ? "w-64" : undefined}>
|
||||
{compact && <ComboboxInput aria-label="Search workers" placeholder="Search workers…" showTrigger={false} />}
|
||||
<ComboboxEmpty>No matching workers</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: WorkerOption) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import { TerminologyProvider } from "@/contexts/TerminologyContext";
|
||||
|
|
@ -647,6 +648,13 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps workspace and Chat discovery available with collapsed=%s", async (collapsed) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Sidebar collapsed={collapsed} />);
|
||||
await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
|
||||
expect(await screen.findByRole("menuitem", { name: /^Chat / })).toBeVisible();
|
||||
});
|
||||
|
||||
it("marks the nav item for the current route active", () => {
|
||||
navState.pathname = "/ui/logs";
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
|
@ -660,6 +668,12 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
it("does not highlight Home when the Aria workspace is active", () => {
|
||||
navState.pathname = "/ui/aria";
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("data-active");
|
||||
});
|
||||
|
||||
it("expands the parent group of the current nested route and marks the child active", () => {
|
||||
navState.pathname = "/ui/search-tools";
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ import {
|
|||
Workflow,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
|
||||
import { AriaSidebarLink } from "@/components/liteadmin/LiteAdmin";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -518,6 +520,7 @@ export const labelText = (item: MenuItem): string => (typeof item.label === "str
|
|||
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
|
||||
export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => {
|
||||
const route = routeForPathname(pathname);
|
||||
if (route === "aria") return { section: null, title: "Aria Agent" };
|
||||
for (const group of menuGroups) {
|
||||
for (const item of group.items) {
|
||||
const section = sectionText(group.groupLabel);
|
||||
|
|
@ -552,7 +555,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
const baseUrl = getProxyBaseUrl();
|
||||
const version = healthData?.litellm_version;
|
||||
const currentRoute = routeForPathname(usePathname());
|
||||
const selectedKey = findMenuItemKey(currentRoute);
|
||||
const selectedKey = currentRoute === "aria" ? null : findMenuItemKey(currentRoute);
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(() => {
|
||||
const parent = findParentKey(currentRoute);
|
||||
|
|
@ -730,6 +733,10 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<div className="space-y-1 px-3 pt-3 pb-1">
|
||||
<ViewSwitcher className="h-9 w-full max-w-none justify-start shadow-none group-data-[collapsed=true]/sidebar:size-9 group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:p-0 [&_[data-slot=workspace-label]]:flex-1 [&_[data-slot=workspace-label]]:text-left group-data-[collapsed=true]/sidebar:[&_[data-slot=workspace-label]]:hidden" />
|
||||
<AriaSidebarLink active={currentRoute === "aria"} />
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<nav className="flex flex-col gap-0.5 px-3 pb-3">
|
||||
{visibleGroups.map((group, gi) => (
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export function AriaAnswer({
|
|||
};
|
||||
return (
|
||||
<article aria-label={`${ARIA_AGENT_LABEL} answer`} className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{ARIA_AGENT_LABEL}</p>
|
||||
<div ref={answerRef} className="space-y-4">
|
||||
{chart && <UsageAnswerChart chart={chart} />}
|
||||
<ChatMessageContent
|
||||
|
|
@ -35,9 +34,16 @@ export function AriaAnswer({
|
|||
isLastMessage={false}
|
||||
isStreaming={false}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={exportAnswer} data-export-exclude>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={exportAnswer}
|
||||
data-export-exclude
|
||||
aria-label="Export answer"
|
||||
title="Export answer"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Export answer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ describe("AriaAvatar", () => {
|
|||
setServerRootPath("/");
|
||||
});
|
||||
|
||||
it.each(["/", "/litellm"])("loads the bundled image when the server root is %s", (root) => {
|
||||
it.each(["/", "/litellm"])("renders a decorative icon without an image request under server root %s", (root) => {
|
||||
setServerRootPath(root);
|
||||
|
||||
render(<AriaAvatar />);
|
||||
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute(
|
||||
"src",
|
||||
"/_next/static/media/aria-agent.png",
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import ariaAgentImage from "../../../public/assets/aria-agent.png";
|
||||
import { Sparkle } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export function AriaAvatar({ size = 28, className }: { size?: number; className?: string }) {
|
||||
export function AriaAvatar({ size = 20, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src={ariaAgentImage.src}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
<Sparkle
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("shrink-0 object-contain [image-rendering:pixelated]", className)}
|
||||
className={cn("shrink-0", className)}
|
||||
style={{ width: size, height: size }}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
54
ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx
Normal file
54
ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { AriaAccess } from "./operations";
|
||||
import { ChartNoAxesColumnIncreasing, Coins, TrendingDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const QUESTIONS = [
|
||||
{ label: "Spend overview", question: "How much have I spent this week?", Icon: Coins },
|
||||
{ label: "Cost by model", question: "Which models are costing the most?", Icon: ChartNoAxesColumnIncreasing },
|
||||
{ label: "Find savings", question: "How can I reduce my spend?", Icon: TrendingDown },
|
||||
];
|
||||
|
||||
export function AriaStart({
|
||||
composer,
|
||||
onQuestion,
|
||||
disabled,
|
||||
access,
|
||||
}: {
|
||||
composer: ReactNode;
|
||||
onQuestion: (question: string) => void;
|
||||
disabled: boolean;
|
||||
access: AriaAccess;
|
||||
}) {
|
||||
const usageDescription =
|
||||
access.mode === "usage" && access.scope.type === "user" ? "Ask about your usage" : "Ask about gateway usage";
|
||||
const description = access.mode === "admin" ? "Manage your gateway · Changes require approval" : usageDescription;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-10 sm:px-6 sm:pb-[16vh]">
|
||||
<div className="my-auto w-full shrink-0 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl leading-tight font-medium tracking-[-0.035em] text-foreground sm:text-4xl">
|
||||
What would you like to know?
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="[&>div]:p-0">{composer}</div>
|
||||
<div aria-label="Suggested questions" className="flex flex-wrap gap-2">
|
||||
{QUESTIONS.map(({ label, question, Icon }) => (
|
||||
<Button
|
||||
key={label}
|
||||
variant="outline"
|
||||
aria-label={question}
|
||||
disabled={disabled}
|
||||
onClick={() => onQuestion(question)}
|
||||
className="h-9 gap-2 rounded-full border-border bg-transparent dark:bg-transparent px-3.5 text-[13px] font-normal text-muted-foreground shadow-none hover:border-foreground/20 hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,19 +11,19 @@ import userEvent from "@testing-library/user-event";
|
|||
import { Fragment, useEffect, type ComponentType } from "react";
|
||||
import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMenu";
|
||||
import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown";
|
||||
import LiteAdmin, { AriaUsageComposer, AriaUsageTrigger, LiteAdminFrame, LiteAdminHome } from "./LiteAdmin";
|
||||
import { AriaUsageTrigger, LiteAdminFrame, LiteAdminPage, AriaSidebarLink } from "./LiteAdmin";
|
||||
import { MAX_INPUT_LENGTH } from "./agent";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
const { transport } = vi.hoisted(() => {
|
||||
const { transport, push } = vi.hoisted(() => {
|
||||
const transport = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal("fetch", transport);
|
||||
return { transport };
|
||||
return { transport, push: vi.fn() };
|
||||
});
|
||||
|
||||
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
|
||||
vi.unmock("@/lib/toast");
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
const MANAGEMENT = "https://management.test/proxy";
|
||||
const INFERENCE = "https://management.test/inference";
|
||||
|
|
@ -108,11 +108,11 @@ function renderWidget(
|
|||
Menu?: ComponentType<{ onLogout: () => void }>,
|
||||
showHome = false,
|
||||
showUsage = false,
|
||||
withTerminology = false,
|
||||
{ withTerminology = false, showPage = true }: { withTerminology?: boolean; showPage?: boolean } = {},
|
||||
) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
const Terminology = withTerminology ? TerminologyProvider : Fragment;
|
||||
const tree = (home = showHome, usage = showUsage) => (
|
||||
const tree = (_home = showHome, usage = showUsage, page = showPage) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<Toaster />
|
||||
<Terminology>
|
||||
|
|
@ -121,11 +121,10 @@ function renderWidget(
|
|||
{Menu && <Menu onLogout={() => undefined} />}
|
||||
<LiteAdminFrame>
|
||||
<Page />
|
||||
{home && <LiteAdminHome />}
|
||||
<LiteAdmin />
|
||||
<AriaSidebarLink active={page} />
|
||||
{page && <LiteAdminPage />}
|
||||
{usage && (
|
||||
<>
|
||||
<AriaUsageComposer />
|
||||
<AriaUsageTrigger />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -135,7 +134,11 @@ function renderWidget(
|
|||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree());
|
||||
return { ...view, refresh: (home = showHome, usage = showUsage) => view.rerender(tree(home, usage)), client };
|
||||
return {
|
||||
...view,
|
||||
refresh: (home = showHome, usage = showUsage, page = showPage) => view.rerender(tree(home, usage, page)),
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
|
|
@ -226,7 +229,6 @@ async function selectModel(model = "chat-model") {
|
|||
}
|
||||
|
||||
async function openWidget() {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
await selectModel();
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +240,7 @@ function send(text: string) {
|
|||
beforeEach(() => {
|
||||
pageMounts = 0;
|
||||
transport.mockReset();
|
||||
push.mockReset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
switchToWorkerUrl(null);
|
||||
|
|
@ -251,17 +254,57 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("Aria Agent in the gateway", () => {
|
||||
it.each([true, false])(
|
||||
"keeps the answer, draft and model when returning to Aria with premium=%s",
|
||||
async (premium) => {
|
||||
session("proxy_admin", "first-admin", { premium_user: premium });
|
||||
gateway([answer("Your usage is within budget.")]);
|
||||
const view = renderWidget();
|
||||
await selectModel();
|
||||
fireEvent.keyDown(document, { key: "j", metaKey: true });
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus();
|
||||
send("Check my usage");
|
||||
await screen.findByText("Your usage is within budget.");
|
||||
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "What changed?" } });
|
||||
view.refresh(false, false, false);
|
||||
expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
|
||||
expect(push).toHaveBeenCalledWith("/ui/aria");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
view.refresh(false, false, true);
|
||||
expect(screen.getByText("Your usage is within budget.")).toBeVisible();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("What changed?");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.getByRole("region", { name: "Aria Agent", exact: true })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("links Usage and the sidebar to one page without mounting a composer elsewhere", async () => {
|
||||
const requests = gateway([]);
|
||||
const { client } = renderWidget(undefined, false, true, { showPage: false });
|
||||
expect(await screen.findByRole("link", { name: "Ask Aria" })).toHaveAttribute("href", "/ui/aria");
|
||||
expect(screen.getByRole("link", { name: "Aria Agent" })).toHaveAttribute("href", "/ui/aria");
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
expect(requests.some((r) => r.url.endsWith("/models") || r.url.endsWith("/sso/get/ui_settings"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["proxy_admin", "internal_user"])(
|
||||
"should start a conversation from the exact suggested question without changing %s permissions",
|
||||
"prefills suggestions without sending and submits the edited draft with %s permissions",
|
||||
async (role) => {
|
||||
session(role);
|
||||
const requests = gateway([answer("Here is your weekly spend.")]);
|
||||
renderWidget();
|
||||
await userEvent.setup().click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
const question = "How much have I spent this week?";
|
||||
const suggestion = await screen.findByRole("button", { name: question });
|
||||
await waitFor(() => expect(suggestion).toBeEnabled());
|
||||
act(() => fireEvent.click(suggestion));
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue(question);
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus();
|
||||
expect(requests.filter((r) => r.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
act(() => send(question + " Show a daily breakdown."));
|
||||
expect(await screen.findByText("Here is your weekly spend.")).toBeInTheDocument();
|
||||
for (const label of [question, "Which models are costing the most?", "How can I reduce my spend?"]) {
|
||||
expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument();
|
||||
|
|
@ -270,7 +313,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].body.messages).toEqual([
|
||||
{ role: "system", content: expect.any(String) },
|
||||
{ role: "user", content: question },
|
||||
{ role: "user", content: question + " Show a daily breakdown." },
|
||||
]);
|
||||
if (role === "internal_user") {
|
||||
expect(completions[0].body.tools).toEqual([
|
||||
|
|
@ -285,7 +328,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
it("should disable suggested questions when no chat model is available", async () => {
|
||||
const requests = gateway([], { models: [] });
|
||||
renderWidget();
|
||||
await userEvent.setup().click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
const picker = await screen.findByRole("combobox", { name: "Aria Agent model" });
|
||||
expect(picker).toBeEnabled();
|
||||
expect(screen.queryByText("No chat models are available for your account.")).not.toBeInTheDocument();
|
||||
|
|
@ -302,201 +344,17 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should restore header trigger focus and preserve the conversation when the popup closes with Escape", async () => {
|
||||
const requests = gateway([answer("Your current usage is $12.34."), answer("Last month was $10.")]);
|
||||
const user = userEvent.setup();
|
||||
const backgroundAction = vi.fn();
|
||||
function PageAction() {
|
||||
return <button onClick={backgroundAction}>Background page action</button>;
|
||||
}
|
||||
renderWidget(PageAction);
|
||||
await user.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
|
||||
expect(popup).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
await user.click(screen.getByRole("button", { name: "Background page action" }));
|
||||
expect(backgroundAction).toHaveBeenCalledOnce();
|
||||
expect(popup).toBeVisible();
|
||||
act(() => send("What is my usage?"));
|
||||
await screen.findByText("Your current usage is $12.34.");
|
||||
act(() => fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And last month?" } }));
|
||||
await user.click(screen.getByPlaceholderText("Ask Aria…"));
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument());
|
||||
const trigger = screen.getByRole("button", { name: "Aria Agent" });
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
await user.keyboard("{Enter}");
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(screen.getByText("Your current usage is $12.34.")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And last month?");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
|
||||
await screen.findByText("Last month was $10.");
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions.at(-1)?.body.messages).toEqual([
|
||||
{ role: "system", content: expect.any(String) },
|
||||
{ role: "user", content: "What is my usage?" },
|
||||
{ role: "assistant", content: "Your current usage is $12.34." },
|
||||
{ role: "user", content: "And last month?" },
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveFocus());
|
||||
});
|
||||
|
||||
it("should show the same product name on Home, Usage, the header trigger and the popup", async () => {
|
||||
gateway([]);
|
||||
const view = renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
|
||||
expect(within(home).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
|
||||
view.refresh(false, true);
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
expect(within(usage).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
|
||||
expect(screen.getByRole("button", { name: "Open Aria Agent" })).toHaveTextContent("Aria Agent");
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
|
||||
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
|
||||
expect(within(popup).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, "SI"],
|
||||
[false, "AI"],
|
||||
])("should describe usage help in the configured terminology when SI terminology is %s", async (useSi, shownTerm) => {
|
||||
session("internal_user");
|
||||
gateway([], { useSiTerminology: useSi });
|
||||
renderWidget(undefined, false, true, true);
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
expect(await within(usage).findByText(`Your ${shownTerm} usage assistant`)).toBeInTheDocument();
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
|
||||
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
|
||||
expect(within(popup).getByText(`Your ${shownTerm} assistant for usage and spend.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the same scoped conversation when leaving Usage and reopening the header trigger", async () => {
|
||||
session("internal_user");
|
||||
const requests = gateway(
|
||||
[answer("Your usage is within budget."), answer("The same usage conversation continues.")],
|
||||
{
|
||||
models: [
|
||||
{ model_group: "chat-model", mode: "chat" },
|
||||
{ model_group: "other-chat-model", mode: "chat" },
|
||||
],
|
||||
},
|
||||
);
|
||||
const view = renderWidget(undefined, false, true);
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
await selectModel("other-chat-model");
|
||||
act(() => {
|
||||
fireEvent.change(within(usage).getByRole("textbox", { name: "Message Aria Agent" }), {
|
||||
target: { value: "Check my usage" },
|
||||
});
|
||||
});
|
||||
|
||||
view.refresh(false, false);
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria about usage" })).not.toBeInTheDocument();
|
||||
const trigger = screen.getByRole("button", { name: "Aria Agent" });
|
||||
expect(trigger).toHaveTextContent("Aria Agent");
|
||||
act(() => trigger.focus());
|
||||
await userEvent.setup().keyboard("{Enter}");
|
||||
expect(await screen.findByPlaceholderText("Ask Aria…")).toHaveValue("Check my usage");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
|
||||
await screen.findByText("Your usage is within budget.");
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And last month?" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
});
|
||||
|
||||
view.refresh(false, true);
|
||||
const restoredUsage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
expect(within(restoredUsage).getByRole("textbox", { name: "Message Aria Agent" })).toHaveValue("And last month?");
|
||||
view.refresh(false, false);
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(screen.getByText("Your usage is within budget.")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And last month?");
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
|
||||
await screen.findByText("The same usage conversation continues.");
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions.map((request) => request.body.model)).toEqual(["other-chat-model", "other-chat-model"]);
|
||||
expect(completions.at(-1)?.body.messages).toEqual([
|
||||
{ role: "system", content: expect.any(String) },
|
||||
{ role: "user", content: "Check my usage" },
|
||||
{ role: "assistant", content: "Your usage is within budget.", tool_calls: undefined },
|
||||
{ role: "user", content: "And last month?" },
|
||||
]);
|
||||
expect(completions.at(-1)?.body.tools).toEqual([
|
||||
expect.objectContaining({ function: expect.objectContaining({ name: "render_usage_chart" }) }),
|
||||
expect.objectContaining({ function: expect.objectContaining({ name: "get_usage_data" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should keep the header trigger unavailable without a signed-in session", async () => {
|
||||
it("keeps Aria unavailable without a signed-in session", async () => {
|
||||
document.cookie = "token=; Max-Age=0; Path=/";
|
||||
const requests = gateway([]);
|
||||
const { client } = renderWidget();
|
||||
await screen.findByText("Session ready");
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
act(() => fireEvent.keyDown(document, { key: "j", ctrlKey: true }));
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should share the usage prompt's draft, selected model and conversation with the header panel", async () => {
|
||||
const requests = gateway([answer("Your spend is within budget."), answer("The same conversation continues.")], {
|
||||
models: [
|
||||
{ model_group: "chat-model", mode: "chat" },
|
||||
{ model_group: "other-chat-model", mode: "chat" },
|
||||
],
|
||||
});
|
||||
renderWidget(undefined, false, true);
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
await selectModel("other-chat-model");
|
||||
act(() => {
|
||||
fireEvent.change(within(usage).getByRole("textbox", { name: "Message Aria Agent" }), {
|
||||
target: { value: "How is my spend?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Aria Agent" }));
|
||||
});
|
||||
expect(usage).not.toBeVisible();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("How is my spend?");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
|
||||
expect(await screen.findByText("Your spend is within budget.")).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And my teams?" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
});
|
||||
const usageTrigger = screen.getByRole("button", { name: "Open Aria Agent" });
|
||||
expect(usageTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(usage).getByRole("textbox", { name: "Message Aria Agent" })).toHaveValue("And my teams?");
|
||||
expect(within(usage).getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
|
||||
const headerTrigger = screen.getByRole("button", { name: "Aria Agent" });
|
||||
act(() => fireEvent.click(headerTrigger));
|
||||
expect(usageTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(headerTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(screen.getByText("Your spend is within budget.")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And my teams?");
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
});
|
||||
expect(await screen.findByText("The same conversation continues.")).toBeInTheDocument();
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions.map((request) => request.body.model)).toEqual(["other-chat-model", "other-chat-model"]);
|
||||
expect(requests.some((request) => request.url.includes("/usage/ai/chat"))).toBe(false);
|
||||
expect(completions.at(-1)?.body.messages).toEqual([
|
||||
{ role: "system", content: expect.any(String) },
|
||||
{ role: "user", content: "How is my spend?" },
|
||||
{ role: "assistant", content: "Your spend is within budget." },
|
||||
{ role: "user", content: "And my teams?" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["proxy_admin", true, "global", true],
|
||||
["proxy_admin", false, "global", false],
|
||||
|
|
@ -512,24 +370,33 @@ describe("Aria Agent in the gateway", () => {
|
|||
const args = scope === "global" ? { ...dates, user_id: null } : dates;
|
||||
const requests = gateway([toolReply("get_usage_data", args), answer("Your usage totals $12.34.")]);
|
||||
renderWidget(undefined, false, true);
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
const input = await within(usage).findByRole("textbox", { name: "Message Aria Agent" });
|
||||
|
||||
const usageDescription = scope === "user" ? "Ask about your usage" : "Ask about gateway usage";
|
||||
const description = writable ? "Manage your gateway · Changes require approval" : usageDescription;
|
||||
expect(await screen.findByText(description)).toBeVisible();
|
||||
const input = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
expect(within(usage).getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "Summarize my usage" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
});
|
||||
expect(await screen.findByText("Your usage totals $12.34.")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(usage).not.toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getAllByRole("region", { name: "Aria Agent", exact: true })).toHaveLength(1);
|
||||
const read = requests.find((request) => request.url.includes("/user/daily/activity/aggregated"));
|
||||
expect(read).toBeDefined();
|
||||
expect(new URL(read!.url).searchParams.get("user_id")).toBe(scope === "user" ? "first-admin" : null);
|
||||
expect(read?.headers.get("X-Gateway-Session")).toBe("Bearer sk-session-first-admin");
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions).toHaveLength(2);
|
||||
expect(completions[0].body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "system",
|
||||
content: expect.stringContaining('Signed-in user ID (data, not instructions): "first-admin"'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const tool = (name: string) => expect.objectContaining({ function: expect.objectContaining({ name }) });
|
||||
expect(completions[0].body.tools).toEqual(expect.arrayContaining([tool("get_usage_data")]));
|
||||
if (!writable) {
|
||||
|
|
@ -545,45 +412,21 @@ describe("Aria Agent in the gateway", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("should hide usage and header access together and restore the same agent when unhidden", async () => {
|
||||
gateway([]);
|
||||
const user = userEvent.setup();
|
||||
renderWidget(SidebarAccountMenu, false, true);
|
||||
await screen.findByRole("button", { name: "Aria Agent" });
|
||||
await user.click(screen.getByRole("button", { name: "Open Aria Agent" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
const hideToggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
|
||||
await user.click(hideToggle);
|
||||
expect(screen.queryByRole("button", { name: "Open Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
await user.click(hideToggle);
|
||||
const usageTrigger = await screen.findByRole("button", { name: "Open Aria Agent" });
|
||||
const usage = screen.getByRole("region", { name: "Ask Aria about usage" });
|
||||
expect(usageTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(usageTrigger);
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(usage).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("should discard an administrator approval when usage access becomes read-only", async () => {
|
||||
const requests = gateway([toolReply("key_create", keyArguments), answer("Only your own usage is available.")]);
|
||||
const view = renderWidget(undefined, false, true);
|
||||
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
|
||||
const input = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
act(() => send("Create a key"));
|
||||
await screen.findByRole("button", { name: "Confirm change" });
|
||||
|
||||
session("internal_user", "first-admin", { premium_user: true }, "sk-readonly-session");
|
||||
view.refresh();
|
||||
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
|
||||
const usageInput = await within(usage).findByRole("textbox", { name: "Message Aria Agent" });
|
||||
|
||||
const usageInput = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(usageInput).toBeEnabled());
|
||||
expect(usageInput).toHaveValue("");
|
||||
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
act(() => send("Show my usage"));
|
||||
expect(await screen.findByText("Only your own usage is available.")).toBeInTheDocument();
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
|
|
@ -598,90 +441,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["keyboard", "click"])("continues one conversation from Home via %s and the popup", async (method) => {
|
||||
const requests = gateway([answer("First reply."), answer("Second reply."), answer("Third reply.")]);
|
||||
renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
|
||||
await selectModel();
|
||||
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" }));
|
||||
expect(await screen.findByText("First reply.")).toBeInTheDocument();
|
||||
expect(home).not.toBeVisible();
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1);
|
||||
send("Second question");
|
||||
expect(await screen.findByText("Second reply.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
expect(home).toBeVisible();
|
||||
send("Third question");
|
||||
expect(await screen.findByText("Third reply.")).toBeInTheDocument();
|
||||
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completions).toHaveLength(3);
|
||||
expect(completions[2].body.messages).toEqual([
|
||||
{ role: "system", content: expect.any(String) },
|
||||
{ role: "user", content: "First question" },
|
||||
{ role: "assistant", content: "First reply." },
|
||||
{ role: "user", content: "Second question" },
|
||||
{ role: "assistant", content: "Second reply." },
|
||||
{ role: "user", content: "Third question" },
|
||||
]);
|
||||
});
|
||||
|
||||
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 Aria Agent" });
|
||||
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("dialog", { name: "Aria Agent" })).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("should preserve the draft and chosen model when opening, closing and leaving Home", async () => {
|
||||
const requests = gateway([answer("Draft received.")], {
|
||||
models: [
|
||||
{ model_group: "claude-sonnet-5-5", mode: "chat" },
|
||||
{ model_group: "chat-model", mode: "chat" },
|
||||
],
|
||||
});
|
||||
const view = renderWidget(undefined, true);
|
||||
await selectModel();
|
||||
fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
|
||||
target: { value: "Keep this draft" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open conversation" }));
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("Keep this draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
const home = screen.getByRole("region", { name: "Ask Aria Agent" });
|
||||
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: "Aria Agent" }));
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("Keep this draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
expect(await screen.findByText("Draft received.")).toBeInTheDocument();
|
||||
const completion = requests.find((request) => request.url.endsWith("/chat/completions"));
|
||||
expect(completion?.body.model).toBe("chat-model");
|
||||
expect(completion?.body.messages).toContainEqual({ role: "user", content: "Keep this draft" });
|
||||
});
|
||||
|
||||
it("should start over with an empty history and draft while preserving the selected model", async () => {
|
||||
const requests = gateway([answer("Old answer."), answer("Fresh answer.")], {
|
||||
models: [
|
||||
|
|
@ -701,7 +460,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(screen.queryByText("Old answer.")).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
|
||||
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
|
||||
expect(screen.getByRole("button", { name: "Start over" })).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Start over" })).not.toBeInTheDocument();
|
||||
act(() => send("Fresh question"));
|
||||
await screen.findByText("Fresh answer.");
|
||||
const completion = requests.filter((request) => request.url.endsWith("/chat/completions")).at(-1);
|
||||
|
|
@ -714,14 +473,13 @@ describe("Aria Agent in the gateway", () => {
|
|||
|
||||
it("should focus a pending approval when reopening the same conversation", async () => {
|
||||
const requests = gateway([toolReply("key_create", keyArguments)]);
|
||||
renderWidget();
|
||||
const view = renderWidget();
|
||||
await openWidget();
|
||||
act(() => send("Prepare a key for approval"));
|
||||
const review = await screen.findByRole("region", { name: "Create a virtual key" });
|
||||
expect(review).toHaveFocus();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
await user.click(screen.getByRole("button", { name: "Aria Agent" }));
|
||||
view.refresh(false, false, false);
|
||||
view.refresh(false, false, true);
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveFocus());
|
||||
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -768,65 +526,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps Home drafts unsent without a model or with empty or oversized input", async () => {
|
||||
const options: GatewayOptions = { models: [] };
|
||||
const requests = gateway([], options);
|
||||
const { client } = renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
|
||||
const user = userEvent.setup();
|
||||
await user.click(await within(home).findByRole("combobox", { name: "Aria Agent model" }));
|
||||
expect(await screen.findByText("No available models")).toBeVisible();
|
||||
await user.keyboard("{Escape}");
|
||||
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("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" });
|
||||
expect(submit).toBeDisabled();
|
||||
fireEvent.click(submit);
|
||||
expect(input).toHaveValue(text);
|
||||
}
|
||||
expect(home).toBeVisible();
|
||||
expect(within(home).getByRole("alert")).toHaveTextContent("8,000 characters");
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows destination consent in Home and discards its draft when the destination changes", async () => {
|
||||
const settings = { target: EXTERNAL_INFERENCE, status: 200 };
|
||||
const settingsReady = deferred<void>();
|
||||
const requests = gateway([], {
|
||||
settings: async () => {
|
||||
await settingsReady.promise;
|
||||
return json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: settings.target });
|
||||
},
|
||||
});
|
||||
const { client } = renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
|
||||
expect(await within(home).findByLabelText("Loading gateway settings")).toBeInTheDocument();
|
||||
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("What would you like to do?")).not.toBeInTheDocument();
|
||||
fireEvent.click(consent);
|
||||
await selectModel();
|
||||
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("What would you like to do?")).toHaveValue("");
|
||||
expect(home).toBeVisible();
|
||||
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["sidebar", SidebarAccountMenu],
|
||||
["navbar", UserDropdown],
|
||||
|
|
@ -834,27 +533,27 @@ describe("Aria Agent in the gateway", () => {
|
|||
gateway([]);
|
||||
const user = userEvent.setup();
|
||||
const view = renderWidget(Menu, true);
|
||||
await screen.findByRole("button", { name: "Aria Agent" });
|
||||
await screen.findByRole("link", { name: "Aria Agent" });
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
const toggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
|
||||
expect(toggle).not.toBeChecked();
|
||||
await user.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
const restored = renderWidget(Menu, true);
|
||||
await screen.findByText("Session ready");
|
||||
await waitFor(() => expect(restored.client.isFetching()).toBe(0));
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
const savedToggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
|
||||
expect(savedToggle).toBeChecked();
|
||||
await user.click(savedToggle);
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(await screen.findByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
|
||||
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(await screen.findByRole("region", { name: "Aria Agent", exact: true })).toBeVisible();
|
||||
|
||||
for (const premiumUser of [false, null, undefined, "true"]) {
|
||||
session("proxy_admin", "first-admin", { premium_user: premiumUser });
|
||||
|
|
@ -864,27 +563,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([false, null, undefined, "true"])(
|
||||
"should offer the scoped header trigger without admin Home access when premium_user is %s",
|
||||
async (premiumUser) => {
|
||||
session("proxy_admin", "first-admin", { premium_user: premiumUser });
|
||||
const requests = gateway([]);
|
||||
const { client } = renderWidget(undefined, true);
|
||||
await screen.findByText("Session ready");
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
|
||||
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
|
||||
act(() => fireEvent.keyDown(document, { key: "j", metaKey: true }));
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
|
||||
act(() => fireEvent.keyDown(document, { key: "j", ctrlKey: true }));
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("should discard admin state and preserve scoped header trigger access when enterprise access is lost", async () => {
|
||||
it("discards admin state and preserves scoped page access when enterprise access is lost", async () => {
|
||||
const requests = gateway([answer("Your usage remains available.")]);
|
||||
const view = renderWidget();
|
||||
await openWidget();
|
||||
|
|
@ -892,9 +571,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
await waitFor(() => expect(view.client.isFetching()).toBe(0));
|
||||
session("proxy_admin", "first-admin", { premium_user: false });
|
||||
view.refresh();
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
|
||||
act(() => send("Show my usage"));
|
||||
|
|
@ -908,10 +584,8 @@ describe("Aria Agent in the gateway", () => {
|
|||
|
||||
session();
|
||||
view.refresh();
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("region", { name: "Aria Agent", exact: true })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
|
||||
});
|
||||
|
||||
|
|
@ -919,31 +593,31 @@ describe("Aria Agent in the gateway", () => {
|
|||
gateway([]);
|
||||
const user = userEvent.setup();
|
||||
const view = renderWidget(SidebarAccountMenu);
|
||||
await screen.findByRole("button", { name: "Aria Agent" });
|
||||
await screen.findByRole("link", { name: "Aria Agent" });
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
await user.click(await screen.findByRole("switch", { name: "Toggle hide Aria Agent" }));
|
||||
|
||||
session("proxy_admin", "second-admin");
|
||||
view.refresh();
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
|
||||
session();
|
||||
view.refresh();
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
|
||||
switchToWorkerUrl("https://other-gateway.test");
|
||||
view.refresh();
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
|
||||
switchToWorkerUrl(MANAGEMENT);
|
||||
view.refresh();
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
localStorage.clear();
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: null }));
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
|
|
@ -961,79 +635,16 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(screen.queryByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the page mounted and comes back closed after Hide Aria Agent is turned off", async () => {
|
||||
gateway([]);
|
||||
pageMounts = 0;
|
||||
const user = userEvent.setup();
|
||||
renderWidget(SidebarAccountMenu);
|
||||
await screen.findByRole("button", { name: "Aria Agent" });
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
const toggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
|
||||
act(() => fireEvent.keyDown(document, { key: "j", metaKey: true }));
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(await screen.findByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(pageMounts).toBe(1);
|
||||
});
|
||||
|
||||
it("moves focus into the panel on open: the panel while loading, the message box, then a pending review", async () => {
|
||||
const settings = deferred<Response>();
|
||||
gateway([toolReply("key_create", keyArguments)], { settings: () => settings.promise });
|
||||
renderWidget();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
await waitFor(() => expect(screen.getByRole("dialog", { name: "Aria Agent" })).toHaveFocus());
|
||||
await act(async () =>
|
||||
settings.resolve(json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: INFERENCE })),
|
||||
);
|
||||
await selectModel();
|
||||
const user = userEvent.setup();
|
||||
const reopen = async () => {
|
||||
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
await user.click(screen.getByRole("button", { name: "Aria Agent" }));
|
||||
};
|
||||
await reopen();
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus());
|
||||
send("Create a key for the team");
|
||||
const review = await screen.findByRole("region", { name: "Create a virtual key" });
|
||||
await reopen();
|
||||
await waitFor(() => expect(review).toHaveFocus());
|
||||
});
|
||||
|
||||
it("toggles with Cmd+J or Ctrl+J and ignores the shortcut while hidden", async () => {
|
||||
gateway([]);
|
||||
const user = userEvent.setup();
|
||||
renderWidget(SidebarAccountMenu);
|
||||
const headerTrigger = await screen.findByRole("button", { name: "Aria Agent" });
|
||||
fireEvent.keyDown(document, { key: "j", metaKey: true, shiftKey: true });
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "j", metaKey: true });
|
||||
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
|
||||
expect(headerTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
|
||||
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /account menu/i }));
|
||||
await user.click(await screen.findByRole("switch", { name: "Toggle hide Aria Agent" }));
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "j", metaKey: true });
|
||||
expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["proxy_admin_viewer", "internal_user", "internal_user_viewer", "org_admin"])(
|
||||
"should expose only scoped usage tools from the dashboard header trigger to %s",
|
||||
"exposes only scoped usage tools on the Aria page to %s",
|
||||
async (role) => {
|
||||
session(role);
|
||||
const requests = gateway([answer("Usage is available.")]);
|
||||
const { client } = renderWidget(undefined, true);
|
||||
await screen.findByText("Session ready");
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Aria Agent" })).toBeVisible();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
|
||||
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
act(() => send("Show my usage"));
|
||||
await screen.findByText("Usage is available.");
|
||||
|
|
@ -1051,7 +662,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
it("uses the chosen model and existing session header for reads", async () => {
|
||||
const requests = gateway([toolReply("team_info", { team_id: "team-1" }), answer("The team budget is $40.")]);
|
||||
renderWidget();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
await selectModel();
|
||||
send("Check the team budget");
|
||||
|
|
@ -1120,7 +730,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
},
|
||||
);
|
||||
renderWidget(undefined, false, true);
|
||||
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
|
||||
const input = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
await selectModel("other-chat-model");
|
||||
act(() => send("Which models cost the most?"));
|
||||
|
|
@ -1168,7 +778,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
].join("\n");
|
||||
gateway([answer(markdown), answer("The primary model accounts for $3.40 of your $4.00 spend.")]);
|
||||
renderWidget(undefined, false, true);
|
||||
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
|
||||
const input = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
act(() => send("Show my usage by model as a table"));
|
||||
const conversation = screen.getByLabelText("Aria Agent conversation");
|
||||
|
|
@ -1211,7 +821,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
],
|
||||
});
|
||||
const { client } = renderWidget();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
send("Use the default");
|
||||
await screen.findByText("Default selected.");
|
||||
|
|
@ -1240,7 +849,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
const requests = gateway([answer("Your usage is available.")], options);
|
||||
const { client } = renderWidget(undefined, false, true);
|
||||
const user = userEvent.setup();
|
||||
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
|
||||
const input = await screen.findByPlaceholderText("Ask Aria…");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
const picker = screen.getByRole("combobox", { name: "Aria Agent model" });
|
||||
expect(picker).toHaveValue("allowed-chat");
|
||||
|
|
@ -1269,7 +878,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
const requests = gateway([], { callableModels: async () => json({ error: { message: "Forbidden" } }, 403) });
|
||||
renderWidget(undefined, false, true);
|
||||
expect(await screen.findByText(/Could not load models/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Message Aria Agent" })).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -1289,7 +898,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
}),
|
||||
});
|
||||
renderWidget(undefined, false, true);
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Message Aria Agent" })).toBeEnabled());
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
|
||||
const picker = screen.getByRole("combobox", { name: "Aria Agent model" });
|
||||
expect(picker).toHaveValue("team-chat");
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -1327,21 +936,21 @@ describe("Aria Agent in the gateway", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("should keep a single inline action through review, close/reopen and one confirmed write", async () => {
|
||||
it("keeps one action across route navigation and one confirmed write", async () => {
|
||||
const response = deferred<Response>();
|
||||
const proposed = { ...toolReply("key_create", keyArguments), content: "Here is the requested change." };
|
||||
const requests = gateway([proposed, answer("Created the key."), answer("You are welcome.")], {
|
||||
write: () => response.promise,
|
||||
});
|
||||
renderWidget();
|
||||
const view = renderWidget();
|
||||
await openWidget();
|
||||
send("Create a key for the team");
|
||||
const review = await screen.findByRole("region", { name: "Create a virtual key" });
|
||||
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("region", { name: "Aria Agent", exact: true })).toHaveLength(1);
|
||||
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
|
||||
expect(within(review).getByText("Widget key")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aria Agent" }));
|
||||
view.refresh(false, false, false);
|
||||
view.refresh(false, false, true);
|
||||
const confirm = await screen.findByRole("button", { name: "Confirm change" });
|
||||
fireEvent.click(confirm);
|
||||
fireEvent.click(confirm);
|
||||
|
|
@ -1349,9 +958,9 @@ describe("Aria Agent in the gateway", () => {
|
|||
act(() => fireEvent.click(screen.getByRole("button", { name: "Start over" })));
|
||||
expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveTextContent("Applying");
|
||||
expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
|
||||
view.refresh(false, false, false);
|
||||
view.refresh(false, false, true);
|
||||
await act(async () => response.resolve(json({ key: NEW_KEY })));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aria Agent" }));
|
||||
expect(await screen.findByText("Created the key.")).toBeInTheDocument();
|
||||
expect(screen.getByText(NEW_KEY)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy generated key" })).toBeInTheDocument();
|
||||
|
|
@ -1473,21 +1082,18 @@ describe("Aria Agent in the gateway", () => {
|
|||
} else {
|
||||
session("proxy_admin_viewer", "first-admin", { premium_user: true }, "sk-session-first-admin-viewer");
|
||||
view.refresh();
|
||||
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeVisible();
|
||||
session();
|
||||
view.refresh();
|
||||
}
|
||||
if (stage === "pending") await waitFor(() => expect(original?.signal.aborted).toBe(true));
|
||||
const trigger = await screen.findByRole("button", { name: "Aria Agent" });
|
||||
if (trigger.getAttribute("aria-expanded") === "false") act(() => fireEvent.click(trigger));
|
||||
await screen.findByRole("dialog", { name: "Aria Agent" });
|
||||
await screen.findByRole("region", { name: "Aria Agent", exact: true });
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Old question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Old answer.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Old approval")).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
|
||||
expect(await screen.findByPlaceholderText("Ask Aria…")).toHaveValue("");
|
||||
await selectModel();
|
||||
if (stage === "pending") await act(async () => pending.resolve(toolReply("key_create", keyArguments)));
|
||||
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
|
||||
|
|
@ -1531,7 +1137,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
const settings = { target: EXTERNAL_INFERENCE, status: 200 };
|
||||
const requests = gateway([answer("Connected.")], { settings });
|
||||
const { client } = renderWidget();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
const approve = await screen.findByRole("button", { name: "Use configured gateway" });
|
||||
expect(requests.some((request) => request.url.startsWith(EXTERNAL_INFERENCE))).toBe(false);
|
||||
fireEvent.click(approve);
|
||||
|
|
@ -1550,7 +1155,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
it("blocks inference when settings cannot be loaded", async () => {
|
||||
const requests = gateway([], { settings: { target: INFERENCE, status: 503 } });
|
||||
renderWidget(undefined, true);
|
||||
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
|
||||
const home = await screen.findByRole("region", { name: "Aria Agent", exact: true });
|
||||
expect(await within(home).findByText("Could not load gateway settings.")).toBeInTheDocument();
|
||||
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
|
||||
});
|
||||
|
|
@ -1570,7 +1175,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
},
|
||||
});
|
||||
const view = renderWidget();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use configured gateway" }));
|
||||
await selectModel();
|
||||
send("Check the original gateway");
|
||||
|
|
@ -1580,7 +1184,6 @@ describe("Aria Agent in the gateway", () => {
|
|||
switchToWorkerUrl(worker);
|
||||
view.refresh();
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
|
||||
await settingsRequested.promise;
|
||||
expect(screen.queryByText(EXTERNAL_INFERENCE)).not.toBeInTheDocument();
|
||||
expect(await screen.findByLabelText("Loading gateway settings")).toBeInTheDocument();
|
||||
|
|
@ -1620,7 +1223,7 @@ describe("Aria Agent in the gateway", () => {
|
|||
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue(draft);
|
||||
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("8,000 characters");
|
||||
expect(within(screen.getByLabelText("Aria Agent conversation")).queryByText(draft)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Aria Agent conversation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retains the action receipt across later model and settings failures", async () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import {
|
|||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { History, RotateCcw, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { sidebarMenuButtonVariants } from "@/components/shared/Sidebar";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
|
||||
|
|
@ -25,40 +29,27 @@ import { createGatewayClient } from "@/components/llm_calls/gateway_client";
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { hasProxyWideSpendView, isProxyAdminRole } from "@/utils/roles";
|
||||
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
|
||||
import { AriaAvatar } from "./AriaAvatar";
|
||||
import { AriaStart } from "./AriaStart";
|
||||
import { ARIA_AGENT_LABEL } from "./aria-brand";
|
||||
import { LiteAdminConversation } from "./LiteAdminConversation";
|
||||
import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
|
||||
import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
|
||||
|
||||
type ManagementSession = Omit<LiteAdminSession, "inferenceBaseUrl">;
|
||||
type LiteAdminState = {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
show: () => void;
|
||||
access: LiteAdminSession["access"];
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
homeContainer: HTMLDivElement | null;
|
||||
setHomeContainer: (container: HTMLDivElement | null) => void;
|
||||
usageContainer: HTMLDivElement | null;
|
||||
setUsageContainer: (container: HTMLDivElement | null) => void;
|
||||
pageContainer: HTMLDivElement | null;
|
||||
setPageContainer: (container: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
function getComposerContainer(state: LiteAdminState | null, open: boolean): HTMLDivElement | null {
|
||||
if (open) return null;
|
||||
return state?.usageContainer ?? state?.homeContainer ?? null;
|
||||
}
|
||||
|
||||
const LiteAdminContext = createContext<LiteAdminState | null>(null);
|
||||
function useLiteAdminSession() {
|
||||
const auth = useAuthorized();
|
||||
|
|
@ -73,7 +64,13 @@ function useLiteAdminSession() {
|
|||
mode: "usage",
|
||||
scope: hasProxyWideSpendView(auth.userRole) ? { type: "global" } : { type: "user", userId: auth.userId },
|
||||
};
|
||||
const session = { token: auth.token, accessToken: auth.accessToken, managementBaseUrl: getProxyBaseUrl(), access };
|
||||
const session = {
|
||||
userId: auth.userId,
|
||||
token: auth.token,
|
||||
accessToken: auth.accessToken,
|
||||
managementBaseUrl: getProxyBaseUrl(),
|
||||
access,
|
||||
};
|
||||
return {
|
||||
session,
|
||||
key: JSON.stringify([auth.userId, session.token, session.accessToken, session.managementBaseUrl, access]),
|
||||
|
|
@ -83,151 +80,93 @@ function useLiteAdminSession() {
|
|||
export function LiteAdminFrame({ children }: { children: ReactNode }) {
|
||||
const configured = useLiteAdminSession();
|
||||
const sessionKey = configured?.key ?? null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const router = useRouter();
|
||||
const [started, setStarted] = useState(false);
|
||||
const [homeContainer, setHomeContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [usageContainer, updateUsageContainer] = useState<HTMLDivElement | null>(null);
|
||||
const setUsageContainer = useCallback((container: HTMLDivElement | null) => {
|
||||
updateUsageContainer(container);
|
||||
const [pageContainer, updatePageContainer] = useState<HTMLDivElement | null>(null);
|
||||
const setPageContainer = useCallback((container: HTMLDivElement | null) => {
|
||||
updatePageContainer(container);
|
||||
if (container) setStarted(true);
|
||||
}, []);
|
||||
const [openedSession, setOpenedSession] = useState(sessionKey);
|
||||
if (openedSession !== sessionKey) {
|
||||
setOpenedSession(sessionKey);
|
||||
setOpen(false);
|
||||
setStarted(Boolean(usageContainer));
|
||||
setStarted(Boolean(pageContainer));
|
||||
}
|
||||
const toggle = () => {
|
||||
setStarted(true);
|
||||
setOpen((current) => !current);
|
||||
};
|
||||
const show = () => {
|
||||
if (open) return;
|
||||
setStarted(true);
|
||||
setOpen(true);
|
||||
};
|
||||
const showPanel = configured?.session.access.mode === "admin" || started;
|
||||
useEffect(() => {
|
||||
if (sessionKey === null) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
const extraModifier = event.shiftKey || event.altKey;
|
||||
if (event.key.toLowerCase() !== "j" || !modifier || extraModifier) return;
|
||||
if (event.key.toLowerCase() !== "j" || !(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
if (pageContainer) {
|
||||
pageContainer.querySelector<HTMLTextAreaElement>("textarea:enabled")?.focus();
|
||||
} else {
|
||||
router.push(uiHref("aria"));
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [sessionKey]);
|
||||
}, [sessionKey, pageContainer, router]);
|
||||
return (
|
||||
<LiteAdminContext.Provider
|
||||
value={
|
||||
configured
|
||||
? {
|
||||
open,
|
||||
toggle,
|
||||
show,
|
||||
access: configured.session.access,
|
||||
triggerRef,
|
||||
homeContainer,
|
||||
setHomeContainer,
|
||||
usageContainer,
|
||||
setUsageContainer,
|
||||
pageContainer,
|
||||
setPageContainer,
|
||||
}
|
||||
: null
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{configured && showPanel && (
|
||||
<AriaPopup key={configured.key} session={configured.session} open={open} close={() => setOpen(false)} />
|
||||
{configured && started && (
|
||||
<AriaSession key={configured.key} session={configured.session} open={Boolean(pageContainer)} />
|
||||
)}
|
||||
</LiteAdminContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiteAdminHome() {
|
||||
export function AriaSidebarLink({ active }: { active: boolean }) {
|
||||
const state = useContext(LiteAdminContext);
|
||||
if (!state || state.access.mode !== "admin") return null;
|
||||
const { open, show, setHomeContainer } = state;
|
||||
if (!state) return null;
|
||||
return (
|
||||
<section aria-label={`Ask ${ARIA_AGENT_LABEL}`} 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">
|
||||
<AriaAvatar size={32} />
|
||||
{ARIA_AGENT_LABEL}
|
||||
</h2>
|
||||
<p className="border-l pl-3 text-sm text-muted-foreground">Your gateway, a little easier.</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={show} className="text-muted-foreground">
|
||||
<History className="size-3.5" />
|
||||
Open conversation
|
||||
</Button>
|
||||
</div>
|
||||
<div ref={setHomeContainer} />
|
||||
</section>
|
||||
<Link
|
||||
href={uiHref("aria")}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={ARIA_AGENT_LABEL}
|
||||
title={ARIA_AGENT_LABEL}
|
||||
className={cn(
|
||||
sidebarMenuButtonVariants({ isActive: active }),
|
||||
"group-data-[collapsed=true]/sidebar:justify-center",
|
||||
)}
|
||||
>
|
||||
<AriaAvatar size={18} />
|
||||
<span className="flex-1 group-data-[collapsed=true]/sidebar:hidden">{ARIA_AGENT_LABEL}</span>
|
||||
<Kbd className="bg-transparent text-[10px] group-data-[collapsed=true]/sidebar:hidden">⌘J</Kbd>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiteAdminTrigger() {
|
||||
const state = useContext(LiteAdminContext);
|
||||
if (!state) return null;
|
||||
const { triggerRef, open, toggle } = state;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
ref={triggerRef}
|
||||
aria-label={ARIA_AGENT_LABEL}
|
||||
aria-keyshortcuts="Meta+J Control+J"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className="gap-2 rounded-full border border-blue-200/70 bg-blue-50/80 pl-1.5 pr-3 text-blue-700 hover:bg-blue-100 hover:text-blue-800 aria-expanded:bg-blue-100 dark:border-blue-400/20 dark:bg-blue-400/10 dark:text-blue-300 dark:hover:bg-blue-400/20"
|
||||
>
|
||||
<AriaAvatar />
|
||||
<span className="hidden lg:inline">{ARIA_AGENT_LABEL}</span>
|
||||
<Kbd className="hidden lg:inline-flex">⌘J</Kbd>
|
||||
</Button>
|
||||
);
|
||||
export function LiteAdminPage() {
|
||||
const setPageContainer = useContext(LiteAdminContext)?.setPageContainer;
|
||||
if (!setPageContainer)
|
||||
return <p className="p-6 text-sm text-muted-foreground">Aria is not available for this session.</p>;
|
||||
return <div ref={setPageContainer} className="flex min-h-0 flex-1 flex-col" />;
|
||||
}
|
||||
|
||||
export function AriaUsageTrigger() {
|
||||
const state = useContext(LiteAdminContext);
|
||||
if (!state) return null;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Open ${ARIA_AGENT_LABEL}`}
|
||||
aria-expanded={state.open}
|
||||
onClick={state.show}
|
||||
className="gap-1.5 border-blue-200 bg-blue-50/70 pl-1.5 text-blue-700 hover:bg-blue-100 hover:text-blue-800 dark:border-blue-400/20 dark:bg-blue-400/10 dark:text-blue-300 dark:hover:bg-blue-400/20"
|
||||
>
|
||||
<AriaAvatar size={24} />
|
||||
{ARIA_AGENT_LABEL}
|
||||
</Button>
|
||||
<Link href={uiHref("aria")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
|
||||
<AriaAvatar size={18} />
|
||||
Ask Aria
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function AriaUsageComposer() {
|
||||
const state = useContext(LiteAdminContext);
|
||||
const term = useTerminology();
|
||||
if (!state) return null;
|
||||
const { open, setUsageContainer } = state;
|
||||
return (
|
||||
<section aria-label="Ask Aria about usage" hidden={open} className="mb-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<AriaAvatar size={24} /> {ARIA_AGENT_LABEL}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{term("Your AI usage assistant")}</p>
|
||||
</div>
|
||||
<div ref={setUsageContainer} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AriaPopup({ session, open, close }: { session: ManagementSession; open: boolean; close: () => void }) {
|
||||
function AriaSession({ session, open }: { session: ManagementSession; open: boolean }) {
|
||||
const settings = useProxySettingsQuery(session.accessToken);
|
||||
const candidate =
|
||||
settings.data?.LITELLM_UI_API_DOC_BASE_URL?.trim() ||
|
||||
|
|
@ -244,7 +183,6 @@ function AriaPopup({ session, open, close }: { session: ManagementSession; open:
|
|||
loading={settings.isPending}
|
||||
retry={() => void settings.refetch()}
|
||||
open={open}
|
||||
close={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -255,23 +193,19 @@ function Destination({
|
|||
loading,
|
||||
retry,
|
||||
open,
|
||||
close,
|
||||
}: {
|
||||
session: ManagementSession;
|
||||
target: ReturnType<typeof resolveInferenceTarget> | null;
|
||||
loading: boolean;
|
||||
retry: () => void;
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [approved, setApproved] = useState(false);
|
||||
if (target?.baseUrl && (!target.requiresConsent || approved)) {
|
||||
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} close={close} />;
|
||||
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} />;
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
open={open}
|
||||
close={close}
|
||||
composer={
|
||||
<div className="p-4">
|
||||
{loading && <Skeleton className="h-24" aria-label="Loading gateway settings" />}
|
||||
|
|
@ -308,49 +242,34 @@ function Destination({
|
|||
);
|
||||
}
|
||||
|
||||
/** On open, focus `initialFocus` when given, else the composer when it is usable, else the panel itself. */
|
||||
function Panel({
|
||||
open,
|
||||
close,
|
||||
initialFocus,
|
||||
composer,
|
||||
welcome,
|
||||
empty = false,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
initialFocus?: RefObject<HTMLElement | null>;
|
||||
composer: ReactNode;
|
||||
welcome?: ReactNode;
|
||||
empty?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const state = useContext(LiteAdminContext);
|
||||
const homeContainer = getComposerContainer(state, open);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
modal={false}
|
||||
disablePointerDismissal
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) close();
|
||||
}}
|
||||
const container = useContext(LiteAdminContext)?.pageContainer;
|
||||
if (!container) return null;
|
||||
return createPortal(
|
||||
<section
|
||||
aria-label={ARIA_AGENT_LABEL}
|
||||
className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-2 sm:px-4"
|
||||
>
|
||||
<DialogContent
|
||||
ref={ref}
|
||||
keepMounted
|
||||
showCloseButton={false}
|
||||
overlayClassName="hidden"
|
||||
hidden={!open}
|
||||
aria-label={ARIA_AGENT_LABEL}
|
||||
initialFocus={() =>
|
||||
initialFocus?.current ?? ref.current?.querySelector<HTMLElement>("textarea:enabled") ?? ref.current
|
||||
}
|
||||
finalFocus={state?.triggerRef}
|
||||
className="flex h-[min(48rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 shadow-xl sm:max-w-4xl"
|
||||
>
|
||||
{children}
|
||||
{homeContainer ? createPortal(composer, homeContainer) : composer}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{empty ? (
|
||||
welcome
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{composer}
|
||||
</>
|
||||
)}
|
||||
</section>,
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -362,13 +281,17 @@ function useForegroundReviewFocus(
|
|||
const lastReviewId = useRef<string | null>(null);
|
||||
const pendingReviewId = entries.find((entry) => entry.kind === "action" && entry.status === "review")?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!foreground) {
|
||||
lastReviewId.current = null;
|
||||
return;
|
||||
}
|
||||
if (lastReviewId.current === pendingReviewId) return;
|
||||
lastReviewId.current = pendingReviewId;
|
||||
if (foreground && pendingReviewId) reviewRef.current?.focus({ preventScroll: true });
|
||||
}, [foreground, pendingReviewId, reviewRef]);
|
||||
}
|
||||
|
||||
function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; open: boolean; close: () => void }) {
|
||||
function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boolean }) {
|
||||
const state = useContext(LiteAdminContext);
|
||||
const chat = useLiteAdmin(session);
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -399,7 +322,7 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
|
|||
}))
|
||||
.sort((a, b) => a.model_group.localeCompare(b.model_group));
|
||||
},
|
||||
enabled: open || Boolean(state?.homeContainer || state?.usageContainer),
|
||||
enabled: open,
|
||||
select: (available: ModelGroup[]) =>
|
||||
available.filter((item) => isModeCompatibleWithEndpoint(item.mode, EndpointType.CHAT)),
|
||||
};
|
||||
|
|
@ -410,20 +333,23 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
|
|||
const tooLong = input.trim().length > MAX_INPUT_LENGTH;
|
||||
const hasValidInput = selectedModel && input.trim() && !tooLong;
|
||||
const submitDisabled = busy || models.isError || !hasValidInput;
|
||||
const questionsDisabled = busy || models.isError || !selectedModel;
|
||||
const send = (message: string) => {
|
||||
const invalidMessage = !message.trim() || message.trim().length > MAX_INPUT_LENGTH;
|
||||
const canSend = !busy && !models.isError && !invalidMessage;
|
||||
if (!canSend || !selectedModel) return;
|
||||
state?.show();
|
||||
void chat.send(message, selectedModel);
|
||||
setInput("");
|
||||
};
|
||||
const selectQuestion = (question: string) => {
|
||||
setInput(question);
|
||||
state?.pageContainer?.querySelector<HTMLTextAreaElement>("textarea:enabled")?.focus();
|
||||
};
|
||||
const startOver = () => {
|
||||
if (chat.phase === "applying") return;
|
||||
chat.reset();
|
||||
setInput("");
|
||||
};
|
||||
const homeContainer = getComposerContainer(state, open);
|
||||
const composerProps = {
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
|
|
@ -439,34 +365,42 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
|
|||
models={models}
|
||||
model={selectedModel}
|
||||
onModelChange={setModel}
|
||||
home={Boolean(homeContainer)}
|
||||
usage={Boolean(state?.usageContainer)}
|
||||
tooLong={tooLong}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Panel open={open} close={close} initialFocus={reviewRef} composer={composer}>
|
||||
<Panel
|
||||
composer={composer}
|
||||
empty={chat.entries.length === 0}
|
||||
welcome={
|
||||
<AriaStart
|
||||
composer={composer}
|
||||
onQuestion={selectQuestion}
|
||||
disabled={questionsDisabled}
|
||||
access={session.access}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PanelHeader>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={chat.phase === "applying" || (chat.entries.length === 0 && !input)}
|
||||
onClick={startOver}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Start over
|
||||
</Button>
|
||||
{(chat.entries.length > 0 || input.length > 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={chat.phase === "applying"}
|
||||
onClick={startOver}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Start over
|
||||
</Button>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<LiteAdminConversation
|
||||
entries={chat.entries}
|
||||
thinking={chat.phase === "thinking"}
|
||||
readOnly={session.access.mode === "usage"}
|
||||
open={open}
|
||||
reviewRef={reviewRef}
|
||||
onAnswer={chat.answer}
|
||||
onQuestion={send}
|
||||
questionsDisabled={busy || models.isError || !selectedModel}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
|
|
@ -476,40 +410,32 @@ function LiteAdminComposer({
|
|||
models,
|
||||
model,
|
||||
onModelChange,
|
||||
home,
|
||||
usage,
|
||||
tooLong,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof LiteAdminHomeComposer>, "modelPicker" | "usage"> & {
|
||||
}: Omit<ComponentProps<typeof ChatComposer>, "tools" | "placeholder"> & {
|
||||
models: UseQueryResult<ModelGroup[]>;
|
||||
model: string | null;
|
||||
onModelChange: (value: string | null) => void;
|
||||
home: boolean;
|
||||
usage: boolean;
|
||||
tooLong: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-3", !home && "border-t p-3")}>
|
||||
<div className="space-y-3 px-4 pt-2 pb-5 sm:px-6">
|
||||
<ModelStatus models={models} />
|
||||
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
|
||||
{home ? (
|
||||
<LiteAdminHomeComposer
|
||||
{...props}
|
||||
usage={usage}
|
||||
modelPicker={
|
||||
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatComposer
|
||||
{...props}
|
||||
placeholder="Ask Aria…"
|
||||
className="[&_[data-slot=input-group]]:min-h-0 [&_textarea]:min-h-12"
|
||||
tools={
|
||||
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ChatComposer
|
||||
{...props}
|
||||
placeholder="Ask Aria…"
|
||||
className="[&>div>[data-slot=input-group]]:min-h-0 [&>div>[data-slot=input-group]]:rounded-2xl [&>div>[data-slot=input-group]]:border-foreground/15 [&>div>[data-slot=input-group]]:shadow-none [&>div>[data-slot=input-group]]:ring-0 dark:[&>div>[data-slot=input-group]]:bg-foreground/[0.03] [&_textarea]:min-h-20 [&_textarea]:px-5 [&_textarea]:pt-5 [&_textarea]:text-base [&_textarea]:placeholder:text-muted-foreground"
|
||||
tools={
|
||||
<ModelPicker
|
||||
models={models}
|
||||
value={model}
|
||||
onChange={onModelChange}
|
||||
disabled={Boolean(props.isLoading)}
|
||||
compact
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -558,27 +484,5 @@ function ModelStatus({ models }: { models: UseQueryResult<ModelGroup[]> }) {
|
|||
}
|
||||
|
||||
function PanelHeader({ children }: { children?: ReactNode }) {
|
||||
const state = useContext(LiteAdminContext);
|
||||
const term = useTerminology();
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 border-b p-4">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<DialogTitle className="flex items-center gap-2 font-semibold">
|
||||
<AriaAvatar size={32} />
|
||||
{ARIA_AGENT_LABEL}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{state?.access.mode === "usage"
|
||||
? term("Your AI assistant for usage and spend.")
|
||||
: "Ask about your gateway. Review changes in chat."}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0">
|
||||
{children}
|
||||
<DialogClose render={<Button variant="ghost" size="icon-sm" aria-label={`Close ${ARIA_AGENT_LABEL}`} />}>
|
||||
<X className="size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="flex h-12 shrink-0 items-center justify-end px-4 sm:px-6">{children}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useEffect, type RefObject } from "react";
|
||||
import { ArrowDown, Check, ChevronDown } from "lucide-react";
|
||||
import { MessageScroller, useMessageScroller } from "@shadcn/react/message-scroller";
|
||||
import { ChatMessageContent } from "@/components/chat/ChatMessages";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -11,7 +10,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { ActionEntry, ConversationEntry } from "./useLiteAdmin";
|
||||
import { AriaAvatar } from "./AriaAvatar";
|
||||
import { AriaAnswer } from "./AriaAnswer";
|
||||
import { ARIA_AGENT_LABEL } from "./aria-brand";
|
||||
|
||||
|
|
@ -23,33 +21,15 @@ const ACTION_STATUS: Record<ActionEntry["status"], string> = {
|
|||
unknown: "Check result",
|
||||
};
|
||||
|
||||
const SUGGESTED_QUESTIONS = [
|
||||
"How much have I spent this week?",
|
||||
"Which models are costing the most?",
|
||||
"How can I reduce my spend?",
|
||||
];
|
||||
|
||||
interface ConversationProps {
|
||||
entries: ConversationEntry[];
|
||||
thinking: boolean;
|
||||
readOnly?: boolean;
|
||||
open: boolean;
|
||||
reviewRef: RefObject<HTMLDivElement | null>;
|
||||
onAnswer: (id: string, approved: boolean) => void;
|
||||
onQuestion: (question: string) => void;
|
||||
questionsDisabled: boolean;
|
||||
}
|
||||
|
||||
export function LiteAdminConversation({
|
||||
entries,
|
||||
thinking,
|
||||
open,
|
||||
readOnly = false,
|
||||
reviewRef,
|
||||
onAnswer,
|
||||
onQuestion,
|
||||
questionsDisabled,
|
||||
}: ConversationProps) {
|
||||
export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAnswer }: ConversationProps) {
|
||||
return (
|
||||
<MessageScroller.Provider autoScroll>
|
||||
<MessageScroller.Root className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -64,14 +44,11 @@ export function LiteAdminConversation({
|
|||
(entry.message.role === "assistant" ? (
|
||||
<AriaAnswer message={entry.message} chart={entry.chart} question={entry.question} />
|
||||
) : (
|
||||
<article aria-label="Your question" className="space-y-2">
|
||||
<p className="text-right text-xs font-medium text-muted-foreground">You</p>
|
||||
<ChatMessageContent
|
||||
message={entry.message}
|
||||
allowImages={false}
|
||||
isLastMessage={false}
|
||||
isStreaming={false}
|
||||
/>
|
||||
<article
|
||||
aria-label="Your question"
|
||||
className="ml-auto w-fit max-w-[90%] whitespace-pre-wrap break-words rounded-xl bg-muted px-4 py-3 text-sm leading-relaxed"
|
||||
>
|
||||
{entry.message.content}
|
||||
</article>
|
||||
))}
|
||||
{entry.kind === "action" && (
|
||||
|
|
@ -95,30 +72,6 @@ export function LiteAdminConversation({
|
|||
)}
|
||||
</MessageScroller.Content>
|
||||
</MessageScroller.Viewport>
|
||||
{entries.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center gap-4 overflow-y-auto p-5 text-center">
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<AriaAvatar size={40} />
|
||||
<p className="text-base font-semibold">Hi, I’m Aria.</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2" aria-label="Suggested questions">
|
||||
{SUGGESTED_QUESTIONS.map((question) => (
|
||||
<Button
|
||||
key={question}
|
||||
variant="outline"
|
||||
className="h-auto min-h-10 justify-start whitespace-normal px-3 py-2 text-left font-normal"
|
||||
disabled={questionsDisabled}
|
||||
onClick={() => onQuestion(question)}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="max-w-64 text-xs leading-relaxed text-muted-foreground">
|
||||
{readOnly ? "Answers use usage data your account can access." : "You review every change before it runs."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<MessageScroller.Button
|
||||
aria-label="Jump to latest"
|
||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full shadow-sm data-[active=false]:pointer-events-none data-[active=false]:opacity-0"
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import { useRef, type ReactNode } from "react";
|
||||
import { ArrowUp, BarChart3, Box, CreditCard, KeyRound, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { ARIA_AGENT_LABEL } from "./aria-brand";
|
||||
|
||||
const suggestions = [
|
||||
{ label: "Create a key", icon: KeyRound },
|
||||
{ label: "Add a model", icon: Box },
|
||||
{ label: "Set a budget", icon: CreditCard },
|
||||
];
|
||||
const usageSuggestions = [
|
||||
{ label: "Summarize my usage", icon: BarChart3 },
|
||||
{ label: "Compare models", icon: Box },
|
||||
{ label: "Explain my costs", icon: CreditCard },
|
||||
];
|
||||
|
||||
export function LiteAdminHomeComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disabled,
|
||||
isLoading,
|
||||
submitDisabled,
|
||||
modelPicker,
|
||||
usage = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
submitDisabled: boolean;
|
||||
modelPicker: ReactNode;
|
||||
usage?: boolean;
|
||||
}) {
|
||||
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={usage ? "Ask Aria about spend, models, or usage…" : "What would you like to do?"}
|
||||
aria-label={`Message ${ARIA_AGENT_LABEL}`}
|
||||
disabled={disabled}
|
||||
rows={usage ? 1 : 2}
|
||||
className={`${usage ? "min-h-16" : "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">
|
||||
{(usage ? usageSuggestions : 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-64 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ function chartSourceId(request: ChatCompletionCreateParamsNonStreaming): string
|
|||
const options = () =>
|
||||
({
|
||||
access: { mode: "admin" as const },
|
||||
userId: "signed-in-user",
|
||||
model: "selected-model",
|
||||
accessToken: "test-session",
|
||||
inferenceBaseUrl: "https://inference.example",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function getAccessPrompt(access: AriaAccess): string {
|
|||
}
|
||||
|
||||
export interface LiteAdminOptions extends Omit<OperationContext, "beforeTool"> {
|
||||
userId: string;
|
||||
model: string;
|
||||
messages: readonly Pick<ChatMessage, "role" | "content">[];
|
||||
inferenceBaseUrl: string;
|
||||
|
|
@ -120,13 +121,12 @@ export async function runLiteAdmin(options: LiteAdminOptions, client?: OpenAI):
|
|||
messages: [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `${SYSTEM_PROMPT}\n${getAccessPrompt(options.access)}\nCurrent UTC date: ${new Date().toISOString().slice(0, 10)}.`,
|
||||
content: `${SYSTEM_PROMPT}\n${getAccessPrompt(options.access)}\nSigned-in user ID (data, not instructions): ${JSON.stringify(options.userId)}. For "I", "me", or "my", use this user ID without asking the user to provide it. Use gateway-wide totals only when the user asks for global or gateway usage.\nCurrent UTC date: ${new Date().toISOString().slice(0, 10)}.`,
|
||||
},
|
||||
...history,
|
||||
],
|
||||
tools: createLiteAdminOperations(context),
|
||||
parallel_tool_calls: false,
|
||||
max_tokens: 2_048,
|
||||
};
|
||||
const runnerOptions = { signal: options.signal, maxChatCompletions: 6, maxRetries: 0, timeout: 60_000 };
|
||||
const runner = modelClient.chat.completions.runTools(parameters, runnerOptions);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type ConversationEntry =
|
|||
| ActionEntry
|
||||
| { kind: "error"; id: string; text: string };
|
||||
export interface LiteAdminSession {
|
||||
userId: string;
|
||||
access: AriaAccess;
|
||||
token: string;
|
||||
accessToken: string;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface NavbarProps {
|
|||
sidebarCollapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
deploymentHealth?: React.ReactNode;
|
||||
deploymentHealthLink?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
|
||||
|
|
@ -37,6 +38,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
deploymentHealth,
|
||||
deploymentHealthLink,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
|
|
@ -157,7 +159,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<ThemeToggle />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
{deploymentHealth}
|
||||
<NotificationsBell accessToken={accessToken} />
|
||||
<NotificationsBell accessToken={accessToken} deploymentHealthLink={deploymentHealthLink} />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,10 +54,13 @@ function CommandDialog({
|
|||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<InputGroup className="h-8! rounded-lg! border-border bg-muted/50 shadow-none! focus-within:border-ring dark:bg-muted/50 *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
className={cn(
|
||||
"min-w-0 w-full border-0 bg-transparent p-0 text-sm text-foreground shadow-none outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue