mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(lens): add an ambient live strip that opens the drawer
This commit is contained in:
parent
8e473ade78
commit
68cc5484d1
4 changed files with 218 additions and 178 deletions
|
|
@ -1,170 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { durationText, money } from "../../model/format";
|
||||
import { modelsUsed } from "../../model/inbox";
|
||||
import {
|
||||
analysisModel,
|
||||
conclusions,
|
||||
isCompact,
|
||||
liveStats,
|
||||
outcome,
|
||||
providerOf,
|
||||
focusedReview,
|
||||
issueCount,
|
||||
reviewKey,
|
||||
shownCount,
|
||||
tickerLine,
|
||||
verdictLine,
|
||||
stripState,
|
||||
} from "../../model/live";
|
||||
import type { Job, Review } from "../../model/types";
|
||||
import { ConclusionsPanel, type Arrival } from "./ConclusionsPanel";
|
||||
import { ReadingPanel } from "./ReadingPanel";
|
||||
import { ReviewQueue } from "./ReviewQueue";
|
||||
import { LiveDrawer } from "./LiveDrawer";
|
||||
import { LiveStrip } from "./LiveStrip";
|
||||
import { useDrawerOpen, useStripOpen } from "./useLivePanels";
|
||||
import { useReviewPlayback } from "./useReviewPlayback";
|
||||
import { useShowWork } from "./useShowWork";
|
||||
|
||||
const COLUMN_HEAD =
|
||||
"sticky top-0 z-sticky flex h-8 shrink-0 items-center justify-between gap-2 border-b border-border bg-muted/40 px-3 text-[10px] tracking-[0.08em] text-muted-foreground uppercase backdrop-blur";
|
||||
const COLUMN = "flex max-h-[320px] min-h-0 flex-col overflow-y-auto";
|
||||
|
||||
function useArrival(review: Review | null, verdict: boolean, enabled: boolean): Arrival | null {
|
||||
const issue = review && verdict && enabled ? review.verdicts.find((v) => v.kind === "issue") : undefined;
|
||||
const key = review && issue ? reviewKey(review) : "";
|
||||
const checkId = issue?.check_id ?? "";
|
||||
const text = review ? verdictLine(review) : "";
|
||||
return useMemo(() => (key ? { key, checkId, text } : null), [key, checkId, text]);
|
||||
}
|
||||
|
||||
function Model({ model }: { model: string }) {
|
||||
if (!model) return null;
|
||||
const provider = providerOf(model);
|
||||
return (
|
||||
<span data-testid="live-run-model" className="inline-flex shrink-0 items-center gap-1.5 text-foreground">
|
||||
{provider && <ProviderLogo provider={provider} className="size-4 shrink-0" />}
|
||||
<span className="font-mono text-[11px]">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Ticker({ review }: { review: Review | null }) {
|
||||
return (
|
||||
<span className="relative h-4 min-w-0 flex-1 overflow-hidden text-muted-foreground">
|
||||
{review && (
|
||||
<span
|
||||
key={reviewKey(review)}
|
||||
data-testid="live-run-ticker"
|
||||
className="absolute inset-0 truncate motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300"
|
||||
>
|
||||
{tickerLine(review)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveRun({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
export function LiveRun({ job, name }: { job: Job; name: string }) {
|
||||
const live = job.status === "queued" || job.status === "running";
|
||||
const [open, toggle] = useShowWork();
|
||||
const [stripOpen, setStripOpen] = useStripOpen();
|
||||
const [drawerOpen, setDrawerOpen] = useDrawerOpen(job.id, live);
|
||||
const [pinned, setPinned] = useState<string | null>(null);
|
||||
const playback = useReviewPlayback(job.reviews, live);
|
||||
const { current, played, phase, duration, replay } = playback;
|
||||
const now = useNow(1000);
|
||||
const verdictRef = useRef<HTMLDivElement>(null);
|
||||
const model = analysisModel([...job.reviews.map((r) => r.model), ...modelsUsed(job.steps), job.settings.model]);
|
||||
const decided = phase.verdict && current ? [...played, current] : played;
|
||||
const groups = conclusions(decided, job.settings.checks);
|
||||
const arrival = useArrival(current, phase.verdict, open && !isCompact(duration));
|
||||
const stats = liveStats(job, now);
|
||||
const { review: focused, following } = focusedReview(job.reviews, pinned, playback.current);
|
||||
const issues = issueCount(job);
|
||||
const reviewed = live ? shownCount(job.reviewed, playback) : job.reviewed;
|
||||
const selected = job.coverage.selected;
|
||||
const percent = live ? Math.min(100, Math.round((reviewed / Math.max(1, selected)) * 100)) : 100;
|
||||
const issues = (live ? decided : job.reviews).filter((r) => outcome(r) === "issue").length;
|
||||
const showWork = () => {
|
||||
if (!open && !live) replay();
|
||||
toggle();
|
||||
const open = (review?: Review) => {
|
||||
setPinned(review && review !== playback.current ? reviewKey(review) : null);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
const issueText = `${issues} ${issues === 1 ? "issue" : "issues"}`;
|
||||
|
||||
return (
|
||||
<section aria-label="Analysis progress" className="overflow-hidden rounded-md border bg-card">
|
||||
<div className="flex h-9 items-center gap-3 px-3 text-[12px]">
|
||||
{live ? (
|
||||
<>
|
||||
<Loader2 aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground motion-safe:animate-spin" />
|
||||
<Model model={model} />
|
||||
<Ticker review={current} />
|
||||
<span role="status" className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{reviewed} of {selected} traces · {issueText} · {money(stats.cost)} · {durationText(stats.elapsedSeconds)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span role="status" className="flex min-w-0 flex-1 items-center gap-1.5 text-muted-foreground">
|
||||
<span className="shrink-0">Reviewed {reviewed} traces with</span>
|
||||
<Model model={model} />
|
||||
<span className="truncate tabular-nums">
|
||||
in {durationText(stats.elapsedSeconds)} · {issueText} · {money(stats.cost)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{live && onCancel && (
|
||||
<Button variant="ghost" size="xs" className="shrink-0" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-expanded={open}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={showWork}
|
||||
>
|
||||
{open ? "Hide work" : "Show work"}
|
||||
<ChevronDown className={cn("transition-transform duration-150", open && "rotate-180")} />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Traces reviewed"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={`${reviewed} of ${selected} traces reviewed`}
|
||||
className="h-px bg-foreground/10"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-foreground/60 transition-[width] duration-300 motion-reduce:transition-none"
|
||||
style={{ width: `${percent}%` }}
|
||||
<>
|
||||
{stripOpen && (
|
||||
<LiveStrip
|
||||
model={model}
|
||||
state={stripState(job, model)}
|
||||
playback={playback}
|
||||
reviewed={reviewed}
|
||||
selected={job.coverage.selected}
|
||||
issues={issues}
|
||||
cost={job.cost}
|
||||
onOpen={open}
|
||||
onClose={() => setStripOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] xl:grid-cols-[16rem_minmax(0,1fr)_20rem]">
|
||||
<div className={`${COLUMN} border-b md:border-r md:border-b-0`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>Traces</span>
|
||||
<span className="font-mono tracking-normal normal-case">{reviewed}</span>
|
||||
</div>
|
||||
<ReviewQueue playback={playback} live={live} />
|
||||
</div>
|
||||
<div className={`${COLUMN} border-b xl:border-r xl:border-b-0`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>{live ? "Reading trace" : "Last trace"}</span>
|
||||
</div>
|
||||
<ReadingPanel review={current} phase={phase} verdictRef={verdictRef} />
|
||||
</div>
|
||||
<div className={`${COLUMN} md:col-span-2 xl:col-span-1`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>Conclusions</span>
|
||||
<span className="font-mono tracking-normal normal-case">{issueText}</span>
|
||||
</div>
|
||||
<ConclusionsPanel groups={groups} reviewed={decided.length} arrival={arrival} verdictRef={verdictRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{!stripOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStripOpen(true)}
|
||||
className="self-start text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Show live trace results
|
||||
</button>
|
||||
)}
|
||||
<LiveDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
name={name}
|
||||
model={model}
|
||||
job={job}
|
||||
playback={playback}
|
||||
live={live}
|
||||
focused={focused}
|
||||
following={following}
|
||||
phase={playback.phase}
|
||||
groups={conclusions(job.reviews, job.settings.checks)}
|
||||
scope={issues.scope === "findings" ? `from the last ${job.reviews.length} reviews` : issues.scope}
|
||||
onPick={(review) => setPinned(review === playback.current ? null : reviewKey(review))}
|
||||
onFollow={() => setPinned(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { PanelRight, X } from "lucide-react";
|
||||
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { money } from "../../model/format";
|
||||
import { outcome, providerOf, queueRows, reviewKey, shortVerdict, type IssueCount, type StripState } from "../../model/live";
|
||||
import type { Playback } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
|
||||
const RECENT = 5;
|
||||
const MARK = { issue: "●", clear: "✓", unknown: "–" } as const;
|
||||
|
||||
export function ModelName({ model }: { model: string }) {
|
||||
if (!model) return null;
|
||||
const provider = providerOf(model);
|
||||
return (
|
||||
<span data-testid="live-model" className="inline-flex shrink-0 items-center gap-1.5 text-foreground">
|
||||
{provider && <ProviderLogo provider={provider} className="size-4 shrink-0" />}
|
||||
<span className="font-mono text-[11px]">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentLine({ review, now, onOpen }: { review: Review; now: number; onOpen: (review: Review) => void }) {
|
||||
const result = outcome(review);
|
||||
const issue = result === "issue";
|
||||
return (
|
||||
<li className="motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(review)}
|
||||
className="grid w-full grid-cols-[0.75rem_minmax(0,9rem)_minmax(0,1fr)_auto] items-center gap-2 rounded px-1.5 py-0.5 text-left hover:bg-muted"
|
||||
>
|
||||
<span aria-label={result} className={issue ? "text-[#e5484d]" : ""}>
|
||||
{MARK[result]}
|
||||
</span>
|
||||
<span className="truncate">{review.agent || review.name}</span>
|
||||
<span className={cn("truncate", issue && "text-[#e5484d]")}>{shortVerdict(review)}</span>
|
||||
<span className="tabular-nums">{agoLabel(Date.parse(review.at), now)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function issueLabel({ count, scope }: IssueCount): string {
|
||||
const noun = count === 1 ? "issue" : "issues";
|
||||
if (scope === "findings") return `${count} ${noun} found`;
|
||||
return scope ? `${count} ${noun} ${scope}` : `${count} ${noun}`;
|
||||
}
|
||||
|
||||
export function LiveStrip({
|
||||
model,
|
||||
state,
|
||||
playback,
|
||||
reviewed,
|
||||
selected,
|
||||
issues,
|
||||
cost,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
model: string;
|
||||
state: StripState;
|
||||
playback: Pick<Playback, "played" | "current">;
|
||||
reviewed: number;
|
||||
selected: number;
|
||||
issues: IssueCount;
|
||||
cost: number;
|
||||
onOpen: (review?: Review) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const now = useNow(5000);
|
||||
const recent = queueRows(playback, RECENT);
|
||||
return (
|
||||
<section
|
||||
aria-label="Live trace results"
|
||||
className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground"
|
||||
>
|
||||
<div className="flex min-w-[16rem] flex-col gap-1">
|
||||
<ModelName model={model} />
|
||||
<span className="tabular-nums">
|
||||
{reviewed} of {selected} traces · {issueLabel(issues)} · {money(cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{state.kind === "failed" && (
|
||||
<p role="alert" className="py-0.5 text-[#e5484d]">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "waiting" && (
|
||||
<p role="status" className="py-0.5">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
{recent.length > 0 && state.kind !== "waiting" && (
|
||||
<ol aria-label="Recently reviewed traces" className="flex flex-col">
|
||||
{recent.map((review) => (
|
||||
<RecentLine key={reviewKey(review)} review={review} now={now} onOpen={onOpen} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
<Button variant="ghost" size="xs" onClick={() => onOpen()} disabled={!recent.length}>
|
||||
<PanelRight />
|
||||
View live
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Hide live trace results" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const STRIP_KEY = "litellm.lens.liveStrip";
|
||||
const DRAWER_KEY = "litellm.lens.liveDrawerClosed";
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useStripOpen(): [boolean, (open: boolean) => void] {
|
||||
const [open, setOpen] = useState(() => read(STRIP_KEY) !== "closed");
|
||||
const update = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
write(STRIP_KEY, next ? "open" : "closed");
|
||||
}, []);
|
||||
return [open, update];
|
||||
}
|
||||
|
||||
export function useDrawerOpen(jobId: string, live: boolean): [boolean, (open: boolean) => void] {
|
||||
const [open, setOpen] = useState(() => live && read(DRAWER_KEY) !== jobId);
|
||||
const update = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) write(DRAWER_KEY, jobId);
|
||||
},
|
||||
[jobId],
|
||||
);
|
||||
return [open, update];
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const KEY = "litellm.lens.showWork";
|
||||
|
||||
function readStored(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function store(open: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, open ? "1" : "0");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useShowWork(): [boolean, () => void] {
|
||||
const [open, setOpen] = useState(readStored);
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((current) => {
|
||||
store(!current);
|
||||
return !current;
|
||||
});
|
||||
}, []);
|
||||
return [open, toggle];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue