mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(ui): rename the Playground System One tab to Decisions and give the JSON editor VS Code light and dark themes (#45729)
* fix(ui): render the System One JSON editor with the stock One Dark theme Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): rename the Playground System One tab to Decisions and use the VS Code Dark+ JSON theme Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep the JSON editor header and issue list on the light surface Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ui): drop the root block scanner test and the unused band styles Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): follow the dashboard theme in the JSON editor and drop the breakdown accents Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): round the Decisions score pill to two decimals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): strip the Prism block backgrounds so the JSON editor theme swap stops the React style warning Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kerry <kerry@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
15154a40a8
commit
512692dd36
8 changed files with 85 additions and 176 deletions
|
|
@ -1,11 +1,12 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { useId, useMemo, useRef } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useId, useRef } from "react";
|
||||
import { createElement, PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||||
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
|
||||
import { findRootBlocks, ROOT_BLOCK_STYLES, type RootBlock } from "./lib/rootBlocks";
|
||||
import { vs, vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { SystemOnePayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("json", json);
|
||||
|
|
@ -14,16 +15,44 @@ const EDITOR_TEXT = "m-0 whitespace-pre-wrap wrap-anywhere py-3 font-mono text-x
|
|||
const GUTTER_WIDTH = "w-11";
|
||||
type LineRendererProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0];
|
||||
const CONTENT_INSET = "pl-14 pr-3";
|
||||
const CODE_TAG_PROPS = { className: "language-json", style: { whiteSpace: "pre-wrap" } } as const;
|
||||
type SyntaxTheme = NonNullable<SyntaxHighlighterProps["style"]>;
|
||||
const THEME_BLOCKS = ['pre[class*="language-"]', 'code[class*="language-"]'] as const;
|
||||
|
||||
const TOKEN_COLORS = [
|
||||
"[&_.token.property]:text-sky-700 dark:[&_.token.property]:text-sky-300",
|
||||
"[&_.token.string]:text-emerald-700 dark:[&_.token.string]:text-emerald-300",
|
||||
"[&_.token.number]:text-amber-700 dark:[&_.token.number]:text-amber-300",
|
||||
"[&_.token.boolean]:text-violet-700 dark:[&_.token.boolean]:text-violet-300",
|
||||
"[&_.token.null]:text-violet-700 dark:[&_.token.null]:text-violet-300",
|
||||
"[&_.token.punctuation]:text-muted-foreground [&_.token.operator]:text-muted-foreground",
|
||||
].join(" ");
|
||||
function withoutBlockBackground(theme: SyntaxTheme): SyntaxTheme {
|
||||
const stripped = THEME_BLOCKS.map((block) => {
|
||||
const { background: _background, backgroundColor: _backgroundColor, ...rest } = theme[block] ?? {};
|
||||
return [block, rest] as const;
|
||||
});
|
||||
return { ...theme, ...Object.fromEntries(stripped) };
|
||||
}
|
||||
|
||||
const VS_CODE_DARK_PLUS = withoutBlockBackground(vscDarkPlus);
|
||||
const VS_CODE_LIGHT_PLUS = {
|
||||
...withoutBlockBackground(vs),
|
||||
property: { color: "#0451a5" },
|
||||
string: { color: "#a31515" },
|
||||
number: { color: "#098658" },
|
||||
boolean: { color: "#0000ff" },
|
||||
punctuation: { color: "#000000" },
|
||||
operator: { color: "#000000" },
|
||||
} as const;
|
||||
const INHERIT_TEXT = {
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
textShadow: "none",
|
||||
} as const;
|
||||
const CODE_TAG_PROPS = { className: "language-json", style: INHERIT_TEXT } as const;
|
||||
const TRANSPARENT_PRE = {
|
||||
...INHERIT_TEXT,
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: "visible",
|
||||
} as const;
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
|
|
@ -69,39 +98,24 @@ export function IssueList({ id, validation }: { id: string; validation: SystemOn
|
|||
);
|
||||
}
|
||||
|
||||
function renderLines(rootBlocks: readonly RootBlock[]) {
|
||||
return function LineRows({ rows, stylesheet, useInlineStyles }: LineRendererProps) {
|
||||
return rows.map((row, line) => {
|
||||
const lineElement = { node: row, stylesheet, useInlineStyles, key: line };
|
||||
const block = rootBlocks.find(({ startLine, endLine }) => line >= startLine && line <= endLine);
|
||||
return (
|
||||
<div key={line} className="flex">
|
||||
<span className={cn(GUTTER_WIDTH, "shrink-0 select-none pr-3 text-right text-muted-foreground")}>
|
||||
{line + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-h-5 min-w-0 flex-1 px-3",
|
||||
block && [
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-0.5",
|
||||
ROOT_BLOCK_STYLES[block.key].band,
|
||||
line === block.startLine && "rounded-t-sm before:rounded-t-sm",
|
||||
line === block.endLine && "rounded-b-sm before:rounded-b-sm",
|
||||
],
|
||||
)}
|
||||
>
|
||||
{createElement(lineElement)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
function LineRows({ rows, stylesheet, useInlineStyles }: LineRendererProps) {
|
||||
return rows.map((row, line) => {
|
||||
const lineElement = { node: row, stylesheet, useInlineStyles, key: line };
|
||||
return (
|
||||
<div key={line} className="flex">
|
||||
<span className={cn(GUTTER_WIDTH, "shrink-0 select-none pr-3 text-right text-zinc-400 dark:text-zinc-500")}>
|
||||
{line + 1}
|
||||
</span>
|
||||
<span className="min-h-5 min-w-0 flex-1 px-3">{createElement(lineElement)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function JsonEditor({ value, onChange, validation }: JsonEditorProps) {
|
||||
const issuesId = useId();
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const renderer = useMemo(() => renderLines(findRootBlocks(value)), [value]);
|
||||
const syntaxTheme = useTheme().resolvedTheme === "dark" ? VS_CODE_DARK_PLUS : VS_CODE_LIGHT_PLUS;
|
||||
const lineCount = value.split("\n").length;
|
||||
const hasErrors = !validation.isValid;
|
||||
|
||||
|
|
@ -121,20 +135,22 @@ export default function JsonEditor({ value, onChange, validation }: JsonEditorPr
|
|||
{lineCount} {lineCount === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative min-h-80 flex-1">
|
||||
<div aria-hidden="true" className={cn(GUTTER_WIDTH, "absolute inset-y-0 left-0 border-r bg-muted/50")} />
|
||||
<div className="relative min-h-80 flex-1 bg-background dark:bg-[#1e1e1e]">
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
className={cn("absolute inset-0 overflow-hidden", EDITOR_TEXT, TOKEN_COLORS)}
|
||||
>
|
||||
className={cn(
|
||||
GUTTER_WIDTH,
|
||||
"absolute inset-y-0 left-0 border-r bg-muted/50 dark:border-zinc-700 dark:bg-zinc-800/60",
|
||||
)}
|
||||
/>
|
||||
<div ref={highlightRef} aria-hidden="true" className={cn("absolute inset-0 overflow-hidden", EDITOR_TEXT)}>
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={{}}
|
||||
useInlineStyles={false}
|
||||
style={syntaxTheme}
|
||||
customStyle={TRANSPARENT_PRE}
|
||||
PreTag="div"
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
renderer={renderer}
|
||||
renderer={LineRows}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ROOT_BLOCK_STYLES } from "./lib/rootBlocks";
|
||||
import type { PlaygroundQuestion, PlaygroundRequest } from "./lib/schemas";
|
||||
|
||||
function formatState(state: unknown): string {
|
||||
|
|
@ -74,10 +72,7 @@ export default function QuestionBreakdown({ payload }: { payload?: PlaygroundReq
|
|||
<CardDescription>Review the state and criteria that will be sent.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 wrap-anywhere">
|
||||
<section
|
||||
aria-labelledby="system-one-state-heading"
|
||||
className={cn("grid gap-2 border-l-2 pl-3", ROOT_BLOCK_STYLES.state.accent)}
|
||||
>
|
||||
<section aria-labelledby="system-one-state-heading" className="grid gap-2">
|
||||
<h3 id="system-one-state-heading" className="text-sm font-medium">
|
||||
State
|
||||
</h3>
|
||||
|
|
@ -85,7 +80,7 @@ export default function QuestionBreakdown({ payload }: { payload?: PlaygroundReq
|
|||
{formatState(payload.state)}
|
||||
</pre>
|
||||
</section>
|
||||
<div className={cn("grid gap-3 border-l-2 pl-3", ROOT_BLOCK_STYLES.questions.accent)}>
|
||||
<div className="grid gap-3">
|
||||
{Object.entries(payload.questions).map(([id, question]) => (
|
||||
<section key={id} className="grid gap-3 rounded-md border p-3" aria-labelledby={`question-${id}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
|
|
|
|||
|
|
@ -59,6 +59,20 @@ describe("ResponseView", () => {
|
|||
expect(screen.getByRole("meter", { name: "1 probability" })).toHaveAttribute("aria-valuetext", "0%");
|
||||
});
|
||||
|
||||
it("rounds the calibrated score pill to two decimals and leaves integers bare", () => {
|
||||
const precise: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: { type: "score", score: 3.9809950180008897, probabilities: { "3": 0.02, "4": 0.98 } },
|
||||
clarity: { type: "score", score: 2, probabilities: { "2": 1 }, legend: { "2": "Clear enough" } },
|
||||
},
|
||||
};
|
||||
render(<ResponseView response={precise} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText("3.98")).toBeInTheDocument();
|
||||
expect(screen.queryByText("3.9809950180008897")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders structured decision score legends without coercing objects to strings", () => {
|
||||
const response: SystemOneResponse = {
|
||||
answers: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { ChevronDown, LoaderCircle } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
import type { SystemOneAnswer, SystemOneResponse } from "./lib/schemas";
|
||||
|
||||
const SCORE_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
||||
|
||||
interface ResponseViewProps {
|
||||
response?: SystemOneResponse;
|
||||
fallbackModel?: string;
|
||||
|
|
@ -85,7 +87,7 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
|
|||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Score</span>
|
||||
<Badge>{answer.score}</Badge>
|
||||
<Badge>{SCORE_FORMAT.format(answer.score)}</Badge>
|
||||
{answer.confidence !== undefined && (
|
||||
<Badge variant="outline">{Math.round(answer.confidence * 100)}% confidence</Badge>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findRootBlocks } from "./rootBlocks";
|
||||
|
||||
describe("findRootBlocks", () => {
|
||||
it("maps each top-level request key to the lines its value spans", () => {
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
model: "jev-latest",
|
||||
state: { message: "Hi" },
|
||||
questions: { escalate: { type: "noul", instructions: "Escalate?" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(findRootBlocks(text)).toEqual([
|
||||
{ key: "model", startLine: 1, endLine: 1 },
|
||||
{ key: "state", startLine: 2, endLine: 4 },
|
||||
{ key: "questions", startLine: 5, endLine: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores nested keys, unrelated root keys and braces or quotes inside strings", () => {
|
||||
const text = [
|
||||
"{",
|
||||
' "temperature": 0,',
|
||||
' "state": "a } tricky \\" { string",',
|
||||
' "questions": {',
|
||||
' "model": { "state": "nested" }',
|
||||
" }",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
expect(findRootBlocks(text)).toEqual([
|
||||
{ key: "state", startLine: 2, endLine: 2 },
|
||||
{ key: "questions", startLine: 3, endLine: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds every block on a single minified line", () => {
|
||||
expect(findRootBlocks('{"state":"Hi","questions":{"a":{"type":"noul"}}}')).toEqual([
|
||||
{ key: "state", startLine: 0, endLine: 0 },
|
||||
{ key: "questions", startLine: 0, endLine: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps highlighting an unfinished block while the user is typing", () => {
|
||||
expect(findRootBlocks('{\n "state": "Hi",\n "questions": {\n "a": {')).toEqual([
|
||||
{ key: "state", startLine: 1, endLine: 1 },
|
||||
{ key: "questions", startLine: 2, endLine: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
export type RootBlockKey = "model" | "state" | "questions";
|
||||
|
||||
export interface RootBlock {
|
||||
readonly key: RootBlockKey;
|
||||
readonly startLine: number;
|
||||
readonly endLine: number;
|
||||
}
|
||||
|
||||
export const ROOT_BLOCK_STYLES: Readonly<Record<RootBlockKey, { band: string; accent: string }>> = {
|
||||
model: { band: "bg-blue-500/10 before:bg-blue-500/60", accent: "border-l-blue-500/60" },
|
||||
state: { band: "bg-green-500/10 before:bg-green-500/60", accent: "border-l-green-500/60" },
|
||||
questions: { band: "bg-orange-500/10 before:bg-orange-500/60", accent: "border-l-orange-500/60" },
|
||||
};
|
||||
|
||||
interface Token {
|
||||
readonly text: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
interface ScanState {
|
||||
readonly depth: number;
|
||||
readonly previous?: Token;
|
||||
readonly open?: { readonly key: RootBlockKey; readonly startLine: number };
|
||||
readonly blocks: readonly RootBlock[];
|
||||
}
|
||||
|
||||
const JSON_TOKEN = /"(?:[^"\\\n]|\\.)*"?|[{}[\],:]|[^\s{}[\],:"]+/g;
|
||||
|
||||
const isRootBlockKey = (key: string): key is RootBlockKey => Object.hasOwn(ROOT_BLOCK_STYLES, key);
|
||||
|
||||
const tokenize = (text: string): Token[] =>
|
||||
text
|
||||
.split("\n")
|
||||
.flatMap((lineText, line) => Array.from(lineText.matchAll(JSON_TOKEN), ([match]) => ({ text: match, line })));
|
||||
|
||||
const closeOpenBlock = ({ open, previous, blocks }: ScanState): readonly RootBlock[] =>
|
||||
open && previous ? [...blocks, { ...open, endLine: previous.line }] : blocks;
|
||||
|
||||
function step(state: ScanState, token: Token): ScanState {
|
||||
const next = { ...state, previous: token };
|
||||
switch (token.text) {
|
||||
case "{":
|
||||
case "[":
|
||||
return { ...next, depth: state.depth + 1 };
|
||||
case "}":
|
||||
case "]":
|
||||
return state.depth === 1
|
||||
? { ...next, depth: 0, open: undefined, blocks: closeOpenBlock(state) }
|
||||
: { ...next, depth: state.depth - 1 };
|
||||
case ",":
|
||||
return state.depth === 1 ? { ...next, open: undefined, blocks: closeOpenBlock(state) } : next;
|
||||
case ":": {
|
||||
const key = state.previous?.text.slice(1, -1) ?? "";
|
||||
return state.depth === 1 && state.previous?.text.startsWith('"') && isRootBlockKey(key)
|
||||
? { ...next, open: { key, startLine: state.previous.line } }
|
||||
: next;
|
||||
}
|
||||
default:
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export function findRootBlocks(text: string): readonly RootBlock[] {
|
||||
return closeOpenBlock(tokenize(text).reduce(step, { depth: 0, blocks: [] }));
|
||||
}
|
||||
|
|
@ -87,10 +87,10 @@ describe("PlaygroundPage ?tab= deep link", () => {
|
|||
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("activates System One from ?tab=system-one", () => {
|
||||
it("activates Decisions from ?tab=system-one", () => {
|
||||
renderWithProviders(<PlaygroundPage />, { searchParams: { tab: "system-one" } });
|
||||
|
||||
const tab = screen.getByRole("tab", { name: /^System One/ });
|
||||
const tab = screen.getByRole("tab", { name: /^Decisions/ });
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab).toHaveTextContent("Beta");
|
||||
expect(screen.getByTestId("system-one-ui")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default function PlaygroundPage() {
|
|||
Compliance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system-one" className="flex-none">
|
||||
<BetaBadge>System One</BetaBadge>
|
||||
<BetaBadge>Decisions</BetaBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent-builder" className="flex-none">
|
||||
Agent Builder (Experimental)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue