From dc6c2d18b340e531f4e7e72fcb3ef1e9cb287249 Mon Sep 17 00:00:00 2001 From: yuez Date: Fri, 4 Sep 2026 14:22:56 +0800 Subject: [PATCH] feat: add subscription plan inference and Management Center theme sync --- plugin/quota_page.go | 150 ++++++++++++++++++++++++++++++++++++++++++- plugin/theme.go | 79 +++++++++++++++++++++++ plugin/theme_test.go | 93 +++++++++++++++++++++++++++ plugin/types.go | 9 +++ plugin/usage.go | 68 ++++++++++++++++++++ plugin/usage_test.go | 118 ++++++++++++++++++++++++++++++++++ 6 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 plugin/theme.go create mode 100644 plugin/theme_test.go diff --git a/plugin/quota_page.go b/plugin/quota_page.go index e64966e..a76809f 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -37,8 +37,9 @@ const QuotaPageHTML = ` --radius-lg: 16px; } + /* Auto mode fallback: when no explicit data-theme is set, follow OS dark preference */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]):not([data-theme="white"]) { --bg-page: #0b0f19; --bg-card: #151d30; --bg-subtle: #1e293b; @@ -61,6 +62,53 @@ const QuotaPageHTML = ` } } + /* Explicit dark theme from Management Center */ + :root[data-theme="dark"] { + --bg-page: #0b0f19; + --bg-card: #151d30; + --bg-subtle: #1e293b; + --border-color: #334155; + --text-main: #f8fafc; + --text-muted: #94a3b8; + --text-dim: #64748b; + --primary: #60a5fa; + --primary-hover: #3b82f6; + --primary-subtle: rgba(59, 130, 246, 0.12); + --success: #34d399; + --success-subtle: rgba(16, 185, 129, 0.12); + --warning: #fbbf24; + --warning-subtle: rgba(245, 158, 11, 0.12); + --danger: #f87171; + --danger-subtle: rgba(239, 68, 68, 0.12); + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5); + } + + /* Explicit white / light theme from Management Center: overrides OS dark preference */ + :root[data-theme="white"], + :root[data-theme="light"] { + --bg-page: #f8fafc; + --bg-card: #ffffff; + --bg-subtle: #f1f5f9; + --border-color: #e2e8f0; + --text-main: #0f172a; + --text-muted: #64748b; + --text-dim: #94a3b8; + --primary: #3b82f6; + --primary-hover: #2563eb; + --primary-subtle: #eff6ff; + --success: #10b981; + --success-subtle: #ecfdf5; + --warning: #f59e0b; + --warning-subtle: #fffbeb; + --danger: #ef4444; + --danger-subtle: #fef2f2; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + } + * { box-sizing: border-box; margin: 0; @@ -141,6 +189,29 @@ const QuotaPageHTML = ` border: 1px solid var(--primary); } + .plan-tag { + font-size: 11px; + font-weight: 700; + padding: 2px 10px; + border-radius: 9999px; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.15)); + color: #8b5cf6; + border: 1px solid rgba(139, 92, 246, 0.35); + display: inline-flex; + align-items: center; + gap: 4px; + letter-spacing: 0.3px; + } + + :root[data-theme="dark"] .plan-tag, + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]):not([data-theme="white"]) .plan-tag { + background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2)); + color: #c084fc; + border-color: rgba(167, 139, 250, 0.45); + } + } + .brand-subtitle { font-size: 13px; color: var(--text-muted); @@ -588,6 +659,7 @@ const QuotaPageHTML = `
Command Code 配额 v0.1.0 +
CLIProxyAPI 实时限额与 Credits 用量监控
@@ -796,6 +868,7 @@ const QuotaPageHTML = ` const alertMsg = document.getElementById("alertMsg"); const statusBadge = document.getElementById("statusBadge"); const statusText = document.getElementById("statusText"); + const planBadge = document.getElementById("planBadge"); const valMonthlyCredits = document.getElementById("valMonthlyCredits"); const valOpensourceCredits = document.getElementById("valOpensourceCredits"); @@ -898,6 +971,16 @@ const QuotaPageHTML = ` const credits = data.credits || (data.data && data.data.credits) || {}; const limits = data.window_limits || (data.data && data.data.window_limits) || {}; + // Plan + const plan = data.plan || (data.data && data.data.plan); + if (plan) { + const planName = plan.name || (typeof plan === "string" ? plan : "Unknown"); + planBadge.textContent = "Plan: " + planName; + planBadge.style.display = "inline-flex"; + } else { + planBadge.style.display = "none"; + } + // Credits valMonthlyCredits.textContent = formatNumber(credits.monthly_credits); valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits); @@ -1062,6 +1145,71 @@ const QuotaPageHTML = ` inputMgmtKey.value = initKey; } + function syncTheme() { + let themeSetting = null; + + // 1. Try reading from parent window (if same-origin iframe) + try { + if (window.parent && window.parent !== window && window.parent.document) { + const parentTheme = window.parent.document.documentElement.getAttribute("data-theme"); + if (parentTheme) { + themeSetting = parentTheme; + } + } + } catch (e) {} + + // 2. Try reading from localStorage['cli-proxy-theme'] + if (!themeSetting) { + try { + const stored = localStorage.getItem("cli-proxy-theme"); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed && parsed.state) { + if (parsed.state.theme) { + themeSetting = parsed.state.theme; + } + if (parsed.state.resolvedTheme && themeSetting === "auto") { + themeSetting = parsed.state.resolvedTheme; + } + } + } + } catch (e) {} + } + + // 3. Fallback to own html attribute + if (!themeSetting) { + themeSetting = document.documentElement.getAttribute("data-theme"); + } + + // Apply theme to documentElement + const docEl = document.documentElement; + if (themeSetting === "dark") { + docEl.setAttribute("data-theme", "dark"); + } else if (themeSetting === "white" || themeSetting === "light") { + docEl.setAttribute("data-theme", themeSetting); + } else { + // auto or unset: check system preference + const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + if (isDark) { + docEl.setAttribute("data-theme", "dark"); + } else { + docEl.setAttribute("data-theme", "light"); + } + } + } + + // Initialize theme and sync listeners + syncTheme(); + window.addEventListener("storage", (e) => { + if (e.key === "cli-proxy-theme") { + syncTheme(); + } + }); + if (window.matchMedia) { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", syncTheme); + } + setInterval(syncTheme, 2000); + if (!timerInterval) { timerInterval = setInterval(updateTimers, 1000); } diff --git a/plugin/theme.go b/plugin/theme.go new file mode 100644 index 0000000..d8690a4 --- /dev/null +++ b/plugin/theme.go @@ -0,0 +1,79 @@ +package plugin + +import ( + "encoding/json" + "strings" +) + +// ThemeStorageState represents the zustand-persisted theme state from CLIProxyAPI Management Center. +type ThemeStorageState struct { + State struct { + Theme string `json:"theme"` + ResolvedTheme string `json:"resolvedTheme"` + } `json:"state"` + Version int `json:"version"` +} + +// ResolveThemeFromStorage extracts theme configuration from the 'cli-proxy-theme' localStorage JSON. +// Returns the user-selected theme setting ("auto", "white", "light", "dark") and the resolved theme ("dark" or "light"/"white"). +func ResolveThemeFromStorage(storageJSON string) (theme string, resolved string) { + theme = "auto" + resolved = "light" + + s := strings.TrimSpace(storageJSON) + if s == "" { + return theme, resolved + } + + // 1. Try zustand persist format + var zustandState ThemeStorageState + if err := json.Unmarshal([]byte(s), &zustandState); err == nil && (zustandState.State.Theme != "" || zustandState.State.ResolvedTheme != "") { + if zustandState.State.Theme != "" { + theme = zustandState.State.Theme + } + if zustandState.State.ResolvedTheme != "" { + resolved = zustandState.State.ResolvedTheme + } else { + switch theme { + case "dark": + resolved = "dark" + case "white", "light": + resolved = "light" + default: + resolved = "light" + } + } + return theme, resolved + } + + // 2. Try simple map {"theme": "..."} + var simpleMap map[string]any + if err := json.Unmarshal([]byte(s), &simpleMap); err == nil { + if t, ok := simpleMap["theme"].(string); ok && t != "" { + theme = t + if r, ok2 := simpleMap["resolvedTheme"].(string); ok2 && r != "" { + resolved = r + } else if theme == "dark" { + resolved = "dark" + } else { + resolved = "light" + } + return theme, resolved + } + } + + // 3. Fallback for raw string literals like `"dark"` or `dark` + clean := strings.ToLower(strings.Trim(s, "\" \t\r\n")) + switch clean { + case "dark": + return "dark", "dark" + case "white": + return "white", "white" + case "light": + return "light", "light" + case "auto": + return "auto", "light" + } + + return theme, resolved +} diff --git a/plugin/theme_test.go b/plugin/theme_test.go new file mode 100644 index 0000000..a080000 --- /dev/null +++ b/plugin/theme_test.go @@ -0,0 +1,93 @@ +package plugin + +import ( + "testing" +) + +func TestResolveThemeFromStorage(t *testing.T) { + tests := []struct { + name string + storageJSON string + wantTheme string + wantResolved string + }{ + { + name: "zustand dark theme", + storageJSON: `{"state":{"theme":"dark","resolvedTheme":"dark"},"version":0}`, + wantTheme: "dark", + wantResolved: "dark", + }, + { + name: "zustand white theme", + storageJSON: `{"state":{"theme":"white","resolvedTheme":"light"},"version":0}`, + wantTheme: "white", + wantResolved: "light", + }, + { + name: "zustand light theme", + storageJSON: `{"state":{"theme":"light","resolvedTheme":"light"},"version":0}`, + wantTheme: "light", + wantResolved: "light", + }, + { + name: "zustand auto theme with resolved dark", + storageJSON: `{"state":{"theme":"auto","resolvedTheme":"dark"},"version":0}`, + wantTheme: "auto", + wantResolved: "dark", + }, + { + name: "zustand auto theme with resolved light", + storageJSON: `{"state":{"theme":"auto","resolvedTheme":"light"},"version":0}`, + wantTheme: "auto", + wantResolved: "light", + }, + { + name: "simple json dark", + storageJSON: `{"theme":"dark"}`, + wantTheme: "dark", + wantResolved: "dark", + }, + { + name: "simple json white", + storageJSON: `{"theme":"white"}`, + wantTheme: "white", + wantResolved: "light", + }, + { + name: "raw string dark", + storageJSON: `"dark"`, + wantTheme: "dark", + wantResolved: "dark", + }, + { + name: "raw string white", + storageJSON: `white`, + wantTheme: "white", + wantResolved: "white", + }, + { + name: "empty string defaults to auto/light", + storageJSON: "", + wantTheme: "auto", + wantResolved: "light", + }, + { + name: "invalid json defaults to auto/light", + storageJSON: `{not-valid-json`, + wantTheme: "auto", + wantResolved: "light", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTheme, gotResolved := ResolveThemeFromStorage(tt.storageJSON) + if gotTheme != tt.wantTheme { + t.Errorf("theme = %q, want %q", gotTheme, tt.wantTheme) + } + if gotResolved != tt.wantResolved { + t.Errorf("resolved = %q, want %q", gotResolved, tt.wantResolved) + } + }) + } +} diff --git a/plugin/types.go b/plugin/types.go index 7d4cc94..86bd2ea 100644 --- a/plugin/types.go +++ b/plugin/types.go @@ -225,6 +225,7 @@ type UpstreamCreditsResponse struct { // UpstreamWindowLimits carries fiveHour and weekly window metrics. type UpstreamWindowLimits struct { + Limited *bool `json:"limited,omitempty"` FiveHour UpstreamWindowLimit `json:"fiveHour"` Weekly UpstreamWindowLimit `json:"weekly"` } @@ -274,8 +275,15 @@ type UsageWindowLimitsData struct { Weekly UsageWindowLimitData `json:"weekly"` } +// PlanInfo represents inferred Command Code subscription plan details. +type PlanInfo struct { + Name string `json:"name"` + Code string `json:"code"` +} + // FormattedUsageData is the complete formatted usage payload. type FormattedUsageData struct { + Plan PlanInfo `json:"plan"` Credits UsageCreditsData `json:"credits"` WindowLimits UsageWindowLimitsData `json:"window_limits"` UpdatedAt string `json:"updated_at"` @@ -284,6 +292,7 @@ type FormattedUsageData struct { // FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage. type FormattedUsageResponse struct { OK bool `json:"ok"` + Plan PlanInfo `json:"plan"` Data FormattedUsageData `json:"data"` Credits UsageCreditsData `json:"credits"` WindowLimits UsageWindowLimitsData `json:"window_limits"` diff --git a/plugin/usage.go b/plugin/usage.go index a8da2e3..150c3a7 100644 --- a/plugin/usage.go +++ b/plugin/usage.go @@ -163,8 +163,12 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now windowLimitsData := formatWindowLimits(upstream.WindowLimits, now) windowLimitsData.Monthly = formatMonthlyWindow(upstream.Credits, summary, now) + // Inferred subscription plan + planInfo := PlanFromWindowLimits(upstream.WindowLimits.FiveHour.Cap, upstream.WindowLimits.Weekly.Cap, upstream.WindowLimits.Limited) + nowRFC := now.Format(time.RFC3339) data := FormattedUsageData{ + Plan: planInfo, Credits: creditsData, WindowLimits: windowLimitsData, UpdatedAt: nowRFC, @@ -172,6 +176,7 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now return &FormattedUsageResponse{ OK: true, + Plan: planInfo, Data: data, Credits: creditsData, WindowLimits: windowLimitsData, @@ -179,6 +184,69 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now }, nil } +// PlanFromWindowLimits infers the Command Code subscription plan based on window limit caps. +// +// Rules: +// - windowLimits.limited == false -> Provider plan (pay-as-you-go) +// - 5h cap=14 && weekly cap=35 -> GOAT plan +// - 5h cap=16 && weekly cap=40 -> Pro plan +// - 5h cap=90 && weekly cap=180 -> Max 20× plan +// - 5h cap=45 && weekly cap=90 -> Max 10× plan +// - 5h cap=3 && weekly cap=6 -> Go plan +// - Otherwise -> Unknown +func PlanFromWindowLimits(fiveHourCap, weeklyCap float64, limited *bool) PlanInfo { + if limited != nil && !*limited { + return PlanInfo{ + Name: "Provider", + Code: "provider", + } + } + + match := func(capVal, target float64) bool { + return math.Abs(capVal-target) < 0.01 + } + + if match(fiveHourCap, 14) && match(weeklyCap, 35) { + return PlanInfo{ + Name: "GOAT", + Code: "goat", + } + } + + if match(fiveHourCap, 16) && match(weeklyCap, 40) { + return PlanInfo{ + Name: "Pro", + Code: "pro", + } + } + + if match(fiveHourCap, 90) && match(weeklyCap, 180) { + return PlanInfo{ + Name: "Max 20×", + Code: "max_20x", + } + } + + if match(fiveHourCap, 45) && match(weeklyCap, 90) { + return PlanInfo{ + Name: "Max 10×", + Code: "max_10x", + } + } + + if match(fiveHourCap, 3) && match(weeklyCap, 6) { + return PlanInfo{ + Name: "Go", + Code: "go", + } + } + + return PlanInfo{ + Name: "Unknown", + Code: "unknown", + } +} + func formatCredits(credits map[string]any) UsageCreditsData { data := UsageCreditsData{ Details: credits, diff --git a/plugin/usage_test.go b/plugin/usage_test.go index ab2e080..c1a25ca 100644 --- a/plugin/usage_test.go +++ b/plugin/usage_test.go @@ -174,6 +174,27 @@ func TestParseAndFormatUsage(t *testing.T) { if weekly.ResetAt != "2025-03-10T12:00:00Z" { t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt) } + + // Verify inferred plan (cap 25 / 100 is unknown) + if usage.Plan.Name != "Unknown" || usage.Plan.Code != "unknown" { + t.Errorf("Plan = %+v, want Unknown", usage.Plan) + } + + // Verify plan inference for GOAT + rawGOAT := []byte(`{ + "credits": {"monthlyCredits": 100}, + "windowLimits": { + "fiveHour": {"used": 2, "cap": 14}, + "weekly": {"used": 10, "cap": 35} + } + }`) + usageGOAT, errGOAT := ParseAndFormatUsage(rawGOAT, nil, now) + if errGOAT != nil { + t.Fatalf("ParseAndFormatUsage GOAT error: %v", errGOAT) + } + if usageGOAT.Plan.Name != "GOAT" || usageGOAT.Plan.Code != "goat" { + t.Errorf("GOAT Plan = %+v, want name=GOAT code=goat", usageGOAT.Plan) + } } func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) { @@ -258,3 +279,100 @@ func TestFetchCreditsRaw_HostCaller(t *testing.T) { t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload)) } } + +func TestPlanFromWindowLimits(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + fiveHourCap float64 + weeklyCap float64 + limited *bool + wantName string + wantCode string + }{ + { + name: "GOAT plan", + fiveHourCap: 14, + weeklyCap: 35, + limited: &trueVal, + wantName: "GOAT", + wantCode: "goat", + }, + { + name: "Pro plan", + fiveHourCap: 16, + weeklyCap: 40, + limited: nil, + wantName: "Pro", + wantCode: "pro", + }, + { + name: "Max 20x plan", + fiveHourCap: 90, + weeklyCap: 180, + limited: &trueVal, + wantName: "Max 20×", + wantCode: "max_20x", + }, + { + name: "Max 10x plan", + fiveHourCap: 45, + weeklyCap: 90, + limited: nil, + wantName: "Max 10×", + wantCode: "max_10x", + }, + { + name: "Go plan", + fiveHourCap: 3, + weeklyCap: 6, + limited: nil, + wantName: "Go", + wantCode: "go", + }, + { + name: "Provider pay-as-you-go plan", + fiveHourCap: 0, + weeklyCap: 0, + limited: &falseVal, + wantName: "Provider", + wantCode: "provider", + }, + { + name: "Provider plan with caps set but limited=false", + fiveHourCap: 14, + weeklyCap: 35, + limited: &falseVal, + wantName: "Provider", + wantCode: "provider", + }, + { + name: "Float tolerance test", + fiveHourCap: 13.999, + weeklyCap: 35.001, + limited: nil, + wantName: "GOAT", + wantCode: "goat", + }, + { + name: "Unknown caps", + fiveHourCap: 10, + weeklyCap: 20, + limited: nil, + wantName: "Unknown", + wantCode: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PlanFromWindowLimits(tt.fiveHourCap, tt.weeklyCap, tt.limited) + if got.Name != tt.wantName || got.Code != tt.wantCode { + t.Errorf("PlanFromWindowLimits(%v, %v, %v) = %+v, want name=%q code=%q", + tt.fiveHourCap, tt.weeklyCap, tt.limited, got, tt.wantName, tt.wantCode) + } + }) + } +}