refactor(frontend): metrics modal (#10968)

This commit is contained in:
Hiep Le
2025-09-17 21:55:25 +07:00
committed by GitHub
parent 02c299d88f
commit ac9badbd20
8 changed files with 207 additions and 131 deletions

View File

@@ -12,7 +12,7 @@ import { SystemMessageModal } from "../conversation-panel/system-message-modal";
import { MicroagentsModal } from "../conversation-panel/microagents-modal";
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
import { MetricsModal } from "./metrics-modal";
import { MetricsModal } from "./metrics-modal/metrics-modal";
export function ConversationName() {
const { t } = useTranslation();

View File

@@ -1,130 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
import { BudgetDisplay } from "../conversation-panel/budget-display";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
interface MetricsModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
const { t } = useTranslation();
const metrics = useSelector((state: RootState) => state.metrics);
return (
<BaseModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
testID="metrics-modal"
>
<div className="space-y-4">
{(metrics?.cost !== null || metrics?.usage !== null) && (
<div className="rounded-md p-3">
<div className="grid gap-3">
{metrics?.cost !== null && (
<div className="flex justify-between items-center pb-2">
<span className="text-lg font-semibold">
{t(I18nKey.CONVERSATION$TOTAL_COST)}
</span>
<span className="font-semibold">
${metrics.cost.toFixed(4)}
</span>
</div>
)}
<BudgetDisplay
cost={metrics?.cost ?? null}
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
/>
{metrics?.usage !== null && (
<>
<div className="flex justify-between items-center pb-2">
<span>{t(I18nKey.CONVERSATION$INPUT)}</span>
<span className="font-semibold">
{metrics.usage.prompt_tokens.toLocaleString()}
</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_HIT)}
</span>
<span className="text-right">
{metrics.usage.cache_read_tokens.toLocaleString()}
</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
</span>
<span className="text-right">
{metrics.usage.cache_write_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span>{t(I18nKey.CONVERSATION$OUTPUT)}</span>
<span className="font-semibold">
{metrics.usage.completion_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$TOTAL)}
</span>
<span className="font-bold">
{(
metrics.usage.prompt_tokens +
metrics.usage.completion_tokens
).toLocaleString()}
</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}
</span>
</div>
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${Math.min(100, (metrics.usage.per_turn_token / metrics.usage.context_window) * 100)}%`,
}}
/>
</div>
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
{metrics.usage.per_turn_token.toLocaleString()} /{" "}
{metrics.usage.context_window.toLocaleString()} (
{(
(metrics.usage.per_turn_token /
metrics.usage.context_window) *
100
).toFixed(2)}
% {t(I18nKey.CONVERSATION$USED)})
</span>
</div>
</div>
</>
)}
</div>
</div>
)}
{!metrics?.cost && !metrics?.usage && (
<div className="rounded-md p-4 text-center">
<p className="text-neutral-400">
{t(I18nKey.CONVERSATION$NO_METRICS)}
</p>
</div>
)}
</div>
</BaseModal>
);
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface ContextWindowSectionProps {
perTurnToken: number;
contextWindow: number;
}
export function ContextWindowSection({
perTurnToken,
contextWindow,
}: ContextWindowSectionProps) {
const { t } = useTranslation();
const usagePercentage = (perTurnToken / contextWindow) * 100;
const progressWidth = Math.min(100, usagePercentage);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}
</span>
</div>
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progressWidth}%` }}
/>
</div>
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
{perTurnToken.toLocaleString()} / {contextWindow.toLocaleString()} (
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { BudgetDisplay } from "../../conversation-panel/budget-display";
import { I18nKey } from "#/i18n/declaration";
interface CostSectionProps {
cost: number | null;
maxBudgetPerTask: number | null;
}
export function CostSection({ cost, maxBudgetPerTask }: CostSectionProps) {
const { t } = useTranslation();
if (cost === null) {
return null;
}
return (
<>
<div className="flex justify-between items-center pb-2">
<span className="text-lg font-semibold">
{t(I18nKey.CONVERSATION$TOTAL_COST)}
</span>
<span className="font-semibold">${cost.toFixed(4)}</span>
</div>
<BudgetDisplay cost={cost} maxBudgetPerTask={maxBudgetPerTask} />
</>
);
}

View File

@@ -0,0 +1,12 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function EmptyState() {
const { t } = useTranslation();
return (
<div className="rounded-md p-4 text-center">
<p className="text-neutral-400">{t(I18nKey.CONVERSATION$NO_METRICS)}</p>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { ReactNode } from "react";
interface MetricRowProps {
label: ReactNode;
value: ReactNode;
labelClassName?: string;
valueClassName?: string;
}
export function MetricRow({
label,
value,
labelClassName = "",
valueClassName = "font-semibold",
}: MetricRowProps) {
return (
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span className={labelClassName}>{label}</span>
<span className={valueClassName}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { CostSection } from "./cost-section";
import { UsageSection } from "./usage-section";
import { ContextWindowSection } from "./context-window-section";
import { EmptyState } from "./empty-state";
interface MetricsModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
const { t } = useTranslation();
const metrics = useSelector((state: RootState) => state.metrics);
return (
<BaseModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
testID="metrics-modal"
>
<div className="space-y-4">
{(metrics?.cost !== null || metrics?.usage !== null) && (
<div className="rounded-md p-3">
<div className="grid gap-3">
<CostSection
cost={metrics?.cost ?? null}
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
/>
{metrics?.usage !== null && (
<>
<UsageSection usage={metrics.usage} />
<ContextWindowSection
perTurnToken={metrics.usage.per_turn_token}
contextWindow={metrics.usage.context_window}
/>
</>
)}
</div>
</div>
)}
{!metrics?.cost && !metrics?.usage && <EmptyState />}
</div>
</BaseModal>
);
}

View File

@@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { MetricRow } from "./metric-row";
interface UsageSectionProps {
usage: {
prompt_tokens: number;
completion_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
};
}
export function UsageSection({ usage }: UsageSectionProps) {
const { t } = useTranslation();
return (
<>
<MetricRow
label={t(I18nKey.CONVERSATION$INPUT)}
value={usage.prompt_tokens.toLocaleString()}
/>
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_HIT)}
</span>
<span className="text-right">
{usage.cache_read_tokens.toLocaleString()}
</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
</span>
<span className="text-right">
{usage.cache_write_tokens.toLocaleString()}
</span>
</div>
<MetricRow
label={t(I18nKey.CONVERSATION$OUTPUT)}
value={usage.completion_tokens.toLocaleString()}
/>
<MetricRow
label={t(I18nKey.CONVERSATION$TOTAL)}
value={(usage.prompt_tokens + usage.completion_tokens).toLocaleString()}
labelClassName="font-semibold"
valueClassName="font-bold"
/>
</>
);
}