mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
feat(ui): redesign the playground chat around a single composer and inspector (#46934)
* feat(ui): add clean-slate playground chat prototype
* feat(ui): replace the playground chat with the redesigned composer and inspector
* fix(ui): close the playground chat gaps found in self-review
Resend only complete text exchanges, so empty replies, MCP direct calls and
transcriptions no longer leak into chat history. A stopped request can no longer
append output, attach its gateway trace to a newer reply or clear the newer
request's loading state. MCP selections are only pruned once both the server and
toolset lists load. The MCP tool picker lists toolsets again and calls the server
that backs the chosen tool, and a saved model the current key cannot use is
cleared when the model list loads, matching the old chat
Also vendors the scroll-fade-x utility the attachment primitive uses
* fix(ui): resolve playground toolset tools per server and drop late session ids
A toolset now lists only its own tools, each resolved on the server the toolset
names, so the args form and the call always target the same tool. A saved
toolset no longer asks for tools with its own id while the toolset list loads,
and a failed tool list says so instead of claiming the server has no tools
A stopped request can no longer overwrite the Responses session id, starting a
new chat drops a request that is still running, embeddings replies are no longer
resent as chat history, and a saved model missing from an already cached model
list is cleared too. A failing toolset list falls back right away instead of
retrying while the MCP pickers show loading
* fix(ui): guard playground sends and label inspector stats
Pressing Enter twice no longer sends the same message twice, an attachment the
new endpoint cannot take is dropped when the endpoint changes, a stopped
Responses request can no longer show its code output on a newer reply, and a
reply that broke mid-stream is no longer resent as history
The inspector's speed, cost and routing stats get the same icons as the reply
footer, and the footer's metrics now line up with its copy and Inspect buttons
* fix(ui): write playground key settings only through the encoded storage path
Key source, virtual key and custom headers now go through their own write loop, separate from the plain settings, so a key value can never take the plain sessionStorage branch
* fix(ui): restore playground BYOK connect and reconnect
Picking a BYOK server for a direct tool call now shows a key button next to the
server picker, so the key can be connected without leaving the MCP endpoint. A
connected BYOK server gets a Replace key action in the MCP dialog, also shown
when its tools fail to load, and saving a key reloads that server's tool list
* revert: restore the single playground settings storage loop
Reverts ca4e742115. The CodeQL finding covers the same encoded sessionStorage handling the old chat used, so it is left as is
* test(ui): type the BYOK credential mock without deep instantiation
* fix(ui): let users back out of replacing a BYOK key
The replace-key form in the MCP dialog now has Cancel, and it resets when another server is picked or the dialog closes, so a working key is never forced to be overwritten
* refactor(ui): name the MCP dialog row focus handler
* fix(ui): accept exact custom models, tags and decimal temperatures in the playground
A typed model or tag that is a prefix of a listed one could not be used,
because Enter picked the partial match and the custom action only showed
when nothing matched. The custom value is now a list option whenever no
exact match exists. Temperature gets an editable input beside the slider
so values finer than the 0.1 step can be sent again.
* fix(ui): stop claiming no guardrails ran when the gateway sent no headers
Direct MCP calls, A2A agents and any request that fails before headers
arrive built their trace from an empty header set, so the inspector said
no guardrails or policies ran. The gateway fields are now left unset in
that case and the inspector says the endpoint does not report them.
* fix(ui): leave policies unknown when the gateway headers are hidden
The proxy exposes x-litellm-applied-guardrails to other origins but not
x-litellm-applied-policies, so a dashboard on another origin read every
request as having run no policies. Policies are now unknown unless the
call id header is readable, and the inspector says so.
This commit is contained in:
parent
78372b440d
commit
5bcced0187
56 changed files with 6245 additions and 116 deletions
|
|
@ -2,42 +2,57 @@ import { expect, type Locator, type Page as PlaywrightPage } from "@playwright/t
|
|||
import { navigateToPage, dismissFeedbackPopup } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
|
||||
/** Controls for the Test Key / Playground page, shared with the router-fallback specs. */
|
||||
|
||||
/**
|
||||
* The configuration panel is rendered twice, docked and overlay, with one visible at a time.
|
||||
* Every control is narrowed to the visible copy or it trips strict mode against its hidden twin.
|
||||
*/
|
||||
export const onlyVisible = (locator: Locator): Locator => locator.filter({ visible: true }).first();
|
||||
|
||||
/** The model combobox, addressed by the placeholder its search input shows before selection. */
|
||||
export const modelSelect = (page: PlaywrightPage): Locator => onlyVisible(page.getByPlaceholder("Select a Model"));
|
||||
export const composer = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.getByRole("group").filter({ has: page.getByRole("textbox", { name: "Message" }) }));
|
||||
|
||||
export const modelSelect = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.getByRole("combobox", { name: "Model", exact: true }));
|
||||
|
||||
export const sendButton = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.getByRole("button", { name: "Send message" }));
|
||||
composer(page).getByRole("button", { name: "Send message" });
|
||||
|
||||
/** The Virtual Key Source dropdown, addressed by the accessible name on its trigger. */
|
||||
export const keySourceSelect = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.getByLabel("Virtual Key Source"));
|
||||
export const keySourceSelect = (page: PlaywrightPage): Locator => onlyVisible(page.getByLabel("Send as"));
|
||||
|
||||
export async function openPlayground(page: PlaywrightPage): Promise<void> {
|
||||
await navigateToPage(page, Page.LlmPlayground);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(onlyVisible(page.getByText("Virtual Key Source"))).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(modelSelect(page)).toBeVisible({ timeout: 20_000 });
|
||||
const searchHint = page.getByRole("button", { name: "Dismiss search hint" });
|
||||
if (await searchHint.isVisible()) {
|
||||
await searchHint.click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openRequestSettings(page: PlaywrightPage): Promise<void> {
|
||||
await composer(page).getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(keySourceSelect(page)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function enterVirtualKey(page: PlaywrightPage, key: string): Promise<void> {
|
||||
await openRequestSettings(page);
|
||||
await keySourceSelect(page).click();
|
||||
await onlyVisible(page.getByRole("option", { name: "Virtual key", exact: true })).click({ timeout: 15_000 });
|
||||
const keyInput = onlyVisible(page.getByLabel("Virtual key", { exact: true }));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(key);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function searchModels(page: PlaywrightPage, query: string): Promise<void> {
|
||||
await modelSelect(page).click();
|
||||
await onlyVisible(page.getByPlaceholder("Search models")).fill(query);
|
||||
}
|
||||
|
||||
export async function selectModel(page: PlaywrightPage, model: string): Promise<void> {
|
||||
const select = modelSelect(page);
|
||||
await select.click();
|
||||
// Virtualized: options outside the rendered window are absent from the DOM, so search first.
|
||||
await select.fill(model);
|
||||
await searchModels(page, model);
|
||||
await onlyVisible(page.getByRole("option", { name: model, exact: true })).click({ timeout: 15_000 });
|
||||
await expect(modelSelect(page)).toContainText(model);
|
||||
}
|
||||
|
||||
export async function sendMessage(page: PlaywrightPage, message: string): Promise<void> {
|
||||
const input = onlyVisible(page.getByPlaceholder("Type your message", { exact: false }));
|
||||
const input = composer(page).getByRole("textbox", { name: "Message" });
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill(message);
|
||||
await sendButton(page).click();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ADMIN_STORAGE_PATH, MOCK_PRESIDIO_URL, PROPAGATION_TIMEOUT_MS } from ".
|
|||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { CHAT_MODEL_A, masterKey, rootPath, uniqueSuffix, waitForSpendLog } from "../../helpers/traffic";
|
||||
import { openPlayground, selectModel, sendButton, onlyVisible } from "../../helpers/playground";
|
||||
import { composer, openPlayground, selectModel, sendButton, onlyVisible } from "../../helpers/playground";
|
||||
|
||||
const RAW_EMAIL = "jane.doe@example.com";
|
||||
const RAW_PHONE = "555-867-5309";
|
||||
|
|
@ -123,14 +123,12 @@ test.describe("Presidio PII guardrail, end to end from the dashboard", () => {
|
|||
await openPlayground(page);
|
||||
await selectModel(page, CHAT_MODEL_A);
|
||||
|
||||
const guardrailSelect = onlyVisible(page.getByPlaceholder("Select guardrails"));
|
||||
await expect(guardrailSelect).toBeVisible({ timeout: 20_000 });
|
||||
await guardrailSelect.click();
|
||||
await guardrailSelect.fill(guardrailName);
|
||||
await composer(page).getByRole("combobox", { name: "Guardrails" }).click();
|
||||
await onlyVisible(page.getByPlaceholder("Search guardrails")).fill(guardrailName);
|
||||
await onlyVisible(page.getByRole("option", { name: guardrailName, exact: true })).click({ timeout: 20_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const input = onlyVisible(page.getByPlaceholder("Type your message", { exact: false }));
|
||||
const input = composer(page).getByRole("textbox", { name: "Message" });
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await input.fill(prompt);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, clickTeamId } from "../../helpers/navigation";
|
||||
import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, masterKey } from "../../helpers/traffic";
|
||||
import { keySourceSelect, onlyVisible, openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
import { openPlayground, selectModel, sendMessage, enterVirtualKey } from "../../helpers/playground";
|
||||
|
||||
test.describe("Internal User", () => {
|
||||
test.use({ storageState: INTERNAL_USER_STORAGE_PATH });
|
||||
|
|
@ -73,12 +73,7 @@ test.describe("Internal User", () => {
|
|||
|
||||
try {
|
||||
await openPlayground(page);
|
||||
await keySourceSelect(page).click();
|
||||
await onlyVisible(page.getByRole("option", { name: "Virtual Key" })).click({ timeout: 15_000 });
|
||||
|
||||
const keyInput = onlyVisible(page.getByPlaceholder("Enter custom Virtual Key"));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(apiKey);
|
||||
await enterVirtualKey(page, apiKey);
|
||||
|
||||
await selectModel(page, CHAT_MODEL_A);
|
||||
await sendMessage(page, `internal user team key ping ${keyName}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { CHAT_MODEL_A, CHAT_MODEL_B, MOCK_RESPONSE_TEXT, createVirtualKey } from "../../helpers/traffic";
|
||||
import { keySourceSelect, onlyVisible, openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
import {
|
||||
keySourceSelect,
|
||||
openPlayground,
|
||||
openRequestSettings,
|
||||
selectModel,
|
||||
sendMessage,
|
||||
enterVirtualKey,
|
||||
} from "../../helpers/playground";
|
||||
|
||||
/**
|
||||
* The one flow that exercises the dashboard's own LLM call path rather than an admin CRUD endpoint,
|
||||
|
|
@ -14,8 +21,9 @@ test.describe("Playground", () => {
|
|||
test(`chats with ${model} using the current UI session`, async ({ page }) => {
|
||||
await openPlayground(page);
|
||||
|
||||
// "Current UI Session" is the default: the logged-in admin's key, nothing pasted.
|
||||
await expect(keySourceSelect(page)).toContainText("Current UI Session");
|
||||
await openRequestSettings(page);
|
||||
await expect(keySourceSelect(page)).toContainText("Current UI session");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await selectModel(page, model);
|
||||
const prompt = `playground ping for ${model}`;
|
||||
|
|
@ -34,14 +42,7 @@ test.describe("Playground", () => {
|
|||
|
||||
await openPlayground(page);
|
||||
|
||||
// Switch the source to "Virtual Key" and paste the key we just minted.
|
||||
await keySourceSelect(page).click();
|
||||
await onlyVisible(page.getByRole("option", { name: "Virtual Key" })).click({ timeout: 15_000 });
|
||||
|
||||
const keyInput = onlyVisible(page.getByPlaceholder("Enter custom Virtual Key"));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(key);
|
||||
|
||||
await enterVirtualKey(page, key);
|
||||
await selectModel(page, CHAT_MODEL_A);
|
||||
await sendMessage(page, "playground ping via virtual key");
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Page } from "../../fixtures/pages";
|
|||
import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
import { CHAT_MODEL_A, masterKey } from "../../helpers/traffic";
|
||||
import { keySourceSelect, modelSelect, onlyVisible, openPlayground } from "../../helpers/playground";
|
||||
import { onlyVisible, openPlayground, searchModels, enterVirtualKey } from "../../helpers/playground";
|
||||
|
||||
/**
|
||||
* Every identifier a roster is addressable by. Which of user_id / user_email is populated depends on
|
||||
|
|
@ -228,21 +228,14 @@ test.describe("Team Admin", () => {
|
|||
.toBe(true);
|
||||
|
||||
await openPlayground(page);
|
||||
await keySourceSelect(page).click();
|
||||
await onlyVisible(page.getByRole("option", { name: "Virtual Key" })).click({ timeout: 15_000 });
|
||||
await enterVirtualKey(page, teamKey);
|
||||
|
||||
const keyInput = onlyVisible(page.getByPlaceholder("Enter custom Virtual Key"));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(teamKey);
|
||||
|
||||
const select = modelSelect(page);
|
||||
await select.click();
|
||||
await select.fill(teamModelName);
|
||||
await searchModels(page, teamModelName);
|
||||
await expect(onlyVisible(page.getByRole("option", { name: teamModelName }))).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await select.fill(CHAT_MODEL_A);
|
||||
await onlyVisible(page.getByPlaceholder("Search models")).fill(CHAT_MODEL_A);
|
||||
await expect(onlyVisible(page.getByRole("option", { name: CHAT_MODEL_A }))).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2090,11 +2090,26 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/attachment.tsx": {
|
||||
"local/no-ad-hoc-z-index": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/ui/message-scroller.tsx": {
|
||||
"local/no-noop-hover-variant": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/slider.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/toggle-group.tsx": {
|
||||
"local/no-ad-hoc-z-index": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/update_model_credentials_modal.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { Check, ExternalLink, KeyRound } from "lucide-react";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroupButton } from "@/components/ui/input-group";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const saveErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof ApiError) {
|
||||
const detail = (error.body as { detail?: { error?: string } } | null)?.detail?.error;
|
||||
if (detail) return detail;
|
||||
}
|
||||
return error instanceof Error && error.message ? error.message : "Failed to connect";
|
||||
};
|
||||
|
||||
export function ByokConnect({
|
||||
server,
|
||||
name,
|
||||
onConnected,
|
||||
onCancel,
|
||||
}: {
|
||||
server: MCPServer;
|
||||
name: string;
|
||||
onConnected: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [credential, setCredential] = useState("");
|
||||
const [save, setSave] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const keyId = useId();
|
||||
const saveId = useId();
|
||||
|
||||
const connect = async () => {
|
||||
if (!credential.trim()) {
|
||||
toast.error("Enter your API key");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await fetchClient.POST("/v1/mcp/server/{server_id}/user-credential", {
|
||||
params: { path: { server_id: server.server_id } },
|
||||
body: { credential: credential.trim(), save },
|
||||
});
|
||||
toast.success(`Connected to ${name}`);
|
||||
setCredential("");
|
||||
onConnected();
|
||||
} catch (error) {
|
||||
toast.error(saveErrorMessage(error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldGroup className="gap-4 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
{server.has_user_credential ? `Replace your ${name} API key` : `${name} needs your API key`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{server.has_user_credential
|
||||
? `The new key replaces the one the gateway uses for ${name}.`
|
||||
: `The gateway calls ${name} with your own key. Connect it to list and use its tools.`}
|
||||
</p>
|
||||
</div>
|
||||
{server.byok_description && server.byok_description.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 text-sm">
|
||||
{server.byok_description.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<Check className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor={keyId}>{name} API key</FieldLabel>
|
||||
<PasswordInput
|
||||
id={keyId}
|
||||
value={credential}
|
||||
onChange={(event) => setCredential(event.target.value)}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
{server.byok_api_key_help_url && (
|
||||
<FieldDescription>
|
||||
<a href={server.byok_api_key_help_url} target="_blank" rel="noopener noreferrer">
|
||||
Where to find this key
|
||||
<ExternalLink className="ml-1 inline size-3" />
|
||||
</a>
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor={saveId} className="font-normal">
|
||||
Save the key for next time
|
||||
</FieldLabel>
|
||||
<Switch id={saveId} checked={save} onCheckedChange={setSave} />
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => void connect()} disabled={isSaving}>
|
||||
{isSaving && <Spinner />}
|
||||
Connect
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function ByokKeyButton({
|
||||
server,
|
||||
name,
|
||||
onConnected,
|
||||
}: {
|
||||
server: MCPServer;
|
||||
name: string;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger render={<InputGroupButton size="xs" className="shrink-0" />}>
|
||||
<KeyRound />
|
||||
{server.has_user_credential ? "Replace key" : "Connect key"}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<ByokConnect
|
||||
server={server}
|
||||
name={name}
|
||||
onConnected={() => {
|
||||
setOpen(false);
|
||||
onConnected();
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { createRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
|
||||
import { Composer, type ChecklistProps, type DraftState } from "./Composer";
|
||||
import { capabilitiesFor } from "./endpointCapabilities";
|
||||
|
||||
const checklist: ChecklistProps = { groups: [], value: [], onValueChange: vi.fn(), isLoading: false };
|
||||
|
||||
const draft: DraftState = {
|
||||
text: "",
|
||||
setText: vi.fn(),
|
||||
files: [],
|
||||
attach: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
isLoading: false,
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
const renderComposer = () =>
|
||||
render(
|
||||
<Composer
|
||||
capabilities={capabilitiesFor(EndpointType.CHAT)}
|
||||
endpoint={EndpointType.CHAT}
|
||||
targetRow={<input aria-label="Model search" />}
|
||||
draft={draft}
|
||||
directTool={null}
|
||||
toolArgsForm={createRef<MCPToolArgumentsFormRef>()}
|
||||
mcpControl={null}
|
||||
vectorStores={checklist}
|
||||
guardrails={checklist}
|
||||
codeInterpreter={{ enabled: false, onToggle: vi.fn() }}
|
||||
settingsControl={createPortal(<input aria-label="Request tags" />, document.body)}
|
||||
sendDisabled
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("Composer", () => {
|
||||
it("keeps focus in a popover field opened from the composer toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComposer();
|
||||
|
||||
await user.click(screen.getByLabelText("Request tags"));
|
||||
|
||||
expect(screen.getByLabelText("Request tags")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focuses the message box when empty toolbar space is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComposer();
|
||||
const toolbar = container.querySelectorAll<HTMLElement>('[data-slot="input-group-addon"]');
|
||||
|
||||
await user.click(toolbar[toolbar.length - 1]);
|
||||
|
||||
expect(screen.getByLabelText("Message")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode, type RefObject } from "react";
|
||||
import { ArrowUp, Code2, Database, Paperclip, Shield, Square } from "lucide-react";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import MCPToolArgumentsForm, { type MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
|
||||
import type { MCPTool } from "@/components/mcp_tools/types";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DraftAttachments } from "./ComposerControls";
|
||||
import type { DraftFile } from "./composerFiles";
|
||||
import { ACCEPT_FOR, ATTACH_LABEL, type Capabilities } from "./endpointCapabilities";
|
||||
import { ChecklistMenu, type OptionGroup } from "./Pickers";
|
||||
|
||||
export type ChecklistProps = Readonly<{
|
||||
groups: readonly OptionGroup[];
|
||||
value: readonly string[];
|
||||
onValueChange: (value: readonly string[]) => void;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
export type DraftState = Readonly<{
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
files: readonly DraftFile[];
|
||||
attach: (files: readonly File[]) => void;
|
||||
removeFile: (index: number) => void;
|
||||
isLoading: boolean;
|
||||
send: () => Promise<void>;
|
||||
cancel: () => void;
|
||||
}>;
|
||||
|
||||
function AttachButton({
|
||||
capabilities,
|
||||
attach,
|
||||
}: {
|
||||
capabilities: Capabilities;
|
||||
attach: (files: readonly File[]) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
if (!capabilities.attach) return null;
|
||||
const label = ATTACH_LABEL[capabilities.attach];
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<InputGroupButton size="icon-xs" aria-label={label} onClick={() => input.current?.click()} />}
|
||||
>
|
||||
<Paperclip />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
accept={ACCEPT_FOR[capabilities.attach]}
|
||||
multiple={capabilities.attach === "images"}
|
||||
onChange={(event) => {
|
||||
attach(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SendButton({ draft, disabled }: { draft: DraftState; disabled: boolean }) {
|
||||
if (draft.isLoading)
|
||||
return (
|
||||
<InputGroupButton size="icon-xs" variant="default" aria-label="Stop" onClick={draft.cancel}>
|
||||
<Square className="fill-current" />
|
||||
</InputGroupButton>
|
||||
);
|
||||
return (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="default"
|
||||
aria-label="Send message"
|
||||
disabled={disabled}
|
||||
onClick={() => void draft.send()}
|
||||
>
|
||||
<ArrowUp />
|
||||
</InputGroupButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
capabilities,
|
||||
endpoint,
|
||||
targetRow,
|
||||
draft,
|
||||
directTool,
|
||||
toolArgsForm,
|
||||
mcpControl,
|
||||
vectorStores,
|
||||
guardrails,
|
||||
codeInterpreter,
|
||||
settingsControl,
|
||||
sendDisabled,
|
||||
}: {
|
||||
capabilities: Capabilities;
|
||||
endpoint: EndpointType;
|
||||
targetRow: ReactNode;
|
||||
draft: DraftState;
|
||||
directTool: MCPTool | null;
|
||||
toolArgsForm: RefObject<MCPToolArgumentsFormRef | null>;
|
||||
mcpControl: ReactNode;
|
||||
vectorStores: ChecklistProps;
|
||||
guardrails: ChecklistProps;
|
||||
codeInterpreter: Readonly<{ enabled: boolean; onToggle: (enabled: boolean) => void }>;
|
||||
settingsControl: ReactNode;
|
||||
sendDisabled: boolean;
|
||||
}) {
|
||||
const isDirectCall = endpoint === EndpointType.MCP;
|
||||
const isRealtime = endpoint === EndpointType.REALTIME;
|
||||
const message = useRef<HTMLTextAreaElement>(null);
|
||||
const focusMessage = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!event.currentTarget.contains(target) || target.closest("button, input, textarea")) return;
|
||||
message.current?.focus();
|
||||
};
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (!capabilities.attach) return;
|
||||
event.preventDefault();
|
||||
draft.attach(Array.from(event.dataTransfer.files));
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
|
||||
event.preventDefault();
|
||||
if (!sendDisabled) void draft.send();
|
||||
};
|
||||
const directCallBody = (
|
||||
<div data-slot="input-group-control" className="w-full px-3 py-3">
|
||||
{directTool ? (
|
||||
<MCPToolArgumentsForm ref={toolArgsForm} tool={directTool} className="flex flex-col gap-3" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Pick a server and a tool above to fill in its arguments</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const textBody = (
|
||||
<InputGroupTextarea
|
||||
ref={message}
|
||||
value={draft.text}
|
||||
onChange={(event) => draft.setText(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={capabilities.placeholder}
|
||||
rows={2}
|
||||
aria-label="Message"
|
||||
className="max-h-60"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<InputGroup onDragOver={(event) => capabilities.attach && event.preventDefault()} onDrop={handleDrop}>
|
||||
<InputGroupAddon align="block-start" className="gap-1 border-b border-border pb-2" onClick={focusMessage}>
|
||||
{targetRow}
|
||||
</InputGroupAddon>
|
||||
{draft.files.length > 0 && (
|
||||
<InputGroupAddon align="block-start" className="pt-2" onClick={focusMessage}>
|
||||
<DraftAttachments files={draft.files} onRemove={draft.removeFile} />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
{!isRealtime && (isDirectCall ? directCallBody : textBody)}
|
||||
<InputGroupAddon align="block-end" className="gap-1" onClick={focusMessage}>
|
||||
{capabilities.mcp && mcpControl}
|
||||
{capabilities.vectorStores && (
|
||||
<ChecklistMenu label="Vector stores" icon={<Database />} emptyText="No vector stores yet" {...vectorStores} />
|
||||
)}
|
||||
{capabilities.guardrails && (
|
||||
<ChecklistMenu label="Guardrails" icon={<Shield />} emptyText="No guardrails yet" {...guardrails} />
|
||||
)}
|
||||
{capabilities.codeInterpreter && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={codeInterpreter.enabled}
|
||||
onPressedChange={codeInterpreter.onToggle}
|
||||
aria-label="Code interpreter"
|
||||
className="h-6 gap-1 px-1.5 text-xs"
|
||||
>
|
||||
<Code2 />
|
||||
Code interpreter
|
||||
</Toggle>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{settingsControl}
|
||||
<AttachButton capabilities={capabilities} attach={draft.attach} />
|
||||
{!isRealtime && <SendButton draft={draft} disabled={sendDisabled} />}
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, FileText, Music, X } from "lucide-react";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/components/ui/attachment";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { InputGroupButton } from "@/components/ui/input-group";
|
||||
import { ENDPOINT_OPTIONS, OPEN_AI_VOICE_SELECT_OPTIONS, type OpenAIVoice } from "../chat_ui/chatConstants";
|
||||
import { formatBytes, type DraftFile } from "./composerFiles";
|
||||
|
||||
const shortEndpoint = (label: string): string => label.replace(/^\/v1(beta)?\//, "");
|
||||
|
||||
export function EndpointMenu({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: EndpointType;
|
||||
onValueChange: (value: EndpointType) => void;
|
||||
}) {
|
||||
const current = ENDPOINT_OPTIONS.find((option) => option.value === value);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<InputGroupButton size="xs" className="shrink-0" aria-label="Endpoint" />}>
|
||||
<span className="font-mono text-xs">{shortEndpoint(current?.label ?? value)}</span>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(next as EndpointType)}>
|
||||
<DropdownMenuLabel>Endpoint</DropdownMenuLabel>
|
||||
{ENDPOINT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick className="font-mono text-xs">
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceMenu({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: OpenAIVoice;
|
||||
onValueChange: (value: OpenAIVoice) => void;
|
||||
}) {
|
||||
const current = OPEN_AI_VOICE_SELECT_OPTIONS.find((option) => option.value === value);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<InputGroupButton size="xs" className="shrink-0" aria-label="Voice" />}>
|
||||
<span className="text-xs">Voice: {current?.label.split(" - ")[0] ?? value}</span>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(next as OpenAIVoice)}>
|
||||
<DropdownMenuLabel>Voice</DropdownMenuLabel>
|
||||
{OPEN_AI_VOICE_SELECT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FileMedia({ draft }: { draft: DraftFile }) {
|
||||
if (draft.previewUrl)
|
||||
return (
|
||||
<AttachmentMedia variant="image">
|
||||
<img src={draft.previewUrl} alt="" />
|
||||
</AttachmentMedia>
|
||||
);
|
||||
return <AttachmentMedia>{draft.file.type.startsWith("audio/") ? <Music /> : <FileText />}</AttachmentMedia>;
|
||||
}
|
||||
|
||||
export function DraftAttachments({
|
||||
files,
|
||||
onRemove,
|
||||
}: {
|
||||
files: readonly DraftFile[];
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<AttachmentGroup className="w-full">
|
||||
{files.map((draft, index) => (
|
||||
<Attachment key={`${draft.file.name}-${index}`} size="xs">
|
||||
<FileMedia draft={draft} />
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{draft.file.name}</AttachmentTitle>
|
||||
<AttachmentDescription>{formatBytes(draft.file.size)}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label={`Remove ${draft.file.name}`} onClick={() => onRemove(index)}>
|
||||
<X />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Brain,
|
||||
Clock,
|
||||
Coins,
|
||||
DatabaseZap,
|
||||
FileText,
|
||||
Music,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
ScanSearch,
|
||||
Timer,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
|
||||
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
|
||||
import type { MessageAttachment, MessageType } from "@/components/chat_ui/types";
|
||||
import type { CodeInterpreterResult } from "@/components/llm_calls/code_interpreter_handler";
|
||||
import type { MCPEvent } from "@/components/mcp_tools/types";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/components/ui/attachment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bubble, BubbleContent } from "@/components/ui/Bubble";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker";
|
||||
import { Message, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message";
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@/components/ui/message-scroller";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import A2AMetrics from "../chat_ui/A2AMetrics";
|
||||
import CodeInterpreterOutput from "../chat_ui/CodeInterpreterOutput";
|
||||
import { SearchResultsDisplay } from "../chat_ui/SearchResultsDisplay";
|
||||
import { formatBytes } from "./composerFiles";
|
||||
import { CopyIconButton, FallbackBadge, LongValue } from "./ModelName";
|
||||
import { ReplyBody } from "./ReplyContent";
|
||||
|
||||
export const formatSeconds = (ms: number): string => `${(ms / 1000).toFixed(2)}s`;
|
||||
|
||||
export const formatCost = (cost: number): string =>
|
||||
`$${cost < 0.0001 && cost > 0 ? cost.toPrecision(2) : cost.toFixed(4)}`;
|
||||
|
||||
const MCP_EVENT_ENDPOINTS: readonly EndpointType[] = [EndpointType.CHAT, EndpointType.RESPONSES];
|
||||
|
||||
const textOf = (message: MessageType): string => (typeof message.content === "string" ? message.content : "");
|
||||
|
||||
const fellBack = (message: MessageType): boolean => (message.trace?.attemptedFallbacks ?? 0) > 0;
|
||||
|
||||
export const servedModel = (message: MessageType): string | undefined =>
|
||||
message.trace?.modelGroup ?? (message.isError ? undefined : message.model ?? message.trace?.requestedModel);
|
||||
|
||||
const replyModel = (message: MessageType): Readonly<{ value: string; detail: string }> | null => {
|
||||
const served = servedModel(message);
|
||||
if (served) return { value: served, detail: fellBack(message) ? "Served by, after a fallback" : "Served by" };
|
||||
const requested = message.trace?.requestedModel ?? message.model;
|
||||
return requested ? { value: requested, detail: "Requested model" } : null;
|
||||
};
|
||||
|
||||
function Metric({ icon, value, label }: { icon: ReactNode; value: string; label?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap [&_svg]:size-3.5">
|
||||
{icon}
|
||||
<span className="text-foreground">{value}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyMetrics({ message }: { message: MessageType }) {
|
||||
const usage = message.usage;
|
||||
const cost = usage?.cost ?? message.trace?.cost;
|
||||
const cached = usage?.servedFromResponseCache || message.trace?.cacheHit;
|
||||
return (
|
||||
<div
|
||||
aria-label="Request metrics"
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{message.timeToFirstToken !== undefined && (
|
||||
<Metric icon={<Timer />} value={formatSeconds(message.timeToFirstToken)} label="to first token" />
|
||||
)}
|
||||
{message.totalLatency !== undefined && (
|
||||
<Metric icon={<Clock />} value={formatSeconds(message.totalLatency)} label="total" />
|
||||
)}
|
||||
{usage?.promptTokens !== undefined && (
|
||||
<Metric icon={<ArrowDownToLine />} value={String(usage.promptTokens)} label="in" />
|
||||
)}
|
||||
{usage?.completionTokens !== undefined && (
|
||||
<Metric icon={<ArrowUpFromLine />} value={String(usage.completionTokens)} label="out" />
|
||||
)}
|
||||
{usage?.reasoningTokens ? (
|
||||
<Metric icon={<Brain />} value={String(usage.reasoningTokens)} label="reasoning" />
|
||||
) : null}
|
||||
{usage?.cacheReadTokens ? (
|
||||
<Metric icon={<DatabaseZap />} value={String(usage.cacheReadTokens)} label="cached" />
|
||||
) : null}
|
||||
{cost !== undefined && <Metric icon={<Coins />} value={formatCost(cost)} />}
|
||||
{message.toolName && <Metric icon={<Wrench />} value={message.toolName} />}
|
||||
{cached && <Badge variant="secondary">Cached response</Badge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentMediaFor({ attachment }: { attachment: MessageAttachment }) {
|
||||
if (attachment.previewUrl)
|
||||
return (
|
||||
<AttachmentMedia variant="image">
|
||||
<img src={attachment.previewUrl} alt="" />
|
||||
</AttachmentMedia>
|
||||
);
|
||||
return <AttachmentMedia>{attachment.kind === "audio" ? <Music /> : <FileText />}</AttachmentMedia>;
|
||||
}
|
||||
|
||||
function UserTurn({ message }: { message: MessageType }) {
|
||||
const text = textOf(message);
|
||||
return (
|
||||
<Message align="end">
|
||||
<MessageContent>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentGroup className="justify-end">
|
||||
{message.attachments.map((attachment, index) => (
|
||||
<Attachment key={`${attachment.name}-${index}`} size="sm">
|
||||
<AttachmentMediaFor attachment={attachment} />
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{attachment.name}</AttachmentTitle>
|
||||
<AttachmentDescription>{formatBytes(attachment.size)}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
)}
|
||||
{message.mcpToolCall && (
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Wrench className="size-3.5" />
|
||||
Called <span className="font-mono text-foreground">{message.mcpToolCall.tool}</span>
|
||||
</span>
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(message.mcpToolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)}
|
||||
{text && (
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent className="whitespace-pre-wrap">{text}</BubbleContent>
|
||||
</Bubble>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
export type LastReplyExtras = Readonly<{
|
||||
endpoint: EndpointType;
|
||||
mcpEvents: readonly MCPEvent[];
|
||||
codeInterpreter: CodeInterpreterResult | null;
|
||||
accessToken: string;
|
||||
}>;
|
||||
|
||||
function AssistantReply({
|
||||
message,
|
||||
isLast,
|
||||
extras,
|
||||
inspected,
|
||||
onInspect,
|
||||
}: {
|
||||
message: MessageType;
|
||||
isLast: boolean;
|
||||
extras: LastReplyExtras;
|
||||
inspected: boolean;
|
||||
onInspect: () => void;
|
||||
}) {
|
||||
const model = replyModel(message);
|
||||
const showMcpEvents = isLast && extras.mcpEvents.length > 0 && MCP_EVENT_ENDPOINTS.includes(extras.endpoint);
|
||||
const showCode = isLast && extras.codeInterpreter && extras.endpoint === EndpointType.RESPONSES;
|
||||
return (
|
||||
<Message>
|
||||
<MessageContent className="text-sm">
|
||||
{model && (
|
||||
<MessageHeader className="gap-2 px-0 font-normal">
|
||||
<LongValue
|
||||
value={model.value}
|
||||
detail={model.detail}
|
||||
copyLabel="model name"
|
||||
className="max-w-96 text-xs text-muted-foreground"
|
||||
/>
|
||||
{fellBack(message) && <FallbackBadge />}
|
||||
</MessageHeader>
|
||||
)}
|
||||
{message.reasoningContent && <ReasoningContent reasoningContent={message.reasoningContent} />}
|
||||
{showMcpEvents && <MCPEventsDisplay events={[...extras.mcpEvents]} />}
|
||||
{message.searchResults && <SearchResultsDisplay searchResults={message.searchResults} />}
|
||||
{showCode && extras.codeInterpreter && (
|
||||
<CodeInterpreterOutput
|
||||
code={extras.codeInterpreter.code}
|
||||
containerId={extras.codeInterpreter.containerId}
|
||||
annotations={extras.codeInterpreter.annotations}
|
||||
accessToken={extras.accessToken}
|
||||
/>
|
||||
)}
|
||||
{message.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>The request failed</AlertTitle>
|
||||
<AlertDescription className="font-mono text-xs wrap-anywhere">{textOf(message)}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ReplyBody message={message} />
|
||||
)}
|
||||
<MessageFooter className="items-center gap-2 px-0 font-normal">
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.a2aMetadata ? (
|
||||
<A2AMetrics
|
||||
a2aMetadata={message.a2aMetadata}
|
||||
timeToFirstToken={message.timeToFirstToken}
|
||||
totalLatency={message.totalLatency}
|
||||
/>
|
||||
) : (
|
||||
<ReplyMetrics message={message} />
|
||||
)}
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
{!message.isImage && !message.isAudio && <CopyIconButton value={textOf(message)} label="reply" />}
|
||||
{message.trace && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={inspected}
|
||||
onPressedChange={onInspect}
|
||||
aria-label="Inspect this request"
|
||||
className="h-6 gap-1 px-1.5 text-xs"
|
||||
>
|
||||
<ScanSearch />
|
||||
Inspect
|
||||
</Toggle>
|
||||
)}
|
||||
</span>
|
||||
</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingTurn({ target }: { target: string | undefined }) {
|
||||
return (
|
||||
<Message>
|
||||
<MessageContent className="text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner className="size-3.5" />
|
||||
<span className="shimmer">{target ? `Waiting for ${target}` : "Waiting for a reply"}</span>
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchMarker({ model }: { model: string }) {
|
||||
return (
|
||||
<Marker variant="separator">
|
||||
<MarkerIcon>
|
||||
<Repeat />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Switched to {model}</MarkerContent>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyConversation({
|
||||
suggestions,
|
||||
onPick,
|
||||
}: {
|
||||
suggestions: readonly string[];
|
||||
onPick: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Empty className="flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<PlayCircle />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Send a test request</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Pick a model and anything the request should go through, then inspect what the gateway did with it
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{suggestions.length > 0 && (
|
||||
<EmptyContent className="max-w-xl flex-row flex-wrap justify-center gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<Button key={suggestion} variant="outline" size="sm" onClick={() => onPick(suggestion)}>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</EmptyContent>
|
||||
)}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
const switchedModel = (messages: readonly MessageType[], index: number): string | null => {
|
||||
const message = messages[index];
|
||||
if (message?.role !== "user" || !message.model) return null;
|
||||
const previous = messages.slice(0, index).findLast((candidate) => candidate.role === "user");
|
||||
return previous?.model && previous.model !== message.model ? message.model : null;
|
||||
};
|
||||
|
||||
export function Conversation({
|
||||
messages,
|
||||
isLoading,
|
||||
waitingFor,
|
||||
extras,
|
||||
inspectedIndex,
|
||||
onInspect,
|
||||
suggestions,
|
||||
onPickSuggestion,
|
||||
}: {
|
||||
messages: readonly MessageType[];
|
||||
isLoading: boolean;
|
||||
waitingFor: string | undefined;
|
||||
extras: LastReplyExtras;
|
||||
inspectedIndex: number | null;
|
||||
onInspect: (index: number) => void;
|
||||
suggestions: readonly string[];
|
||||
onPickSuggestion: (text: string) => void;
|
||||
}) {
|
||||
if (messages.length === 0) return <EmptyConversation suggestions={suggestions} onPick={onPickSuggestion} />;
|
||||
const lastIndex = messages.length - 1;
|
||||
const waiting = isLoading && messages[lastIndex]?.role === "user";
|
||||
return (
|
||||
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
||||
<MessageScroller className="min-h-0 flex-1">
|
||||
<MessageScrollerViewport aria-label="Conversation" className="scroll-fade-b">
|
||||
<MessageScrollerContent className="mx-auto w-full max-w-3xl gap-6 px-6 py-6">
|
||||
{messages.flatMap((message, index) => {
|
||||
const switched = switchedModel(messages, index);
|
||||
const turn = (
|
||||
<MessageScrollerItem key={`m${index}`} messageId={`m${index}`} scrollAnchor={index === lastIndex}>
|
||||
{message.role === "user" ? (
|
||||
<UserTurn message={message} />
|
||||
) : (
|
||||
<AssistantReply
|
||||
message={message}
|
||||
isLast={index === lastIndex}
|
||||
extras={extras}
|
||||
inspected={inspectedIndex === index}
|
||||
onInspect={() => onInspect(index)}
|
||||
/>
|
||||
)}
|
||||
</MessageScrollerItem>
|
||||
);
|
||||
return switched
|
||||
? [
|
||||
<MessageScrollerItem key={`s${index}`} messageId={`s${index}`}>
|
||||
<SwitchMarker model={switched} />
|
||||
</MessageScrollerItem>,
|
||||
turn,
|
||||
]
|
||||
: [turn];
|
||||
})}
|
||||
{waiting && (
|
||||
<MessageScrollerItem key="waiting" messageId="waiting" scrollAnchor>
|
||||
<WaitingTurn target={waitingFor} />
|
||||
</MessageScrollerItem>
|
||||
)}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Brain,
|
||||
Clock,
|
||||
Coins,
|
||||
DatabaseZap,
|
||||
Gauge,
|
||||
RotateCw,
|
||||
Server,
|
||||
Shuffle,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import type { MessageType } from "@/components/chat_ui/types";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { FieldDescription } from "@/components/ui/field";
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { formatCost, formatSeconds, servedModel } from "./Conversation";
|
||||
import { CopyIconButton, FallbackBadge, LongValue } from "./ModelName";
|
||||
|
||||
export type CodeSdk = "openai" | "azure";
|
||||
|
||||
function TraceRow({ title, description, children }: { title: string; description?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<Item size="xs" className="flex-nowrap px-0 py-1.5">
|
||||
<ItemContent className="flex-none gap-0">
|
||||
<ItemTitle className="font-normal whitespace-nowrap text-muted-foreground">{title}</ItemTitle>
|
||||
{description && <ItemDescription className="whitespace-nowrap">{description}</ItemDescription>}
|
||||
</ItemContent>
|
||||
{children && <ItemActions className="min-w-0 flex-1 justify-end text-sm tabular-nums">{children}</ItemActions>}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
type StatValue = Readonly<{ label: string; icon: ReactNode; value: string | undefined }>;
|
||||
|
||||
function StatGrid({ stats }: { stats: readonly StatValue[] }) {
|
||||
const shown = stats.filter((stat): stat is StatValue & { value: string } => stat.value !== undefined);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<ItemGroup className="grid grid-cols-3 gap-x-4 gap-y-3 py-2 has-data-[size=sm]:gap-y-3">
|
||||
{shown.map((stat) => (
|
||||
<Item key={stat.label} size="sm" className="flex-col flex-nowrap items-start gap-0.5 p-0">
|
||||
<ItemDescription className="inline-flex items-center gap-1 whitespace-nowrap [&_svg]:size-3.5">
|
||||
{stat.icon}
|
||||
{stat.label}
|
||||
</ItemDescription>
|
||||
<ItemTitle className="tabular-nums">{stat.value}</ItemTitle>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceSection({ value, title, children }: { value: string; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<AccordionItem value={value} className="px-4">
|
||||
<AccordionTrigger className="py-3">{title}</AccordionTrigger>
|
||||
<AccordionContent className="pb-3 [&_p:not(:last-child)]:mb-0">
|
||||
<ItemGroup className="gap-0 pb-1 has-data-[size=xs]:gap-0">{children}</ItemGroup>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function Muted({ children }: { children: ReactNode }) {
|
||||
return <p className="py-1.5 text-sm text-muted-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
const count = (value: number | undefined): string | undefined => (value === undefined ? undefined : String(value));
|
||||
const millis = (value: number | undefined): string | undefined =>
|
||||
value === undefined ? undefined : `${Math.round(value)}ms`;
|
||||
|
||||
function Routing({ message }: { message: MessageType }) {
|
||||
const trace = message.trace;
|
||||
const served = servedModel(message);
|
||||
const fellBack = (trace?.attemptedFallbacks ?? 0) > 0;
|
||||
const reported = Boolean(trace?.callId || trace?.modelGroup || trace?.providerModel);
|
||||
return (
|
||||
<TraceSection value="routing" title="Routing">
|
||||
{trace?.requestedModel && (
|
||||
<TraceRow title="Requested">
|
||||
<LongValue value={trace.requestedModel} detail="Requested model" copyLabel="model name" className="text-xs" />
|
||||
</TraceRow>
|
||||
)}
|
||||
{served && (
|
||||
<TraceRow title="Served by">
|
||||
{fellBack && <FallbackBadge />}
|
||||
<LongValue value={served} detail="Model group that answered" copyLabel="model name" className="text-xs" />
|
||||
</TraceRow>
|
||||
)}
|
||||
{trace?.providerModel && (
|
||||
<TraceRow title="Provider model">
|
||||
<LongValue
|
||||
value={trace.providerModel}
|
||||
detail={trace.deploymentId ? `Deployment ID ${trace.deploymentId}` : "Model sent to the provider"}
|
||||
copyLabel="provider model"
|
||||
className="text-xs"
|
||||
align="end"
|
||||
/>
|
||||
</TraceRow>
|
||||
)}
|
||||
{trace?.apiBase && (
|
||||
<TraceRow title="API base">
|
||||
<LongValue
|
||||
value={trace.apiBase}
|
||||
detail="Provider endpoint"
|
||||
copyLabel="API base"
|
||||
className="text-xs"
|
||||
align="end"
|
||||
/>
|
||||
</TraceRow>
|
||||
)}
|
||||
{trace?.callId && (
|
||||
<TraceRow title="Call ID">
|
||||
<span className="min-w-0 truncate font-mono text-xs">{trace.callId}</span>
|
||||
<CopyIconButton value={trace.callId} label="call ID" />
|
||||
</TraceRow>
|
||||
)}
|
||||
{trace?.responseId && (
|
||||
<TraceRow title="Response ID">
|
||||
<span className="min-w-0 truncate font-mono text-xs">{trace.responseId}</span>
|
||||
<CopyIconButton value={trace.responseId} label="response ID" />
|
||||
</TraceRow>
|
||||
)}
|
||||
<StatGrid
|
||||
stats={[
|
||||
{ label: "Fallbacks", icon: <Shuffle />, value: count(trace?.attemptedFallbacks) },
|
||||
{ label: "Retries", icon: <RotateCw />, value: count(trace?.attemptedRetries) },
|
||||
{ label: "Overhead", icon: <Gauge />, value: millis(trace?.overheadMs) },
|
||||
]}
|
||||
/>
|
||||
{!reported && (
|
||||
<Muted>
|
||||
The gateway did not share routing details with this page. Open the dashboard from the proxy to see them
|
||||
</Muted>
|
||||
)}
|
||||
</TraceSection>
|
||||
);
|
||||
}
|
||||
|
||||
const noneRan = (policies: readonly string[] | undefined): string | null => {
|
||||
if (policies === undefined) return "No guardrails ran on this request";
|
||||
return policies.length === 0 ? "No guardrails or policies ran on this request" : null;
|
||||
};
|
||||
|
||||
function Guardrails({ message }: { message: MessageType }) {
|
||||
const guardrails = message.trace?.appliedGuardrails;
|
||||
const policies = message.trace?.appliedPolicies;
|
||||
if (guardrails === undefined) {
|
||||
return (
|
||||
<TraceSection value="guardrails" title="Guardrails and policies">
|
||||
<Muted>This endpoint does not report which guardrails or policies ran</Muted>
|
||||
</TraceSection>
|
||||
);
|
||||
}
|
||||
const emptyNote = guardrails.length === 0 ? noneRan(policies) : null;
|
||||
return (
|
||||
<TraceSection value="guardrails" title="Guardrails and policies">
|
||||
{guardrails.map((name) => (
|
||||
<TraceRow key={`g-${name}`} title={name} description="Guardrail">
|
||||
<Badge variant="secondary">Ran</Badge>
|
||||
</TraceRow>
|
||||
))}
|
||||
{policies?.map((name) => (
|
||||
<TraceRow key={`p-${name}`} title={name} description="Policy">
|
||||
<Badge variant="secondary">Ran</Badge>
|
||||
</TraceRow>
|
||||
))}
|
||||
{emptyNote && <Muted>{emptyNote}</Muted>}
|
||||
{policies === undefined && <Muted>Policies are only reported when the dashboard is opened from the proxy</Muted>}
|
||||
</TraceSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedAndCost({ message }: { message: MessageType }) {
|
||||
const usage = message.usage;
|
||||
const cost = usage?.cost ?? message.trace?.cost;
|
||||
return (
|
||||
<TraceSection value="performance" title="Speed and cost">
|
||||
<StatGrid
|
||||
stats={[
|
||||
{
|
||||
label: "First token",
|
||||
icon: <Timer />,
|
||||
value: message.timeToFirstToken === undefined ? undefined : formatSeconds(message.timeToFirstToken),
|
||||
},
|
||||
{
|
||||
label: "Total",
|
||||
icon: <Clock />,
|
||||
value: message.totalLatency === undefined ? undefined : formatSeconds(message.totalLatency),
|
||||
},
|
||||
{ label: "Cost", icon: <Coins />, value: cost === undefined ? undefined : formatCost(cost) },
|
||||
{ label: "Tokens in", icon: <ArrowDownToLine />, value: count(usage?.promptTokens) },
|
||||
{ label: "Tokens out", icon: <ArrowUpFromLine />, value: count(usage?.completionTokens) },
|
||||
{ label: "Cached", icon: <DatabaseZap />, value: count(usage?.cacheReadTokens) },
|
||||
{ label: "Reasoning", icon: <Brain />, value: count(usage?.reasoningTokens) },
|
||||
{ label: "Gateway time", icon: <Server />, value: millis(message.trace?.responseMs) },
|
||||
]}
|
||||
/>
|
||||
</TraceSection>
|
||||
);
|
||||
}
|
||||
|
||||
function NothingYet() {
|
||||
return (
|
||||
<Empty className="flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle className="text-sm">Nothing to inspect yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Each reply shows here with the deployment that served it, fallbacks, guardrails, timing and cost
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function Trace({ message }: { message: MessageType | null }) {
|
||||
if (!message) return <NothingYet />;
|
||||
return (
|
||||
<Accordion multiple defaultValue={["routing", "guardrails", "performance"]}>
|
||||
<Routing message={message} />
|
||||
<Guardrails message={message} />
|
||||
<SpeedAndCost message={message} />
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeTab({ code }: { code: (sdk: CodeSdk) => string }) {
|
||||
const [sdk, setSdk] = useState<CodeSdk>("openai");
|
||||
const snippet = code(sdk);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 pt-3">
|
||||
<ToggleGroup
|
||||
value={[sdk]}
|
||||
onValueChange={(value) => value[0] && setSdk(value[0] as CodeSdk)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
aria-label="SDK"
|
||||
>
|
||||
<ToggleGroupItem value="openai" className="h-7 px-2 text-xs">
|
||||
OpenAI SDK
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="azure" className="h-7 px-2 text-xs">
|
||||
Azure SDK
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="ml-auto">
|
||||
<CopyIconButton value={snippet} label="code" />
|
||||
</span>
|
||||
</div>
|
||||
<FieldDescription className="px-4 pt-2 text-xs">
|
||||
Send this request from your own app. Replace YOUR_LITELLM_API_KEY with a virtual key
|
||||
</FieldDescription>
|
||||
<ScrollArea className="w-full">
|
||||
<pre className="px-4 py-3 font-mono text-xs leading-5 whitespace-pre text-foreground">{snippet}</pre>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorPanel({ message, code }: { message: MessageType | null; code: (sdk: CodeSdk) => string }) {
|
||||
return (
|
||||
<Tabs defaultValue="trace" className="min-h-0 flex-1 gap-0">
|
||||
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="trace">Trace</TabsTrigger>
|
||||
<TabsTrigger value="code">Code</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<TabsContent value="trace" className="flex min-h-96 flex-col">
|
||||
<Trace message={message} />
|
||||
</TabsContent>
|
||||
<TabsContent value="code">
|
||||
<CodeTab code={code} />
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Blocks, KeyRound, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { ByokConnect } from "./ByokConnect";
|
||||
import {
|
||||
ALL_MCP_SERVERS,
|
||||
TOOLSET_PREFIX,
|
||||
allowedTools,
|
||||
toggleMcpItem,
|
||||
toggleToolLimit,
|
||||
withoutStaleLimits,
|
||||
type ToolLimits,
|
||||
} from "./playgroundSettings";
|
||||
import { useServerTools } from "./usePlaygroundCatalog";
|
||||
|
||||
type Row = Readonly<{ value: string; label: string }>;
|
||||
|
||||
export const serverName = (server: MCPServer): string => server.alias || server.server_name || server.server_id;
|
||||
|
||||
const matches = (value: string, query: string): boolean => value.toLowerCase().includes(query.trim().toLowerCase());
|
||||
|
||||
const needsKey = (server: MCPServer | undefined): boolean => Boolean(server?.is_byok && !server.has_user_credential);
|
||||
|
||||
function SearchField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
autoFocus: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className="h-8">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function MasterRow({
|
||||
row,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
active,
|
||||
onSelect,
|
||||
summary,
|
||||
}: {
|
||||
row: Row;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
summary?: string;
|
||||
}) {
|
||||
return (
|
||||
<div data-active={active || undefined} className="flex items-center gap-1 rounded-sm pl-2 data-active:bg-accent">
|
||||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} aria-label={`Use ${row.label}`} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={active}
|
||||
onClick={onSelect}
|
||||
className="min-w-0 flex-1 justify-between px-2 font-normal hover:bg-transparent aria-pressed:bg-transparent"
|
||||
>
|
||||
<span className="truncate">{row.label}</span>
|
||||
{summary && <span className="text-xs whitespace-nowrap text-muted-foreground">{summary}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({
|
||||
label,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field orientation="horizontal" className="min-h-8 gap-2 rounded-sm px-2 py-1 hover:bg-accent">
|
||||
<Checkbox id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
|
||||
<FieldLabel htmlFor={id} className="min-w-0 flex-1 truncate font-mono text-xs font-normal">
|
||||
{label}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle className="text-sm">{title}</EmptyTitle>
|
||||
<EmptyDescription>{children}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{action && <EmptyContent>{action}</EmptyContent>}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function RowGroup({ title, rows, render }: { title: string; rows: readonly Row[]; render: (row: Row) => ReactNode }) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend variant="label" className="mb-0 px-2 pt-2 pb-1 text-xs font-normal text-muted-foreground">
|
||||
{title}
|
||||
</FieldLegend>
|
||||
{rows.map(render)}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpDialog({
|
||||
requestKey,
|
||||
servers,
|
||||
toolsets,
|
||||
isLoading,
|
||||
selection,
|
||||
toolLimits,
|
||||
onChange,
|
||||
onCredentialSaved,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
requestKey: string | null;
|
||||
servers: readonly MCPServer[];
|
||||
toolsets: readonly MCPToolset[];
|
||||
isLoading: boolean;
|
||||
selection: readonly string[];
|
||||
toolLimits: ToolLimits;
|
||||
onChange: (selection: readonly string[], limits: ToolLimits) => void;
|
||||
onCredentialSaved: () => void;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [focused, setFocused] = useState<string>(selection.find((item) => item !== ALL_MCP_SERVERS) ?? ALL_MCP_SERVERS);
|
||||
const [query, setQuery] = useState("");
|
||||
const [toolQuery, setToolQuery] = useState("");
|
||||
const [replacingKey, setReplacingKey] = useState<string | null>(null);
|
||||
const serverById = new Map(servers.map((server) => [server.server_id, server]));
|
||||
const selectedServers = selection.filter((item) => serverById.has(item));
|
||||
const toolsByServer = useServerTools(
|
||||
requestKey,
|
||||
[...new Set([...selectedServers, ...(serverById.has(focused) ? [focused] : [])])].filter(
|
||||
(id) => !needsKey(serverById.get(id)),
|
||||
),
|
||||
);
|
||||
const toolNames = (serverId: string): readonly string[] =>
|
||||
(toolsByServer[serverId]?.tools ?? []).map((tool) => tool.name);
|
||||
|
||||
const serverRows: Row[] = servers.map((server) => ({ value: server.server_id, label: serverName(server) }));
|
||||
const toolsetRows: Row[] = toolsets.map((toolset) => ({
|
||||
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
|
||||
label: toolset.toolset_name,
|
||||
}));
|
||||
const visible = (rows: readonly Row[]) => rows.filter((row) => matches(row.label, query));
|
||||
|
||||
const toggle = (item: string, on: boolean) => {
|
||||
const next = toggleMcpItem(selection, item, on);
|
||||
onChange(next, withoutStaleLimits(toolLimits, next));
|
||||
if (on) setFocused(item);
|
||||
};
|
||||
|
||||
const summary = (row: Row): string | undefined => {
|
||||
if (!selection.includes(row.value)) return undefined;
|
||||
const server = serverById.get(row.value);
|
||||
if (!server) return undefined;
|
||||
if (needsKey(server)) return "Needs your key";
|
||||
const tools = toolNames(row.value);
|
||||
if (tools.length === 0) return undefined;
|
||||
const allowed = allowedTools(toolLimits, row.value, tools).length;
|
||||
return allowed === tools.length ? `All ${tools.length} tools` : `${allowed} of ${tools.length} tools`;
|
||||
};
|
||||
|
||||
const focus = (item: string) => {
|
||||
setFocused(item);
|
||||
setToolQuery("");
|
||||
setReplacingKey(null);
|
||||
};
|
||||
|
||||
const masterRow = (row: Row) => (
|
||||
<MasterRow
|
||||
key={row.value}
|
||||
row={row}
|
||||
checked={selection.includes(row.value)}
|
||||
onCheckedChange={(on) => toggle(row.value, on)}
|
||||
active={row.value === focused}
|
||||
onSelect={() => focus(row.value)}
|
||||
summary={summary(row)}
|
||||
/>
|
||||
);
|
||||
|
||||
const detail = (): ReactNode => {
|
||||
if (focused === ALL_MCP_SERVERS)
|
||||
return <Note title="All MCP servers">Every server this key can reach, with all of its tools</Note>;
|
||||
const toolset = toolsets.find((item) => `${TOOLSET_PREFIX}${item.toolset_id}` === focused);
|
||||
if (toolset)
|
||||
return (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{toolset.description || "A toolset's tools are fixed by whoever created it"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{toolset.tools.map((tool) => (
|
||||
<Badge key={`${tool.server_id}-${tool.tool_name}`} variant="secondary" className="font-mono">
|
||||
{tool.tool_name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
const server = serverById.get(focused);
|
||||
if (!server) return <Note title="Nothing selected">Pick a server or toolset on the left</Note>;
|
||||
const name = serverName(server);
|
||||
if (needsKey(server) || replacingKey === focused)
|
||||
return (
|
||||
<ByokConnect
|
||||
server={server}
|
||||
name={name}
|
||||
onConnected={() => {
|
||||
setReplacingKey(null);
|
||||
onCredentialSaved();
|
||||
}}
|
||||
onCancel={needsKey(server) ? undefined : () => setReplacingKey(null)}
|
||||
/>
|
||||
);
|
||||
const replaceKey = server.is_byok ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setReplacingKey(focused)}>
|
||||
<KeyRound />
|
||||
Replace key
|
||||
</Button>
|
||||
) : undefined;
|
||||
if (!selection.includes(focused))
|
||||
return (
|
||||
<Note title={name} action={replaceKey}>
|
||||
Check this server to choose which of its tools the model can call
|
||||
</Note>
|
||||
);
|
||||
const state = toolsByServer[focused];
|
||||
if (!state || state.isLoading)
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
if (state.failed)
|
||||
return (
|
||||
<Note title={name} action={replaceKey}>
|
||||
Couldn't load this server's tools
|
||||
</Note>
|
||||
);
|
||||
const tools = toolNames(focused);
|
||||
if (tools.length === 0) return <Note title={name}>This server has no tools</Note>;
|
||||
const allowed = allowedTools(toolLimits, focused, tools);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<SearchField
|
||||
value={toolQuery}
|
||||
onChange={setToolQuery}
|
||||
placeholder={`Search ${name} tools`}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={allowed.length === tools.length}
|
||||
onClick={() => onChange(selection, { ...toolLimits, [focused]: [] })}
|
||||
>
|
||||
Allow all
|
||||
</Button>
|
||||
{replaceKey}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="grid grid-cols-2 p-1">
|
||||
{tools
|
||||
.filter((tool) => matches(tool, toolQuery))
|
||||
.map((tool) => (
|
||||
<ToolRow
|
||||
key={tool}
|
||||
label={tool}
|
||||
checked={allowed.includes(tool)}
|
||||
disabled={allowed.length === 1 && allowed.includes(tool)}
|
||||
onCheckedChange={(on) =>
|
||||
onChange(selection, toggleToolLimit(toolLimits, { server: focused, tools }, tool, on))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const nothingAvailable = !isLoading && servers.length === 0 && toolsets.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog defaultOpen={defaultOpen} onOpenChange={(open) => !open && setReplacingKey(null)}>
|
||||
<DialogTrigger render={<InputGroupButton size="xs" aria-label="MCP" />}>
|
||||
<Blocks />
|
||||
MCP
|
||||
{selection.length > 0 && (
|
||||
<Badge variant="secondary" className="h-4 min-w-4 px-1 tabular-nums">
|
||||
{selection.length}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="border-b border-border p-4">
|
||||
<DialogTitle>MCP servers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the servers the model can use, then limit their tools. A server allows every tool until you uncheck
|
||||
some.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{nothingAvailable ? (
|
||||
<div className="h-96">
|
||||
<Note title="No MCP servers for this key">
|
||||
Add one on the <a href={uiHref("mcp-servers")}>MCP servers page</a>, or send as a key that can reach one
|
||||
</Note>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-96 grid-cols-[18rem_1fr]">
|
||||
<div className="flex min-h-0 flex-col border-r border-border">
|
||||
<div className="p-2">
|
||||
<SearchField value={query} onChange={setQuery} placeholder="Search servers" autoFocus />
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
<RowGroup
|
||||
title="MCP servers"
|
||||
rows={visible([{ value: ALL_MCP_SERVERS, label: "All MCP servers" }, ...serverRows])}
|
||||
render={masterRow}
|
||||
/>
|
||||
<RowGroup title="Toolsets" rows={visible(toolsetRows)} render={masterRow} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col">{detail()}</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="m-0 border-t border-border p-3">
|
||||
<DialogClose render={<Button size="sm" />}>Done</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { Copy, Split } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export function copyText(value: string, label: string): void {
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => toast.success(`${label} copied`),
|
||||
() => toast.error("Unable to copy to clipboard"),
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIconButton({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Copy ${label}`} onClick={() => copyText(value, label)} />
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy {label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function LongValue({
|
||||
value,
|
||||
detail,
|
||||
copyLabel,
|
||||
className,
|
||||
align = "start",
|
||||
}: {
|
||||
value: string;
|
||||
detail: string;
|
||||
copyLabel: string;
|
||||
className?: string;
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
delay={300}
|
||||
render={<span tabIndex={0} className={cn("min-w-0 cursor-default truncate font-mono", className)} />}
|
||||
>
|
||||
{value}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align={align} className="w-80">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{detail}</span>
|
||||
<span className="font-mono text-sm wrap-anywhere">{value}</span>
|
||||
</div>
|
||||
<CopyIconButton value={value} label={copyLabel} />
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function FallbackBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="border-warning/20 bg-warning/10 text-warning">
|
||||
<Split data-icon="inline-start" />
|
||||
Fallback
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChecklistMenu, TargetPicker, type OptionGroup } from "./Pickers";
|
||||
|
||||
const GROUPS: readonly OptionGroup[] = [
|
||||
{
|
||||
label: "openai",
|
||||
options: [
|
||||
{ value: "gpt-fast", label: "gpt-fast" },
|
||||
{ value: "gpt-smart", label: "gpt-smart" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const renderTargetPicker = (onValueChange = vi.fn()) => {
|
||||
render(
|
||||
<TargetPicker
|
||||
label="Model"
|
||||
placeholder="Select a model"
|
||||
groups={GROUPS}
|
||||
value={null}
|
||||
onValueChange={onValueChange}
|
||||
isLoading={false}
|
||||
emptyText="No models"
|
||||
allowCustom
|
||||
/>,
|
||||
);
|
||||
return onValueChange;
|
||||
};
|
||||
|
||||
describe("TargetPicker", () => {
|
||||
it("selects the first match when Enter is pressed after searching", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = renderTargetPicker();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.type(await screen.findByPlaceholderText("Search models"), "smart{Enter}");
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith("gpt-smart");
|
||||
});
|
||||
|
||||
it("uses a typed model name that matches nothing when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = renderTargetPicker();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.type(await screen.findByPlaceholderText("Search models"), "my-custom-model{Enter}");
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith("my-custom-model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChecklistMenu", () => {
|
||||
it("toggles the first match when Enter is pressed after searching", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<ChecklistMenu
|
||||
label="Guardrails"
|
||||
icon={null}
|
||||
groups={[
|
||||
{
|
||||
label: "Guardrails",
|
||||
options: [
|
||||
{ value: "pii-mask", label: "pii-mask" },
|
||||
{ value: "toxicity", label: "toxicity" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
value={["pii-mask"]}
|
||||
onValueChange={onValueChange}
|
||||
isLoading={false}
|
||||
emptyText="No guardrails"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Guardrails" }));
|
||||
await user.type(await screen.findByPlaceholderText("Search guardrails"), "tox{Enter}");
|
||||
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(["pii-mask", "toxicity"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from "@/components/ui/combobox";
|
||||
import { InputGroupButton } from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import type { PickerOption } from "./usePlaygroundCatalog";
|
||||
|
||||
export type OptionGroup = Readonly<{ label: string; options: readonly PickerOption[] }>;
|
||||
|
||||
type GroupItems = Readonly<{ value: string; items: readonly string[] }>;
|
||||
|
||||
const toItems = (groups: readonly OptionGroup[]): GroupItems[] =>
|
||||
groups
|
||||
.filter((group) => group.options.length > 0)
|
||||
.map((group) => ({ value: group.label, items: group.options.map((option) => option.value) }));
|
||||
|
||||
const isListed = (index: ReadonlyMap<string, PickerOption>, name: string): boolean =>
|
||||
index.has(name) || [...index.values()].some((option) => option.label === name);
|
||||
|
||||
const optionIndex = (groups: readonly OptionGroup[]): ReadonlyMap<string, PickerOption> =>
|
||||
new Map(groups.flatMap((group) => group.options.map((option) => [option.value, option] as const)));
|
||||
|
||||
function GroupedOptions({ index, mono = false }: { index: ReadonlyMap<string, PickerOption>; mono?: boolean }) {
|
||||
return (
|
||||
<ComboboxList>
|
||||
{(group: GroupItems) => (
|
||||
<ComboboxGroup key={group.value} items={[...group.items]}>
|
||||
<ComboboxLabel>{group.value}</ComboboxLabel>
|
||||
<ComboboxCollection>
|
||||
{(value: string) => {
|
||||
const option = index.get(value);
|
||||
if (!option) return <CustomItem key={value} value={value} />;
|
||||
return (
|
||||
<ComboboxItem key={value} value={value} className={cn("items-start", mono && "font-mono text-xs")}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="truncate font-sans text-xs text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
)}
|
||||
</ComboboxList>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomItem({ value }: { value: string }) {
|
||||
return (
|
||||
<ComboboxItem value={value} className="font-mono text-xs">
|
||||
<Plus />
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-sans">Use</span> {value}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function CountBadge({ count }: { count: number }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<Badge variant="secondary" className="h-4 min-w-4 px-1 tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChecklistMenu({
|
||||
label,
|
||||
icon,
|
||||
groups,
|
||||
value,
|
||||
onValueChange,
|
||||
isLoading,
|
||||
emptyText,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
groups: readonly OptionGroup[];
|
||||
value: readonly string[];
|
||||
onValueChange: (value: readonly string[]) => void;
|
||||
isLoading: boolean;
|
||||
emptyText: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const index = optionIndex(groups);
|
||||
const items = toItems(groups);
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
multiple
|
||||
value={[...value]}
|
||||
onValueChange={(next: string[]) => onValueChange(next)}
|
||||
itemToStringLabel={(item: string) => index.get(item)?.label ?? item}
|
||||
defaultOpen={defaultOpen}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxTrigger render={<InputGroupButton size="xs" aria-label={label} />}>
|
||||
{icon}
|
||||
{label}
|
||||
<CountBadge count={value.length} />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent side="top" className="w-72">
|
||||
<ComboboxInput showTrigger={false} placeholder={`Search ${label.toLowerCase()}`} />
|
||||
<ComboboxEmpty>
|
||||
{isLoading ? <Spinner className="size-3.5" /> : items.length === 0 && emptyText}
|
||||
{!isLoading && items.length > 0 && "Nothing matches"}
|
||||
</ComboboxEmpty>
|
||||
<GroupedOptions index={index} />
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export function TargetPicker({
|
||||
label,
|
||||
placeholder,
|
||||
groups,
|
||||
value,
|
||||
onValueChange,
|
||||
isLoading,
|
||||
emptyText,
|
||||
allowCustom = false,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
groups: readonly OptionGroup[];
|
||||
value: string | null;
|
||||
onValueChange: (value: string) => void;
|
||||
isLoading: boolean;
|
||||
emptyText: string;
|
||||
allowCustom?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const index = optionIndex(groups);
|
||||
const custom = query.trim();
|
||||
const offerCustom = allowCustom && custom !== "" && !isListed(index, custom);
|
||||
const items = [...toItems(groups), ...(offerCustom ? [{ value: "Custom name", items: [custom] }] : [])];
|
||||
const unsetText = isLoading ? `Loading ${label.toLowerCase()}s` : placeholder;
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(next: string | null) => next && onValueChange(next)}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
onOpenChange={(open) => !open && setQuery("")}
|
||||
itemToStringLabel={(item: string) => index.get(item)?.label ?? item}
|
||||
defaultOpen={defaultOpen}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxTrigger render={<InputGroupButton size="xs" className="max-w-72 min-w-0" aria-label={label} />}>
|
||||
{isLoading && !value ? <Spinner className="size-3" /> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-mono text-xs font-medium",
|
||||
value ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value ? index.get(value)?.label ?? value : unsetText}
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent side="top" className="w-80">
|
||||
<ComboboxInput showTrigger={false} placeholder={`Search ${label.toLowerCase()}s`} />
|
||||
<ComboboxEmpty>
|
||||
{items.length === 0 && !custom ? emptyText : `No matching ${label.toLowerCase()}`}
|
||||
</ComboboxEmpty>
|
||||
<GroupedOptions index={index} mono />
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,658 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders as render, testQueryClient } from "@/../tests/test-utils";
|
||||
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
|
||||
import { callMCPTool, fetchMCPServers, fetchMCPToolsets, listMCPTools, tagListCall } from "@/components/networking";
|
||||
import { setSecureItem } from "@/utils/secureStorage";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { PlaygroundChat } from "./PlaygroundChat";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: () => true }));
|
||||
|
||||
vi.mock("@/lib/http/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/http/api")>();
|
||||
return { ...actual, fetchClient: { ...actual.fetchClient, POST: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn() }));
|
||||
|
||||
vi.mock("@/components/llm_calls/chat_completion", () => ({ makeOpenAIChatCompletionRequest: vi.fn() }));
|
||||
|
||||
vi.mock("@/components/llm_calls/responses_api", () => ({ makeOpenAIResponsesRequest: vi.fn() }));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
tagListCall: vi.fn().mockResolvedValue({}),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [{ guardrail_name: "pii-mask" }] }),
|
||||
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
|
||||
fetchMCPServers: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
callMCPTool: vi.fn(),
|
||||
getGatewayBaseUrl: () => "http://gateway.test",
|
||||
getGlobalLitellmHeaderName: () => "Authorization",
|
||||
}));
|
||||
|
||||
const chatRequest = vi.mocked(makeOpenAIChatCompletionRequest);
|
||||
|
||||
const responsesRequest = vi.mocked(makeOpenAIResponsesRequest);
|
||||
|
||||
const credentialPost = fetchClient.POST as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
type ChatArgs = Parameters<typeof makeOpenAIChatCompletionRequest>;
|
||||
|
||||
type ResponsesArgs = Parameters<typeof makeOpenAIResponsesRequest>;
|
||||
|
||||
const sentMessages = (call: number) => (chatRequest.mock.calls[call] as ChatArgs)[0];
|
||||
|
||||
const replyWith =
|
||||
(text: string) =>
|
||||
async (...args: ChatArgs) => {
|
||||
args[1](text, args[2]);
|
||||
};
|
||||
|
||||
const preload = (values: Readonly<Record<string, string>>) =>
|
||||
Object.entries(values).forEach(([key, value]) => sessionStorage.setItem(key, value));
|
||||
|
||||
const deferred = () => {
|
||||
const control: { release: () => void } = { release: () => undefined };
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
control.release = resolve;
|
||||
});
|
||||
return { promise, release: () => control.release() };
|
||||
};
|
||||
|
||||
const mcpTool = (name: string, required: readonly string[] = []) => ({
|
||||
name,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(required.map((field) => [field, { type: "string" }])),
|
||||
required: [...required],
|
||||
},
|
||||
});
|
||||
|
||||
const settled = () => waitFor(() => expect(testQueryClient.isFetching()).toBe(0));
|
||||
|
||||
const renderChat = () =>
|
||||
render(<PlaygroundChat accessToken="session-token" disabledPersonalKeyCreation={false} proxySettings={undefined} />);
|
||||
|
||||
const send = async (user: ReturnType<typeof userEvent.setup>, text: string) => {
|
||||
await user.type(screen.getByRole("textbox", { name: "Message" }), text);
|
||||
await user.click(screen.getByRole("button", { name: "Send message" }));
|
||||
};
|
||||
|
||||
describe("PlaygroundChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
testQueryClient.clear();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
chatRequest.mockReset();
|
||||
responsesRequest.mockReset();
|
||||
vi.mocked(callMCPTool).mockReset();
|
||||
credentialPost.mockReset().mockResolvedValue({ data: {}, response: new Response() });
|
||||
vi.mocked(fetchMCPServers).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPToolsets).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [] });
|
||||
vi.mocked(fetchAvailableModels).mockResolvedValue([
|
||||
{ model_group: "gpt-fast", mode: "chat", providers: ["openai"] },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends the chosen model and guardrails with the session key", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(await screen.findByRole("option", { name: "gpt-fast" }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Guardrails" }));
|
||||
await user.click(await screen.findByRole("option", { name: "pii-mask" }));
|
||||
await user.keyboard("{Escape}");
|
||||
await send(user, "hello");
|
||||
|
||||
expect(await screen.findByText("Hi there")).toBeInTheDocument();
|
||||
const [messages, , model, apiKey, , , , , , , , guardrails] = chatRequest.mock.calls[0] as ChatArgs;
|
||||
expect(messages).toEqual([{ role: "user", content: "hello" }]);
|
||||
expect(model).toBe("gpt-fast");
|
||||
expect(apiKey).toBe("session-token");
|
||||
expect(guardrails).toEqual(["pii-mask"]);
|
||||
});
|
||||
|
||||
it("keeps answered turns in the history but never resends a failed one", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest
|
||||
.mockImplementationOnce(replyWith("Hi there"))
|
||||
.mockRejectedValueOnce(new Error("rate limited"))
|
||||
.mockImplementationOnce(replyWith("Back again"));
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(await screen.findByRole("option", { name: "gpt-fast" }));
|
||||
await send(user, "hello");
|
||||
expect(await screen.findByText("Hi there")).toBeInTheDocument();
|
||||
await send(user, "this one fails");
|
||||
expect(await screen.findByText("The request failed")).toBeInTheDocument();
|
||||
await send(user, "try again");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(3));
|
||||
expect(sentMessages(2)).toEqual([
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
{ role: "user", content: "try again" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends every saved request setting with the chat request", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([{ server_id: "srv-1", alias: "docs" }]);
|
||||
preload({
|
||||
selectedModel: "gpt-fast",
|
||||
selectedTags: JSON.stringify(["team-a"]),
|
||||
selectedVectorStores: JSON.stringify(["vs-1"]),
|
||||
selectedPolicies: JSON.stringify(["strict"]),
|
||||
selectedMCPServers: JSON.stringify(["srv-1"]),
|
||||
streamingEnabled: "false",
|
||||
});
|
||||
setSecureItem("customHeaders", JSON.stringify([["x-team", "blue"]]));
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => expect(fetchMCPServers).toHaveBeenCalled());
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
const args = chatRequest.mock.calls[0] as ChatArgs;
|
||||
expect(args[2]).toBe("gpt-fast");
|
||||
expect(args[4]).toEqual(["team-a"]);
|
||||
expect(args[10]).toEqual(["vs-1"]);
|
||||
expect(args[12]).toEqual(["strict"]);
|
||||
expect(args[13]).toEqual(["srv-1"]);
|
||||
expect(args[25]).toBe(false);
|
||||
expect(args[26]).toMatchObject({ "x-team": "blue" });
|
||||
});
|
||||
|
||||
it("sends a custom model and tag that are prefixes of listed ones", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
vi.mocked(tagListCall).mockResolvedValueOnce({
|
||||
production: { name: "production", models: [], created_at: "", updated_at: "" },
|
||||
});
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.type(await screen.findByPlaceholderText("Search models"), "gpt-f");
|
||||
await user.click(await screen.findByRole("option", { name: "Use gpt-f" }));
|
||||
await user.click(screen.getByRole("button", { name: "Settings" }));
|
||||
await user.type(await screen.findByLabelText("Tags"), "prod");
|
||||
await user.click(await screen.findByRole("option", { name: "Add prod" }));
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
const args = chatRequest.mock.calls[0] as ChatArgs;
|
||||
expect(args[2]).toBe("gpt-f");
|
||||
expect(args[4]).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
it("sends a temperature typed with two decimals", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Advanced" }));
|
||||
await user.type(screen.getByLabelText("Temperature value"), "0.05");
|
||||
await user.keyboard("{Escape}");
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
expect((chatRequest.mock.calls[0] as ChatArgs)[16]).toBe(0.05);
|
||||
});
|
||||
|
||||
it("keeps the saved MCP selection when the server list cannot be fetched", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
vi.mocked(fetchMCPServers).mockRejectedValue(new Error("forbidden"));
|
||||
preload({ selectedModel: "gpt-fast", selectedMCPServers: JSON.stringify(["srv-1"]) });
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => expect(fetchMCPServers).toHaveBeenCalled());
|
||||
await settled();
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
expect((chatRequest.mock.calls[0] as ChatArgs)[13]).toEqual(["srv-1"]);
|
||||
});
|
||||
|
||||
it("drops a saved model that the current key cannot use", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
preload({ selectedModel: "retired-model" });
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => expect(fetchAvailableModels).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("Select a model"));
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(await screen.findByRole("option", { name: "gpt-fast" }));
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
expect((chatRequest.mock.calls[0] as ChatArgs)[2]).toBe("gpt-fast");
|
||||
});
|
||||
|
||||
it("calls a toolset tool on the server that backs it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPToolsets).mockResolvedValue([
|
||||
{ toolset_id: "ts-1", toolset_name: "Research", tools: [{ server_id: "srv-2", tool_name: "search" }] },
|
||||
]);
|
||||
vi.mocked(listMCPTools).mockResolvedValue({
|
||||
tools: [{ name: "search", inputSchema: { type: "object", properties: {} } }],
|
||||
});
|
||||
vi.mocked(callMCPTool).mockResolvedValue({ content: [{ type: "text", text: "found it" }] });
|
||||
preload({ endpointType: "mcp", selectedMCPServers: JSON.stringify(["toolset:ts-1"]) });
|
||||
renderChat();
|
||||
|
||||
await user.click(await screen.findByRole("combobox", { name: "Tool" }));
|
||||
await user.click(await screen.findByRole("option", { name: /search/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(callMCPTool).toHaveBeenCalledTimes(1));
|
||||
expect(listMCPTools).toHaveBeenCalledWith("session-token", "srv-2");
|
||||
expect(listMCPTools).not.toHaveBeenCalledWith("session-token", "toolset:ts-1");
|
||||
expect(vi.mocked(callMCPTool).mock.calls[0]?.slice(1, 3)).toEqual(["srv-2", "search"]);
|
||||
});
|
||||
|
||||
it("offers toolsets as targets and lists only the toolset's own tools", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPToolsets).mockResolvedValue([
|
||||
{
|
||||
toolset_id: "ts-1",
|
||||
toolset_name: "Research",
|
||||
tools: [
|
||||
{ server_id: "srv-a", tool_name: "search" },
|
||||
{ server_id: "srv-b", tool_name: "fetch" },
|
||||
{ server_id: "srv-b", tool_name: "search" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
vi.mocked(listMCPTools).mockImplementation(async (_key: string, server: string) => ({
|
||||
tools:
|
||||
server === "srv-a" ? [mcpTool("search"), mcpTool("fetch", ["a_arg"])] : [mcpTool("search"), mcpTool("fetch")],
|
||||
}));
|
||||
vi.mocked(callMCPTool).mockResolvedValue({ content: [{ type: "text", text: "fetched" }] });
|
||||
preload({ endpointType: "mcp" });
|
||||
renderChat();
|
||||
|
||||
await user.click(await screen.findByRole("combobox", { name: "Server" }));
|
||||
await user.click(await screen.findByRole("option", { name: /Research/ }));
|
||||
await settled();
|
||||
await user.click(screen.getByRole("combobox", { name: "Tool" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual(["search", "fetch"]),
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: "fetch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(callMCPTool).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(callMCPTool).mock.calls[0]?.slice(1, 4)).toEqual(["srv-b", "fetch", {}]);
|
||||
});
|
||||
|
||||
it("says the tools could not be loaded when the server's tool list fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([{ server_id: "srv-1", alias: "docs" }]);
|
||||
vi.mocked(listMCPTools).mockResolvedValue({ error: "server unreachable" });
|
||||
preload({ endpointType: "mcp", selectedMCPServers: JSON.stringify(["srv-1"]) });
|
||||
renderChat();
|
||||
|
||||
await settled();
|
||||
await user.click(screen.getByRole("combobox", { name: "Tool" }));
|
||||
|
||||
expect(await screen.findByText("Unable to load tools for this server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the saved MCP selection when the toolset list cannot be fetched", async () => {
|
||||
const user = userEvent.setup();
|
||||
chatRequest.mockImplementation(replyWith("Hi there"));
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([{ server_id: "srv-1", alias: "docs" }]);
|
||||
vi.mocked(fetchMCPToolsets).mockRejectedValue(new Error("database not connected"));
|
||||
preload({ selectedModel: "gpt-fast", selectedMCPServers: JSON.stringify(["srv-1", "srv-gone"]) });
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => expect(fetchMCPToolsets).toHaveBeenCalled());
|
||||
await settled();
|
||||
await send(user, "hello");
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(1));
|
||||
expect((chatRequest.mock.calls[0] as ChatArgs)[13]).toEqual(["srv-1", "srv-gone"]);
|
||||
});
|
||||
|
||||
it("drops a saved model that is missing from an already loaded model list", async () => {
|
||||
const first = renderChat();
|
||||
await waitFor(() => expect(fetchAvailableModels).toHaveBeenCalled());
|
||||
await settled();
|
||||
first.unmount();
|
||||
sessionStorage.setItem("selectedModel", "retired-model");
|
||||
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("Select a model"));
|
||||
expect(fetchAvailableModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops a request that is still running when a new chat starts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = deferred();
|
||||
chatRequest.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
await first.promise;
|
||||
args[1]("late reply", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
expect(await screen.findByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "New chat" }));
|
||||
await act(async () => {
|
||||
first.release();
|
||||
await chatRequest.mock.results[0]?.value;
|
||||
});
|
||||
|
||||
expect(chatRequest).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("late reply")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stopped request's response id out of the next request", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = deferred();
|
||||
responsesRequest
|
||||
.mockImplementationOnce(async (...args: ResponsesArgs) => {
|
||||
await first.promise;
|
||||
args[15]?.("resp-stopped");
|
||||
})
|
||||
.mockImplementationOnce(async (...args: ResponsesArgs) => {
|
||||
args[15]?.("resp-fresh");
|
||||
args[1]("assistant", "fresh reply", args[2]);
|
||||
})
|
||||
.mockImplementationOnce(async (...args: ResponsesArgs) => {
|
||||
args[1]("assistant", "third reply", args[2]);
|
||||
});
|
||||
preload({ endpointType: "responses", selectedModel: "gpt-fast", useApiSessionManagement: "true" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
await user.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
await send(user, "second");
|
||||
expect(await screen.findByText("fresh reply")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
first.release();
|
||||
await responsesRequest.mock.results[0]?.value;
|
||||
});
|
||||
await send(user, "third");
|
||||
|
||||
await waitFor(() => expect(responsesRequest).toHaveBeenCalledTimes(3));
|
||||
expect((responsesRequest.mock.calls[2] as ResponsesArgs)[14]).toBe("resp-fresh");
|
||||
});
|
||||
|
||||
it("ignores a stopped request that finishes after a newer one started", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
chatRequest
|
||||
.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
await first.promise;
|
||||
args[1]("late reply", args[2]);
|
||||
})
|
||||
.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
await second.promise;
|
||||
args[1]("fresh reply", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
await user.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
await send(user, "second");
|
||||
first.release();
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("late reply")).not.toBeInTheDocument();
|
||||
second.release();
|
||||
expect(await screen.findByText("fresh reply")).toBeInTheDocument();
|
||||
expect(screen.queryByText("late reply")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only says no guardrails ran when the gateway reported its headers", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
}));
|
||||
chatRequest
|
||||
.mockImplementationOnce(replyWith("unreported reply"))
|
||||
.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
args[27]?.(new Headers({ "x-litellm-call-id": "call-reported" }));
|
||||
args[1]("reported reply", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast", selectedGuardrails: JSON.stringify(["pii-mask"]) });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
expect(await screen.findByText("unreported reply")).toBeInTheDocument();
|
||||
expect(screen.getByText("This endpoint does not report which guardrails or policies ran")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No guardrails or policies ran on this request")).not.toBeInTheDocument();
|
||||
|
||||
await send(user, "second");
|
||||
expect(await screen.findByText("call-reported")).toBeInTheDocument();
|
||||
expect(screen.getByText("No guardrails or policies ran on this request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim policies were skipped when the gateway's own headers are hidden", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
}));
|
||||
chatRequest.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
args[27]?.(new Headers({ "content-type": "application/json" }));
|
||||
args[1]("cross-origin reply", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "hello");
|
||||
expect(await screen.findByText("cross-origin reply")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Policies are only reported when the dashboard is opened from the proxy"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/policies ran on this request/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stopped request's gateway details off the newer reply", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
}));
|
||||
const first = deferred();
|
||||
chatRequest
|
||||
.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
args[27]?.(new Headers({ "x-litellm-call-id": "call-stopped" }));
|
||||
await first.promise;
|
||||
})
|
||||
.mockImplementationOnce(async (...args: ChatArgs) => {
|
||||
args[27]?.(new Headers({ "x-litellm-call-id": "call-fresh" }));
|
||||
args[1]("fresh reply", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
await user.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
await send(user, "second");
|
||||
expect(await screen.findByText("call-fresh")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
first.release();
|
||||
await chatRequest.mock.results[0]?.value;
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("call-fresh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("call-stopped")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends once when Enter is pressed twice before the first request starts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = deferred();
|
||||
chatRequest.mockImplementation(async (...args: ChatArgs) => {
|
||||
await first.promise;
|
||||
args[1]("Hi there", args[2]);
|
||||
});
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
const message = screen.getByRole("textbox", { name: "Message" });
|
||||
await user.type(message, "hello");
|
||||
fireEvent.keyDown(message, { key: "Enter" });
|
||||
fireEvent.keyDown(message, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(chatRequest).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
first.release();
|
||||
await chatRequest.mock.results[0]?.value;
|
||||
});
|
||||
expect(chatRequest).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getAllByText("hello")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops a chat attachment when the endpoint cannot use it", async () => {
|
||||
const user = userEvent.setup();
|
||||
preload({ selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
fireEvent.drop(screen.getByRole("textbox", { name: "Message" }), {
|
||||
dataTransfer: { files: [new File(["%PDF"], "notes.pdf", { type: "application/pdf" })] },
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Remove notes.pdf" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Endpoint" }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "/v1/audio/transcriptions" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Endpoint" })).toHaveTextContent("audio/transcriptions"),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Remove notes.pdf" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stopped request's code output off the newer reply", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = deferred();
|
||||
responsesRequest
|
||||
.mockImplementationOnce(async (...args: ResponsesArgs) => {
|
||||
await first.promise;
|
||||
args[18]?.({ code: "print('stopped run')", containerId: "c-1", annotations: [] });
|
||||
})
|
||||
.mockImplementationOnce(async (...args: ResponsesArgs) => {
|
||||
args[1]("assistant", "fresh reply", args[2]);
|
||||
});
|
||||
preload({ endpointType: "responses", selectedModel: "gpt-fast" });
|
||||
renderChat();
|
||||
|
||||
await send(user, "first");
|
||||
await user.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
await send(user, "second");
|
||||
expect(await screen.findByText("fresh reply")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
first.release();
|
||||
await responsesRequest.mock.results[0]?.value;
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Python Code Executed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a user connect a key for a BYOK server picked for a direct tool call", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: "srv-byok", alias: "Linear", is_byok: true, has_user_credential: false },
|
||||
]);
|
||||
preload({ endpointType: "mcp", selectedMCPServers: JSON.stringify(["srv-byok"]) });
|
||||
renderChat();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Connect key" }));
|
||||
await user.type(await screen.findByLabelText("Linear API key"), "lin-key-1");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
await waitFor(() => expect(credentialPost).toHaveBeenCalledTimes(1));
|
||||
expect(credentialPost.mock.calls[0]?.[1]).toMatchObject({
|
||||
params: { path: { server_id: "srv-byok" } },
|
||||
body: { credential: "lin-key-1" },
|
||||
});
|
||||
await waitFor(() => expect(fetchMCPServers).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("lets a user replace the key of a connected BYOK server whose tools fail to load", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: "srv-byok", alias: "Linear", is_byok: true, has_user_credential: true },
|
||||
]);
|
||||
vi.mocked(listMCPTools).mockResolvedValue({ error: "401 from upstream" });
|
||||
preload({ selectedModel: "gpt-fast", selectedMCPServers: JSON.stringify(["srv-byok"]) });
|
||||
renderChat();
|
||||
|
||||
await settled();
|
||||
await user.click(screen.getByRole("button", { name: "MCP" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Replace key" }));
|
||||
await user.type(await screen.findByLabelText("Linear API key"), "lin-key-2");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
await waitFor(() => expect(credentialPost).toHaveBeenCalledTimes(1));
|
||||
expect(credentialPost.mock.calls[0]?.[1]).toMatchObject({ body: { credential: "lin-key-2" } });
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listMCPTools).mock.calls.filter(([, server]) => server === "srv-byok").length).toBeGreaterThan(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves replace-key mode on cancel or when the MCP dialog closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: "srv-byok", alias: "Linear", is_byok: true, has_user_credential: true },
|
||||
]);
|
||||
vi.mocked(listMCPTools).mockResolvedValue({ error: "401 from upstream" });
|
||||
preload({ selectedModel: "gpt-fast", selectedMCPServers: JSON.stringify(["srv-byok"]) });
|
||||
renderChat();
|
||||
|
||||
await settled();
|
||||
await user.click(screen.getByRole("button", { name: "MCP" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Replace key" }));
|
||||
expect(await screen.findByLabelText("Linear API key")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByLabelText("Linear API key")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Replace key" }));
|
||||
expect(await screen.findByLabelText("Linear API key")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByLabelText("Linear API key")).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "MCP" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Replace key" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Linear API key")).not.toBeInTheDocument();
|
||||
expect(credentialPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,489 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronDown, PanelRight, RotateCcw, SquarePen } from "lucide-react";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
|
||||
import { EndpointType, getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { MessageType } from "@/components/chat_ui/types";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { customHeadersFromPairs } from "@/components/llm_calls/request_headers";
|
||||
import type { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
|
||||
import type { MCPServer, MCPTool, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useChatHistory } from "../../hooks/useChatHistory";
|
||||
import { useCodeInterpreter } from "../../hooks/useCodeInterpreter";
|
||||
import { filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "../chat_ui/EndpointUtils";
|
||||
import RealtimePlayground from "../chat_ui/RealtimePlayground";
|
||||
import { Composer } from "./Composer";
|
||||
import { EndpointMenu, VoiceMenu } from "./ComposerControls";
|
||||
import { Conversation, formatCost } from "./Conversation";
|
||||
import { capabilitiesFor, CODE_INTERPRETER_SUGGESTIONS, SUGGESTIONS } from "./endpointCapabilities";
|
||||
import { InspectorPanel, type CodeSdk } from "./Inspector";
|
||||
import { ByokKeyButton } from "./ByokConnect";
|
||||
import { McpDialog, serverName } from "./McpDialog";
|
||||
import { TargetPicker, type OptionGroup } from "./Pickers";
|
||||
import { TOOLSET_PREFIX, withEndpoint, withReachableMcp } from "./playgroundSettings";
|
||||
import { SettingsPopover } from "./SettingsPopover";
|
||||
import {
|
||||
useGuardrailOptions,
|
||||
useMcpCatalog,
|
||||
usePlaygroundAgents,
|
||||
usePlaygroundModels,
|
||||
usePolicyOptions,
|
||||
useServerTools,
|
||||
useTagOptions,
|
||||
useVectorStoreOptions,
|
||||
type PickerOption,
|
||||
type ServerTools,
|
||||
} from "./usePlaygroundCatalog";
|
||||
import { usePlaygroundSend, type SendDeps } from "./usePlaygroundSend";
|
||||
import { usePlaygroundSettings } from "./usePlaygroundSettings";
|
||||
import { useIsWide } from "./useIsWide";
|
||||
|
||||
export type ProxySettings = Readonly<{ PROXY_BASE_URL?: string; LITELLM_UI_API_DOC_BASE_URL?: string | null }>;
|
||||
|
||||
const POLICY_PREFIX = "policy:";
|
||||
|
||||
const providerOf = (model: ModelGroup): string => model.providers?.[0] ?? "other";
|
||||
|
||||
const modelGroups = (models: readonly ModelGroup[]): readonly OptionGroup[] =>
|
||||
[...new Set(models.map(providerOf))].sort().map((provider) => ({
|
||||
label: provider,
|
||||
options: models
|
||||
.filter((model) => providerOf(model) === provider)
|
||||
.map((model) => ({ value: model.model_group, label: model.model_group })),
|
||||
}));
|
||||
|
||||
const modelEmptyText = (failed: boolean, hasKey: boolean): string => {
|
||||
if (failed) return "Unable to load models for this key";
|
||||
if (!hasKey) return "Enter a virtual key to load models";
|
||||
return "No models available for this endpoint";
|
||||
};
|
||||
|
||||
const reachableMcp = (servers: readonly MCPServer[], toolsets: readonly MCPToolset[]): ReadonlySet<string> =>
|
||||
new Set([
|
||||
...servers.map((server) => server.server_id),
|
||||
...toolsets.map((toolset) => `${TOOLSET_PREFIX}${toolset.toolset_id}`),
|
||||
]);
|
||||
|
||||
const toolsetFor = (selection: string | null, toolsets: readonly MCPToolset[]): MCPToolset | null =>
|
||||
toolsets.find((toolset) => `${TOOLSET_PREFIX}${toolset.toolset_id}` === selection) ?? null;
|
||||
|
||||
const backingServers = (selection: string | null, toolset: MCPToolset | null): readonly string[] => {
|
||||
if (toolset) return [...new Set(toolset.tools.map((tool) => tool.server_id))];
|
||||
return selection && !selection.startsWith(TOOLSET_PREFIX) ? [selection] : [];
|
||||
};
|
||||
|
||||
type DirectTarget = Readonly<{ server: string; tool: MCPTool }>;
|
||||
|
||||
const toolsetTargets = (toolset: MCPToolset, loaded: Readonly<Record<string, ServerTools>>): DirectTarget[] =>
|
||||
toolset.tools
|
||||
.flatMap((entry) => {
|
||||
const tool = loaded[entry.server_id]?.tools.find((candidate) => candidate.name === entry.tool_name);
|
||||
return tool ? [{ server: entry.server_id, tool }] : [];
|
||||
})
|
||||
.filter((target, index, targets) => targets.findIndex((other) => other.tool.name === target.tool.name) === index);
|
||||
|
||||
const directTargets = (
|
||||
servers: readonly string[],
|
||||
toolset: MCPToolset | null,
|
||||
loaded: Readonly<Record<string, ServerTools>>,
|
||||
): readonly DirectTarget[] => {
|
||||
if (toolset) return toolsetTargets(toolset, loaded);
|
||||
return servers.flatMap((server) => (loaded[server]?.tools ?? []).map((tool) => ({ server, tool })));
|
||||
};
|
||||
|
||||
const prefixed = (options: readonly PickerOption[]): readonly PickerOption[] =>
|
||||
options.map((option) => ({ ...option, value: `${POLICY_PREFIX}${option.value}` }));
|
||||
|
||||
const lastTracedReply = (messages: readonly MessageType[]): number =>
|
||||
messages.findLastIndex((message) => message.role === "assistant" && message.trace !== undefined);
|
||||
|
||||
const totalCost = (messages: readonly MessageType[]): number =>
|
||||
messages.reduce((sum, message) => sum + (message.usage?.cost ?? message.trace?.cost ?? 0), 0);
|
||||
|
||||
const conversationSummary = (messages: readonly MessageType[]): string => {
|
||||
const requests = messages.filter((message) => message.role === "user").length;
|
||||
if (requests === 0) return "New chat";
|
||||
const label = requests === 1 ? "1 request" : `${requests} requests`;
|
||||
const cost = totalCost(messages);
|
||||
return cost > 0 ? `${label}, ${formatCost(cost)}` : label;
|
||||
};
|
||||
|
||||
export function PlaygroundChat({
|
||||
accessToken,
|
||||
disabledPersonalKeyCreation,
|
||||
proxySettings,
|
||||
}: {
|
||||
accessToken: string | null;
|
||||
disabledPersonalKeyCreation: boolean;
|
||||
proxySettings: ProxySettings | undefined;
|
||||
}) {
|
||||
const { settings: stored, update, reset } = usePlaygroundSettings(disabledPersonalKeyCreation);
|
||||
const canViewPolicies = useCan("viewPolicies");
|
||||
const apiKey = (stored.keySource === "session" ? accessToken : stored.virtualKey.trim()) || null;
|
||||
const mcp = useMcpCatalog(apiKey);
|
||||
const settings = withReachableMcp(stored, mcp.isReady ? reachableMcp(mcp.servers, mcp.toolsets) : null);
|
||||
const capabilities = capabilitiesFor(settings.endpoint);
|
||||
const chat = useChatHistory({ simplified: false });
|
||||
const codeInterpreter = useCodeInterpreter();
|
||||
const toolArgsForm = useRef<MCPToolArgumentsFormRef | null>(null);
|
||||
const [inspected, setInspected] = useState<number | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const isWide = useIsWide();
|
||||
|
||||
const models = usePlaygroundModels(apiKey);
|
||||
const [checkedModels, setCheckedModels] = useState<readonly ModelGroup[] | undefined>(undefined);
|
||||
if (models.data && models.data !== checkedModels) {
|
||||
setCheckedModels(models.data);
|
||||
const loaded = models.data;
|
||||
update((current) =>
|
||||
current.model && !loaded.some((option) => option.model_group === current.model)
|
||||
? { ...current, model: null }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
const agents = usePlaygroundAgents(apiKey, settings.proxyBaseUrl, settings.endpoint === EndpointType.A2A_AGENTS);
|
||||
const guardrails = useGuardrailOptions(accessToken);
|
||||
const policies = usePolicyOptions(accessToken, canViewPolicies);
|
||||
const vectorStores = useVectorStoreOptions(accessToken);
|
||||
const tags = useTagOptions(accessToken);
|
||||
|
||||
const directSelection = settings.endpoint === EndpointType.MCP ? settings.mcpSelection[0] ?? null : null;
|
||||
const directToolset = toolsetFor(directSelection, mcp.toolsets);
|
||||
const directServers = backingServers(directSelection, directToolset);
|
||||
const directServerTools = useServerTools(apiKey, directServers);
|
||||
const directByok = directToolset ? undefined : mcp.servers.find((server) => server.server_id === directSelection);
|
||||
const targets = directTargets(directServers, directToolset, directServerTools);
|
||||
const directTarget = targets.find((target) => target.tool.name === settings.directTool) ?? null;
|
||||
const directTool = directTarget?.tool ?? null;
|
||||
const toolsFailed = directServers.some((server) => directServerTools[server]?.failed);
|
||||
const customHeaders = customHeadersFromPairs(settings.headerPairs);
|
||||
|
||||
const sendDeps: SendDeps = {
|
||||
settings,
|
||||
capabilities,
|
||||
apiKey,
|
||||
signedIn: Boolean(accessToken),
|
||||
customHeaders,
|
||||
mcpServers: mcp.servers,
|
||||
mcpToolsets: mcp.toolsets,
|
||||
directCall: () => (directTarget ? { serverId: directTarget.server, tool: directTarget.tool.name } : null),
|
||||
toolArgsForm,
|
||||
chat,
|
||||
codeInterpreter,
|
||||
};
|
||||
const draft = usePlaygroundSend(sendDeps);
|
||||
|
||||
const messages = chat.chatHistory;
|
||||
const inspectedIndex = inspected ?? lastTracedReply(messages);
|
||||
const inspectedMessage = inspectedIndex >= 0 ? messages[inspectedIndex] ?? null : null;
|
||||
const modelList = models.data ?? [];
|
||||
|
||||
const selectModel = (model: string) => {
|
||||
const info = modelList.find((option) => option.model_group === model);
|
||||
const switchTo =
|
||||
info?.mode && !isModelCompatibleWithEndpoint(info, settings.endpoint) ? getEndpointType(info.mode) : null;
|
||||
update((current) => ({ ...(switchTo ? withEndpoint(current, switchTo) : current), model }));
|
||||
};
|
||||
|
||||
const inspect = (index: number) => {
|
||||
setInspected(index);
|
||||
if (!isWide) setSheetOpen(true);
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
chat.clearChatHistory();
|
||||
draft.clearDraft();
|
||||
codeInterpreter.clearResult();
|
||||
setInspected(null);
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
clearChat();
|
||||
toast.success("Chat history cleared.");
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
clearChat();
|
||||
reset();
|
||||
toast.success("Chat and settings reset");
|
||||
};
|
||||
|
||||
const code = (sdk: CodeSdk): string => {
|
||||
const snippet: Parameters<typeof generateCodeSnippet>[0] = {
|
||||
apiKeySource: "custom",
|
||||
accessToken: null,
|
||||
apiKey: "",
|
||||
inputMessage: draft.text,
|
||||
chatHistory: messages,
|
||||
selectedTags: [...settings.tags],
|
||||
selectedVectorStores: [...settings.vectorStores],
|
||||
selectedGuardrails: [...settings.guardrails],
|
||||
selectedPolicies: [...settings.policies],
|
||||
selectedMCPServers: [...settings.mcpSelection],
|
||||
mcpServers: [...mcp.servers],
|
||||
mcpServerToolRestrictions: Object.fromEntries(
|
||||
Object.entries(settings.toolLimits).map(([server, tools]) => [server, [...tools]]),
|
||||
),
|
||||
endpointType: settings.endpoint,
|
||||
selectedModel: settings.model ?? undefined,
|
||||
selectedSdk: sdk,
|
||||
selectedVoice: settings.voice,
|
||||
proxySettings,
|
||||
customHeaders,
|
||||
};
|
||||
return generateCodeSnippet(snippet);
|
||||
};
|
||||
|
||||
const targetRow = (
|
||||
<>
|
||||
{capabilities.target === "model" && (
|
||||
<TargetPicker
|
||||
label="Model"
|
||||
placeholder="Select a model"
|
||||
groups={modelGroups(filterModelsForEndpoint([...modelList], settings.endpoint))}
|
||||
value={settings.model}
|
||||
onValueChange={selectModel}
|
||||
isLoading={models.isLoading}
|
||||
emptyText={modelEmptyText(models.isError, Boolean(apiKey))}
|
||||
allowCustom
|
||||
/>
|
||||
)}
|
||||
{capabilities.target === "agent" && (
|
||||
<TargetPicker
|
||||
label="Agent"
|
||||
placeholder="Select an agent"
|
||||
groups={[
|
||||
{
|
||||
label: "Agents",
|
||||
options: (agents.data ?? []).map((agent) => ({
|
||||
value: agent.agent_name,
|
||||
label: agent.agent_name || agent.agent_id,
|
||||
description: agent.agent_card_params?.description,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
value={settings.agent}
|
||||
onValueChange={(agent) => update({ agent })}
|
||||
isLoading={agents.isLoading}
|
||||
emptyText="No agents available for this key"
|
||||
/>
|
||||
)}
|
||||
{capabilities.target === "mcpTool" && (
|
||||
<>
|
||||
<TargetPicker
|
||||
label="Server"
|
||||
placeholder="Select an MCP server"
|
||||
groups={[
|
||||
{
|
||||
label: "MCP servers",
|
||||
options: mcp.servers.map((server) => ({ value: server.server_id, label: serverName(server) })),
|
||||
},
|
||||
{
|
||||
label: "Toolsets",
|
||||
options: mcp.toolsets.map((toolset) => ({
|
||||
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
|
||||
label: toolset.toolset_name,
|
||||
description: toolset.description,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
value={directSelection}
|
||||
onValueChange={(server) => update({ mcpSelection: [server], directTool: null })}
|
||||
isLoading={mcp.isLoading}
|
||||
emptyText="No MCP servers available for this key"
|
||||
/>
|
||||
{directByok?.is_byok && (
|
||||
<ByokKeyButton server={directByok} name={serverName(directByok)} onConnected={mcp.refreshCredentials} />
|
||||
)}
|
||||
{directSelection && (
|
||||
<TargetPicker
|
||||
label="Tool"
|
||||
placeholder="Select a tool"
|
||||
groups={[
|
||||
{
|
||||
label: "Tools",
|
||||
options: targets.map(({ tool }) => ({
|
||||
value: tool.name,
|
||||
label: tool.name,
|
||||
description: tool.description,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
value={settings.directTool}
|
||||
onValueChange={(tool) => update({ directTool: tool })}
|
||||
isLoading={directServers.some((server) => directServerTools[server]?.isLoading)}
|
||||
emptyText={toolsFailed ? "Unable to load tools for this server" : "This server has no tools"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<EndpointMenu
|
||||
value={settings.endpoint}
|
||||
onValueChange={(endpoint) => update((current) => withEndpoint(current, endpoint))}
|
||||
/>
|
||||
{capabilities.voice && <VoiceMenu value={settings.voice} onValueChange={(voice) => update({ voice })} />}
|
||||
</>
|
||||
);
|
||||
|
||||
const guardrailGroups: readonly OptionGroup[] = [
|
||||
{ label: "Guardrails", options: guardrails.data ?? [] },
|
||||
...(canViewPolicies ? [{ label: "Policies", options: prefixed(policies.data ?? []) }] : []),
|
||||
];
|
||||
|
||||
const composer = (
|
||||
<Composer
|
||||
capabilities={capabilities}
|
||||
endpoint={settings.endpoint}
|
||||
targetRow={targetRow}
|
||||
draft={draft}
|
||||
directTool={directTool}
|
||||
toolArgsForm={toolArgsForm}
|
||||
mcpControl={
|
||||
<McpDialog
|
||||
requestKey={apiKey}
|
||||
servers={mcp.servers}
|
||||
toolsets={mcp.toolsets}
|
||||
isLoading={mcp.isLoading}
|
||||
selection={settings.mcpSelection}
|
||||
toolLimits={settings.toolLimits}
|
||||
onChange={(mcpSelection, toolLimits) => update({ mcpSelection, toolLimits })}
|
||||
onCredentialSaved={mcp.refreshCredentials}
|
||||
/>
|
||||
}
|
||||
vectorStores={{
|
||||
groups: [{ label: "Vector stores", options: vectorStores.data ?? [] }],
|
||||
value: settings.vectorStores,
|
||||
onValueChange: (value) => update({ vectorStores: value }),
|
||||
isLoading: vectorStores.isLoading,
|
||||
}}
|
||||
guardrails={{
|
||||
groups: guardrailGroups,
|
||||
value: [...settings.guardrails, ...settings.policies.map((policy) => `${POLICY_PREFIX}${policy}`)],
|
||||
onValueChange: (value) =>
|
||||
update({
|
||||
guardrails: value.filter((item) => !item.startsWith(POLICY_PREFIX)),
|
||||
policies: value
|
||||
.filter((item) => item.startsWith(POLICY_PREFIX))
|
||||
.map((item) => item.slice(POLICY_PREFIX.length)),
|
||||
}),
|
||||
isLoading: guardrails.isLoading || policies.isLoading,
|
||||
}}
|
||||
codeInterpreter={{ enabled: codeInterpreter.enabled, onToggle: codeInterpreter.setEnabled }}
|
||||
settingsControl={
|
||||
<SettingsPopover
|
||||
settings={settings}
|
||||
update={update}
|
||||
capabilities={capabilities}
|
||||
keysDisabled={disabledPersonalKeyCreation}
|
||||
tagOptions={tags.data ?? []}
|
||||
suggestedBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL ?? null}
|
||||
sessions={{
|
||||
enabled: chat.useApiSessionManagement,
|
||||
responseId: chat.responsesSessionId,
|
||||
onToggle: chat.handleToggleSessionManagement,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sendDisabled={draft.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
const suggestions =
|
||||
settings.endpoint === EndpointType.RESPONSES && codeInterpreter.enabled
|
||||
? CODE_INTERPRETER_SUGGESTIONS
|
||||
: SUGGESTIONS[settings.endpoint] ?? [];
|
||||
|
||||
const inspector = <InspectorPanel message={inspectedMessage} code={code} />;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{conversationSummary(messages)}</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{!isWide && (
|
||||
<Button variant="outline" size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<PanelRight />
|
||||
Inspector
|
||||
</Button>
|
||||
)}
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="sm" onClick={newChat}>
|
||||
<SquarePen />
|
||||
New chat
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon-sm" aria-label="More chat actions" />}
|
||||
>
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuItem onClick={newChat}>
|
||||
<SquarePen />
|
||||
New chat, keep settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={resetAll}>
|
||||
<RotateCcw />
|
||||
Reset chat and settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</span>
|
||||
</div>
|
||||
{settings.endpoint === EndpointType.REALTIME ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RealtimePlayground
|
||||
accessToken={apiKey ?? ""}
|
||||
selectedModel={settings.model ?? ""}
|
||||
customProxyBaseUrl={settings.proxyBaseUrl || undefined}
|
||||
selectedGuardrails={settings.guardrails.length > 0 ? [...settings.guardrails] : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Conversation
|
||||
messages={messages}
|
||||
isLoading={draft.isLoading}
|
||||
waitingFor={messages.at(-1)?.model}
|
||||
extras={{
|
||||
endpoint: settings.endpoint,
|
||||
mcpEvents: chat.mcpEvents,
|
||||
codeInterpreter: codeInterpreter.result,
|
||||
accessToken: apiKey ?? "",
|
||||
}}
|
||||
inspectedIndex={inspectedIndex}
|
||||
onInspect={inspect}
|
||||
suggestions={suggestions}
|
||||
onPickSuggestion={draft.setText}
|
||||
/>
|
||||
)}
|
||||
<div className="mx-auto w-full max-w-3xl px-6 pb-6">{composer}</div>
|
||||
</div>
|
||||
{isWide ? (
|
||||
<aside aria-label="Inspector" className="flex w-96 shrink-0 flex-col border-l border-border">
|
||||
{inspector}
|
||||
</aside>
|
||||
) : (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent side="right" className="w-96 gap-0 p-0" showCloseButton={false}>
|
||||
<SheetTitle className="sr-only">Inspector</SheetTitle>
|
||||
{inspector}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { MessageType } from "@/components/chat_ui/types";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
|
||||
import AudioRenderer from "../chat_ui/AudioRenderer";
|
||||
|
||||
const textOf = (message: MessageType): string => (typeof message.content === "string" ? message.content : "");
|
||||
|
||||
function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
const theme = useSyntaxTheme(coy);
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={theme}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: 0, fontSize: "0.75rem" }}
|
||||
className="my-2 overflow-x-auto rounded-lg border border-border"
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node: _node, ...props }) => <p className="my-2 first:mt-0 last:mb-0" {...props} />,
|
||||
ul: ({ node: _node, ...props }) => <ul className="my-2 list-disc space-y-1 pl-5" {...props} />,
|
||||
ol: ({ node: _node, ...props }) => <ol className="my-2 list-decimal space-y-1 pl-5" {...props} />,
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a className="underline underline-offset-3" target="_blank" rel="noreferrer" {...props} />
|
||||
),
|
||||
pre: ({ node: _node, children }) => <>{children}</>,
|
||||
code: ({ node: _node, className, children, ...props }) => {
|
||||
const language = /language-(\w+)/.exec(className ?? "")?.[1];
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
if (language || code.includes("\n")) return <CodeBlock language={language ?? "text"} code={code} />;
|
||||
return (
|
||||
<code className="rounded-sm bg-muted px-1 py-0.5 font-mono text-xs" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ node: _node, ...props }) => (
|
||||
<div className="my-2 rounded-lg border border-border">
|
||||
<Table {...props} />
|
||||
</div>
|
||||
),
|
||||
thead: ({ node: _node, ...props }) => <TableHeader {...props} />,
|
||||
tbody: ({ node: _node, ...props }) => <TableBody {...props} />,
|
||||
tr: ({ node: _node, ...props }) => <TableRow {...props} />,
|
||||
th: ({ node: _node, ...props }) => <TableHead {...props} />,
|
||||
td: ({ node: _node, ...props }) => <TableCell className="whitespace-normal" {...props} />,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratedImage({ src }: { src: string }) {
|
||||
return <img src={src} alt="Generated image" className="max-h-[500px] max-w-full rounded-lg border border-border" />;
|
||||
}
|
||||
|
||||
function Embeddings({ value }: { value: string }) {
|
||||
return (
|
||||
<ScrollArea className="max-h-64 w-full rounded-lg border border-border">
|
||||
<pre className="p-3 font-mono text-xs leading-5 whitespace-pre">{value}</pre>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplyBody({ message }: { message: MessageType }) {
|
||||
if (message.isImage) return <GeneratedImage src={textOf(message)} />;
|
||||
if (message.isAudio) return <AudioRenderer message={message} />;
|
||||
if (message.isEmbeddings) return <Embeddings value={textOf(message)} />;
|
||||
return (
|
||||
<>
|
||||
<Markdown>{textOf(message)}</Markdown>
|
||||
{message.image && <GeneratedImage src={message.image.url} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Minus, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import type { KeyValuePair } from "@/components/key_value_input";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSet } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroupButton } from "@/components/ui/input-group";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { Capabilities } from "./endpointCapabilities";
|
||||
import { CopyIconButton } from "./ModelName";
|
||||
import type { KeySource, PlaygroundSettings } from "./playgroundSettings";
|
||||
import type { SettingsUpdate } from "./usePlaygroundSettings";
|
||||
|
||||
const KEY_SOURCES = [
|
||||
{ value: "session", label: "Current UI session" },
|
||||
{ value: "custom", label: "Virtual key" },
|
||||
];
|
||||
|
||||
const DEFAULT_TEMPERATURE = 1;
|
||||
|
||||
function TagField({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: readonly string[];
|
||||
options: readonly string[];
|
||||
onChange: (value: readonly string[]) => void;
|
||||
}) {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
const custom = query.trim();
|
||||
const known = [...new Set([...options, ...value])];
|
||||
const items = custom === "" || known.includes(custom) ? known : [...known, custom];
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
multiple
|
||||
value={[...value]}
|
||||
onValueChange={(next: string[]) => onChange(next)}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxChips ref={anchor} className="min-h-8 py-1">
|
||||
<ComboboxValue>
|
||||
{(values: string[]) => values.map((tag) => <ComboboxChip key={tag}>{tag}</ComboboxChip>)}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput id="playground-tags" placeholder={value.length === 0 ? "Add a tag" : ""} />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No tags yet, type one to add it</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(tag: string) => (
|
||||
<ComboboxItem key={tag} value={tag}>
|
||||
{known.includes(tag) ? (
|
||||
tag
|
||||
) : (
|
||||
<>
|
||||
<Plus />
|
||||
Add {tag}
|
||||
</>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderEditor({
|
||||
pairs,
|
||||
onChange,
|
||||
}: {
|
||||
pairs: readonly KeyValuePair[];
|
||||
onChange: (pairs: readonly KeyValuePair[]) => void;
|
||||
}) {
|
||||
const replace = (index: number, pair: KeyValuePair) => onChange(pairs.map((item, i) => (i === index ? pair : item)));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pairs.map(([name, value], index) => (
|
||||
<ButtonGroup key={index} className="w-full">
|
||||
<Input
|
||||
aria-label="Header name"
|
||||
value={name}
|
||||
onChange={(event) => replace(index, [event.target.value, value])}
|
||||
placeholder="Name"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
aria-label="Header value"
|
||||
value={value}
|
||||
onChange={(event) => replace(index, [name, event.target.value])}
|
||||
placeholder="Value"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${name || "header"}`}
|
||||
onClick={() => onChange(pairs.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" onClick={() => onChange([...pairs, ["", ""]])}>
|
||||
<Plus />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyBaseUrlField({
|
||||
value,
|
||||
suggested,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
suggested: string | null;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel htmlFor="playground-base-url">Proxy base URL</FieldLabel>
|
||||
{value ? (
|
||||
<Button variant="link" size="xs" className="h-auto p-0" onClick={() => onChange("")}>
|
||||
Clear
|
||||
</Button>
|
||||
) : (
|
||||
suggested && (
|
||||
<Button variant="link" size="xs" className="h-auto p-0" onClick={() => onChange(suggested)}>
|
||||
Use {suggested}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="playground-base-url"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Same as this dashboard"
|
||||
className="h-8"
|
||||
/>
|
||||
{value && <FieldDescription>Requests go to {value}</FieldDescription>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const parseTemperature = (text: string): number | null | undefined => {
|
||||
if (text.trim() === "") return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 2 ? parsed : undefined;
|
||||
};
|
||||
|
||||
function TemperatureInput({ value, onChange }: { value: number | null; onChange: (value: number | null) => void }) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const edit = (text: string) => {
|
||||
setDraft(text);
|
||||
const parsed = parseTemperature(text);
|
||||
if (parsed !== undefined) onChange(parsed);
|
||||
};
|
||||
return (
|
||||
<Input
|
||||
aria-label="Temperature value"
|
||||
inputMode="decimal"
|
||||
placeholder="Default"
|
||||
value={draft ?? value?.toString() ?? ""}
|
||||
onChange={(event) => edit(event.target.value)}
|
||||
onBlur={() => setDraft(null)}
|
||||
className="h-8 w-16 shrink-0 text-right text-xs tabular-nums"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SamplingFields({
|
||||
settings,
|
||||
update,
|
||||
}: {
|
||||
settings: PlaygroundSettings;
|
||||
update: (change: SettingsUpdate) => void;
|
||||
}) {
|
||||
const temperature = settings.temperature ?? DEFAULT_TEMPERATURE;
|
||||
return (
|
||||
<>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="playground-temperature" className="w-28 shrink-0 font-normal">
|
||||
Temperature
|
||||
</FieldLabel>
|
||||
<Slider
|
||||
id="playground-temperature"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={[temperature]}
|
||||
onValueChange={(value) => update({ temperature: Array.isArray(value) ? value[0] : value })}
|
||||
/>
|
||||
<TemperatureInput value={settings.temperature} onChange={(temperature) => update({ temperature })} />
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="playground-max-tokens" className="w-28 shrink-0 font-normal">
|
||||
Max tokens
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="playground-max-tokens"
|
||||
type="number"
|
||||
min={1}
|
||||
value={settings.maxTokens ?? ""}
|
||||
onChange={(event) => {
|
||||
const parsed = Number.parseInt(event.target.value, 10);
|
||||
update({ maxTokens: Number.isFinite(parsed) && parsed > 0 ? parsed : null });
|
||||
}}
|
||||
placeholder="Default"
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPopover({
|
||||
settings,
|
||||
update,
|
||||
capabilities,
|
||||
keysDisabled,
|
||||
tagOptions,
|
||||
suggestedBaseUrl,
|
||||
sessions,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
settings: PlaygroundSettings;
|
||||
update: (change: SettingsUpdate) => void;
|
||||
capabilities: Capabilities;
|
||||
keysDisabled: boolean;
|
||||
tagOptions: readonly string[];
|
||||
suggestedBaseUrl: string | null;
|
||||
sessions: Readonly<{ enabled: boolean; responseId: string | null; onToggle: (enabled: boolean) => void }>;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const advancedSet = [
|
||||
settings.headerPairs.length > 0,
|
||||
Boolean(settings.proxyBaseUrl),
|
||||
settings.temperature !== null,
|
||||
settings.maxTokens !== null,
|
||||
].some(Boolean);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(advancedSet);
|
||||
const hasToggles = capabilities.stream || capabilities.simulateFallback || capabilities.sessions;
|
||||
const lastResponseId = capabilities.sessions && sessions.enabled ? sessions.responseId : null;
|
||||
return (
|
||||
<Popover defaultOpen={defaultOpen}>
|
||||
<PopoverTrigger render={<InputGroupButton size="xs" />}>
|
||||
<SlidersHorizontal />
|
||||
Settings
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="top" className="max-h-(--available-height) w-96 overflow-y-auto p-0">
|
||||
<FieldGroup className="gap-5 p-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="playground-key-source">Send as</FieldLabel>
|
||||
<Select
|
||||
items={KEY_SOURCES}
|
||||
value={settings.keySource}
|
||||
onValueChange={(value) => update({ keySource: value as KeySource })}
|
||||
disabled={keysDisabled}
|
||||
>
|
||||
<SelectTrigger id="playground-key-source" size="sm" className="w-full" aria-label="Send as">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KEY_SOURCES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.keySource === "custom" ? (
|
||||
<PasswordInput
|
||||
aria-label="Virtual key"
|
||||
value={settings.virtualKey}
|
||||
onChange={(event) => update({ virtualKey: event.target.value })}
|
||||
placeholder="Paste a virtual key"
|
||||
/>
|
||||
) : (
|
||||
<FieldDescription>Pick a virtual key to test what it can access</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="playground-tags">Tags</FieldLabel>
|
||||
<TagField value={settings.tags} options={tagOptions} onChange={(tags) => update({ tags })} />
|
||||
</Field>
|
||||
{hasToggles && (
|
||||
<FieldSet className="gap-3">
|
||||
{capabilities.stream && (
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="playground-stream" className="font-normal">
|
||||
Stream response
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="playground-stream"
|
||||
checked={settings.stream}
|
||||
onCheckedChange={(stream) => update({ stream })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{capabilities.simulateFallback && (
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="playground-fallback" className="font-normal">
|
||||
Simulate a fallback
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="playground-fallback"
|
||||
checked={settings.simulateFallback}
|
||||
onCheckedChange={(simulateFallback) => update({ simulateFallback })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{capabilities.sessions && (
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="playground-sessions" className="flex-col items-start gap-0.5 font-normal">
|
||||
Keep context on the gateway
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Sends previous_response_id instead of the whole history
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Switch id="playground-sessions" checked={sessions.enabled} onCheckedChange={sessions.onToggle} />
|
||||
</Field>
|
||||
)}
|
||||
{lastResponseId && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="shrink-0">Last response</span>
|
||||
<span className="min-w-0 truncate font-mono">{lastResponseId}</span>
|
||||
<CopyIconButton value={lastResponseId} label="response ID" />
|
||||
</div>
|
||||
)}
|
||||
</FieldSet>
|
||||
)}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 w-fit text-muted-foreground aria-expanded:bg-transparent"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronRight className="transition-transform in-data-panel-open:rotate-90" />
|
||||
Advanced
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<FieldGroup className="gap-5 pt-3">
|
||||
{capabilities.customHeaders && (
|
||||
<Field>
|
||||
<FieldLabel>Custom headers</FieldLabel>
|
||||
<HeaderEditor pairs={settings.headerPairs} onChange={(headerPairs) => update({ headerPairs })} />
|
||||
<FieldDescription>Sent with every request, like anthropic-beta</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
<ProxyBaseUrlField
|
||||
value={settings.proxyBaseUrl}
|
||||
suggested={suggestedBaseUrl}
|
||||
onChange={(proxyBaseUrl) => update({ proxyBaseUrl })}
|
||||
/>
|
||||
{capabilities.sampling && <SamplingFields settings={settings} update={update} />}
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { MessageAttachment } from "@/components/chat_ui/types";
|
||||
import { validateAudioFile, validateChatAttachment, validateImageEditFile } from "../chat_ui/uploadValidation";
|
||||
import type { AttachKind } from "./endpointCapabilities";
|
||||
|
||||
export type DraftFile = Readonly<{ file: File; previewUrl?: string }>;
|
||||
|
||||
export type AddFilesResult = Readonly<{ files: readonly DraftFile[]; errors: readonly string[] }>;
|
||||
|
||||
const isImage = (file: File): boolean => file.type.startsWith("image/");
|
||||
|
||||
const kindOf = (file: File): MessageAttachment["kind"] => {
|
||||
if (file.type.startsWith("audio/")) return "audio";
|
||||
if (isImage(file)) return "image";
|
||||
return "pdf";
|
||||
};
|
||||
|
||||
export const toAttachment = ({ file, previewUrl }: DraftFile): MessageAttachment => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
kind: kindOf(file),
|
||||
...(previewUrl ? { previewUrl } : {}),
|
||||
});
|
||||
|
||||
const validate = (kind: AttachKind, file: File, count: number) => {
|
||||
if (kind === "images") return validateImageEditFile(file, count);
|
||||
if (kind === "audio") return validateAudioFile(file);
|
||||
return validateChatAttachment(file);
|
||||
};
|
||||
|
||||
export const addFiles = (
|
||||
kind: AttachKind,
|
||||
current: readonly DraftFile[],
|
||||
incoming: readonly File[],
|
||||
preview: (file: File) => string | undefined,
|
||||
): AddFilesResult => {
|
||||
const single = kind !== "images";
|
||||
return incoming.reduce<AddFilesResult>(
|
||||
(result, file) => {
|
||||
const kept = single ? [] : result.files;
|
||||
const check = validate(kind, file, kept.length);
|
||||
if (!check.ok) return { ...result, errors: [...result.errors, check.error] };
|
||||
return { ...result, files: [...kept, { file, previewUrl: isImage(file) ? preview(file) : undefined }] };
|
||||
},
|
||||
{ files: current, errors: [] },
|
||||
);
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MessageType } from "@/components/chat_ui/types";
|
||||
import { textHistory } from "./dispatchRequest";
|
||||
|
||||
const user = (content: string): MessageType => ({ role: "user", content });
|
||||
const assistant = (content: string, extra: Partial<MessageType> = {}): MessageType => ({
|
||||
role: "assistant",
|
||||
content,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("textHistory", () => {
|
||||
it("resends only answered text turns", () => {
|
||||
const history: MessageType[] = [
|
||||
user("first question"),
|
||||
assistant("first answer"),
|
||||
user("question that failed"),
|
||||
assistant("rate limited", { isError: true }),
|
||||
user("question that broke mid-stream"),
|
||||
assistant("half an ans"),
|
||||
assistant("connection reset", { isError: true }),
|
||||
user("question that was cancelled"),
|
||||
user("draw a cat"),
|
||||
assistant("data:image/png;base64,AAA", { isImage: true }),
|
||||
{ role: "user", content: "", mcpToolCall: { tool: "search", arguments: {} } },
|
||||
assistant("tool output"),
|
||||
user("think hard"),
|
||||
assistant(""),
|
||||
user("think again"),
|
||||
assistant(" \n"),
|
||||
user("embed this"),
|
||||
assistant("[0.1, 0.2]", { isEmbeddings: true }),
|
||||
user("second question"),
|
||||
assistant("second answer"),
|
||||
];
|
||||
|
||||
expect(textHistory(history)).toEqual([
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "assistant", content: "first answer" },
|
||||
{ role: "user", content: "second question" },
|
||||
{ role: "assistant", content: "second answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a transcription turn that has no typed text", () => {
|
||||
expect(textHistory([user(""), assistant("transcribed words"), user("hi"), assistant("hello")])).toEqual([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a trailing user turn that never got a reply", () => {
|
||||
expect(textHistory([user("hi"), assistant("hello"), user("still waiting")])).toEqual([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { MessageType } from "@/components/chat_ui/types";
|
||||
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
|
||||
import type { CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
import type { ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { callMCPTool } from "@/components/networking";
|
||||
import type { UseChatHistoryReturn } from "../../hooks/useChatHistory";
|
||||
import type { UseCodeInterpreterReturn } from "../../hooks/useCodeInterpreter";
|
||||
import { makeA2ASendMessageRequest } from "../../llm_calls/a2a_send_message";
|
||||
import { makeAnthropicMessagesRequest } from "../../llm_calls/anthropic_messages";
|
||||
import { makeOpenAIAudioSpeechRequest } from "../../llm_calls/audio_speech";
|
||||
import { makeOpenAIAudioTranscriptionRequest } from "../../llm_calls/audio_transcriptions";
|
||||
import { makeOpenAIEmbeddingsRequest } from "../../llm_calls/embeddings_api";
|
||||
import { makeOpenAIImageEditsRequest } from "../../llm_calls/image_edits";
|
||||
import { makeOpenAIImageGenerationRequest } from "../../llm_calls/image_generation";
|
||||
import { makeInteractionsRequest } from "../../llm_calls/interactions_api";
|
||||
import type { PlaygroundSettings } from "./playgroundSettings";
|
||||
|
||||
type ApiMessage = { role: string; content: string | any[] };
|
||||
|
||||
export type DirectToolCall = Readonly<{ serverId: string; tool: string; arguments: Record<string, unknown> }>;
|
||||
|
||||
export type DispatchContext = Readonly<{
|
||||
settings: PlaygroundSettings;
|
||||
apiKey: string;
|
||||
text: string;
|
||||
userMessage: ApiMessage;
|
||||
history: readonly MessageType[];
|
||||
traceId: string;
|
||||
signal: AbortSignal;
|
||||
files: readonly File[];
|
||||
customHeaders: CustomHeaders;
|
||||
mcpServers: readonly MCPServer[];
|
||||
mcpToolsets: readonly MCPToolset[];
|
||||
directCall: DirectToolCall | null;
|
||||
chat: UseChatHistoryReturn;
|
||||
codeInterpreter: UseCodeInterpreterReturn;
|
||||
onHeaders: ResponseHeadersListener;
|
||||
onResponseId: (responseId: string) => void;
|
||||
}>;
|
||||
|
||||
const optional = (values: readonly string[]): string[] | undefined => (values.length > 0 ? [...values] : undefined);
|
||||
|
||||
const NON_TEXT_FLAGS = ["isImage", "isAudio", "isEmbeddings", "isError", "mcpToolCall"] as const;
|
||||
|
||||
const isText = (message: MessageType): boolean => NON_TEXT_FLAGS.every((flag) => !message[flag]);
|
||||
|
||||
const isFilled = (content: MessageType["content"]): boolean => typeof content !== "string" || content.trim() !== "";
|
||||
|
||||
const isTurn = (message: MessageType | undefined, role: "user" | "assistant"): boolean =>
|
||||
message?.role === role && isText(message) && isFilled(message.content);
|
||||
|
||||
const isReply = (history: readonly MessageType[], index: number): boolean =>
|
||||
isTurn(history[index], "assistant") && !history[index + 1]?.isError;
|
||||
|
||||
const inExchange = (history: readonly MessageType[], index: number): boolean =>
|
||||
isTurn(history[index], "user")
|
||||
? isReply(history, index + 1)
|
||||
: isReply(history, index) && isTurn(history[index - 1], "user");
|
||||
|
||||
export const textHistory = (history: readonly MessageType[]): ApiMessage[] =>
|
||||
history.filter((_, index) => inExchange(history, index)).map(({ role, content }) => ({ role, content }));
|
||||
|
||||
const toolResultText = (result: { content?: { type: string; text?: string }[] } | null): string => {
|
||||
if (result?.content && result.content.length > 0) {
|
||||
return JSON.stringify(
|
||||
result.content.map((item) => (item.type === "text" ? item.text : item)).filter(Boolean),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
return JSON.stringify(result, null, 2) || "Tool executed successfully.";
|
||||
};
|
||||
|
||||
export async function dispatchRequest(context: DispatchContext): Promise<void> {
|
||||
const { settings, apiKey, text, userMessage, history, traceId, signal, chat, onHeaders } = context;
|
||||
const model = settings.model ?? "";
|
||||
const tags = [...settings.tags];
|
||||
const baseUrl = settings.proxyBaseUrl || undefined;
|
||||
const headers = context.customHeaders;
|
||||
const vectorStores = optional(settings.vectorStores);
|
||||
const guardrails = optional(settings.guardrails);
|
||||
const policies = optional(settings.policies);
|
||||
const mcpSelection = [...settings.mcpSelection];
|
||||
const mcpServers = [...context.mcpServers];
|
||||
const toolLimits = Object.fromEntries(Object.entries(settings.toolLimits).map(([key, tools]) => [key, [...tools]]));
|
||||
const mcpToolsets = [...context.mcpToolsets];
|
||||
|
||||
switch (settings.endpoint) {
|
||||
case EndpointType.CHAT:
|
||||
return makeOpenAIChatCompletionRequest(
|
||||
[
|
||||
...textHistory(history).map(({ role, content }) => ({
|
||||
role,
|
||||
content: typeof content === "string" ? content : "",
|
||||
})),
|
||||
userMessage,
|
||||
],
|
||||
(chunk, servedModel) => chat.updateTextUI("assistant", chunk, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
chat.updateReasoningContent,
|
||||
chat.updateTimingData,
|
||||
chat.updateUsageData,
|
||||
traceId,
|
||||
vectorStores,
|
||||
guardrails,
|
||||
policies,
|
||||
mcpSelection,
|
||||
chat.updateChatImageUI,
|
||||
chat.updateSearchResults,
|
||||
settings.temperature ?? undefined,
|
||||
settings.maxTokens ?? undefined,
|
||||
chat.updateTotalLatency,
|
||||
baseUrl,
|
||||
mcpServers,
|
||||
toolLimits,
|
||||
chat.handleMCPEvent,
|
||||
settings.simulateFallback,
|
||||
mcpToolsets,
|
||||
settings.stream,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.RESPONSES: {
|
||||
const continuing = chat.useApiSessionManagement && chat.responsesSessionId;
|
||||
return makeOpenAIResponsesRequest(
|
||||
(continuing ? [userMessage] : [...textHistory(history), userMessage]) as MessageType[],
|
||||
(role, delta, servedModel) => chat.updateTextUI(role, delta, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
chat.updateReasoningContent,
|
||||
chat.updateTimingData,
|
||||
chat.updateUsageData,
|
||||
traceId,
|
||||
vectorStores,
|
||||
guardrails,
|
||||
policies,
|
||||
mcpSelection,
|
||||
chat.useApiSessionManagement ? chat.responsesSessionId : null,
|
||||
context.onResponseId,
|
||||
chat.handleMCPEvent,
|
||||
context.codeInterpreter.enabled,
|
||||
context.codeInterpreter.setResult,
|
||||
baseUrl,
|
||||
mcpServers,
|
||||
toolLimits,
|
||||
mcpToolsets,
|
||||
settings.stream,
|
||||
chat.updateTotalLatency,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
}
|
||||
case EndpointType.ANTHROPIC_MESSAGES:
|
||||
return makeAnthropicMessagesRequest(
|
||||
[...textHistory(history), userMessage] as MessageType[],
|
||||
(role, delta, servedModel) => chat.updateTextUI(role, delta, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
chat.updateReasoningContent,
|
||||
chat.updateTimingData,
|
||||
chat.updateUsageData,
|
||||
traceId,
|
||||
vectorStores,
|
||||
guardrails,
|
||||
policies,
|
||||
mcpSelection,
|
||||
baseUrl,
|
||||
mcpServers,
|
||||
toolLimits,
|
||||
mcpToolsets,
|
||||
settings.stream,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.IMAGE:
|
||||
return makeOpenAIImageGenerationRequest(
|
||||
text,
|
||||
(imageUrl, servedModel) => chat.updateImageUI(imageUrl, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
baseUrl,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.IMAGE_EDITS:
|
||||
return makeOpenAIImageEditsRequest(
|
||||
context.files.length === 1 ? context.files[0] : [...context.files],
|
||||
text,
|
||||
(imageUrl, servedModel) => chat.updateImageUI(imageUrl, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
baseUrl,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.SPEECH:
|
||||
return makeOpenAIAudioSpeechRequest(
|
||||
text,
|
||||
settings.voice,
|
||||
(audioUrl, servedModel) => chat.updateAudioUI(audioUrl, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
undefined,
|
||||
undefined,
|
||||
baseUrl,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.TRANSCRIPTION:
|
||||
return makeOpenAIAudioTranscriptionRequest(
|
||||
context.files[0],
|
||||
(transcription, servedModel) => chat.updateTextUI("assistant", transcription, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
baseUrl,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.EMBEDDINGS:
|
||||
return makeOpenAIEmbeddingsRequest(
|
||||
text,
|
||||
(embeddings, servedModel) => chat.updateEmbeddingsUI(embeddings, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
baseUrl,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.INTERACTIONS:
|
||||
return makeInteractionsRequest(
|
||||
text,
|
||||
(reply, servedModel) => chat.updateTextUI("assistant", reply, servedModel),
|
||||
model,
|
||||
apiKey,
|
||||
tags,
|
||||
signal,
|
||||
baseUrl,
|
||||
undefined,
|
||||
headers,
|
||||
onHeaders,
|
||||
);
|
||||
case EndpointType.A2A_AGENTS:
|
||||
return makeA2ASendMessageRequest(
|
||||
settings.agent ?? "",
|
||||
text,
|
||||
(chunk, servedModel) => chat.updateTextUI("assistant", chunk, servedModel),
|
||||
apiKey,
|
||||
signal,
|
||||
chat.updateTimingData,
|
||||
chat.updateTotalLatency,
|
||||
chat.updateA2AMetadata,
|
||||
baseUrl,
|
||||
guardrails,
|
||||
headers,
|
||||
);
|
||||
case EndpointType.MCP: {
|
||||
if (!context.directCall) return;
|
||||
const result = await callMCPTool(
|
||||
apiKey,
|
||||
context.directCall.serverId,
|
||||
context.directCall.tool,
|
||||
context.directCall.arguments,
|
||||
{ ...(guardrails ? { guardrails } : {}), customHeaders: { ...headers } },
|
||||
);
|
||||
chat.updateTextUI("assistant", toolResultText(result));
|
||||
return;
|
||||
}
|
||||
case EndpointType.REALTIME:
|
||||
case EndpointType.VIDEO:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import { AUDIO_ACCEPT, CHAT_ATTACHMENT_ACCEPT, IMAGE_EDIT_ACCEPT } from "../chat_ui/uploadValidation";
|
||||
|
||||
export type AttachKind = "chat" | "images" | "audio";
|
||||
|
||||
export type Capabilities = Readonly<{
|
||||
target: "model" | "agent" | "mcpTool";
|
||||
mcp: boolean;
|
||||
vectorStores: boolean;
|
||||
guardrails: boolean;
|
||||
policies: boolean;
|
||||
attach: AttachKind | null;
|
||||
codeInterpreter: boolean;
|
||||
stream: boolean;
|
||||
simulateFallback: boolean;
|
||||
sampling: boolean;
|
||||
sessions: boolean;
|
||||
voice: boolean;
|
||||
customHeaders: boolean;
|
||||
textRequired: boolean;
|
||||
placeholder: string;
|
||||
}>;
|
||||
|
||||
const BASE: Capabilities = {
|
||||
target: "model",
|
||||
mcp: false,
|
||||
vectorStores: false,
|
||||
guardrails: false,
|
||||
policies: false,
|
||||
attach: null,
|
||||
codeInterpreter: false,
|
||||
stream: false,
|
||||
simulateFallback: false,
|
||||
sampling: false,
|
||||
sessions: false,
|
||||
voice: false,
|
||||
customHeaders: true,
|
||||
textRequired: true,
|
||||
placeholder: "Send a test request",
|
||||
};
|
||||
|
||||
const TEXT_CHAT: Partial<Capabilities> = {
|
||||
mcp: true,
|
||||
vectorStores: true,
|
||||
guardrails: true,
|
||||
policies: true,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const CAPABILITIES: Readonly<Record<EndpointType, Capabilities>> = {
|
||||
[EndpointType.CHAT]: { ...BASE, ...TEXT_CHAT, attach: "chat", simulateFallback: true, sampling: true },
|
||||
[EndpointType.RESPONSES]: { ...BASE, ...TEXT_CHAT, attach: "chat", codeInterpreter: true, sessions: true },
|
||||
[EndpointType.ANTHROPIC_MESSAGES]: { ...BASE, ...TEXT_CHAT },
|
||||
[EndpointType.IMAGE]: { ...BASE, placeholder: "Describe the image you want to generate" },
|
||||
[EndpointType.VIDEO]: { ...BASE, placeholder: "Describe the video you want to generate" },
|
||||
[EndpointType.IMAGE_EDITS]: { ...BASE, attach: "images", placeholder: "Describe how to edit the images" },
|
||||
[EndpointType.EMBEDDINGS]: { ...BASE, placeholder: "Text to embed" },
|
||||
[EndpointType.SPEECH]: { ...BASE, voice: true, placeholder: "Text to turn into speech" },
|
||||
[EndpointType.TRANSCRIPTION]: {
|
||||
...BASE,
|
||||
attach: "audio",
|
||||
textRequired: false,
|
||||
placeholder: "Optional context for the transcription",
|
||||
},
|
||||
[EndpointType.A2A_AGENTS]: { ...BASE, target: "agent", guardrails: true, placeholder: "Send a message to the agent" },
|
||||
[EndpointType.MCP]: { ...BASE, target: "mcpTool", guardrails: true, textRequired: false },
|
||||
[EndpointType.REALTIME]: { ...BASE, guardrails: true, customHeaders: false },
|
||||
[EndpointType.INTERACTIONS]: { ...BASE },
|
||||
};
|
||||
|
||||
export const capabilitiesFor = (endpoint: EndpointType): Capabilities => CAPABILITIES[endpoint];
|
||||
|
||||
export const ACCEPT_FOR: Readonly<Record<AttachKind, string>> = {
|
||||
chat: CHAT_ATTACHMENT_ACCEPT,
|
||||
images: IMAGE_EDIT_ACCEPT,
|
||||
audio: AUDIO_ACCEPT,
|
||||
};
|
||||
|
||||
export const ATTACH_LABEL: Readonly<Record<AttachKind, string>> = {
|
||||
chat: "Attach an image or PDF",
|
||||
images: "Add images to edit",
|
||||
audio: "Attach an audio file",
|
||||
};
|
||||
|
||||
export const SUGGESTIONS: Readonly<Partial<Record<EndpointType, readonly string[]>>> = {
|
||||
[EndpointType.CHAT]: ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"],
|
||||
[EndpointType.RESPONSES]: [
|
||||
"Write me a poem",
|
||||
"Explain quantum computing",
|
||||
"Draft a polite email requesting a meeting",
|
||||
],
|
||||
[EndpointType.ANTHROPIC_MESSAGES]: [
|
||||
"Write me a poem",
|
||||
"Explain quantum computing",
|
||||
"Draft a polite email requesting a meeting",
|
||||
],
|
||||
[EndpointType.A2A_AGENTS]: ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"],
|
||||
};
|
||||
|
||||
export const CODE_INTERPRETER_SUGGESTIONS: readonly string[] = [
|
||||
"Generate sample sales data CSV and create a chart",
|
||||
"Create a PNG bar chart comparing AI gateway providers including LiteLLM",
|
||||
"Generate a CSV of LLM pricing data and visualize it as a line chart",
|
||||
];
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import { OPEN_AI_VOICES } from "../chat_ui/chatConstants";
|
||||
import {
|
||||
ALL_MCP_SERVERS,
|
||||
allowedTools,
|
||||
defaultSettings,
|
||||
loadSettings,
|
||||
storedValues,
|
||||
toggleMcpItem,
|
||||
toggleToolLimit,
|
||||
withEndpoint,
|
||||
withReachableMcp,
|
||||
type PlaygroundSettings,
|
||||
type SettingsStorage,
|
||||
} from "./playgroundSettings";
|
||||
|
||||
const storageOf = (values: Readonly<Record<string, string>>): SettingsStorage => ({
|
||||
read: (key) => values[key] ?? null,
|
||||
readSecure: (key) => values[key] ?? null,
|
||||
});
|
||||
|
||||
const settingsWith = (change: Partial<PlaygroundSettings>): PlaygroundSettings => ({
|
||||
...defaultSettings(false),
|
||||
...change,
|
||||
});
|
||||
|
||||
const TOOLS = ["search", "fetch", "summarize"] as const;
|
||||
const SCOPE = { server: "s", tools: TOOLS };
|
||||
|
||||
describe("loadSettings", () => {
|
||||
it("restores every persisted field from what storedValues wrote", () => {
|
||||
const persisted: Partial<PlaygroundSettings> = {
|
||||
keySource: "custom",
|
||||
virtualKey: "sk-test",
|
||||
proxyBaseUrl: "http://gateway.test",
|
||||
endpoint: EndpointType.RESPONSES,
|
||||
model: "gpt-test",
|
||||
voice: OPEN_AI_VOICES.NOVA,
|
||||
tags: ["team-a"],
|
||||
headerPairs: [["x-trace", "abc"]],
|
||||
vectorStores: ["vs_1"],
|
||||
guardrails: ["pii-mask"],
|
||||
policies: ["strict"],
|
||||
mcpSelection: ["server-1"],
|
||||
toolLimits: { "server-1": ["search"] },
|
||||
stream: false,
|
||||
};
|
||||
const original = settingsWith(persisted);
|
||||
const written = Object.fromEntries(
|
||||
storedValues(original)
|
||||
.filter((entry): entry is { key: string; value: string; secure: boolean } => entry.value !== null)
|
||||
.map(({ key, value }) => [key, value]),
|
||||
);
|
||||
|
||||
expect(loadSettings(storageOf(written), false)).toEqual(original);
|
||||
});
|
||||
|
||||
it("falls back to defaults when stored values are corrupt or unknown", () => {
|
||||
const corrupt = {
|
||||
endpointType: "/v1/not-an-endpoint",
|
||||
selectedVoice: "robot",
|
||||
selectedTags: "{not json",
|
||||
selectedGuardrails: JSON.stringify([1, 2]),
|
||||
mcpServerToolRestrictions: JSON.stringify({ good: ["a"], bad: "a" }),
|
||||
};
|
||||
const loaded = loadSettings(storageOf(corrupt), false);
|
||||
|
||||
expect(loaded.endpoint).toBe(EndpointType.CHAT);
|
||||
expect(loaded.voice).toBe(OPEN_AI_VOICES.ALLOY);
|
||||
expect(loaded.tags).toEqual([]);
|
||||
expect(loaded.guardrails).toEqual([]);
|
||||
expect(loaded.toolLimits).toEqual({ good: ["a"] });
|
||||
});
|
||||
|
||||
it("forces the virtual key source when personal keys are disabled", () => {
|
||||
expect(loadSettings(storageOf({ apiKeySource: JSON.stringify("session") }), true).keySource).toBe("custom");
|
||||
expect(loadSettings(storageOf({ apiKeySource: JSON.stringify("session") }), false).keySource).toBe("session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withEndpoint", () => {
|
||||
it("clears the model and keeps only a single MCP server when switching to direct tool calls", () => {
|
||||
const next = withEndpoint(settingsWith({ model: "gpt-test", mcpSelection: ["a", "b"] }), EndpointType.MCP);
|
||||
|
||||
expect(next.endpoint).toBe(EndpointType.MCP);
|
||||
expect(next.model).toBeNull();
|
||||
expect(next.mcpSelection).toEqual([]);
|
||||
expect(withEndpoint(settingsWith({ mcpSelection: ["a"] }), EndpointType.MCP).mcpSelection).toEqual(["a"]);
|
||||
expect(withEndpoint(settingsWith({ mcpSelection: [ALL_MCP_SERVERS] }), EndpointType.MCP).mcpSelection).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the MCP selection for model endpoints", () => {
|
||||
expect(withEndpoint(settingsWith({ mcpSelection: ["a", "b"] }), EndpointType.RESPONSES).mcpSelection).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleMcpItem", () => {
|
||||
it("treats all servers as exclusive with individual servers", () => {
|
||||
expect(toggleMcpItem(["a", "b"], ALL_MCP_SERVERS, true)).toEqual([ALL_MCP_SERVERS]);
|
||||
expect(toggleMcpItem([ALL_MCP_SERVERS], "a", true)).toEqual(["a"]);
|
||||
expect(toggleMcpItem(["a", "b"], "a", false)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withReachableMcp", () => {
|
||||
it("drops servers the current key cannot reach along with their tool limits", () => {
|
||||
const settings = settingsWith({
|
||||
mcpSelection: [ALL_MCP_SERVERS, "kept", "gone"],
|
||||
toolLimits: { kept: ["search"], gone: ["fetch"] },
|
||||
});
|
||||
|
||||
const next = withReachableMcp(settings, new Set(["kept"]));
|
||||
|
||||
expect(next.mcpSelection).toEqual([ALL_MCP_SERVERS, "kept"]);
|
||||
expect(next.toolLimits).toEqual({ kept: ["search"] });
|
||||
});
|
||||
|
||||
it("leaves settings untouched while the catalog is loading or when everything is reachable", () => {
|
||||
const settings = settingsWith({ mcpSelection: ["kept"] });
|
||||
|
||||
expect(withReachableMcp(settings, null)).toBe(settings);
|
||||
expect(withReachableMcp(settings, new Set(["kept", "other"]))).toBe(settings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleToolLimit", () => {
|
||||
it("narrows an unrestricted server to the remaining tools when one is unchecked", () => {
|
||||
const limits = toggleToolLimit({}, SCOPE, "fetch", false);
|
||||
|
||||
expect(allowedTools(limits, "s", TOOLS)).toEqual(["search", "summarize"]);
|
||||
});
|
||||
|
||||
it("refuses to uncheck the last allowed tool", () => {
|
||||
const limits = { s: ["search"] };
|
||||
|
||||
expect(toggleToolLimit(limits, SCOPE, "search", false)).toBe(limits);
|
||||
});
|
||||
|
||||
it("lifts the restriction once every tool is checked again", () => {
|
||||
const limits = toggleToolLimit({ s: ["search", "fetch"] }, SCOPE, "summarize", true);
|
||||
|
||||
expect(limits.s).toEqual([]);
|
||||
expect(allowedTools(limits, "s", TOOLS)).toEqual(TOOLS);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { KeyValuePair } from "@/components/key_value_input";
|
||||
import { parseStoredHeaderPairs } from "@/components/llm_calls/request_headers";
|
||||
import { OPEN_AI_VOICES, type OpenAIVoice } from "../chat_ui/chatConstants";
|
||||
|
||||
export type KeySource = "session" | "custom";
|
||||
|
||||
export type ToolLimits = Readonly<Record<string, readonly string[]>>;
|
||||
|
||||
export type PlaygroundSettings = Readonly<{
|
||||
keySource: KeySource;
|
||||
virtualKey: string;
|
||||
proxyBaseUrl: string;
|
||||
endpoint: EndpointType;
|
||||
model: string | null;
|
||||
agent: string | null;
|
||||
voice: OpenAIVoice;
|
||||
tags: readonly string[];
|
||||
headerPairs: readonly KeyValuePair[];
|
||||
vectorStores: readonly string[];
|
||||
guardrails: readonly string[];
|
||||
policies: readonly string[];
|
||||
mcpSelection: readonly string[];
|
||||
toolLimits: ToolLimits;
|
||||
directTool: string | null;
|
||||
stream: boolean;
|
||||
simulateFallback: boolean;
|
||||
temperature: number | null;
|
||||
maxTokens: number | null;
|
||||
}>;
|
||||
|
||||
export type StoredValue = Readonly<{ key: string; value: string | null; secure: boolean }>;
|
||||
|
||||
export type SettingsStorage = Readonly<{
|
||||
read: (key: string) => string | null;
|
||||
readSecure: (key: string) => string | null;
|
||||
}>;
|
||||
|
||||
export const ALL_MCP_SERVERS = "__all__";
|
||||
export const TOOLSET_PREFIX = "toolset:";
|
||||
|
||||
const ENDPOINTS: readonly string[] = Object.values(EndpointType);
|
||||
const VOICES: readonly string[] = Object.values(OPEN_AI_VOICES);
|
||||
|
||||
const parseJson = (raw: string | null): unknown => {
|
||||
if (raw === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isStringList = (value: unknown): value is readonly string[] =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
|
||||
const stringList = (raw: string | null): readonly string[] => {
|
||||
const parsed = parseJson(raw);
|
||||
return isStringList(parsed) ? parsed : [];
|
||||
};
|
||||
|
||||
const toolLimits = (raw: string | null): ToolLimits => {
|
||||
const parsed = parseJson(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, string[]] => isStringList(entry[1])),
|
||||
);
|
||||
};
|
||||
|
||||
const endpoint = (raw: string | null): EndpointType =>
|
||||
ENDPOINTS.includes(raw ?? "") ? (raw as EndpointType) : EndpointType.CHAT;
|
||||
|
||||
const voice = (raw: string | null): OpenAIVoice => {
|
||||
const parsed = parseJson(raw);
|
||||
const candidate = typeof parsed === "string" ? parsed : raw;
|
||||
return VOICES.includes(candidate ?? "") ? (candidate as OpenAIVoice) : OPEN_AI_VOICES.ALLOY;
|
||||
};
|
||||
|
||||
const keySource = (raw: string | null, keysDisabled: boolean): KeySource => {
|
||||
if (keysDisabled) return "custom";
|
||||
const parsed = parseJson(raw);
|
||||
return parsed === "custom" ? "custom" : "session";
|
||||
};
|
||||
|
||||
export const defaultSettings = (keysDisabled: boolean): PlaygroundSettings => ({
|
||||
keySource: keysDisabled ? "custom" : "session",
|
||||
virtualKey: "",
|
||||
proxyBaseUrl: "",
|
||||
endpoint: EndpointType.CHAT,
|
||||
model: null,
|
||||
agent: null,
|
||||
voice: OPEN_AI_VOICES.ALLOY,
|
||||
tags: [],
|
||||
headerPairs: [],
|
||||
vectorStores: [],
|
||||
guardrails: [],
|
||||
policies: [],
|
||||
mcpSelection: [],
|
||||
toolLimits: {},
|
||||
directTool: null,
|
||||
stream: true,
|
||||
simulateFallback: false,
|
||||
temperature: null,
|
||||
maxTokens: null,
|
||||
});
|
||||
|
||||
export const loadSettings = (storage: SettingsStorage, keysDisabled: boolean): PlaygroundSettings => ({
|
||||
...defaultSettings(keysDisabled),
|
||||
keySource: keySource(storage.readSecure("apiKeySource"), keysDisabled),
|
||||
virtualKey: storage.readSecure("apiKey") ?? "",
|
||||
headerPairs: parseStoredHeaderPairs(storage.readSecure("customHeaders")),
|
||||
proxyBaseUrl: storage.read("customProxyBaseUrl") ?? "",
|
||||
endpoint: endpoint(storage.read("endpointType")),
|
||||
model: storage.read("selectedModel"),
|
||||
voice: voice(storage.read("selectedVoice")),
|
||||
tags: stringList(storage.read("selectedTags")),
|
||||
vectorStores: stringList(storage.read("selectedVectorStores")),
|
||||
guardrails: stringList(storage.read("selectedGuardrails")),
|
||||
policies: stringList(storage.read("selectedPolicies")),
|
||||
mcpSelection: stringList(storage.read("selectedMCPServers")),
|
||||
toolLimits: toolLimits(storage.read("mcpServerToolRestrictions")),
|
||||
stream: storage.read("streamingEnabled") !== "false",
|
||||
});
|
||||
|
||||
export const storedValues = (settings: PlaygroundSettings): readonly StoredValue[] => [
|
||||
{ key: "apiKeySource", value: JSON.stringify(settings.keySource), secure: true },
|
||||
{ key: "apiKey", value: settings.virtualKey, secure: true },
|
||||
{ key: "customHeaders", value: JSON.stringify(settings.headerPairs), secure: true },
|
||||
{ key: "customProxyBaseUrl", value: settings.proxyBaseUrl || null, secure: false },
|
||||
{ key: "endpointType", value: settings.endpoint, secure: false },
|
||||
{ key: "selectedModel", value: settings.model, secure: false },
|
||||
{ key: "selectedVoice", value: settings.voice, secure: false },
|
||||
{ key: "selectedTags", value: JSON.stringify(settings.tags), secure: false },
|
||||
{ key: "selectedVectorStores", value: JSON.stringify(settings.vectorStores), secure: false },
|
||||
{ key: "selectedGuardrails", value: JSON.stringify(settings.guardrails), secure: false },
|
||||
{ key: "selectedPolicies", value: JSON.stringify(settings.policies), secure: false },
|
||||
{ key: "selectedMCPServers", value: JSON.stringify(settings.mcpSelection), secure: false },
|
||||
{ key: "mcpServerToolRestrictions", value: JSON.stringify(settings.toolLimits), secure: false },
|
||||
{ key: "streamingEnabled", value: String(settings.stream), secure: false },
|
||||
];
|
||||
|
||||
const singleServer = (selection: readonly string[]): readonly string[] =>
|
||||
selection.length === 1 && selection[0] !== ALL_MCP_SERVERS ? selection : [];
|
||||
|
||||
export const withEndpoint = (settings: PlaygroundSettings, next: EndpointType): PlaygroundSettings => ({
|
||||
...settings,
|
||||
endpoint: next,
|
||||
model: null,
|
||||
agent: null,
|
||||
directTool: null,
|
||||
mcpSelection: next === EndpointType.MCP ? singleServer(settings.mcpSelection) : settings.mcpSelection,
|
||||
});
|
||||
|
||||
export const toggleMcpItem = (selection: readonly string[], item: string, on: boolean): readonly string[] => {
|
||||
if (!on) return selection.filter((value) => value !== item);
|
||||
if (item === ALL_MCP_SERVERS) return [ALL_MCP_SERVERS];
|
||||
return [...selection.filter((value) => value !== ALL_MCP_SERVERS && value !== item), item];
|
||||
};
|
||||
|
||||
export const withoutStaleLimits = (limits: ToolLimits, selection: readonly string[]): ToolLimits =>
|
||||
Object.fromEntries(Object.entries(limits).filter(([server]) => selection.includes(server)));
|
||||
|
||||
export const withReachableMcp = (
|
||||
settings: PlaygroundSettings,
|
||||
reachable: ReadonlySet<string> | null,
|
||||
): PlaygroundSettings => {
|
||||
if (!reachable) return settings;
|
||||
const mcpSelection = settings.mcpSelection.filter((item) => item === ALL_MCP_SERVERS || reachable.has(item));
|
||||
if (mcpSelection.length === settings.mcpSelection.length) return settings;
|
||||
return { ...settings, mcpSelection, toolLimits: withoutStaleLimits(settings.toolLimits, mcpSelection) };
|
||||
};
|
||||
|
||||
export const allowedTools = (limits: ToolLimits, server: string, tools: readonly string[]): readonly string[] => {
|
||||
const limit = limits[server] ?? [];
|
||||
return limit.length === 0 ? tools : tools.filter((tool) => limit.includes(tool));
|
||||
};
|
||||
|
||||
export type ToolScope = Readonly<{ server: string; tools: readonly string[] }>;
|
||||
|
||||
export const toggleToolLimit = (
|
||||
limits: ToolLimits,
|
||||
{ server, tools }: ToolScope,
|
||||
tool: string,
|
||||
on: boolean,
|
||||
): ToolLimits => {
|
||||
const current = allowedTools(limits, server, tools);
|
||||
const next = on
|
||||
? tools.filter((name) => name === tool || current.includes(name))
|
||||
: current.filter((name) => name !== tool);
|
||||
if (next.length === 0) return limits;
|
||||
return { ...limits, [server]: next.length === tools.length ? [] : next };
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const QUERY = "(min-width: 80rem)";
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const media = window.matchMedia(QUERY);
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
};
|
||||
|
||||
export const useIsWide = (): boolean =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(QUERY).matches,
|
||||
() => true,
|
||||
);
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { useQueries, useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import type { MCPServer, MCPTool, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
fetchMCPServers,
|
||||
fetchMCPToolsets,
|
||||
getGuardrailsList,
|
||||
getPoliciesList,
|
||||
listMCPTools,
|
||||
tagListCall,
|
||||
vectorStoreListCall,
|
||||
} from "@/components/networking";
|
||||
import { getPolicyOptionEntries } from "@/components/policies/PolicySelector";
|
||||
import type { Policy } from "@/components/policies/types";
|
||||
import type { Tag } from "@/components/tag_management/types";
|
||||
import type { VectorStore } from "@/components/vector_store_management/types";
|
||||
import { fetchAvailableAgents, type Agent } from "../../llm_calls/fetch_agents";
|
||||
|
||||
export type PickerOption = Readonly<{ value: string; label: string; description?: string }>;
|
||||
|
||||
const EMPTY: readonly never[] = [];
|
||||
|
||||
const serverList = (response: MCPServer[] | { data?: MCPServer[] }): readonly MCPServer[] =>
|
||||
Array.isArray(response) ? response : response.data ?? [];
|
||||
|
||||
export function usePlaygroundModels(requestKey: string | null) {
|
||||
const options = {
|
||||
queryKey: ["playground", "models", requestKey],
|
||||
queryFn: (): Promise<readonly ModelGroup[]> => fetchAvailableModels(requestKey ?? ""),
|
||||
enabled: Boolean(requestKey),
|
||||
retry: false,
|
||||
} satisfies UseQueryOptions<readonly ModelGroup[]>;
|
||||
return useQuery(options);
|
||||
}
|
||||
|
||||
export function useMcpCatalog(requestKey: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const servers = useQuery<readonly MCPServer[]>({
|
||||
queryKey: ["playground", "mcpServers", requestKey],
|
||||
queryFn: async () => serverList(await fetchMCPServers(requestKey ?? "")),
|
||||
enabled: Boolean(requestKey),
|
||||
});
|
||||
const toolsets = useQuery<readonly MCPToolset[] | null>({
|
||||
queryKey: ["playground", "mcpToolsets", requestKey],
|
||||
queryFn: async () => {
|
||||
const response: unknown = await fetchMCPToolsets(requestKey ?? "").catch(() => null);
|
||||
if (response === null) return null;
|
||||
return Array.isArray(response) ? (response as MCPToolset[]) : [];
|
||||
},
|
||||
enabled: Boolean(requestKey),
|
||||
});
|
||||
return {
|
||||
servers: servers.data ?? EMPTY,
|
||||
toolsets: toolsets.data ?? EMPTY,
|
||||
isLoading: servers.isLoading || toolsets.isLoading,
|
||||
isReady: servers.isSuccess && Boolean(toolsets.data),
|
||||
refreshCredentials: () => {
|
||||
void servers.refetch();
|
||||
void queryClient.invalidateQueries({ queryKey: ["playground", "mcpTools", requestKey] });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ServerTools = Readonly<{ tools: readonly MCPTool[]; isLoading: boolean; failed: boolean }>;
|
||||
|
||||
export function useServerTools(requestKey: string | null, serverIds: readonly string[]) {
|
||||
const results = useQueries({
|
||||
queries: serverIds.map((serverId) => ({
|
||||
queryKey: ["playground", "mcpTools", requestKey, serverId],
|
||||
queryFn: async (): Promise<readonly MCPTool[]> => {
|
||||
const response: { tools?: MCPTool[]; error?: string } = await listMCPTools(requestKey ?? "", serverId);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.tools ?? [];
|
||||
},
|
||||
enabled: Boolean(requestKey),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
return Object.fromEntries(
|
||||
serverIds.map((serverId, index) => [
|
||||
serverId,
|
||||
{
|
||||
tools: results[index]?.data ?? EMPTY,
|
||||
isLoading: results[index]?.isLoading ?? false,
|
||||
failed: results[index]?.isError ?? false,
|
||||
},
|
||||
]),
|
||||
) as Readonly<Record<string, ServerTools>>;
|
||||
}
|
||||
|
||||
export function usePlaygroundAgents(requestKey: string | null, proxyBaseUrl: string, enabled: boolean) {
|
||||
return useQuery<readonly Agent[]>({
|
||||
queryKey: ["playground", "agents", requestKey, proxyBaseUrl],
|
||||
queryFn: () => fetchAvailableAgents(requestKey ?? "", proxyBaseUrl || undefined),
|
||||
enabled: enabled && Boolean(requestKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGuardrailOptions(uiToken: string | null) {
|
||||
return useQuery<readonly PickerOption[]>({
|
||||
queryKey: ["playground", "guardrails", uiToken],
|
||||
queryFn: async () => {
|
||||
const response: { guardrails?: { guardrail_name?: string | null }[] } = await getGuardrailsList(uiToken ?? "");
|
||||
return (response.guardrails ?? []).flatMap((guardrail) =>
|
||||
guardrail.guardrail_name ? [{ value: guardrail.guardrail_name, label: guardrail.guardrail_name }] : [],
|
||||
);
|
||||
},
|
||||
enabled: Boolean(uiToken),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePolicyOptions(uiToken: string | null, canView: boolean) {
|
||||
return useQuery<readonly PickerOption[]>({
|
||||
queryKey: ["playground", "policies", uiToken],
|
||||
queryFn: async () => {
|
||||
const response: { policies?: Policy[] } = await getPoliciesList(uiToken ?? "");
|
||||
return getPolicyOptionEntries(response.policies ?? []);
|
||||
},
|
||||
enabled: canView && Boolean(uiToken),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVectorStoreOptions(uiToken: string | null) {
|
||||
return useQuery<readonly PickerOption[]>({
|
||||
queryKey: ["playground", "vectorStores", uiToken],
|
||||
queryFn: async () => {
|
||||
const response: { data?: VectorStore[] } = await vectorStoreListCall(uiToken ?? "");
|
||||
return (response.data ?? []).map((store) => ({
|
||||
value: store.vector_store_id,
|
||||
label: store.vector_store_name || store.vector_store_id,
|
||||
description: store.vector_store_name ? store.vector_store_id : undefined,
|
||||
}));
|
||||
},
|
||||
enabled: Boolean(uiToken),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTagOptions(uiToken: string | null) {
|
||||
return useQuery<readonly string[]>({
|
||||
queryKey: ["playground", "tags", uiToken],
|
||||
queryFn: async () => {
|
||||
const response: Record<string, Tag> = await tagListCall(uiToken ?? "");
|
||||
return Object.values(response).map((tag) => tag.name);
|
||||
},
|
||||
enabled: Boolean(uiToken),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { useRef, useState, type RefObject } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { MessageType, ReplyTrace } from "@/components/chat_ui/types";
|
||||
import { gatewayTraceFromHeaders } from "@/components/llm_calls/reply_trace";
|
||||
import type { CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
import type { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { UseChatHistoryReturn } from "../../hooks/useChatHistory";
|
||||
import type { UseCodeInterpreterReturn } from "../../hooks/useCodeInterpreter";
|
||||
import { createChatMultimodalMessage } from "../chat_ui/ChatImageUtils";
|
||||
import { createMultimodalMessage } from "../chat_ui/ResponsesImageUtils";
|
||||
import { addFiles, toAttachment, type DraftFile } from "./composerFiles";
|
||||
import { dispatchRequest, type DirectToolCall, type DispatchContext } from "./dispatchRequest";
|
||||
import type { AttachKind, Capabilities } from "./endpointCapabilities";
|
||||
import type { PlaygroundSettings } from "./playgroundSettings";
|
||||
|
||||
const MODEL_ENDPOINTS: readonly EndpointType[] = [
|
||||
EndpointType.CHAT,
|
||||
EndpointType.IMAGE,
|
||||
EndpointType.SPEECH,
|
||||
EndpointType.IMAGE_EDITS,
|
||||
EndpointType.RESPONSES,
|
||||
EndpointType.ANTHROPIC_MESSAGES,
|
||||
EndpointType.EMBEDDINGS,
|
||||
EndpointType.TRANSCRIPTION,
|
||||
EndpointType.INTERACTIONS,
|
||||
];
|
||||
|
||||
export type SendDeps = Readonly<{
|
||||
settings: PlaygroundSettings;
|
||||
capabilities: Capabilities;
|
||||
apiKey: string | null;
|
||||
signedIn: boolean;
|
||||
customHeaders: CustomHeaders;
|
||||
mcpServers: readonly MCPServer[];
|
||||
mcpToolsets: readonly MCPToolset[];
|
||||
directCall: () => Omit<DirectToolCall, "arguments"> | null;
|
||||
toolArgsForm: RefObject<MCPToolArgumentsFormRef | null>;
|
||||
chat: UseChatHistoryReturn;
|
||||
codeInterpreter: UseCodeInterpreterReturn;
|
||||
}>;
|
||||
|
||||
const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
const previewFor = (file: File): string | undefined => {
|
||||
const url = URL.createObjectURL(file);
|
||||
return url.startsWith("blob:") ? url : undefined;
|
||||
};
|
||||
|
||||
const missingInput = (deps: SendDeps, text: string, files: readonly DraftFile[]): string | null => {
|
||||
const { settings, capabilities } = deps;
|
||||
if (settings.endpoint === EndpointType.IMAGE_EDITS && files.length === 0)
|
||||
return "Please upload at least one image for editing";
|
||||
if (settings.endpoint === EndpointType.TRANSCRIPTION && files.length === 0)
|
||||
return "Please upload an audio file for transcription";
|
||||
if (capabilities.textRequired && text.trim() === "") return "";
|
||||
if (settings.endpoint === EndpointType.A2A_AGENTS && !settings.agent)
|
||||
return "Please select an agent to send a message";
|
||||
if (MODEL_ENDPOINTS.includes(settings.endpoint) && !settings.model)
|
||||
return "Please select a model before sending a request";
|
||||
return null;
|
||||
};
|
||||
|
||||
const apiMessage = async (endpoint: EndpointType, text: string, file: File | undefined) => {
|
||||
if (file && endpoint === EndpointType.RESPONSES) return createMultimodalMessage(text, file);
|
||||
if (file && endpoint === EndpointType.CHAT) return createChatMultimodalMessage(text, file);
|
||||
return { role: "user", content: text };
|
||||
};
|
||||
|
||||
const targetName = (
|
||||
settings: PlaygroundSettings,
|
||||
direct: Omit<DirectToolCall, "arguments"> | null,
|
||||
): string | undefined => {
|
||||
if (settings.endpoint === EndpointType.A2A_AGENTS) return settings.agent ?? undefined;
|
||||
if (settings.endpoint === EndpointType.MCP) return direct?.tool;
|
||||
return settings.model ?? undefined;
|
||||
};
|
||||
|
||||
const withTrace = (history: MessageType[], from: number, trace: ReplyTrace): MessageType[] => {
|
||||
const replyIndex = history.findLastIndex((message, index) => index > from && message.role === "assistant");
|
||||
if (replyIndex < 0) return history;
|
||||
return history.map((message, index) => (index === replyIndex ? { ...message, trace } : message));
|
||||
};
|
||||
|
||||
const untilAborted = <T extends object>(target: T, signal: AbortSignal): T =>
|
||||
new Proxy(target, {
|
||||
get: (object, key, receiver) => {
|
||||
const value: unknown = Reflect.get(object, key, receiver);
|
||||
return typeof value === "function"
|
||||
? (...args: unknown[]) => (signal.aborted ? undefined : value(...args))
|
||||
: value;
|
||||
},
|
||||
});
|
||||
|
||||
type Draft = Readonly<{ kind: AttachKind | null; files: readonly DraftFile[] }>;
|
||||
|
||||
type PreparedRequest = Readonly<{
|
||||
direct: Omit<DirectToolCall, "arguments"> | null;
|
||||
toolArguments: Record<string, unknown>;
|
||||
userMessage: Awaited<ReturnType<typeof apiMessage>>;
|
||||
}>;
|
||||
|
||||
export function usePlaygroundSend(deps: SendDeps) {
|
||||
const [text, setText] = useState("");
|
||||
const [draft, setDraft] = useState<Draft>({ kind: null, files: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const preparing = useRef(false);
|
||||
const files = draft.kind === deps.capabilities.attach ? draft.files : [];
|
||||
const setFiles = (next: readonly DraftFile[]) => setDraft({ kind: deps.capabilities.attach, files: next });
|
||||
|
||||
const attach = (incoming: readonly File[]) => {
|
||||
if (!deps.capabilities.attach) return;
|
||||
const result = addFiles(deps.capabilities.attach, files, incoming, previewFor);
|
||||
result.errors.forEach((error) => toast.error(error));
|
||||
setFiles(result.files);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const removed = files[index];
|
||||
if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
stop();
|
||||
files.forEach((file) => file.previewUrl && URL.revokeObjectURL(file.previewUrl));
|
||||
setFiles([]);
|
||||
setText("");
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (!abortRef.current) return;
|
||||
stop();
|
||||
toast.info("Request cancelled");
|
||||
};
|
||||
|
||||
const prepare = async (): Promise<PreparedRequest | null> => {
|
||||
const { settings } = deps;
|
||||
const missing = missingInput(deps, text, files);
|
||||
if (missing !== null) {
|
||||
if (missing) toast.fromError(missing);
|
||||
return null;
|
||||
}
|
||||
const direct = settings.endpoint === EndpointType.MCP ? deps.directCall() : null;
|
||||
if (settings.endpoint === EndpointType.MCP && !direct) {
|
||||
toast.fromError("Pick an MCP server and a tool to call");
|
||||
return null;
|
||||
}
|
||||
const toolArguments = await (direct
|
||||
? deps.toolArgsForm.current?.getSubmitValues().catch((error: unknown) => {
|
||||
toast.fromError(error instanceof Error ? error.message : "Please fill in all required parameters");
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve({}));
|
||||
if (toolArguments === null) return null;
|
||||
if (!deps.signedIn) return null;
|
||||
if (!deps.apiKey) {
|
||||
toast.fromError("Please provide a Virtual Key or select Current UI Session");
|
||||
return null;
|
||||
}
|
||||
|
||||
const userMessage = await apiMessage(settings.endpoint, text, files[0]?.file).catch(() => null);
|
||||
if (!userMessage) {
|
||||
toast.fromError("Failed to process the attachment. Please try again.");
|
||||
return null;
|
||||
}
|
||||
return { direct, toolArguments: toolArguments ?? {}, userMessage };
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (preparing.current || abortRef.current) return;
|
||||
preparing.current = true;
|
||||
const prepared = await prepare().finally(() => {
|
||||
preparing.current = false;
|
||||
});
|
||||
if (!prepared || !deps.apiKey) return;
|
||||
const { settings, chat } = deps;
|
||||
const { direct, toolArguments, userMessage } = prepared;
|
||||
|
||||
const traceId = chat.messageTraceId ?? uuidv4();
|
||||
if (!chat.messageTraceId) chat.setMessageTraceId(traceId);
|
||||
const history = chat.chatHistory;
|
||||
const requestedModel = targetName(settings, direct);
|
||||
chat.setChatHistory([
|
||||
...history,
|
||||
{
|
||||
role: "user",
|
||||
content: text,
|
||||
model: requestedModel,
|
||||
...(files.length > 0 ? { attachments: files.map(toAttachment) } : {}),
|
||||
...(direct ? { mcpToolCall: { tool: direct.tool, arguments: toolArguments } } : {}),
|
||||
},
|
||||
]);
|
||||
chat.clearMCPEvents();
|
||||
deps.codeInterpreter.clearResult();
|
||||
const sentFiles = files.map((draft) => draft.file);
|
||||
setText("");
|
||||
setFiles([]);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const live = untilAborted(chat, controller.signal);
|
||||
const captured: { headers?: Headers; responseId?: string } = {};
|
||||
const trace = (): ReplyTrace => ({
|
||||
...(captured.headers ? gatewayTraceFromHeaders(captured.headers) : {}),
|
||||
endpoint: settings.endpoint,
|
||||
requestedModel,
|
||||
responseId: captured.responseId,
|
||||
});
|
||||
|
||||
try {
|
||||
const context: DispatchContext = {
|
||||
settings,
|
||||
apiKey: deps.apiKey,
|
||||
text,
|
||||
userMessage,
|
||||
history,
|
||||
traceId,
|
||||
signal: controller.signal,
|
||||
files: sentFiles,
|
||||
customHeaders: deps.customHeaders,
|
||||
mcpServers: deps.mcpServers,
|
||||
mcpToolsets: deps.mcpToolsets,
|
||||
directCall: direct ? { ...direct, arguments: toolArguments } : null,
|
||||
chat: live,
|
||||
codeInterpreter: untilAborted(deps.codeInterpreter, controller.signal),
|
||||
onHeaders: (headers) => {
|
||||
captured.headers = headers;
|
||||
},
|
||||
onResponseId: (responseId) => {
|
||||
captured.responseId = responseId;
|
||||
live.handleResponseId(responseId);
|
||||
},
|
||||
};
|
||||
await dispatchRequest(context);
|
||||
if (controller.signal.aborted) return;
|
||||
chat.setChatHistory((current) => withTrace(current, history.length, trace()));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
chat.setChatHistory((current) => [
|
||||
...current,
|
||||
{ role: "assistant", content: errorText(error), isError: true, model: requestedModel, trace: trace() },
|
||||
]);
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { text, setText, files, attach, removeFile, clearDraft, isLoading, send, cancel } as const;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
|
||||
import { defaultSettings, loadSettings, storedValues, type PlaygroundSettings } from "./playgroundSettings";
|
||||
|
||||
export type SettingsUpdate = Partial<PlaygroundSettings> | ((current: PlaygroundSettings) => PlaygroundSettings);
|
||||
|
||||
const browserStorage = {
|
||||
read: (key: string) => window.sessionStorage.getItem(key),
|
||||
readSecure: getSecureItem,
|
||||
};
|
||||
|
||||
const persist = (settings: PlaygroundSettings): void => {
|
||||
storedValues(settings).forEach(({ key, value, secure }) => {
|
||||
try {
|
||||
if (value === null) window.sessionStorage.removeItem(key);
|
||||
else if (secure) setSecureItem(key, value);
|
||||
else window.sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function usePlaygroundSettings(keysDisabled: boolean) {
|
||||
const [settings, setSettings] = useState<PlaygroundSettings>(() => loadSettings(browserStorage, keysDisabled));
|
||||
|
||||
useEffect(() => persist(settings), [settings]);
|
||||
|
||||
const update = useCallback(
|
||||
(change: SettingsUpdate) =>
|
||||
setSettings((current) => (typeof change === "function" ? change(current) : { ...current, ...change })),
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => setSettings(defaultSettings(keysDisabled)), [keysDisabled]);
|
||||
|
||||
return { settings, update, reset } as const;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { MessageType } from "@/components/chat_ui/types";
|
||||
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
|
||||
|
|
@ -36,6 +37,7 @@ export async function makeAnthropicMessagesRequest(
|
|||
mcpToolsets?: MCPToolset[],
|
||||
streamingEnabled: boolean = true,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new Error("Virtual Key is required");
|
||||
|
|
@ -55,6 +57,7 @@ export async function makeAnthropicMessagesRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: headers,
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import openai from "openai";
|
||||
import { getGatewayBaseUrl } from "@/components/networking";
|
||||
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
|
|
@ -16,6 +17,7 @@ export async function makeOpenAIAudioSpeechRequest(
|
|||
speed?: number,
|
||||
customBaseUrl?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
// base url should be the current base_url
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
|
|
@ -28,6 +30,7 @@ export async function makeOpenAIAudioSpeechRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import openai from "openai";
|
||||
import { getGatewayBaseUrl } from "@/components/networking";
|
||||
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
|
|
@ -16,6 +17,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
|
|||
temperature?: number,
|
||||
customBaseUrl?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
// base url should be the current base_url
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
|
|
@ -29,6 +31,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import {
|
||||
|
|
@ -14,6 +15,7 @@ export async function makeOpenAIEmbeddingsRequest(
|
|||
tags?: string[],
|
||||
customBaseUrl?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new Error("Virtual Key is required");
|
||||
|
|
@ -43,6 +45,7 @@ export async function makeOpenAIEmbeddingsRequest(
|
|||
input,
|
||||
}),
|
||||
});
|
||||
onResponseHeaders?.(response.headers);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import openai from "openai";
|
||||
import { getGatewayBaseUrl } from "@/components/networking";
|
||||
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
|
|
@ -13,6 +14,7 @@ export async function makeOpenAIImageEditsRequest(
|
|||
signal?: AbortSignal,
|
||||
customBaseUrl?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
// base url should be the current base_url
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
|
|
@ -26,6 +28,7 @@ export async function makeOpenAIImageEditsRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import openai from "openai";
|
||||
import { getGatewayBaseUrl } from "@/components/networking";
|
||||
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
|
||||
|
|
@ -12,6 +13,7 @@ export async function makeOpenAIImageGenerationRequest(
|
|||
signal?: AbortSignal,
|
||||
customBaseUrl?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
// base url should be the current base_url
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
|
|
@ -24,6 +26,7 @@ export async function makeOpenAIImageGenerationRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import {
|
||||
|
|
@ -16,6 +17,7 @@ export async function makeInteractionsRequest(
|
|||
customBaseUrl?: string,
|
||||
previousInteractionId?: string,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
): Promise<void> {
|
||||
if (!accessToken) {
|
||||
throw new Error("Virtual Key is required");
|
||||
|
|
@ -51,6 +53,7 @@ export async function makeInteractionsRequest(
|
|||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
onResponseHeaders?.(response.headers);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ vi.mock("@/utils/proxyUtils", () => ({
|
|||
fetchProxySettings: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/playground/components/chat_ui/ChatUI", () => ({
|
||||
default: () => <div data-testid="chat-ui" />,
|
||||
vi.mock("@/app/(dashboard)/playground/components/chat/PlaygroundChat", () => ({
|
||||
PlaygroundChat: () => <div data-testid="chat-ui" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/playground/components/compareUI/CompareUI", () => ({
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { PlayCircle } from "lucide-react";
|
||||
import AgentBuilderView from "@/app/(dashboard)/playground/components/chat_ui/AgentBuilderView";
|
||||
import ChatUI from "@/app/(dashboard)/playground/components/chat_ui/ChatUI";
|
||||
import { PlaygroundChat, type ProxySettings } from "@/app/(dashboard)/playground/components/chat/PlaygroundChat";
|
||||
import CompareUI from "@/app/(dashboard)/playground/components/compareUI/CompareUI";
|
||||
import ComplianceUI from "@/app/(dashboard)/playground/components/complianceUI/ComplianceUI";
|
||||
import SystemOneUI from "@/app/(dashboard)/playground/components/systemOneUI/SystemOneUI";
|
||||
import BetaBadge from "@/components/BetaBadge";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useUrlTab } from "@/hooks/useUrlTab";
|
||||
import { Page, PageTabs, PageTabsContent, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
|
||||
const PLAYGROUND_TABS = ["chat", "compare", "compliance", "system-one", "agent-builder"] as const;
|
||||
|
||||
interface ProxySettings {
|
||||
PROXY_BASE_URL?: string;
|
||||
LITELLM_UI_API_DOC_BASE_URL?: string | null;
|
||||
}
|
||||
const TAB_TRIGGER = "h-full group-data-horizontal/tabs:after:-bottom-0.5";
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token, isViewOnly } = useAuthorized();
|
||||
|
|
@ -52,53 +51,51 @@ export default function PlaygroundPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
<TabsList variant="line" className="w-full shrink-0 justify-start overflow-x-auto pb-1">
|
||||
<TabsTrigger value="chat" className="flex-none">
|
||||
<Page className="h-full overflow-hidden pb-0 sm:pb-0">
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<PlayCircle />
|
||||
Playground
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Send test requests through the gateway with any model, key, guardrail or tool
|
||||
</PageHeaderDescription>
|
||||
</PageHeader>
|
||||
<PageTabs value={activeTab} onValueChange={setActiveTab} className="gap-0">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="chat" className={TAB_TRIGGER}>
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compare" className="flex-none">
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="compare" className={TAB_TRIGGER}>
|
||||
Compare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compliance" className="flex-none">
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="compliance" className={TAB_TRIGGER}>
|
||||
Compliance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system-one" className="flex-none">
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="system-one" className={TAB_TRIGGER}>
|
||||
<BetaBadge>Decisions</BetaBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent-builder" className="flex-none">
|
||||
Agent Builder (Experimental)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="chat"
|
||||
className="mt-0 h-full min-h-0 min-w-0 overflow-hidden data-hidden:hidden"
|
||||
keepMounted
|
||||
>
|
||||
<ChatUI
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="agent-builder" className={TAB_TRIGGER}>
|
||||
Agent builder
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="chat" className="overflow-hidden data-hidden:hidden" keepMounted>
|
||||
<PlaygroundChat
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userId}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="compare" className="mt-0 h-full data-hidden:hidden" keepMounted>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="compare" className="overflow-y-auto pt-4 data-hidden:hidden" keepMounted>
|
||||
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="compliance" className="mt-0 h-full data-hidden:hidden" keepMounted>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="compliance" className="overflow-y-auto pt-4 data-hidden:hidden" keepMounted>
|
||||
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="system-one" className="mt-0 min-h-0 data-hidden:hidden" keepMounted>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="system-one" className="overflow-y-auto pt-4 data-hidden:hidden" keepMounted>
|
||||
<SystemOneUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agent-builder" className="mt-0 h-full data-hidden:hidden" keepMounted>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="agent-builder" className="overflow-y-auto pt-4 data-hidden:hidden" keepMounted>
|
||||
<AgentBuilderView
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
|
|
@ -108,8 +105,8 @@ export default function PlaygroundPage() {
|
|||
proxySettings={proxySettings}
|
||||
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,3 +493,199 @@
|
|||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@property --scroll-fade-b {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes scroll-fade-reveal-b {
|
||||
from {
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
to {
|
||||
--scroll-fade-b: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-b {
|
||||
--_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
--scroll-fade-mask: linear-gradient(to bottom, #000 0, #000 calc(100% - var(--scroll-fade-b, 0px)), transparent 100%);
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-b 1ms ease-in-out;
|
||||
animation-timeline: scroll(self y);
|
||||
animation-range: calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b);
|
||||
}
|
||||
}
|
||||
|
||||
@property --scroll-fade-s {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes scroll-fade-reveal-s {
|
||||
from {
|
||||
--scroll-fade-s: 0px;
|
||||
}
|
||||
to {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-x {
|
||||
--_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
--_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
--scroll-fade-inline: linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
--scroll-fade-inline: linear-gradient(
|
||||
to left,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-s 1ms ease-in-out,
|
||||
scroll-fade-reveal-e 1ms ease-in-out;
|
||||
animation-timeline: scroll(self inline), scroll(self inline);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s);
|
||||
--scroll-fade-e: var(--_scroll-fade-size-e);
|
||||
}
|
||||
}
|
||||
|
||||
@property --shimmer-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: true;
|
||||
initial-value: 20deg;
|
||||
}
|
||||
@property --shimmer-image {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --shimmer-text-fill {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes tw-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility shimmer {
|
||||
--_spread: var(--shimmer-spread, calc(3ch + 40px));
|
||||
--_base: currentColor;
|
||||
--_highlight: var(--shimmer-color, oklch(from currentColor l c h / calc(alpha * 0.2)));
|
||||
|
||||
background-image: var(
|
||||
--shimmer-image,
|
||||
linear-gradient(
|
||||
calc(90deg + var(--shimmer-angle)),
|
||||
var(--_base) calc(50% - var(--_spread)),
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5),
|
||||
var(--_highlight) 50%,
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5),
|
||||
var(--_base) calc(50% + var(--_spread))
|
||||
)
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(200% + var(--_spread) * 2) 100%;
|
||||
background-position: 0 0;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
|
||||
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||
|
||||
@variant dark {
|
||||
--_highlight: var(--shimmer-color, oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4)));
|
||||
}
|
||||
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@utility shimmer-once {
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@utility shimmer-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@utility shimmer-none {
|
||||
--shimmer-image: none;
|
||||
--shimmer-text-fill: currentColor;
|
||||
}
|
||||
|
||||
@utility shimmer-color-* {
|
||||
--shimmer-color: --value(--color, [color]);
|
||||
--shimmer-color: color-mix(in oklch, --value(--color, [color]) calc(--modifier(integer) * 1%), transparent);
|
||||
}
|
||||
|
||||
@utility shimmer-duration-* {
|
||||
--shimmer-duration: calc(--value(integer) * 1ms);
|
||||
}
|
||||
|
||||
@utility shimmer-spread-* {
|
||||
--shimmer-spread: calc(var(--spacing) * --value(integer));
|
||||
--shimmer-spread: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility shimmer-angle-* {
|
||||
--shimmer-angle: calc(--value(integer) * 1deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TokenUsage } from "./ResponseMetrics";
|
||||
import type { GatewayTrace } from "@/components/llm_calls/reply_trace";
|
||||
|
||||
export interface VectorStoreSearchResult {
|
||||
score: number;
|
||||
|
|
@ -25,6 +26,25 @@ export interface A2ATaskMetadata {
|
|||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ReplyTrace = Partial<GatewayTrace> &
|
||||
Readonly<{
|
||||
endpoint: string;
|
||||
requestedModel?: string;
|
||||
responseId?: string;
|
||||
}>;
|
||||
|
||||
export type MessageAttachment = Readonly<{
|
||||
name: string;
|
||||
size: number;
|
||||
kind: "image" | "pdf" | "audio";
|
||||
previewUrl?: string;
|
||||
}>;
|
||||
|
||||
export type McpToolCallDisplay = Readonly<{
|
||||
tool: string;
|
||||
arguments: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export interface MessageType {
|
||||
role: string;
|
||||
content: string | MultimodalContent[];
|
||||
|
|
@ -44,6 +64,10 @@ export interface MessageType {
|
|||
};
|
||||
searchResults?: VectorStoreSearchResponse[];
|
||||
a2aMetadata?: A2ATaskMetadata;
|
||||
trace?: ReplyTrace;
|
||||
attachments?: readonly MessageAttachment[];
|
||||
mcpToolCall?: McpToolCallDisplay;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface MultimodalContent {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
|
|||
import { parseUsageCost } from "./usage_cost";
|
||||
import { buildPlaygroundHeaders, type CustomHeaders } from "./request_headers";
|
||||
import { createGatewayClient } from "./gateway_client";
|
||||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
|
||||
const completionAsSingleChunk = (completion: ChatCompletion): ChatCompletionChunk =>
|
||||
({
|
||||
|
|
@ -51,6 +52,7 @@ export async function makeOpenAIChatCompletionRequest(
|
|||
mcpToolsets?: MCPToolset[],
|
||||
streamingEnabled: boolean = true,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
// base url should be the current base_url
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
|
|
@ -59,11 +61,13 @@ export async function makeOpenAIChatCompletionRequest(
|
|||
}
|
||||
const headers = buildPlaygroundHeaders(tags, customHeaders);
|
||||
|
||||
const client = createGatewayClient({
|
||||
const clientOptions = {
|
||||
accessToken,
|
||||
baseURL: customBaseUrl,
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
};
|
||||
const client = createGatewayClient(clientOptions);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
|
@ -86,12 +90,13 @@ export async function makeOpenAIChatCompletionRequest(
|
|||
if (selectedMCPServers && selectedMCPServers.length > 0) {
|
||||
if (selectedMCPServers.includes("__all__")) {
|
||||
// All MCP Servers selected
|
||||
tools.push({
|
||||
const allServersTool = {
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: "litellm_proxy/mcp",
|
||||
require_approval: "never",
|
||||
});
|
||||
};
|
||||
tools.push(allServersTool);
|
||||
} else {
|
||||
// Individual servers/toolsets selected - create one entry per item
|
||||
selectedMCPServers.forEach((serverId) => {
|
||||
|
|
@ -99,24 +104,26 @@ export async function makeOpenAIChatCompletionRequest(
|
|||
const toolsetId = serverId.slice("toolset:".length);
|
||||
const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId);
|
||||
const toolsetName = toolset?.toolset_name || toolsetId;
|
||||
tools.push({
|
||||
const toolsetTool = {
|
||||
type: "mcp",
|
||||
server_label: toolsetName,
|
||||
server_url: `litellm_proxy/mcp/${encodeURIComponent(toolsetName)}`,
|
||||
require_approval: "never",
|
||||
});
|
||||
};
|
||||
tools.push(toolsetTool);
|
||||
} else {
|
||||
const server = mcpServers?.find((s) => s.server_id === serverId);
|
||||
const serverName = server?.alias || server?.server_name || serverId;
|
||||
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
|
||||
|
||||
tools.push({
|
||||
const serverTool = {
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: `litellm_proxy/mcp/${serverName}`,
|
||||
require_approval: "never",
|
||||
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
|
||||
});
|
||||
};
|
||||
tools.push(serverTool);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { gatewayTraceFromHeaders } from "./reply_trace";
|
||||
|
||||
describe("gatewayTraceFromHeaders", () => {
|
||||
it("reads routing, timing, cost and guardrail details from the gateway headers", () => {
|
||||
const headers = new Headers([
|
||||
["x-litellm-call-id", "call-1"],
|
||||
["x-litellm-model-group", "fast"],
|
||||
["x-litellm-model-id", "deployment-1"],
|
||||
["x-litellm-model-name", "provider/model-a"],
|
||||
["x-litellm-model-api-base", "https://provider.test/v1"],
|
||||
["x-litellm-attempted-fallbacks", "1"],
|
||||
["x-litellm-attempted-retries", "2"],
|
||||
["x-litellm-overhead-duration-ms", "12.5"],
|
||||
["x-litellm-response-duration-ms", "840"],
|
||||
["x-litellm-response-cost", "0.0004"],
|
||||
["x-litellm-applied-guardrails", "pii-mask, toxicity"],
|
||||
["x-litellm-applied-policies", "strict"],
|
||||
["x-litellm-cache-key", "abc"],
|
||||
]);
|
||||
const trace = gatewayTraceFromHeaders(headers);
|
||||
|
||||
const expected = {
|
||||
callId: "call-1",
|
||||
modelGroup: "fast",
|
||||
deploymentId: "deployment-1",
|
||||
providerModel: "provider/model-a",
|
||||
apiBase: "https://provider.test/v1",
|
||||
attemptedFallbacks: 1,
|
||||
attemptedRetries: 2,
|
||||
overheadMs: 12.5,
|
||||
responseMs: 840,
|
||||
cost: 0.0004,
|
||||
appliedGuardrails: ["pii-mask", "toxicity"],
|
||||
appliedPolicies: ["strict"],
|
||||
cacheHit: true,
|
||||
};
|
||||
expect(trace).toEqual(expected);
|
||||
});
|
||||
|
||||
it("leaves details unset when headers are missing, blank or not numeric", () => {
|
||||
const trace = gatewayTraceFromHeaders(
|
||||
new Headers({
|
||||
"x-litellm-model-group": " ",
|
||||
"x-litellm-attempted-fallbacks": "none",
|
||||
"x-litellm-applied-guardrails": " , ",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(trace.modelGroup).toBeUndefined();
|
||||
expect(trace.attemptedFallbacks).toBeUndefined();
|
||||
expect(trace.appliedGuardrails).toEqual([]);
|
||||
expect(trace.cacheHit).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves policies unknown when the gateway's own headers are hidden from the page", () => {
|
||||
const hidden = gatewayTraceFromHeaders(new Headers({ "x-litellm-applied-guardrails": "pii-mask" }));
|
||||
const visible = gatewayTraceFromHeaders(new Headers({ "x-litellm-call-id": "call-1" }));
|
||||
|
||||
expect(hidden.appliedGuardrails).toEqual(["pii-mask"]);
|
||||
expect(hidden.appliedPolicies).toBeUndefined();
|
||||
expect(visible.appliedPolicies).toEqual([]);
|
||||
});
|
||||
});
|
||||
49
ui/litellm-dashboard/src/components/llm_calls/reply_trace.ts
Normal file
49
ui/litellm-dashboard/src/components/llm_calls/reply_trace.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export type GatewayTrace = Readonly<{
|
||||
callId?: string;
|
||||
modelGroup?: string;
|
||||
deploymentId?: string;
|
||||
providerModel?: string;
|
||||
apiBase?: string;
|
||||
attemptedFallbacks?: number;
|
||||
attemptedRetries?: number;
|
||||
overheadMs?: number;
|
||||
responseMs?: number;
|
||||
cost?: number;
|
||||
appliedGuardrails: readonly string[];
|
||||
appliedPolicies?: readonly string[];
|
||||
cacheHit: boolean;
|
||||
}>;
|
||||
|
||||
const text = (headers: Headers, name: string): string | undefined => {
|
||||
const value = headers.get(name)?.trim();
|
||||
return value ? value : undefined;
|
||||
};
|
||||
|
||||
const number = (headers: Headers, name: string): number | undefined => {
|
||||
const value = text(headers, name);
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const list = (headers: Headers, name: string): readonly string[] =>
|
||||
(text(headers, name) ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
|
||||
export const gatewayTraceFromHeaders = (headers: Headers): GatewayTrace => ({
|
||||
callId: text(headers, "x-litellm-call-id"),
|
||||
modelGroup: text(headers, "x-litellm-model-group"),
|
||||
deploymentId: text(headers, "x-litellm-model-id"),
|
||||
providerModel: text(headers, "x-litellm-model-name"),
|
||||
apiBase: text(headers, "x-litellm-model-api-base"),
|
||||
attemptedFallbacks: number(headers, "x-litellm-attempted-fallbacks"),
|
||||
attemptedRetries: number(headers, "x-litellm-attempted-retries"),
|
||||
overheadMs: number(headers, "x-litellm-overhead-duration-ms"),
|
||||
responseMs: number(headers, "x-litellm-response-duration-ms"),
|
||||
cost: number(headers, "x-litellm-response-cost"),
|
||||
appliedGuardrails: list(headers, "x-litellm-applied-guardrails"),
|
||||
appliedPolicies: headers.has("x-litellm-call-id") ? list(headers, "x-litellm-applied-policies") : undefined,
|
||||
cacheHit: text(headers, "x-litellm-cache-key") !== undefined,
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
|
||||
import openai from "openai";
|
||||
import { MessageType } from "../chat_ui/types";
|
||||
import { TokenUsage } from "../chat_ui/ResponseMetrics";
|
||||
|
|
@ -87,6 +88,7 @@ export async function makeOpenAIResponsesRequest(
|
|||
streamingEnabled: boolean = true,
|
||||
onTotalLatency?: (latency: number) => void,
|
||||
customHeaders?: CustomHeaders,
|
||||
onResponseHeaders?: ResponseHeadersListener,
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new Error("Virtual Key is required");
|
||||
|
|
@ -111,6 +113,7 @@ export async function makeOpenAIResponsesRequest(
|
|||
baseURL: proxyBaseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: headers,
|
||||
...(onResponseHeaders ? { fetch: observeResponseHeaders(onResponseHeaders) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -143,12 +146,13 @@ export async function makeOpenAIResponsesRequest(
|
|||
if (selectedMCPServers && selectedMCPServers.length > 0) {
|
||||
if (selectedMCPServers.includes("__all__")) {
|
||||
// All MCP Servers selected
|
||||
tools.push({
|
||||
const allServersTool = {
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: `${mcpBaseUrl}/mcp`,
|
||||
require_approval: "never",
|
||||
});
|
||||
};
|
||||
tools.push(allServersTool);
|
||||
} else {
|
||||
// Individual servers/toolsets selected - create one entry per item
|
||||
selectedMCPServers.forEach((serverId) => {
|
||||
|
|
@ -157,12 +161,13 @@ export async function makeOpenAIResponsesRequest(
|
|||
const toolsetId = serverId.slice("toolset:".length);
|
||||
const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId);
|
||||
const toolsetName = toolset?.toolset_name || toolsetId;
|
||||
tools.push({
|
||||
const toolsetTool = {
|
||||
type: "mcp",
|
||||
server_label: toolsetName,
|
||||
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(toolsetName)}`,
|
||||
require_approval: "never",
|
||||
});
|
||||
};
|
||||
tools.push(toolsetTool);
|
||||
} else {
|
||||
const server = mcpServers?.find((s) => s.server_id === serverId);
|
||||
// Use server_name for both routing and labelling. server_name is the
|
||||
|
|
@ -170,13 +175,14 @@ export async function makeOpenAIResponsesRequest(
|
|||
const routeName = server?.server_name || serverId;
|
||||
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
|
||||
|
||||
tools.push({
|
||||
const serverTool = {
|
||||
type: "mcp",
|
||||
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
|
||||
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
|
||||
require_approval: "never",
|
||||
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
|
||||
});
|
||||
};
|
||||
tools.push(serverTool);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
59
ui/litellm-dashboard/src/components/ui/accordion.tsx
Normal file
59
ui/litellm-dashboard/src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-md border border-transparent py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
||||
/>
|
||||
<ChevronUpIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
185
ui/litellm-dashboard/src/components/ui/attachment.tsx
Normal file
185
ui/litellm-dashboard/src/components/ui/attachment.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const attachmentVariants = cva(
|
||||
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
|
||||
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
|
||||
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
|
||||
},
|
||||
orientation: {
|
||||
horizontal: "min-w-40 items-center",
|
||||
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Attachment({
|
||||
className,
|
||||
state = "done",
|
||||
size = "default",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof attachmentVariants> & {
|
||||
state?: "idle" | "uploading" | "processing" | "error" | "done";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment"
|
||||
data-state={state}
|
||||
data-size={size}
|
||||
data-orientation={orientation}
|
||||
className={cn(attachmentVariants({ size, orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const attachmentMediaVariants = cva(
|
||||
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
icon: "",
|
||||
image:
|
||||
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "icon",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function AttachmentMedia({
|
||||
className,
|
||||
variant = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-media"
|
||||
data-variant={variant}
|
||||
className={cn(attachmentMediaVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-content"
|
||||
className={cn(
|
||||
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-title"
|
||||
className={cn(
|
||||
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-description"
|
||||
className={cn(
|
||||
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
|
||||
"max-w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-actions"
|
||||
className={cn(
|
||||
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentAction({ className, variant, size = "icon-xs", ...props }: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="attachment-action"
|
||||
variant={variant ?? "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTrigger({ className, render, type, ...props }: useRender.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
type: render ? type : type ?? "button",
|
||||
className: cn("absolute inset-0 z-10 outline-none", className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "attachment-trigger",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-group"
|
||||
className={cn(
|
||||
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Attachment,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentContent,
|
||||
AttachmentTitle,
|
||||
AttachmentDescription,
|
||||
AttachmentActions,
|
||||
AttachmentAction,
|
||||
AttachmentTrigger,
|
||||
};
|
||||
179
ui/litellm-dashboard/src/components/ui/item.tsx
Normal file
179
ui/litellm-dashboard/src/components/ui/item.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return <Separator data-slot="item-separator" orientation="horizontal" className={cn("my-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-md border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-3.5 px-4 py-3.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="item-actions" className={cn("flex items-center gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
20
ui/litellm-dashboard/src/components/ui/kbd.tsx
Normal file
20
ui/litellm-dashboard/src/components/ui/kbd.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <kbd data-slot="kbd-group" className={cn("inline-flex items-center gap-1", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
67
ui/litellm-dashboard/src/components/ui/marker.tsx
Normal file
67
ui/litellm-dashboard/src/components/ui/marker.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const markerVariants = cva(
|
||||
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
separator:
|
||||
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
|
||||
border: "border-b border-border pb-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Marker({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(markerVariants({ variant, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "marker",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-icon"
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-content"
|
||||
className={cn(
|
||||
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Marker, MarkerIcon, MarkerContent, markerVariants };
|
||||
117
ui/litellm-dashboard/src/components/ui/message-scroller.tsx
Normal file
117
ui/litellm-dashboard/src/components/ui/message-scroller.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
MessageScroller as MessageScrollerPrimitive,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
} from "@shadcn/react/message-scroller";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
|
||||
function MessageScrollerProvider(props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>) {
|
||||
return <MessageScrollerPrimitive.Provider {...props} />;
|
||||
}
|
||||
|
||||
function MessageScroller({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Root
|
||||
data-slot="message-scroller"
|
||||
className={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Viewport
|
||||
data-slot="message-scroller-viewport"
|
||||
className={cn(
|
||||
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Content
|
||||
data-slot="message-scroller-content"
|
||||
className={cn("flex h-max min-h-full flex-col gap-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerItem({
|
||||
className,
|
||||
scrollAnchor = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Item
|
||||
data-slot="message-scroller-item"
|
||||
scrollAnchor={scrollAnchor}
|
||||
className={cn("min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerButton({
|
||||
direction = "end",
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
variant = "secondary",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Button
|
||||
data-slot="message-scroller-button"
|
||||
data-direction={direction}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
direction={direction}
|
||||
className={cn(
|
||||
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
render={render ?? <Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<ArrowDownIcon />
|
||||
<span className="sr-only">{direction === "end" ? "Scroll to end" : "Scroll to start"}</span>
|
||||
</>
|
||||
)}
|
||||
</MessageScrollerPrimitive.Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
MessageScrollerProvider,
|
||||
MessageScroller,
|
||||
MessageScrollerViewport,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerButton,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
};
|
||||
74
ui/litellm-dashboard/src/components/ui/message.tsx
Normal file
74
ui/litellm-dashboard/src/components/ui/message.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="message-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function Message({ className, align = "start", ...props }: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-avatar"
|
||||
className={cn(
|
||||
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-content"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-header"
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-footer"
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { MessageGroup, Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader };
|
||||
16
ui/litellm-dashboard/src/components/ui/spinner.tsx
Normal file
16
ui/litellm-dashboard/src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
data-slot="spinner"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
87
ui/litellm-dashboard/src/components/ui/toggle-group.tsx
Normal file
87
ui/litellm-dashboard/src/components/ui/toggle-group.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 2,
|
||||
orientation: "horizontal",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 2,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=0]:data-[variant=outline]:shadow-xs data-vertical:flex-col data-vertical:items-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing, orientation }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:shadow-none focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md data-[state=on]:bg-muted group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
37
ui/litellm-dashboard/src/components/ui/toggle.tsx
Normal file
37
ui/litellm-dashboard/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent shadow-xs hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export type ResponseHeadersListener = (headers: Headers) => void;
|
||||
|
||||
export const observeResponseHeaders =
|
||||
(onHeaders: ResponseHeadersListener): typeof fetch =>
|
||||
async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
onHeaders(response.headers);
|
||||
return response;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue