diff --git a/src/main/config.ts b/src/main/config.ts index 681681ef..cbdc8694 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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(); + 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") diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index 860ed107..990f6051 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -203,7 +203,7 @@ export function OverviewView({ > widget.id)} strategy={rectSortingStrategy}> -
+
{visibleWidgets.map((widget) => ( setSelectedWidgetId(widget.id)}> ))} {visibleWidgets.length === 0 ? ( -
+
{t("No widgets configured")}
) : null} @@ -442,7 +442,7 @@ function SortableOverviewWidget({ return ( +
; + content = ; + } else if (widget.type === "account-balance") { + content = ; + } else if (widget.type === "metric") { + content = ; + } else if (widget.type === "usage-trend") { + content = ; + } else if (widget.type === "token-mix") { + content = ; + } else if (widget.type === "client-analysis") { + content = ; + } else { + content = ; } - if (widget.type === "account-balance") { - return ; - } - if (widget.type === "metric") { - return ; - } - if (widget.type === "usage-trend") { - return ; - } - if (widget.type === "token-mix") { - return ; - } - if (widget.type === "client-analysis") { - return ; - } - return ; + + return
{content}
; } function OverviewMetricWidget({ @@ -572,8 +571,8 @@ function OverviewMetricWidget({ if (variant === "compact") { return ( - - + +
{item.label}
{item.value}
@@ -583,8 +582,8 @@ function OverviewMetricWidget({ if (variant === "bar") { return ( - - + +
{item.label}
{item.value}
@@ -599,8 +598,8 @@ function OverviewMetricWidget({ if (variant === "ring") { return ( - - + +
{item.label}
@@ -652,8 +651,8 @@ function UsageTrendWidget({ const t = useAppText(); return ( - - + + {t("Usage Trend")}
{usageRangeOptions.map((option) => ( @@ -672,8 +671,8 @@ function UsageTrendWidget({ ))}
- - + + {({ height, width }) => ( @@ -732,12 +731,12 @@ function TokenMixOverviewWidget({ const total = tokenMix.reduce((sum, item) => sum + item.value, 0); return ( - - + + {t("Token Mix")} {formatCompactNumber(totals.totalTokens)} - + {variant === "stacked" ? (
@@ -749,7 +748,7 @@ function TokenMixOverviewWidget({
) : null} {variant === "donut" || variant === "pie" ? ( - + {({ height, width }) => ( } /> @@ -772,7 +771,7 @@ function TokenMixOverviewWidget({ ) : null} {variant === "bars" ? ( - + {({ height, width }) => ( @@ -832,12 +831,12 @@ function OverviewAnalysisWidget({ if (variant === "compact") { return ( - - + + {title} {rows.length} - + {rows.length === 0 ? (
{emptyLabel}
) : ( @@ -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 ( - - + +
@@ -1150,8 +1185,8 @@ function SystemStatusBar({ } return ( - - + +

{t("System status")}

@@ -1231,12 +1266,12 @@ function ProviderAccountsOverview({ .slice(0, 6); return ( - - + + {t("Account Balance")} {accounts.length} - + {visibleAccounts.length === 0 ? (
{t("No account balance connectors configured")} @@ -1897,16 +1932,16 @@ function UsageAnalysisCard({ const t = useAppText(); return ( - - + + {title} {rows.length} - + {rows.length === 0 ? (
{emptyLabel}
) : ( -
+
@@ -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(null); const [size, setSize] = useState({ height: 0, width: 0 }); @@ -2070,7 +2105,7 @@ function ChartFrame({ children }: { children: (size: { height: number; width: nu }, []); return ( -
+
{size.height > 0 && size.width > 0 ? children(size) : null}
); diff --git a/src/renderer/pages/home/components/settings.tsx b/src/renderer/pages/home/components/settings.tsx index 6f3cbfb1..9545e6e0 100644 --- a/src/renderer/pages/home/components/settings.tsx +++ b/src/renderer/pages/home/components/settings.tsx @@ -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) { 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({
{paletteItems.map((option) => { const Icon = option.icon; + const enabledSingleton = !option.repeatable && widgets.some((widget) => widget.type === option.template.type); - return ( + return option.repeatable ? ( + ) : ( +
+ + + + + toggleSingletonTrayWidget(option.template, checked)} + /> +
); })}
@@ -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" } diff --git a/src/renderer/pages/home/shared.tsx b/src/renderer/pages/home/shared.tsx index 7e91fbd2..58b86e3a 100644 --- a/src/renderer/pages/home/shared.tsx +++ b/src/renderer/pages/home/shared.tsx @@ -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 ( - +
- +
{label}
{value}
@@ -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(); + 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 { diff --git a/src/renderer/pages/tray/TrayApp.tsx b/src/renderer/pages/tray/TrayApp.tsx index 1a8763f0..7c4913fb 100644 --- a/src/renderer/pages/tray/TrayApp.tsx +++ b/src/renderer/pages/tray/TrayApp.tsx @@ -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; diff --git a/src/renderer/pages/tray/shared.tsx b/src/renderer/pages/tray/shared.tsx index 96e38446..7f454db4 100644 --- a/src/renderer/pages/tray/shared.tsx +++ b/src/renderer/pages/tray/shared.tsx @@ -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> = { "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(); + 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") diff --git a/src/shared/app.ts b/src/shared/app.ts index c7180cb2..e2890d7b 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -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; +export const TRAY_SINGLETON_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[]; export type TrayWidgetConfig = { id: string;