mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(ui): bound WebSocket turn rendering and remove quadratic scans
This commit is contained in:
parent
74119b7c8f
commit
44b461fa25
4 changed files with 79 additions and 22 deletions
|
|
@ -1,9 +1,30 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PrettyMessagesView } from "./PrettyMessagesView";
|
||||
|
||||
describe("PrettyMessagesView", () => {
|
||||
it("bounds rendered WebSocket turns and lets operators navigate all pages", () => {
|
||||
const response = Array.from({ length: 101 }, (_, index) => ({
|
||||
type: "response.completed",
|
||||
response: { id: `resp_${index}`, output: [] },
|
||||
}));
|
||||
render(<PrettyMessagesView request={{}} response={response} />);
|
||||
expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50);
|
||||
expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next turns" }));
|
||||
expect(screen.queryByText("Turn 1 · Completed")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next turns" }));
|
||||
expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Next turns" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous turns" }));
|
||||
expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([false, true])("renders separate WebSocket turns (wrapped: %s)", (wrapped) => {
|
||||
const events = [
|
||||
{ type: "response.created", response: { id: "resp_1", output: [] } },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { toast } from "@/lib/toast";
|
|||
import { ResponsesWebSocketTurn } from "./prettyMessagesTypes";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import { SimpleMessageBlock } from "./SimpleMessageBlock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const TURNS_PER_PAGE = 50;
|
||||
|
||||
interface ResponsesWebSocketPrettyViewProps {
|
||||
turns: readonly ResponsesWebSocketTurn[];
|
||||
|
|
@ -16,6 +19,11 @@ export function ResponsesWebSocketPrettyView({
|
|||
outputCost,
|
||||
}: ResponsesWebSocketPrettyViewProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const lastPage = Math.max(0, Math.ceil(turns.length / TURNS_PER_PAGE) - 1);
|
||||
const currentPage = Math.min(page, lastPage);
|
||||
const start = currentPage * TURNS_PER_PAGE;
|
||||
const end = Math.min(start + TURNS_PER_PAGE, turns.length);
|
||||
const copyOutput = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(turns, null, 2));
|
||||
toast.success("Output copied");
|
||||
|
|
@ -32,14 +40,14 @@ export function ResponsesWebSocketPrettyView({
|
|||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y divide-border">
|
||||
{turns.map((turn, index) => (
|
||||
{turns.slice(start, end).map((turn, index) => (
|
||||
<section
|
||||
key={`${turn.id}-${index}`}
|
||||
key={`${turn.id}-${start + index}`}
|
||||
className="px-4 py-3"
|
||||
aria-label={`Turn ${index + 1} · ${turn.status}`}
|
||||
aria-label={`Turn ${start + index + 1} · ${turn.status}`}
|
||||
>
|
||||
<h4 className="mb-2 text-sm font-medium">
|
||||
Turn {index + 1} · {turn.status}
|
||||
Turn {start + index + 1} · {turn.status}
|
||||
</h4>
|
||||
{turn.id && <div className="mb-2 break-all text-xs text-muted-foreground">{turn.id}</div>}
|
||||
{turn.detail && <p className="mb-2 whitespace-pre-wrap text-sm">{turn.detail}</p>}
|
||||
|
|
@ -54,6 +62,24 @@ export function ResponsesWebSocketPrettyView({
|
|||
)}
|
||||
</section>
|
||||
))}
|
||||
{turns.length > TURNS_PER_PAGE && (
|
||||
<nav aria-label="Response turns" className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<Button variant="outline" size="sm" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>
|
||||
Previous turns
|
||||
</Button>
|
||||
<span className="text-sm" aria-live="polite">
|
||||
Turns {start + 1}–{end} of {turns.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === lastPage}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
Next turns
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
|
|||
import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils";
|
||||
|
||||
describe("parseResponsesWebSocketTurns", () => {
|
||||
it("reads event types a bounded number of times for a large session", () => {
|
||||
const reads = { count: 0 };
|
||||
const events = Array.from({ length: 2000 }, (_, index) =>
|
||||
["response.created", "response.completed", "response.completed"].map((type) => ({
|
||||
get type() {
|
||||
reads.count += 1;
|
||||
return type;
|
||||
},
|
||||
response: { id: `resp_${index}`, output: [] },
|
||||
})),
|
||||
).flat();
|
||||
const turns = parseResponsesWebSocketTurns(events);
|
||||
expect(turns).toHaveLength(2000);
|
||||
expect(turns?.[1999].id).toBe("resp_1999");
|
||||
expect(reads.count).toBeLessThan(events.length * 20);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -71,16 +71,17 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc
|
|||
"response.incomplete": "Incomplete",
|
||||
"response.failed": "Failed",
|
||||
};
|
||||
return events.flatMap((event, index): ResponsesWebSocketTurn[] => {
|
||||
const terminalIds = new Set(
|
||||
events.flatMap((event) =>
|
||||
terminalTypes.includes(asString(event.type)) && isRecord(event.response) ? [asString(event.response.id)] : [],
|
||||
),
|
||||
);
|
||||
const seenTerminalIds = new Set<string>();
|
||||
return events.flatMap((event): ResponsesWebSocketTurn[] => {
|
||||
const body = isRecord(event.response) ? event.response : {};
|
||||
const id = asString(body.id);
|
||||
if (event.type === "response.created") {
|
||||
const hasTerminal = events.some(
|
||||
(other) =>
|
||||
terminalTypes.includes(asString(other.type)) &&
|
||||
isRecord(other.response) &&
|
||||
asString(other.response.id) === id,
|
||||
);
|
||||
const hasTerminal = terminalIds.has(id);
|
||||
return hasTerminal ? [] : [{ id, status: "No terminal event recorded", message: null, detail: "" }];
|
||||
}
|
||||
if (event.type === "error") {
|
||||
|
|
@ -88,16 +89,8 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc
|
|||
return [{ id: "", status: "Error", message: null, detail: asString(error.message) || asString(error.code) }];
|
||||
}
|
||||
if (!terminalTypes.includes(asString(event.type))) return [];
|
||||
if (
|
||||
id &&
|
||||
events
|
||||
.slice(0, index)
|
||||
.some(
|
||||
(other) =>
|
||||
terminalTypes.includes(asString(other.type)) && isRecord(other.response) && other.response.id === id,
|
||||
)
|
||||
)
|
||||
return [];
|
||||
if (id && seenTerminalIds.has(id)) return [];
|
||||
if (id) seenTerminalIds.add(id);
|
||||
const error = isRecord(body.error) ? body.error : {};
|
||||
const incomplete = isRecord(body.incomplete_details) ? body.incomplete_details : {};
|
||||
return [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue