@mzzsfy/dsh-usage-dash 0.10.3 → 0.10.5
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 +8 -8
- package/package.json +1 -1
- package/src/client.js +134 -69
- package/src/routes.js +18 -13
- package/src/store.js +31 -7
- package/test/pricing-current.test.mjs +179 -0
- package/test/routes.test.mjs +2 -0
- package/test/stats-line-render.test.mjs +1 -1
- package/test/store-owner.test.mjs +45 -0
- package/test/store.test.mjs +163 -36
- package/test/trend-tooltip.test.mjs +182 -0
package/README.md
CHANGED
|
@@ -9,25 +9,25 @@
|
|
|
9
9
|
- 三粒度视图,全部支持自定义时间范围:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天/自定义时刻段)、分钟(3 小时/24 小时/3 天/7 天/自定义时刻段,10 分钟桶粒度),预设挡为滚动窗口;自定义时刻段为起止 datetime 双输入,两端归一到桶边界(小时取所在整点、分钟起点对齐 10 分钟且终点保留原分钟,均闭区间),跨度上限与数据保留期一致(小时 15 天、分钟 7 天,超限自动钳起点),首次切到自定义挡预填最近 24 小时,时/分两视图共享同一对自定义输入;已知限制:自定义输入残缺(任一端为空)时面板不发请求且图表静默空白,与天视图行为一致,交互反馈留待后续增强
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
11
|
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细(配置定价规则后附「费用 ≈」估算行,受「费用显示」开关;费用数据依赖小时桶保留期,仅最近 15 天的天显示,更早的天无该行);仅按天视图展示
|
|
12
|
-
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制)
|
|
12
|
+
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停按已启用曲线显示当前时段命中率、平均生成速度、首 token 延迟,模型明细随 Token/金额视图与图例筛选联动(配置定价规则后 Token 视图可附所选模型估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
13
|
- 模型 donut 与列表:按 token 排序的模型占比环形图(中心为总量,模型不超过 5 个时全展示,超过时折叠为前 4 +「其他」,「其他」可展开),每行名称两行展示(厂商/模型名),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
|
-
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token
|
|
14
|
+
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题、左轴和悬停的总计/逐模型/「其他」明细同步切金额读数(带货币符号和「≈」),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导)。图例点击单选、再点恢复默认显示(全部模型及有数据的速度线),Ctrl+点击多选,隐藏最后一项无效;换窗口时只保留仍可用的所选项,全部失效则恢复默认显示;柱高、提示合计、辅助费用和均价均按可见模型计算,「其他」子项不重复计入合计,金额模式「其他」明细按费用降序。命中率/速度/首 token 延迟曲线保持整个时段的统计口径,提示中标注「时段整体指标」,仅显示已启用且当前桶有数据的曲线读数(速度或延迟为零仍属有效值,独立 timing 桶没有 Token 也可展示);仅选曲线时不再显示模型合计与费用。零用量模型不显示,有用量的零费用模型保留零费用读数,无可见模型用量且没有启用曲线的有效读数时不显示提示;切换时间窗口时提示绑定时间桶,不复用旧位置。金额模式主费用读数不受「费用显示」开关控制;有可见模型用量且所选费用为正时显示均价,该均价亦不受此开关控制。Token 模式辅助费用与均价仍受「费用显示」开关控制
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
|
-
- 会话底栏追加(append 模式):本插件在官方 activity/usage 胶囊之后仅显示估算费用,不另建明细按钮或浮层。精确缓存命中率开关(`cachePrecision`)原位修改官方 usage 胶囊的命中率文字、按钮 `aria-label` 与官方弹窗原命中率 `dd`,统一显示两位小数;关闭开关或卸载增强时还原,保留官方节点与交互。「会话 Token 明细」开关(`tokenDetail`)只在已打开的官方 `[data-session-stats-usage]` 用量弹窗 `dl` 末尾追加「输入合计(含缓存)」(未缓存输入+缓存读+缓存写),仅当 `cacheWriteTokens > 0` 再追加「未命中合计(含缓存写入)」(未缓存输入+缓存写入)
|
|
16
|
+
- 会话底栏追加(append 模式):本插件在官方 activity/usage 胶囊之后仅显示估算费用,不另建明细按钮或浮层。精确缓存命中率开关(`cachePrecision`)原位修改官方 usage 胶囊的命中率文字、按钮 `aria-label` 与官方弹窗原命中率 `dd`,统一显示两位小数;关闭开关或卸载增强时还原,保留官方节点与交互。「会话 Token 明细」开关(`tokenDetail`)只在已打开的官方 `[data-session-stats-usage]` 用量弹窗 `dl` 末尾追加「输入合计(含缓存)」(未缓存输入+缓存读+缓存写),仅当 `cacheWriteTokens > 0` 再追加「未命中合计(含缓存写入)」(未缓存输入+缓存写入)。合计使用完整千分位计数,官方已有的总量、缓存读写、输出与命中率不重复追加;无输入用量时不追加零值合计。偏好卡三个开关仍默认全开,已有偏好继续生效;「费用显示」控制底栏费用、Token 趋势 tooltip 的辅助费用行与回合费用芯片;金额趋势主读数不受此开关控制。官方弹窗关闭时不挂载补充行;空用量无插件可见条目,精度或明细单独开启均不产生底栏入口。官方未公开用量弹窗扩展 slot,上述增强依赖宿主 DOM 语义标记,标记改变或无法唯一确认弹窗归属时停用相应增强,底栏费用仍独立显示。
|
|
17
17
|
- 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
|
|
18
18
|
- 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
|
|
19
19
|
- 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
|
|
20
|
-
- 数据 API 守卫:POST 同源校验 + JSON content-type
|
|
20
|
+
- 数据 API 守卫:POST 同源校验 + JSON content-type,与 dsh-usage-panel 同构(pricing 端点例外:GET/POST 放行且不要求 content-type),局域网远程访问可用
|
|
21
21
|
|
|
22
22
|
口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。聚合响应 `models` 数组每模型带 `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` 四桶拆分(与定价四桶同口径),模型列表据此展示输入输出对比。平均生成速度 = decode 配对分子 ÷ 解码时长,两者均取官方吞吐口径(与官方 session-stats 投影同构):解码时长为该步首 token 时刻(首个产出 token 的 attempt 流,回落 message 自带流)到 usage 汇报时刻,不含首 token 前的排队与提示处理等待;首 token 延迟(TTFT)= 首 token 时刻 − `step/start`(起点不随 `llm/retry-started` 重置,即含失败尝试时间)。首 token 时刻在 chunk token 样本上不可得,由后续 usage 报告(message 为主)补发零桶 timing 增量行承载(decodeTokens 与时长同源配对,不重复计 token);存量旧行(时长为旧全时长口径)速度分子回落输出 token,聚合随新数据自然收敛,重建(重扫)可全量按新口径重建。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
|
|
23
23
|
|
|
24
|
-
写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush
|
|
24
|
+
写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush 周期,统计可由会话重扫重建。同一存储服务复用现有健康实例;主域已占用时新实例报告降级,不会关闭占用者或中断其待写数据。备份域占用仅停用备份,主采集继续。更换全局缓存结构后须冷重启宿主,不热迁移仍在使用的存储域。
|
|
25
25
|
|
|
26
26
|
## 定价与费用估算
|
|
27
27
|
|
|
28
|
-
规则存于设置存储域,经 `GET/POST /api/usage-dash/pricing` 读写(响应含单调 revision)
|
|
28
|
+
规则存于设置存储域,经 `GET/POST /api/usage-dash/pricing` 读写(响应含单调 revision);设置面板编辑器为常规入口。单页内价格请求合并为一个在途请求,缓存有效期内复用;保存成功会同步更新已挂载底栏/费用芯片,并重取面板日范围、热力图及当前时/分数据。迟到的低 revision 不覆盖新价格,其他编辑器未保存草稿保持独立;价格不存本机持久缓存。
|
|
29
29
|
|
|
30
|
-
规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段)
|
|
30
|
+
规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。**服务端单币约束**:pricing POST 整表替换时非空 currency 全表必须一致,混币提交 400 拒绝——费用聚合按数值直接相加,混币相加无意义。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。**计价时点口径**:时间条件按「估算时点」匹配当前规则表——面板聚合以小时桶时点,回合费用芯片以回合结束事件被观测时刻(用量首达即冻结,重渲染不漂移);规则表变更后按当前规则重算历史为既定选择,不做价格版本化存储。
|
|
31
31
|
|
|
32
32
|
- 模型匹配:`model` 为两段式 `vendor/model`(首个 `/` 分段,模型段允许含 `/`),两段各自可 `*` 通配;匹配链为 全名精确 > 模型名精确(`*/model`,跨供应商同模型名同价)> 供应商精确(`vendor/*`,同供应商多模型同价)> `*/*` 全通,档位相同按数组序取首个命中,高档条件不满足逐层落低档;全链无命中不计费用并计 unpriced。提交(编辑器保存与 pricing POST)强制两段式,单段旧形态(`*` 或裸名)不再合法
|
|
33
33
|
- 同模型多规则(分时段定价):组内附加计费规则从上到下首个「条件全过」者生效,全不命中落组内默认价(无条件规则,恒兜底);附加规则卡带 ↑/↓ 调序。注意同一模型的多条规则请使用相同的模型键写法——不同写法(如 `*/model` 与 `vendor/model`)分属不同档位,按档位优先级而非数组序取胜
|
|
@@ -93,4 +93,4 @@ dsh --profile <profile> --no-open --port 9191
|
|
|
93
93
|
|
|
94
94
|
## dsh 版本兼容
|
|
95
95
|
|
|
96
|
-
按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md`
|
|
96
|
+
按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md`(本地维护,不入库)与本包 `兼容性测试.md`。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
3
|
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏官方数据后追加增强项与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -214,6 +214,10 @@ const MESSAGES_ZH = {
|
|
|
214
214
|
modelOutput: '输出',
|
|
215
215
|
other: '其他',
|
|
216
216
|
total: '总用量',
|
|
217
|
+
selectedTotal: '所选用量',
|
|
218
|
+
totalCost: '估算费用',
|
|
219
|
+
selectedCost: '所选估算费用',
|
|
220
|
+
slotMetrics: '时段整体指标',
|
|
217
221
|
percent: '占比',
|
|
218
222
|
asOf: '统计截至',
|
|
219
223
|
empty: '当前时间范围内暂无用量数据。Token 用量从本面板启用后开始累计,并会一次性回扫已有的历史会话。',
|
|
@@ -357,6 +361,10 @@ const MESSAGES_EN = {
|
|
|
357
361
|
modelOutput: 'Output',
|
|
358
362
|
other: 'Other',
|
|
359
363
|
total: 'Total',
|
|
364
|
+
selectedTotal: 'Selected usage',
|
|
365
|
+
totalCost: 'Estimated cost',
|
|
366
|
+
selectedCost: 'Selected estimated cost',
|
|
367
|
+
slotMetrics: 'Overall interval metrics',
|
|
360
368
|
percent: 'Share',
|
|
361
369
|
asOf: 'Stats as of',
|
|
362
370
|
empty: 'No usage data in this range yet. Token usage accumulates from when this panel is enabled, and existing sessions are scanned once.',
|
|
@@ -927,6 +935,13 @@ const tipModelEntries = (hoverSlot, visibleSet) => {
|
|
|
927
935
|
return { main, other }
|
|
928
936
|
}
|
|
929
937
|
|
|
938
|
+
// 悬浮合计与柱形共用可见模型集合,其他子项仅展开、不重复求和。
|
|
939
|
+
const visibleTipSlot = (slot, visibleModels) => slot ? {
|
|
940
|
+
...slot,
|
|
941
|
+
total: visibleModels.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0),
|
|
942
|
+
cost: slot.cost === undefined ? undefined : visibleModels.reduce((sum, model) => sum + (slot.byCostModel[model] ?? 0), 0),
|
|
943
|
+
} : null
|
|
944
|
+
|
|
930
945
|
// 趋势 tooltip 槽级均价:槽费用折每百万 token 单价;无槽或无有效费用(含零值)为 null
|
|
931
946
|
const tipAvgPriceText = (hoverSlot, currency) => (
|
|
932
947
|
hoverSlot ? avgPriceText(hoverSlot.cost, hoverSlot.total, currency) || null : null
|
|
@@ -1785,8 +1800,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1785
1800
|
const PRICING_CACHE_TTL_MS = 5 * 60 * 1000
|
|
1786
1801
|
const PRICING_SAVED_NOTICE_MS = 3 * 1000
|
|
1787
1802
|
let cached = null
|
|
1788
|
-
|
|
1803
|
+
let pricingFlight = null
|
|
1804
|
+
const pricingListeners = new Set()
|
|
1805
|
+
const validPricing = value => value && Number.isFinite(value.revision) && Array.isArray(value.rules)
|
|
1806
|
+
const applyPricingValue = (value, mutation = false) => {
|
|
1807
|
+
if (!validPricing(value) || (cached && value.revision < cached.revision)) return cached
|
|
1808
|
+
const refresh = mutation || (cached !== null && value.revision !== cached.revision)
|
|
1789
1809
|
cached = { revision: value.revision, rules: value.rules, fetchedAt: Date.now() }
|
|
1810
|
+
for (const listener of pricingListeners) listener(cached, refresh)
|
|
1790
1811
|
return cached
|
|
1791
1812
|
}
|
|
1792
1813
|
|
|
@@ -1809,10 +1830,29 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1809
1830
|
// value 缺形状按失败处理:回退旧值,无旧值为 null(费用显示占位)
|
|
1810
1831
|
const fetchPricing = async (force = false) => {
|
|
1811
1832
|
if (!force && cached && Date.now() - cached.fetchedAt < PRICING_CACHE_TTL_MS) return cached
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1833
|
+
if (!pricingFlight) {
|
|
1834
|
+
pricingFlight = (async () => {
|
|
1835
|
+
const result = await requestGet(ENDPOINTS.pricing)
|
|
1836
|
+
return validPricing(result.ok ? result.value : null) ? applyPricingValue(result.value) : cached
|
|
1837
|
+
})().finally(() => { pricingFlight = null })
|
|
1838
|
+
}
|
|
1839
|
+
return pricingFlight
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function usePricing() {
|
|
1843
|
+
const [value, setValue] = useState(cached)
|
|
1844
|
+
const [tick, setTick] = useState(0)
|
|
1845
|
+
useEffect(() => {
|
|
1846
|
+
const listener = (next, refresh) => {
|
|
1847
|
+
setValue(next)
|
|
1848
|
+
if (refresh) setTick(previous => previous + 1)
|
|
1849
|
+
}
|
|
1850
|
+
pricingListeners.add(listener)
|
|
1851
|
+
if (cached) listener(cached, false)
|
|
1852
|
+
void fetchPricing()
|
|
1853
|
+
return () => pricingListeners.delete(listener)
|
|
1854
|
+
}, [])
|
|
1855
|
+
return { rules: value?.rules ?? null, tick }
|
|
1816
1856
|
}
|
|
1817
1857
|
|
|
1818
1858
|
// 交互与尺寸常量
|
|
@@ -1921,11 +1961,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1921
1961
|
}
|
|
1922
1962
|
|
|
1923
1963
|
const STYLE_CSS = `
|
|
1924
|
-
/*
|
|
1925
|
-
|
|
1964
|
+
/* 图表区分色:主色与热力图梯度挂业务蓝派生(brand-primary 在宿主新版主题为单色黑白,不可作数据色);
|
|
1965
|
+
speed/ttft 用 success/warn 语义槽,亮暗随宿主别名自动适配 */
|
|
1926
1966
|
.ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
|
|
1927
|
-
--ud-chart-1:var(--dsw-alias-
|
|
1928
|
-
--ud-heat-0:color-mix(in srgb, var(--dsw-alias-label-tertiary) 12%, transparent);--ud-heat-1:color-mix(in srgb, var(--dsw-alias-
|
|
1967
|
+
--ud-chart-1:var(--dsw-alias-state-business-primary);--ud-chart-2:var(--dsw-alias-state-success-primary);--ud-chart-3:var(--dsw-alias-state-warn-primary);--ud-chart-4:#975bf1;--ud-chart-5:#d34591;--ud-chart-other:var(--dsw-alias-label-tertiary);
|
|
1968
|
+
--ud-heat-0:color-mix(in srgb, var(--dsw-alias-label-tertiary) 12%, transparent);--ud-heat-1:color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);--ud-heat-2:color-mix(in srgb, var(--dsw-alias-state-business-primary) 36%, transparent);--ud-heat-3:color-mix(in srgb, var(--dsw-alias-state-business-primary) 54%, transparent);--ud-heat-4:color-mix(in srgb, var(--dsw-alias-state-business-primary) 74%, transparent);--ud-heat-5:var(--dsw-alias-state-business-primary);--ud-trend-line:var(--dsw-alias-state-business-primary);--ud-trend-speed:var(--dsw-alias-state-success-primary);--ud-trend-ttft:var(--dsw-alias-state-warn-primary)}
|
|
1929
1969
|
.ud-toolbar{display:flex;align-items:flex-start;gap:8px}
|
|
1930
1970
|
.ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
|
|
1931
1971
|
.ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
|
|
@@ -2222,7 +2262,16 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2222
2262
|
}, [])
|
|
2223
2263
|
const hasSpeed = slots.some((slot) => slot.speed !== undefined)
|
|
2224
2264
|
const hasTtft = slots.some((slot) => slot.ttft !== undefined)
|
|
2225
|
-
const
|
|
2265
|
+
const availableKeys = new Set([
|
|
2266
|
+
...modelOrder, LEGEND_KEY_RATE,
|
|
2267
|
+
...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
|
|
2268
|
+
...(hasTtft ? [LEGEND_KEY_TTFT] : []),
|
|
2269
|
+
])
|
|
2270
|
+
const selectedKeys = visibleKeys && new Set([...visibleKeys].filter((key) => availableKeys.has(key)))
|
|
2271
|
+
if (selectedKeys && selectedKeys.size !== visibleKeys.size) {
|
|
2272
|
+
setVisibleKeys(selectedKeys.size ? selectedKeys : null)
|
|
2273
|
+
}
|
|
2274
|
+
const visibleSet = selectedKeys?.size ? selectedKeys : trendDefaultVisibleKeys(modelOrder, hasSpeed)
|
|
2226
2275
|
const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
|
|
2227
2276
|
const showRate = visibleSet.has(LEGEND_KEY_RATE)
|
|
2228
2277
|
const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
|
|
@@ -2242,15 +2291,26 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2242
2291
|
// 金额视图左轴走紧凑金额读数,速度接管时仍为 tok/s 紧凑读数
|
|
2243
2292
|
const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax,
|
|
2244
2293
|
money ? (value) => formatCostCompact(value, costCurrency) : formatCompact)
|
|
2245
|
-
|
|
2246
|
-
const
|
|
2294
|
+
const hoverSlot = hover ? tipSlots.find((slot) => slot.day === hover.day) : null
|
|
2295
|
+
const selectedSlot = visibleTipSlot(hoverSlot, visibleModels)
|
|
2296
|
+
const tipEntries = tipModelEntries(selectedSlot, visibleSet)
|
|
2297
|
+
const hasModels = tipEntries.main.length > 0
|
|
2298
|
+
const tipRate = showRate && hoverSlot && cacheRate(hoverSlot.cacheHit, hoverSlot.cacheMiss) !== null
|
|
2299
|
+
const tipSpeed = showSpeed && hoverSlot?.speed !== undefined
|
|
2300
|
+
const tipTtft = showTtft && hoverSlot?.ttft !== undefined
|
|
2301
|
+
const hasMetrics = tipRate || tipSpeed || tipTtft
|
|
2302
|
+
const hasTip = hasModels || hasMetrics
|
|
2303
|
+
const filtered = visibleModels.length < modelOrder.length
|
|
2304
|
+
const formatTipValue = money ? (value) => `≈ ${formatCost(value, costCurrency)}` : (value) => `${formatTokens(value)} tok`
|
|
2305
|
+
const tipTotalLabel = t(money ? (filtered ? 'selectedCost' : 'totalCost') : (filtered ? 'selectedTotal' : 'total'))
|
|
2247
2306
|
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
2248
2307
|
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2249
2308
|
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2250
|
-
const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
|
|
2309
|
+
const pick = (index) => (event) => setHover({ day: slots[index].day, anchor: pointerAt(event) })
|
|
2251
2310
|
const clear = () => setHover(null)
|
|
2252
|
-
const
|
|
2253
|
-
const avgPrice = tipAvgPriceText(
|
|
2311
|
+
const showCost = hasModels && (money || prefsRef.current.costDisplay) && selectedSlot.cost !== undefined
|
|
2312
|
+
const avgPrice = showCost ? tipAvgPriceText(selectedSlot, costCurrency) : null
|
|
2313
|
+
const otherEntries = money ? [...tipEntries.other].sort(([a], [b]) => (hoverSlot.otherCostByModel[b] ?? 0) - (hoverSlot.otherCostByModel[a] ?? 0)) : tipEntries.other
|
|
2254
2314
|
return h('div', { className: 'ud-section' },
|
|
2255
2315
|
h('div', { className: 'ud-section-head' },
|
|
2256
2316
|
h('span', { className: 'ud-section-title' }, title),
|
|
@@ -2279,11 +2339,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2279
2339
|
x: plotRight + AXIS_RATE_GAP, y: y + AXIS_LABEL_BASELINE,
|
|
2280
2340
|
}, String(tick))
|
|
2281
2341
|
}),
|
|
2282
|
-
layout.bars.flatMap((bar
|
|
2342
|
+
layout.bars.flatMap((bar) => bar.segments.map((segment) => h('rect', {
|
|
2283
2343
|
key: `${bar.key}/${segment.model}`, className: 'ud-bar',
|
|
2284
2344
|
x: bar.x - layout.barWidth / 2, y: segment.y, width: layout.barWidth, height: segment.height,
|
|
2285
2345
|
fill: colorFor(segment.model),
|
|
2286
|
-
style: hover?.
|
|
2346
|
+
style: hover?.day === bar.key
|
|
2287
2347
|
? { transform: `scaleX(${(layout.barWidth + BAR_HOVER_GROW) / layout.barWidth})` }
|
|
2288
2348
|
: undefined,
|
|
2289
2349
|
}))),
|
|
@@ -2321,25 +2381,22 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2321
2381
|
isVisible: (key) => visibleSet.has(key),
|
|
2322
2382
|
onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleSet, key, ctrl)),
|
|
2323
2383
|
}),
|
|
2324
|
-
h(ChartTip, { anchor:
|
|
2325
|
-
|
|
2384
|
+
h(ChartTip, { anchor: hasTip ? hover.anchor : null, panelRef },
|
|
2385
|
+
hasTip
|
|
2326
2386
|
? [
|
|
2327
2387
|
h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
|
|
2328
|
-
h('div', { key: 'total', className: 'ud-tip-row' }, `${
|
|
2388
|
+
hasModels ? h('div', { key: 'total', className: 'ud-tip-row' }, `${tipTotalLabel}: ${formatTipValue(money ? selectedSlot.cost : selectedSlot.total)}`) : null,
|
|
2329
2389
|
...tipEntries.main.map((row) => h('div', { key: `m-${row.model}`, className: 'ud-tip-row' },
|
|
2330
2390
|
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(row.model) } }),
|
|
2331
|
-
`${row.model === OTHER_MODEL ? t('other') : row.model}: ${
|
|
2332
|
-
...
|
|
2333
|
-
`${model}: ${
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
avgPrice ? h('div', { key: 'avg-price', className: 'ud-tip-row' }, `${t('avgPrice')}: ${avgPrice}`) : null,
|
|
2341
|
-
]
|
|
2342
|
-
: null,
|
|
2391
|
+
`${row.model === OTHER_MODEL ? t('other') : row.model}: ${formatTipValue(money ? hoverSlot.byCostModel[row.model] ?? 0 : row.tokens)}`)),
|
|
2392
|
+
...otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
|
|
2393
|
+
`${model}: ${formatTipValue(money ? hoverSlot.otherCostByModel[model] ?? 0 : tokens)}`)),
|
|
2394
|
+
showCost && !money ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(selectedSlot.cost, costCurrency)}`) : null,
|
|
2395
|
+
avgPrice ? h('div', { key: 'avg-price', className: 'ud-tip-row' }, `${t('avgPrice')}: ${avgPrice}`) : null,
|
|
2396
|
+
hasMetrics ? h('div', { key: 'metrics', className: 'ud-tip-title' }, t('slotMetrics')) : null,
|
|
2397
|
+
tipRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
|
|
2398
|
+
tipSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
|
|
2399
|
+
tipTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
|
|
2343
2400
|
]
|
|
2344
2401
|
: null))
|
|
2345
2402
|
}
|
|
@@ -2605,14 +2662,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2605
2662
|
const usage = useProjection('tokenUsage')
|
|
2606
2663
|
const [prefs, setPrefs] = useState(() => statsLineState.get())
|
|
2607
2664
|
useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
|
|
2608
|
-
const
|
|
2609
|
-
useEffect(() => {
|
|
2610
|
-
let alive = true
|
|
2611
|
-
fetchPricing().then((value) => {
|
|
2612
|
-
if (alive && value) setPricingRules(value.rules)
|
|
2613
|
-
})
|
|
2614
|
-
return () => { alive = false }
|
|
2615
|
-
}, [])
|
|
2665
|
+
const { rules: pricingRules } = usePricing()
|
|
2616
2666
|
const anchorRef = useRef(null)
|
|
2617
2667
|
const costRef = useRef(null)
|
|
2618
2668
|
useLayoutEffect(() => {
|
|
@@ -2649,17 +2699,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2649
2699
|
: null
|
|
2650
2700
|
const [prefs, setPrefs] = useState(() => statsLineState.get())
|
|
2651
2701
|
useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
|
|
2652
|
-
const
|
|
2653
|
-
useEffect(() => {
|
|
2654
|
-
let alive = true
|
|
2655
|
-
fetchPricing().then((value) => {
|
|
2656
|
-
if (alive && value) setPricingRules(value.rules)
|
|
2657
|
-
})
|
|
2658
|
-
return () => { alive = false }
|
|
2659
|
-
}, [])
|
|
2702
|
+
const { rules: pricingRules } = usePricing()
|
|
2660
2703
|
const matched = usageSourceBuckets(usageSource)
|
|
2661
|
-
|
|
2662
|
-
|
|
2704
|
+
// 计价时点 = 回合结束事件被观测的时刻(F15 决策:统一用当时价格、按回合
|
|
2705
|
+
// 事件时点匹配当前规则表)。官方 tokenUsage 无事件时间戳,以用量首次到
|
|
2706
|
+
// 达的观测时刻冻结:后续规则重渲染不再漂移,同一回合计价时点恒定
|
|
2707
|
+
const observedAtRef = useRef(null)
|
|
2708
|
+
if (matched && observedAtRef.current === null) observedAtRef.current = Date.now()
|
|
2709
|
+
const price = matched && prefs.costDisplay && pricingRules !== null && observedAtRef.current !== null
|
|
2710
|
+
? matchPrice(pricingRules, turnModelOf(matched), new Date(observedAtRef.current))
|
|
2663
2711
|
: null
|
|
2664
2712
|
// 0 元(全零价/全零桶)与价未命中同不渲染,无占位符
|
|
2665
2713
|
const cost = matched && price ? turnCostAmountOf(matched, price) : null
|
|
@@ -2900,6 +2948,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2900
2948
|
const [saving, setSaving] = useState(false)
|
|
2901
2949
|
const [saved, setSaved] = useState(false)
|
|
2902
2950
|
const savedTimerRef = useRef(null)
|
|
2951
|
+
const aliveRef = useRef(false)
|
|
2952
|
+
useEffect(() => {
|
|
2953
|
+
aliveRef.current = true
|
|
2954
|
+
return () => { aliveRef.current = false }
|
|
2955
|
+
}, [])
|
|
2903
2956
|
|
|
2904
2957
|
useEffect(() => {
|
|
2905
2958
|
let alive = true
|
|
@@ -2930,13 +2983,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2930
2983
|
if (found.size > 0) return
|
|
2931
2984
|
setSaving(true)
|
|
2932
2985
|
const result = await requestPost(ENDPOINTS.pricing, { rules: coercePricingRules(rules) })
|
|
2986
|
+
// 保存属于共享数据提交,编辑器退出后仍广播;局部状态只由存活实例更新。
|
|
2987
|
+
if (result.ok && validPricing(result.value)) applyPricingValue(result.value, true)
|
|
2988
|
+
if (!aliveRef.current) return
|
|
2933
2989
|
setSaving(false)
|
|
2934
|
-
if (!result.ok) {
|
|
2935
|
-
setSaveError(result.message)
|
|
2990
|
+
if (!result.ok || !validPricing(result.value)) {
|
|
2991
|
+
setSaveError(result.message || '价格响应无效')
|
|
2936
2992
|
return
|
|
2937
2993
|
}
|
|
2938
|
-
//
|
|
2939
|
-
applyPricingValue(result.value)
|
|
2994
|
+
// 编辑副本同步为本次保存返回的规则,其他编辑器草稿保持独立。
|
|
2940
2995
|
setSaveError('')
|
|
2941
2996
|
setErrors(new Map())
|
|
2942
2997
|
setRules(copyRules(result.value.rules))
|
|
@@ -3151,20 +3206,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3151
3206
|
const heatGenerationRef = useRef(0)
|
|
3152
3207
|
const panelRef = useRef(null)
|
|
3153
3208
|
const [heatDays, setHeatDays] = useState(null)
|
|
3209
|
+
// 热力图重取信号(F23):手动刷新/回扫完成即 bump,独立请求随之重取
|
|
3210
|
+
const [heatFetchTick, setHeatFetchTick] = useState(0)
|
|
3211
|
+
const bumpHeatFetch = () => setHeatFetchTick((tick) => tick + 1)
|
|
3154
3212
|
// 费用展示货币来源:价格规则异步首帧未回时空串即不带符号
|
|
3155
|
-
const
|
|
3156
|
-
|
|
3157
|
-
useEffect(() => {
|
|
3158
|
-
let alive = true
|
|
3159
|
-
fetchPricing().then((value) => {
|
|
3160
|
-
if (alive && value) setPricingRules(value.rules)
|
|
3161
|
-
})
|
|
3162
|
-
return () => { alive = false }
|
|
3163
|
-
}, [])
|
|
3213
|
+
const { rules: pricingRules, tick: pricingTick } = usePricing()
|
|
3164
3214
|
|
|
3165
3215
|
const costCurrency = aggregateCurrencyOf(pricingRules)
|
|
3166
3216
|
|
|
3167
|
-
//
|
|
3217
|
+
// 热力图独立请求:范围切换不重取,价格变更重算费用,过期响应丢弃
|
|
3168
3218
|
useEffect(() => {
|
|
3169
3219
|
const generation = ++heatGenerationRef.current
|
|
3170
3220
|
const request = { from: localDay(-(HEAT_WINDOW_DAYS - 1)), to: localDay(0) }
|
|
@@ -3183,7 +3233,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3183
3233
|
}
|
|
3184
3234
|
}))
|
|
3185
3235
|
})
|
|
3186
|
-
|
|
3236
|
+
return () => { heatGenerationRef.current++ }
|
|
3237
|
+
}, [pricingTick, heatFetchTick])
|
|
3187
3238
|
|
|
3188
3239
|
const load = useCallback(async () => {
|
|
3189
3240
|
const request = range === 'custom'
|
|
@@ -3201,10 +3252,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3201
3252
|
}
|
|
3202
3253
|
setError('')
|
|
3203
3254
|
setStats(result.value)
|
|
3204
|
-
}, [range, customFrom, customTo])
|
|
3255
|
+
}, [range, customFrom, customTo, pricingTick])
|
|
3205
3256
|
|
|
3206
3257
|
useEffect(() => {
|
|
3207
3258
|
load()
|
|
3259
|
+
return () => { generationRef.current++ }
|
|
3208
3260
|
}, [load])
|
|
3209
3261
|
|
|
3210
3262
|
const rawPreset = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
|
|
@@ -3213,7 +3265,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3213
3265
|
|
|
3214
3266
|
useEffect(() => {
|
|
3215
3267
|
if (!activeQuery) return
|
|
3216
|
-
if (pointStatsMatches(pointStats, view, activeQuery.key)) return
|
|
3268
|
+
if (pointStatsMatches(pointStats, view, activeQuery.key) && pointStats.pricingTick === pricingTick) return
|
|
3217
3269
|
const generation = ++pointGenerationRef.current
|
|
3218
3270
|
setPointStatus('loading')
|
|
3219
3271
|
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
|
|
@@ -3225,9 +3277,10 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3225
3277
|
}
|
|
3226
3278
|
setError('')
|
|
3227
3279
|
setPointStatus('ok')
|
|
3228
|
-
setPointStats({ view, preset: activeQuery.key, value: result.value })
|
|
3280
|
+
setPointStats({ view, preset: activeQuery.key, pricingTick, value: result.value })
|
|
3229
3281
|
})
|
|
3230
|
-
|
|
3282
|
+
return () => { pointGenerationRef.current++ }
|
|
3283
|
+
}, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick, pricingTick])
|
|
3231
3284
|
|
|
3232
3285
|
const refreshPoints = useCallback(() => {
|
|
3233
3286
|
setPointStats(null)
|
|
@@ -3260,6 +3313,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3260
3313
|
|
|
3261
3314
|
const refresh = () => {
|
|
3262
3315
|
setError('')
|
|
3316
|
+
// 手动刷新同触热力图重取(F23):重建后热力图口径随日桶更新,
|
|
3317
|
+
// 独立请求此前只随挂载/价格变更重取,重建产物不进热力图
|
|
3318
|
+
bumpHeatFetch()
|
|
3263
3319
|
if (view === 'day') load()
|
|
3264
3320
|
else refreshPoints()
|
|
3265
3321
|
}
|
|
@@ -3290,9 +3346,18 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3290
3346
|
baseDone = value.done
|
|
3291
3347
|
scheduleRefresh()
|
|
3292
3348
|
}
|
|
3349
|
+
wasRunning = true
|
|
3293
3350
|
schedule(STATUS_POLL_FAST_MS)
|
|
3351
|
+
} else if (wasRunning) {
|
|
3352
|
+
// running→idle 转换补最终刷新(F23):reset 同步等回扫,响应时 running 已 false,
|
|
3353
|
+
// done 增长分支永不触发——回扫产物的最终态靠此转换收尾(含热力图重取)
|
|
3354
|
+
wasRunning = false
|
|
3355
|
+
scheduleRefresh()
|
|
3356
|
+
bumpHeatFetch()
|
|
3357
|
+
schedule(STATUS_POLL_SLOW_MS)
|
|
3294
3358
|
}
|
|
3295
3359
|
}
|
|
3360
|
+
let wasRunning = false
|
|
3296
3361
|
tick()
|
|
3297
3362
|
statusMachineRef.current = { restart: () => { baseDone = 0; schedule(STATUS_POLL_FAST_MS) } }
|
|
3298
3363
|
return () => {
|
package/src/routes.js
CHANGED
|
@@ -73,20 +73,25 @@ const conditionSchema = z.union(
|
|
|
73
73
|
CONDITION_KINDS.map((kind) => z.object({ kind: z.literal(kind), ...CONDITION_FIELD_SCHEMAS[kind] })),
|
|
74
74
|
)
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
cacheWrite: z.number().min(0),
|
|
87
|
-
}),
|
|
88
|
-
conditions: z.array(conditionSchema),
|
|
76
|
+
const pricingRuleShape = z.object({
|
|
77
|
+
// 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
|
|
78
|
+
model: z.string().refine(isTwoSegmentModel),
|
|
79
|
+
unit: z.literal(UNIT_PER_MILLION).optional(),
|
|
80
|
+
currency: z.enum(CURRENCIES).nullable(),
|
|
81
|
+
price: z.object({
|
|
82
|
+
input: z.number().min(0),
|
|
83
|
+
output: z.number().min(0),
|
|
84
|
+
cacheRead: z.number().min(0),
|
|
85
|
+
cacheWrite: z.number().min(0),
|
|
89
86
|
}),
|
|
87
|
+
conditions: z.array(conditionSchema),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const pricingRulesSchema = z.array(pricingRuleShape).refine(
|
|
91
|
+
// 表级单币约束(F15):定价聚合按数值直接相加,混币相加无意义;
|
|
92
|
+
// 非空币种全表必须同一种(null 视为未指定,不参与币种一致对账)
|
|
93
|
+
(rules) => new Set(rules.map((rule) => rule.currency).filter((c) => c !== null)).size <= 1,
|
|
94
|
+
{ message: '定价表必须单币种:非空 currency 全表一致' },
|
|
90
95
|
)
|
|
91
96
|
|
|
92
97
|
// 桶串形态锚定 + 本地时区分量回读,回滚形(13 月/25 时/60 分)当场拒绝,
|
package/src/store.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// single 布局每次持久化写都全量重发布 unit 文档,故写入侧做合并(write-behind):
|
|
3
3
|
// record 同步累加进内存 pending,按周期 flush 批量落盘,同桶多样本折叠为一次
|
|
4
4
|
// update;flush 失败的行留在 pending 下轮重试,错误经 onFlushError 上抛。
|
|
5
|
-
// 游标读写与 flush 全部串行在同一条 promise 链上,防 lost update
|
|
6
|
-
//
|
|
5
|
+
// 游标读写与 flush 全部串行在同一条 promise 链上,防 lost update。store 实例
|
|
6
|
+
// 按 facility 弱关联缓存,防 HMR 热重载换 facility 后旧实例泄漏或撞单开守卫;
|
|
7
|
+
// 同名域被上一代遗留占用时收养(close 孤儿后重开),不留永久降级。
|
|
7
8
|
|
|
8
9
|
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
9
10
|
import { z } from 'zod'
|
|
@@ -36,6 +37,7 @@ const TABLE_BUCKETS = 'buckets'
|
|
|
36
37
|
const MISSING_RECORD_PATTERN = /no record .* to update/
|
|
37
38
|
const MINUTE_KEY_PREFIX = `${GRANULARITY_MINUTE}|`
|
|
38
39
|
const HOUR_KEY_PREFIX = `${GRANULARITY_HOURLY}|`
|
|
40
|
+
const DAILY_KEY_PREFIX = `${GRANULARITY_DAILY}|`
|
|
39
41
|
|
|
40
42
|
// 保留值归一:非法回落默认,超出上限截到上限;0(禁用)合法保留
|
|
41
43
|
export function clampMinuteRetentionDays(days) {
|
|
@@ -69,6 +71,16 @@ export const GRANULARITIES = [
|
|
|
69
71
|
[GRANULARITY_MINUTE, minuteKey],
|
|
70
72
|
]
|
|
71
73
|
|
|
74
|
+
// 档保留资格(F14):样本桶 ≥ 该档 cutoff 即可写入。日档无上限恒写;
|
|
75
|
+
// 小时档固定留存;分钟档走可配置天数(0 = 分钟档关闭,一律不写)
|
|
76
|
+
function withinRetention(g, bucket, nowMs, minuteRetentionDays) {
|
|
77
|
+
if (g === GRANULARITY_DAILY) return true
|
|
78
|
+
if (g === GRANULARITY_HOURLY) return bucket >= hourKey(nowMs - HOUR_RETENTION_DAYS * DAY_MS)
|
|
79
|
+
const retention = clampMinuteRetentionDays(minuteRetentionDays)
|
|
80
|
+
if (retention === 0) return false
|
|
81
|
+
return bucket >= minuteKey(nowMs - retention * DAY_MS)
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
// vendor 段推导与定价匹配同源:首个 / 前段,无 / 或首位斜杠归 default
|
|
73
85
|
export function providerOf(modelRef) {
|
|
74
86
|
return splitRequestSegments(modelRef)[0]
|
|
@@ -217,6 +229,7 @@ export class UsageStore {
|
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
async initialize(facility) {
|
|
232
|
+
// 占用不等于无主:开域失败只降级,不关闭其他实例仍在写入的句柄。
|
|
220
233
|
const domain = await facility.open(usageStatsDomain)
|
|
221
234
|
this.domain = domain
|
|
222
235
|
this.table = domain.table(TABLE_BUCKETS)
|
|
@@ -257,7 +270,9 @@ export class UsageStore {
|
|
|
257
270
|
return this.readCursor() ?? {}
|
|
258
271
|
}
|
|
259
272
|
|
|
260
|
-
// 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush
|
|
273
|
+
// 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期。
|
|
274
|
+
// 入队按档保留资格过滤(F14):超出该档留存期的样本不写入——
|
|
275
|
+
// 写后即被 prune 删除是纯 IO 浪费;回扫历史导入经同一通道自动受控
|
|
261
276
|
async record(sample) {
|
|
262
277
|
await this.ready
|
|
263
278
|
this.requireTable()
|
|
@@ -266,6 +281,7 @@ export class UsageStore {
|
|
|
266
281
|
const provider = sample.turn ? PROVIDER_UNSET : providerOf(model)
|
|
267
282
|
const delta = deltaOf(sample, nowMs)
|
|
268
283
|
for (const [g, bucketOf] of GRANULARITIES) {
|
|
284
|
+
if (!withinRetention(g, bucketOf(sample.time), nowMs, this.retentionDays())) continue
|
|
269
285
|
const bucket = bucketOf(sample.time)
|
|
270
286
|
const entry = { ...delta, bucket, provider, model }
|
|
271
287
|
const existing = this.pending.get(rowKey(g, bucket, provider, model))
|
|
@@ -308,6 +324,9 @@ export class UsageStore {
|
|
|
308
324
|
if (outcome.status !== 'rejected') return []
|
|
309
325
|
const key = [...batch.keys()][index]
|
|
310
326
|
const [entryKey, entry] = [key, batch.get(key)]
|
|
327
|
+
// 重排前复核保留资格(F14):失败重试期间样本可能已滑出留存期,不再重排
|
|
328
|
+
const g = key.startsWith(DAILY_KEY_PREFIX) ? GRANULARITY_DAILY : key.startsWith(HOUR_KEY_PREFIX) ? GRANULARITY_HOURLY : GRANULARITY_MINUTE
|
|
329
|
+
if (!withinRetention(g, entry.bucket, this.now(), this.retentionDays())) return []
|
|
311
330
|
const existing = this.pending.get(entryKey)
|
|
312
331
|
this.pending.set(entryKey, existing ? addDelta(existing, entry) : entry)
|
|
313
332
|
return [outcome.reason]
|
|
@@ -480,13 +499,18 @@ export class UsageStore {
|
|
|
480
499
|
}
|
|
481
500
|
}
|
|
482
501
|
|
|
483
|
-
const SHARED_STORE_KEY = '
|
|
502
|
+
const SHARED_STORE_KEY = '__dshUsageDashStores'
|
|
484
503
|
|
|
504
|
+
// 单例按 facility 关联:插件热重载会换 facility 再 apply,全局单 key 缓存会把
|
|
505
|
+
// 绑定旧域的实例泄漏给新一代,或将新一代 open 撞 reserved 固化为永久降级;
|
|
506
|
+
// 未降级实例同 facility 复用,降级/异 facility 一律重建
|
|
507
|
+
// 可作弱键的 facility 才可缓存:服务缺席桩(非对象)退化为不缓存,每次新建
|
|
485
508
|
export function sharedStore(facility, options = {}) {
|
|
486
|
-
const
|
|
487
|
-
|
|
509
|
+
const cacheable = (typeof facility === 'object' && facility !== null) || typeof facility === 'function'
|
|
510
|
+
const existing = cacheable ? globalThis[SHARED_STORE_KEY]?.get?.(facility) : undefined
|
|
511
|
+
if (existing && existing.degradation === undefined) return existing
|
|
488
512
|
const store = new UsageStore(facility, options)
|
|
489
|
-
globalThis[SHARED_STORE_KEY]
|
|
513
|
+
if (cacheable) (globalThis[SHARED_STORE_KEY] ??= new WeakMap()).set(facility, store)
|
|
490
514
|
return store
|
|
491
515
|
}
|
|
492
516
|
|