feat(webui): unify turn observability

This commit is contained in:
Xubin Ren
2026-08-22 20:51:24 +08:00
parent dbc1801d3c
commit 48eea29313
44 changed files with 1871 additions and 930 deletions
+80 -3
View File
@@ -34,7 +34,11 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
fmtDateTime,
formatCompactTokenCount,
formatMessageEndTime,
} from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
import { sessionHandleColor } from "@/lib/session-handle";
@@ -50,6 +54,7 @@ import type {
UIMessage,
MessageDeliveryErrorKind,
MessageDeliveryStatus,
TurnUsage,
} from "@/lib/types";
interface MessageBubbleProps {
@@ -176,6 +181,71 @@ function MessageCopyButton({ content }: { content: string }) {
);
}
function compactDuration(milliseconds: number): string {
const seconds = milliseconds / 1_000;
if (seconds < 10) return `${seconds.toFixed(1)}s`;
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
}
function TurnUsageMeta({
usage,
latencyMs,
}: {
usage: TurnUsage;
latencyMs?: number;
}) {
const { t } = useTranslation();
const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
const parts: string[] = [];
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`);
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
if (
typeof usage.cached_tokens === "number"
&& typeof prompt === "number"
&& prompt > 0
) {
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`);
}
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
if (parts.length === 0) return null;
const details: string[] = [];
if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
}
const usageMeta = (
<span
data-turn-usage
tabIndex={details.length ? 0 : undefined}
className={cn(
"text-[11px] leading-none text-muted-foreground/70 tabular-nums",
details.length && "cursor-help",
)}
>
{parts.join(" · ")}
</span>
);
if (details.length === 0) return usageMeta;
return (
<Tooltip>
<TooltipTrigger asChild>{usageMeta}</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-96 whitespace-nowrap"
>
{details.join(" · ")}
</TooltipContent>
</Tooltip>
);
}
function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string,
@@ -479,7 +549,9 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming;
const showAssistantFooterRow =
showCopyButton || showForkButton || showAssistantTimestamp || showUsage;
const showAssistantFooterSlot =
message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0);
@@ -545,6 +617,12 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showUsage ? (
<TurnUsageMeta
usage={message.usage!}
latencyMs={message.latencyMs}
/>
) : null}
{showAssistantTimestamp ? (
<MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
@@ -576,7 +654,6 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)}
aria-label={label}
title={text}
>
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">