diff --git a/go.mod b/go.mod index 7172e466..3b660bb8 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index b7d70cd3..8e942c10 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= diff --git a/internal/gui/api.go b/internal/gui/api.go index 7ec167ea..e5426ac6 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -187,6 +187,9 @@ type settingsJSON struct { WorkBuddyCheckins []provider.WorkBuddyCheckin `json:"workbuddyCheckins,omitempty"` // FX is the dollar-to-yuan rate the cny currency choice shows costs at FX fxJSON `json:"fx"` + // NotifyProblem is why a usage alert set wouldn't be seen: "denied" + // (notifications turned off for magpie) or "unavailable" + NotifyProblem string `json:"notifyProblem,omitempty"` } func settingsState() settingsJSON { @@ -198,6 +201,9 @@ func settingsState() settingsJSON { s.TerminalDefault = found.Default } s.FX = currentFX() + if (s.UsageAlert > 0 || s.BalanceAlert > 0) && notifyProblem != nil { + s.NotifyProblem = notifyProblem() + } s.ProxyNow, s.ProxySource = netproxy.Describe() s.Login = autostart.Enabled() if s.LAN { @@ -503,6 +509,12 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { if (!slices.Equal(settings.Load().TrayUsages, cur.TrayUsages) || in.TrayUsageEvery != cur.TrayUsageEvery) && onTrayUsage != nil { onTrayUsage() } + // an alert turned on or moved is looked at now, the Mac asked for its + // leave to notify as it is turned on (#368) + if (in.UsageAlert != cur.UsageAlert || in.BalanceAlert != cur.BalanceAlert) && + (in.UsageAlert > 0 || in.BalanceAlert > 0) && onAlerts != nil { + onAlerts() + } // the tray menu follows the page's language (#301) if in.Lang != cur.Lang && onLang != nil { onLang() diff --git a/internal/gui/app.go b/internal/gui/app.go index 432be1d7..e1dee40b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -416,6 +416,7 @@ func Run(version string, showMain bool, link string) error { h.tray.SetMenu(menu) h.tray.AttachWindow(h.panel).WindowOffset(6) h.watchTrayUsage() + h.watchAlerts() // the quick panel by the icon, or the main window if the user would // rather (Settings → Tray icon) h.tray.OnClick(func() { diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 6091de90..8a3ab14f 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1606,6 +1606,11 @@ input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; } .warm-at input.at { width: auto; height: 27px; padding: 0 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: inherit; font-size: 12.5px; font-variant-numeric: tabular-nums; outline: none; transition: border-color .12s, box-shadow .12s; } .warm-at input.at:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } .warm-at input.at::-webkit-calendar-picker-indicator { opacity: .55; } +/* a usage alert's share or amount: a short number field, its unit beside it */ +.warm-at input.at.num { width: 64px; text-align: right; } +.warm-at .unit { color: var(--muted); font-size: 12.5px; margin-left: -4px; } +.row.pref .sub .warn { color: var(--amber); } +.row.pref .sub.wraps { white-space: normal; } /* the field's clock and picker in the page's own palette */ :root[data-theme="dark"] .warm-at input.at { color-scheme: dark; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .warm-at input.at { color-scheme: dark; } } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 35e43f8c..bc9b6cef 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -8961,6 +8961,7 @@ function renderTrayUsage(s, keep) { $("#quotaLeftSegs").replaceChildren(segs([[false, t("Used")], [true, t("Left")]], !!s.quotaLeft, (on) => { if (on !== quotaLeft) setQuotaLeft(on); })); $("#currencySegs").replaceChildren(segs(CURRENCIES.map(([id, name]) => [id, t(name)]), s.currency || "usd", (v) => savePrefs({ ...keep, currency: v }))); + renderAlerts(s, keep); // the agents' lists name a model with its provider's after it, or alone // (#335): set on its own, so the agents are told $("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.plainNames ? "off" : "on", (v) => @@ -9074,6 +9075,48 @@ function renderWarmAt(box, sub, at, onReset, via, save) { box.append(segs([["off", t("Off")], ["on", t("On")]], at ? "on" : "off", (v) => save(v === "on" ? at || "06:00" : ""))); } +// renderAlerts draws the usage alerts (#368): a notification when a window +// that routing counts reaches the share set, or a balance falls to the +// amount set, each once; Off, or the number in a field beside On. +function renderAlerts(s, keep) { + const problem = s.notifyProblem === "denied" ? t("Notifications are turned off for magpie in the system's settings") + : s.notifyProblem === "unavailable" ? t("Notifications can't be shown on this system") : ""; + const sub = (id, what) => { + const box = $(id); + box.textContent = what; + box.title = what; + // the problem said whole, the line let wrap for it + box.classList.toggle("wraps", !!problem); + if (problem) box.append(" · ", el("span", "warn", problem)); + }; + sub("#usageAlertSub", t("A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs")); + sub("#balanceAlertSub", t("A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up")); + const field = (box, value, label, unit, ok, save) => { + box.replaceChildren(); + if (value) { + const i = input(String(value), "", "number"); + i.className = "at num"; + i.inputMode = "decimal"; + i.setAttribute("aria-label", label); + i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") i.blur(); }; + i.onchange = () => { + const n = Number(i.value); + if (!ok(n)) { i.value = String(value); return; } + if (n !== value) save(n); + }; + box.append(i); + if (unit) box.append(el("span", "unit", unit)); + } + return box; + }; + field($("#usageAlertSegs"), s.usageAlert || 0, t("Share used"), "%", (n) => Number.isInteger(n) && n >= 1 && n <= 100, + (n) => savePrefs({ ...keep, usageAlert: n })) + .append(segs([["off", t("Off")], ["on", t("On")]], s.usageAlert ? "on" : "off", (v) => savePrefs({ ...keep, usageAlert: v === "on" ? s.usageAlert || 80 : 0 }))); + field($("#balanceAlertSegs"), s.balanceAlert || 0, t("Amount"), "", (n) => Number.isFinite(n) && n > 0, + (n) => savePrefs({ ...keep, balanceAlert: n })) + .append(segs([["off", t("Off")], ["on", t("On")]], s.balanceAlert ? "on" : "off", (v) => savePrefs({ ...keep, balanceAlert: v === "on" ? s.balanceAlert || 5 : 0 }))); +} + // renderImages: the model that describes images to a model that can't see // them — the one magpie picks, one named, or none, and then such an image // is turned away. @@ -9414,7 +9457,8 @@ function prefsKeep(s) { trayUsages: s.trayUsages || [], redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "", claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "", workbuddyCheckin: !!s.workbuddyCheckin, noStats: !!s.noStats, - trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd" }; + trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd", + usageAlert: s.usageAlert || 0, balanceAlert: s.balanceAlert || 0 }; } // savePrefs sends what the page was drawn with (prefsBase) and the choice diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index a7ebdccc..d382170a 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -152,6 +152,14 @@ const I18N = { "Usage shows": "用量显示", "How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar": "每个窗口显示已用多少或剩余多少,用量页、面板和菜单栏一致", "Currency": "货币", + "Usage alert": "用量提醒", + "Low balance alert": "余额提醒", + "A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs": "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次", + "A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up": "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次", + "Notifications are turned off for magpie in the system's settings": "系统设置里关闭了 magpie 的通知", + "Notifications can't be shown on this system": "此系统无法显示通知", + "Share used": "已用比例", + "Amount": "金额", "Provider in model names": "模型名带提供商", "Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same": "写给 agent 的模型列表里,每个模型名后面带上提供商或“routing group”。关闭后只用模型名,除非有两个会重名", "$ USD": "$ 美元", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index e308bd32..6d0ac9f9 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -201,6 +201,14 @@
Currency
+
+
Usage alert
+
+
+
+
Low balance alert
+
+
Provider in model names
Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same
diff --git a/internal/gui/quota_alert.go b/internal/gui/quota_alert.go new file mode 100644 index 00000000..ba07a3d8 --- /dev/null +++ b/internal/gui/quota_alert.go @@ -0,0 +1,102 @@ +package gui + +import ( + "fmt" + "math" + "regexp" + "strconv" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// onAlerts is told when the Settings page turns a usage or balance alert +// on, for the process that notifies to ask the system's leave to and look +// at once; set by the process that has the tray. +var onAlerts func() + +// notifyProblem says why magpie's notifications can't be shown, "" when +// they can or it isn't known: "denied" (turned off for magpie in the +// system's settings) or "unavailable" (a build or desktop without them). +// Set by the process that has the tray. +var notifyProblem func() string + +var ( + hoursName = regexp.MustCompile(`^(\d+) hours?$`) + daysName = regexp.MustCompile(`^(\d+) days?$`) +) + +// alertWindowZh is a window's name in Chinese, as the page's i18n.js has +// the common ones; one it doesn't know stays as the vendor named it. +func alertWindowZh(name string) string { + if m := hoursName.FindStringSubmatch(name); m != nil { + return m[1] + " 小时" + } + if m := daysName.FindStringSubmatch(name); m != nil { + return m[1] + " 天" + } + switch name { + case "Weekly": + return "每周" + case "Monthly": + return "每月" + case "Daily": + return "每日" + } + return name +} + +// alertClockZh is provider.ResetClock in Chinese. +func alertClockZh(at, now time.Time) string { + at, now = at.Local(), now.Local() + day := func(t time.Time) time.Time { y, m, d := t.Date(); return time.Date(y, m, d, 0, 0, 0, 0, time.Local) } + switch days := int(math.Round(day(at).Sub(day(now)).Hours() / 24)); { + case days <= 0: + return at.Format("15:04") + case days == 1: + return "明天 " + at.Format("15:04") + case days < 7: + return [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}[at.Weekday()] + " " + at.Format("15:04") + } + return fmt.Sprintf("%d月%d日 %s", at.Month(), at.Day(), at.Format("15:04")) +} + +// alertText is a usage alert as a notification says it, in the language +// (en or zh): the card, and what reached the line. left says a window by +// how much of it is left, as the Usage page does when set to. +func alertText(lang string, a provider.QuotaAlert, bal float64, left bool, now time.Time) (title, body string) { + title = a.Name + if a.User != "" { + title += " · " + a.User + } + if a.Window == "" { + line := strconv.FormatFloat(bal, 'f', -1, 64) + if lang == "zh" { + return title, fmt.Sprintf("余额已降至 %s(提醒线 %s)", a.Balance, line) + } + return title, fmt.Sprintf("Balance down to %s (alert at %s)", a.Balance, line) + } + n := a.Used + if left { + n = max(0, 100-n) + } + pct := strconv.FormatFloat(math.Round(n*10)/10, 'f', -1, 64) + "%" + if lang == "zh" { + body = fmt.Sprintf("%s窗口已用 %s", alertWindowZh(a.Window), pct) + if left { + body = fmt.Sprintf("%s窗口剩余 %s", alertWindowZh(a.Window), pct) + } + if a.ResetsAt != nil { + body += "," + alertClockZh(*a.ResetsAt, now) + " 重置" + } + return title, body + } + body = fmt.Sprintf("%s: %s used", a.Window, pct) + if left { + body = fmt.Sprintf("%s: %s left", a.Window, pct) + } + if a.ResetsAt != nil { + body += ", resets " + provider.ResetClock(*a.ResetsAt, now) + } + return title, body +} diff --git a/internal/gui/quota_alert_notify.go b/internal/gui/quota_alert_notify.go new file mode 100644 index 00000000..8efbeb8f --- /dev/null +++ b/internal/gui/quota_alert_notify.go @@ -0,0 +1,124 @@ +//go:build !nogui + +package gui + +import ( + "context" + "fmt" + "log" + "sync" + "sync/atomic" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// notifier is the system's notifications, started the first time an alert +// needs them rather than with the app: started with it, a Mac build that +// isn't a signed bundle, or a Linux desktop without a notification daemon, +// would stop the app from starting at all. +type notifier struct { + once sync.Once + ns *notifications.NotificationService + err error + h *host + // refused is the Mac's answer when asked this run was no: before it + // is asked, not yet allowed and turned off read the same + refused atomic.Bool +} + +func (n *notifier) start() error { + n.once.Do(func() { + ns := notifications.New() + // on the main thread, as the app would start it: Windows registers + // its toast activator for the thread it is on + if err := application.InvokeSyncWithError(func() error { + return ns.ServiceStartup(context.Background(), application.ServiceOptions{}) + }); err != nil { + n.err = err + log.Println("notifications:", err) + return + } + // a click on one opens the Usage page + ns.OnNotificationResponse(func(notifications.NotificationResult) { + n.h.whenReady(func() { application.InvokeAsync(func() { n.h.ShowMain("usage") }) }) + }) + n.ns = ns + }) + return n.err +} + +// allowed asks for the system's leave to notify where it is asked for (the +// Mac, which asks once and then keeps the answer). +func (n *notifier) allowed() bool { + if n.start() != nil { + return false + } + if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && ok { + return true + } + ok, err := n.ns.RequestNotificationAuthorization() + n.refused.Store(err == nil && !ok) + return err == nil && ok +} + +// problem is notifyProblem: why a notification wouldn't be seen. +func (n *notifier) problem() string { + select { + case <-n.h.ready: + default: + return "" // the app isn't running yet + } + if n.start() != nil { + return "unavailable" + } + if !n.refused.Load() { + return "" + } + if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && !ok { + return "denied" + } + return "" +} + +func (n *notifier) send(title, body string) { + if !n.allowed() { + return + } + id := fmt.Sprintf("magpie-alert-%d", time.Now().UnixNano()) + if err := n.ns.SendNotification(notifications.NotificationOptions{ID: id, Title: title, Body: body}); err != nil { + log.Println("notifications:", err) + } +} + +// watchAlerts tells the user, with a notification, of a window used past +// the share or a balance fallen to the amount set in Settings (#368). +func (h *host) watchAlerts() { + n := ¬ifier{h: h} + wake := make(chan struct{}, 1) + onAlerts = func() { + // the Mac's question asked as the alert is turned on, not later + // when one is due and the user is elsewhere + go n.allowed() + select { + case wake <- struct{}{}: + default: + } + } + notifyProblem = n.problem + go func() { + <-h.ready + // set before this run: asked now, so Settings can say if it was refused + if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 { + go n.allowed() + } + provider.WatchQuotas(context.Background(), wake, func(a provider.QuotaAlert) { + s := settings.Load() + title, body := alertText(trayLang(s.Lang, systemLang), a, s.BalanceAlert, s.QuotaLeft, time.Now()) + n.send(title, body) + }) + }() +} diff --git a/internal/gui/quota_alert_test.go b/internal/gui/quota_alert_test.go new file mode 100644 index 00000000..6e043e32 --- /dev/null +++ b/internal/gui/quota_alert_test.go @@ -0,0 +1,38 @@ +package gui + +import ( + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +func TestAlertText(t *testing.T) { + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.Local) + reset := time.Date(2026, 10, 1, 14, 30, 0, 0, time.Local) + w := provider.QuotaAlert{Provider: "claude", Name: "Claude Code", User: "a@b.c", Window: "5 hours", Used: 82.34, ResetsAt: &reset} + b := provider.QuotaAlert{Provider: "deepseek", Name: "DeepSeek", Balance: "¥4.20"} + for _, c := range []struct { + lang string + a provider.QuotaAlert + left bool + title, body string + }{ + {"en", w, false, "Claude Code · a@b.c", "5 hours: 82.3% used, resets 14:30"}, + {"en", w, true, "Claude Code · a@b.c", "5 hours: 17.7% left, resets 14:30"}, + {"zh", w, false, "Claude Code · a@b.c", "5 小时窗口已用 82.3%,14:30 重置"}, + {"zh", w, true, "Claude Code · a@b.c", "5 小时窗口剩余 17.7%,14:30 重置"}, + {"en", b, false, "DeepSeek", "Balance down to ¥4.20 (alert at 5)"}, + {"zh", b, false, "DeepSeek", "余额已降至 ¥4.20(提醒线 5)"}, + } { + title, body := alertText(c.lang, c.a, 5, c.left, now) + if title != c.title || body != c.body { + t.Errorf("%s left=%v: %q / %q, want %q / %q", c.lang, c.left, title, body, c.title, c.body) + } + } + tomorrow := reset.Add(24 * time.Hour) + week := provider.QuotaAlert{Name: "Codex", Window: "7 days", Used: 80, ResetsAt: &tomorrow} + if _, body := alertText("zh", week, 0, false, now); body != "7 天窗口已用 80%,明天 14:30 重置" { + t.Errorf("zh tomorrow: %q", body) + } +} diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 37766162..a0c9dd81 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -525,10 +525,18 @@ moment; a tab picked while waiting shows its own outline and stays picked; the Library folder waits. When the library comes the outline goes, the tabs get their counts and don't move. +`usage-alert.test.cjs` sets the usage alerts (#368) on the Settings page: +"Usage alert" and "Low balance alert" start Off; On saves 80% (and 5 for a +balance), the field beside it saves the number typed and puts back a share +past 100 unsaved, and a setting saved later (the currency) keeps both +alerts. With magpie's notifications turned off in the system, both rows say +so. No click moves the page and no row has a coloured left border; English +and Chinese, Chromium and WebKit, with `/api/plugins` faked. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/usage-alert.test.cjs b/internal/gui/tests/usage-alert.test.cjs new file mode 100644 index 00000000..b2fef3ca --- /dev/null +++ b/internal/gui/tests/usage-alert.test.cjs @@ -0,0 +1,226 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Usage alerts (#368): the Settings page's "Usage alert" and "Low balance +// alert" rows start Off; On saves 80% (and 5 for a balance), the field +// beside it saves the number typed, a share outside 1-100 is put back +// unsaved, and a choice saved later elsewhere on the page keeps the alerts +// (the page sends all its settings each time, and the server keeps only +// what it is sent). When the system has magpie's notifications turned off +// the rows say so. No click moves the page, and the rows have no coloured +// left border. English and Chinese; no backend, the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); + +function settingsPayload(over) { + return { + theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd", + dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [], + codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false, + trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "", + version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425", + proxyNow: "none", proxySource: "none", login: false, + visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [], + fx: { rate: 7.2, at: new Date().toISOString(), stale: false }, + ...over, + }; +} + +// server answers as magpie does: a POST to /api/settings replaces what is +// kept with what the page sent, and the notifications' state (problem) is +// said while an alert is on. +function server(lang, posts, problem) { + const fixed = settingsPayload({ lang }); + let cur = fixed; + const answer = () => ((cur.usageAlert || cur.balanceAlert) && problem ? { ...cur, notifyProblem: problem } : cur); + return async (route) => { + const req = route.request(), url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: fixed.fx }); + if (url.pathname === "/api/settings") { + if (req.method() === "POST") { + const body = req.postDataJSON(); + posts.push(body); + cur = { ...fixed, ...body }; + } + return json(answer()); + } + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop); + +// wheelTo scrolls the settings with the wheel, as a reader would (a script +// setting scrollTop is put back), until the alert rows are mid-page. +async function wheelTo(page) { + await page.locator("#view-settings").hover(); + for (let i = 0; i < 40; i++) { + const box = await page.locator("#balanceAlertRow").boundingBox(); + if (box && box.y + box.height < 300 && await view(page) > 0) return; + await page.mouse.wheel(0, 120); + await page.waitForTimeout(30); + } + throw new Error("couldn't scroll the alert rows into view"); +} + +const words = { + en: { + usage: "Usage alert", balance: "Low balance alert", off: "Off", on: "On", + usageSub: "A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs", + balanceSub: "A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up", + denied: "Notifications are turned off for magpie in the system's settings", + share: "Share used", amount: "Amount", cny: "¥ CNY", + }, + zh: { + usage: "用量提醒", balance: "余额提醒", off: "关闭", on: "开启", + usageSub: "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次", + balanceSub: "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次", + denied: "系统设置里关闭了 magpie 的通知", + share: "已用比例", amount: "金额", cny: "¥ 人民币", + }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": usage and balance alerts are set on the Settings page", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const pages = []; + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-usage-alert-${i}.png`) }); + } + await browser.close(); + }); + + for (const lang of ["en", "zh"]) { + const w = words[lang]; + await t.test(lang, async () => { + const errors = [], posts = []; + const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + pages.push(page); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, posts, "")); + const posted = async (n) => { for (let i = 0; i < 100 && posts.length < n; i++) await page.waitForTimeout(20); assert.equal(posts.length, n, "posts"); return posts[n - 1]; }; + const settled = () => page.waitForTimeout(300); + + await page.goto("http://magpie.test/?view=settings"); + const usageSegs = page.locator("#usageAlertSegs .opt"), balSegs = page.locator("#balanceAlertSegs .opt"); + await usageSegs.first().waitFor(); + + // the rows, in the page's language, Off to begin with + assert.equal((await page.locator("#usageAlertRow .name").textContent()).trim(), w.usage); + assert.equal((await page.locator("#balanceAlertRow .name").textContent()).trim(), w.balance); + assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub); + assert.equal((await page.locator("#balanceAlertSub").textContent()).trim(), w.balanceSub); + assert.deepEqual(await usageSegs.allTextContents(), [w.off, w.on]); + assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.off); + assert.equal(await page.locator("#balanceAlertSegs .opt.on").textContent(), w.off); + assert.equal(await page.locator("#usageAlertSegs input, #balanceAlertSegs input").count(), 0, "no field while off"); + + // scrolled down with the wheel (the rows to the middle, clear of + // the footer), turning it on moves nothing + await wheelTo(page); + const before = await view(page); + assert(before > 0, "the settings list must be long enough to scroll"); + await usageSegs.nth(1).click(); + assert.equal((await posted(1)).usageAlert, 80, "On saves 80%"); + const share = page.locator("#usageAlertSegs input"); + await share.waitFor(); + await settled(); + assert.equal(await view(page), before, "turning the alert on must not scroll the settings page"); + assert.equal(await share.inputValue(), "80"); + assert.equal(await share.getAttribute("aria-label"), w.share); + assert.equal((await page.locator("#usageAlertSegs .unit").textContent()), "%"); + assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.on); + + // a share typed in is saved; one past 100 is put back, unsaved + await share.fill("90"); + await share.press("Enter"); + assert.equal((await posted(2)).usageAlert, 90); + await settled(); + await share.fill("150"); + await share.press("Enter"); + await settled(); + assert.equal(posts.length, 2, "150% is not saved"); + assert.equal(await share.inputValue(), "90"); + + // the balance: On saves 5, then the amount typed + await balSegs.nth(1).click(); + const b = await posted(3); + assert.equal(b.balanceAlert, 5); + assert.equal(b.usageAlert, 90, "the usage alert is sent along"); + const amount = page.locator("#balanceAlertSegs input"); + await amount.waitFor(); + assert.equal(await amount.getAttribute("aria-label"), w.amount); + await amount.fill("2.5"); + await amount.press("Enter"); + assert.equal((await posted(4)).balanceAlert, 2.5); + await settled(); + + // another setting saved later keeps both alerts + await page.locator("#currencySegs .opt", { hasText: w.cny }).click(); + const c = await posted(5); + assert.equal(c.currency, "cny"); + assert.equal(c.usageAlert, 90, "the usage alert is kept by another save"); + assert.equal(c.balanceAlert, 2.5, "the balance alert is kept by another save"); + await settled(); + assert.equal(await share.inputValue(), "90"); + assert.equal(await view(page), before, "no click moved the page"); + + // Off saves 0, and the field goes + await usageSegs.nth(0).click(); + assert.equal((await posted(6)).usageAlert, 0); + await settled(); + assert.equal(await page.locator("#usageAlertSegs input").count(), 0); + + // no coloured stripe down a row's left + for (const sel of ["#usageAlertRow", "#balanceAlertRow", "#usageAlertSub", "#balanceAlertSub"]) { + assert.equal(await page.locator(sel).evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px", sel); + } + assert.deepEqual(errors, []); + await context.close(); + }); + + await t.test(lang + ": notifications turned off in the system", async () => { + const errors = [], posts = []; + const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + pages.push(page); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, posts, "denied")); + await page.goto("http://magpie.test/?view=settings"); + await page.locator("#usageAlertSegs .opt").first().waitFor(); + await wheelTo(page); + // nothing said while every alert is off + assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub); + await page.locator("#usageAlertSegs .opt").nth(1).click(); + await page.locator("#usageAlertSegs input").waitFor(); + await page.waitForTimeout(300); + for (const id of ["#usageAlertSub", "#balanceAlertSub"]) { + const sub = page.locator(id); + assert((await sub.textContent()).includes(w.denied), `${id} says notifications are off: ${await sub.textContent()}`); + assert.equal((await sub.locator(".warn").textContent()), w.denied, "the problem stands out"); + assert.equal(await sub.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px"); + } + assert.deepEqual(errors, []); + await context.close(); + }); + } + }); +} diff --git a/internal/provider/quota_alert.go b/internal/provider/quota_alert.go new file mode 100644 index 00000000..8ec87f4b --- /dev/null +++ b/internal/provider/quota_alert.go @@ -0,0 +1,223 @@ +package provider + +// Usage alerts (#368): a notification when a subscription's or plan's window +// has reached the share of it the user chose, or a balance has fallen to the +// amount they chose (settings.UsageAlert, settings.BalanceAlert). Each is +// said once: a window once each time it runs, a balance once until it is +// topped up past the amount again. What was said is kept in a file, so a +// restart doesn't say it again. + +import ( + "context" + "encoding/json" + "log" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/settings" +) + +// QuotaAlert is one thing to tell the user: a window used past the share +// (Window set) or a balance at or under the amount (Balance set). +type QuotaAlert struct { + Provider string + Name string // the card's name, as the Usage page shows it + User string // the account or key, when the provider has several + Window string // the window's name + Used float64 + ResetsAt *time.Time + Balance string // the balance as the vendor tells it +} + +// alertMark is what was said of one window or balance: when, and for a +// window the reset it was said before, which is how a new run of the window +// is told from the same one read again. +type alertMark struct { + At time.Time `json:"at"` + Until *time.Time `json:"until,omitempty"` +} + +// alertSameRun is how far apart two readings of a window's reset may be and +// still be the same run of it: Codex's is the reading's time plus the +// seconds left, and moves a little from one reading to the next. +const alertSameRun = 10 * time.Minute + +// alertKeep is how long the mark of a window or balance no longer read +// (signed out, a key removed) is kept. +const alertKeep = 40 * 24 * time.Hour + +// dueAlerts is what the readings qs call for with a window alert at pct +// percent used (0 off) and a balance alert at bal (0 off), and the marks +// brought up to date with it. A reading that failed, or is one kept from +// before (AsOf), neither alerts nor clears: nothing new is known. +func dueAlerts(qs []SubscriptionQuota, marks map[string]alertMark, pct int, bal float64, now time.Time) []QuotaAlert { + var out []QuotaAlert + seen := map[string]bool{} + for _, q := range qs { + if q.Error != "" || q.AsOf != nil { + for k := range marks { + if strings.HasPrefix(k, alertPrefix(q)) { + seen[k] = true + } + } + continue + } + for _, w := range q.Windows { + if w.Aside { + continue // using it up doesn't stop the account + } + key := alertPrefix(q) + "w|" + w.Name + "|" + w.Model + seen[key] = true + resets := w.ResetsAt + if resets == nil && w.ResetSecs > 0 { + t := now.Add(time.Duration(w.ResetSecs) * time.Second) + resets = &t + } + if pct <= 0 || w.Used < float64(pct) { + delete(marks, key) + continue + } + if m, ok := marks[key]; ok && sameRun(m.Until, resets) { + continue + } + marks[key] = alertMark{At: now, Until: resets} + out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Window: w.Name, Used: w.Used, ResetsAt: resets}) + } + if q.Balance == "" { + continue + } + key := alertPrefix(q) + "b" + seen[key] = true + n, ok := BalanceNumber(q.Balance) + if !ok { + continue + } + if bal <= 0 || n > bal { + delete(marks, key) + continue + } + if _, ok := marks[key]; ok { + continue + } + marks[key] = alertMark{At: now} + out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Balance: q.Balance}) + } + // one not read at all now is kept a while, in case it is read again + for k, m := range marks { + if !seen[k] && now.Sub(m.At) > alertKeep { + delete(marks, k) + } + } + return out +} + +// alertPrefix begins the keys of one card's marks: its provider and account. +func alertPrefix(q SubscriptionQuota) string { return q.Provider + "|" + q.User + "|" } + +// sameRun is whether a window marked as resetting at was and one read now +// resetting at now are one run of it. A window that says no reset is taken +// for the same run until its use falls under the share. +func sameRun(was, now *time.Time) bool { + if was == nil || now == nil { + return was == nil && now == nil + } + d := was.Sub(*now) + return d < alertSameRun && d > -alertSameRun +} + +var balanceNum = regexp.MustCompile(`-?\d[\d,]*(?:\.\d+)?`) + +// BalanceNumber is the amount a balance as a vendor tells it holds: "$12.34", +// "¥1,024.50", "CNY 8", "12.3k credits". A share ("45%"), or text with no +// number, is none. +func BalanceNumber(s string) (float64, bool) { + if strings.Contains(s, "%") { + return 0, false + } + loc := balanceNum.FindStringIndex(s) + if loc == nil { + return 0, false + } + n, err := strconv.ParseFloat(strings.ReplaceAll(s[loc[0]:loc[1]], ",", ""), 64) + if err != nil || math.IsNaN(n) || math.IsInf(n, 0) { + return 0, false + } + switch rest := strings.TrimSpace(s[loc[1]:]); { + case strings.HasPrefix(rest, "k") || strings.HasPrefix(rest, "K"): + n *= 1e3 + case strings.HasPrefix(rest, "M"): + n *= 1e6 + } + return n, true +} + +func quotaAlertPath() string { return filepath.Join(filepath.Dir(Path()), "usage-alerts.json") } + +// quotaAlertMu keeps the file to one writer in this magpie. +var quotaAlertMu sync.Mutex + +func readAlertMarks(path string) map[string]alertMark { + m := map[string]alertMark{} + if b, err := os.ReadFile(path); err == nil { + json.Unmarshal(b, &m) + } + return m +} + +// checkAlerts reads what is left everywhere and says what is due, keeping +// the marks in path. +func checkAlerts(path string, qs []SubscriptionQuota, pct int, bal float64, now time.Time) []QuotaAlert { + quotaAlertMu.Lock() + defer quotaAlertMu.Unlock() + marks := readAlertMarks(path) + before, _ := json.Marshal(marks) + out := dueAlerts(qs, marks, pct, bal, now) + if after, _ := json.Marshal(marks); string(after) != string(before) { + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, append(after, '\n'), 0o600) + } + return out +} + +// quotaAlertEvery is how often the allowances are read for the alerts: the +// vendors' private endpoints limit how often they are asked, and the +// windows move slowly. +const quotaAlertEvery = 5 * time.Minute + +// WatchQuotas reads every allowance and balance while an alert is set, and +// hands send each one due, until ctx ends. wake, when it gets a value, +// has it look at once (an alert just turned on). +func WatchQuotas(ctx context.Context, wake <-chan struct{}, send func(QuotaAlert)) { + t := time.NewTimer(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + case <-wake: + } + if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 { + cx, cancel := context.WithTimeout(ctx, time.Minute) + qs := Quotas(cx) + cancel() + for _, a := range checkAlerts(quotaAlertPath(), qs, s.UsageAlert, s.BalanceAlert, time.Now()) { + log.Printf("usage alert: %s %s %s %.0f%% %s", a.Name, a.User, a.Window, a.Used, a.Balance) + send(a) + } + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(quotaAlertEvery) + } +} diff --git a/internal/provider/quota_alert_test.go b/internal/provider/quota_alert_test.go new file mode 100644 index 00000000..2bb70019 --- /dev/null +++ b/internal/provider/quota_alert_test.go @@ -0,0 +1,148 @@ +package provider + +import ( + "path/filepath" + "testing" + "time" +) + +func TestDueAlertsWindowOncePerRun(t *testing.T) { + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + reset := now.Add(3 * time.Hour) + card := func(used float64, resets time.Time) []SubscriptionQuota { + return []SubscriptionQuota{{Provider: "codex", Name: "Codex", User: "a@b.c", Windows: []QuotaWindow{ + {Name: "5 hours", Used: used, ResetsAt: &resets}, + {Name: "On-demand", Used: 100, Aside: true}, // set aside: never told + }}} + } + marks := map[string]alertMark{} + if got := dueAlerts(card(79.9, reset), marks, 80, 0, now); len(got) != 0 { + t.Fatalf("under the share: %+v", got) + } + got := dueAlerts(card(80, reset), marks, 80, 0, now) + if len(got) != 1 || got[0].Window != "5 hours" || got[0].User != "a@b.c" || got[0].Used != 80 || !got[0].ResetsAt.Equal(reset) { + t.Fatalf("at the share: %+v", got) + } + // read again, the reset a few seconds off (Codex's moves): the same run + if got := dueAlerts(card(95, reset.Add(20*time.Second)), marks, 80, 0, now.Add(5*time.Minute)); len(got) != 0 { + t.Fatalf("told twice in one run: %+v", got) + } + // the window reset and was used up to the share again: told again + next := reset.Add(5 * time.Hour) + if got := dueAlerts(card(85, next), marks, 80, 0, reset.Add(time.Hour)); len(got) != 1 { + t.Fatalf("a new run: %+v", got) + } + // falling under the share clears it, in the same run too + dueAlerts(card(10, next), marks, 80, 0, reset.Add(2*time.Hour)) + if len(marks) != 0 { + t.Fatalf("marks kept under the share: %v", marks) + } + if got := dueAlerts(card(81, next), marks, 80, 0, reset.Add(3*time.Hour)); len(got) != 1 { + t.Fatalf("back over the share: %+v", got) + } + // off: nothing, and nothing kept + if got := dueAlerts(card(100, next), marks, 0, 0, reset.Add(4*time.Hour)); len(got) != 0 || len(marks) != 0 { + t.Fatalf("off: %+v %v", got, marks) + } +} + +func TestDueAlertsSecondsLeft(t *testing.T) { + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + q := func(secs int64) []SubscriptionQuota { + return []SubscriptionQuota{{Provider: "codex", Windows: []QuotaWindow{{Name: "7 days", Used: 90, ResetSecs: secs}}}} + } + marks := map[string]alertMark{} + got := dueAlerts(q(3600), marks, 80, 0, now) + if len(got) != 1 || got[0].ResetsAt == nil || !got[0].ResetsAt.Equal(now.Add(time.Hour)) { + t.Fatalf("reset from the seconds left: %+v", got) + } + if got := dueAlerts(q(3000), marks, 80, 0, now.Add(10*time.Minute)); len(got) != 0 { + t.Fatalf("the same run, counted down: %+v", got) + } +} + +func TestDueAlertsStaleReadingKeepsMarks(t *testing.T) { + now := time.Now() + reset := now.Add(time.Hour) + ok := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}} + marks := map[string]alertMark{} + if len(dueAlerts(ok, marks, 80, 0, now)) != 1 { + t.Fatal("not told") + } + // a failed reading, and one kept from before, say nothing new + failed := []SubscriptionQuota{{Provider: "claude", User: "x", Error: "429"}} + if got := dueAlerts(failed, marks, 80, 0, now.Add(alertKeep+time.Hour)); len(got) != 0 || len(marks) != 1 { + t.Fatalf("failed reading: %+v %v", got, marks) + } + stale := []SubscriptionQuota{{Provider: "claude", User: "x", AsOf: &now, Windows: []QuotaWindow{{Name: "5 hours", Used: 10, ResetsAt: &reset}}}} + if dueAlerts(stale, marks, 80, 0, now); len(marks) != 1 { + t.Fatalf("a stale reading cleared the mark: %v", marks) + } + if len(dueAlerts(ok, marks, 80, 0, now)) != 0 { + t.Fatal("told again after a failed reading") + } + // one no longer read at all is let go after a while + dueAlerts(nil, marks, 80, 0, now.Add(alertKeep+time.Hour)) + if len(marks) != 0 { + t.Fatalf("kept forever: %v", marks) + } +} + +func TestDueAlertsBalance(t *testing.T) { + now := time.Now() + q := func(b string) []SubscriptionQuota { + return []SubscriptionQuota{{Provider: "deepseek", Name: "DeepSeek", Balance: b, Windows: []QuotaWindow{}}} + } + marks := map[string]alertMark{} + if got := dueAlerts(q("¥5.01"), marks, 80, 5, now); len(got) != 0 { + t.Fatalf("over the amount: %+v", got) + } + got := dueAlerts(q("¥5.00"), marks, 80, 5, now) + if len(got) != 1 || got[0].Balance != "¥5.00" || got[0].Window != "" { + t.Fatalf("at the amount: %+v", got) + } + if got := dueAlerts(q("¥1.20"), marks, 80, 5, now); len(got) != 0 { + t.Fatalf("told twice: %+v", got) + } + // topped up, then down again + dueAlerts(q("¥100.00"), marks, 80, 5, now) + if got := dueAlerts(q("¥4.00"), marks, 80, 5, now); len(got) != 1 { + t.Fatalf("down again: %+v", got) + } + // a share isn't an amount, and off is off + if got := dueAlerts(q("3%"), map[string]alertMark{}, 80, 5, now); len(got) != 0 { + t.Fatalf("a share: %+v", got) + } + if got := dueAlerts(q("$0.10"), map[string]alertMark{}, 80, 0, now); len(got) != 0 { + t.Fatalf("off: %+v", got) + } +} + +func TestBalanceNumber(t *testing.T) { + for s, want := range map[string]float64{ + "$12.34": 12.34, "¥1,024.50": 1024.5, "CNY 8": 8, "12.3k credits": 12300, "$-3.20": -3.2, "2M tokens": 2e6, + } { + if n, ok := BalanceNumber(s); !ok || n != want { + t.Errorf("BalanceNumber(%q) = %v, %v; want %v", s, n, ok, want) + } + } + for _, s := range []string{"45%", "", "unlimited"} { + if _, ok := BalanceNumber(s); ok { + t.Errorf("BalanceNumber(%q) read a number", s) + } + } +} + +// what was told is kept across a restart: the file, not memory +func TestCheckAlertsKeepsMarks(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage-alerts.json") + now := time.Now() + reset := now.Add(time.Hour) + qs := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}} + if got := checkAlerts(path, qs, 80, 0, now); len(got) != 1 { + t.Fatalf("first: %+v", got) + } + if got := checkAlerts(path, qs, 80, 0, now.Add(time.Minute)); len(got) != 0 { + t.Fatalf("told again after the file was read back: %+v", got) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 66a0d02b..fa453baf 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -119,6 +119,15 @@ type Settings struct { // QuotaLeft shows a subscription's windows by how much of each is left, // not used: the Usage page, the tray panel and the menu bar alike. QuotaLeft bool `json:"quotaLeft,omitempty"` + // UsageAlert is how much of a subscription's or plan's window, in + // percent, is used when magpie says so with a notification (#368): + // once for each time the window runs, for every window routing counts + // (not one set aside, as on-demand spending is); 0 is off. + UsageAlert int `json:"usageAlert,omitempty"` + // BalanceAlert is the balance a key or account has fallen to when + // magpie says so, in that balance's own currency or credits, once until + // it is topped up past it again; 0 is off. + BalanceAlert float64 `json:"balanceAlert,omitempty"` // PlainNames has the model lists magpie gives agents name each model // by its name alone, without its provider's or "routing group" after it // (#335) — but for two in one list that would read the same, which keep @@ -474,6 +483,12 @@ func Save(s Settings) error { if !slices.Contains(TrayEvery, s.TrayUsageEvery) { return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery) } + if s.UsageAlert < 0 || s.UsageAlert > 100 { + return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or 0 for off, not %d", s.UsageAlert) + } + if math.IsNaN(s.BalanceAlert) || math.IsInf(s.BalanceAlert, 0) || s.BalanceAlert < 0 { + return fmt.Errorf("a balance alert is at an amount of 0 or more (0 for off), not %v", s.BalanceAlert) + } if !slices.Contains(TextSizes, s.TextSize) { return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize) } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index cb8b48f7..60baf7b9 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -483,3 +483,19 @@ func TestModelPriceKeysStayTheFilesOwnWhileMessagesSayTheParts(t *testing.T) { t.Errorf("refusing a price: %v; want it to ask for a cache read price", err) } } + +// the usage alerts' share and amount (#368): 0 is off, the rest in range +func TestAlertsChecked(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for _, s := range []Settings{{UsageAlert: 101}, {UsageAlert: -1}, {BalanceAlert: -0.5}} { + if err := Save(s); err == nil { + t.Errorf("saved %+v", s) + } + } + if err := Save(Settings{UsageAlert: 80, BalanceAlert: 2.5}); err != nil { + t.Fatal(err) + } + if s := Load(); s.UsageAlert != 80 || s.BalanceAlert != 2.5 { + t.Fatalf("read back %d %v", s.UsageAlert, s.BalanceAlert) + } +} diff --git a/main.go b/main.go index d2c0cdc5..212dff63 100644 --- a/main.go +++ b/main.go @@ -154,6 +154,11 @@ func run(args []string) error { return runGUI(true, "") case "tray": return runGUI(false, "") + case "-Embedding": + // Windows starting magpie for a click on one of its notifications + // (a usage alert, #368) left in the Action Center after it quit: + // the window, on the Usage page + return runWindow("usage") case "panel": return runPanel() case "autostart": diff --git a/quota_cli.go b/quota_cli.go index 82f5c62f..d18c7c76 100644 --- a/quota_cli.go +++ b/quota_cli.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "slices" + "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( const quotaUsage = `usage: magpie quota […] [--json] magpie quota reset [] [--yes] magpie quota auto-reset [] [on|off] + magpie quota alert [|off] [--balance |off] what is left of every subscription, plan and key magpie has: each window's use and when it starts again, and each key's balance, asked of the vendors now (or less than a minute ago). --json is for scripts and agents; the gateway answers the same at @@ -25,7 +27,12 @@ const quotaUsage = `usage: magpie quota […] [--json] named). It can't be undone, so it asks first; --yes doesn't. quota auto-reset on lets the account spend one by itself when its weekly window is used up and no other account can take a request, one a week at most (the five hours running out never - does); off stops it, and alone it says which accounts do. It is off until turned on.` + does); off stops it, and alone it says which accounts do. It is off until turned on. + quota alert 80 has the magpie app notify when any window of a subscription or plan + reaches 80% used, once each time the window runs (not windows set aside, such as + on-demand spending); --balance 5 when a balance falls to 5 or under, in its own + currency or credits, once until topped up past it. off turns either off, and alone + it says what is set. Both are off until set.` // quotaCmd: magpie quota […] [--json] func quotaCmd(args []string) error { @@ -35,6 +42,9 @@ func quotaCmd(args []string) error { if len(args) > 1 && args[1] == "auto-reset" { return quotaAutoResetCmd(args[2:]) } + if len(args) > 1 && args[1] == "alert" { + return quotaAlertCmd(args[2:]) + } asJSON := false var only []string for _, a := range args[1:] { @@ -232,6 +242,70 @@ func quotaAutoResetCmd(args []string) error { return nil } +// quotaAlertCmd: magpie quota alert [|off] [--balance |off] +// — the usage alerts Settings sets (#368), the notifications the app shows. +func quotaAlertCmd(args []string) error { + s := settings.Load() + changed := false + for i := 0; i < len(args); i++ { + a := strings.ToLower(strings.TrimSpace(args[i])) + switch { + case a == "help" || a == "-h" || a == "--help": + fmt.Println(quotaUsage) + return nil + case a == "--balance" || strings.HasPrefix(a, "--balance="): + v, ok := strings.CutPrefix(a, "--balance=") + if !ok { + if i+1 >= len(args) { + return fmt.Errorf("--balance needs an amount, or off") + } + i++ + v = strings.ToLower(strings.TrimSpace(args[i])) + } + if v == "off" { + s.BalanceAlert = 0 + } else { + n, err := strconv.ParseFloat(v, 64) + if err != nil || n <= 0 { + return fmt.Errorf("a balance alert is at an amount over 0, or off, not %q", v) + } + s.BalanceAlert = n + } + changed = true + case a == "off": + s.UsageAlert = 0 + changed = true + default: + n, err := strconv.Atoi(strings.TrimSuffix(a, "%")) + if err != nil || n < 1 || n > 100 { + return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or off, not %q", args[i]) + } + s.UsageAlert = n + changed = true + } + } + if changed { + if err := settings.Save(s); err != nil { + return err + } + fmt.Print(green.Render("✓") + " ") + } + if s.UsageAlert > 0 { + fmt.Printf("notifies when a window reaches %d%% used, once each time it runs", s.UsageAlert) + } else { + fmt.Print("no usage alert") + } + if s.BalanceAlert > 0 { + fmt.Printf("; and when a balance falls to %s or under, once until topped up\n", strconv.FormatFloat(s.BalanceAlert, 'f', -1, 64)) + } else { + fmt.Println("; no balance alert") + } + if s.UsageAlert > 0 || s.BalanceAlert > 0 { + fmt.Println(muted.Render(" the notifications come from the magpie app, while it runs")) + } + return nil +} + // quotaTitle is a quota's line head: its provider, plan and account. func quotaTitle(q provider.Quota) string { t := q.Provider