mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(lens): show a now reading stage that types each trace's reasoning
This commit is contained in:
parent
69b5265a17
commit
3577f3bebf
3 changed files with 132 additions and 0 deletions
|
|
@ -69,6 +69,16 @@
|
|||
--animate-slot-slide-in: slot-slide-in 150ms cubic-bezier(0, 0, 0.2, 1) both;
|
||||
--animate-trace-drawer-in: trace-drawer-in 200ms cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||
--animate-trace-drawer-out: trace-drawer-out 200ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
--animate-lens-shimmer: lens-shimmer 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
|
||||
@keyframes lens-shimmer {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trace-drawer-in {
|
||||
from {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { durationLabel, outcome, shortVerdict } from "../../model/live";
|
||||
import { laneText, typedChars, type Lane } from "../../model/stage";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
|
||||
const RED = "text-[#e5484d]";
|
||||
|
||||
function LaneRow({ lane, now, charMs }: { lane: Lane; now: number; charMs: number }) {
|
||||
const { review } = lane;
|
||||
const text = review ? laneText(review.reasoning) : "";
|
||||
const typed = typedChars(lane, now, charMs);
|
||||
const typing = !!review && typed < text.length;
|
||||
const issue = review ? outcome(review) === "issue" : false;
|
||||
return (
|
||||
<li className="flex flex-col gap-1 rounded-lg bg-background/70 px-2.5 py-2 text-[12px] ring-1 ring-border/60 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-1 motion-safe:duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
!review ? "bg-foreground motion-safe:animate-pulse" : issue && !typing ? "bg-[#e5484d]" : "bg-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate font-medium text-foreground">{lane.agent || "trace"}</span>
|
||||
<span className="truncate font-mono text-[11px] text-muted-foreground">{lane.traceId.slice(0, 8)}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{review ? durationLabel(review.duration_ms) : `reading · ${durationLabel(Math.max(0, now - lane.startedAt))}`}
|
||||
</span>
|
||||
</div>
|
||||
{!review ? (
|
||||
<div aria-hidden="true" className="h-0.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full w-2/5 rounded-full bg-foreground/30 motion-safe:animate-lens-shimmer" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="min-h-[1.25rem] leading-relaxed text-muted-foreground">
|
||||
{text.slice(0, typed)}
|
||||
{typing && <span aria-hidden="true" className="ml-px inline-block h-3 w-px translate-y-0.5 bg-foreground" />}
|
||||
</p>
|
||||
{!typing && (
|
||||
<p className={cn("truncate font-medium motion-safe:animate-in motion-safe:fade-in", issue ? RED : "text-foreground")}>
|
||||
{shortVerdict(review)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function NowReading({
|
||||
model,
|
||||
counter,
|
||||
lanes,
|
||||
now,
|
||||
charMs,
|
||||
}: {
|
||||
model: string;
|
||||
counter: string;
|
||||
lanes: readonly Lane[];
|
||||
now: number;
|
||||
charMs: number;
|
||||
}) {
|
||||
return (
|
||||
<section aria-label="Now reading" className="mb-3 flex flex-col gap-2 rounded-xl bg-muted p-2.5 ring-1 ring-border">
|
||||
<header className="flex items-center justify-between gap-3 px-0.5">
|
||||
<ModelName model={model} size="md" />
|
||||
<span role="status" className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{counter}
|
||||
</span>
|
||||
</header>
|
||||
{lanes.length ? (
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{lanes.map((lane) => (
|
||||
<LaneRow key={lane.key} lane={lane} now={now} charMs={charMs} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div aria-hidden="true" className="h-0.5 overflow-hidden rounded-full bg-background/60">
|
||||
<div className="h-full w-2/5 rounded-full bg-foreground/30 motion-safe:animate-lens-shimmer" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { InFlight } from "../../model/live";
|
||||
import { startStage, stepStage, type Stage } from "../../model/stage";
|
||||
import type { Review } from "../../model/types";
|
||||
|
||||
const TICK_MS = 30;
|
||||
const CHAR_MS = 3;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
export function useStage(
|
||||
reviews: readonly Review[],
|
||||
reading: readonly InFlight[],
|
||||
running: boolean,
|
||||
slots: number,
|
||||
): { stage: Stage; now: number; charMs: number } {
|
||||
const [charMs] = useState(() => (prefersReducedMotion() ? 0 : CHAR_MS));
|
||||
const [stage, setStage] = useState(() => startStage(reviews));
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const next = stepStage(stage, { reading, reviews, now, slots, running, charMs });
|
||||
if (next !== stage) setStage(next);
|
||||
const busy = running || next.lanes.length > 0;
|
||||
useEffect(() => {
|
||||
if (!busy) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), TICK_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [busy]);
|
||||
return { stage: next, now, charMs };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue