- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
9.8 KiB
TypeScript
201 lines
9.8 KiB
TypeScript
import { Icon } from "./Icon";
|
||
import { useEffect } from "react";
|
||
import { useFetch, useModels } from "../hooks";
|
||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
|
||
import { useStore } from "../store";
|
||
import type { Aggregate, Session } from "../types";
|
||
|
||
interface SessionStats {
|
||
total: Aggregate;
|
||
by_model: (Aggregate & { model: string })[];
|
||
by_agent: (Aggregate & { key: string })[];
|
||
first: number | null;
|
||
last: number | null;
|
||
currency: string;
|
||
}
|
||
|
||
interface BudgetRow {
|
||
provider: string;
|
||
budget: number | null;
|
||
spent: number;
|
||
share: number | null;
|
||
warning: boolean;
|
||
}
|
||
|
||
function Row({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) {
|
||
return (
|
||
<div className="kv" title={hint}>
|
||
<span>{label}</span>
|
||
<b>{value}</b>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const perMillion = (a: Aggregate) => {
|
||
const tokens = a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens;
|
||
return tokens > 0 ? (a.cost / tokens) * 1_000_000 : 0;
|
||
};
|
||
|
||
/** Everything about the models used in a session: configuration, usage, speed, cost. */
|
||
export function UsageOverlay({ session, onClose }: { session: Session; onClose: () => void }) {
|
||
const t = useT();
|
||
const { data } = useFetch<SessionStats>(`/sessions/${session.id}/stats`);
|
||
const models = useModels();
|
||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||
const budget = useFetch<{ providers: BudgetRow[]; currency: string }>(isAdmin ? "/budget" : null).data;
|
||
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||
window.addEventListener("keydown", h);
|
||
return () => window.removeEventListener("keydown", h);
|
||
}, [onClose]);
|
||
|
||
const cur = data?.currency ?? "USD";
|
||
const info = (spec: string) => {
|
||
const [p, ...rest] = spec.split("/");
|
||
return models.find((x) => x.provider === p)?.models.find((m) => m.id === rest.join("/"));
|
||
};
|
||
const u = session.last_usage;
|
||
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
|
||
|
||
return (
|
||
<div className="modal-backdrop" onClick={onClose}>
|
||
<div className="modal wide" role="dialog" aria-label={t("usage.title")} onClick={(e) => e.stopPropagation()}>
|
||
<div className="row" style={{ marginBottom: 8 }}>
|
||
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
|
||
<span className="spacer" />
|
||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||
<Icon name="close" />
|
||
</button>
|
||
</div>
|
||
{!data ? (
|
||
<div className="empty">{t("common.loading")}</div>
|
||
) : (
|
||
<>
|
||
<div className="kpis">
|
||
<div className="kpi">
|
||
<div className="k-label">{t("stats.cost")}</div>
|
||
<div className="k-value">{fmtCost(data.total.cost, cur)}</div>
|
||
<div className="k-sub">{t("stats.perRequest", { v: fmtCost(data.total.requests ? data.total.cost / data.total.requests : 0, cur) })}</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="k-label">{t("stats.requests")}</div>
|
||
<div className="k-value">{fmtNumber(data.total.requests)}</div>
|
||
{data.total.errors > 0 && <div className="k-sub">{t("stats.errors", { n: data.total.errors })}</div>}
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="k-label">{t("stats.tokens")}</div>
|
||
<div className="k-value">
|
||
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" /> {fmtCompact(data.total.output_tokens)}<Icon name="arrow-down" className="tiny" />
|
||
</div>
|
||
<div className="k-sub">
|
||
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
|
||
</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="k-label">{t("usage.period")}</div>
|
||
<div className="k-value" style={{ fontSize: 15 }}>
|
||
{data.first ? fmtDate(data.first) : "–"}
|
||
</div>
|
||
<div className="k-sub">{data.last ? <><Icon name="chevron-right" className="tiny" /> {fmtDate(data.last)}</> : ""}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{data.by_model.map((m) => {
|
||
const mi = info(m.model);
|
||
const provider = m.model.split("/")[0];
|
||
const b = budget?.providers.find((x) => x.provider === provider);
|
||
const isCurrent = m.model === session.model;
|
||
return (
|
||
<div className="panel" key={m.model}>
|
||
<h3>
|
||
{mi?.name ?? m.model}
|
||
{isCurrent && <span className="chip">{t("usage.current")}</span>}
|
||
<span className="chip">{provider}</span>
|
||
</h3>
|
||
<div className="kv-grid">
|
||
<div>
|
||
<div className="kv-title">{t("usage.model")}</div>
|
||
<Row label="ID" value={<code>{m.model}</code>} />
|
||
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
|
||
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
|
||
<Row label={t("settings.reasoning")} value={mi?.reasoning ? <><Icon name="check" />{mi.effort ? ` (${mi.effort})` : ""}</> : "–"} />
|
||
<Row label={t("settings.vision")} value={mi?.vision ? <Icon name="check" /> : "–"} />
|
||
{isCurrent && mi?.context ? (
|
||
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<div className="kv-title">{t("usage.prices")}</div>
|
||
<Row label={t("settings.priceIn")} value={mi?.input_price != null ? fmtCost(mi.input_price, cur) : "–"} />
|
||
<Row label={t("settings.priceOut")} value={mi?.output_price != null ? fmtCost(mi.output_price, cur) : "–"} />
|
||
<Row label={t("settings.priceCacheRead")} value={mi?.cache_read_price != null ? fmtCost(mi.cache_read_price, cur) : "–"} />
|
||
<Row label={t("settings.priceCacheWrite")} value={mi?.cache_write_price != null ? fmtCost(mi.cache_write_price, cur) : "–"} />
|
||
<Row label={t("stats.perMillion")} value={fmtCost(perMillion(m), cur)} hint={t("stats.perMillionHint")} />
|
||
</div>
|
||
<div>
|
||
<div className="kv-title">{t("usage.usage")}</div>
|
||
<Row label={t("stats.requests")} value={fmtNumber(m.requests)} />
|
||
<Row label={t("stats.input")} value={fmtNumber(m.input_tokens)} />
|
||
<Row label={t("stats.cacheRead")} value={fmtNumber(m.cache_read_tokens)} />
|
||
<Row label={t("usage.cacheWrite")} value={fmtNumber(m.cache_write_tokens)} />
|
||
<Row label={t("stats.output")} value={fmtNumber(m.output_tokens)} />
|
||
<Row label={t("stats.cacheHit")} value={fmtPercent(m.cache_hit_rate)} />
|
||
</div>
|
||
<div>
|
||
<div className="kv-title">{t("usage.speed")}</div>
|
||
<Row label={t("stats.ttft")} value={`${fmtNumber(m.avg_ttft_ms)} ms`} />
|
||
<Row label={t("stats.prefillSpeed")} value={`${fmtNumber(m.prefill_tps)} tok/s`} />
|
||
<Row label={t("stats.outputSpeed")} value={`${fmtNumber(m.output_tps, 1)} tok/s`} />
|
||
<Row label={t("usage.totalTime")} value={`${fmtNumber(m.duration_ms / 1000, 1)} s`} />
|
||
<div className="kv-title" style={{ marginTop: 8 }}>{t("stats.cost")}</div>
|
||
<Row label={t("usage.inSession")} value={fmtCost(m.cost, cur)} />
|
||
{b && (
|
||
<Row
|
||
label={t("usage.providerMonth")}
|
||
value={
|
||
<span style={{ color: b.warning ? "var(--accent2)" : undefined }}>
|
||
{fmtCost(b.spent, budget!.currency)}
|
||
{b.budget ? ` / ${fmtCost(b.budget, budget!.currency)}` : ""}
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{data.by_agent.length > 1 && (
|
||
<div className="panel">
|
||
<h3>{t("usage.byAgent")}</h3>
|
||
<table className="grid">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("stats.agent")}</th>
|
||
<th className="num">{t("stats.requests")}</th>
|
||
<th className="num">{t("stats.tokens")}</th>
|
||
<th className="num">{t("stats.cost")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.by_agent.map((a) => (
|
||
<tr key={a.key}>
|
||
<td>{a.key}</td>
|
||
<td className="num">{fmtNumber(a.requests)}</td>
|
||
<td className="num">{fmtCompact(a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens)}</td>
|
||
<td className="num">{fmtCost(a.cost, cur)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|