diff --git a/README.md b/README.md index b798200..597c719 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) - 展示 Codex 账户、套餐、剩余额度和下次重置时间 -- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 +- 展示当前重置周期 Token 合计、单日峰值、连续使用天数及每日 Token 趋势 - 在本地 SQLite 中保留历史用量和限额快照 - 在限额重置前、重置后发送 Telegram 或邮件提醒 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 @@ -169,10 +169,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口 - ChatGPT/Codex 账户和套餐类型 - 限额窗口使用百分比、窗口长度和重置时间 -- 累计 Token、单日峰值、连续使用天数、最长任务时长等摘要 +- 当前重置周期 Token 合计、单日峰值、连续使用天数、最长任务时长等摘要 - 每日总 Token 桶 -当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 +当前总览的 Token 合计按 app-server 返回的当前有效最长限额窗口汇总每日 Token bucket,通常对应 secondary 周窗口;周期边界所在日期按整日统计。系统不根据 Token 推算 Credits、美元价值或订阅价格。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 ## 常见问题 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 0bbd68a..babca90 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -31,13 +31,15 @@ account:{email:string|null, authMode:string|null, planType:string|null, connected:bool}, limits:[{limitId, limitName:string|null, windowType, usedPercent, windowDurationMinutes, resetsAt, planType:string|null}], + currentCycle?:{limitId, windowType, windowDurationMinutes, + startedAt, resetsAt, totalTokens}, summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?, currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?}, usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}], fetchedAt, stale, lastError?} ``` -时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。 +时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。没有有效窗口或用量接口失败时省略该字段。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。 ## 3. 系统、初始化与会话 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 6542856..e33f8c9 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -460,6 +460,7 @@ func (a *App) syncAccount(ctx context.Context, id int64) error { d.Usage = append(d.Usage, p) _, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt) } + d.CurrentCycle = currentTokenCycle(d.Limits, d.Usage, d.FetchedAt) } resetDetected, e := a.storeLimitSnapshots(d) if e != nil { @@ -530,6 +531,58 @@ type rawLimit struct { Secondary *LimitWindow `json:"secondary"` } +func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle { + current := LimitBucket{} + found := false + for _, limit := range limits { + if limit.WindowDurationMinutes <= 0 || limit.ResetsAt <= fetchedAt { + continue + } + if !found || betterTokenCycleLimit(limit, current) { + current = limit + found = true + } + } + if !found { + return nil + } + + startedAt := current.ResetsAt - int64(current.WindowDurationMinutes)*60 + startDate := time.Unix(startedAt, 0).UTC().Format("2006-01-02") + endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02") + var total int64 + for _, point := range usage { + if point.Date < startDate || point.Date > endDate { + continue + } + if _, err := time.Parse("2006-01-02", point.Date); err != nil { + continue + } + total += point.TotalTokens + } + return &TokenCycle{ + LimitID: current.LimitID, + WindowType: current.WindowType, + WindowDurationMinutes: current.WindowDurationMinutes, + StartedAt: startedAt, + ResetsAt: current.ResetsAt, + TotalTokens: total, + } +} + +func betterTokenCycleLimit(candidate, current LimitBucket) bool { + if candidate.WindowDurationMinutes != current.WindowDurationMinutes { + return candidate.WindowDurationMinutes > current.WindowDurationMinutes + } + if candidate.WindowType != current.WindowType { + return candidate.WindowType == "secondary" + } + if candidate.LimitID != current.LimitID { + return candidate.LimitID < current.LimitID + } + return candidate.ResetsAt > current.ResetsAt +} + func flattenLimit(x rawLimit) []LimitBucket { out := []LimitBucket{} if x.Primary != nil { diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 5a9a208..81b0619 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -71,6 +71,72 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { } } +func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) { + now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) + reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) + cycle := currentTokenCycle( + []LimitBucket{ + { + LimitID: "codex", + WindowType: "primary", + WindowDurationMinutes: 300, + ResetsAt: now.Add(5 * time.Hour).Unix(), + }, + { + LimitID: "codex", + WindowType: "secondary", + WindowDurationMinutes: 7 * 24 * 60, + ResetsAt: reset.Unix(), + }, + }, + []UsagePoint{ + {Date: "2026-08-07", TotalTokens: 50}, + {Date: "2026-08-08", TotalTokens: 100}, + {Date: "2026-08-10", TotalTokens: 200}, + {Date: "2026-08-14", TotalTokens: 300}, + {Date: "2026-08-15", TotalTokens: 400}, + }, + now.Unix(), + ) + if cycle == nil { + t.Fatal("currentTokenCycle() returned nil") + } + if cycle.WindowType != "secondary" || cycle.WindowDurationMinutes != 7*24*60 { + t.Fatalf("cycle window = %#v; want the seven-day secondary window", cycle) + } + if cycle.StartedAt != time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix() { + t.Fatalf("cycle start = %d; want 2026-08-08", cycle.StartedAt) + } + if cycle.ResetsAt != reset.Unix() || cycle.TotalTokens != 600 { + t.Fatalf("cycle = %#v; want reset %d and 600 tokens", cycle, reset.Unix()) + } +} + +func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) { + cycle := currentTokenCycle( + []LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}}, + []UsagePoint{{Date: "2026-08-14", TotalTokens: 100}}, + time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(), + ) + if cycle != nil { + t.Fatalf("currentTokenCycle() = %#v; want nil", cycle) + } +} + +func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) { + var limit rawLimit + if err := json.Unmarshal([]byte(`{ + "limitId":"codex", + "primary":{"usedPercent":20,"windowDurationMins":10080,"resetsAt":1787197043} + }`), &limit); err != nil { + t.Fatal(err) + } + flattened := flattenLimit(limit) + if len(flattened) != 1 || flattened[0].WindowDurationMinutes != 10080 { + t.Fatalf("flattened limit = %#v; want a 10080-minute window", flattened) + } +} + type fakeCodexClient struct { mu sync.Mutex connected bool diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index 7e484b1..b3903c8 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -41,7 +41,7 @@ type AccountView struct { } type LimitWindow struct { UsedPercent float64 `json:"usedPercent"` - WindowDurationMins int `json:"windowDurationMinutes"` + WindowDurationMins int `json:"windowDurationMins"` ResetsAt int64 `json:"resetsAt"` } type LimitBucket struct { @@ -70,16 +70,25 @@ type UsagePoint struct { InputTokens *int64 `json:"inputTokens"` OutputTokens *int64 `json:"outputTokens"` } +type TokenCycle struct { + LimitID string `json:"limitId"` + WindowType string `json:"windowType"` + WindowDurationMinutes int `json:"windowDurationMinutes"` + StartedAt int64 `json:"startedAt"` + ResetsAt int64 `json:"resetsAt"` + TotalTokens int64 `json:"totalTokens"` +} type Dashboard struct { - AccountID int64 `json:"accountId"` - DisplayName string `json:"displayName"` - Account AccountView `json:"account"` - Limits []LimitBucket `json:"limits"` - Summary UsageSummary `json:"summary"` - Usage []UsagePoint `json:"usage"` - FetchedAt int64 `json:"fetchedAt"` - Stale bool `json:"stale"` - LastError string `json:"lastError,omitempty"` + AccountID int64 `json:"accountId"` + DisplayName string `json:"displayName"` + Account AccountView `json:"account"` + Limits []LimitBucket `json:"limits"` + Summary UsageSummary `json:"summary"` + Usage []UsagePoint `json:"usage"` + CurrentCycle *TokenCycle `json:"currentCycle,omitempty"` + FetchedAt int64 `json:"fetchedAt"` + Stale bool `json:"stale"` + LastError string `json:"lastError,omitempty"` } func defaults() GeneralSettings { diff --git a/docs/backend/codex-integration-and-usage.md b/docs/backend/codex-integration-and-usage.md index aa40952..fcff75a 100644 --- a/docs/backend/codex-integration-and-usage.md +++ b/docs/backend/codex-integration-and-usage.md @@ -25,11 +25,13 @@ account/login/start {type:"chatgptDeviceCode"} - `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。 - 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。 - 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。 -- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。 +- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。 - 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。 `account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。 +`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。 + ## 套餐类型 用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index 8fd9689..a542482 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读 ## 总览与设置 -总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、四项摘要及每日 Token 图。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。 +总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、其他摘要及每日 Token 图。当前周期 Token 由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。 设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f850070..6ce0b42 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -687,6 +687,11 @@ function AccountQuickSummary({ {remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`} + + {dashboard.currentCycle == null + ? "本周期 Token 暂无" + : `本周期 ${num(dashboard.currentCycle.totalTokens)} Tokens`} + {reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"} ); @@ -776,11 +781,22 @@ function AccountDashboardDetails({ 该连接暂无限额数据,请确认登录后刷新。 )} + {dashboard.currentCycle && ( +
+ + + 本周期:{cycleTimestamp(dashboard.currentCycle.startedAt)} 至{" "} + {cycleTimestamp(dashboard.currentCycle.resetsAt)}( + {cycleDuration(dashboard.currentCycle.windowDurationMinutes)} + ,按每日数据汇总) + +
+ )}
} - label="累计 Tokens" - v={num(dashboard.summary.lifetimeTokens)} + label="本周期 Tokens" + v={num(dashboard.currentCycle?.totalTokens)} /> } @@ -1664,7 +1680,7 @@ function Header({ const num = (v?: number | null) => v == null ? "暂无" - : new Intl.NumberFormat("zh-CN", { + : new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1, }).format(v); @@ -1676,6 +1692,14 @@ const duration = (v?: number | null) => : v < 3600 ? `${Math.floor(v / 60)} 分 ${v % 60} 秒` : `${(v / 3600).toFixed(1)} 小时`; +const cycleTimestamp = (value: number) => + new Date(value * 1000).toLocaleString(); +const cycleDuration = (minutes: number) => + minutes % (24 * 60) === 0 + ? `${minutes / (24 * 60)} 天窗口` + : minutes % 60 === 0 + ? `${minutes / 60} 小时窗口` + : `${minutes} 分钟窗口`; const remainingPercent = (limit: Limit) => 100 - Math.min(100, Math.max(0, limit.usedPercent)); const limitLabel = (limit: Limit) => { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1d4b37b..d9154e2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -432,6 +432,24 @@ header p { border-radius: 10px; background: color-mix(in srgb, var(--danger) 8%, var(--panel)); } +.cycle-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} +.cycle-note svg { + flex: none; + width: 16px; + margin-top: 2px; + color: var(--accent); +} .account-info-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/frontend/src/types.test.ts b/frontend/src/types.test.ts index e86964e..abb6970 100644 --- a/frontend/src/types.test.ts +++ b/frontend/src/types.test.ts @@ -24,6 +24,29 @@ describe("API decoders", () => { expect(value.account.email).toBeNull(); }); + it("解码当前重置周期 Token 合计", () => { + const value = decodeDashboard({ + accountId: 1, + displayName: "个人账号", + account: { email: "user@example.com", planType: "plus", connected: true }, + limits: [], + currentCycle: { + limitId: "codex", + windowType: "secondary", + windowDurationMinutes: 10080, + startedAt: 1_900_000_000, + resetsAt: 1_900_604_800, + totalTokens: 123_456, + }, + summary: {}, + usage: [], + fetchedAt: 1_900_000_000, + stale: false, + }); + expect(value.currentCycle?.windowType).toBe("secondary"); + expect(value.currentCycle?.totalTokens).toBe(123_456); + }); + it("拒绝未知主题", () => { expect(() => decodeGeneral({ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 97e6135..1b8ea08 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -48,6 +48,14 @@ export interface Point { date: string; totalTokens: number; } +export interface TokenCycle { + limitId: string; + windowType: string; + windowDurationMinutes: number; + startedAt: number; + resetsAt: number; + totalTokens: number; +} export interface Dashboard { accountId: number; displayName: string; @@ -57,6 +65,7 @@ export interface Dashboard { connected: boolean; }; limits: Limit[]; + currentCycle?: TokenCycle; summary: { lifetimeTokens?: number | null; peakDailyTokens?: number | null; @@ -153,6 +162,20 @@ export const decodeAccounts: Decoder = (value) => { if (!Array.isArray(value)) throw new Error("账号列表格式无效"); return value.map(decodeAccount); }; +const decodeTokenCycle: Decoder = (value) => { + const x = record(value, "currentCycle"); + return { + limitId: string(x.limitId, "limitId"), + windowType: string(x.windowType, "windowType"), + windowDurationMinutes: number( + x.windowDurationMinutes, + "windowDurationMinutes", + ), + startedAt: number(x.startedAt, "startedAt"), + resetsAt: number(x.resetsAt, "resetsAt"), + totalTokens: number(x.totalTokens, "totalTokens"), + }; +}; export const decodeDashboard: Decoder = (value) => { const x = record(value, "Dashboard"), account = record(x.account, "account"), @@ -181,6 +204,9 @@ export const decodeDashboard: Decoder = (value) => { resetsAt: number(l.resetsAt, "resetsAt"), }; }), + ...(x.currentCycle == null + ? {} + : { currentCycle: decodeTokenCycle(x.currentCycle) }), summary: { lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"), peakDailyTokens: optionalNumber( diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index df3771e..04534a5 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -91,6 +91,14 @@ test("shows every account as a collapsible summary on the overview", async ({ resetsAt: 1_900_000_000, }, ], + currentCycle: { + limitId: "codex", + windowType: "secondary", + windowDurationMinutes: 10_080, + startedAt: 1_899_999_000, + resetsAt: 1_900_604_800, + totalTokens: 123_456, + }, summary: { lifetimeTokens: 12_345, peakDailyTokens: 3_000, @@ -151,6 +159,7 @@ test("shows every account as a collapsible summary on the overview", async ({ await expect(page.locator(".account-overview")).toHaveCount(2); await expect.poll(() => requestedDashboards.size).toBe(2); await expect(page.getByText("最低 65% 剩余")).toBeVisible(); + await expect(page.getByText("本周期 123.5K Tokens")).toBeVisible(); await expect(page.getByText("限额暂无")).toBeVisible(); await expect(page.locator(".account-detail")).toHaveCount(0); await expect(page.locator(".account-select")).toHaveCount(0); @@ -162,6 +171,9 @@ test("shows every account as a collapsible summary on the overview", async ({ await expect( first.getByRole("heading", { name: "每日 Token 趋势" }), ).toBeVisible(); + await expect(first.getByText("本周期 Tokens")).toBeVisible(); + await expect(first.getByText("123.5K", { exact: true })).toBeVisible(); + await expect(first.getByText("按每日数据汇总")).toBeVisible(); await expect(page.locator(".account-detail")).toHaveCount(1); await first.getByRole("button", { name: "收起个人账号详情" }).click();