在输入框上方显示 Command Code 套餐额度:还剩多少、还能撑多久、会不会超

一个 Claude Code mod:把 Command Code 套餐额度画在输入框上方,让你一眼看清 三个额度窗口各自还剩多少、照当前速度会走到哪。
GOAT 5h ██████████████ 3.0% │ 周 ██████████████ 16% │ 月 ██████████████ 52% 1 详情
└ 1 亮 + 13 暗 └ 2 亮 + 12 暗 └ 7 黄 + 7 红(会冲过上限)
最左边是会员名(Go / GOAT / Pro / Max)—— 那是你花钱买的那一档,会分好几种。 它的颜色跟着档位走(success / warning / error),所以「红色会员名 = 有问题」 这个信号还在,但不需要再写一个字。
曾经这里放的是档位词(
宽裕/偏紧/吃紧/断粮)。用户原话: "这偏紧两个字放在这毫无意义……你要么就不加,或者你说啊,我们这是 goat 的会员。" 档位由颜色和三个窗口的条已经说清楚了,重复写一遍只是占地方。 拿不到会员名时才退回档位词 —— 总比空着强。

<sub>上图由 tools/mock.mjs 调用真实的布局函数渲染,与真机同源;配色是近似值,以实际主题为准。</sub>
Claude Code 的 statusLine 在 Claude Desktop 的 Code tab 里不渲染 —— 你自己写的 statusLine 脚本在终端里正常,切到 Desktop 就是一片空白。
mod 的 AbovePrompt band 是那个表面上唯一能常驻放东西的地方,所以有了这个。
三个窗口等权重地摆在一行,各自一条条,中间用 │ 分隔。
条上全程只用 █ 一个字符,靠颜色区分三段:
| 颜色 | 含义 |
|---|---|
| 亮色(绿 / 黄 / 红) | 已经花掉的 |
| 红色 | 按当前速度还将花掉的(只在会冲过上限时出现) |
| 暗色 | 照这个速度会剩下来的(余量) |
会不会爆表不用读字:右半截变红、暗色余量段消失,就是同一件事的两种画法。 轴固定 0→100%,不画刻度 —— 条的末端就是上限。
撑得住时「已用」和「落点」同色合成一根完整的条,于是:
一根条一个颜色 = 安全;出现红色 = 会爆表。
这点和普通的进度条不一样。普通条只说「现在到哪了」;这一根同时说「照这么走下去会到哪」。
早期版本用 █ / ▒ / ░ 表示 100% / 50% / 25% 三种墨量。实际渲染出来,同一根条看起来 像三种不同的东西拼在一起,高矮粗细对不齐,很别扭。现在三段是同一个字形,天然对齐。
同理,没有图标。曾经用 ◑(半填充圆)表示档位,实测在真实字体里会渲染成一个 大圆圈,既不协调也没人认得。档位现在只靠颜色 + 词(宽裕 / 偏紧 / 吃紧 / 断粮 / 采样中)表达。
设计上踩过两次坑,都记在这里:
现在三条给同样的宽度、一起伸缩,谁也不会把谁挤没。挤不下时按这个顺序让位: 先丢条 → 再丢档位词,但三个窗口的标签和百分比永不丢。 50 列时退化成 5h 3.0% │ 周 16% │ 月 52% 1 详情,仍然一眼看得全。
顺序是反过来的:早期版本先丢档位词、再去纠结条。后果是窄屏下既没有条、也没有结论, 只剩三个裸数字。条和百分比说的是同一件事,档位词是整行唯一的结论 —— 该先牺牲谁很清楚。
如果连这个最精简的骨架都放不下(窄于约 20 列),退化成一个百分比(月 52%), 而不是什么都不画:空白和「mod 没装」长得一模一样,说不出任何事。
填充率 52% 看着安全,但按当前速度会提前 5 天烧完 —— 画成绿色就是骗人。 所以月额度的颜色由节奏决定:
| 情况 | 档位词 | 条的颜色 |
|---|---|---|
| 还有余量、撑得到周期结束 | 宽裕 | 绿 |
| 会提前烧完,但还能撑 > 3 天 | 偏紧 | 黄 |
| 会提前烧完,且只剩 ≤ 3 天可撑 | 吃紧 | 红(加粗) |
| 余量为零 | 断粮 | 红(加粗) |
| 周期太短、样本不足 | 采样中 | 不取色 |
判据是「还能撑多久」,不是「缺口多少天」。 这两者方向相反: 早 13 天烧完比早 1 天烧完严重得多,但按缺口天数判会给前者「偏紧/黄」、 后者「吃紧/红」—— 颜色和紧挨着的数字对着干(「吃紧」配「还能撑 15d」、「偏紧」配「还能撑 2.3d」)。 早期版本就是这么写的,README 和测试都把反向判据一起固化了,所以一直没被发现。
引擎对 color / backgroundColor / borderColor 只做一次检查:
typeof t === "string" && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)
不过就判整棵 ui.render 树不合法 → 整棵丢弃、改画引擎自己的版本(= 空白), 屏幕上没有任何提示,只在日志里留一句 a hook returned a tree that does not validate。 而 claude plugin validate 抓不到这一条。
所以:
ansi:red 这种写法必然失败。用主题令牌 success / warning / error(它们在字符集内,而且会跟着明暗主题走)。return Box({ children: [rest, mine] }) 时 三棵树会合并成一棵再一起校验 —— 一处颜色写错,三个 mod 一起消失。 这就是「mod 突然全没了、查了半天」的那次事故。textOf() 出门前会先过一遍这个字符集,不合法的颜色直接不写: 少一个颜色只是少一个颜色,不会连累别人。tools/tree_lint.mjs 把引擎这套规则重实现了出来(属性白名单、枚举值、容量上限、 颜色字符集),tests/tree.test.ts 拿它驱动真实的 register.mjs, 跑 8 个数据态 × 10 个列宽 × 桌面/手机 × band/pane。它是一道网,不是保证 —— 引擎改规则而我们没跟上,它会漏。
月度用「已过 5% 周期」就能给结论,因为它的消耗是匀速的。 滚动窗口不行 —— 实测 5h 窗口跑了 16 分钟、用了 7.4%,按 5% 门槛恰好放行, 外推出来是 139% 的假警报。
现在滚动窗口要求已过 20% 的窗口长度(5h 要跑满 1 小时、周窗口要过 1.4 天) 才给落点,之前那条只画普通的水平条。
(原版 commandcode-usage 干脆不在状态栏显示 5h 的预测,也是这个原因。)
| 操作 | 效果 |
|---|---|
空输入框里按 1 | 打开详情面板(不用点、不用切焦点) |
| 面板里改「试算」的数字 + 回车 | 换一个花法会怎样 —— 纯本地算术,立刻算出新的断粮时刻 |
面板里 1 刷新 / 2 复制快照 | 立即取数 / 复制一屏可贴的摘要 |
/quota | 打印完整的文本快照 |
/quota models | 打印完整的模型次数表(markdown 表格)。会强制重抓一次官方文档页 —— 显式要表就是要现在的真相 |
Esc | 关面板 |
面板里还有:
偏紧 预计 10/11 01:27 断粮 · 还能撑 5.3d。第二行是 周期末预计 $95 / $70.00 会超 $25.00。 这两件是用户打开面板想知道的唯二的事,所以它们在第一屏 —— 以前它们夹在三行裸数字和一串参考值中间,还得自己找。周期已过 47%,额度已用 72%。 单看「已用 72%」没有参照系:配上前半句才知道是花快了。差值超过 10 个点标黄。每天 ≤ $2.50 就不会超。把「会超」这个结论翻译成一个当天就能执行的目标。 以前它只在试算成功之后才出现,等于藏在"你得先试算一次"后面。PANE_MODELS 可调):按每月次数降序 (次数最多 = 每美元买到最多请求),新增/改价的钉在最前面并在名字右侧标 ★新(绿)/ ↑价(黄)。放不下的走 /quota models。10/11 01:27)而不是"还剩 5.3 天"这个长度。 长度可以拖,时刻不能;人会自动把时刻跟日历上别的事对照,所以它比天数扎心得多。这样会烧到 04/01 00:13 —— 撑得到周期末。从上到下是紧迫度递减的一条轴,块之间用空行分隔:
| 块 | 回答 | 视觉手段 |
|---|---|---|
| 结论(2 行) | 够不够 / 何时断 / 会超多少 | 全篇唯一的加粗彩色 |
| 三个额度窗口(3 行) | 现在到哪了 | 唯一的进度条 + 加粗百分比 |
| 节奏(4 行) | 照这花法会怎样、该改成怎样 | 全暗,想深究才看 |
| 模型(标题 + N 行) | 哪些便宜好用 | 参考信息 |
| 工具(试算 / 快照 / 按钮) | 动手 | 最下 |
永不砍:第一行结论、三个窗口的名字 + 百分比(README 两次踩坑换来的硬约束)。
模型次数是两列对齐的表,但没有框线。这个结论是失败两次换来的:
| 版本 | 做法 | 怎么坏的 | |
|---|---|---|---|
| 一 | 框线用 box-drawing(┌─┬┐│) | 它们是 East Asian Ambiguous 宽度,占一格还是两格由字体决定,中文环境下常被渲染成两格。宽度按一格算 → 横线比表格长一倍,整张表散架 | |
| 二 | 换成 ASCII 框线(`+ - | `) | 字符宽度没问题了,但一行被切成了好几个段(名字 / 标记 / 数字,为了给标记单独染色)。面板把每段渲染成独立的文本节点,而节点边界的空白会被吃掉 —— 补齐的空格正好落在边界上,列全歪 |
| 三 | 不画线,一行一个单段字符串 | ✅ |
第三版的两条要点:
{ text } 一个段。 补齐的空格是这段文字的一部分,不会被重算, 所以列对得齐。tests/catalog.test.ts 里有断言钉着:每行 r.length === 1。 (代价:整行一段,标记没法单独染色 —— 染了整行都会变绿。★新 / ↑价 靠字形本身醒目。)模型次数 · 每月可调用次数(越多越省)· 51 个 · 官方 16 分钟前更新
模型 每月调用
MiMo V2.6 Flash ★新 64,900
LongCat 2.0 ★新 64,100
Jev 595,000
DeepSeek V4 Flash (latest) 154,000
… 其余 45 个 · /quota models 看全表
顺带记一条同样重要的:对齐不能靠定宽 Box。 Box 的 width 只是 flex-basis, 而引擎给 Box 的默认值里有 flexShrink: 1 —— 主轴放不下时定宽会被一路压回内容宽, 短名字那行的数字就跑到左边去。真要用定宽得 width + minWidth 同值 + flexShrink: 0, 且列宽之和 ≤ 面板列数。证据:CLI 里 jg = {…, flexShrink:1, …} 与 props→yoga 桥接的 setFlexShrink/setWidth。
会话里的 /quota models 是两回事:它走 markdown 渲染,连续空格会被吃掉, 所以那里用真正的 markdown 表格(---: 让数字列右对齐),交给渲染器排 —— 那个反而是最好看的。
点「详情」默认打开的是停靠的侧边栏,实测 bodyColumns = 50(band 是 95)。 一切以 50 列为准:表格在这个宽度下是 45 格,每一行精确等宽。 展开的大面板更宽,同一套布局自然铺开。
额度跨档时主动弹一次 toast(每档每周期只报一次)。读数陈旧超过两个刷新周期时整行转暗。
需要 Claude Code v2.1.287 或更新(mods 是那个版本引入的)。
claude --plugin-dir /path/to/runway
热重载:改完存盘即生效。
Desktop 传不了命令行参数,所以用这个环境变量 —— 它就是为「拿不到 flag 的 app」设计的, 写在 ~/.claude/settings.json 里:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/runway"
}
}
Windows 上多条路径用 ; 分隔,其他平台用 :。撤销就是删掉这一行。 用 claude plugin list 看 runway@inline 是否 loaded。
~/.claude/settings.json 是多个写入方共用的一个文件。桌面应用按它自己那份状态 整份覆盖时,会把别人写进去的键一起抹掉:enabledPlugins 里的 mod 没了, env.CLAUDE_CODE_PLUGIN_DIRS 整个键消失 —— 表现就是所有 mod 同时不显示, 而文件本身语法完全正确,看不出哪儿错了。(已实测过:claude plugin validate / claude plugin test 不会写这个文件,所以不是它们干的。)
恢复:
node tools/restore-settings.mjs # 检查 + 补回(先自动备份)
node tools/restore-settings.mjs --dry-run # 只看要改什么
它只碰那两个键,其余原样保留;幂等,好的时候什么都不做;写完会复读一遍确认没被 别的进程抢写。跑完要重启应用才会重新加载。
更抗覆盖的通道:《mod 参考》里写明
CLAUDE_CODE_PLUGIN_DIRS可以从进程环境变量 或 settings 的env块读取。设成用户级环境变量就不会被应用覆盖 —— 代价是它要 重新登录(或重启资源管理器)才生效,而且别和 settings 里那份同时存在, 否则同一个目录会被注册两次、画两遍。
把本仓库当作 marketplace 加进去:
claude plugin marketplace add Jovan1666/claude-code-runway
claude plugin install runway@claude-code-runway --scope user
每一个显示出来的数字从哪来、多久更新一次、官方变了会怎样 —— 见 docs/data-sources.md。 那张表把 live / derived / 写死的 分得很清楚,也包括剩下的写死项和已知偏差。
打 api.commandcode.ai 的三个端点:
GET /alpha/billing/credits?orgId= → windowLimits.{fiveHour,weekly} + credits
GET /alpha/billing/subscriptions?orgId= → 计划与计费周期
GET /alpha/usage/summary?since=<周期起点> → 花费与均价
/alpha/whoami 省掉了:实测它返回的 org 恒为 null,而 credits 并不需要 orgId。
凭据按顺序找:
COMMAND_CODE_API_KEY / COMMANDCODE_API_KEY / CMD_API_KEYsettings.json 的 env 段中名字含 commandcode 的项access.apiKey + 匹配 commandcode.ai 的 api.baseUrl, 按 ~/.zcode/v2/provider_config.json → ~/.config/opencode/opencode.json → ~/.pi/agent/settings.json → ~/.claude/settings.json 的顺序找读不到凭据就什么都不画 —— 不占位置,也不报错。取数失败保留上一次读数并标注陈旧。
刷新:会话启动取一次,之后每 180 秒一次,每轮对话结束后去抖(15 秒)补一次。 渲染路径永远不发网络请求 —— ui.render 每帧都跑。
滚动窗口的落点用重置时刻倒推窗口起点: elapsed = 窗口长度 − 距下次重置的时间(5h 窗口 5 小时、周窗口 7 天)。
「这个套餐下每个模型还能调用多少次」不在 API 里 —— /alpha/* 不暴露那张表。 官方只把它公布在公开文档页上,所以这一项走另一个来源:
GET https://commandcode.ai/docs/plans/<slug> 头:RSC: 1
返回的是 Next.js 的 flight 流(text/x-component,约 200 KB)。解析不按 record id (每次部署都会变),只按结构签名找那条记录。口径和官方页面一致:
每次成本 = 输入/1e6×输入单价 + 输出/1e6×输出单价 + 缓存读/1e6×缓存读单价
月次数 = 该模型的 budgetUsd ÷ 每次成本 5h = 月 × fiveHourFraction
周 = 月 × weeklyFraction
每个模型有自己的 budgetUsd(GOAT 里 DeepSeek V4.1 Flash 是 $60、GPT-5.6 Sol 是 $70), 套餐级的 $70 不参与这条公式 —— 官方两处口径不同,不能互相推算,界面上也照实这么写。
| 刷新 | 24 小时一次(模型上下架、调价是天级的事,跟着 180 秒抓等于敲人家的文档页)。失败隔 6 小时再试 |
| 变更标记 | 每次抓回来和上一份比:★新 / ↑改价,并钉在表面(用户要的就是这个);内容没变就不推进"上一份",免得标记被洗掉 |
| 降级 | 抓失败保留旧目录并标出「多久之前抓的」;从没抓到过就整段不显示,不影响额度条 |
| 页面 | 官方 planId → 文档页 slug:individual-goat→goat、individual-go/-go-v1→go、individual-max/-ultra→max。Go 改过代,窗口系数按 planId 覆盖(老的 0.2/0.5,新的 0.3/0.6) |
⚠️ 官方忽略 If-* 条件头,所以没有用 ETag 做变更探测,直接比内容指纹。
node tools/preview.mjs # 取真实数据,打印额度条 / 面板 / 命令输出(纯文本)
node tools/preview.mjs --json # 额外 dump 归一化后的对象
node tools/mock.mjs --demo # 用合成数据写出带颜色的视觉稿 tools/mock.html
node tools/mock.mjs --real # 用真实读数出图(默认拒绝,见下)
node tools/privacy-scan.mjs # 提交前跑:仓库里有没有混进本机的真实读数
claude plugin validate . # 静态检查:列出 hooks、$ 调用、读写哪些环境变量
claude plugin test . # 纯函数测试,不需要会话或网络
这个口子栽过三次,每次都是同一个动作 —— 把真机屏幕上的数字抄进测试夹具或文档 (金额、请求数、均价三者一起出现就等于公开账本;有一次还写进了"别抄真实数据"的警告注释里)。
所以有一道自动检查。它不维护"敏感值清单"(清单本身就是泄露),而是读本机此刻的读数 (mod 自己缓存的那份)再拿去 grep 仓库:
node tools/privacy-scan.mjs # 命中就以非 0 退出,别提交
node tools/privacy-scan.mjs --all # 连未跟踪的文件一起扫
局限(它是一道网,不是保证):只抓"此刻缓存里还在"的数;很久以前抄进去、 缓存已经滚过去的抓不到;四舍五入改写过也抓不到。所以夹具值仍然必须自己编, 照 tests/quota.test.ts 那份合成基准写,而且要编得像样。
claude plugin test 跑三个文件,66 条:
| 文件 | 管什么 |
|---|---|
tests/quota.test.ts | 额度换算、落点条、排版、降级顺序;颜色必须过引擎的字符集 |
tests/catalog.test.ts | 模型目录的解析与换算、新增/改价、表格排版(列宽契约) |
tests/tree.test.ts | 把真实的 register.mjs 产出的树喂进 tools/tree_lint.mjs:8 数据态 × 10 列宽 × 桌面/手机 × band/pane |
tools/tree_lint.mjs 把引擎那套 ui.render 树校验重实现了一遍(属性白名单、 枚举值、容量上限、颜色字符集),常量逐条对得上 CLI 二进制。它是防「整条 band 静默消失」 的护栏 —— claude plugin validate 抓不到这一类。它是一道网,不是保证: 引擎改规则而这里没跟上,它会漏。
视觉稿默认拒绝真实读数。 这个工具的产物
tools/mock.html就是拿来截图贴进 本文档的,真实读数一旦落到docs/就跟着仓库一起公开了。 所以不带--demo时必须显式加--real才会取真实数据。视觉稿的每一个候选框都按标注的列数定宽,标注里给出「实宽 / 可用」, 超宽标红。这是为了能看见溢出:早期版本用
overflow-x:auto让浏览器自己横向回流, 于是 58 列的面板和 76 列的面板在视觉稿里一样宽 —— 面板比它声明的列数 宽 12 格这件事从来没被看出来过。框的像素宽度只是近似:中文字体在浏览器里的字宽与终端的「列」不严格相等, 判定以标注里的数字为准。
2.1.288 上的一个坑:本仓库根目录同时有
.claude-plugin/marketplace.json, 而 2.1.288 的claude plugin validate遇到这种情况会只校验 marketplace、 跳过插件本身(不再输出hooks:/calls:那套静态分析)。这是 2.1.289 修掉的 已知 bug。在 2.1.288 上想看静态分析,复制一份去掉 marketplace 清单再校验:T=$(mktemp -d) && cp -r . "$T/" && rm -f "$T/.claude-plugin/marketplace.json" && rm -rf "$T/.git" claude plugin validate "$T"; rm -rf "$T"
tools/mock.mjs 为什么必须存在mod 的 band 只在真实会话里渲染,写代码的模型看不到自己的输出。
这个项目的整个过程就是在证明这一点:早期版本纯靠想象设计,结果条糊成一坨、图标没人 认得、面板还因为一个未定义变量整片空白。后来把 layoutRow / layoutPane 抽成纯函数, hooks/register.mjs 把「段」变成元素树、tools/mock.mjs 把同一批「段」变成 HTML —— 同一份布局两个出口,于是浏览器里看到的就是屏幕上的,可以反复比对再定稿。
lib/quota.mjs 里不出现 $:mods 的静态检查只允许把 $ 传给同一文件里顶层声明的 函数,跨文件传会验证失败 —— 把纯逻辑分出去正好避开这条约束,顺便让它可测、可预览。
commandcode.ai/docs 只承认 /provider/v1/* (chat / responses / models 那些),billing / credits / usage / plans 一律不在文档里 —— /alpha/* 是 Studio 网页自己在用的内部路由(实测探了 20 个候选端点,除 /alpha/whoami 外全 404,404 正文自述 "is not a registered API route")。所以它随时可能变更或收紧。 这也是为什么月额度只能靠"已花 + 余额"推 —— 官方没有给这个数的端点。claude -p、云会话不绘制。monthlyCredits)和滚动窗口的上限, 没有"这个档位一个月多少"。所以用 本周期已花 + 当前余额 推(summary 带 since=<周期起点>)。 官方调价/改档时它会跟着变,但如果你另买了额度,余额含购买部分而"已花"不含 → 分母偏大、月百分比偏低。写死的档位上限只在连 summary 都拿不到时兜底。claude -p、云会话不绘制 —— 那些场合用 /quota 命令。ui.render 交回坏树可能让整个会话以 unrecoverable interface error 结束(2.1.289 才改成引擎自己兜底)。本 mod 在渲染层 做了 try/catch,但建议用 2.1.289 或更新。plugin.json 里声明了 dependencies: ["token-weather@claude-code-playground-mods"]。
band 的多个 mod 是一条中间件链,某个 hook 返回自己的树而不调 next(e) 就短路了排在它 后面的所有 mod。上游的 token-weather 在有读数时正是这么做的,会把排在它后面的 band mod 整个吃掉 —— 表现是「装了但永远不显示」, 而且极难排查。
本机的副本已经修过(两处,同一个毛病):
| 文件 | 改动 |
|---|---|
token-weather.mjs | 两条 drawing 路径都改成 Box({ children: [rest, mine] });e.props 而不是 e 读宽度;无读数时画一行状态而不是静默让位 |
replay-theater.mjs | 同样:两个分支都裹 next(e);e.props 读 maxRows / bodyColumns |
这些改动没有推回上游 —— claude-code-playground 是 Anthropic 的仓库,不是我们的。 所以换一台机器重装这两个 mod,就得重新打一遍。
而「装了 mod 的插件里,声明了 dependencies 的那个先跑」,所以这是唯一能保证顺序的手段。 代价是 token-weather 会被连带启用。
本 mod 一定 await next(e) 并把别人的树嵌进外层 Box,不会反过来吃掉谁。 不想装 token-weather 的话,把这行删掉即可 —— 但如果它在你之后加载,这行就不会显示。
MIT。见 LICENSE。
Command Code 是其各自所有者的商标;本项目与它没有隶属关系。
hooks/register.mjs 728 lines1// runway · hooks 模块
2//
3// 把 Command Code 套餐的"还剩多少 / 还能撑多久"画在输入框上方。
4//
5// 三条硬规矩(写错就白干):
6// 1. `$` 只能原样写 `$.noun.method(...)`,不能赋给变量、不能解构、
7// 不能传给对象成员方法。只允许传给**本文件顶层声明的函数**。
8// 2. `ui.render` 里**绝不发网络请求** —— 它按输入值缓存,但一
9// invalidate 就会重跑,而 await 网络会把这一帧拖住。渲染只读模块变量。
10// 3. 必须 `await next(e)` 并把它嵌进自己的 Box,否则会抹掉
11// token-weather(它在有读数时直接 return,短路口后的所有 band mod)。
12
13import {
14 computePace,
15 fmtDay,
16 fmtDays,
17 fmtMoney,
18 fnv1a,
19 layoutPane,
20 layoutRow,
21 modelTableText,
22 normalize,
23 scanForProviderKey,
24 snapshotText,
25 tierOf,
26} from '../lib/quota.mjs';
27import { buildCatalog, catalogDiff, catalogUrl, parsePlanEstimates } from '../lib/catalog.mjs';
28
29const PANE_ID = 'runway';
30const CACHE_KEY = 'runway.cache';
31const MARKS_KEY = 'runway.marks';
32const CATALOG_KEY = 'runway.catalog';
33
34const API_BASE = 'https://api.commandcode.ai';
35const TTL_MS = 180_000; // 与 cc-usage.mjs 的 cacheTtl 同值
36const TURN_DEBOUNCE_MS = 15_000;
37// 模型目录(官方文档页)换得慢 —— 天级。跟着 180s 去抓等于每三分钟打一次
38// 人家的文档站,既不礼貌也没意义。失败后隔 6h 再试,别死磕。
39const CATALOG_TTL_MS = 24 * 3600_000;
40const CATALOG_RETRY_MS = 6 * 3600_000;
41// `/quota models` 是"显式要现在这张表",所以强制重抓;但最多等这么久 ——
42// `$.http.fetch` 没有 timeout,网关黑洞会让它永远挂着,而这是个要立刻出结果的命令。
43const CATALOG_FORCE_WAIT_MS = 8000;
44// 取数的看门狗。$.http.fetch **没有 timeoutMs**(类型定义里 HttpInit 只有
45// method/headers/body/auth/socketPath),DNS 卡死或代理黑洞会让它永不 settle,
46// 而 inflight 只在 finally 里清空 —— 那样 refresh() 会永远返回同一个 pending
47// promise,此后一个请求都不再发,面板永久停在旧读数且只标"陈旧"。
48// 所以自己看时间:挂太久就当作失败,允许下一次重试。
49const FETCH_HUNG_MS = 45_000;
50
51// 凭据候选文件(相对 home)。本机命中的是第一条。
52const CONFIG_PATHS = [
53 '.zcode/v2/provider_config.json',
54 '.config/opencode/opencode.json',
55 '.pi/agent/settings.json',
56 '.claude/settings.json',
57];
58
59// ── 模块状态(热重载会清空,session.start 会重新灌回来)──
60let snap = null; // 当前读数;渲染只读它
61let snapAt = 0;
62let inflight = null;
63let inflightAt = 0;
64let lastTurnRefresh = 0;
65let lastErrorAt = 0; // 最近一次取数失败的时刻,手动刷新用它给回执
66let credFound = false; // 有没有找到凭据 —— 决定没读数时状态行写哪个原因
67
68// 模型次数目录。抓的是公开文档页(命令见 refreshCatalog),跟额度那三个
69// /alpha 接口完全不同的来源,所以生命周期也分开:它按天变,额度按秒变。
70let catalog = null;
71let catalogAt = 0;
72let catalogRetryAt = 0;
73let catalogInflight = null;
74let catalogInflightAt = 0;
75let prevModels = null; // 上一份目录的模型清单,用来比出「新增 / 改价」
76let catalogDigest = null; // 内容指纹:没变就不推进 prevModels,免得把 ★新 洗掉
77let catDiff = { added: new Set(), repriced: new Set(), removed: new Set() };
78
79let marks = { announcedCycle: null, announcedTier: null, sawDetail: false };
80let whatIf = null; // 试算输入的内容(纯本地计算,不发请求)
81const timers = [];
82
83// ════════════════════════════════════════════════════════════
84// 注册
85// ════════════════════════════════════════════════════════════
86export function register(on) {
87 on('session.start', async ($, e, next) => {
88 // 先把 next(e) 走完,别的 mod 的启动逻辑不受我们影响
89 const result = await next(e);
90
91 await hydrate($); // 只读本地 store,快
92 refresh($); // 不 await:网络请求可能永久挂起(见文件末的说明)
93 refreshCatalog($); // 同理,且它自己有 24h TTL,命中就立刻返回
94
95 const stop = $.clock.every(TTL_MS, () => {
96 refresh($);
97 });
98 timers.push(stop);
99
100 if (!marks.sawDetail) {
101 $.ui.toast('额度条在输入框上方 · 按 1 或 /quota 看明细');
102 }
103
104 // 命令最后注册;名字冲突时抛错,不能连累上面几件事
105 try {
106 await $.command.register({
107 name: 'quota',
108 description: '显示 Command Code 套餐额度与节奏',
109 });
110 } catch {
111 /* /quota 被别的插件占了,band 照常工作 */
112 }
113 return result;
114 });
115
116 on('turn.complete', async ($, e, next) => {
117 const result = await next(e);
118 // 子 agent 的 turn 不算,避免一节里刷很多次
119 if (!e.agentId && Date.now() - lastTurnRefresh > TURN_DEBOUNCE_MS) {
120 lastTurnRefresh = Date.now();
121 refresh($);
122 }
123 return result;
124 });
125
126 on('command.run', { command: 'quota' }, async ($, e) => {
127 const arg = String(e.args || '').trim().toLowerCase();
128 if (arg === 'models' || arg === 'm' || arg === '模型') {
129 if (!catalog) await hydrate($);
130 // 显式要这张表 = 要**现在**的真相,所以强制重抓一次
131 // (后台那份有 24h TTL,是给"随手看一眼"用的)。
132 //
133 // 但不能无限等:没有 timeout 的请求遇上网关黑洞会一直挂着,
134 // 而这是个要立刻出结果的命令。等一小会儿拿不到,就拿手里这份旧的 ——
135 // 表头会写清它是多久之前抓的,不会假装新鲜。
136 await Promise.race([
137 refreshCatalog($, true) || Promise.resolve(),
138 new Promise((r) => setTimeout(r, CATALOG_FORCE_WAIT_MS)),
139 ]);
140 return { text: modelTableText(catalog, catDiff, Date.now()) };
141 }
142 if (!snap) await hydrate($);
143 // 必须 await:refresh 是 async,不 await 的话下面那句判的还是 null,
144 // /quota 在冷启动时 100% 返回"取不到",而网络其实几十毫秒后就好了。
145 if (!snap) await refresh($);
146 if (!snap) {
147 return { text: '额度暂时取不到:没找到 Command Code 凭据,或接口不可达。' };
148 }
149 return { text: snapshotText(snap, Date.now()) };
150 });
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 const rest = await next(e); // 别人的 band(token-weather 就在里面)
154 // 宽度和问卷标记都在 **e.props** 里,不在 e 上。
155 // 以前读 e.bodyColumns / e.hasSurvey,两者恒为 undefined ——
156 // 后果是 band 永远按兜底的 80 列排版(宽屏浪费、窄屏溢出),
157 // 而且问卷出现时不让位,两块内容叠在一起。
158 // 这句在 try 之外,所以对 props 缺失也要免疫(缺了就当没有问卷)。
159 if (e.props && e.props.hasSurvey) return rest; // 有问卷时让位
160 // 2.1.288 上,ui.render 抛错或交回坏树会让**整个会话**以
161 // "unrecoverable interface error" 结束(2.1.289 才改成引擎自己兜底)。
162 // 所以在自己这层就吞掉,画不出来就让位。
163 try {
164 // 没读数时不再直接让位:那样"没装"和"装了但取不到数"长得一模一样,
165 // 只能靠猜该去查配置还是该去查网络。改画一行状态,把原因写在屏幕上。
166 const row = snap ? renderRow($, e) : renderStatus($, e);
167 if (!row) return rest;
168 if (!rest) return row;
169 const { Box } = $.ui.resolve(e);
170 return Box({ flexDirection: 'column', children: [rest, row] });
171 } catch {
172 // 画不出来就整块让位。引擎那边(2.1.288)交回坏树会让整个会话
173 // 以 "unrecoverable interface error" 结束,所以宁可不画。
174 return rest;
175 }
176 });
177
178 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
179 if (e.requestId !== PANE_ID || !snap) return next(e);
180 const rest = await next(e);
181 try {
182 return renderPane($, e);
183 } catch {
184 return rest;
185 }
186 });
187
188 on('session.end', async ($, e, next) => {
189 for (const timer of timers.splice(0)) {
190 try {
191 // $.clock.every 返回的是 { cancel() } 对象,不是函数。
192 // 以前这里写 `typeof stop === 'function'` → 恒为 false →
193 // 定时器在 session.end 从来没被取消过一次。
194 if (timer && typeof timer.cancel === 'function') timer.cancel();
195 } catch {
196 /* 定时器可能已经不在 */
197 }
198 }
199 return next(e);
200 });
201}
202
203// ════════════════════════════════════════════════════════════
204// 取数(全部接受 $ 作为参数的函数都在本文件顶层)
205// ════════════════════════════════════════════════════════════
206
207function readDoc(raw) {
208 if (raw && typeof raw === 'object') return raw;
209 if (typeof raw === 'string' && raw) {
210 try {
211 return JSON.parse(raw);
212 } catch {
213 return null;
214 }
215 }
216 return null;
217}
218
219async function hydrate($) {
220 try {
221 const doc = readDoc(await $.store.get(CACHE_KEY));
222 if (doc && doc.view) {
223 snap = doc.view;
224 snapAt = doc.savedAt || 0;
225 }
226 } catch {
227 /* store 读不到就等着网络 */
228 }
229 try {
230 const m = readDoc(await $.store.get(MARKS_KEY));
231 if (m && typeof m === 'object') {
232 marks = {
233 announcedCycle: m.announcedCycle ?? null,
234 announcedTier: m.announcedTier ?? null,
235 sawDetail: Boolean(m.sawDetail),
236 };
237 }
238 } catch {
239 /* 同上 */
240 }
241 try {
242 const c = readDoc(await $.store.get(CATALOG_KEY));
243 if (c && c.catalog && Array.isArray(c.catalog.models)) {
244 catalog = c.catalog;
245 catalogAt = c.catalog.fetchedAt || c.savedAt || 0;
246 prevModels = Array.isArray(c.prevModels) ? c.prevModels : null;
247 catalogDigest = c.digest ?? null;
248 // 重新比一遍,这样"★新"在重启后依然认得出
249 catDiff = catalogDiff(prevModels ? { models: prevModels } : null, catalog);
250 }
251 } catch {
252 /* 没目录也能用,只是面板里少一段 */
253 }
254}
255
256function saveMarks($) {
257 $.store.set(MARKS_KEY, marks).catch(() => {});
258}
259
260// 凭据:先三个字面量环境变量,再 settings 的 env 段,最后读配置文件。
261// (`$.env.get` 只吃字符串字面量,所以"遍历环境变量找含 commandcode 的键"这条路走不通。)
262async function findCredential($) {
263 const KEY_SHAPE = /^(user_|cc_)[A-Za-z0-9_-]{8,}$/;
264
265 const a = await $.env.get('COMMAND_CODE_API_KEY');
266 if (a && KEY_SHAPE.test(a.trim())) return { apiKey: a.trim(), apiBase: null };
267
268 const b = await $.env.get('COMMANDCODE_API_KEY');
269 if (b && KEY_SHAPE.test(b.trim())) return { apiKey: b.trim(), apiBase: null };
270
271 const c = await $.env.get('CMD_API_KEY');
272 if (c && KEY_SHAPE.test(c.trim())) return { apiKey: c.trim(), apiBase: null };
273
274 try {
275 const s = await $.settings.read();
276 const env = s && typeof s.env === 'object' && s.env ? s.env : null;
277 if (env) {
278 for (const name of Object.keys(env)) {
279 if (!/commandcode/i.test(name)) continue;
280 const val = env[name];
281 if (typeof val === 'string' && KEY_SHAPE.test(val.trim())) {
282 return { apiKey: val.trim(), apiBase: null };
283 }
284 }
285 }
286 } catch {
287 /* settings 读不到就继续 */
288 }
289
290 let home = '';
291 try {
292 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '';
293 } catch {
294 home = '';
295 }
296 if (!home) return null;
297 const sep = home.includes('\\') ? '\\' : '/';
298
299 for (const rel of CONFIG_PATHS) {
300 const path = home + sep + rel.split('/').join(sep);
301 let text;
302 try {
303 text = await $.fs.read(path);
304 } catch {
305 continue; // 不存在,或超过单文件 4 MiB
306 }
307 let doc;
308 try {
309 doc = JSON.parse(text);
310 } catch {
311 continue;
312 }
313 const hit = scanForProviderKey(doc);
314 if (hit) {
315 return {
316 // https://api.commandcode.ai/provider/v1 → https://api.commandcode.ai
317 apiBase: hit.baseUrl.replace(/\/provider\/v\d+\/?$/, '').replace(/\/+$/, ''),
318 apiKey: hit.apiKey,
319 };
320 }
321 }
322 return null;
323}
324
325async function getJson($, url, headers) {
326 const res = await $.http.fetch(url, { method: 'GET', headers });
327 // $.http.fetch 对非 2xx 不抛错,必须自己判,否则会解析到错误正文
328 if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : 'no-response'));
329 const body = JSON.parse(res.text);
330 if (!body || typeof body !== 'object') throw new Error('non-JSON body');
331 return body;
332}
333
334// 模型目录走的是**公开文档页**,不是 /alpha 接口 ——
335// 官方只在 docs 站上公布「各模型能用多少次」这张表,API 不暴露它。
336// 头只有 `RSC: 1`(Next.js 的 flight 流),不需要凭据。
337function refreshCatalog($, force) {
338 if (catalogInflight && Date.now() - catalogInflightAt < FETCH_HUNG_MS) return catalogInflight;
339 const now = Date.now();
340 if (!force) {
341 if (catalog && now - catalogAt < CATALOG_TTL_MS) return null;
342 if (catalogRetryAt && now < catalogRetryAt) return null;
343 }
344 const planId = snap && snap.plan ? snap.plan.id : null;
345 const url = catalogUrl(planId);
346 // 没有对应文档页的档位(比如 teams-pro)就干脆不抓,别去猜。
347 if (!url) return null;
348
349 catalogInflightAt = now;
350 catalogInflight = (async () => {
351 try {
352 const res = await $.http.fetch(url, { method: 'GET', headers: { RSC: '1' } });
353 if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : 'no-response'));
354 const raw = parsePlanEstimates(res.text);
355 if (!raw) throw new Error('parse-mismatch');
356 const next = buildCatalog(raw, planId, Date.now());
357 if (!next) throw new Error('empty-catalog');
358
359 // 只有内容真的变了才把"上一份"往前挪 ——
360 // 否则每隔 24h 重抓一次同样的内容,会把 ★新 的标记洗掉。
361 const digest = fnv1a(next.models.map((m) => m.name + '|' + m.budgetUsd + '|' + m.costPerRequest).join('\n'));
362 const changed = digest !== catalogDigest;
363 if (changed) {
364 prevModels = catalog ? catalog.models : prevModels;
365 catDiff = catalogDiff(prevModels ? { models: prevModels } : null, next);
366 }
367 catalog = next;
368 catalogAt = next.fetchedAt;
369 catalogDigest = digest;
370 catalogRetryAt = 0;
371 saveCatalog($);
372 $.ui.invalidate('ui.render');
373 } catch {
374 // 抓不到就保留旧目录;界面会标出"多久之前抓的"。
375 // 隔 6h 再试,别每三分钟敲人家的文档页一次。
376 catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
377 } finally {
378 catalogInflight = null;
379 }
380 })();
381 return catalogInflight;
382}
383
384function saveCatalog($) {
385 try {
386 $.store
387 .set(CATALOG_KEY, { savedAt: Date.now(), catalog, prevModels, digest: catalogDigest })
388 .catch(() => {});
389 } catch {
390 /* 存不下不影响这次会话 */
391 }
392}
393
394// 重新取数。不 await 网络的那条路是刻意为之:$.http.fetch 没有 timeoutMs,
395// 而 $ 调用的耗时又不计入 hook 预算 —— 挂起会一直挂着,所以绝不让它挡住 hook。
396// 代价是必须自己看时间(见 FETCH_HUNG_MS),否则一次挂起就永久静默。
397function refresh($) {
398 if (inflight && Date.now() - inflightAt < FETCH_HUNG_MS) return inflight;
399 inflightAt = Date.now();
400 inflight = (async () => {
401 try {
402 const cred = await findCredential($);
403 if (!cred) {
404 credFound = false;
405 lastErrorAt = Date.now();
406 return;
407 }
408 credFound = true;
409 const digest = fnv1a(cred.apiKey);
410
411 const cached = readDoc(await $.store.get(CACHE_KEY));
412 const sameAccount = cached && cached.digest === digest;
413 if (sameAccount && cached.view && Date.now() - (cached.savedAt || 0) < TTL_MS) {
414 adopt(cached.view, cached.savedAt, $);
415 return;
416 }
417
418 const base = (cred.apiBase || API_BASE).replace(/\/+$/, '');
419 const headers = {
420 Authorization: 'Bearer ' + cred.apiKey,
421 'Content-Type': 'application/json',
422 'User-Agent': 'runway-mod/0.1.0',
423 };
424
425 // whoami 整个省掉:实测 org 恒为 null,credits 不需要 orgId。
426 // 三个请求**并行**。
427 //
428 // 以前 summary 串行等 subscriptions,就为了拿它的周期起点当 `since`。
429 // 实测(2026-10-05 逐端点验过)不必:
430 // · `since` 是已注册参数(传坏值 400;`from`/`start`/`after` 那些一律被忽略);
431 // · 但它只是"事件时间下界",而**不传时返回的本来就是当前计费周期**
432 // (响应里 `periodBasis: "billing-period"`)—— 传周期起点、传更早、不传,三者数值相同。
433 // 所以周期起点优先用上一轮的缓存,拿不到就不传(等价),换来少一次往返。
434 //
435 // 顺带:这正是"已花 + 余额 = 月总额"成立的原因 —— summary 就是本周期口径。
436 const sinceMs = snap && snap.plan ? snap.plan.periodStartMs : null;
437 const since = sinceMs ? new Date(sinceMs).toISOString() : null;
438 const [credits, subs, summary] = await Promise.all([
439 getJson($, base + '/alpha/billing/credits', headers),
440 getJson($, base + '/alpha/billing/subscriptions', headers),
441 getJson(
442 $,
443 base + '/alpha/usage/summary' + (since ? '?since=' + encodeURIComponent(since) : ''),
444 headers,
445 ),
446 ]);
447
448 const now = Date.now();
449 // 不把 apiBase / 凭据来源写进 view:它们对界面没用,
450 // 而 ~/.claude 是同步、备份、共享配置的常见目标,没有必要
451 // 在磁盘上留一份"私有网关地址 + 个人消费"的长期记录。
452 const view = normalize({ credits, subscription: subs, summary }, { now });
453 await $.store.set(CACHE_KEY, { savedAt: now, digest, view });
454 adopt(view, now, $);
455 // 额度到手了才知道 planId,正好此时决定要不要抓目录(它自己有 24h TTL)
456 refreshCatalog($);
457 } catch {
458 // 取失败:保留旧读数(下次仍会重试),界面自己会标"陈旧"。
459 // 记下时刻好让手动刷新能给一句诚实的回执;再 invalidate 一次 ——
460 // ui.render 是按输入值缓存的,不 invalidate 的话"快照 N 秒前"永远不动。
461 lastErrorAt = Date.now();
462 try {
463 $.ui.invalidate('ui.render');
464 } catch {
465 /* 还没有界面可刷新 */
466 }
467 } finally {
468 inflight = null;
469 }
470 })();
471 return inflight;
472}
473
474function adopt(view, at, $) {
475 snap = view;
476 snapAt = at || Date.now();
477 $.ui.invalidate('ui.render');
478 announce($, view);
479}
480
481// ── 事件驱动预警:只有跨档才说话,且每档每周期只报一次 ──
482function announce($, v) {
483 const pace = computePace(v, Date.now());
484 const tier = tierOf(v, pace);
485 const cycleKey = v.plan.periodStartMs ? String(v.plan.periodStartMs) : 'unknown';
486 if (marks.announcedCycle !== cycleKey) {
487 marks.announcedCycle = cycleKey;
488 marks.announcedTier = null;
489 }
490 if (marks.announcedTier === tier.word) return;
491 marks.announcedTier = tier.word;
492 saveMarks($);
493
494 // 宽裕与采样中不值得打断
495 if (tier.word === '宽裕' || tier.word === '采样中') return;
496 if (tier.word === '断粮') {
497 $.ui.toast('额度已用尽 · 剩 ' + fmtMoney(v.monthly.remaining));
498 return;
499 }
500 if (!pace || pace.insufficient) return;
501 $.ui.toast(
502 '额度' +
503 tier.word +
504 ' · 按本周期均速还能撑 ' +
505 fmtDays(pace.runwayDays) +
506 ',比周期结束早 ' +
507 fmtDays(pace.gapDays),
508 );
509}
510
511// ════════════════════════════════════════════════════════════
512// 渲染:输入框上方那一行
513// ════════════════════════════════════════════════════════════
514
515// 段 → 元素。颜色只有两个来源:
516// · 主题令牌(success/warning/error/rate_limit_empty)—— 明暗自适应,推荐
517// · 裸色名(green/yellow/red)—— 引擎接受,但不跟明暗主题走
518// · dimColor —— 次要信息
519// 写死 hex 也可以,只是不跟明暗主题走,浅色主题下容易糊。
520// (旧注释说"非法值不报错,表现为整行消失"——**那是错的**,代价是白查了一轮:
521// 非法颜色会让**整棵 band 树**被判不合法。)
522//
523// 引擎对 color / backgroundColor / borderColor 只做一次检查:
524// typeof t === 'string' && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)
525// 不过就判**整棵 ui.render 树**不合法 → 整棵丢弃、改画引擎自己的(= 空白)。
526// band 是中间件链,三棵树的合并体一起被校验,所以**一处颜色写错,三块内容一起消失**,
527// 而且屏幕上没有一行提示 —— 这就是那个"mod 突然全没了"的事故。
528//
529// 所以颜色在**出门之前**先过一遍这道字符集:合法的才带上,不合法的直接不写
530// (少一个颜色只是少一个颜色,不会连累任何人)。引擎那边将来放宽了也没关系,
531// 这里只是把非法值挡掉,合法值原样透传。
532const COLOR_RE = /^[#a-zA-Z0-9_().,% -]{1,40}$/;
533
534function safeColor(v) {
535 return typeof v === 'string' && COLOR_RE.test(v) ? v : null;
536}
537
538function textOf(Text, key, s) {
539 const props = { key, children: s.text };
540 const color = safeColor(s.color);
541 const bg = safeColor(s.bg);
542 if (color) props.color = color;
543 if (bg) props.backgroundColor = bg;
544 if (s.bold) props.bold = true;
545 if (s.dim) props.dimColor = true;
546 return Text(props);
547}
548
549function meterNode(Box, Text, key, parts) {
550 return Box({
551 key,
552 flexDirection: 'row',
553 children: parts.map((p, j) => textOf(Text, key + '-' + j, p)),
554 });
555}
556
557// 没有读数时画这一行,把原因写在屏幕上。
558// 「没装」和「装了但取不到数」在静默让位时长得一模一样 —— 用户只能靠猜。
559function renderStatus($, e) {
560 const { Box, Text } = $.ui.resolve(e);
561 const why = credFound ? '取数失败 · 接口或网络不可达' : '没找到 Command Code 凭据';
562 return Box({
563 flexDirection: 'row',
564 paddingX: 1,
565 children: [Text({ dimColor: true, children: `额度条 · ${why}` })],
566 });
567}
568
569function renderRow($, e) {
570 const { Box, Text, Button } = $.ui.resolve(e);
571 // 可用宽度在 **e.props** 里。以前读 e.bodyColumns,恒为 undefined,
572 // 于是这一行永远按兜底的 80 列排版:宽屏浪费、窄屏溢出被截。
573 // 再减去 Box 自己的 paddingX: 1(左右各一格)—— 这两格以前完全没进预算,
574 // 80 列的终端上这一行实际占 82 格,会折行。
575 const cols = Math.max(20, (e.props?.bodyColumns ?? 80) - 2);
576 const now = Date.now();
577 const v = snap;
578 const pace = computePace(v, now);
579 const tier = tierOf(v, pace);
580 const stale = now - snapAt > TTL_MS * 2;
581 const segs = layoutRow(v, pace, tier, cols, now, stale);
582 if (!segs.length) return null;
583
584 const children = [];
585 segs.forEach((s, i) => {
586 if (i > 0) children.push(Text({ key: 'rw-sp' + i, children: ' ' }));
587 if (s.kind === 'button') {
588 children.push(
589 Button({
590 key: 'rw-' + s.id,
591 label: s.label,
592 hotkey: s.hotkey,
593 plain: true,
594 onPress: () => {
595 openDetail($);
596 },
597 }),
598 );
599 } else if (s.kind === 'meter') {
600 children.push(meterNode(Box, Text, 'rw-' + s.id, s.parts));
601 } else {
602 children.push(textOf(Text, 'rw-' + s.id, s));
603 }
604 });
605 return Box({ flexDirection: 'row', paddingX: 1, children });
606}
607
608// ════════════════════════════════════════════════════════════
609// 交互
610// ════════════════════════════════════════════════════════════
611
612// 试算:纯本地算术,不打接口、不改任何东西
613function setWhatIf($, text) {
614 whatIf = String(text == null ? '' : text).trim() || null;
615 $.ui.invalidate('ui.render');
616}
617
618async function openDetail($) {
619 marks.sawDetail = true;
620 saveMarks($);
621 const placed = await $.ui.open({
622 id: PANE_ID,
623 title: '额度',
624 focus: true,
625 closeOnEscape: true,
626 // 面板内容 17 行起(用了试算 19 行),原来声明 18 就装不下,
627 // 底部的「刷新 / 复制快照」按钮落在窗框之外点不到。
628 // 宽度同理:固定部分就有 53 格,58 列会让「周」行折行。
629 // rows 是"想要多高",不是硬性要求(引擎按能给的给),所以这里按内容给足。
630 rows: 30,
631 columns: 78,
632 });
633 // < 144 列(从没开过)/ < 110 列时面板会静默不放置。不提示的话,
634 // 用户按了 1 什么也没发生,而且没有任何报错。
635 if (placed && placed.isPlaced === false) {
636 $.ui.toast('终端太窄,放不下面板 · 拉宽后再按 1');
637 }
638}
639
640function copySnapshot($, surface) {
641 $.ui.copy({ text: snapshotText(snap, Date.now()), surface });
642 $.ui.toast('额度快照已复制');
643}
644
645function manualRefresh($) {
646 const startedAt = Date.now();
647 $.ui.toast('正在刷新…');
648 refresh($).then(() => {
649 $.ui.invalidate('ui.render');
650 // refresh 内部把错误全吞了(网络路径不该抛给 hook),所以只能靠
651 // "这次尝试期间有没有记下失败"来给回执。否则刷新成功与失败长得一模一样,
652 // 用户只能自己盯着"快照 N 秒前"推断。
653 $.ui.toast(lastErrorAt >= startedAt ? '刷新失败 · 显示的是旧读数' : '已更新');
654 });
655}
656
657function renderPane($, e) {
658 const { Box, Text, Button, Input } = $.ui.resolve(e);
659 const now = Date.now();
660 const pace = computePace(snap, now);
661 const tier = tierOf(snap, pace);
662 const stale = now - snapAt > TTL_MS * 2;
663 const cols = Math.max(30, e.props?.bodyColumns ?? 56);
664
665 // 布局是纯函数,这里只负责把段变成元素、把按钮接到处理函数
666 const handlers = {
667 refresh: () => {
668 manualRefresh($);
669 },
670 copy: (press) => {
671 copySnapshot($, press.surface);
672 },
673 };
674
675 const children = [];
676 layoutPane(snap, pace, tier, cols, now, stale, whatIf, { catalog, diff: catDiff }).forEach((r, i) => {
677 if (r.kind === 'gap') {
678 children.push(Text({ key: 'rw-pg' + i, children: ' ' }));
679 return;
680 }
681 const kids = r.segs.map((s) => {
682 if (s.kind === 'meter') return meterNode(Box, Text, 'rw-' + s.id, s.parts);
683 if (s.kind === 'input') {
684 // mobile 的元素表里**没有 Input**(类型定义原文:"No `Input` or `Select`"),
685 // 而 Pane 在 mobile 上也会触发。直接调用 Input(...) 会抛错、
686 // 被上面的 catch 吞掉,于是整个面板在手机上是一片空白。
687 // 画不了输入框就跳过这一行 —— 试算是附加功能,不该拖垮整个面板。
688 if (!Input) return null;
689 return Input({
690 key: 'rw-' + s.id,
691 label: s.label,
692 placeholder: s.placeholder,
693 submitLabel: s.submitLabel,
694 onSubmit: (text) => {
695 setWhatIf($, text);
696 },
697 });
698 }
699 if (s.kind === 'button') {
700 const props = {
701 key: 'rw-' + s.id,
702 label: s.label,
703 hotkey: s.hotkey,
704 plain: true,
705 onPress: (press) => {
706 handlers[s.id](press);
707 },
708 };
709 // autoFocus 只接受 true。落在后果最小的控件上:焦点在"复制",
710 // 回车是复制而不是刷新(照抄 blast-radius 把 autoFocus 给 Cancel)。
711 if (s.id === 'copy') props.autoFocus = true;
712 return Button(props);
713 }
714 // 注意:**不要**在这里靠 `s.width` 去包定宽 Box 做列对齐。
715 //
716 // 踩过:Box 的 `width` 是 flex-basis,而引擎给 Box 的默认值里有
717 // `flexShrink: 1` —— 主轴放不下时定宽会被一路压回内容宽,
718 // 于是"短名字那行的数字跑到左边去"。真要做固定列,三件事缺一不可:
719 // width + minWidth 同值 + **flexShrink: 0**,并且**列宽之和 ≤ bodyColumns**
720 // (关掉压缩后放不下就是溢出被裁,不会自己缩)。
721 // 但模型那块现在走的是"一行一个模型",本来就不需要对齐,所以不引入这套机制。
722 return textOf(Text, 'rw-' + s.id, s);
723 });
724 children.push(Box({ key: 'rw-pr' + i, flexDirection: 'row', children: kids.filter(Boolean) }));
725 });
726 return Box({ flexDirection: 'column', children });
727}
728lib/quota.mjs 1379 lines1// runway · 纯函数层
2//
3// 这个文件里不出现 `$`。所有函数只吃普通数据、吐普通数据,
4// 所以 hooks 模块可以 import 它,`claude plugin test` 也能直接测它。
5// (mods 的静态检查只允许把 `$` 传给同一文件里顶层声明的函数,
6// 跨文件传 `$` 会验证失败 —— 把纯逻辑分出来正好避开这条约束。)
7
8import { catalogAge, fmtCount } from './catalog.mjs';
9
10// ────────────────────────────────────────────────────────────
11// 套餐上限表(照抄 cc-usage.mjs 的 PLANS,值来自 Command Code 公开档位)
12// 接口没返回 cap 时用它兜底。
13// ────────────────────────────────────────────────────────────
14export const PLANS = {
15 'individual-go': { name: 'Go', monthly: 10, fiveHour: 3, weekly: 6 },
16 'individual-goat': { name: 'GOAT', monthly: 70, fiveHour: 14, weekly: 35 },
17 'individual-pro': { name: 'Pro', monthly: 80, fiveHour: 16, weekly: 40 },
18 'individual-max-10x': { name: 'Max 10x', monthly: 150, fiveHour: 45, weekly: 90 },
19 'individual-max-20x': { name: 'Max 20x', monthly: 300, fiveHour: 90, weekly: 180 },
20 'teams-pro': { name: 'Team Pro', monthly: 40, fiveHour: 12, weekly: 24 },
21};
22
23// 从**接口实时返回的 planId** 推一个能看的会员名。
24//
25// 为什么需要它:`PLANS` 是一张**写死**的表。官方加一档、或者改 id(比如
26// `individual-max-10x` 变成 `individual-max`),表里就没有 —— 面板上那一格
27// 会是空的。而这些东西是**会变的**,写死的表永远追不上。
28//
29// 所以:表里查得到就用表(手写的名字更好看,比如 GOAT 全大写),
30// 查不到就从 id 推。**永远不会空白。**
31export function prettyPlanName(planId) {
32 const id = safeLabel(planId, 40);
33 if (!id) return '';
34 const tail = id.replace(/^(individual|teams?|business|enterprise)[-_]/i, '');
35 return tail
36 .split(/[-_]/)
37 .filter(Boolean)
38 .map((w) => (/^[0-9]/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
39 .join(' ');
40}
41
42export function planInfo(planId) {
43 if (!planId || typeof planId !== 'string') return null;
44 const norm = planId.toLowerCase().replace(/_/g, '-');
45 // **精确匹配**,不做前缀匹配。
46 //
47 // 以前是"长键优先"的前缀匹配,没有词边界约束 —— 于是任何以已知键开头的新档位
48 // 都会被误配:实测 `individual-gold` → 命中 Go(月 $10)、`individual-go-ultra` → 也是 Go。
49 // 后果不只是名字错:**额度上限跟着错**,所有百分比都按那个错的上限算。
50 // 查不到就走 prettyPlanName(从实时 planId 推名字),宁可没有上限也不要错的上限。
51 const key = Object.keys(PLANS).find((k) => norm === k);
52 return key ? { id: planId, ...PLANS[key] } : null;
53}
54
55// ────────────────────────────────────────────────────────────
56// 显示宽度:CJK 与全角记 2 格,其余记 1 格。
57// 块字符(█▓░│ 等 U+25xx / U+2500)在这里按 1 格算 —— 与 token-weather
58// 的图表同一处理方式,在同一个渲染器里表现一致。
59// ────────────────────────────────────────────────────────────
60export function dispWidth(s) {
61 let w = 0;
62 for (const ch of String(s)) {
63 const cp = ch.codePointAt(0);
64 const wide =
65 cp >= 0x1100 &&
66 (cp <= 0x115f ||
67 cp === 0x2329 ||
68 cp === 0x232a ||
69 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
70 (cp >= 0xac00 && cp <= 0xd7a3) ||
71 (cp >= 0xf900 && cp <= 0xfaff) ||
72 (cp >= 0xfe30 && cp <= 0xfe6f) ||
73 (cp >= 0xff00 && cp <= 0xff60) ||
74 (cp >= 0xffe0 && cp <= 0xffe6));
75 w += wide ? 2 : 1;
76 }
77 return w;
78}
79
80// ────────────────────────────────────────────────────────────
81// FNV-1a。只用来判断"这份缓存属于哪个账号",不是加密用途。
82// 照抄 cc-usage.mjs。
83// ────────────────────────────────────────────────────────────
84export function fnv1a(text) {
85 let h = 0x811c9dc5;
86 for (let i = 0; i < text.length; i += 1) {
87 h ^= text.charCodeAt(i);
88 h = Math.imul(h, 0x01000193) >>> 0;
89 }
90 return h.toString(16).padStart(8, '0');
91}
92
93// ────────────────────────────────────────────────────────────
94// 凭据的信任边界。
95//
96// 配置文件里的 `api.baseUrl` 是**不可信输入** —— 这个项目的用法就是
97// "别人给你一份 provider 配置模板、你把 key 填进去",所以那个文件里的
98// 地址不能当作可信来源。而我们会把 API key 当 Bearer 头打到这个地址上。
99//
100// 之前这里只做 `/commandcode\.ai/i` 子串匹配,实测这些都会命中:
101// https://evil.tld/commandcode.ai/provider/v1 → 配置里的任意域名
102// https://commandcode.ai.evil.tld/provider/v1 → 抢注的近似域名
103// https://evil.tld/x?u=commandcode.ai → 参数里带一下就够
104// http://api.commandcode.ai/provider/v1 → 明文,key 裸奔
105// 等于一个"把 key 寄给配置文件作者"的通道,且不需要对方能读我们的文件。
106//
107// 所以命中条件收紧成:**精确主机 + 必须 https**。
108// ────────────────────────────────────────────────────────────
109export const ALLOWED_HOSTS = new Set(['api.commandcode.ai']);
110
111export function hostOf(url) {
112 if (typeof url !== 'string' || !url.trim()) return null;
113 try {
114 const u = new URL(url.trim());
115 return u.protocol === 'https:' ? u.host.toLowerCase() : null;
116 } catch {
117 return null;
118 }
119}
120
121export function isTrustedBase(url) {
122 const host = hostOf(url);
123 return host !== null && ALLOWED_HOSTS.has(host);
124}
125
126// 配置文件里的 key 也要过形状检查。以前这里只要求"是个非空字符串",
127// 于是配置里任何一个字符串都能让本模块发出带 Bearer 的请求。
128// 用宽松但真实的形状(可打印、无空白、20 字符以上),
129// 而不是要求 user_ / cc_ 前缀 —— 前缀是接口的实现细节,用它会把合法 key 挡在门外。
130export function looksLikeKey(value) {
131 return typeof value === 'string' && /^[A-Za-z0-9_.\-]{20,256}$/.test(value.trim());
132}
133
134// ────────────────────────────────────────────────────────────
135// 在一份配置对象里递归找 Command Code 的凭据。
136// 命中条件:同一个节点上既有 access.apiKey 又有**可信的** api.baseUrl。
137// 照抄 cc-usage.mjs 的 scanForProviderKey(深度上限 8)。
138// ────────────────────────────────────────────────────────────
139export function scanForProviderKey(node, depth = 0) {
140 if (!node || typeof node !== 'object' || depth > 8) return null;
141 if (Array.isArray(node)) {
142 for (const item of node) {
143 const hit = scanForProviderKey(item, depth + 1);
144 if (hit) return hit;
145 }
146 return null;
147 }
148 const key = node.access && node.access.apiKey;
149 const url = node.api && node.api.baseUrl;
150 if (looksLikeKey(key) && isTrustedBase(url)) {
151 return { apiKey: key.trim(), baseUrl: url.trim() };
152 }
153 for (const value of Object.values(node)) {
154 const hit = scanForProviderKey(value, depth + 1);
155 if (hit) return hit;
156 }
157 return null;
158}
159
160// ────────────────────────────────────────────────────────────
161// 不可信字符串 → 单行可见文本。
162//
163// planId 来自接口响应,之前它被原样写进面板和"复制快照"的文本里 ——
164// 而快照的设计目的就是给人贴到 issue / 群里。于是响应里一个带换行的
165// planId 就能伪造出额外的行(例如伪造一行"凭证 user_xxx"),
166// ANSI/OSC 序列还能改窗口标题或覆写同一行。
167// 摈弃控制字符,并限长。
168// ────────────────────────────────────────────────────────────
169export function safeLabel(value, max = 32) {
170 if (value === null || value === undefined) return '';
171 return String(value)
172 .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
173 .trim()
174 .slice(0, max);
175}
176
177
178// ────────────────────────────────────────────────────────────
179// 归一化:把三个端点的原始响应算成界面要用的数。
180//
181// ⚠ 两个易混字段(名字同源、语义相反):
182// credits.credits.monthlyCredits = 月度**余额**
183// summary.totalMonthlyCredits = 月度**已花**
184// 这里用前者,后者不碰。
185// ────────────────────────────────────────────────────────────
186function num(v) {
187 const n = Number(v);
188 return Number.isFinite(n) ? n : 0;
189}
190
191function windowOf(spec, fallbackCap, now) {
192 if (!spec || typeof spec !== 'object') return null;
193 const used = Math.max(0, num(spec.used));
194 const capFromApi = num(spec.cap);
195 const cap = capFromApi || fallbackCap || 0;
196 // 上限**是不是接口给的**。不是的话(拿档位表兜底)就不给百分比 ——
197 // 那个百分比会照着兜底值算出来,官方改档后它就是错的,而界面上看不出它是错的。
198 // 宁可显示 `—`,也不显示一个可能是假的数。
199 const capKnown = capFromApi > 0;
200 const resetAt = num(spec.resetAt) || null; // 0 归一成 null:实测接口会给 0
201 return {
202 used,
203 cap,
204 capKnown,
205 percent: capKnown && cap > 0 ? Math.min((used / cap) * 100, 100) : null,
206 rawPercent: capKnown && cap > 0 ? (used / cap) * 100 : null,
207 remaining: Math.max(0, cap - used),
208 resetAt,
209 resetsInMs: resetAt !== null ? Math.max(0, resetAt - now) : null,
210 exceeded: Boolean(spec.exceeded),
211 // 实测存在 started:true 但 used:0 的合法态,两个条件都要看
212 started: used > 0 || (resetAt !== null && resetAt > now),
213 };
214}
215
216export function normalize(raw, meta) {
217 const now = meta.now;
218 const sub = raw.subscription && raw.subscription.data ? raw.subscription.data : null;
219 const plan = planInfo(sub && sub.planId);
220 const c = (raw.credits && raw.credits.credits) || {};
221 const wl = (raw.credits && raw.credits.windowLimits) || null;
222 const sm = raw.summary || {};
223
224 // 字段缺失 ≠ 余额为 0。以前这里把缺失的 monthlyCredits 直接当成 0,
225 // 于是 totalPool 取档位上限、monthlyUsed = 上限 → 100% 已用、remaining 0,
226 // tierOf 判「断粮」,announce() 还会弹一条「额度已用尽 · 剩 $0.00」。
227 // 一个字段没返回就能伪造出"额度花光"的警报。
228 const hasMonthly = Number.isFinite(Number(c.monthlyCredits));
229 const monthlyRemaining = hasMonthly ? Math.max(0, num(c.monthlyCredits)) : 0;
230 const purchasedRemaining = Math.max(0, num(c.purchasedCredits));
231 const freeRemaining = Math.max(0, num(c.freeCredits));
232 const totalRemaining = monthlyRemaining + purchasedRemaining + freeRemaining;
233 const totalSpent = Math.max(0, num(sm.totalCost));
234
235 // 官方文档:无订阅账号与 Enterprise 自定义池**没有滚动窗口上限**,只受余额约束。
236 // 那种情况下把两个窗口当"不适用"渲染 —— 比"发现 used>0 才被动判断"更安全。
237 // (实测 GOAT 是 limited:true;false 态没法在本账号观测到,所以按文档保守处理。)
238 const limited = wl ? Boolean(wl.limited) : null;
239 const active = Boolean(sub && sub.status === 'active');
240 const planMonthly = active && plan ? plan.monthly : null;
241 // 月**总量**从哪来?接口不给这个数 —— 它给的是余额(`monthlyCredits`)和滚动窗口的上限,
242 // 没有"这个档位一个月多少"。所以以前这里用 `PLANS`,一张**写死的表**。
243 // 但那张表会过期:官方调价、改档,它不跟着变,而月百分比正是照着它算的。
244 //
245 // 活数据里有一个自洽的口径:**本周期已花 + 当前余额 = 本周期总量**
246 // (summary 是带 `since=<周期起点>` 取的,所以就是本周期)。优先用它,
247 // 写死的档位上限只在连 summary 都拿不到时兜底。
248 //
249 // 已知偏差:用户**另买了额度**时,余额含购买部分而"已花"不含 → 分母偏大、百分比偏低。
250 // 写进 README 的已知限制了。
251 const poolFromLive = totalSpent + totalRemaining;
252 const poolFromPlan = planMonthly !== null ? planMonthly + purchasedRemaining + freeRemaining : null;
253 const totalPool = poolFromLive > 0 ? poolFromLive : poolFromPlan ?? 0;
254 const monthlyUsed = Math.max(0, totalPool - totalRemaining);
255
256 // Date.parse 对不可解析的字符串返回 NaN,而 NaN !== null ——
257 // 于是 Math.max(0, NaN) 会把「周期还剩 NaN 天」直接画到屏幕上。
258 const parseAt = (s) => {
259 const t = s ? Date.parse(s) : NaN;
260 return Number.isFinite(t) ? t : null;
261 };
262 const periodStartMs = parseAt(sub && sub.currentPeriodStart);
263 const periodEndMs = parseAt(sub && sub.currentPeriodEnd);
264
265 return {
266 fetchedAt: now,
267 plan: {
268 id: plan ? plan.id : null,
269 // planId 来自接口响应,会被写进面板和"复制快照"(而快照的设计目的是给人贴出去)。
270 // 不剥控制字符的话,一个带换行的 planId 就能在快照里伪造出额外的行。
271 // 表里查得到就用表(手写的名字更好看),查不到就从**接口给的 planId** 推 ——
272 // 官方加档或改 id 时,这里不会空白。
273 name: plan ? plan.name : sub ? prettyPlanName(sub.planId) : '',
274 status: sub ? sub.status : null,
275 active,
276 monthlyTotal: planMonthly,
277 periodStartMs,
278 periodEndMs,
279 periodDays:
280 periodStartMs && periodEndMs ? Math.max(0, (periodEndMs - periodStartMs) / 86400000) : null,
281 daysLeft:
282 periodEndMs !== null ? Math.max(0, Math.ceil((periodEndMs - now) / 86400000)) : null,
283 },
284 monthly: {
285 used: monthlyUsed,
286 total: totalPool,
287 remaining: totalRemaining,
288 // **字段缺失 ≠ 额度用完。**
289 //
290 // 余额(monthlyCredits)缺失时"还剩多少"根本不知道,而 used 是按"总量 − 余额"
291 // 推的 → 总量退化成"已花",于是 percent 恒等于 100%。后果:tierOf 判「断粮」、
292 // announce() 弹「额度已用尽 · 剩 $0.00」—— **一个字段没返回,就伪造出最严重的那个警报**。
293 // (这段注释以前写着"已修好",其实没有:旧代码换成"总量=已花"之后同样是 100%。)
294 //
295 // 所以这里显式区分:拿不到余额就给 null,界面显示 `—`,档位走「无数据」。
296 unknown: !hasMonthly,
297 percent: hasMonthly && totalPool > 0 ? Math.min((monthlyUsed / totalPool) * 100, 100) : null,
298 rawPercent: hasMonthly && totalPool > 0 ? (monthlyUsed / totalPool) * 100 : null,
299 belowThreshold: Boolean(c.belowThreshold),
300 },
301 windows: limited === false ? { limited: false, fiveHour: null, weekly: null } : {
302 limited,
303 fiveHour: windowOf(wl && wl.fiveHour, plan && plan.fiveHour, now),
304 weekly: windowOf(wl && wl.weekly, plan && plan.weekly, now),
305 },
306 avgCostPerRequest: num(sm.averageCost) || null,
307 requestCount: num(sm.totalCount) || null,
308 };
309}
310
311// ────────────────────────────────────────────────────────────
312// 节奏外推:把"用了多少 %"翻译成"还剩多久"。
313//
314// 样本门控照抄 cc-usage.mjs 的 minSample(10 分钟与 5% 周期取大者):
315// 短样本外推几乎一直在报警 —— 一次正常爆发在 25 分钟处会被外推成 140%。
316// 门控不过就不给结论,界面显示"采样中"。
317// ────────────────────────────────────────────────────────────
318export function computePace(v, now) {
319 const startMs = v.plan.periodStartMs;
320 const endMs = v.plan.periodEndMs;
321 if (!startMs || !endMs || endMs <= startMs) return null;
322 const totalMs = endMs - startMs;
323 const elapsedMs = Math.min(now, endMs) - startMs;
324 const minSample = Math.max(10 * 60_000, totalMs * 0.05);
325 const daysLeftMs = Math.max(0, endMs - now);
326
327 if (elapsedMs < minSample || !(v.monthly.used > 0)) {
328 return {
329 insufficient: true,
330 elapsedMs,
331 totalMs,
332 sampleDays: elapsedMs / 86400000,
333 daysLeftDays: daysLeftMs / 86400000,
334 };
335 }
336
337 const perMs = v.monthly.used / elapsedMs;
338 const remainingMs = v.monthly.remaining / perMs;
339 return {
340 insufficient: false,
341 elapsedMs,
342 totalMs,
343 sampleDays: elapsedMs / 86400000,
344 perDay: perMs * 86400000,
345 runwayDays: remainingMs / 86400000,
346 daysLeftDays: daysLeftMs / 86400000,
347 // > 0 表示会在周期结束前耗尽
348 gapDays: (daysLeftMs - remainingMs) / 86400000,
349 projectedTotal: perMs * totalMs,
350 willExceed: perMs * totalMs > v.monthly.total,
351 };
352}
353
354// ────────────────────────────────────────────────────────────
355// 档位。图标取 Geometric Shapes 块(单宽),不用 emoji。
356// color 一律用主题令牌,明暗主题都会自适应。
357// ────────────────────────────────────────────────────────────
358export function tierOf(v, pace) {
359 // 先判"根本没数据",再判"用完了" —— 顺序反了会把缺失当成耗尽(见 normalize 里的注释)
360 if (v.monthly.unknown) {
361 return { icon: '◌', word: '无数据', color: null, bold: false };
362 }
363 if (v.monthly.remaining <= 0 || v.monthly.percent >= 100) {
364 return { icon: '●', word: '断粮', color: 'error', bold: true };
365 }
366 if (!pace || pace.insufficient) {
367 return { icon: '◌', word: '采样中', color: null, bold: false };
368 }
369 if (!pace.willExceed) {
370 return { icon: '○', word: '宽裕', color: 'success', bold: false };
371 }
372 // 严重度看「还能撑多久」,不看「缺口多少天」。
373 // 判据原来是 gapDays <= 3 → 吃紧,方向是反的:gapDays 是"比周期结束早多少天烧完",
374 // 越大越糟。实测它给出自相矛盾的画面 ——「吃紧」+红+加粗 配"还能撑 15d,早 1.2d 烧完",
375 // 而「偏紧」+黄 配"还能撑 2.3d,早 13.7d 烧完"。颜色和紧挨着的数字对着干。
376 if (pace.runwayDays <= 3) {
377 return { icon: '◕', word: '吃紧', color: 'error', bold: true };
378 }
379 return { icon: '◑', word: '偏紧', color: 'warning', bold: false };
380}
381
382// ────────────────────────────────────────────────────────────
383// 进度条:用**背景色实心块**画,不是块字符。
384//
385// 为什么换掉 █▓░:它们是 U+2588 块字符,宽度是 East-Asian Ambiguous,
386// 取决于字体按单宽还是双宽渲染;而且多个块字符挨在一起时,
387// 有些字体会在每个字形之间留缝,整根条糊成一片灰。
388// 背景色涂在空格上则与字体无关 —— 任何字体下都是实心矩形。
389//
390// 取色用原始的 ANSI 色名(green/yellow/red)而不是主题令牌:
391// success/warning/error 是为**文字**调的前景色,天生柔和,
392// 当背景用永远不够鲜明。轨道用主题令牌 rate_limit_empty ——
393// 它就是 /usage 那根用量条的空槽色,明暗主题都合适。
394// ────────────────────────────────────────────────────────────
395// ⚠ 取色只有三种合法写法,别写错 —— 写错的代价不是"没颜色",是**整条 band 消失**。
396//
397// 引擎对 color / backgroundColor / borderColor 只做一次检查(cli.js 里 PUt):
398//
399// typeof t === 'string' && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)
400//
401// 不过就判整棵 ui.render 树不合法,**整棵树被丢弃、改画引擎自己的(= 空白)**,
402// 只在日志里留一句 "a hook returned a tree that does not validate"。
403// 也就是说:一处颜色写错,三个 mod 一起消失,且屏幕上没有任何提示。
404//
405// 所以这里绝不能写 `ansi:red` —— **冒号不在那个字符集里**,直接判不合法。
406// (这个错误我犯过一次,代价是 band 空白了很久,查了整整一轮才定位。)
407//
408// 用主题令牌 success / warning / error:它们在字符集内,主题里确实有,
409// 而且会跟着明暗主题走 —— 用户是 light 主题,写死 hex 在浅色底上会不对。
410export function levelColor(pct) {
411 // 不知道多少 → 不取色。涂成绿色会让人以为"很安全",而其实是没有数据。
412 if (!Number.isFinite(Number(pct))) return null;
413 if (pct >= 85) return 'error';
414 if (pct >= 60) return 'warning';
415 return 'success';
416}
417
418// 月额度条的取色:**听节奏,不是只听填充率**。
419// 填充率 56% 看着很安全,但如果按当前速度会提前烧完,
420// 画成绿色就是骗人 —— 绿色配旁边红色的「还能撑 11d」自相矛盾。
421export function quotaColor(pct, short, runwayDays) {
422 if (!Number.isFinite(Number(pct))) return null;
423 if (short) return runwayDays <= 3 ? 'error' : 'warning';
424 return levelColor(pct);
425}
426
427export function meterParts(pct, cells, fill) {
428 return landingParts(pct, null, cells, fill).parts;
429}
430
431// ────────────────────────────────────────────────────────────
432// 落点条:一根条讲完 过去 / 未来 / 余量。
433//
434// █ 已经花掉的
435// ▒ 按当前速度**还将花掉**的
436// ░ 照这个速度**会剩下来的**
437//
438// 会不会爆表不用读字:**右半截变红 = 会冲过上限**,
439// 余量格消失也说明同一件事。轴固定 0→100%,不画刻度 —— 条的末端就是上限。
440//
441// projPct 传 null 就退化成普通水平条(那个窗口样本不足、算不出落点时)。
442// ────────────────────────────────────────────────────────────
443export function landingParts(usedPct, projPct, cells, color) {
444 const n = Math.max(0, Math.floor(cells));
445 // 0 格直接返回空。否则下面"极小填充率也至少给一格"那条规则会在 n=0 时
446 // 吐出 1 格 —— 破坏"总宽恒等于给定格数"这条不变量(fuzz 里 81 例)。
447 if (n <= 0) return { parts: [], over: false };
448 const u = Math.min(Math.max(Number(usedPct) || 0, 0), 100);
449 const p = projPct == null ? u : Math.min(Math.max(Number(projPct) || 0, u), 999);
450 const over = p > 100;
451 const usedCells = u <= 0 ? 0 : Math.max(1, Math.round((u / 100) * n));
452 const projCells = Math.max(usedCells, Math.min(n, Math.round((Math.min(p, 100) / 100) * n)));
453
454 // **全程只用 █,靠颜色区分**,不混用 █ / ▒ / ░ 三种密度字符。
455 // 混用的后果:100% / 50% / 25% 三种"重量"拼在一起,同一根条看起来像三种东西,
456 // 高矮粗细都对不齐 —— 这是用户直接指出来的。
457 // 现在实心段 / 落点段 / 余量段是同一个字形,只有颜色不同,天然对齐。
458 const parts = [];
459 // 已用段加粗、落点段不加粗 —— 这样即使颜色全丢了(灰阶终端、色盲、
460 // 主题把 ansi 色映射成同色),"到哪为止是已经花掉的"依然读得出来。
461 // 以前两段的 bold / dim 完全相同,唯一区别就是颜色。
462 if (usedCells > 0) parts.push({ text: '█'.repeat(usedCells), color, bold: true, dim: false });
463 const added = projCells - usedCells;
464 if (added > 0) {
465 // 会冲过上限 → 落点段整段变红;撑得住 → 和已用段同色,合成一根完整的条
466 parts.push({ text: '█'.repeat(added), color: over ? 'error' : color, bold: false, dim: false });
467 }
468 if (n - projCells > 0) {
469 parts.push({ text: '█'.repeat(n - projCells), color: null, bold: false, dim: true });
470 }
471 return { parts, over };
472}
473
474// ────────────────────────────────────────────────────────────
475// 「还剩 11 天」是个长度,可以拖;「10/26 04:00」是个时刻,不能。
476// 人会自动把时刻跟日历上别的事对照,所以它比天数扎心得多。
477// ────────────────────────────────────────────────────────────
478export function exhaustAtMs(remaining, dailyRate, now) {
479 if (!(remaining > 0) || !(dailyRate > 0)) return null;
480 return now + (remaining / dailyRate) * 86400000;
481}
482
483export function fmtDay(ms) {
484 // 光判 isFinite 不够:试算框输入一个极小的日花费会算出 3e30 这种
485 // "有限但超出 Date 范围"的值,new Date(3e30) 是 Invalid Date,
486 // getMonth() 返回 NaN → 屏幕上出现「NaN/NaN NaN:NaN」。
487 if (ms == null || !Number.isFinite(Number(ms)) || Math.abs(Number(ms)) > MAX_DATE_MS) return '—';
488 const d = new Date(ms);
489 const p = (n) => String(n).padStart(2, '0');
490 return p(d.getMonth() + 1) + '/' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
491}
492
493// ────────────────────────────────────────────────────────────
494// 滚动窗口的落点。用重置时刻倒推窗口何时开始:
495// elapsed = 窗口长度 − 距下次重置的时间
496// 5h 窗口长度 5 小时,周窗口 7 天。样本不足就不给结论。
497// ────────────────────────────────────────────────────────────
498// ────────────────────────────────────────────────────────────
499// 「每天花多少才不会超」——把「周期末会超」这个结论翻译成当天就能执行的目标。
500//
501// daysLeft 用精确天数,但**下限压到 1 天**:剩不到一天时,「每天不超过 $X」
502// 这句话本来就没有意义(今天之内花掉就完了),按半天摊反而会把目标报成两倍,
503// 看着像是"还能多花一倍"。压到一天给出的是最保守、也是最好执行的那个数。
504// ────────────────────────────────────────────────────────────
505export function safeDailyBudget(remaining, endMs, now) {
506 const left = Number(remaining);
507 if (!Number.isFinite(left) || left <= 0) return null;
508 if (!endMs || endMs <= now) return null;
509 return left / Math.max(1, (endMs - now) / 86400000);
510}
511
512// ────────────────────────────────────────────────────────────
513// 周期进度:这个账期过了百分之几。
514//
515// 单独看「额度已用 80%」没有参照系 —— 配上「周期才过 50%」才知道是花快了。
516// 它不依赖采样,所以「采样中」时这一行照样给得出来。
517// ────────────────────────────────────────────────────────────
518export function cycleProgress(pace) {
519 if (!pace || !pace.totalMs || pace.totalMs <= 0) return null;
520 return Math.min(100, Math.max(0, (pace.elapsedMs / pace.totalMs) * 100));
521}
522
523export function projectWindow(spec, periodMs, now) {
524 if (!spec || !spec.started || !spec.resetAt || !(spec.cap > 0)) return null;
525 // 重置时刻已经过去 → 这是一份过期读数。以前 leftMs 被夹到 0,
526 // elapsedMs 于是等于整个窗口长度、projected 恒等于 used → 恒定报"安全",
527 // 把"早就该重置了"误读成"很安全"。
528 if (spec.resetAt <= now) return null;
529 const leftMs = Math.max(0, spec.resetAt - now);
530 const elapsedMs = periodMs - Math.min(leftMs, periodMs);
531 if (elapsedMs <= 0 || !(spec.used > 0)) return null;
532 // 样本门槛要比月度那道严得多。
533 // 月度用 5% 周期(1.5 天)就够,因为它的消耗是匀速的;
534 // 但滚动窗口一开场往往是突发 —— 实测 5h 窗口跑了 16 分钟、用了 7.4%,
535 // 按 5% 门槛(15 分钟)刚好放行,外推出来是 139%,纯噪声。
536 // 原版 cc-usage 干脆不在状态栏显示 5h 的 pace,就是这个原因。
537 // 取 20%:5h 窗口要跑满 1 小时、周窗口要过 1.4 天才给结论。
538 const minSample = Math.max(30 * 60_000, periodMs * 0.2);
539 if (elapsedMs < minSample) return null;
540 const projected = (spec.used / elapsedMs) * periodMs;
541 return {
542 projectedPct: (projected / spec.cap) * 100,
543 willExceed: projected > spec.cap,
544 overBy: Math.max(0, projected - spec.cap),
545 sampleMs: elapsedMs,
546 };
547}
548
549// ────────────────────────────────────────────────────────────
550// 格式化
551// ────────────────────────────────────────────────────────────
552const MAX_DATE_MS = 8.64e15; // ECMAScript Date 的合法上限(±1e8 天)
553
554export function fmtMoney(n) {
555 if (n === null || n === undefined || !Number.isFinite(Number(n))) return '—';
556 const v = Number(n);
557 const a = Math.abs(v);
558 // 均单价在 $0.003 量级,固定两位会把它压成 $0.00 —— 看着像坏了
559 if (a === 0) return '$0.00';
560 if (a < 0.01) return '$' + v.toFixed(4);
561 if (a < 1) return '$' + v.toFixed(3);
562 if (a >= 100) return '$' + v.toFixed(0);
563 return '$' + v.toFixed(2);
564}
565
566export function fmtDays(d) {
567 if (d === null || d === undefined || !Number.isFinite(Number(d))) return '—';
568 const v = Number(d);
569 if (v < 0) return '0d';
570 if (v >= 10) return v.toFixed(0) + 'd';
571 return v.toFixed(1) + 'd';
572}
573
574export function fmtPct(p) {
575 // null / undefined = **不知道**,不是 0。
576 // `Number(null)` 是 0,所以原来那个 `Number.isFinite(Number(p))` 会把"没有数据"
577 // 画成 `0.0%` —— 看起来像"一点没用",而其实是"拿不到"。
578 if (p === null || p === undefined) return '—';
579 if (!Number.isFinite(Number(p))) return '—';
580 const v = Number(p);
581 return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%';
582}
583
584// 照抄 cc-usage.mjs 的 resetText:>= 1 天显示日期,否则显示倒计时
585export function resetText(w, now) {
586 const at = w && Number(w.resetAt);
587 if (!at || !Number.isFinite(at) || Math.abs(at) > MAX_DATE_MS) return null;
588 const ms = w.resetsInMs != null ? w.resetsInMs : Math.max(0, w.resetAt - now);
589 if (ms >= 86400000) {
590 const d = new Date(w.resetAt);
591 const p = (n) => String(n).padStart(2, '0');
592 return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())} 重置`;
593 }
594 const t = Math.max(0, Math.floor(ms / 1000));
595 const h = Math.floor(t / 3600);
596 const m = Math.floor((t % 3600) / 60);
597 if (h > 0) return `${h}h${m > 0 ? m + 'm' : ''}后重置`;
598 if (m > 0) return `${m}m后重置`;
599 return `${t}s后重置`;
600}
601
602// ────────────────────────────────────────────────────────────
603// 按宽度裁段:优先丢 prio 大的,prio 0 永不丢。
604// ────────────────────────────────────────────────────────────
605const SEG_GAP = 2;
606// plain 按钮的实宽:热键 + ": " + 标签。
607// 以前手写常量("1 详情" = 6),漏了冒号和空格,宽度预算因此永远少 1 格。
608// (Desktop 上画的是原生按钮,宽度由控件决定 —— 这里是保守估计。)
609const plainButtonW = (label, hotkey) => dispWidth(String(hotkey)) + 2 + dispWidth(label);
610const BUTTON_W = plainButtonW('详情', '1');
611
612export function fitSegments(segs, cols, gap = SEG_GAP) {
613 // 丢掉的是段对象本身,不是 id —— id 可能重复(两个分隔段的 id 都是 'sep')。
614 const dropped = new Set();
615 const alive = () => segs.filter((s) => !dropped.has(s));
616 // 每丢一段都要重算剩余宽度。原来 total() 一直按完整列表算,
617 // 于是"还超宽"恒为真,一超宽就把所有 prio > 0 的段**一次全丢光**,
618 // 留一堆本该被保留的段和一片空白。
619 const total = () => {
620 const now = alive();
621 return now.reduce((a, s) => a + s.width, 0) + gap * Math.max(0, now.length - 1);
622 };
623 for (const seg of segs.filter((s) => s.prio > 0).sort((a, b) => b.prio - a.prio)) {
624 if (total() <= cols) break;
625 dropped.add(seg);
626 }
627 return alive();
628}
629
630// ────────────────────────────────────────────────────────────
631// 布局:把一行拆成渲染无关的"段"。
632// { id, prio, width, kind:'text'|'meter'|'button', text?, parts?, label?, hotkey?,
633// color, bg, bold, dim }
634// hooks 模块把段变成元素树,tools/mock.mjs 把段变成 HTML,
635// 同一份布局两个出口 —— 所以我在浏览器里看到的排版就是屏幕上的排版。
636//
637// 显示顺序:月(主窗口) → 主条 → 还能撑多久 → 剩多少 → 5h 小条 → 周 小条 → 详情入口
638// 主条是这个设计的招牌,放不下时它主动挤掉小窗口,而不是自己被丢掉。
639// ────────────────────────────────────────────────────────────
640export const WEEK_MS = 7 * 86400000;
641export const FIVE_HOUR_MS = 5 * 3600000;
642
643// 月窗口画成"落点条"需要的三个数。
644//
645// 这里以前是一个通用的 hero 抽象(在 月 / 周 / 5h 之间切主窗口),
646// 但切换入口从没做出来(layoutPane 只以 'monthly' 调用它),于是
647// HERO_ORDER / HERO_LABEL / heroSpecFor 和一个不可达分支长期挂着,
648// register.mjs 还因此引用了一个**没 import 的 HERO_ORDER** ——
649// 那行抛的 ReferenceError 被 catch 吞掉,把 marks 恢复整条逻辑废掉了
650// (后果:引导 toast 每次启动都弹、档位 toast 每个进程重报一次)。
651// 砍掉抽象,只留真正在用的那条路径。
652export function monthlyHero(v, pace) {
653 const m = v.monthly;
654 const canProj = Boolean(pace && !pace.insufficient && m.total > 0);
655 return {
656 usedPct: m.percent,
657 projPct: canProj ? (pace.projectedTotal / m.total) * 100 : null,
658 color: quotaColor(m.percent, Boolean(canProj && pace.willExceed), pace && pace.runwayDays),
659 };
660}
661
662// 布局:把一行拆成渲染无关的"段"。
663// { id, prio, width, kind:'text'|'meter'|'button', text?, parts?, label?, hotkey?, ... }
664//
665// 显示顺序:主窗口 → 落点条 → 落点结论 → 金额 → 其它两个窗口的小标 → 详情 / 切窗口
666// 落点条是招牌,挤不下时它主动挤掉数字,而不是自己被丢掉。
667// ────────────────────────────────────────────────────────────
668// 三个窗口等权重地摆在一行里,各自一条落点条,中间用 │ 分隔。
669//
670// 两次教训:
671// 1. 让"月"独占一根 30 格大条,把 周/5h 挤成没有分隔的小文字标 ——
672// 用户看不到另外两个窗口,也分不清哪段是哪段。
673// 2. 就算改成三条,只要宽度分配算错(没算段间距),窄屏下 5h/周 还是会被整段丢掉。
674// 所以这里**三条给同样的宽度、一起伸缩**,谁也不会把谁挤没。
675const BAR_MIN = 4;
676const BAR_MAX = 14;
677// 面板里的条可以更长 —— 它单独占一行,不用跟另外两根抢地方。
678const PANE_BAR_MAX = 22;
679// 面板里最多列几个模型。剩下的交给 /quota models。
680const PANE_MODELS = 6;
681
682export function layoutRow(v, pace, tier, cols, now, stale) {
683 const pacePr =
684 pace && !pace.insufficient && v.monthly.total > 0
685 ? {
686 projectedPct: (pace.projectedTotal / v.monthly.total) * 100,
687 willExceed: pace.willExceed,
688 overBy: Math.max(0, pace.projectedTotal - v.monthly.total),
689 }
690 : null;
691
692 const defs = [
693 { key: 'five', label: '5h', spec: v.windows.fiveHour, pr: projectWindow(v.windows.fiveHour, FIVE_HOUR_MS, now) },
694 { key: 'week', label: '周', spec: v.windows.weekly, pr: projectWindow(v.windows.weekly, WEEK_MS, now) },
695 { key: 'mon', label: '月', spec: v.monthly, pr: pacePr },
696 ];
697
698 const T = (id, s, o = {}) => ({
699 id,
700 prio: o.prio ?? 0,
701 kind: 'text',
702 width: dispWidth(s),
703 text: s,
704 color: o.color ?? null,
705 bg: null,
706 bold: Boolean(o.bold),
707 dim: Boolean(o.dim),
708 });
709 // 窗口数据缺失时显示「—」并转暗,不能显示 0.0% ——
710 // 0.0% 与"这个窗口一点没用"完全同形,而面板对同一份数据是跳过整行,
711 // 两处口径不一致会让人以为额度真的没用过。
712 const labelOf = (d) => T(d.key + 'l', d.label, { bold: true, dim: stale || !d.spec });
713 const pctOf = (d) =>
714 d.spec
715 ? T(d.key + 'p', fmtPct(d.spec.percent), { bold: true, dim: stale })
716 : T(d.key + 'p', '—', { dim: true });
717 const SEP = () => T('sep', ' │ ', { dim: true });
718 // 详情入口可以丢(/quota 与快捷键仍在),三个窗口的百分比不能丢。
719 const detail = { id: 'detail', kind: 'button', width: BUTTON_W, label: '详情', hotkey: '1', prio: 1 };
720
721 // 档位词就是这一行的结论,**永远显示**。
722 //
723 // 以前它只是"没有超支结论时的替补",于是恰恰在额度吃紧的时候(有超支结论)
724 // 反而不显示 —— 用户看到的是三个没有任何结论的裸数字。
725 // 而那个替补上去的「月超 $33.80」是没有行动价值的:超了就是超了,
726 // 知道超多少既不能少花钱也不能多额度(用户原话)。
727 // 想知道"超多少"就去按 1 看明细,那里有金额和断粮时刻。
728 // 开头那个词是**会员名**(Go / GOAT / Pro / Max),不是档位词。
729 //
730 // 档位词(宽裕 / 偏紧 / 吃紧 / 断粮)放在这儿没有意义 —— 用户原话:
731 // "这偏紧两个字放在这毫无意义,你要么就不加,或者你说啊,我们这是 goat 的会员"。
732 // 会员名是他自己花钱买的那一档、会分好几种,而档位由**颜色**和三个窗口的条
733 // 已经说清楚了(颜色跟着档位走,红色 = 有问题)。
734 // 拿不到会员名时退回档位词 —— 总比空着强。
735 const planSeg = tier && (v.plan.name || tier.word)
736 ? T('plan', v.plan.name || tier.word, {
737 color: stale ? null : tier.color,
738 bold: !stale,
739 dim: stale,
740 })
741 : null;
742
743
744 const skeleton = (withTier) => {
745 const a = [];
746 if (planSeg && withTier) a.push(planSeg);
747 defs.forEach((d, i) => {
748 if (i > 0) a.push(SEP());
749 a.push(labelOf(d), pctOf(d));
750 });
751 a.push(detail);
752 return a;
753 };
754 // 三条各占 n + SEG_GAP
755 const fitBars = (a) => Math.min(BAR_MAX, Math.floor((cols - widthOfSegs(a) - 3 * SEG_GAP) / 3));
756
757 // 降级顺序:**先丢条,再丢档位词**。
758 // 条和百分比说的是同一件事,百分比永不丢,所以条是第一个该牺牲的;
759 // 而档位词是这一行唯一的结论,比条更值钱。
760 // (以前是反的:先把档位词丢掉、再去纠结条 —— 于是 60 列以下既没有条也没有结论,
761 // 只剩三个裸数字。)
762 // 三个窗口的标签与百分比**永不丢** —— 这是踩过两次坑换来的硬约束
763 // (用户原话:"只能看到5小时的限额看不到周限额")。
764 let withTier = Boolean(planSeg);
765 let n = fitBars(skeleton(withTier));
766 if (n < BAR_MIN) {
767 n = 0;
768 // 连档位词都塞不下时它才让位。
769 if (widthOfSegs(skeleton(true)) > cols) withTier = false;
770 }
771
772 const out = [];
773 if (planSeg && withTier) out.push(planSeg);
774 defs.forEach((d, i) => {
775 if (i > 0) out.push(SEP());
776 out.push(labelOf(d));
777 if (n > 0 && d.spec) {
778 const pct = d.spec.percent;
779 const color = stale
780 ? null
781 : d.key === 'mon'
782 ? quotaColor(pct, Boolean(pacePr && pacePr.willExceed), pace && pace.runwayDays)
783 : levelColor(pct);
784 out.push({
785 id: d.key + 'bar',
786 // 条是最先该牺牲的:百分比已经说明了同一件事,而窗口的百分比永不丢。
787 // 以前这里也是 prio 0(永不可丢),于是 fitSegments 其实是个空操作。
788 prio: 2,
789 kind: 'meter',
790 width: n,
791 parts: landingParts(pct, d.pr ? d.pr.projectedPct : null, n, color).parts,
792 });
793 }
794 out.push(pctOf(d));
795 });
796 out.push(detail);
797
798 const fitted = fitSegments(out, cols);
799 if (widthOfSegs(fitted) <= cols) return fitted;
800 // 连最精简的骨架都放不下时,**绝不退化成"什么都不画"**。
801 //
802 // 这一条是踩出来的:band 在 Desktop Code tab 上的可用列数比终端窄得多
803 // (终端里 80 格够用,那个界面上不够),而三个窗口加「详情」的最小骨架
804 // 要 45 格。放不下就整行不画,结果是**一整行空白 —— 和「mod 没装」
805 // 长得一模一样**,只能靠猜该去查配置还是该去查宽度。
806 // 空白说不出任何事,所以退化成主窗口的一个百分比:约 6 格,任何宽度都放得下,
807 // 而且它仍然是那个唯一的问题(额度还够不够)。
808 return compactRow(v, pace, cols);
809}
810
811// 放不下时的最小可用形态:主窗口百分比 + 档位颜色。
812function compactRow(v, pace, cols) {
813 const tier = tierOf(v, pace);
814 for (const text of ['月 ' + fmtPct(v.monthly.percent), fmtPct(v.monthly.percent)]) {
815 const width = dispWidth(text);
816 if (width > cols) continue;
817 return [{
818 id: 'monp',
819 prio: 0,
820 kind: 'text',
821 width,
822 text,
823 color: tier.color,
824 bg: null,
825 bold: true,
826 dim: false,
827 }];
828 }
829 return [];
830}
831
832function widthOfSegs(a) {
833 return a.reduce((x, s) => x + s.width, 0) + SEG_GAP * (a.length - 1);
834}
835
836// ────────────────────────────────────────────────────────────
837// 详情面板的布局,和 layoutRow 同一套"段"模型。
838// 返回 { rows: [ {kind:'row', segs:[...]} | {kind:'gap'} ] },
839// 由调用方把段变成元素(hooks)或 HTML(tools/mock.mjs)。
840// ────────────────────────────────────────────────────────────
841export function layoutPane(v, pace, tier, cols, now, stale, whatIf, extra) {
842 const m = v.monthly;
843 const fh = v.windows.fiveHour;
844 const wk = v.windows.weekly;
845 // 条长是**量出来的**,不是猜的。
846 //
847 // 以前写死 `PANE_FIXED = 54`("名称 5 + 百分比 6 + 金额 16 + 重置文案 26"),
848 // 那是在 74 列的面板里数的。而面板在 Desktop Code tab 上比这个窄得多,
849 // 于是 `cols - 54` 小于等于 0 → 条长度算成 0 格 → **整根条一格都不画**。
850 // 屏幕上就是一堆没有条的百分比,看起来像残废 —— 用户报的就是这个。
851 //
852 // 现在只把"条那一行上必须有"的东西算进固定宽度:名称 + 百分比 + 金额。
853 // 金额宽度按三个窗口里最长的那个量。重置文案不进去 —— 它是 prio 3,
854 // 挤不下时本来就该让位。
855 const LABEL_W = 5;
856 const PCT_W = 6;
857 const moneyOf = (spec, cap) =>
858 ' ' + fmtMoney(spec ? spec.used : 0) + ' / ' + fmtMoney(cap);
859 const MONEY_W = Math.max(
860 dispWidth(moneyOf(m, m.total)),
861 dispWidth(moneyOf(fh, fh ? fh.cap : 0)),
862 dispWidth(moneyOf(wk, wk ? wk.cap : 0)),
863 );
864 const cells = Math.max(0, Math.min(PANE_BAR_MAX, cols - (LABEL_W + PCT_W + MONEY_W)));
865
866 const T = (id, s, opts = {}) => ({
867 id,
868 prio: opts.prio ?? 0,
869 kind: 'text',
870 width: dispWidth(s),
871 text: s,
872 color: opts.color ?? null,
873 bg: null,
874 bold: Boolean(opts.bold),
875 dim: Boolean(opts.dim),
876 });
877 const M = (id, pct, n, fill) => ({
878 id,
879 prio: 1,
880 kind: 'meter',
881 width: n,
882 parts: meterParts(pct, n, fill || levelColor(pct)),
883 });
884 const pad = (s, n) => s + ' '.repeat(Math.max(0, n - dispWidth(s)));
885
886 const rows = [];
887 // 面板每一行也按可用列数裁一遍:renderPane 把段直接相邻排列(没有分隔符),
888 // 所以 gap 用 0。以前面板完全不做裁剪,58 列的面板里排到 71 格宽。
889 const row = (...segs) => rows.push({ kind: 'row', segs: fitSegments(segs, cols, 0) });
890 const gap = () => rows.push({ kind: 'gap' });
891
892 // ── 块 1 · 结论 ──
893 //
894 // 打开面板想知道两件事:**够不够、什么时候用完**。所以它们在第一条线上,
895 // 而不是像以前那样夹在三行裸数字和一堆参考值中间。
896 // 排列意图是「结论 → 证据 → 参考 → 动作」,越往下越是想深究才看的。
897 //
898 // 不带图标:◑ 这类 Geometric Shapes 在有些字体里会渲染成一个大圆圈,
899 // 既不好看也没人认得。档位靠**颜色 + 词**表达就够了。
900 {
901 const phrase =
902 tier.word === '断粮'
903 ? '额度已用完'
904 : tier.word === '采样中'
905 ? '样本不足,暂不外推'
906 : pace && !pace.insufficient
907 ? pace.willExceed
908 ? '预计 ' +
909 fmtDay(
910 pace.perDay > 0 ? exhaustAtMs(m.remaining, pace.perDay, now) : null,
911 ) +
912 ' 断粮 · 还能撑 ' +
913 fmtDays(pace.runwayDays)
914 : '撑得到周期末 · 还能撑 ' + fmtDays(pace.runwayDays)
915 : '—';
916 const planTail =
917 (v.plan.name || '—') +
918 (v.plan.daysLeft != null ? ' · 周期还剩 ' + v.plan.daysLeft + ' 天' : '');
919 // 这一行三段都是**结论**,一段都不能被 fitSegments 丢掉(prio 0 丢不掉),
920 // 所以合起来超宽时它们只会被面板边缘**裁掉** —— 而这正是窄面板会发生的事:
921 // `columns: 78` 只是"请求值",窄窗口或用户拖动之后面板可能只有三四十列。
922 // 因此放不下就换行,不裁。
923 const h1 = T('h1', tier.word, { color: stale ? null : tier.color, bold: !stale, dim: stale });
924 const phraseOpts = {
925 bold: !stale && tier.word !== '宽裕',
926 dim: stale,
927 color: stale || tier.word !== '断粮' ? null : 'error',
928 };
929 const h2 = T('h2', ' ' + phrase, phraseOpts);
930 const h3 = T('h3', ' · ' + planTail, { dim: true, prio: 2 });
931 // 判据只看**丢不掉的两段**(档位词 + 短语);第三段(会员名 · 周期)prio 2,
932 // 放不下时 fitSegments 自己会丢掉它,不该为了它换行。
933 if (h1.width + h2.width <= cols) {
934 row(h1, h2, h3);
935 } else {
936 row(h1);
937 row(T('h2b', truncTo(phrase, cols), phraseOpts));
938 if (dispWidth(' · ' + planTail) <= cols) {
939 row(T('h3b', ' · ' + planTail, { dim: true }));
940 }
941 }
942 if (pace && !pace.insufficient) {
943 const over = pace.projectedTotal - m.total;
944 row(
945 T('h4', '周期末预计 ', { dim: true }),
946 T('h5', fmtMoney(pace.projectedTotal) + ' / ' + fmtMoney(m.total), { prio: 1 }),
947 T(
948 'h6',
949 over > 0 ? ' 会超 ' + fmtMoney(over) : ' 富余 ' + fmtMoney(Math.max(0, -over)),
950 over > 0 ? { color: 'warning', bold: true, prio: 1 } : { color: 'success', prio: 2 },
951 ),
952 );
953 }
954 }
955 gap();
956
957 const winRow = (key, name, spec, fill) => {
958 const pct = spec ? spec.percent : 0;
959 const segs = [
960 T(key + 'n', pad(name, 5), { dim: true, bold: true }),
961 T(key + 'p', pad(fmtPct(pct), 6), { bold: true }),
962 ];
963 if (cells > 0) segs.push(M(key + 'm', pct, cells, fill));
964 segs.push(
965 // 月窗口的上限字段叫 total,滚动窗口叫 cap
966 T(
967 key + 'v',
968 ' ' +
969 fmtMoney(spec ? spec.used : 0) +
970 ' / ' +
971 fmtMoney(spec && spec.total != null ? spec.total : spec && spec.cap),
972 { prio: 2 },
973 ),
974 );
975 const r = spec ? resetText(spec, now) : null;
976 if (r) segs.push(T(key + 'r', ' ' + r, { dim: true, prio: 3 }));
977 row(...segs);
978 };
979
980 // 月条画成落点条:实心=已花,斜纹=还将花掉
981 {
982 const h = monthlyHero(v, pace);
983 const segs = [
984 T('mn', pad('月', 5), { dim: true, bold: true }),
985 T('mp', pad(fmtPct(m.percent), 6), { bold: true }),
986 ];
987 if (cells > 0) {
988 segs.push({
989 id: 'mm',
990 prio: 1,
991 kind: 'meter',
992 width: cells,
993 parts: landingParts(h.usedPct, h.projPct, cells, stale ? null : h.color).parts,
994 });
995 }
996 segs.push(T('mv', ' ' + fmtMoney(m.used) + ' / ' + fmtMoney(m.total), { prio: 2 }));
997 row(...segs);
998 }
999 // 「还能撑多久 / 断粮时刻」已经并进上面的结论行(第 1 行),这里不再说第二遍。
1000 // 以前它在第 3 行、绝对时刻在第 14 行,同一件事说两遍,还都排在参考值的后面。
1001
1002 if (fh && fh.started) {
1003 winRow('h', '5h', fh);
1004 } else if (fh) {
1005 row(
1006 T('hn', pad('5h', 5), { dim: true, bold: true }),
1007 T('hp', '未启动', { dim: true }),
1008 T('hv', ' 上限 ' + fmtMoney(fh.cap), { dim: true, prio: 2 }),
1009 );
1010 }
1011 if (wk) winRow('w', '周', wk);
1012 gap();
1013
1014 // 「周期过了百分之几」是「额度用了百分之几」的参照系 ——
1015 // 单看「已用 80%」没有意义,配上「周期才过 50%」才知道是花快了。
1016 // 它只跟时间有关,所以「采样中」时照样给得出来。
1017 // 参考行 = 标签 + 值。值放不下就**整行不画** —— 只剩一个孤零零的标签比没有更糟
1018 // (窄面板上出现过「均单价 」这种残行)。标签和值一起进退。
1019 const refRow = (id, label, valueSeg) => {
1020 if (dispWidth(pad(label, 10)) + valueSeg.width > cols) return;
1021 row(T(id + 'a', pad(label, 10), { dim: true }), valueSeg);
1022 };
1023
1024 {
1025 const cycle = cycleProgress(pace);
1026 if (cycle != null) {
1027 refRow(
1028 'd0',
1029 '进度',
1030 T('d0b', '周期已过 ' + Math.round(cycle) + '%,额度已用 ' + fmtPct(m.percent), {
1031 dim: true,
1032 color: m.percent - cycle > 10 ? 'warning' : null,
1033 }),
1034 );
1035 }
1036 }
1037 refRow(
1038 'd1',
1039 '均速',
1040 T(
1041 'd1b',
1042 pace && !pace.insufficient
1043 ? fmtMoney(pace.perDay) + '/天(样本 ' + fmtDays(pace.sampleDays) + ')'
1044 : '样本不足,暂不外推',
1045 { dim: true },
1046 ),
1047 );
1048 // 「每天不超过多少就不会超」以前只在试算成功之后才出现 ——
1049 // 它是一个可执行的目标,不该藏在"你得先试算一次"后面。
1050 if (pace && !pace.insufficient) {
1051 const budget = safeDailyBudget(m.remaining, v.plan.periodEndMs, now);
1052 refRow(
1053 'd2',
1054 '安全线',
1055 T(
1056 'd2b',
1057 budget != null ? '每天 ≤ ' + fmtMoney(budget) + ' 就不会超' : '已无余量,怎么花都会超',
1058 budget != null ? { color: 'success' } : { color: 'error' },
1059 ),
1060 );
1061 }
1062 refRow(
1063 'd3',
1064 '均单价',
1065 T(
1066 'd3b',
1067 (v.avgCostPerRequest ? fmtMoney(v.avgCostPerRequest) + '/次' : '—') +
1068 (v.requestCount ? ' · 本期 ' + v.requestCount + ' 次请求' : ''),
1069 { dim: true },
1070 ),
1071 );
1072
1073 // ── 模型次数表 ──
1074 // 「这个套餐里哪些模型便宜好用、官方有没有上新」—— 用户用 GOAT 就是冲着这个来的。
1075 // 目录来自公开文档页(lib/catalog.mjs),不在这里取数,只排版。
1076 if (extra && extra.catalog) {
1077 gap();
1078 let mi = 0;
1079 for (const r of modelTable(extra.catalog, extra.diff, cols, now, PANE_MODELS)) {
1080 if (r.gap) {
1081 gap();
1082 continue;
1083 }
1084 row(...r.map((x) => T('m' + mi++, x.text, x)));
1085 }
1086 }
1087
1088 // 「读数是什么时候取的」属于工具区(和刷新按钮挨着),不属于参考区 ——
1089 // 以前它夹在模型表和试算之间,把两块内容切开了。
1090 gap();
1091
1092 // 试算:换一个花法会怎样。纯本地计算,不打接口。
1093 //
1094 // 输入框是引擎画的原生控件,宽度我们说了不算(`width` 只是本文件排版用的预算)。
1095 // 面板窄到放不下时**整行不画** —— 和 mobile 上跳过它同一个理由:附加功能
1096 // 不该被面板边缘裁掉半截,也不该把面板撑出去。
1097 const WHATIF_W = 22;
1098 if (10 + WHATIF_W <= cols) {
1099 row(
1100 T('w1', pad('试算', 10), { dim: true }),
1101 {
1102 id: 'whatif',
1103 kind: 'input',
1104 width: WHATIF_W,
1105 label: '每天 $ ',
1106 placeholder: pace && !pace.insufficient ? pace.perDay.toFixed(2) : '2.00',
1107 submitLabel: '算',
1108 },
1109 );
1110 if (whatIf != null) {
1111 const rate = Number(whatIf);
1112 if (!Number.isFinite(rate) || rate <= 0) {
1113 row(T('w2', pad('', 10), {}), T('w3', '填一个大于 0 的数', { dim: true }));
1114 } else {
1115 const ends = v.plan.periodEndMs;
1116 // 余量为 0 时 exhaustAtMs 返回 null。以前这里不判 null,
1117 // 而 `ends - null` 得到的是"从 1970 年到现在"的毫秒数,
1118 // 于是屏幕上出现「这样会烧到 —,比周期末早 20543d 断」。
1119 const at = exhaustAtMs(m.remaining, rate, now);
1120 const safe = at != null && ends != null && at >= ends;
1121 const verdict =
1122 at == null
1123 ? '已经没有余量了,怎么花都会超'
1124 : rate === (pace && pace.perDay)
1125 ? '就是当前速度'
1126 : '这样会烧到 ' +
1127 fmtDay(at) +
1128 (safe
1129 ? ' —— 撑得到周期末'
1130 : ends != null
1131 ? ',比周期末早 ' + fmtDays((ends - at) / 86400000) + ' 断'
1132 : '');
1133 // 裁到剩下的宽度里 —— 这句话长度随数据变,窄面板上会超。
1134 row(
1135 T('w2', pad('', 10), {}),
1136 T('w3', truncTo(verdict, Math.max(4, cols - 10)), safe ? { color: 'success' } : { color: 'error', bold: true }),
1137 );
1138 }
1139 // 以前这里还有一句「也就是每天不超过 $X 就不会超」——
1140 // 现在「安全线」是常驻行,不用非得试算一次才能看到。
1141 }
1142 }
1143 gap();
1144
1145 row(
1146 T('f1', stale ? '读数可能已过期 · ' : '快照 ', { dim: true }),
1147 T(
1148 'f2',
1149 Math.max(0, Math.round((now - snapAtSafe(v, now)) / 1000)) + ' 秒前' + (stale ? '(刷新失败,显示的是旧读数)' : ''),
1150 { dim: true, prio: 1 },
1151 ),
1152 );
1153 gap();
1154
1155 row(
1156 { id: 'refresh', kind: 'button', width: plainButtonW('刷新', '1'), label: '刷新', hotkey: '1' },
1157 T('bs', ' '),
1158 { id: 'copy', kind: 'button', width: plainButtonW('复制快照', '2'), label: '复制快照', hotkey: '2' },
1159 );
1160
1161 return rows;
1162}
1163
1164// 面板里显示"快照多久之前",但要和 hooks 侧真正记的抓取时刻一致。
1165// 纯函数拿不到那个模块变量,所以由调用方通过 v.fetchedAt 带进来。
1166function snapAtSafe(v, now) {
1167 const t = Number(v && v.fetchedAt);
1168 return Number.isFinite(t) && t > 0 ? t : now;
1169}
1170
1171// ────────────────────────────────────────────────────────────
1172// 可复制的纯文本快照:一屏自解释,带上口径与时间戳,
1173// 能直接贴给同事或存进 issue。
1174// ────────────────────────────────────────────────────────────
1175export function snapshotText(v, now) {
1176 const pace = computePace(v, now);
1177 const tier = tierOf(v, pace);
1178 const d = new Date(now);
1179 const p = (n) => String(n).padStart(2, '0');
1180 const lines = [
1181 `runway · ${v.plan.name || '—'} · ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`,
1182 `月额度 ${fmtPct(v.monthly.percent)} 已用 · 剩 ${fmtMoney(v.monthly.remaining)} / ${fmtMoney(v.monthly.total)}`,
1183 `周期 剩 ${v.plan.daysLeft != null ? v.plan.daysLeft + ' 天' : '—'}`,
1184 ];
1185 const fh = v.windows.fiveHour;
1186 const wk = v.windows.weekly;
1187 if (fh) {
1188 lines.push(
1189 `5h 窗口 ${fh.started ? `${fmtPct(fh.percent)} 已用 · ${fmtMoney(fh.used)} / ${fmtMoney(fh.cap)}${fh.resetsInMs != null ? ' · ' + resetText(fh, now) : ''}` : `未启动(上限 ${fmtMoney(fh.cap)})`}`,
1190 );
1191 }
1192 if (wk) {
1193 lines.push(
1194 `周窗口 ${fmtPct(wk.percent)} 已用 · ${fmtMoney(wk.used)} / ${fmtMoney(wk.cap)}`,
1195 );
1196 }
1197 if (pace && !pace.insufficient) {
1198 lines.push(
1199 `节奏 ${tier.word} · 均速 ${fmtMoney(pace.perDay)}/天(样本 ${fmtDays(pace.sampleDays)})`,
1200 );lib/catalog.mjs 199 lines1// runway · 模型次数目录(纯函数,不出现 `$`,可测可预览)
2//
3// 「这个套餐下,每个模型大概还能调用多少次」—— Command Code 只在**公开文档页**上
4// 公布这张表,`/alpha/*` 接口不暴露它。所以这里解析的是 docs 站的 RSC 流:
5//
6// GET https://commandcode.ai/docs/plans/<slug> 头:RSC: 1
7// → text/x-component(Next.js 的 flight 流,约 200 KB,公开页、不需要凭据)
8//
9// 口径(照抄官方页面的算法,dsh-commandcode-quota 的 catalog.mjs 是同一套):
10//
11// 每次成本 = 输入/1e6×输入单价 + 输出/1e6×输出单价 + 缓存读/1e6×缓存读单价
12// 月次数 = 该模型的 budgetUsd ÷ 每次成本
13// 5h 次数 = 月次数 × fiveHourFraction
14// 周次数 = 月次数 × weeklyFraction
15//
16// **每个模型有自己的 budgetUsd**(GOAT 里 DeepSeek V4.1 Flash 是 $60、GPT-5.6 Sol 是 $70),
17// 套餐级的那个 $70 不参与这条公式 —— 官方两处口径不同,不能互相推算。
18
19// planId → docs 页 slug。官方 planId 与「套餐名」不是一回事,别拿 PLANS 的键来对。
20const PLAN_SLUGS = {
21 'individual-go': 'go',
22 'individual-go-v1': 'go',
23 'individual-goat': 'goat',
24 'individual-pro': 'pro',
25 'individual-pro-v1': 'pro',
26 'individual-max': 'max',
27 'individual-ultra': 'max', // 官方没有独立专页,按 Max 推断
28};
29
30// ⚠️ 这里**曾经**有一张写死的 GO_FRACTIONS 表(individual-go → 0.2/0.5,
31// individual-go-v1 → 0.3/0.6),理由是"页面 props 只反映当前那一代,老套餐会低估"。
32//
33// 2026-10-05 实测把它推翻了:**Go 页面此刻给的正是 0.3 / 0.6**,而那张写死的表
34// 对 `individual-go` 覆盖成 0.2 / 0.5 —— 于是 5h/周的可调用次数**比真值低三分之一**。
35// 这就是"拿写死的盖住实时的"最典型的翻车方式。
36//
37// 现在**以页面为准**(它就是官方此刻公布的口径)。至于某个老套餐的历史系数是否与
38// 当前页面不同 —— 那是个假设,没有证据,不该拿它去覆盖实测数据。
39export const GO_FRACTIONS = {};
40
41export function planSlug(planId) {
42 if (!planId || typeof planId !== 'string') return null;
43 return PLAN_SLUGS[planId.toLowerCase().replace(/_/g, '-')] ?? null;
44}
45
46export function catalogUrl(planId) {
47 const slug = planSlug(planId);
48 return slug ? 'https://commandcode.ai/docs/plans/' + slug : null;
49}
50
51// ── 解析 ──
52// flight 流是「一行一条记录」:`<id>:<JSON>`;id 每次部署都会变,
53// 所以只按**结构签名**找那条记录(有 rows、有 budgetUsd、有两个窗口系数),
54// 绝不按 id 匹配。
55
56function findEstimate(node) {
57 if (!node || typeof node !== 'object') return null;
58 if (Array.isArray(node)) {
59 for (const x of node) {
60 const r = findEstimate(x);
61 if (r) return r;
62 }
63 return null;
64 }
65 if (
66 typeof node.fiveHourFraction === 'number' &&
67 typeof node.weeklyFraction === 'number' &&
68 Array.isArray(node.rows) &&
69 node.rows.some((r) => r && typeof r.budgetUsd === 'number' && isObj(r.rates) && isObj(r.shape))
70 ) {
71 return node;
72 }
73 for (const v of Object.values(node)) {
74 const r = findEstimate(v);
75 if (r) return r;
76 }
77 return null;
78}
79
80function isObj(v) {
81 return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
82}
83
84const flightRe = /^([0-9a-f]+):(.*)$/;
85
86export function parsePlanEstimates(text) {
87 if (typeof text !== 'string' || !text) return null;
88 // 压缩过的响应不能当文本解析 —— 早失败好过解析出半张表。
89 if (text.charCodeAt(0) === 0x1f && text.charCodeAt(1) === 0x8b) return null;
90 if (!text.includes('budgetUsd') || !text.includes('fiveHourFraction')) return null;
91
92 for (const line of text.split('\n')) {
93 const m = flightRe.exec(line);
94 if (!m) continue;
95 let v;
96 try {
97 v = JSON.parse(m[2]);
98 } catch {
99 continue;
100 }
101 const est = findEstimate(v);
102 if (!est) continue;
103 const rows = est.rows.filter(
104 (r) => r && typeof r.name === 'string' && r.name && typeof r.budgetUsd === 'number' && isObj(r.rates) && isObj(r.shape),
105 );
106 if (!rows.length) continue;
107 return {
108 rows,
109 fiveHourFraction: est.fiveHourFraction,
110 weeklyFraction: est.weeklyFraction,
111 };
112 }
113 return null;
114}
115
116// ── 换算 ──
117
118function costOf(row) {
119 const r = row.rates;
120 const s = row.shape;
121 const n = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
122 return (
123 (n(s.inputTokens) / 1e6) * n(r.inputCost) +
124 (n(s.outputTokens) / 1e6) * n(r.outputCost) +
125 (n(s.cacheReadTokens) / 1e6) * n(r.cacheReadCost)
126 );
127}
128
129// 官方展示口径:有效数字 3 位,再按 en-US 加千位分隔(154,000 / 30,800)。
130export function fmtCount(n) {
131 if (!Number.isFinite(n)) return '—';
132 return Number(n.toPrecision(3)).toLocaleString('en-US');
133}
134
135// 原始解析结果 → 可直接上屏的目录。按**每月次数降序**:
136// 次数最多 = 每美元能买到的请求最多 = 最该被看见的那一档。
137export function buildCatalog(raw, planId, at) {
138 if (!raw || !Array.isArray(raw.rows) || !raw.rows.length) return null;
139 const slug = planSlug(planId);
140 const go = GO_FRACTIONS[String(planId || '').toLowerCase().replace(/_/g, '-')];
141 const fh = go ? go.fiveHour : raw.fiveHourFraction;
142 const wk = go ? go.weekly : raw.weeklyFraction;
143
144 const models = [];
145 for (const row of raw.rows) {
146 const cost = costOf(row);
147 const monthly = cost > 0 ? row.budgetUsd / cost : null; // cost 为 0 → 官方显示 Free
148 models.push({
149 name: row.name,
150 budgetUsd: row.budgetUsd,
151 costPerRequest: cost > 0 ? cost : null,
152 monthly,
153 fiveHour: monthly == null ? null : monthly * fh,
154 weekly: monthly == null ? null : monthly * wk,
155 hasTimeOfDay: isObj(row.timeOfDay),
156 });
157 }
158 // 次数算不出来的(Free)排在最后,而不是当成 0 混在中间。
159 models.sort((a, b) => (b.monthly ?? -1) - (a.monthly ?? -1));
160 return { slug, planId: planId ?? null, fiveHourFraction: fh, weeklyFraction: wk, models, fetchedAt: at ?? null };
161}
162
163// ── 和上一份比:哪些是新的、哪些改了价 ──
164// 用户要的就是这个:「看看它有没有更新哪些新的又便宜又好用的模型」。
165export function catalogDiff(prev, next) {
166 const empty = { added: new Set(), repriced: new Set(), removed: new Set() };
167 if (!next || !Array.isArray(next.models)) return empty;
168 if (!prev || !Array.isArray(prev.models) || !prev.models.length) return empty;
169
170 const before = new Map(prev.models.map((m) => [m.name, m]));
171 const added = new Set();
172 const repriced = new Set();
173 for (const m of next.models) {
174 const old = before.get(m.name);
175 if (!old) {
176 added.add(m.name);
177 continue;
178 }
179 // 单价或月度份额变了 = 官方调过价。比字符串的话会误报,所以比数值。
180 if (Math.abs((old.costPerRequest ?? 0) - (m.costPerRequest ?? 0)) > 1e-9 || (old.budgetUsd ?? 0) !== (m.budgetUsd ?? 0)) {
181 repriced.add(m.name);
182 }
183 }
184 const now = new Set(next.models.map((m) => m.name));
185 const removed = new Set(prev.models.map((m) => m.name).filter((n) => !now.has(n)));
186 return { added, repriced, removed };
187}
188
189// 目录本身的新鲜度。官方是实时更新的(模型上下架、调价),
190// 所以「什么时候抓的」必须能看见 —— 不然一张过期表比没有表更误导。
191export function catalogAge(catalog, now) {
192 if (!catalog || !catalog.fetchedAt) return null;
193 const ms = Math.max(0, (Number(now) || 0) - catalog.fetchedAt);
194 if (ms < 90_000) return '刚刚更新';
195 if (ms < 3600_000) return Math.round(ms / 60_000) + ' 分钟前更新';
196 if (ms < 48 * 3600_000) return Math.round(ms / 3600_000) + ' 小时前更新';
197 return Math.round(ms / 86400_000) + ' 天前更新';
198}
199