feat(webui): unify turn observability
This commit is contained in:
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user