@mzzsfy/dsh-usage-dash 0.10.3 → 0.10.4
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 +7 -7
- package/package.json +1 -1
- package/src/client.js +112 -67
- package/src/store.js +13 -6
- package/test/pricing-current.test.mjs +179 -0
- package/test/stats-line-render.test.mjs +1 -1
- package/test/store-owner.test.mjs +45 -0
- package/test/store.test.mjs +109 -4
- package/test/trend-tooltip.test.mjs +182 -0
package/README.md
CHANGED
|
@@ -9,23 +9,23 @@
|
|
|
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
30
|
规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。
|
|
31
31
|
|
|
@@ -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.4",
|
|
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,14 +2699,7 @@ 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
2704
|
const price = matched && prefs.costDisplay && pricingRules !== null
|
|
2662
2705
|
? matchPrice(pricingRules, turnModelOf(matched), new Date())
|
|
@@ -2900,6 +2943,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2900
2943
|
const [saving, setSaving] = useState(false)
|
|
2901
2944
|
const [saved, setSaved] = useState(false)
|
|
2902
2945
|
const savedTimerRef = useRef(null)
|
|
2946
|
+
const aliveRef = useRef(false)
|
|
2947
|
+
useEffect(() => {
|
|
2948
|
+
aliveRef.current = true
|
|
2949
|
+
return () => { aliveRef.current = false }
|
|
2950
|
+
}, [])
|
|
2903
2951
|
|
|
2904
2952
|
useEffect(() => {
|
|
2905
2953
|
let alive = true
|
|
@@ -2930,13 +2978,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2930
2978
|
if (found.size > 0) return
|
|
2931
2979
|
setSaving(true)
|
|
2932
2980
|
const result = await requestPost(ENDPOINTS.pricing, { rules: coercePricingRules(rules) })
|
|
2981
|
+
// 保存属于共享数据提交,编辑器退出后仍广播;局部状态只由存活实例更新。
|
|
2982
|
+
if (result.ok && validPricing(result.value)) applyPricingValue(result.value, true)
|
|
2983
|
+
if (!aliveRef.current) return
|
|
2933
2984
|
setSaving(false)
|
|
2934
|
-
if (!result.ok) {
|
|
2935
|
-
setSaveError(result.message)
|
|
2985
|
+
if (!result.ok || !validPricing(result.value)) {
|
|
2986
|
+
setSaveError(result.message || '价格响应无效')
|
|
2936
2987
|
return
|
|
2937
2988
|
}
|
|
2938
|
-
//
|
|
2939
|
-
applyPricingValue(result.value)
|
|
2989
|
+
// 编辑副本同步为本次保存返回的规则,其他编辑器草稿保持独立。
|
|
2940
2990
|
setSaveError('')
|
|
2941
2991
|
setErrors(new Map())
|
|
2942
2992
|
setRules(copyRules(result.value.rules))
|
|
@@ -3152,19 +3202,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3152
3202
|
const panelRef = useRef(null)
|
|
3153
3203
|
const [heatDays, setHeatDays] = useState(null)
|
|
3154
3204
|
// 费用展示货币来源:价格规则异步首帧未回时空串即不带符号
|
|
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
|
-
}, [])
|
|
3205
|
+
const { rules: pricingRules, tick: pricingTick } = usePricing()
|
|
3164
3206
|
|
|
3165
3207
|
const costCurrency = aggregateCurrencyOf(pricingRules)
|
|
3166
3208
|
|
|
3167
|
-
//
|
|
3209
|
+
// 热力图独立请求:范围切换不重取,价格变更重算费用,过期响应丢弃
|
|
3168
3210
|
useEffect(() => {
|
|
3169
3211
|
const generation = ++heatGenerationRef.current
|
|
3170
3212
|
const request = { from: localDay(-(HEAT_WINDOW_DAYS - 1)), to: localDay(0) }
|
|
@@ -3183,7 +3225,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3183
3225
|
}
|
|
3184
3226
|
}))
|
|
3185
3227
|
})
|
|
3186
|
-
|
|
3228
|
+
return () => { heatGenerationRef.current++ }
|
|
3229
|
+
}, [pricingTick])
|
|
3187
3230
|
|
|
3188
3231
|
const load = useCallback(async () => {
|
|
3189
3232
|
const request = range === 'custom'
|
|
@@ -3201,10 +3244,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3201
3244
|
}
|
|
3202
3245
|
setError('')
|
|
3203
3246
|
setStats(result.value)
|
|
3204
|
-
}, [range, customFrom, customTo])
|
|
3247
|
+
}, [range, customFrom, customTo, pricingTick])
|
|
3205
3248
|
|
|
3206
3249
|
useEffect(() => {
|
|
3207
3250
|
load()
|
|
3251
|
+
return () => { generationRef.current++ }
|
|
3208
3252
|
}, [load])
|
|
3209
3253
|
|
|
3210
3254
|
const rawPreset = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
|
|
@@ -3213,7 +3257,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3213
3257
|
|
|
3214
3258
|
useEffect(() => {
|
|
3215
3259
|
if (!activeQuery) return
|
|
3216
|
-
if (pointStatsMatches(pointStats, view, activeQuery.key)) return
|
|
3260
|
+
if (pointStatsMatches(pointStats, view, activeQuery.key) && pointStats.pricingTick === pricingTick) return
|
|
3217
3261
|
const generation = ++pointGenerationRef.current
|
|
3218
3262
|
setPointStatus('loading')
|
|
3219
3263
|
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
|
|
@@ -3225,9 +3269,10 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3225
3269
|
}
|
|
3226
3270
|
setError('')
|
|
3227
3271
|
setPointStatus('ok')
|
|
3228
|
-
setPointStats({ view, preset: activeQuery.key, value: result.value })
|
|
3272
|
+
setPointStats({ view, preset: activeQuery.key, pricingTick, value: result.value })
|
|
3229
3273
|
})
|
|
3230
|
-
|
|
3274
|
+
return () => { pointGenerationRef.current++ }
|
|
3275
|
+
}, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick, pricingTick])
|
|
3231
3276
|
|
|
3232
3277
|
const refreshPoints = useCallback(() => {
|
|
3233
3278
|
setPointStats(null)
|
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'
|
|
@@ -217,6 +218,7 @@ export class UsageStore {
|
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
async initialize(facility) {
|
|
221
|
+
// 占用不等于无主:开域失败只降级,不关闭其他实例仍在写入的句柄。
|
|
220
222
|
const domain = await facility.open(usageStatsDomain)
|
|
221
223
|
this.domain = domain
|
|
222
224
|
this.table = domain.table(TABLE_BUCKETS)
|
|
@@ -480,13 +482,18 @@ export class UsageStore {
|
|
|
480
482
|
}
|
|
481
483
|
}
|
|
482
484
|
|
|
483
|
-
const SHARED_STORE_KEY = '
|
|
485
|
+
const SHARED_STORE_KEY = '__dshUsageDashStores'
|
|
484
486
|
|
|
487
|
+
// 单例按 facility 关联:插件热重载会换 facility 再 apply,全局单 key 缓存会把
|
|
488
|
+
// 绑定旧域的实例泄漏给新一代,或将新一代 open 撞 reserved 固化为永久降级;
|
|
489
|
+
// 未降级实例同 facility 复用,降级/异 facility 一律重建
|
|
490
|
+
// 可作弱键的 facility 才可缓存:服务缺席桩(非对象)退化为不缓存,每次新建
|
|
485
491
|
export function sharedStore(facility, options = {}) {
|
|
486
|
-
const
|
|
487
|
-
|
|
492
|
+
const cacheable = (typeof facility === 'object' && facility !== null) || typeof facility === 'function'
|
|
493
|
+
const existing = cacheable ? globalThis[SHARED_STORE_KEY]?.get?.(facility) : undefined
|
|
494
|
+
if (existing && existing.degradation === undefined) return existing
|
|
488
495
|
const store = new UsageStore(facility, options)
|
|
489
|
-
globalThis[SHARED_STORE_KEY]
|
|
496
|
+
if (cacheable) (globalThis[SHARED_STORE_KEY] ??= new WeakMap()).set(facility, store)
|
|
490
497
|
return store
|
|
491
498
|
}
|
|
492
499
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_SOURCE } from './client-eval.mjs'
|
|
4
|
+
|
|
5
|
+
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
6
|
+
const nodes = node => !node || typeof node !== 'object' ? [] : Array.isArray(node) ? node.flatMap(nodes) : [node, ...(node.children ?? []).flatMap(nodes)]
|
|
7
|
+
const text = node => typeof node === 'string' ? node : Array.isArray(node) ? node.map(text).join('') : (node?.children ?? []).map(text).join('')
|
|
8
|
+
const rule = (amount = 1, currency = '¥') => ({ model: '*/*', unit: 'perMillion', currency, price: { input: amount, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] })
|
|
9
|
+
const price = (revision, amount = revision) => ({ revision, rules: [rule(amount)] })
|
|
10
|
+
const usage = { uncachedInputTokens: 1000 * 1000, inputTokens: 1000 * 1000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, routes: [{ model: 'p/m' }] }
|
|
11
|
+
|
|
12
|
+
// 执行真实loader和槽组件,Hook仅模拟状态/依赖/清理,网络为可控边界。
|
|
13
|
+
function fixture() {
|
|
14
|
+
let current, factory, now = Date.now()
|
|
15
|
+
const slots = new Map(), mounted = [], requests = [], gets = [], saves = [], timers = new Map()
|
|
16
|
+
let lateState = 0
|
|
17
|
+
const depsEqual = (a, b) => a && b && a.length === b.length && a.every((value, i) => Object.is(value, b[i]))
|
|
18
|
+
const hooks = {
|
|
19
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
|
20
|
+
memo: fn => fn, Fragment: 'fragment', useId: () => 'fixture',
|
|
21
|
+
useState(seed) {
|
|
22
|
+
const owner = current, i = owner.cursor++
|
|
23
|
+
if (!(i in owner.states)) owner.states[i] = typeof seed === 'function' ? seed() : seed
|
|
24
|
+
return [owner.states[i], value => {
|
|
25
|
+
if (!owner.alive) { lateState++; return }
|
|
26
|
+
owner.states[i] = typeof value === 'function' ? value(owner.states[i]) : value
|
|
27
|
+
owner.dirty = true
|
|
28
|
+
}]
|
|
29
|
+
},
|
|
30
|
+
useRef(seed) { const i = current.cursor++; return current.refs[i] ??= { current: seed } },
|
|
31
|
+
useMemo(fn, deps) {
|
|
32
|
+
const i = current.cursor++, old = current.memos[i]
|
|
33
|
+
if (!old || !depsEqual(old.deps, deps)) current.memos[i] = { value: fn(), deps }
|
|
34
|
+
return current.memos[i].value
|
|
35
|
+
},
|
|
36
|
+
useEffect(fn, deps) {
|
|
37
|
+
const owner = current, i = owner.cursor++, old = owner.effects[i]
|
|
38
|
+
if (!old || !depsEqual(old.deps, deps)) owner.pending.push(() => {
|
|
39
|
+
old?.cleanup?.(); owner.effects[i] = { deps, cleanup: fn() }
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
hooks.useCallback = (fn, deps) => hooks.useMemo(() => fn, deps)
|
|
44
|
+
hooks.useLayoutEffect = hooks.useEffect
|
|
45
|
+
const window = { __ModuleLoader__: { load: entry => { factory = entry.factory } }, addEventListener() {}, removeEventListener() {} }
|
|
46
|
+
const document = { querySelector: () => ({}), querySelectorAll: () => [], documentElement: {}, body: {} }
|
|
47
|
+
const schedule = (fn, ms) => { const id = {}; timers.set(id, { fn, ms }); return id }
|
|
48
|
+
const fetch = async (path, options = {}) => {
|
|
49
|
+
requests.push({ path, ...options })
|
|
50
|
+
const deferred = Promise.withResolvers()
|
|
51
|
+
if (path.endsWith('/pricing')) {
|
|
52
|
+
const list = options.method === 'POST' ? saves : gets
|
|
53
|
+
list.push({ ...deferred, body: options.body && JSON.parse(options.body) })
|
|
54
|
+
return deferred.promise
|
|
55
|
+
}
|
|
56
|
+
const value = path.endsWith('/status') ? { running: false } : { models: [], daily: [], total: 0, requests: 0, from: '2026-10-01', to: '2026-10-28' }
|
|
57
|
+
return { json: async () => ({ ok: true, value }) }
|
|
58
|
+
}
|
|
59
|
+
const Clock = class extends Date { static now() { return now } }
|
|
60
|
+
new Function('window', 'document', 'fetch', 'localStorage', 'MutationObserver', 'setTimeout', 'clearTimeout', 'Date', CLIENT_SOURCE)(window, document, fetch, null, class { observe() {} disconnect() {} takeRecords() { return [] } }, schedule, id => timers.delete(id), Clock)
|
|
61
|
+
const mod = factory(name => name === 'react' ? hooks : { createPortal: null })
|
|
62
|
+
mod.apply({ effect: fn => fn(), locale: { register() {} }, slots: { inject: (_name, fn) => fn(), register: (entry, component) => slots.set(entry.name, component) } })
|
|
63
|
+
const render = owner => {
|
|
64
|
+
current = owner; owner.cursor = 0; owner.dirty = false
|
|
65
|
+
owner.tree = owner.component(owner.props); current = null
|
|
66
|
+
owner.pending.splice(0).forEach(fn => fn())
|
|
67
|
+
return owner.tree
|
|
68
|
+
}
|
|
69
|
+
const mount = (component, props = {}) => {
|
|
70
|
+
const owner = { component, props, states: [], refs: [], memos: [], effects: [], pending: [], cursor: 0, alive: true, dirty: true }
|
|
71
|
+
mounted.push(owner); render(owner); return owner
|
|
72
|
+
}
|
|
73
|
+
const unmount = owner => { owner.alive = false; owner.effects.forEach(effect => effect?.cleanup?.()) }
|
|
74
|
+
const settle = async () => {
|
|
75
|
+
for (let i = 0; i < 12; i++) { await flush(); mounted.filter(owner => owner.alive && owner.dirty).forEach(render) }
|
|
76
|
+
}
|
|
77
|
+
const panel = () => mount(slots.get('settings.section'))
|
|
78
|
+
const editor = () => { const p = panel(); return mount(nodes(p.tree).find(n => n.type?.name === 'PricingEditor').type) }
|
|
79
|
+
const stats = () => mount(slots.get('conversation.composer.dock'), { useProjection: () => usage })
|
|
80
|
+
const chip = () => mount(slots.get('conversation.chat.assistant-actions'), { messageId: 'm1', useChat: select => select({ nodes: [{ kind: 'turn-tail', data: { closing: { finalNode: { messageId: 'm1' } }, tokenUsage: usage } }] }) })
|
|
81
|
+
const resolve = (item, value) => item.resolve({ json: async () => ({ ok: true, value }) })
|
|
82
|
+
const save = owner => {
|
|
83
|
+
const button = nodes(owner.tree).find(n => n.type === 'button' && text(n) === '保存')
|
|
84
|
+
assert.ok(button, '已载入编辑器保存按钮'); return button.props.onClick()
|
|
85
|
+
}
|
|
86
|
+
return { panel, editor, stats, chip, settle, unmount, requests, gets, saves, resolve, save, mount, nodes, text, timers,
|
|
87
|
+
get lateState() { return lateState }, advance: ms => { now += ms }, cleanup: () => mounted.filter(o => o.alive).forEach(unmount) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const withFixture = async (t, fn) => { const f = fixture(); t.after(f.cleanup); await fn(f) }
|
|
91
|
+
|
|
92
|
+
test('P1 Given concurrent consumers, When cold mounted, Then only one pricing GET is sent', t => withFixture(t, async f => {
|
|
93
|
+
f.stats(); f.stats(); f.panel(); f.editor()
|
|
94
|
+
assert.equal(f.gets.length, 1)
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
test('P2/P4 Given mounted consumers, When editor saves, Then cost changes and panel ranges refresh', t => withFixture(t, async f => {
|
|
98
|
+
const stats = f.stats(), chip = f.chip(), editor = f.editor()
|
|
99
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
100
|
+
assert.match(text(stats.tree), /¥1\.00/)
|
|
101
|
+
assert.match(text(chip.tree), /¥1\.00/)
|
|
102
|
+
const before = f.requests.filter(r => r.path.endsWith('/range')).length
|
|
103
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
104
|
+
assert.match(text(stats.tree), /¥2\.00/)
|
|
105
|
+
assert.match(text(chip.tree), /¥2\.00/)
|
|
106
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, before + 2)
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
test('P3/P8 Given initial load or same revision TTL refresh, When panel mounts, Then range is not duplicated', t => withFixture(t, async f => {
|
|
110
|
+
f.panel(); f.resolve(f.gets[0], price(1)); await f.settle()
|
|
111
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, 2)
|
|
112
|
+
f.advance(5 * 60 * 1000 + 1); f.panel()
|
|
113
|
+
f.resolve(f.gets.at(-1), price(1)); await f.settle()
|
|
114
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, 4)
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
test('P6/P9 Given new save while old GET is pending, When old reply arrives, Then price never goes backward', t => withFixture(t, async f => {
|
|
118
|
+
const editor = f.editor()
|
|
119
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
120
|
+
f.advance(5 * 60 * 1000 + 1)
|
|
121
|
+
const stats = f.stats(), old = f.gets.at(-1)
|
|
122
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(3)); await pending; await f.settle()
|
|
123
|
+
f.resolve(old, price(2)); await f.settle()
|
|
124
|
+
assert.match(text(stats.tree), /¥3\.00/)
|
|
125
|
+
const remount = f.stats(); await f.settle(); assert.match(text(remount.tree), /¥3\.00/)
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
test('P7 Given TTL refresh GET pending, When another editor saves first, Then panel refreshes for mutation', t => withFixture(t, async f => {
|
|
129
|
+
const editor = f.editor(); f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
130
|
+
f.advance(5 * 60 * 1000 + 1); f.panel()
|
|
131
|
+
const before = f.requests.filter(r => r.path.endsWith('/range')).length
|
|
132
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
133
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, before + 4)
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
test('P10 Given editor unmount during save, When reply arrives, Then publish without stale state or timer', t => withFixture(t, async f => {
|
|
137
|
+
const stats = f.stats(), editor = f.editor()
|
|
138
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
139
|
+
const before = f.timers.size, pending = f.save(editor); f.unmount(editor)
|
|
140
|
+
f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
141
|
+
assert.equal(f.lateState, 0)
|
|
142
|
+
assert.equal(f.timers.size, before)
|
|
143
|
+
assert.match(text(stats.tree), /¥2\.00/)
|
|
144
|
+
}))
|
|
145
|
+
|
|
146
|
+
test('P11 Given another editor has a draft, When save publishes, Then draft stays unchanged', t => withFixture(t, async f => {
|
|
147
|
+
const a = f.editor(), b = f.editor()
|
|
148
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
149
|
+
const group = () => nodes(b.tree).find(n => n.type?.name === 'PricingModelGroup')
|
|
150
|
+
group().props.onModelChange('draft/model'); await f.settle()
|
|
151
|
+
const pending = f.save(a); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
152
|
+
assert.equal(group().props.group.model, 'draft/model')
|
|
153
|
+
}))
|
|
154
|
+
|
|
155
|
+
for (const [tab, endpoint] of [['小时', 'hours'], ['分钟', 'minutes']]) {
|
|
156
|
+
test(`P5 Given ${endpoint} view, When save publishes, Then active points refetch once`, t => withFixture(t, async f => {
|
|
157
|
+
const panel = f.panel(), editor = f.editor()
|
|
158
|
+
f.resolve(f.gets[0], price(1)); await f.settle()
|
|
159
|
+
const button = nodes(panel.tree).find(n => n.type === 'button' && text(n) === tab)
|
|
160
|
+
assert.ok(button, tab + '视图按钮'); button.props.onClick(); await f.settle()
|
|
161
|
+
const count = () => f.requests.filter(r => r.path.endsWith('/' + endpoint)).length
|
|
162
|
+
assert.equal(count(), 1)
|
|
163
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
164
|
+
assert.equal(count(), 2)
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('P10-GET Given consumers unmount with GET pending, When response arrives, Then no stale state is written', t => withFixture(t, async f => {
|
|
169
|
+
const stats = f.stats(), editor = f.editor()
|
|
170
|
+
f.unmount(stats); f.unmount(editor)
|
|
171
|
+
f.resolve(f.gets[0], price(2)); await f.settle()
|
|
172
|
+
assert.equal(f.lateState, 0)
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
test('P12 Given failed pricing request, When next consumer mounts, Then failed flight is released and retry works', t => withFixture(t, async f => {
|
|
176
|
+
f.stats(); f.gets[0].reject(new Error('injected network')); await f.settle()
|
|
177
|
+
const stats = f.stats(); assert.equal(f.gets.length, 2)
|
|
178
|
+
f.resolve(f.gets[1], price(2)); await f.settle(); assert.match(text(stats.tree), /¥2\.00/)
|
|
179
|
+
}))
|
|
@@ -17,7 +17,7 @@ function render(options = {}) {
|
|
|
17
17
|
const useState = (init) => [stateIndex++ === 1 ? pricing : typeof init === 'function' ? init() : init, () => {}]
|
|
18
18
|
const noop = () => {}
|
|
19
19
|
const names = Object.keys(core)
|
|
20
|
-
const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', '
|
|
20
|
+
const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', 'usePricing', 'useUsageDialogTarget', 'createPortal', `return (${source})`)(...Object.values(core), React, h, useState, noop, noop, () => ({ current: null }), { get: () => prefs }, core.createTranslator(core.MESSAGES_ZH), () => ({ rules: pricing }), () => target, (content, container) => ({ type: 'portal', props: { container }, children: [content].flat(Infinity) }))
|
|
21
21
|
return component({ useProjection: () => value, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH) })
|
|
22
22
|
}
|
|
23
23
|
const nodes = (tree) => tree && typeof tree === 'object' ? [tree, ...tree.children.flatMap(nodes)] : []
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
|
4
|
+
import { UsageStore, sharedStore, __resetSharedStoreForTests } from '../src/store.js'
|
|
5
|
+
|
|
6
|
+
// 真实宿主域,只替换磁盘后端,不读取生产档案或调用回扫。
|
|
7
|
+
test('UO1 Given real DomainFacility with live pending owner, When a newcomer collides, Then old tokens remain writable exactly once', async () => {
|
|
8
|
+
const media = new Map(), closes = [], errors = []
|
|
9
|
+
const backend = { kv: { async open(spec) {
|
|
10
|
+
const data = media.get(spec.name) ?? { tables: { buckets: {} }, global: null }
|
|
11
|
+
media.set(spec.name, data)
|
|
12
|
+
return {
|
|
13
|
+
async loadAll() { return structuredClone(data) },
|
|
14
|
+
async setGlobal(value) { data.global = structuredClone(value) },
|
|
15
|
+
async putRecord(table, key, value) { data.tables[table][key] = structuredClone(value) },
|
|
16
|
+
async deleteRecord(table, key) { delete data.tables[table][key] },
|
|
17
|
+
async close() { closes.push(spec.name) },
|
|
18
|
+
}
|
|
19
|
+
} } }
|
|
20
|
+
const facility = new DomainFacility({ storage: { backend: { get: () => backend } }, emit() {}, logger: { warn() {} } }, { backend: 'memory' })
|
|
21
|
+
let old, next
|
|
22
|
+
try {
|
|
23
|
+
__resetSharedStoreForTests()
|
|
24
|
+
old = new UsageStore(facility, { onFlushError: error => errors.push(error.message) })
|
|
25
|
+
await old.readyPromise()
|
|
26
|
+
await old.markSeenSessions(['s1'])
|
|
27
|
+
await old.record({ time: Date.now(), model: 'probe/model', inputTokens: 17, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
28
|
+
next = sharedStore(facility)
|
|
29
|
+
await next.readyPromise()
|
|
30
|
+
assert.ok(next.degradation)
|
|
31
|
+
assert.deepEqual(closes, [])
|
|
32
|
+
assert.deepEqual(await old.seenSessions(), new Set(['s1']))
|
|
33
|
+
await old.flushNow()
|
|
34
|
+
await old.flushNow()
|
|
35
|
+
assert.deepEqual(errors, [])
|
|
36
|
+
const sums = Object.entries(media.get('usage_stats').tables.buckets).filter(([key]) => key.startsWith('D|')).reduce((sum, [, row]) => sum + row.inputTokens, 0)
|
|
37
|
+
assert.equal(sums, 17)
|
|
38
|
+
assert.equal(old.pending.size, 0)
|
|
39
|
+
await assert.rejects(next.record({ time: Date.now(), model: 'probe/model', inputTokens: 17 }), /usage store degraded/)
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(old?.flushTimer); clearTimeout(next?.flushTimer)
|
|
42
|
+
await facility.closeAll()
|
|
43
|
+
__resetSharedStoreForTests()
|
|
44
|
+
}
|
|
45
|
+
})
|
package/test/store.test.mjs
CHANGED
|
@@ -563,13 +563,118 @@ test('域打开失败进入降级:ready 可等待,操作按调用失败', async
|
|
|
563
563
|
assert.deepEqual(await store.liveSequences(), new Map())
|
|
564
564
|
})
|
|
565
565
|
|
|
566
|
-
test('globalThis
|
|
566
|
+
test('globalThis 单例按 facility 关联:同 facility 同实例,异 facility 新实例', async () => {
|
|
567
567
|
__resetSharedStoreForTests()
|
|
568
|
-
const
|
|
569
|
-
|
|
568
|
+
const facilityA = facilityOf(fakeDomain())
|
|
569
|
+
const a = await sharedStore(facilityA)
|
|
570
|
+
assert.equal(await sharedStore(facilityA), a)
|
|
571
|
+
assert.notEqual(await sharedStore(facilityOf(fakeDomain())), a)
|
|
570
572
|
__resetSharedStoreForTests()
|
|
571
|
-
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test('降级实例不复用:同 facility 再取重建新实例', async () => {
|
|
576
|
+
__resetSharedStoreForTests()
|
|
577
|
+
const facility = { open: () => Promise.reject(new Error("domain 'usage_stats' is already open")) }
|
|
578
|
+
const degraded = await sharedStore(facility)
|
|
579
|
+
await degraded.readyPromise()
|
|
580
|
+
assert.ok(degraded.degradation)
|
|
581
|
+
const fresh = sharedStore(facility)
|
|
582
|
+
assert.notEqual(fresh, degraded)
|
|
583
|
+
__resetSharedStoreForTests()
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
test('非对象 facility(服务缺席桩)不缓存:每次新建', () => {
|
|
587
|
+
__resetSharedStoreForTests()
|
|
588
|
+
assert.notEqual(sharedStore(undefined), sharedStore(undefined))
|
|
589
|
+
__resetSharedStoreForTests()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
// 同名域占用不证明无主;get 诊断面不可授权关闭活跃 owner。
|
|
593
|
+
function facilityWithReserved({ withCode = true } = {}) {
|
|
594
|
+
const reserved = new Map()
|
|
595
|
+
const closes = []
|
|
596
|
+
const open = async (spec) => {
|
|
597
|
+
if (reserved.has(spec.name)) {
|
|
598
|
+
const err = new Error(`domain '${spec.name}' is already open`)
|
|
599
|
+
if (withCode) err.code = 'already-open'
|
|
600
|
+
throw err
|
|
601
|
+
}
|
|
602
|
+
const domain = fakeDomain()
|
|
603
|
+
domain.close = async () => {
|
|
604
|
+
closes.push(spec.name)
|
|
605
|
+
reserved.delete(spec.name)
|
|
606
|
+
}
|
|
607
|
+
reserved.set(spec.name, domain)
|
|
608
|
+
return domain
|
|
609
|
+
}
|
|
610
|
+
return { open, get: (name) => reserved.get(name), closes }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const withCode of [true, false]) {
|
|
614
|
+
test(`Given occupied domain with code=${withCode}, When another store opens, Then owner stays live and newcomer degrades`, async () => {
|
|
615
|
+
__resetSharedStoreForTests()
|
|
616
|
+
const facility = facilityWithReserved({ withCode })
|
|
617
|
+
const owner = await facility.open({ name: 'usage_stats' })
|
|
618
|
+
await facility.open({ name: 'usage_stats_backup' })
|
|
619
|
+
const store = sharedStore(facility)
|
|
620
|
+
await store.readyPromise()
|
|
621
|
+
assert.ok(store.degradation)
|
|
622
|
+
assert.equal(facility.get('usage_stats'), owner)
|
|
623
|
+
assert.deepEqual(facility.closes, [])
|
|
624
|
+
await owner.global.set({ stillLive: true })
|
|
625
|
+
assert.deepEqual(owner.global.get(), { stillLive: true })
|
|
626
|
+
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
627
|
+
__resetSharedStoreForTests()
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
test('UO1 Given old owner with seen cursor and pending tokens, When new generation opens, Then old owner flushes every bucket once', async () => {
|
|
572
632
|
__resetSharedStoreForTests()
|
|
633
|
+
const facility = facilityWithReserved()
|
|
634
|
+
const old = new UsageStore(facility)
|
|
635
|
+
await old.readyPromise()
|
|
636
|
+
await old.markSeenSessions(['s1'])
|
|
637
|
+
await old.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
638
|
+
clearTimeout(old.flushTimer)
|
|
639
|
+
const newcomer = sharedStore(facility)
|
|
640
|
+
await newcomer.readyPromise()
|
|
641
|
+
assert.deepEqual(facility.closes, [])
|
|
642
|
+
assert.ok(newcomer.degradation)
|
|
643
|
+
await old.flushNow()
|
|
644
|
+
const rows = await old.rangeRows('D', '2026-08-02', '2026-08-02')
|
|
645
|
+
assert.equal(rows.length, 1)
|
|
646
|
+
assert.equal(rows[0].inputTokens, 10)
|
|
647
|
+
assert.equal(rows[0].outputTokens, 20)
|
|
648
|
+
assert.deepEqual(await old.seenSessions(), new Set(['s1']))
|
|
649
|
+
await old.flushNow()
|
|
650
|
+
assert.equal((await old.rangeRows('D', '2026-08-02', '2026-08-02'))[0].inputTokens, 10)
|
|
651
|
+
__resetSharedStoreForTests()
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test('UO3 Given occupied backup only, When main opens, Then collection works and backup owner is untouched', async () => {
|
|
655
|
+
__resetSharedStoreForTests()
|
|
656
|
+
const facility = facilityWithReserved()
|
|
657
|
+
const backup = await facility.open({ name: 'usage_stats_backup' })
|
|
658
|
+
const store = sharedStore(facility)
|
|
659
|
+
await store.readyPromise()
|
|
660
|
+
assert.equal(store.degradation, undefined)
|
|
661
|
+
assert.equal(facility.get('usage_stats_backup'), backup)
|
|
662
|
+
assert.deepEqual(facility.closes, [])
|
|
663
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
664
|
+
clearTimeout(store.flushTimer)
|
|
665
|
+
await store.flushNow()
|
|
666
|
+
assert.equal((await store.rangeRows('D', '2026-08-02', '2026-08-02'))[0].inputTokens, 10)
|
|
667
|
+
__resetSharedStoreForTests()
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('get 缝在场但孤儿已不存在:维持降级,不逃逸', async () => {
|
|
671
|
+
const store = new UsageStore({
|
|
672
|
+
open: () => Promise.reject(new Error("domain 'usage_stats' is already open")),
|
|
673
|
+
get: () => undefined,
|
|
674
|
+
})
|
|
675
|
+
await store.readyPromise()
|
|
676
|
+
assert.ok(store.degradation)
|
|
677
|
+
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
573
678
|
})
|
|
574
679
|
|
|
575
680
|
// —— 写合并(write-behind):record 同步入 pending,flush 周期落盘 ——
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_SOURCE, CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
|
|
4
|
+
|
|
5
|
+
const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
6
|
+
const nodes = node => !node || typeof node !== 'object' ? [] : [node, ...(node.children ?? []).flatMap(nodes)]
|
|
7
|
+
const text = node => typeof node === 'string' ? node : (node?.children ?? []).map(text).join('')
|
|
8
|
+
const OTHER = core.OTHER_MODEL
|
|
9
|
+
const slot = {
|
|
10
|
+
day: '2026-10-28', total: 16000, byModel: { 'p/a': 1000, 'p/b': 9000, [OTHER]: 6000 },
|
|
11
|
+
otherByModel: { 'p/c': 2000, 'p/d': 4000 }, cost: 12,
|
|
12
|
+
byCostModel: { 'p/a': 2, 'p/b': 9, [OTHER]: 1 }, otherCostByModel: { 'p/c': 0.75, 'p/d': 0.25 },
|
|
13
|
+
cacheHit: 3000, cacheMiss: 1000, speed: 12, ttft: 300,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fixture({ money = false, costDisplay = true, currency = '¥', lang = 'zh', data = [slot] } = {}) {
|
|
17
|
+
let factory, cursor = 0
|
|
18
|
+
const states = [], refs = []
|
|
19
|
+
const React = {
|
|
20
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity).filter(x => x != null && x !== false) }),
|
|
21
|
+
memo: fn => fn, useId: () => 'trend-test',
|
|
22
|
+
useState(seed) {
|
|
23
|
+
const i = cursor++
|
|
24
|
+
if (!(i in states)) states[i] = typeof seed === 'function' ? seed() : seed
|
|
25
|
+
return [states[i], value => { states[i] = typeof value === 'function' ? value(states[i]) : value }]
|
|
26
|
+
},
|
|
27
|
+
useRef(seed) { const i = cursor++; return refs[i] ??= { current: seed } },
|
|
28
|
+
useEffect() {}, useLayoutEffect() {}, useMemo: fn => fn(), useCallback: fn => fn,
|
|
29
|
+
}
|
|
30
|
+
const window = { __ModuleLoader__: { load: entry => { factory = entry.factory } } }
|
|
31
|
+
const source = CLIENT_SOURCE.replace("inject: ['slots', 'locale'],", "__test: { TrendChart }, inject: ['slots', 'locale'],")
|
|
32
|
+
new Function('window', 'localStorage', source)(window, { getItem: () => JSON.stringify({ costDisplay }), setItem() {} })
|
|
33
|
+
const { TrendChart } = factory(name => name === 'react' ? React : {}).__test
|
|
34
|
+
let tree
|
|
35
|
+
const props = {
|
|
36
|
+
title: 'trend', notes: [], slots: data, tipSlots: data, modelOrder: ['p/a', 'p/b', OTHER],
|
|
37
|
+
colorFor: () => 'currentColor', labelFor: key => key, labelMinPitch: 60,
|
|
38
|
+
legendModels: [], money, costCurrency: currency, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH),
|
|
39
|
+
}
|
|
40
|
+
const render = () => {
|
|
41
|
+
cursor = 0
|
|
42
|
+
props.slots = props.money ? core.costSlotsOf(props.tipSlots) : props.tipSlots
|
|
43
|
+
tree = TrendChart(props)
|
|
44
|
+
return tree
|
|
45
|
+
}
|
|
46
|
+
const tip = () => nodes(tree).find(n => n.type?.name === 'ChartTip')
|
|
47
|
+
const rows = () => nodes(tip()).filter(n => n.props.className?.includes('ud-tip-row')).map(text)
|
|
48
|
+
const hover = (index = 0) => {
|
|
49
|
+
nodes(tree).filter(n => n.props.className === 'ud-bar-hit')[index].props.onMouseEnter({ clientX: 20, clientY: 20 })
|
|
50
|
+
render()
|
|
51
|
+
}
|
|
52
|
+
const select = (key, ctrl = false) => {
|
|
53
|
+
nodes(tree).find(n => n.type?.name === 'Legend').props.onItem(key, ctrl)
|
|
54
|
+
render()
|
|
55
|
+
}
|
|
56
|
+
render()
|
|
57
|
+
return { hover, select, rows, tip: () => text(tip()), update: changes => { Object.assign(props, changes); render() } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('Given 金额视图 When 悬浮 Then 总计和各模型展示估算费用,其他明细按费用排序', () => {
|
|
61
|
+
const f = fixture({ money: true }); f.hover()
|
|
62
|
+
assert.deepEqual(f.rows().slice(0, 5), ['估算费用: ≈ ¥12.00', 'p/a: ≈ ¥2.00', 'p/b: ≈ ¥9.00', '其他: ≈ ¥1.00', 'p/c: ≈ ¥0.75'])
|
|
63
|
+
assert.ok(f.rows().includes('p/d: ≈ ¥0.25'))
|
|
64
|
+
assert.doesNotMatch(f.tip(), /16,000|9,000|6,000/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Given Token 视图 When 单选模型 Then 总计、费用、均价仅包含可见模型', () => {
|
|
68
|
+
const f = fixture(); f.hover(); f.select('p/a')
|
|
69
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok', '≈ ¥2.00', '均价: ¥2,000.000/M'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('Given 金额视图 When Ctrl 多选包含其他 Then 合计不重复计其他子项', () => {
|
|
73
|
+
const f = fixture({ money: true }); f.hover(); f.select('p/a'); f.select(OTHER, true)
|
|
74
|
+
assert.equal(f.rows()[0], '所选估算费用: ≈ ¥3.00')
|
|
75
|
+
assert.doesNotMatch(f.tip(), /p\/b:/)
|
|
76
|
+
assert.ok(f.rows().includes('均价: ¥428.571/M'))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
for (const [key, label, value] of [['speed', '平均生成速度', '12 tok/s'], ['rate', '缓存命中率', '75.0%'], ['ttft', '首 token 延迟', '0.3秒']]) {
|
|
80
|
+
test(`Given 仅选 ${key} 曲线 When 悬浮 Then 只有时段整体指标且不显示模型合计或费用`, () => {
|
|
81
|
+
const f = fixture({ money: true }); f.hover(); f.select(key)
|
|
82
|
+
assert.match(f.tip(), /时段整体指标/)
|
|
83
|
+
assert.deepEqual(f.rows(), [`${label}: ${value}`])
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('Given 有其他付费槽和零费用模型 When 在金额视图选择零费用模型 Then 保留零费用读数', () => {
|
|
88
|
+
const data = [{ ...slot, byCostModel: { ...slot.byCostModel, 'p/a': 0 }, cost: 10 }]
|
|
89
|
+
const f = fixture({ money: true, data }); f.hover(); f.select('p/a')
|
|
90
|
+
assert.deepEqual(f.rows(), ['所选估算费用: ≈ ¥0.00', 'p/a: ≈ ¥0.00'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('Given 当前槽中选中模型没有用量 When 悬浮 Then 不展示隐藏模型总计或空提示', () => {
|
|
94
|
+
const f = fixture({ data: [{ ...slot, byModel: { ...slot.byModel, 'p/a': 0 } }] }); f.hover(); f.select('p/a')
|
|
95
|
+
assert.equal(f.tip(), '')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('Given 辅助费用偏好关闭 When 切换金额视图 Then 主金额读数仍展示并响应货币变化', () => {
|
|
99
|
+
const f = fixture({ costDisplay: false }); f.hover(); f.select('p/a')
|
|
100
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok'])
|
|
101
|
+
f.update({ money: true, costCurrency: '$' })
|
|
102
|
+
assert.deepEqual(f.rows(), ['所选估算费用: ≈ $2.00', 'p/a: ≈ $2.00', '均价: $2,000.000/M'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('Given 悬浮时间桶 When 切换到不含该桶的窗口 Then 清除提示而不复用数组下标', () => {
|
|
106
|
+
const f = fixture(); f.hover()
|
|
107
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-27' }] })
|
|
108
|
+
assert.equal(f.tip(), '')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('Given 悬浮时间桶 When 窗口前插时间桶 Then 提示仍绑定原时间桶', () => {
|
|
112
|
+
const f = fixture(); f.hover()
|
|
113
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-27' }, slot] })
|
|
114
|
+
assert.match(f.tip(), /^2026-10-28/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('Given 英文金额视图 When 单选模型 Then 使用英文费用标签及当前货币', () => {
|
|
118
|
+
const f = fixture({ money: true, lang: 'en', currency: '$' }); f.hover(); f.select('p/a')
|
|
119
|
+
assert.equal(f.rows()[0], 'Selected estimated cost: ≈ $2.00')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
for (const [key, label, value] of [['speed', '平均生成速度', '0 tok/s'], ['ttft', '首 token 延迟', '0秒']]) {
|
|
123
|
+
test(`Given 只有 timing 的时间桶 When 仅选 ${key} Then 零值指标仍显示提示`, () => {
|
|
124
|
+
const timing = { ...slot, total: 0, byModel: {}, cost: 0, byCostModel: {}, cacheHit: 0, cacheMiss: 0, speed: 0, ttft: 0 }
|
|
125
|
+
const f = fixture({ data: [timing] }); f.select(key); f.hover()
|
|
126
|
+
assert.deepEqual(f.rows(), [`${label}: ${value}`])
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const key of ['speed', 'ttft', 'rate']) {
|
|
131
|
+
test(`Given 所选 ${key} 在当前桶没有数据 When 悬浮 Then 不显示空指标提示`, () => {
|
|
132
|
+
const emptyMetrics = { ...slot, day: '2026-10-27', speed: undefined, ttft: undefined, cacheHit: 0, cacheMiss: 0 }
|
|
133
|
+
const f = fixture({ data: [emptyMetrics, slot] }); f.select(key); f.hover()
|
|
134
|
+
assert.equal(f.tip(), '')
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test('Given 单选旧模型 When 新窗口不含该模型 Then 恢复默认可见模型且能继续单选', () => {
|
|
139
|
+
const f = fixture(); f.select('p/a'); f.hover()
|
|
140
|
+
f.update({ tipSlots: [{ ...slot, total: 100, byModel: { 'p/z': 100 }, cost: 1, byCostModel: { 'p/z': 1 } }], modelOrder: ['p/z'] })
|
|
141
|
+
assert.ok(f.rows().includes('p/z: 100 tok'))
|
|
142
|
+
assert.equal(f.rows()[0], '总用量: 100 tok')
|
|
143
|
+
f.select('p/z')
|
|
144
|
+
assert.ok(f.rows().includes('p/z: 100 tok'))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('Given 单选速度 When 新窗口没有速度 Then 恢复可用模型', () => {
|
|
148
|
+
const f = fixture(); f.select('speed'); f.hover()
|
|
149
|
+
f.update({ tipSlots: [{ ...slot, speed: undefined }] })
|
|
150
|
+
assert.ok(f.rows().includes('p/a: 1,000 tok'))
|
|
151
|
+
assert.doesNotMatch(f.tip(), /平均生成速度:/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('Given 小时桶悬浮 When 切到分钟桶 Then 旧提示清除且新提示显示分钟桶数据', () => {
|
|
155
|
+
const f = fixture({ data: [{ ...slot, day: '2026-10-28T12' }] }); f.hover()
|
|
156
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-28T12:10' }] })
|
|
157
|
+
assert.equal(f.tip(), '')
|
|
158
|
+
f.hover()
|
|
159
|
+
assert.match(f.tip(), /^2026-10-28T12:10/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('Given 单选模型在新窗口全部失效 When 返回原窗口 Then 保持默认显示而不复活旧单选', () => {
|
|
163
|
+
const f = fixture(); f.select('p/a'); f.hover()
|
|
164
|
+
f.update({ tipSlots: [{ ...slot, total: 100, byModel: { 'p/z': 100 }, cost: 1, byCostModel: { 'p/z': 1 } }], modelOrder: ['p/z'] })
|
|
165
|
+
f.update({ tipSlots: [slot], modelOrder: ['p/a', 'p/b', OTHER] })
|
|
166
|
+
assert.equal(f.rows()[0], '总用量: 16,000 tok')
|
|
167
|
+
assert.ok(f.rows().includes('p/b: 9,000 tok'))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('Given 多选中部分模型在新窗口失效 When 返回原窗口 Then 只保留未失效的选择', () => {
|
|
171
|
+
const f = fixture(); f.select('p/a'); f.select('p/b', true); f.hover()
|
|
172
|
+
f.update({ tipSlots: [{ ...slot, total: 1000, byModel: { 'p/a': 1000 }, cost: 2, byCostModel: { 'p/a': 2 } }], modelOrder: ['p/a'] })
|
|
173
|
+
f.update({ tipSlots: [slot], modelOrder: ['p/a', 'p/b', OTHER] })
|
|
174
|
+
assert.equal(f.rows()[0], '所选用量: 1,000 tok')
|
|
175
|
+
assert.doesNotMatch(f.tip(), /p\/b:/)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('Given 未配置费用 When 悬浮 Token 视图 Then 只展示用量及有效曲线', () => {
|
|
179
|
+
const { cost, byCostModel, otherCostByModel, ...unpriced } = slot
|
|
180
|
+
const f = fixture({ data: [unpriced] }); f.select('p/a'); f.hover()
|
|
181
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok'])
|
|
182
|
+
})
|