@leviyuan/lodestar 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/cards.ts +18 -6
- package/src/session.ts +17 -91
- package/src/usage.ts +69 -5
package/README.md
CHANGED
|
@@ -24,7 +24,6 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
24
24
|
- ⌨️ **Type-ahead 不打断**:连珠炮全收,排队下一轮合并处理
|
|
25
25
|
- 🔢 **合并消息加序号**:`[#N]\n` 前缀让模型看清独立边界
|
|
26
26
|
- ⏳ **排队反应可见**:消息进队列加 ⏳,消化/取消自动清/换 ❌
|
|
27
|
-
- 📨 **mid-turn 切新卡**:中途新消息 → 下一 tool 边界切新卡续写
|
|
28
27
|
- ⏰ **定时唤醒可见化**:Cron / ScheduleWakeup 到点自开新卡
|
|
29
28
|
- 📊 **footer 实时指标**:`✅ ⏱时长 · 📊上下文% · 💰本轮成本`
|
|
30
29
|
- 📦 **`hi` 弹控制台**:跨群项目、上下文%、订阅额度一屏看完
|
|
@@ -65,7 +64,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
65
64
|
|
|
66
65
|
**运行时**:[Bun](https://bun.sh) ≥ 1.0。
|
|
67
66
|
|
|
68
|
-
**Claude Code**:装好且能跑 —— 详见[官方文档](https://docs.anthropic.com/en/docs/claude-code)
|
|
67
|
+
**Claude Code**:装好且能跑 —— 详见[官方文档](https://docs.anthropic.com/en/docs/claude-code)。
|
|
69
68
|
|
|
70
69
|
**飞书自建应用**:去[飞书开放平台](https://open.feishu.cn/app)→ 创建企业自建应用,然后:
|
|
71
70
|
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -595,6 +595,15 @@ function fmtResetIn(date: Date | null): string {
|
|
|
595
595
|
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
+
/** Human-readable "time since" — clamps sub-minute values to "刚刚". */
|
|
599
|
+
function fmtAgo(timestamp: number): string {
|
|
600
|
+
const ms = Date.now() - timestamp
|
|
601
|
+
if (ms < 60_000) return '刚刚'
|
|
602
|
+
if (ms < 60 * 60 * 1000) return `${Math.round(ms / 60_000)}m 前`
|
|
603
|
+
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h 前`
|
|
604
|
+
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d 前`
|
|
605
|
+
}
|
|
606
|
+
|
|
598
607
|
const PEER_STATUS_EMOJI: Record<string, string> = {
|
|
599
608
|
idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
|
|
600
609
|
starting: '🚀', stopped: '⚪',
|
|
@@ -623,19 +632,22 @@ export function consoleUsageContent(
|
|
|
623
632
|
case 'network':
|
|
624
633
|
return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
|
|
625
634
|
}
|
|
626
|
-
// state === 'ok'
|
|
635
|
+
// state === 'ok' —— stale 时 head 加 "缓存 Xm 前",重置时间加 `~`
|
|
636
|
+
// 前缀,沿用 omchud HUD 的 stale 标记约定。
|
|
637
|
+
const staleNote = usage.stale ? ` _· 缓存 ${fmtAgo(usage.fetchedAt)}_` : ''
|
|
638
|
+
const resetPrefix = usage.stale ? '~' : ''
|
|
627
639
|
const head = usage.subscriptionType
|
|
628
|
-
? `**📊 订阅额度** · ${usage.subscriptionType}`
|
|
629
|
-
:
|
|
640
|
+
? `**📊 订阅额度** · ${usage.subscriptionType}${staleNote}`
|
|
641
|
+
: `**📊 订阅额度**${staleNote}`
|
|
630
642
|
const lines: string[] = [head]
|
|
631
643
|
if (usage.fiveHour) {
|
|
632
644
|
const parts = [`${Math.round(usage.fiveHour.percent)}%`]
|
|
633
|
-
if (usage.fiveHour.resetsAt) parts.push(`重置 ${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
645
|
+
if (usage.fiveHour.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
634
646
|
lines.push(` · 5h ${parts.join(' · ')}`)
|
|
635
647
|
}
|
|
636
648
|
if (usage.weekly) {
|
|
637
649
|
const parts = [`${Math.round(usage.weekly.percent)}%`]
|
|
638
|
-
if (usage.weekly.resetsAt) parts.push(`重置 ${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
650
|
+
if (usage.weekly.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
639
651
|
lines.push(` · 7d ${parts.join(' · ')}`)
|
|
640
652
|
}
|
|
641
653
|
return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
|
|
@@ -668,7 +680,7 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
668
680
|
lines.push(` · ${dot} \`${p.name}\`${up}${mark}`)
|
|
669
681
|
}
|
|
670
682
|
}
|
|
671
|
-
if (contextTokens != null) {
|
|
683
|
+
if (contextTokens != null && contextTokens > 0) {
|
|
672
684
|
const limit = contextLimit ?? 1_000_000
|
|
673
685
|
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
674
686
|
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
package/src/session.ts
CHANGED
|
@@ -137,25 +137,6 @@ export class Session {
|
|
|
137
137
|
* to clear (via deleteReaction). Empty for eager-opened solo turns
|
|
138
138
|
* and for scheduled wakeups (no user messages went into those). */
|
|
139
139
|
private currentBatchReactionIds = new Map<string, string>()
|
|
140
|
-
/** Set the moment a mid-turn user message lands. Tells the next
|
|
141
|
-
* content-adding event (assistant text delta or fresh tool_use) to
|
|
142
|
-
* rotate the card before applying its update — closes the in-flight
|
|
143
|
-
* card with a `📨 转交新卡` footer and opens a fresh card, so the
|
|
144
|
-
* continuation has a visible boundary instead of piling up under
|
|
145
|
-
* one card. Reset to false after the rotation fires (or on
|
|
146
|
-
* stop/restart/exit). User feedback (2026-05-15): the prior
|
|
147
|
-
* everything-in-one-card behavior made the order feel jumbled. */
|
|
148
|
-
private wantsRotation = false
|
|
149
|
-
/** Holds assistant / thinking / tool_use events that arrive while a
|
|
150
|
-
* card rotation is mid-flight (close-old → open-new straddles a
|
|
151
|
-
* Feishu API await window during which `currentTurn` is transiently
|
|
152
|
-
* null). Replayed onto the new card the moment rotation completes
|
|
153
|
-
* so no streamed token is lost across the boundary. */
|
|
154
|
-
private rotationBuffer: Array<
|
|
155
|
-
| { kind: 'assistant'; delta: string }
|
|
156
|
-
| { kind: 'thinking'; delta: string }
|
|
157
|
-
| { kind: 'tool_use'; id: string; name: string; input: any }
|
|
158
|
-
> = []
|
|
159
140
|
/** Count of `system/init` events seen this subprocess. The first one is
|
|
160
141
|
* the boot init (claimed by whichever user message lands first); all
|
|
161
142
|
* subsequent ones mark the start of an SDK-initiated turn (queued
|
|
@@ -311,8 +292,6 @@ export class Session {
|
|
|
311
292
|
this.lastUserOpenId = ''
|
|
312
293
|
this.pendingReactionIds = new Map()
|
|
313
294
|
this.currentBatchReactionIds = new Map()
|
|
314
|
-
this.wantsRotation = false
|
|
315
|
-
this.rotationBuffer = []
|
|
316
295
|
this.initCount = 0
|
|
317
296
|
this.openingTurn = false
|
|
318
297
|
this.pendingPermissions.clear()
|
|
@@ -333,8 +312,6 @@ export class Session {
|
|
|
333
312
|
this.lastUserOpenId = ''
|
|
334
313
|
this.pendingReactionIds = new Map()
|
|
335
314
|
this.currentBatchReactionIds = new Map()
|
|
336
|
-
this.wantsRotation = false
|
|
337
|
-
this.rotationBuffer = []
|
|
338
315
|
this.initCount = 0
|
|
339
316
|
this.openingTurn = false
|
|
340
317
|
this.pendingPermissions.clear()
|
|
@@ -416,7 +393,6 @@ export class Session {
|
|
|
416
393
|
this.lastUserOpenId = ''
|
|
417
394
|
this.pendingReactionIds = new Map()
|
|
418
395
|
this.currentBatchReactionIds = new Map()
|
|
419
|
-
this.wantsRotation = false
|
|
420
396
|
this.interrupt()
|
|
421
397
|
return true
|
|
422
398
|
case 'kill':
|
|
@@ -453,6 +429,7 @@ export class Session {
|
|
|
453
429
|
// ~5s; not worth blocking the panel on it).
|
|
454
430
|
usage: undefined,
|
|
455
431
|
contextTokens: this.currentContextTokens(),
|
|
432
|
+
contextLimit: this.contextWindowMax(),
|
|
456
433
|
cumStats: this.cumStats,
|
|
457
434
|
lastTurn: this.lastTurnDelta
|
|
458
435
|
? {
|
|
@@ -536,9 +513,6 @@ export class Session {
|
|
|
536
513
|
this.pendingReactionIds.set(msgId, rid)
|
|
537
514
|
}
|
|
538
515
|
})()
|
|
539
|
-
// Rotation hint: a mid-turn user msg means the next assistant /
|
|
540
|
-
// tool event should split the visual into a new card.
|
|
541
|
-
this.wantsRotation = true
|
|
542
516
|
}
|
|
543
517
|
if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
|
|
544
518
|
// Eager open: this message is going to be processed solo (no current
|
|
@@ -853,7 +827,6 @@ export class Session {
|
|
|
853
827
|
this.lastUserOpenId = ''
|
|
854
828
|
this.pendingReactionIds = new Map()
|
|
855
829
|
this.currentBatchReactionIds = new Map()
|
|
856
|
-
this.wantsRotation = false
|
|
857
830
|
this.initCount = 0
|
|
858
831
|
this.openingTurn = false
|
|
859
832
|
this.status = 'stopped'
|
|
@@ -884,14 +857,17 @@ export class Session {
|
|
|
884
857
|
|
|
885
858
|
/** Current context-window occupancy estimate — uses the most recent
|
|
886
859
|
* assistant `usage` (input + caches), since each assistant reply replays
|
|
887
|
-
* the full conversation.
|
|
888
|
-
*
|
|
860
|
+
* the full conversation. Returns 0 when no per-call usage is available
|
|
861
|
+
* (process dead, or fresh spawn before first assistant message);
|
|
862
|
+
* `lastTurnDelta.inputTokens` is the CUMULATIVE turn input across all
|
|
863
|
+
* API calls in the turn (sum of cache_read across N steps) — using it
|
|
864
|
+
* here would inflate the percentage by Nx after a heavy multi-step
|
|
865
|
+
* turn (observed bug 2026-05-16: 417% in the `hi` panel after killing
|
|
866
|
+
* the proc with a long turn's delta still on file). */
|
|
889
867
|
private currentContextTokens(): number {
|
|
890
868
|
const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
|
|
891
|
-
if (u)
|
|
892
|
-
|
|
893
|
-
}
|
|
894
|
-
return this.lastTurnDelta?.inputTokens ?? 0
|
|
869
|
+
if (!u) return 0
|
|
870
|
+
return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
895
871
|
}
|
|
896
872
|
|
|
897
873
|
/** Context-window capacity for the model the subprocess is currently
|
|
@@ -940,44 +916,8 @@ export class Session {
|
|
|
940
916
|
// forget here and rely on enqueue source order — that way no `await`
|
|
941
917
|
// can yield mid-handler and let `closeTurnCard` (or another event) race
|
|
942
918
|
// and mutate `this.currentTurn` underfoot.
|
|
943
|
-
/** Rotate to a fresh card mid-turn: close the in-flight card with a
|
|
944
|
-
* `📨 转交新卡` footer (distinct from `✅ done` and `🛑 打断`) and
|
|
945
|
-
* open a new card so the post-user-message continuation has a
|
|
946
|
-
* visible boundary. Streams that land during the rotation's await
|
|
947
|
-
* windows are buffered in `rotationBuffer` and replayed onto the
|
|
948
|
-
* new card the moment it's ready, so no tokens are lost across the
|
|
949
|
-
* cut. Caller guarantees `wantsRotation` was true sync-immediately
|
|
950
|
-
* before. */
|
|
951
|
-
private async rotateCard(): Promise<void> {
|
|
952
|
-
this.openingTurn = true
|
|
953
|
-
try {
|
|
954
|
-
await this.closeTurnCard('📨 转交新卡')
|
|
955
|
-
await this.openTurnCard('', this.lastUserOpenId, 'user_message')
|
|
956
|
-
} finally {
|
|
957
|
-
this.openingTurn = false
|
|
958
|
-
}
|
|
959
|
-
if (this.rotationBuffer.length === 0) return
|
|
960
|
-
const buf = this.rotationBuffer
|
|
961
|
-
this.rotationBuffer = []
|
|
962
|
-
for (const e of buf) {
|
|
963
|
-
if (e.kind === 'assistant') this.appendAssistant(e.delta)
|
|
964
|
-
else if (e.kind === 'thinking') this.appendThinking(e.delta)
|
|
965
|
-
else if (e.kind === 'tool_use') this.addTool(e.id, e.name, e.input)
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
919
|
private appendAssistant(delta: string): void {
|
|
970
|
-
if (!this.currentTurn)
|
|
971
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
|
|
972
|
-
return
|
|
973
|
-
}
|
|
974
|
-
// Note: assistant text DOES NOT trigger rotation, even if a mid-turn
|
|
975
|
-
// user message landed and set `wantsRotation`. Rotating mid-segment
|
|
976
|
-
// would chop the model's in-progress reply (often a response to the
|
|
977
|
-
// ORIGINAL prompt that started this card) onto a fresh card,
|
|
978
|
-
// visually associating it with the queued msg — which is the bug
|
|
979
|
-
// the user surfaced 2026-05-16. The rotation defers to the next
|
|
980
|
-
// tool_use, which is a clean section boundary.
|
|
920
|
+
if (!this.currentTurn) return
|
|
981
921
|
if (!this.currentTurn.currentAssistantSegmentId) {
|
|
982
922
|
const i = this.currentTurn.assistantSegmentCount++
|
|
983
923
|
const segId = cards.ELEMENTS.assistant(i)
|
|
@@ -1003,12 +943,7 @@ export class Session {
|
|
|
1003
943
|
}
|
|
1004
944
|
|
|
1005
945
|
private appendThinking(delta: string): void {
|
|
1006
|
-
if (!this.currentTurn)
|
|
1007
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
|
|
1008
|
-
return
|
|
1009
|
-
}
|
|
1010
|
-
// Thinking, like assistant text, doesn't trigger rotation — it's
|
|
1011
|
-
// preamble to the same response, not a section break.
|
|
946
|
+
if (!this.currentTurn) return
|
|
1012
947
|
this.currentTurn.thinkingText += delta
|
|
1013
948
|
cardkit.streamTextThrottled(
|
|
1014
949
|
this.currentTurn.cardId,
|
|
@@ -1026,16 +961,7 @@ export class Session {
|
|
|
1026
961
|
}
|
|
1027
962
|
|
|
1028
963
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
1029
|
-
if (!this.currentTurn)
|
|
1030
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1031
|
-
return
|
|
1032
|
-
}
|
|
1033
|
-
if (this.wantsRotation) {
|
|
1034
|
-
this.wantsRotation = false
|
|
1035
|
-
this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1036
|
-
void this.rotateCard()
|
|
1037
|
-
return
|
|
1038
|
-
}
|
|
964
|
+
if (!this.currentTurn) return
|
|
1039
965
|
// Close current assistant segment (if any) so the tool panel renders
|
|
1040
966
|
// AFTER it in card body order. Flush queues the segment's last
|
|
1041
967
|
// buffered delta before the tool element is inserted.
|
|
@@ -1322,15 +1248,15 @@ export class Session {
|
|
|
1322
1248
|
}
|
|
1323
1249
|
const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
|
|
1324
1250
|
// State marker leads the footer (✅ for natural completion, or the
|
|
1325
|
-
// suffix verbatim for non-natural states like
|
|
1251
|
+
// suffix verbatim for non-natural states like `🛑 打断`). The
|
|
1326
1252
|
// trailing "done" word is gone — the ✅ already carries that
|
|
1327
1253
|
// meaning. User-confirmed footer order 2026-05-16.
|
|
1328
1254
|
const stateMark = suffix ? suffix : '✅'
|
|
1329
1255
|
// Per-turn metrics: context-window occupancy (as a real percentage,
|
|
1330
1256
|
// not a token count) and dollar cost. Only meaningful on a clean
|
|
1331
|
-
// close — suffix-tagged turns (
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1257
|
+
// close — suffix-tagged turns (interrupt) didn't fire the `result`
|
|
1258
|
+
// event that populates `lastTurnDelta`, so these numbers would be
|
|
1259
|
+
// stale and misleading.
|
|
1334
1260
|
let metrics = ''
|
|
1335
1261
|
if (!suffix) {
|
|
1336
1262
|
const ctxTokens = this.currentContextTokens()
|
package/src/usage.ts
CHANGED
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* 调用共享同一份快照,不打 API。in-flight 去重保证并发的多个
|
|
27
27
|
* 群同时唤出控制台时只触发一次后台请求。
|
|
28
28
|
*
|
|
29
|
+
* Stale fallback (照 omchud HUD 规则): 单独记最后一次成功拉到的
|
|
30
|
+
* `state:'ok'` 快照,本次拉取失败 (network/rate_limited/auth_failed)
|
|
31
|
+
* 且距上次成功 <= MAX_STALE_MS (15 分钟) 时,返回上次的 ok 快照并打
|
|
32
|
+
* `stale:true` 标签,卡片层加 "缓存 Xm 前" 提示。这是 no_fallbacks
|
|
33
|
+
* 规则的显式例外 —— 用户明确要求订阅额度面板用缓存兜底,因为短暂
|
|
34
|
+
* 网络抖动里把面板上的数字抹成红色"拉取失败"信息密度反而更低。
|
|
35
|
+
*
|
|
36
|
+
* 429 指数退避: 收到 rate_limited 时增加 rateLimitedCount,下次允许
|
|
37
|
+
* 实拉的时间设为 now + CACHE_TTL_MS * 2^(count-1),封顶 5 分钟。
|
|
38
|
+
* 退避窗口内的 readUsage 直接走 stale fallback,不打 API。任何非 429
|
|
39
|
+
* 的响应 (ok / network / auth_failed) 都会重置计数器。
|
|
40
|
+
*
|
|
29
41
|
* 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
|
|
30
42
|
* Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
|
|
31
43
|
* (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
|
|
@@ -42,6 +54,11 @@ const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
|
42
54
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
43
55
|
const API_TIMEOUT_MS = 10_000
|
|
44
56
|
const CACHE_TTL_MS = 60_000
|
|
57
|
+
/** 失败时回退到上次成功快照的最大年龄。超过此值就不再用缓存兜底,
|
|
58
|
+
* 显示真实失败状态 —— 跟 omchud HUD 的 MAX_STALE_DATA_MS 对齐。 */
|
|
59
|
+
const MAX_STALE_MS = 15 * 60 * 1000
|
|
60
|
+
/** 429 退避封顶,跟 omchud HUD 的 MAX_RATE_LIMITED_BACKOFF_MS 对齐。 */
|
|
61
|
+
const RATE_LIMITED_MAX_BACKOFF_MS = 5 * 60 * 1000
|
|
45
62
|
|
|
46
63
|
function credentialsPath(): string {
|
|
47
64
|
return join(homedir(), '.claude', '.credentials.json')
|
|
@@ -73,10 +90,29 @@ export type UsageSnapshot =
|
|
|
73
90
|
fiveHour: UsageWindow | null
|
|
74
91
|
weekly: UsageWindow | null
|
|
75
92
|
fetchedAt: number
|
|
93
|
+
/** true 时本快照不是这次实拉的,而是 lastOk 兜底回来的旧数据。
|
|
94
|
+
* 卡片层据此显示 "缓存" 标记 + 重置时间加 `~` 前缀。 */
|
|
95
|
+
stale?: boolean
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
type UsageSnapshotOk = Extract<UsageSnapshot, { state: 'ok' }>
|
|
99
|
+
|
|
78
100
|
let cache: { data: UsageSnapshot; at: number } | null = null
|
|
101
|
+
/** 最近一次 state:'ok' 的快照,用于失败时兜底。和 cache 分开存:
|
|
102
|
+
* cache 是短时去重 (60s),lastOk 是长尾兜底 (15min)。 */
|
|
103
|
+
let lastOk: { snapshot: UsageSnapshotOk; at: number } | null = null
|
|
79
104
|
let inFlight: Promise<UsageSnapshot> | null = null
|
|
105
|
+
/** 连续 429 计数,用于指数退避;遇到任何非 429 响应就重置为 0。 */
|
|
106
|
+
let rateLimitedCount = 0
|
|
107
|
+
/** 在这个时间戳之前不打 API,直接走 stale fallback。 */
|
|
108
|
+
let rateLimitedUntil = 0
|
|
109
|
+
|
|
110
|
+
function rateLimitedBackoffMs(count: number): number {
|
|
111
|
+
return Math.min(
|
|
112
|
+
CACHE_TTL_MS * Math.pow(2, Math.max(0, count - 1)),
|
|
113
|
+
RATE_LIMITED_MAX_BACKOFF_MS,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
80
116
|
|
|
81
117
|
function readCredentials(): OAuthCredentials | null {
|
|
82
118
|
const path = credentialsPath()
|
|
@@ -246,18 +282,46 @@ async function fetchUsage(): Promise<UsageSnapshot> {
|
|
|
246
282
|
}
|
|
247
283
|
}
|
|
248
284
|
|
|
285
|
+
/** 失败快照 → 如果 MAX_STALE_MS 内还有 lastOk,就返回 lastOk 的副本
|
|
286
|
+
* (打 stale 标);否则透传失败快照。state:'ok' 走 fast path 原样返回。 */
|
|
287
|
+
function withStaleFallback(snapshot: UsageSnapshot): UsageSnapshot {
|
|
288
|
+
if (snapshot.state === 'ok') return snapshot
|
|
289
|
+
if (lastOk && Date.now() - lastOk.at < MAX_STALE_MS) {
|
|
290
|
+
return { ...lastOk.snapshot, stale: true }
|
|
291
|
+
}
|
|
292
|
+
return snapshot
|
|
293
|
+
}
|
|
294
|
+
|
|
249
295
|
/** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
|
|
250
|
-
* 单次后台 fetch
|
|
251
|
-
*
|
|
296
|
+
* 单次后台 fetch。拉取失败但 lastOk 仍在 MAX_STALE_MS 内时,回退到
|
|
297
|
+
* lastOk 并打 stale 标。连续 429 走指数退避,退避窗口内不打 API。
|
|
298
|
+
* 永不抛出 —— 失败状态由 `state` 字段表达,卡片层按 state 分支渲染。 */
|
|
252
299
|
export async function readUsage(): Promise<UsageSnapshot> {
|
|
253
|
-
|
|
300
|
+
// 429 退避窗口内不打 API。cache 里可能存的就是 rate_limited 失败态,
|
|
301
|
+
// withStaleFallback 会自动用 lastOk 顶上(15min 内)。
|
|
302
|
+
if (Date.now() < rateLimitedUntil) {
|
|
303
|
+
return withStaleFallback(cache?.data ?? { state: 'rate_limited' })
|
|
304
|
+
}
|
|
305
|
+
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return withStaleFallback(cache.data)
|
|
254
306
|
if (inFlight) return inFlight
|
|
255
307
|
inFlight = fetchUsage()
|
|
256
|
-
.then(d => {
|
|
308
|
+
.then(d => {
|
|
309
|
+
cache = { data: d, at: Date.now() }
|
|
310
|
+
if (d.state === 'ok') lastOk = { snapshot: d, at: Date.now() }
|
|
311
|
+
if (d.state === 'rate_limited') {
|
|
312
|
+
rateLimitedCount += 1
|
|
313
|
+
rateLimitedUntil = Date.now() + rateLimitedBackoffMs(rateLimitedCount)
|
|
314
|
+
} else {
|
|
315
|
+
rateLimitedCount = 0
|
|
316
|
+
rateLimitedUntil = 0
|
|
317
|
+
}
|
|
318
|
+
inFlight = null
|
|
319
|
+
return withStaleFallback(d)
|
|
320
|
+
})
|
|
257
321
|
.catch(e => {
|
|
258
322
|
log(`usage: fetchUsage threw: ${e}`)
|
|
259
323
|
inFlight = null
|
|
260
|
-
return
|
|
324
|
+
return withStaleFallback({ state: 'network', reason: String(e) })
|
|
261
325
|
})
|
|
262
326
|
return inFlight
|
|
263
327
|
}
|