Refactor overview widgets to use responsive grid sizing

This commit is contained in:
musi
2026-06-18 09:28:49 +08:00
parent 0f4f7f5114
commit 4ff0c9ebc3
7 changed files with 369 additions and 130 deletions
+48 -11
View File
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
import { CONFIGDIR, CONFIG_FILE, GATEWAY_CONFIG_FILE } from "./constants";
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_WINDOW_MODULE_IDS } from "../shared/app";
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../shared/app";
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "../shared/provider-presets";
import type {
AppConfig,
@@ -528,7 +528,7 @@ function parseOverviewWidget(value: unknown): OverviewWidgetConfig | undefined {
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
id: readString(value.id) || overviewWidgetId(type, metric),
...(metric ? { metric } : {}),
size: parseOverviewWidgetSize(value.size) ?? defaultOverviewWidgetSize(type),
size: parseOverviewWidgetSize(value.size, type) ?? defaultOverviewWidgetSize(type),
type,
variant: parseOverviewWidgetVariant(value.variant) ?? defaultOverviewWidgetVariant(type)
};
@@ -538,8 +538,24 @@ function parseOverviewWidgetType(value: unknown): OverviewWidgetType | undefined
return parseEnumValue(value, ["account-balance", "client-analysis", "metric", "provider-analysis", "system-status", "token-mix", "usage-trend"], undefined);
}
function parseOverviewWidgetSize(value: unknown): OverviewWidgetSize | undefined {
return parseEnumValue(value, ["small", "medium", "large", "wide", "full"], undefined);
function parseOverviewWidgetSize(value: unknown, type: OverviewWidgetType): OverviewWidgetSize | undefined {
const size = parseEnumValue(value, OVERVIEW_WIDGET_SIZE_VALUES, undefined);
if (size) {
return size;
}
if (value === "small") {
return "1:1";
}
if (value === "medium" || value === "large") {
return "2:2";
}
if (value === "wide") {
return "3:2";
}
if (value === "full") {
return type === "system-status" ? "4:1" : "4:2";
}
return undefined;
}
function parseOverviewWidgetVariant(value: unknown): OverviewWidgetVariant | undefined {
@@ -552,18 +568,21 @@ function parseOverviewMetricKind(value: unknown): OverviewMetricKind | undefined
function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWidgetSize {
if (type === "metric") {
return "small";
return "1:1";
}
if (type === "token-mix") {
return "medium";
return "1:2";
}
if (type === "client-analysis" || type === "provider-analysis") {
return "large";
return "2:2";
}
if (type === "usage-trend") {
return "wide";
return "3:2";
}
return "full";
if (type === "system-status") {
return "4:1";
}
return "4:2";
}
function defaultOverviewWidgetVariant(type: OverviewWidgetType): OverviewWidgetVariant {
@@ -609,9 +628,9 @@ function parseTrayWidgets(value: unknown): TrayWidgetConfig[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value
return dedupeTraySingletonWidgets(value
.map(parseTrayWidget)
.filter((widget): widget is TrayWidgetConfig => Boolean(widget));
.filter((widget): widget is TrayWidgetConfig => Boolean(widget)));
}
function parseTrayWidget(value: unknown): TrayWidgetConfig | undefined {
@@ -665,6 +684,24 @@ function trayWidgetId(type: TrayWidgetType): string {
return type;
}
function isTraySingletonWidgetType(type: TrayWidgetType): boolean {
return (TRAY_SINGLETON_WIDGET_TYPES as readonly string[]).includes(type);
}
function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
const seenSingletons = new Set<TrayWidgetType>();
return widgets.filter((widget) => {
if (!isTraySingletonWidgetType(widget.type)) {
return true;
}
if (seenSingletons.has(widget.type)) {
return false;
}
seenSingletons.add(widget.type);
return true;
});
}
function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayComponentVariants): TrayWidgetConfig[] {
return modules
.filter((moduleId): moduleId is TrayWidgetType => moduleId !== "footer")
+104 -69
View File
@@ -203,7 +203,7 @@ export function OverviewView({
>
<SortableContext items={visibleWidgets.map((widget) => widget.id)} strategy={rectSortingStrategy}>
<LayoutGroup>
<section className="grid grid-cols-12 gap-4" data-overview-widget-grid>
<section className="grid auto-rows-[132px] grid-cols-1 gap-4 sm:auto-rows-[140px] sm:grid-cols-2 xl:auto-rows-[148px] xl:grid-cols-4" data-overview-widget-grid>
{visibleWidgets.map((widget) => (
<SortableOverviewWidget editing={editing} key={widget.id} widget={widget} onSelect={() => setSelectedWidgetId(widget.id)}>
<OverviewWidgetFrame
@@ -222,7 +222,7 @@ export function OverviewView({
</SortableOverviewWidget>
))}
{visibleWidgets.length === 0 ? (
<div className="col-span-12 rounded-lg border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-[12px] text-muted-foreground">
<div className="col-span-1 rounded-lg border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-[12px] text-muted-foreground sm:col-span-2 xl:col-span-4">
{t("No widgets configured")}
</div>
) : null}
@@ -442,7 +442,7 @@ function SortableOverviewWidget({
return (
<motion.div
className={cn(
"min-w-0",
"min-h-0 min-w-0",
overviewWidgetSizeClass(widget.size),
editing && "cursor-grab touch-none",
isDragging && "relative z-20 cursor-grabbing opacity-70"
@@ -476,7 +476,7 @@ function OverviewWidgetDragOverlay({
widget: OverviewWidgetConfig;
}) {
return (
<div className={cn("pointer-events-none opacity-95 shadow-2xl", overviewWidgetOverlaySizeClass(widget.size))}>
<div className={cn("pointer-events-none overflow-hidden opacity-95 shadow-2xl", overviewWidgetOverlaySizeClass(widget.size))}>
<OverviewWidgetRenderer
providerAccounts={providerAccounts}
setUsageRange={setUsageRange}
@@ -510,7 +510,7 @@ function OverviewWidgetFrame({
<div
aria-selected={editing ? selected : undefined}
className={cn(
"group/overview-widget relative min-w-0 transition-opacity",
"group/overview-widget relative h-full min-h-0 min-w-0 transition-opacity",
editing && (selected
? "rounded-xl outline outline-2 outline-primary outline-offset-2 ring-2 ring-primary/20"
: "rounded-xl outline outline-2 outline-primary/35 outline-offset-2")
@@ -537,25 +537,24 @@ function OverviewWidgetRenderer({
usageStats: UsageStatsSnapshot;
widget: OverviewWidgetConfig;
}) {
let content: ReactNode;
if (widget.type === "system-status") {
return <SystemStatusBar usageRange={usageRange} usageStats={usageStats} variant={widget.variant === "compact" ? "compact" : "timeline"} />;
content = <SystemStatusBar usageRange={usageRange} usageStats={usageStats} variant={widget.variant === "compact" ? "compact" : "timeline"} />;
} else if (widget.type === "account-balance") {
content = <ProviderAccountsOverview accounts={providerAccounts} variant={overviewAccountVariant(widget.variant)} />;
} else if (widget.type === "metric") {
content = <OverviewMetricWidget metric={widget.metric ?? "requests"} totals={usageStats.totals} variant={overviewMetricVariant(widget.variant)} />;
} else if (widget.type === "usage-trend") {
content = <UsageTrendWidget setUsageRange={setUsageRange} usageRange={usageRange} usageStats={usageStats} variant={overviewTrendVariant(widget.variant)} />;
} else if (widget.type === "token-mix") {
content = <TokenMixOverviewWidget totals={usageStats.totals} variant={overviewTokenMixVariant(widget.variant)} />;
} else if (widget.type === "client-analysis") {
content = <OverviewAnalysisWidget kind="client" rows={usageStats.clientModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
} else {
content = <OverviewAnalysisWidget kind="provider" rows={usageStats.providerModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
}
if (widget.type === "account-balance") {
return <ProviderAccountsOverview accounts={providerAccounts} variant={overviewAccountVariant(widget.variant)} />;
}
if (widget.type === "metric") {
return <OverviewMetricWidget metric={widget.metric ?? "requests"} totals={usageStats.totals} variant={overviewMetricVariant(widget.variant)} />;
}
if (widget.type === "usage-trend") {
return <UsageTrendWidget setUsageRange={setUsageRange} usageRange={usageRange} usageStats={usageStats} variant={overviewTrendVariant(widget.variant)} />;
}
if (widget.type === "token-mix") {
return <TokenMixOverviewWidget totals={usageStats.totals} variant={overviewTokenMixVariant(widget.variant)} />;
}
if (widget.type === "client-analysis") {
return <OverviewAnalysisWidget kind="client" rows={usageStats.clientModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
}
return <OverviewAnalysisWidget kind="provider" rows={usageStats.providerModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
return <div className="h-full min-h-0 min-w-0 overflow-hidden">{content}</div>;
}
function OverviewMetricWidget({
@@ -572,8 +571,8 @@ function OverviewMetricWidget({
if (variant === "compact") {
return (
<Card className="min-w-0">
<CardContent className="flex items-center justify-between gap-3 p-3">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardContent className="flex min-h-0 flex-1 items-center justify-between gap-3 p-3">
<div className="min-w-0 truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
<div className="shrink-0 text-[18px] font-semibold tracking-tight">{item.value}</div>
</CardContent>
@@ -583,8 +582,8 @@ function OverviewMetricWidget({
if (variant === "bar") {
return (
<Card className="min-w-0">
<CardContent className="p-3">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardContent className="min-h-0 flex-1 p-3">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
<div className="shrink-0 text-[18px] font-semibold tracking-tight">{item.value}</div>
@@ -599,8 +598,8 @@ function OverviewMetricWidget({
if (variant === "ring") {
return (
<Card className="min-w-0">
<CardContent className="grid grid-cols-[58px_minmax(0,1fr)] items-center gap-3 p-3">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardContent className="grid min-h-0 flex-1 grid-cols-[58px_minmax(0,1fr)] items-center gap-3 p-3">
<OverviewRingMetric ratio={item.ratio} tone={item.tone} />
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
@@ -652,8 +651,8 @@ function UsageTrendWidget({
const t = useAppText();
return (
<Card className="min-w-0">
<CardHeader className="flex-row items-center justify-between">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardHeader className="shrink-0 flex-row items-center justify-between">
<CardTitle>{t("Usage Trend")}</CardTitle>
<div className="flex rounded-md border border-border bg-background p-0.5">
{usageRangeOptions.map((option) => (
@@ -672,8 +671,8 @@ function UsageTrendWidget({
))}
</div>
</CardHeader>
<CardContent>
<ChartFrame>
<CardContent className="min-h-0 flex-1">
<ChartFrame fill>
{({ height, width }) => (
<ComposedChart data={usageStats.series} height={height} margin={{ bottom: 4, left: 0, right: 8, top: 28 }} width={width}>
<CartesianGrid stroke="#dfe3e8" strokeDasharray="3 3" vertical={false} />
@@ -732,12 +731,12 @@ function TokenMixOverviewWidget({
const total = tokenMix.reduce((sum, item) => sum + item.value, 0);
return (
<Card className="min-w-0">
<CardHeader className="flex-row items-center justify-between">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardHeader className="shrink-0 flex-row items-center justify-between">
<CardTitle>{t("Token Mix")}</CardTitle>
<Badge variant="outline">{formatCompactNumber(totals.totalTokens)}</Badge>
</CardHeader>
<CardContent>
<CardContent className="min-h-0 flex-1 overflow-auto">
{variant === "stacked" ? (
<div className="space-y-3">
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
@@ -749,7 +748,7 @@ function TokenMixOverviewWidget({
</div>
) : null}
{variant === "donut" || variant === "pie" ? (
<ChartFrame>
<ChartFrame fill>
{({ height, width }) => (
<PieChart height={height} width={width}>
<Tooltip content={<TokenTooltip />} />
@@ -772,7 +771,7 @@ function TokenMixOverviewWidget({
</ChartFrame>
) : null}
{variant === "bars" ? (
<ChartFrame>
<ChartFrame fill>
{({ height, width }) => (
<BarChart data={tokenMix} height={height} layout="vertical" margin={{ bottom: 8, left: 8, right: 12, top: 8 }} width={width}>
<CartesianGrid stroke="#dfe3e8" strokeDasharray="3 3" horizontal={false} />
@@ -832,12 +831,12 @@ function OverviewAnalysisWidget({
if (variant === "compact") {
return (
<Card className="min-w-0">
<CardHeader className="flex-row items-center justify-between">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardHeader className="shrink-0 flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<Badge variant="outline">{rows.length}</Badge>
</CardHeader>
<CardContent>
<CardContent className="min-h-0 flex-1 overflow-auto">
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-3 py-7 text-center text-[12px] text-muted-foreground">{emptyLabel}</div>
) : (
@@ -860,12 +859,12 @@ function OverviewAnalysisWidget({
function overviewWidgetTemplates(): OverviewWidgetConfig[] {
return [
{ enabled: true, id: "system-status", size: "full", type: "system-status", variant: "timeline" },
{ enabled: true, id: "account-balance", size: "full", type: "account-balance", variant: "cards" },
{ enabled: true, id: "metric-requests", metric: "requests", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "usage-trend", size: "wide", type: "usage-trend", variant: "composed" },
{ enabled: true, id: "token-mix", size: "medium", type: "token-mix", variant: "bars" },
{ enabled: true, id: "client-analysis", size: "large", type: "client-analysis", variant: "table" }
{ enabled: true, id: "system-status", size: "4:1", type: "system-status", variant: "timeline" },
{ enabled: true, id: "account-balance", size: "4:2", type: "account-balance", variant: "cards" },
{ enabled: true, id: "metric-requests", metric: "requests", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "usage-trend", size: "3:2", type: "usage-trend", variant: "composed" },
{ enabled: true, id: "token-mix", size: "1:2", type: "token-mix", variant: "bars" },
{ enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" }
];
}
@@ -1018,19 +1017,55 @@ function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{ label:
}
function overviewWidgetSizeClass(size: OverviewWidgetSize): string {
if (size === "small") return "col-span-12 sm:col-span-6 xl:col-span-2";
if (size === "medium") return "col-span-12 md:col-span-6 xl:col-span-4";
if (size === "large") return "col-span-12 lg:col-span-6";
if (size === "wide") return "col-span-12 lg:col-span-8";
return "col-span-12";
const { height, width } = overviewWidgetDimensions(size);
return cn(overviewWidgetWidthClass(width), overviewWidgetHeightClass(height));
}
function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string {
if (size === "small") return "w-[min(320px,calc(100vw-2rem))]";
if (size === "medium") return "w-[min(460px,calc(100vw-2rem))]";
if (size === "large") return "w-[min(640px,calc(100vw-2rem))]";
if (size === "wide") return "w-[min(860px,calc(100vw-2rem))]";
return "w-[min(960px,calc(100vw-2rem))]";
const { height, width } = overviewWidgetDimensions(size);
return cn(overviewWidgetOverlayWidthClass(width), overviewWidgetOverlayHeightClass(height));
}
function overviewWidgetDimensions(size: OverviewWidgetSize): { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 } {
const [widthText, heightText] = size.split(":");
const width = overviewWidgetDimensionValue(widthText);
const height = overviewWidgetDimensionValue(heightText);
return { height, width };
}
function overviewWidgetDimensionValue(value: string | undefined): 1 | 2 | 3 | 4 {
if (value === "2") return 2;
if (value === "3") return 3;
if (value === "4") return 4;
return 1;
}
function overviewWidgetWidthClass(width: 1 | 2 | 3 | 4): string {
if (width === 1) return "col-span-1";
if (width === 2) return "col-span-1 sm:col-span-2";
if (width === 3) return "col-span-1 sm:col-span-2 xl:col-span-3";
return "col-span-1 sm:col-span-2 xl:col-span-4";
}
function overviewWidgetHeightClass(height: 1 | 2 | 3 | 4): string {
if (height === 1) return "row-span-1";
if (height === 2) return "row-span-2";
if (height === 3) return "row-span-3";
return "row-span-4";
}
function overviewWidgetOverlayWidthClass(width: 1 | 2 | 3 | 4): string {
if (width === 1) return "w-[min(260px,calc(100vw-2rem))]";
if (width === 2) return "w-[min(536px,calc(100vw-2rem))]";
if (width === 3) return "w-[min(812px,calc(100vw-2rem))]";
return "w-[min(1088px,calc(100vw-2rem))]";
}
function overviewWidgetOverlayHeightClass(height: 1 | 2 | 3 | 4): string {
if (height === 1) return "h-[148px]";
if (height === 2) return "h-[312px]";
if (height === 3) return "h-[476px]";
return "h-[640px]";
}
function sameOverviewWidgetOrder(a: OverviewWidgetConfig[], b: OverviewWidgetConfig[]): boolean {
@@ -1130,8 +1165,8 @@ function SystemStatusBar({
if (variant === "compact") {
return (
<Card className="min-w-0 border-border/70 bg-card">
<CardContent className="flex min-w-0 items-center justify-between gap-3 p-4">
<Card className="flex h-full min-h-0 min-w-0 flex-col border-border/70 bg-card">
<CardContent className="flex min-h-0 min-w-0 flex-1 items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full", systemStatusIconClass(overallTone))}>
<StatusIcon className="h-3.5 w-3.5" />
@@ -1150,8 +1185,8 @@ function SystemStatusBar({
}
return (
<Card className="min-w-0 overflow-visible border-border/70 bg-card">
<CardContent className="space-y-4 p-4">
<Card className="flex h-full min-h-0 min-w-0 flex-col border-border/70 bg-card">
<CardContent className="min-h-0 flex-1 space-y-4 overflow-hidden p-4">
<div className="flex min-w-0 items-center justify-between gap-3">
<h2 className="truncate text-[15px] font-semibold tracking-tight">{t("System status")}</h2>
<div className="flex shrink-0 items-center gap-2 text-[12px] font-medium text-muted-foreground">
@@ -1231,12 +1266,12 @@ function ProviderAccountsOverview({
.slice(0, 6);
return (
<Card className="min-w-0">
<CardHeader className="flex-row items-center justify-between">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardHeader className="shrink-0 flex-row items-center justify-between">
<CardTitle>{t("Account Balance")}</CardTitle>
<Badge variant="outline">{accounts.length}</Badge>
</CardHeader>
<CardContent>
<CardContent className="min-h-0 flex-1 overflow-auto">
{visibleAccounts.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-3 py-7 text-center text-[12px] text-muted-foreground">
{t("No account balance connectors configured")}
@@ -1897,16 +1932,16 @@ function UsageAnalysisCard({
const t = useAppText();
return (
<Card className="min-w-0">
<CardHeader className="flex-row items-center justify-between">
<Card className="flex h-full min-h-0 min-w-0 flex-col">
<CardHeader className="shrink-0 flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<Badge variant="outline">{rows.length}</Badge>
</CardHeader>
<CardContent>
<CardContent className="min-h-0 flex-1 overflow-auto">
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-center text-[12px] text-muted-foreground">{emptyLabel}</div>
) : (
<div className="max-h-[420px] overflow-auto rounded-lg border border-border/60">
<div className="h-full overflow-auto rounded-lg border border-border/60">
<table className="min-w-[840px] w-full border-collapse text-left text-[11px]">
<thead className="sticky top-0 z-10 bg-muted text-muted-foreground">
<tr>
@@ -2037,7 +2072,7 @@ function UsageTooltip({
);
}
function ChartFrame({ children }: { children: (size: { height: number; width: number }) => ReactNode }) {
function ChartFrame({ children, fill = false }: { children: (size: { height: number; width: number }) => ReactNode; fill?: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ height: 0, width: 0 });
@@ -2070,7 +2105,7 @@ function ChartFrame({ children }: { children: (size: { height: number; width: nu
}, []);
return (
<div className="h-[260px] min-w-0" ref={containerRef}>
<div className={cn(fill ? "h-full min-h-[120px]" : "h-[260px]", "min-w-0")} ref={containerRef}>
{size.height > 0 && size.width > 0 ? children(size) : null}
</div>
);
@@ -5,7 +5,7 @@ import {
Input, languageDisplayName, Layers3, Palette,
PanelLeftOpen, Power, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
SettingsPageId, themeDisplayName, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, normalizeTrayWidget, normalizeTrayWidgets, trayWidgetVariantOptions, useMemo, useState,
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useMemo, useState,
X
} from "../shared";
export function AppSettingsDialog({
@@ -252,6 +252,13 @@ function TraySettingsPage({
}
function addTrayWidget(template: TrayWidgetConfig) {
const existingSingleton = isTraySingletonWidgetType(template.type)
? widgets.find((widget) => widget.type === template.type)
: undefined;
if (existingSingleton) {
setSelectedTrayWidgetId(existingSingleton.id);
return;
}
const id = uniqueTrayWidgetId(widgets, template.id);
const widget = normalizeTrayWidget({ ...template, id });
if (!widget) {
@@ -261,23 +268,52 @@ function TraySettingsPage({
setSelectedTrayWidgetId(id);
}
function toggleSingletonTrayWidget(template: TrayWidgetConfig, enabled: boolean) {
const existingWidget = widgets.find((widget) => widget.type === template.type);
if (enabled) {
if (existingWidget) {
setSelectedTrayWidgetId(existingWidget.id);
return;
}
addTrayWidget(template);
return;
}
if (!existingWidget) {
return;
}
removeTrayWidget(existingWidget.id);
}
function updateTrayWidget(id: string, patch: Partial<TrayWidgetConfig>) {
commitWidgets(widgets.map((widget) => widget.id === id ? normalizeTrayWidget({ ...widget, ...patch }) ?? widget : widget));
}
function changeSelectedTrayWidgetType(type: TrayWidgetType) {
if (!selectedWidget) {
return;
}
const existingSingleton = isTraySingletonWidgetType(type)
? widgets.find((widget) => widget.type === type && widget.id !== selectedWidget.id)
: undefined;
if (existingSingleton) {
const nextWidgets = widgets.filter((widget) => widget.id !== selectedWidget.id);
commitWidgets(nextWidgets);
setSelectedTrayWidgetId(existingSingleton.id);
return;
}
updateTrayWidget(selectedWidget.id, { type, variant: defaultTrayWidgetVariant(type) });
}
function changeTrayWidgetCategory(category: TrayComponentCategory) {
if (!selectedWidget) {
return;
}
const type = trayWidgetTypeForCategory(category, selectedWidget.type);
updateTrayWidget(selectedWidget.id, { type, variant: defaultTrayWidgetVariant(type) });
changeSelectedTrayWidgetType(type);
}
function changeTrayWidgetData(type: TrayWidgetType) {
if (!selectedWidget) {
return;
}
updateTrayWidget(selectedWidget.id, { type, variant: defaultTrayWidgetVariant(type) });
changeSelectedTrayWidgetType(type);
}
function changeTrayWidgetVariant(variant: TrayWidgetVariant) {
@@ -302,9 +338,19 @@ function TraySettingsPage({
if (!selectedWidget || selectedWidgetIndex < 0) {
return;
}
const nextWidgets = widgets.filter((widget) => widget.id !== selectedWidget.id);
removeTrayWidget(selectedWidget.id);
}
function removeTrayWidget(id: string) {
const widgetIndex = widgets.findIndex((widget) => widget.id === id);
if (widgetIndex < 0) {
return;
}
const nextWidgets = widgets.filter((widget) => widget.id !== id);
commitWidgets(nextWidgets);
setSelectedTrayWidgetId(nextWidgets[Math.min(selectedWidgetIndex, nextWidgets.length - 1)]?.id);
setSelectedTrayWidgetId((currentId) => currentId === id
? nextWidgets[Math.min(widgetIndex, nextWidgets.length - 1)]?.id
: currentId);
}
return (
@@ -335,8 +381,9 @@ function TraySettingsPage({
<div className="grid grid-cols-1 gap-1.5">
{paletteItems.map((option) => {
const Icon = option.icon;
const enabledSingleton = !option.repeatable && widgets.some((widget) => widget.type === option.template.type);
return (
return option.repeatable ? (
<Button
className={cn(
"flex min-h-[46px] w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] font-medium transition-colors",
@@ -358,6 +405,40 @@ function TraySettingsPage({
+
</span>
</Button>
) : (
<div
className={cn(
"flex min-h-[46px] w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] font-medium transition-colors",
enabledSingleton ? "bg-primary/5 text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
key={option.value}
>
<span className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
enabledSingleton ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}>
<Icon className="h-3.5 w-3.5" />
</span>
<button
className="min-w-0 flex-1 text-left"
onClick={() => {
const existingWidget = widgets.find((widget) => widget.type === option.template.type);
if (existingWidget) {
setSelectedTrayWidgetId(existingWidget.id);
return;
}
toggleSingletonTrayWidget(option.template, true);
}}
type="button"
>
<span className="block truncate">{option.label}</span>
<span className="block truncate text-[10px] font-normal opacity-70">{option.description}</span>
</button>
<Switch
checked={enabledSingleton}
onCheckedChange={(checked) => toggleSingletonTrayWidget(option.template, checked)}
/>
</div>
);
})}
</div>
@@ -458,6 +539,7 @@ type TrayWidgetPaletteItem = {
description: string;
icon: typeof Layers3;
label: string;
repeatable: boolean;
template: TrayWidgetConfig;
value: TrayComponentCategory;
};
@@ -471,6 +553,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: copy.settings.trayModuleSourceTabs,
icon: Layers3,
label: t("Provider component"),
repeatable: false,
template: { id: "source-tabs", type: "source-tabs" },
value: "provider-tabs"
},
@@ -479,6 +562,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: copy.settings.trayModuleHeader,
icon: PanelLeftOpen,
label: t("Header component"),
repeatable: false,
template: { id: "header", type: "header" },
value: "header"
},
@@ -487,6 +571,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: copy.settings.trayModuleAccount,
icon: Database,
label: t("Account component"),
repeatable: true,
template: { id: "account", type: "account", variant: defaultTrayWidgetVariant("account") },
value: "account"
},
@@ -495,6 +580,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: copy.settings.trayModuleTokenFlow,
icon: Activity,
label: t("Trend component"),
repeatable: true,
template: { id: "token-flow", type: "token-flow", variant: defaultTrayWidgetVariant("token-flow") },
value: "trend"
},
@@ -503,6 +589,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: copy.settings.trayModuleStats,
icon: Gauge,
label: t("Metric component"),
repeatable: true,
template: { id: "stats", type: "stats", variant: defaultTrayWidgetVariant("stats") },
value: "metrics"
},
@@ -515,6 +602,7 @@ function trayWidgetPalette(copy: AppCopy): TrayWidgetPaletteItem[] {
description: t("Token mix, rings, model share"),
icon: Boxes,
label: t("Breakdown component"),
repeatable: true,
template: { id: "token-mix", type: "token-mix", variant: defaultTrayWidgetVariant("token-mix") },
value: "breakdown"
}
+54 -21
View File
@@ -122,6 +122,8 @@ import {
DEFAULT_TRAY_COMPONENT_VARIANTS,
DEFAULT_TRAY_WIDGETS,
DEFAULT_TRAY_WINDOW_MODULES,
OVERVIEW_WIDGET_SIZE_VALUES,
TRAY_SINGLETON_WIDGET_TYPES,
TRAY_WINDOW_MODULE_IDS
} from "../../../shared/app";
import type {
@@ -230,7 +232,7 @@ export {
cn, claudeCodeLogoUrl, codexLogoUrl, onboardingMascotSpriteUrl, anthropicProviderIconUrl, bailianProviderIconUrl, deepseekProviderIconUrl,
geminiProviderIconUrl, mistralProviderIconUrl, moonshotProviderIconUrl, openaiProviderIconUrl, openrouterProviderIconUrl, siliconflowProviderIconUrl, zaiGlobalCodingProviderIconUrl,
zaiGlobalGeneralProviderIconUrl, zhipuCnCodingProviderIconUrl, zhipuCnGeneralProviderIconUrl, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, BUILTIN_UNIMCP_PACKAGE,
BUILTIN_UNIMCP_SERVER_NAME, BUILTIN_UNIMCP_VISION_TOOL_NAME, BUILTIN_UNIMCP_WEB_SEARCH_TOOL_NAME, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_WINDOW_MODULE_IDS,
BUILTIN_UNIMCP_SERVER_NAME, BUILTIN_UNIMCP_VISION_TOOL_NAME, BUILTIN_UNIMCP_WEB_SEARCH_TOOL_NAME, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS,
customProviderPresetId, defaultProviderAccountConfig, findProviderPreset, findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey,
providerIdentitySafetyIssue, providerPresets, standardProviderAccountConfig, normalizeProviderBaseUrl, providerUrlWithDefaultScheme
};
@@ -1823,11 +1825,7 @@ export const usageRangeOptions: Array<{ label: string; value: UsageStatsRange }>
];
export const overviewWidgetSizeOptions: Array<{ label: string; value: OverviewWidgetSize }> = [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
{ label: "Wide", value: "wide" },
{ label: "Full", value: "full" }
...OVERVIEW_WIDGET_SIZE_VALUES.map((size) => ({ label: size, value: size }))
];
export const overviewMetricOptions: Array<{ label: string; value: OverviewMetricKind }> = [
@@ -2333,9 +2331,9 @@ export type MetricTone = "amber" | "blue" | "indigo" | "rose" | "slate" | "teal"
export function MetricCard({ label, tone, value }: { label: string; tone: MetricTone; value: string }) {
return (
<Card className="h-full min-w-0 overflow-hidden">
<Card className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div className={cn("h-1", metricToneBar(tone))} />
<CardContent className="flex h-full min-h-[88px] flex-col justify-center">
<CardContent className="flex min-h-[88px] flex-1 flex-col justify-center">
<div className="min-w-0">
<div className="truncate text-[11px] font-medium text-muted-foreground">{label}</div>
<div className="mt-1 truncate text-[20px] font-semibold">{value}</div>
@@ -3966,11 +3964,11 @@ export function normalizeTrayComponentVariants(value: unknown): TrayComponentVar
export function normalizeTrayWidgets(value: unknown, fallbackModules?: unknown, fallbackVariants?: unknown): TrayWidgetConfig[] {
if (!Array.isArray(value)) {
return trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules), normalizeTrayComponentVariants(fallbackVariants));
return dedupeTraySingletonWidgets(trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules), normalizeTrayComponentVariants(fallbackVariants)));
}
return value
return dedupeTraySingletonWidgets(value
.map(normalizeTrayWidget)
.filter((widget): widget is TrayWidgetConfig => Boolean(widget));
.filter((widget): widget is TrayWidgetConfig => Boolean(widget)));
}
export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefined {
@@ -4067,6 +4065,24 @@ export function trayWidgetId(type: TrayWidgetType): string {
return type;
}
export function isTraySingletonWidgetType(type: TrayWidgetType): boolean {
return (TRAY_SINGLETON_WIDGET_TYPES as readonly string[]).includes(type);
}
function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
const seenSingletons = new Set<TrayWidgetType>();
return widgets.filter((widget) => {
if (!isTraySingletonWidgetType(widget.type)) {
return true;
}
if (seenSingletons.has(widget.type)) {
return false;
}
seenSingletons.add(widget.type);
return true;
});
}
export function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayComponentVariants): TrayWidgetConfig[] {
return modules
.filter((moduleId): moduleId is TrayWidgetType => moduleId !== "footer")
@@ -4110,7 +4126,7 @@ export function normalizeOverviewWidget(value: unknown): OverviewWidgetConfig |
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
id: stringValue(value.id) || overviewWidgetId(type, metric),
...(metric ? { metric } : {}),
size: normalizeOverviewWidgetSize(value.size) ?? defaultOverviewWidgetSize(type),
size: normalizeOverviewWidgetSize(value.size, type) ?? defaultOverviewWidgetSize(type),
type,
variant
};
@@ -4122,10 +4138,26 @@ export function normalizeOverviewWidgetType(value: unknown): OverviewWidgetType
: undefined;
}
export function normalizeOverviewWidgetSize(value: unknown): OverviewWidgetSize | undefined {
return typeof value === "string" && ["small", "medium", "large", "wide", "full"].includes(value)
? value as OverviewWidgetSize
: undefined;
export function normalizeOverviewWidgetSize(value: unknown, type: OverviewWidgetType): OverviewWidgetSize | undefined {
if (typeof value !== "string") {
return undefined;
}
if ((OVERVIEW_WIDGET_SIZE_VALUES as readonly string[]).includes(value)) {
return value as OverviewWidgetSize;
}
if (value === "small") {
return "1:1";
}
if (value === "medium" || value === "large") {
return "2:2";
}
if (value === "wide") {
return "3:2";
}
if (value === "full") {
return type === "system-status" ? "4:1" : "4:2";
}
return undefined;
}
export function normalizeOverviewMetricKind(value: unknown): OverviewMetricKind | undefined {
@@ -4186,11 +4218,12 @@ export function normalizeOverviewWidgetVariant(type: OverviewWidgetType, value:
}
export function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWidgetSize {
if (type === "metric") return "small";
if (type === "token-mix") return "medium";
if (type === "client-analysis" || type === "provider-analysis") return "large";
if (type === "usage-trend") return "wide";
return "full";
if (type === "metric") return "1:1";
if (type === "token-mix") return "1:2";
if (type === "client-analysis" || type === "provider-analysis") return "2:2";
if (type === "usage-trend") return "3:2";
if (type === "system-status") return "4:1";
return "4:2";
}
export function defaultOverviewWidgetVariant(type: OverviewWidgetType): OverviewWidgetVariant {
+7
View File
@@ -87,8 +87,15 @@ export function TrayApp() {
const weekTotals = snapshots["7d"].totals;
const monthTotals = snapshots["30d"].totals;
const topModel = snapshots["30d"].models[0];
const hasProviderSwitcher = trayWidgets.some((widget) => widget.type === "source-tabs");
const hasAnyVisibleModule = trayWidgets.length > 0;
useEffect(() => {
if (!hasProviderSwitcher && selectedProvider) {
setSelectedProvider(undefined);
}
}, [hasProviderSwitcher, selectedProvider]);
useEffect(() => {
if (!selectedProvider) {
return;
+26 -7
View File
@@ -4,7 +4,7 @@ import { Power } from "lucide-react";
import trayCyanIconUrl from "../../../../assets/tray-cyan.png";
import trayOrangeIconUrl from "../../../../assets/tray-orange.png";
import trayVioletIconUrl from "../../../../assets/tray-violet.png";
import { DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_WINDOW_MODULE_IDS } from "../../../shared/app";
import { DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../../../shared/app";
import type {
AppConfig,
ProviderAccountMeter,
@@ -23,7 +23,7 @@ import type {
export {
createContext, useCallback, useContext, useEffect, useMemo, useState, createRoot,
Power, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_WINDOW_MODULE_IDS
Power, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS
};
export type {
ReactNode, AppConfig, ProviderAccountMeter, ProviderAccountSnapshot, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow,
@@ -49,6 +49,7 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
"24h": "24 小时",
"7d": "7 天",
"30d": "30 天",
"All": "全部",
"Account": "账户",
"All providers": "全部供应商",
"Avg latency": "平均延迟",
@@ -198,8 +199,8 @@ export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders
return [
{
id: "overview",
label: "Overview"
id: "all",
label: "All"
},
...providerTabs
];
@@ -237,13 +238,13 @@ export function normalizeTrayComponentVariants(value: unknown): TrayComponentVar
export function normalizeTrayWidgets(value: unknown, fallbackModules?: unknown, fallbackVariants?: unknown): TrayWidgetConfig[] {
if (!Array.isArray(value)) {
if (Array.isArray(fallbackModules)) {
return trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules as AppConfig["trayWindowModules"]), normalizeTrayComponentVariants(fallbackVariants));
return dedupeTraySingletonWidgets(trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules as AppConfig["trayWindowModules"]), normalizeTrayComponentVariants(fallbackVariants)));
}
return DEFAULT_TRAY_WIDGETS.map((widget) => ({ ...widget }));
}
return value
return dedupeTraySingletonWidgets(value
.map(normalizeTrayWidget)
.filter((widget): widget is TrayWidgetConfig => Boolean(widget));
.filter((widget): widget is TrayWidgetConfig => Boolean(widget)));
}
export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefined {
@@ -340,6 +341,24 @@ export function trayWidgetId(type: TrayWidgetType): string {
return type;
}
export function isTraySingletonWidgetType(type: TrayWidgetType): boolean {
return (TRAY_SINGLETON_WIDGET_TYPES as readonly string[]).includes(type);
}
function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
const seenSingletons = new Set<TrayWidgetType>();
return widgets.filter((widget) => {
if (!isTraySingletonWidgetType(widget.type)) {
return true;
}
if (seenSingletons.has(widget.type)) {
return false;
}
seenSingletons.add(widget.type);
return true;
});
}
export function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayComponentVariants): TrayWidgetConfig[] {
return modules
.filter((moduleId): moduleId is TrayWidgetType => moduleId !== "footer")
+33 -13
View File
@@ -528,7 +528,26 @@ export type OverviewWidgetType =
| "token-mix"
| "usage-trend";
export type OverviewWidgetSize = "small" | "medium" | "large" | "wide" | "full";
export const OVERVIEW_WIDGET_SIZE_VALUES = [
"1:1",
"2:1",
"3:1",
"4:1",
"1:2",
"2:2",
"3:2",
"4:2",
"1:3",
"2:3",
"3:3",
"4:3",
"1:4",
"2:4",
"3:4",
"4:4"
] as const;
export type OverviewWidgetSize = typeof OVERVIEW_WIDGET_SIZE_VALUES[number];
export type OverviewWidgetVariant =
| "area"
| "bar"
@@ -567,18 +586,18 @@ export type OverviewWidgetConfig = {
};
export const DEFAULT_OVERVIEW_WIDGETS: OverviewWidgetConfig[] = [
{ enabled: true, id: "system-status", size: "full", type: "system-status", variant: "timeline" },
{ enabled: true, id: "account-balance", size: "full", type: "account-balance", variant: "cards" },
{ enabled: true, id: "metric-requests", metric: "requests", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "metric-input-tokens", metric: "input-tokens", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "metric-output-tokens", metric: "output-tokens", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "metric-cache-tokens", metric: "cache-tokens", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "metric-cache-ratio", metric: "cache-ratio", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "metric-estimated-cost", metric: "estimated-cost", size: "small", type: "metric", variant: "card" },
{ enabled: true, id: "usage-trend", size: "wide", type: "usage-trend", variant: "composed" },
{ enabled: true, id: "token-mix", size: "medium", type: "token-mix", variant: "bars" },
{ enabled: true, id: "client-analysis", size: "large", type: "client-analysis", variant: "table" },
{ enabled: true, id: "provider-analysis", size: "large", type: "provider-analysis", variant: "table" }
{ enabled: true, id: "system-status", size: "4:1", type: "system-status", variant: "timeline" },
{ enabled: true, id: "account-balance", size: "4:2", type: "account-balance", variant: "cards" },
{ enabled: true, id: "metric-requests", metric: "requests", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "metric-input-tokens", metric: "input-tokens", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "metric-output-tokens", metric: "output-tokens", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "metric-cache-tokens", metric: "cache-tokens", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "metric-cache-ratio", metric: "cache-ratio", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "metric-estimated-cost", metric: "estimated-cost", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "usage-trend", size: "3:2", type: "usage-trend", variant: "composed" },
{ enabled: true, id: "token-mix", size: "1:2", type: "token-mix", variant: "bars" },
{ enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" },
{ enabled: true, id: "provider-analysis", size: "2:2", type: "provider-analysis", variant: "table" }
];
export const TRAY_WINDOW_MODULE_IDS = [
@@ -595,6 +614,7 @@ export const TRAY_WINDOW_MODULE_IDS = [
export type TrayWindowModuleId = (typeof TRAY_WINDOW_MODULE_IDS)[number];
export type TrayWidgetType = Exclude<TrayWindowModuleId, "footer">;
export const TRAY_SINGLETON_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[];
export type TrayWidgetConfig = {
id: string;