dsh-tokenlens 1.2.2 → 1.3.0
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/CHANGELOG.md +8 -0
- package/PROTOCOL.md +45 -7
- package/README.en.md +6 -5
- package/README.md +7 -6
- package/client.js +456 -9
- package/index.js +90 -3
- package/lib/provider-store.mjs +4 -0
- package/lib/update-check.mjs +87 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/):修复 = PATCH,新功能 = MINOR,不兼容变更 = MAJOR。发布时同步更新 `package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION` 与本文件(`node scripts/bump-version.mjs <version> "<note>"`);已发布的条目保持原样。
|
|
4
4
|
|
|
5
|
+
## 1.3.0 — 2026-11-01
|
|
6
|
+
|
|
7
|
+
- feat(update): 可选的**启动时检查更新**(默认关闭):开启后仅向 npm registry 查询一次 `dsh-tokenlens` 的 latest 版本号,不携带凭据/账号/用量信息;主机离线或接口失败时记录错误并说明,不阻塞启动
|
|
8
|
+
- feat(update): 供应商管理页「插件设置」新增「更新检查」块——开关、隐私提示、状态行(尚未检查 / 正在检查 / 已是最新 / 发现新版本 / 检查失败可重试)与「立即检查」;详情弹窗底部在发现新版本时显示一行「发现新版本 vX · 打开 npm」(链接到 npm 包页)
|
|
9
|
+
- feat(wire): `hello` 与 `providers` 载荷新增 `update`(`UpdateInfo`)与 `updatePackageUrl`;`settingsUpdate` 新增 `settings.updateCheck`;新增 bridge action `updateCheck`(Agent 工具同步支持)
|
|
10
|
+
- fix(update): 并发检查单飞(共享同一次在途请求);仅在开关从关→开时触发检查,已开启时保存其它显示设置不会重复请求 npm
|
|
11
|
+
- 测试:277 个自动化用例(新增 Host 版本比较/检查/设置/并发与客户端 UI 回归)
|
|
12
|
+
|
|
5
13
|
## 1.2.2 — 2026-11-01
|
|
6
14
|
|
|
7
15
|
- fix(workspaces): 修复「工作区悬浮卡片不显示 Token 明细」——Host 在裁剪本地路径时把 `path` 字段误删成空串,客户端只剩标题精确匹配;默认工作区等「界面标题 ≠ 统计标签」的场景因此匹配不到统计(影响 v1.2.1 及更早版本)
|
package/PROTOCOL.md
CHANGED
|
@@ -32,12 +32,16 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
|
|
|
32
32
|
|
|
33
33
|
```jsonc
|
|
34
34
|
{
|
|
35
|
-
"action": "snapshot" | "refresh" | "hello" | "stats" | "diagnostics"
|
|
35
|
+
"action": "snapshot" | "refresh" | "hello" | "stats" | "diagnostics" |
|
|
36
|
+
"providers" | "providerUpdate" | "providerTest" | "settingsUpdate" |
|
|
37
|
+
"customProviderSave" | "customProviderDelete" | "updateCheck",
|
|
36
38
|
"providerId": "opencode-go", // optional; defaults to the active provider
|
|
37
39
|
"fresh": false, // stats only: bypass the scan TTL
|
|
38
40
|
"origin": "dsh-app://app", // hello only, optional
|
|
39
41
|
"primitives": 290, // hello only, optional (seeded primitives count)
|
|
40
|
-
"locale": "zh-CN"
|
|
42
|
+
"locale": "zh-CN", // hello only, optional
|
|
43
|
+
"settings": { /* settingsUpdate only */ },
|
|
44
|
+
"update": { /* providerUpdate only */ }
|
|
41
45
|
}
|
|
42
46
|
```
|
|
43
47
|
|
|
@@ -47,6 +51,9 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
|
|
|
47
51
|
answers with a snapshot so the first paint needs one round trip.
|
|
48
52
|
- `stats` — today/all-time token statistics folded from the local session logs.
|
|
49
53
|
- `diagnostics` — config/cache/error state (verification aid, no secrets).
|
|
54
|
+
- `updateCheck` — v1.3.0: run the optional npm-registry update check right now and
|
|
55
|
+
answer `{ ok: true, update: <UpdateInfo> }`. It is also run once at activation
|
|
56
|
+
when `settings.updateCheck` is enabled; disabled by default.
|
|
50
57
|
- Unknown action → HTTP 400 `{ "ok": false, "error": { "code": "unknown-action", … } }`.
|
|
51
58
|
|
|
52
59
|
### Response envelope
|
|
@@ -54,10 +61,11 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
|
|
|
54
61
|
```jsonc
|
|
55
62
|
{ "ok": true, "snapshot": <Snapshot> } // snapshot / refresh / hello
|
|
56
63
|
{ "ok": true, "stats": <Stats> } // stats
|
|
64
|
+
{ "ok": true, "update": <UpdateInfo> } // updateCheck
|
|
57
65
|
{ "ok": false, "error": <Error>, "snapshot": <Snapshot|null> }
|
|
58
66
|
```
|
|
59
67
|
|
|
60
|
-
`hello` answers `{ ok: true, plugin, version, bridge, pid, …snapshotResult }`.
|
|
68
|
+
`hello` answers `{ ok: true, plugin, version, bridge, pid, update: <UpdateInfo|null>, …snapshotResult }`.
|
|
61
69
|
|
|
62
70
|
## Snapshot (wire shape, v3)
|
|
63
71
|
|
|
@@ -319,7 +327,7 @@ needs no provider catalog of its own.
|
|
|
319
327
|
|
|
320
328
|
The dialog 「供应商管理」 page talks to three additional actions:
|
|
321
329
|
|
|
322
|
-
- `{action:"providers"}` → `{ok:true, providers:[{providerId,label,shortLabel,kind,enabled,hasKey,keySource,apiKeyRef,baseURL,consoleUrl,builtin,dshManaged,dshProviderId}], dshProviders:[{providerId,label,supported,usageProviderId}], settings:{labelStyle,defaultView,defaultScope,detailsExpanded,detailsWindow}, storeFile, storeFileDisplay}`. `storeFile` is the real path (local tooling); `storeFileDisplay` replaces the user's home directory with `~` and is what the management page renders.
|
|
330
|
+
- `{action:"providers"}` → `{ok:true, providers:[{providerId,label,shortLabel,kind,enabled,hasKey,keySource,apiKeyRef,baseURL,consoleUrl,builtin,dshManaged,dshProviderId}], dshProviders:[{providerId,label,supported,usageProviderId}], settings:{labelStyle,defaultView,defaultScope,detailsExpanded,detailsWindow,updateCheck}, update:<UpdateInfo|null>, updatePackageUrl, storeFile, storeFileDisplay}`. `storeFile` is the real path (local tooling); `storeFileDisplay` replaces the user's home directory with `~` and is what the management page renders. `update` (v1.3.0) is the last optional update-check result and `updatePackageUrl` the npm page the management UI links to.
|
|
323
331
|
- `{action:"providerUpdate", providerId, update:{enabled?,apiKey?,apiKeyRef?,baseURL?,consoleUrl?,label?,shortLabel?}}` → same list payload;
|
|
324
332
|
empty strings clear a stored value (falling back to the profile/default). `label` (全名, ≤40 chars) and
|
|
325
333
|
`shortLabel` (短标签, ≤12 chars) let the management page rename a provider. The shipped management page no
|
|
@@ -327,7 +335,32 @@ The dialog 「供应商管理」 page talks to three additional actions:
|
|
|
327
335
|
saves enabled/consoleUrl/label/shortLabel. Overrides are persisted atomically to
|
|
328
336
|
`$DSH_HOME/dsh-tokenlens/providers.json` (plugin-owned, not the profile patch); the same file also
|
|
329
337
|
- `{action:"providerTest", providerId}` → the normal snapshot envelope (`{ok,snapshot}` or `{ok:false,error}`), forcing one upstream read.
|
|
330
|
-
- `{action:"settingsUpdate", settings:{labelStyle?:"short"|"full", defaultView?:"chart"|"data", defaultScope?:"provider"|"all", detailsExpanded?:"auto"|"expanded"|"collapsed", detailsWindow?:"today"|"last7Days"|"second"|"total"}}` → the same providers payload; every field is optional and persisted in the store (`settings.*`). `labelStyle` controls whether the composer chip shows each provider's `shortLabel` or full `label`; `defaultView` / `defaultScope` / `detailsExpanded` are the usage-dialog defaults; `detailsWindow` (v1.2.1) is the remembered 明细 donut window (falls back visually to `total` when that window is unavailable on the current provider/host). Invalid values answer `unsupported-provider`.
|
|
338
|
+
- `{action:"settingsUpdate", settings:{labelStyle?:"short"|"full", defaultView?:"chart"|"data", defaultScope?:"provider"|"all", detailsExpanded?:"auto"|"expanded"|"collapsed", detailsWindow?:"today"|"last7Days"|"second"|"total", updateCheck?:boolean}}` → the same providers payload; every field is optional and persisted in the store (`settings.*`). `labelStyle` controls whether the composer chip shows each provider's `shortLabel` or full `label`; `defaultView` / `defaultScope` / `detailsExpanded` are the usage-dialog defaults; `detailsWindow` (v1.2.1) is the remembered 明细 donut window (falls back visually to `total` when that window is unavailable on the current provider/host); `updateCheck` (v1.3.0, default `false`) enables the optional startup update check — enabling it runs one check immediately. Invalid values answer `unsupported-provider`.
|
|
339
|
+
|
|
340
|
+
### Optional update check (v1.3.0)
|
|
341
|
+
|
|
342
|
+
Off by default: the plugin stays local-only unless the user enables
|
|
343
|
+
`settings.updateCheck`. When enabled, one HTTPS `GET` to the fixed endpoint
|
|
344
|
+
`https://registry.npmjs.org/dsh-tokenlens/latest` runs at activation, on the
|
|
345
|
+
off→on transition of the setting, and on the `updateCheck` action. Concurrent
|
|
346
|
+
checks are single-flight (one registry request shared by every caller). Nothing
|
|
347
|
+
but the request itself leaves the machine — no credentials, identifiers or
|
|
348
|
+
usage data.
|
|
349
|
+
|
|
350
|
+
```jsonc
|
|
351
|
+
// UpdateInfo (host → client)
|
|
352
|
+
{
|
|
353
|
+
"current": "1.3.0",
|
|
354
|
+
"latest": "1.3.1" | null, // null when the answer was unusable/failed
|
|
355
|
+
"available": true, // strictly newer published version, error === null
|
|
356
|
+
"checkedAt": "2026-11-02T00:00:00.000Z",
|
|
357
|
+
"error": null | { "code": "network"|"timeout"|"http"|"parse", "message": "…", "retryable": true }
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A failed check never throws and never blocks activation; the client explains the
|
|
362
|
+
error instead of inventing a version. The result lives in memory only and is
|
|
363
|
+
surfaced through `hello`, `providers` and `diagnostics`.
|
|
331
364
|
|
|
332
365
|
`dshProviders` lists every route registered in DSH model settings (in order) with a `supported` flag (whether a usage adapter exists); the shipped management page renders those routes as the primary list and keeps unconfigured preset adapters in a collapsed secondary section. `dshManaged`/`dshProviderId` tell the management page that the route already exists in DSH model settings
|
|
333
366
|
(opencode-go / commandcode / deepseek), so the page can inherit the key and base URL instead of asking for them
|
|
@@ -438,6 +471,11 @@ configure the `opencode-go` provider.
|
|
|
438
471
|
- **chart mode**: the daily bar chart, a click on a day bar drills into that
|
|
439
472
|
day's 24 local-hour bars (when the Host delivers `daily[].hours`), and the
|
|
440
473
|
明细 donuts switch between 今日 / 最近7天 / 最近X天 / 总计;
|
|
441
|
-
- refresh (usage + stats), last-updated/stale line, error banners, console link
|
|
442
|
-
|
|
474
|
+
- refresh (usage + stats), last-updated/stale line, error banners, console link;
|
|
475
|
+
- when `update.available` is true (v1.3.0) the footer shows one muted line
|
|
476
|
+
「发现新版本 vX · 打开 npm」 linking to `updatePackageUrl`.
|
|
477
|
+
3. **Provider management page** (same modal, 管理 tab) — when the optional update
|
|
478
|
+
check is enabled the 插件设置 tab shows its toggle, the last result (up to date /
|
|
479
|
+
new version / failed) and a 「立即检查」 action that sends `updateCheck`.
|
|
480
|
+
4. Everything client-side must work with only the shell's seeded modules
|
|
443
481
|
(`react`, `react-dom`, `@deepseek-ai/dsh-client-ui-primitives`) — no build step.
|
package/README.en.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
> One glance at your usage: plan capacity for the selected provider plus local token statistics across harnesses, right below the DSH composer.
|
|
11
11
|
|
|
12
|
-
**dsh-TokenLens** is a usage plugin for DSH (DeepSeek Harness), version v1.
|
|
12
|
+
**dsh-TokenLens** is a usage plugin for DSH (DeepSeek Harness), version v1.3.0. It keeps two kinds of numbers separate:
|
|
13
13
|
|
|
14
14
|
- **Plan capacity** — the provider's authoritative server-side values (percentage windows, used / cap, balance or remaining credit);
|
|
15
15
|
- **Local token stats** — tokens consumed on this machine across every connected harness, for the selected provider or for all providers combined.
|
|
@@ -21,7 +21,7 @@ Everything is computed locally: zero runtime dependencies, no external database,
|
|
|
21
21
|
- **Usage chip below the composer** — follows the selected model provider and shows its plan capacity; providers without an adapter or credentials stay hidden.
|
|
22
22
|
- **Details dialog** — plan capacity windows with reset times (plus an optional "consumed · estimated consumable" hint) and local token stats for today / last 7 days / last 30 days / all-time, scoped to the current provider or all harnesses.
|
|
23
23
|
- **Data and chart modes** — tables, a daily trend chart and ring charts with breakdowns by harness, provider, top model and unmapped providers. In chart mode, clicking a day's bar drills into that day's 24 hourly bars; the 明细 rings switch between today / last 7 days / last X days (or last 30 days) / all time.
|
|
24
|
-
- **Provider management page** — enabled / built-in / all filters, enable switches, credential status, console links, label editing, connection tests, subscription badges and plugin settings (label style, default view/scope, details expansion, default 明细 donut window).
|
|
24
|
+
- **Provider management page** — enabled / built-in / all filters, enable switches, credential status, console links, label editing, connection tests, subscription badges and plugin settings (label style, default view/scope, details expansion, default 明细 donut window, optional startup update check with a "check now" action).
|
|
25
25
|
- **Auto-discovery and agent tool** — providers configured in DSH Settings → Model Services are detected by route id or base-URL host and reuse their DSH credentials; `tokenlens_usage` lets the model query snapshots, local stats and diagnostics.
|
|
26
26
|
- **Local-only** — 5 harnesses (DSH, ZCode, OpenCode CLI, WorkBuddy / CodeBuddy CLI, Qoder CN CLI), 25 built-in providers (16 with plan / balance adapters), read-only local data, no telemetry.
|
|
27
27
|
|
|
@@ -66,7 +66,7 @@ dsh plugin --profile <profile> add dsh-tokenlens
|
|
|
66
66
|
Then make sure `dsh.profile.bundles` in `$DSH_HOME/profiles/<profile>/package.json` contains `"dsh-tokenlens"`:
|
|
67
67
|
|
|
68
68
|
```jsonc
|
|
69
|
-
{ "dependencies": { "dsh-tokenlens": "^1.
|
|
69
|
+
{ "dependencies": { "dsh-tokenlens": "^1.3.0" },
|
|
70
70
|
"dsh": { "profile": { "bundles": ["…", "dsh-tokenlens"] } } }
|
|
71
71
|
```
|
|
72
72
|
|
|
@@ -123,7 +123,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
|
|
|
123
123
|
|
|
124
124
|
- **Read-only**: only local session logs and harness databases are read; SQLite is opened read-only and only token counters, timestamps and provider / model fields are queried — never message contents.
|
|
125
125
|
- **Keys stay local**: API keys are resolved in the Host process from DSH credentials, environment variables or the DSH credentials file, and are sent only to the matching provider's official HTTPS endpoint. They are never exposed to the browser, logged or included in diagnostics.
|
|
126
|
-
- **No telemetry**: nothing is collected, uploaded, or fetched from remote config / CDN sources (icons are inline SVG).
|
|
126
|
+
- **No telemetry**: nothing is collected, uploaded, or fetched from remote config / CDN sources (icons are inline SVG). The only optional outbound request is the startup update check (v1.3.0, off by default): when enabled it asks the npm registry once for the published version number, without credentials, identifiers or usage data.
|
|
127
127
|
- **Owner-only data**: management overrides are written atomically to `$DSH_HOME/dsh-tokenlens/providers.json` with owner-only permissions; DSH model settings are never modified.
|
|
128
128
|
|
|
129
129
|
## Limitations
|
|
@@ -131,12 +131,13 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
|
|
|
131
131
|
- Provider usage endpoints are mostly private / undocumented and may change at any time; the plugin degrades gracefully (retryable errors, hidden chips, per-row fallbacks) but availability is not guaranteed.
|
|
132
132
|
- Plan capacity shows native server values only: no token → money conversion; Command Code monthly percentage is not guessed when no grant is reported.
|
|
133
133
|
- "Estimated consumable" is derived from local tokens and the plan percentage; it is only a hint, not a provider commitment.
|
|
134
|
+
- The startup update check compares the **latest npm release** only (off by default); commits on the GitHub repository are not detected — a git-installed plugin must be uninstalled and reinstalled to update.
|
|
134
135
|
- Local stats cover connected harnesses on this machine only; uninstalled harnesses are hidden by default (`stats.showUninstalled: true` shows them). Workspace token badges are experimental.
|
|
135
136
|
- **Trademarks**: OpenCode, Command Code, DeepSeek, ZCode, WorkBuddy, CodeBuddy, Qoder and other names are trademarks of their respective owners. This is an independent third-party project, not affiliated with or endorsed by them.
|
|
136
137
|
|
|
137
138
|
## Development
|
|
138
139
|
|
|
139
|
-
Node.js ≥ 24, zero runtime dependencies: `node --test` runs the
|
|
140
|
+
Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 277 tests.
|
|
140
141
|
|
|
141
142
|
Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
|
142
143
|
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
> **一眼看清你的用量**:在 DSH 输入框下方显示当前供应商的套餐容量,并汇总本机跨 Harness 的 Token 消耗。
|
|
11
11
|
|
|
12
|
-
**dsh-TokenLens(词元透镜)** 是 DSH(DeepSeek Harness)的用量插件(v1.
|
|
12
|
+
**dsh-TokenLens(词元透镜)** 是 DSH(DeepSeek Harness)的用量插件(v1.3.0,公开版本)。它把两类数字**按不同口径**分开呈现:
|
|
13
13
|
|
|
14
14
|
- **套餐容量**(供应商服务端权威值):百分比窗口、`已用 / 上限`、账户余额或剩余额度;
|
|
15
15
|
- **本机 Token 统计**(本机记录):当前供应商在本机所有已接入 Harness 上的合计用量,也可切到「全部 Harness」看总账。
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
- **本机 Token 统计**:今日 / 最近 7 天 / 最近 30 天 / 总计(订阅类供应商显示「最近 X 天」生效窗口),可按「当前供应商 · 全部 Harness」与「全部 Harness」两个视角查看。
|
|
42
42
|
- **明细与图表**:按 Harness、按供应商、Top 模型、未映射供应商分组;支持「数据」(表格)与「图表」两种模式切换。图表模式里点击某一天的柱子可下钻查看**当天 24 小时**的 Token 消耗,底部「明细」环形图支持 **今日 / 最近 7 天 / 最近 X 天(或最近 30 天)/ 总计** 窗口切换。
|
|
43
43
|
- **工作区徽标**(实验性):左侧工作区列表的名称旁显示该工作区的本机 Token 合计,悬浮卡片显示今日 / 7 天 / 30 天 / 总计;纯展示,找不到对应 UI 时自动降级。
|
|
44
|
-
- **供应商管理页**:「已配置 / 内置 /
|
|
44
|
+
- **供应商管理页**:「已配置 / 内置 / 全部」筛选、启用开关、凭据状态、控制台链接、短标签与全名编辑、测试连接、已订阅徽章;另有「插件设置」可配置标签样式、默认展示模式、默认统计范围、明细展开状态、图表「明细」环形图的默认窗口(今日 / 最近 7 天 / 最近 X 天 / 总计,记住上次选择)与「启动时检查更新」(默认关闭,可手动立即检查)。
|
|
45
45
|
- **自动识别**:在 DSH「设置 → 模型服务」中配置过的供应商会自动出现在管理页,并按路由 id 或 baseURL 主机匹配内置用量适配器,自动继承 DSH 中的凭据,无需重复填写。
|
|
46
46
|
- **Agent 工具**:注册 `tokenlens_usage`,模型可以自行查询套餐容量与本机用量。
|
|
47
47
|
|
|
@@ -106,7 +106,7 @@ dsh plugin --profile <profile> add dsh-tokenlens
|
|
|
106
106
|
```jsonc
|
|
107
107
|
{
|
|
108
108
|
"dependencies": {
|
|
109
|
-
"dsh-tokenlens": "^1.
|
|
109
|
+
"dsh-tokenlens": "^1.3.0"
|
|
110
110
|
},
|
|
111
111
|
"dsh": {
|
|
112
112
|
"profile": {
|
|
@@ -371,7 +371,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
|
|
|
371
371
|
|
|
372
372
|
- **只读**:插件只读取本机会话日志与各 harness 的本地数据库 / JSONL;SQLite 一律以只读方式打开,只取 token 计数、时间、provider / model 等字段,不读取对话正文。
|
|
373
373
|
- **凭据不出本机**:API Key 只在 Host 进程内从 DSH 凭据服务 / 环境变量 / `.credentials.yaml` / OpenCode CLI 登录文件解析,只发往对应供应商的官方 HTTPS 接口;不下发浏览器、不写日志、不进诊断输出。
|
|
374
|
-
- **无遥测**:插件不收集、不上传任何使用数据,不请求远程配置或 CDN 资源(图标均为内联 SVG
|
|
374
|
+
- **无遥测**:插件不收集、不上传任何使用数据,不请求远程配置或 CDN 资源(图标均为内联 SVG)。唯一的可选外网请求是「启动时检查更新」(v1.3.0,默认关闭):开启后仅向 npm registry 查询一次最新版本号,不携带任何账号、凭据或用量信息。
|
|
375
375
|
- **覆盖文件仅本人可读**:供应商管理页的覆盖写入 `$DSH_HOME/dsh-tokenlens/providers.json`(原子写入,权限仅当前用户),不会改动 DSH 模型设置或 profile patch。
|
|
376
376
|
- **可控降级**:上游接口不可用时只影响对应 chip / 行,不影响 DSH 本身;`enabled: false` 可整体停用插件。
|
|
377
377
|
|
|
@@ -380,6 +380,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
|
|
|
380
380
|
- 各供应商的用量接口多为**未公开的私有接口**,可能随时变更;插件对此做了优雅降级(可重试错误、静默隐藏、行级降级),但不保证长期可用。
|
|
381
381
|
- 套餐容量只显示服务端原生数字:不做 token → 金额换算;Command Code 未报告 grant 时不计算月度百分比。
|
|
382
382
|
- 「预计可消耗」是基于本机 Token 与套餐百分比的**估算**,仅供参考,不代表供应商承诺的额度。
|
|
383
|
+
- 「启动时检查更新」只对比 **npm 上的最新发布版本**(默认关闭);GitHub 仓库的提交更新不会被感知,需要卸载重装 git 安装的版本才会更新。
|
|
383
384
|
- 本机 Token 统计只覆盖**已接入 harness**(当前 DSH / ZCode / OpenCode CLI / WorkBuddy / Qoder CN),只统计本机的本地记录。
|
|
384
385
|
- Harness 卸载后其数据目录消失即判定为未安装,默认从弹窗隐藏(可用 `stats.showUninstalled: true` 显示「未安装」行);数据目录仍在磁盘时按历史记录继续计入。
|
|
385
386
|
- ZCode 数据库通常很大且持续写入:插件只做聚合查询并缓存结果,数据库被锁时会短暂显示不可用。
|
|
@@ -391,7 +392,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
|
|
|
391
392
|
|
|
392
393
|
## 验证
|
|
393
394
|
|
|
394
|
-
- `node --test`:**
|
|
395
|
+
- `node --test`:**277 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、插件集成与浏览器端渲染 / 交互。
|
|
395
396
|
- 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
|
|
396
397
|
- 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
|
|
397
398
|
|
|
@@ -402,7 +403,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
|
|
|
402
403
|
```powershell
|
|
403
404
|
git clone https://github.com/daigahou/dsh-tokenlens.git
|
|
404
405
|
cd dsh-tokenlens
|
|
405
|
-
node --test #
|
|
406
|
+
node --test # 277 个用例
|
|
406
407
|
```
|
|
407
408
|
|
|
408
409
|
本地联调安装方式见[方式 C](#方式-c本地开发从源码-link)。
|
package/client.js
CHANGED
|
@@ -68,6 +68,14 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
const MANAGEMENT_SYNC_MS = 15000
|
|
69
69
|
/** Retry delay of a 明细-window save blocked by an in-flight settings request (v1.2.1). */
|
|
70
70
|
const DETAILS_WINDOW_SAVE_RETRY_MS = 200
|
|
71
|
+
/**
|
|
72
|
+
* Delays of the network-free providers re-reads while 「启动时检查更新」 is on
|
|
73
|
+
* and no result exists yet: the Host's own startup / toggle check runs in
|
|
74
|
+
* the background, so hello or a settings answer can win the race. Two
|
|
75
|
+
* attempts cover a normal registry round-trip without ever asking the
|
|
76
|
+
* registry twice; the management page's silent check covers the rest.
|
|
77
|
+
*/
|
|
78
|
+
const UPDATE_RESULT_REFRESH_MS = [1200, 4000]
|
|
71
79
|
/** How many per-provider / per-model stats rows the dialog lists. */
|
|
72
80
|
const MAX_PROVIDER_ROWS = 5
|
|
73
81
|
const MAX_MODEL_ROWS = 3
|
|
@@ -137,7 +145,12 @@ window.__ModuleLoader__.load({
|
|
|
137
145
|
defaultScope: 'provider',
|
|
138
146
|
detailsExpanded: 'auto',
|
|
139
147
|
detailsWindow: 'total',
|
|
148
|
+
// 「启动时检查更新」 is persisted too; off by default so an old Host and a
|
|
149
|
+
// fresh install never contact the npm registry (privacy first).
|
|
150
|
+
updateCheck: false,
|
|
140
151
|
}
|
|
152
|
+
/** The npm package page the 「打开 npm」 update links point at. */
|
|
153
|
+
const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
|
|
141
154
|
|
|
142
155
|
const zh = {
|
|
143
156
|
title: '词元透镜(dsh-TokenLens)',
|
|
@@ -340,6 +353,18 @@ window.__ModuleLoader__.load({
|
|
|
340
353
|
settingsDetailsCollapsed: '收起',
|
|
341
354
|
settingsResetDefaults: '恢复默认设置',
|
|
342
355
|
settingsResetDefaultsHint: '恢复为插件默认的显示设置',
|
|
356
|
+
updateCheckLabel: '启动时检查更新',
|
|
357
|
+
updateCheckHint: '仅当开启时才访问 npm registry 检查新版本;默认关闭',
|
|
358
|
+
updateCheckOff: '关闭',
|
|
359
|
+
updateCheckOn: '开启',
|
|
360
|
+
updateChecking: '正在检查…',
|
|
361
|
+
updateLatest: '已是最新版本 v{version}',
|
|
362
|
+
updateAvailable: '发现新版本 v{latest}(当前 v{current})',
|
|
363
|
+
updateCheckFailed: '检查失败',
|
|
364
|
+
updateCheckNow: '立即检查',
|
|
365
|
+
updateCheckNever: '尚未检查',
|
|
366
|
+
updateOpenNpm: '打开 npm',
|
|
367
|
+
updateDialogLine: '发现新版本 v{latest} · 打开 npm',
|
|
343
368
|
providerBothLabels: '短标签:{short} · 全名:{full}',
|
|
344
369
|
providerShortLabel: '短标签',
|
|
345
370
|
providerShortLabelHint: '1–12 字符',
|
|
@@ -568,6 +593,18 @@ window.__ModuleLoader__.load({
|
|
|
568
593
|
settingsDetailsCollapsed: 'Collapsed',
|
|
569
594
|
settingsResetDefaults: 'Restore defaults',
|
|
570
595
|
settingsResetDefaultsHint: 'Restore the plugin default display settings',
|
|
596
|
+
updateCheckLabel: 'Check for updates at startup',
|
|
597
|
+
updateCheckHint: 'Contacts the npm registry for a new version only when enabled; off by default',
|
|
598
|
+
updateCheckOff: 'Off',
|
|
599
|
+
updateCheckOn: 'On',
|
|
600
|
+
updateChecking: 'Checking…',
|
|
601
|
+
updateLatest: 'Up to date: v{version}',
|
|
602
|
+
updateAvailable: 'New version v{latest} available (current v{current})',
|
|
603
|
+
updateCheckFailed: 'Update check failed',
|
|
604
|
+
updateCheckNow: 'Check now',
|
|
605
|
+
updateCheckNever: 'Not checked yet',
|
|
606
|
+
updateOpenNpm: 'Open on npm',
|
|
607
|
+
updateDialogLine: 'New version v{latest} · Open on npm',
|
|
571
608
|
providerBothLabels: 'Short label: {short} · Full name: {full}',
|
|
572
609
|
providerShortLabel: 'Short label',
|
|
573
610
|
providerShortLabelHint: '1–12 characters',
|
|
@@ -1111,9 +1148,9 @@ window.__ModuleLoader__.load({
|
|
|
1111
1148
|
}
|
|
1112
1149
|
|
|
1113
1150
|
/**
|
|
1114
|
-
* The five display settings of the `providers`
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1151
|
+
* The five display settings plus 「启动时检查更新」 of the `providers`
|
|
1152
|
+
* payload, every field normalized to a usable value so old Hosts keep the
|
|
1153
|
+
* previous defaults.
|
|
1117
1154
|
*/
|
|
1118
1155
|
function normalizePluginSettings(raw) {
|
|
1119
1156
|
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
@@ -1123,9 +1160,50 @@ window.__ModuleLoader__.load({
|
|
|
1123
1160
|
defaultScope: normalizeDisplayScope(source.defaultScope),
|
|
1124
1161
|
detailsExpanded: normalizeDetailsExpanded(source.detailsExpanded),
|
|
1125
1162
|
detailsWindow: normalizeDetailsWindow(source.detailsWindow),
|
|
1163
|
+
// The startup update check is opt-in: a value other than `true` (an old
|
|
1164
|
+
// Host omits the field entirely) means off.
|
|
1165
|
+
updateCheck: source.updateCheck === true,
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* The optional `update` object of the hello / providers envelope: the last
|
|
1171
|
+
* known update check result, or null when the Host never checked.
|
|
1172
|
+
*
|
|
1173
|
+
* Defensive by design — the update UI must never throw on hostile wire
|
|
1174
|
+
* data. String fields collapse to `''`/null, `available` is only claimed
|
|
1175
|
+
* when it is literally `true` *and* names a version, and a wire error keeps
|
|
1176
|
+
* the shared normalized error shape.
|
|
1177
|
+
*/
|
|
1178
|
+
function normalizeUpdate(raw) {
|
|
1179
|
+
if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
1180
|
+
const current = stringOrEmpty(raw.current).trim()
|
|
1181
|
+
const latestText = stringOrEmpty(raw.latest).trim()
|
|
1182
|
+
const latest = latestText === '' ? null : latestText
|
|
1183
|
+
return {
|
|
1184
|
+
current,
|
|
1185
|
+
latest,
|
|
1186
|
+
available: raw.available === true && latest !== null,
|
|
1187
|
+
checkedAt: stringOrEmpty(raw.checkedAt).trim(),
|
|
1188
|
+
error: raw.error === null || raw.error === undefined ? null : normalizeError(raw.error),
|
|
1126
1189
|
}
|
|
1127
1190
|
}
|
|
1128
1191
|
|
|
1192
|
+
/** Whether two normalized update infos are render-identical (avoids store churn). */
|
|
1193
|
+
function sameUpdate(a, b) {
|
|
1194
|
+
if (a === b) return true
|
|
1195
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
1196
|
+
if (a.current !== b.current || a.latest !== b.latest || a.available !== b.available || a.checkedAt !== b.checkedAt) {
|
|
1197
|
+
return false
|
|
1198
|
+
}
|
|
1199
|
+
if (a.error === null || b.error === null) return a.error === b.error
|
|
1200
|
+
return (
|
|
1201
|
+
a.error.code === b.error.code &&
|
|
1202
|
+
a.error.message === b.error.message &&
|
|
1203
|
+
a.error.retryable === b.error.retryable
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1129
1207
|
/**
|
|
1130
1208
|
* One route discovered in the DSH model settings (v0.15.0), or null when it
|
|
1131
1209
|
* has no usable id. `supported` plus a `usageProviderId` means the plugin has
|
|
@@ -1184,6 +1262,10 @@ window.__ModuleLoader__.load({
|
|
|
1184
1262
|
? raw.dshProviders.map((entry) => normalizeDshProviderEntry(entry)).filter((entry) => entry !== null)
|
|
1185
1263
|
: []
|
|
1186
1264
|
const settings = raw.settings !== null && typeof raw.settings === 'object' ? raw.settings : {}
|
|
1265
|
+
// The update check result travels with every providers payload; `hasUpdate`
|
|
1266
|
+
// distinguishes "the Host said null" from "an old Host omitted the field",
|
|
1267
|
+
// so a hello-provided result is never wiped by a payload without it.
|
|
1268
|
+
const hasUpdate = Object.prototype.hasOwnProperty.call(raw, 'update')
|
|
1187
1269
|
return {
|
|
1188
1270
|
// v0.17.0 identity: dedupe by providerId, last-wins, so the filter tabs
|
|
1189
1271
|
// never list the same provider twice even if the Host sends a duplicate.
|
|
@@ -1196,6 +1278,10 @@ window.__ModuleLoader__.load({
|
|
|
1196
1278
|
storeFile: stringOrEmpty(raw.storeFile).trim(),
|
|
1197
1279
|
storeFileDisplay: stringOrEmpty(raw.storeFileDisplay).trim(),
|
|
1198
1280
|
settings: normalizePluginSettings(settings),
|
|
1281
|
+
update: hasUpdate ? normalizeUpdate(raw.update) : null,
|
|
1282
|
+
hasUpdate,
|
|
1283
|
+
// Optional: the Host may advertise the npm page its links should open.
|
|
1284
|
+
updatePackageUrl: stringOrEmpty(raw.updatePackageUrl).trim(),
|
|
1199
1285
|
labelCatalog,
|
|
1200
1286
|
}
|
|
1201
1287
|
}
|
|
@@ -1773,6 +1859,16 @@ window.__ModuleLoader__.load({
|
|
|
1773
1859
|
labelCatalog: {},
|
|
1774
1860
|
// Plugin version from the hello envelope (dialog footnote).
|
|
1775
1861
|
pluginVersion: '',
|
|
1862
|
+
// 「启动时检查更新」 (persisted setting) and the last check result the
|
|
1863
|
+
// hello / providers envelope reported, plus the in-flight flag of an
|
|
1864
|
+
// explicit 「立即检查」. The flag is deliberately independent from
|
|
1865
|
+
// `management.settingsBusy`: a check never blocks a settings save.
|
|
1866
|
+
updateCheck: DEFAULT_PLUGIN_SETTINGS.updateCheck,
|
|
1867
|
+
update: null,
|
|
1868
|
+
updateChecking: false,
|
|
1869
|
+
// The canonical npm page the update links open; the Host may advertise
|
|
1870
|
+
// its own (newer bundles may move the package), this is the fallback.
|
|
1871
|
+
updatePackageUrl: NPM_PACKAGE_URL,
|
|
1776
1872
|
// Extra DSH-only detail blocks (provider split / top models). Collapsed by
|
|
1777
1873
|
// default on short screens so the dialog fits without a scrollbar.
|
|
1778
1874
|
showDetails:
|
|
@@ -1808,8 +1904,8 @@ window.__ModuleLoader__.load({
|
|
|
1808
1904
|
let usageEpoch = 0
|
|
1809
1905
|
|
|
1810
1906
|
/**
|
|
1811
|
-
* The
|
|
1812
|
-
*
|
|
1907
|
+
* The display settings currently in effect: the five v0.29.0 defaults (the
|
|
1908
|
+
* v1.2.1 明细 donut window included) plus 「启动时检查更新」.
|
|
1813
1909
|
*/
|
|
1814
1910
|
function currentPluginSettings() {
|
|
1815
1911
|
const state = store.get()
|
|
@@ -1819,6 +1915,7 @@ window.__ModuleLoader__.load({
|
|
|
1819
1915
|
defaultScope: normalizeDisplayScope(state.defaultScope),
|
|
1820
1916
|
detailsExpanded: normalizeDetailsExpanded(state.detailsExpanded),
|
|
1821
1917
|
detailsWindow: normalizeDetailsWindow(state.detailsWindow),
|
|
1918
|
+
updateCheck: state.updateCheck === true,
|
|
1822
1919
|
}
|
|
1823
1920
|
}
|
|
1824
1921
|
|
|
@@ -2065,6 +2162,18 @@ window.__ModuleLoader__.load({
|
|
|
2065
2162
|
if (options.adoptDetailsWindow === true && current.detailsWindow !== settings.detailsWindow) {
|
|
2066
2163
|
patch.detailsWindow = settings.detailsWindow
|
|
2067
2164
|
}
|
|
2165
|
+
// 「启动时检查更新」 is adopted on every answer (it is the persisted value
|
|
2166
|
+
// echoing back); the answered checkbox state must win after a toggle.
|
|
2167
|
+
if (current.updateCheck !== settings.updateCheck) patch.updateCheck = settings.updateCheck
|
|
2168
|
+
// A payload that carries `update` (even as null) is authoritative for the
|
|
2169
|
+
// last check result; one that omits it (old Host) keeps what hello said.
|
|
2170
|
+
if (parsed.hasUpdate === true && !sameUpdate(current.update, parsed.update)) {
|
|
2171
|
+
patch.update = parsed.update
|
|
2172
|
+
}
|
|
2173
|
+
// The Host-advertised npm page travels with the same payload.
|
|
2174
|
+
if (parsed.updatePackageUrl !== '' && current.updatePackageUrl !== parsed.updatePackageUrl) {
|
|
2175
|
+
patch.updatePackageUrl = parsed.updatePackageUrl
|
|
2176
|
+
}
|
|
2068
2177
|
if (parsed.labelCatalog !== null && typeof parsed.labelCatalog === 'object') {
|
|
2069
2178
|
const catalog = { ...current.labelCatalog, ...parsed.labelCatalog }
|
|
2070
2179
|
if (Object.keys(catalog).length > 0) patch.labelCatalog = catalog
|
|
@@ -2123,6 +2232,9 @@ window.__ModuleLoader__.load({
|
|
|
2123
2232
|
storeFileDisplay: parsed !== null ? parsed.storeFileDisplay : '',
|
|
2124
2233
|
loadedAt: Date.now(),
|
|
2125
2234
|
})
|
|
2235
|
+
// 「启动时检查更新」: the first management-page load runs one silent
|
|
2236
|
+
// check when the setting is on and no result is known yet.
|
|
2237
|
+
maybeAutoUpdateCheck()
|
|
2126
2238
|
return { kind: 'ok' }
|
|
2127
2239
|
}
|
|
2128
2240
|
patchManagement({ inFlight: false, phase: 'error', error: normalizeError(data.error) })
|
|
@@ -2273,6 +2385,10 @@ window.__ModuleLoader__.load({
|
|
|
2273
2385
|
}
|
|
2274
2386
|
: {}),
|
|
2275
2387
|
})
|
|
2388
|
+
// Turning 「启动时检查更新」 on makes the Host run one check right away;
|
|
2389
|
+
// its answer may still be in flight, so read the local result shortly
|
|
2390
|
+
// after (no extra registry request).
|
|
2391
|
+
if (store.get().updateCheck === true && store.get().update === null) scheduleUpdateResultRefresh()
|
|
2276
2392
|
return { kind: 'ok' }
|
|
2277
2393
|
}
|
|
2278
2394
|
patchManagement({ settingsBusy: false, settingsError: normalizeError(data.error) })
|
|
@@ -2331,6 +2447,128 @@ window.__ModuleLoader__.load({
|
|
|
2331
2447
|
void requestSettingsUpdate({ ...DEFAULT_PLUGIN_SETTINGS })
|
|
2332
2448
|
}
|
|
2333
2449
|
|
|
2450
|
+
/** Guards the one silent check the management page may start after opening. */
|
|
2451
|
+
let autoUpdateCheckStarted = false
|
|
2452
|
+
/** Whether the settings error banner currently shows an update-check failure. */
|
|
2453
|
+
let updateCheckErrorShown = false
|
|
2454
|
+
/** One delayed retry when the Host says "a check is already running". */
|
|
2455
|
+
const UPDATE_CHECK_RETRY_MS = 1200
|
|
2456
|
+
let updateCheckRetryTimer = null
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* Run one update check (「立即检查」, and the management page's one silent
|
|
2460
|
+
* startup check when 「启动时检查更新」 is on).
|
|
2461
|
+
*
|
|
2462
|
+
* `updateChecking` is an independent flag, never `settingsBusy`: a check may
|
|
2463
|
+
* not block a settings save (and vice versa). A Host-level failure travels
|
|
2464
|
+
* inside `update.error` (the status row renders 检查失败); a transport or
|
|
2465
|
+
* envelope failure uses the management page's existing error banner. Never
|
|
2466
|
+
* throws.
|
|
2467
|
+
*/
|
|
2468
|
+
async function requestUpdateCheck(options = {}) {
|
|
2469
|
+
const retried = options.retried === true
|
|
2470
|
+
if (store.get().updateChecking === true) return { kind: 'busy' }
|
|
2471
|
+
store.set({ updateChecking: true })
|
|
2472
|
+
let data
|
|
2473
|
+
try {
|
|
2474
|
+
data = await call({ action: 'updateCheck' })
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
store.set({ updateChecking: false })
|
|
2477
|
+
updateCheckErrorShown = true
|
|
2478
|
+
patchManagement({ settingsError: managementErrorOf(error) })
|
|
2479
|
+
return { kind: 'transport' }
|
|
2480
|
+
}
|
|
2481
|
+
if (data !== null && typeof data === 'object' && typeof data.ok === 'boolean') {
|
|
2482
|
+
if (data.ok === true) {
|
|
2483
|
+
const update = normalizeUpdate(data.update)
|
|
2484
|
+
if (update !== null) {
|
|
2485
|
+
store.set({ updateChecking: false, update })
|
|
2486
|
+
// A successful retry retires the banner of the failed one instead of
|
|
2487
|
+
// leaving a stale check failure above a fresh result.
|
|
2488
|
+
if (updateCheckErrorShown === true) {
|
|
2489
|
+
updateCheckErrorShown = false
|
|
2490
|
+
patchManagement({ settingsError: null })
|
|
2491
|
+
}
|
|
2492
|
+
return { kind: 'ok' }
|
|
2493
|
+
}
|
|
2494
|
+
// A first-ever check that is already in flight comes back as
|
|
2495
|
+
// `{ok:true, update:null}` (the Host's single-flight returns the last
|
|
2496
|
+
// result). Retry once after a short delay instead of reporting a bad
|
|
2497
|
+
// response; a result that arrived meanwhile (management sync) retires
|
|
2498
|
+
// the retry.
|
|
2499
|
+
if (!retried) {
|
|
2500
|
+
if (updateCheckRetryTimer !== null) window.clearTimeout(updateCheckRetryTimer)
|
|
2501
|
+
updateCheckRetryTimer = window.setTimeout(() => {
|
|
2502
|
+
updateCheckRetryTimer = null
|
|
2503
|
+
store.set({ updateChecking: false })
|
|
2504
|
+
if (store.get().update !== null) return
|
|
2505
|
+
void requestUpdateCheck({ retried: true })
|
|
2506
|
+
}, UPDATE_CHECK_RETRY_MS)
|
|
2507
|
+
return { kind: 'pending' }
|
|
2508
|
+
}
|
|
2509
|
+
store.set({ updateChecking: false })
|
|
2510
|
+
return { kind: 'pending' }
|
|
2511
|
+
}
|
|
2512
|
+
store.set({ updateChecking: false })
|
|
2513
|
+
updateCheckErrorShown = true
|
|
2514
|
+
patchManagement({ settingsError: normalizeError(data.error) })
|
|
2515
|
+
return { kind: 'envelope' }
|
|
2516
|
+
}
|
|
2517
|
+
store.set({ updateChecking: false })
|
|
2518
|
+
updateCheckErrorShown = true
|
|
2519
|
+
patchManagement({
|
|
2520
|
+
settingsError: { code: 'internal', message: 'unexpected updateCheck response', retryable: true },
|
|
2521
|
+
})
|
|
2522
|
+
return { kind: 'transport' }
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/**
|
|
2526
|
+
* The management page's ONE silent check: only while the setting is on and
|
|
2527
|
+
* no result is known yet. The module-level flag makes a re-open, a tab
|
|
2528
|
+
* switch or a provider-list refresh impossible to turn into a second check.
|
|
2529
|
+
*/
|
|
2530
|
+
function maybeAutoUpdateCheck() {
|
|
2531
|
+
if (autoUpdateCheckStarted === true) return
|
|
2532
|
+
const state = store.get()
|
|
2533
|
+
if (state.updateCheck !== true || state.update !== null) return
|
|
2534
|
+
autoUpdateCheckStarted = true
|
|
2535
|
+
void requestUpdateCheck()
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/** The pending network-free providers re-read (see UPDATE_RESULT_REFRESH_MS). */
|
|
2539
|
+
let updateResultRefreshTimer = null
|
|
2540
|
+
let updateResultRefreshAttempt = 0
|
|
2541
|
+
|
|
2542
|
+
/**
|
|
2543
|
+
* Read the local providers payload once more after the Host's background
|
|
2544
|
+
* update check had time to finish (`updateCheck` on, no result known yet).
|
|
2545
|
+
*
|
|
2546
|
+
* This never asks the npm registry itself — it only re-reads the Host state
|
|
2547
|
+
* that the Host's own startup / toggle check fills in. No-op once a result
|
|
2548
|
+
* exists or the setting is off, so it can be armed from several places.
|
|
2549
|
+
*/
|
|
2550
|
+
function scheduleUpdateResultRefresh() {
|
|
2551
|
+
if (updateResultRefreshTimer !== null) return
|
|
2552
|
+
const delay = UPDATE_RESULT_REFRESH_MS[updateResultRefreshAttempt]
|
|
2553
|
+
if (delay === undefined) return
|
|
2554
|
+
updateResultRefreshAttempt += 1
|
|
2555
|
+
updateResultRefreshTimer = window.setTimeout(() => {
|
|
2556
|
+
updateResultRefreshTimer = null
|
|
2557
|
+
const state = store.get()
|
|
2558
|
+
if (state.updateCheck !== true || state.update !== null) {
|
|
2559
|
+
updateResultRefreshAttempt = 0
|
|
2560
|
+
return
|
|
2561
|
+
}
|
|
2562
|
+
void bootstrapProviders().then(() => {
|
|
2563
|
+
if (store.get().update !== null) {
|
|
2564
|
+
updateResultRefreshAttempt = 0
|
|
2565
|
+
return
|
|
2566
|
+
}
|
|
2567
|
+
scheduleUpdateResultRefresh()
|
|
2568
|
+
})
|
|
2569
|
+
}, delay)
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2334
2572
|
/** Force one upstream read for a provider and keep the row outcome. Never throws. */
|
|
2335
2573
|
async function requestProviderTest(providerId) {
|
|
2336
2574
|
const before = store.get().management
|
|
@@ -2519,6 +2757,9 @@ window.__ModuleLoader__.load({
|
|
|
2519
2757
|
const current = store.get().management
|
|
2520
2758
|
const stale = current.loadedAt === 0 || Date.now() - current.loadedAt >= PROVIDERS_TTL_MS
|
|
2521
2759
|
if (current.inFlight !== true && stale) void requestProviders()
|
|
2760
|
+
// A fresh check when the setting is already known and no result exists
|
|
2761
|
+
// (the requestProviders answer triggers the same one-shot path otherwise).
|
|
2762
|
+
maybeAutoUpdateCheck()
|
|
2522
2763
|
}
|
|
2523
2764
|
|
|
2524
2765
|
/** Switch back to the usage page (the modal chrome stays). */
|
|
@@ -2633,12 +2874,20 @@ window.__ModuleLoader__.load({
|
|
|
2633
2874
|
void requestProviderUpdate(provider.providerId, { shortLabel: '', label: '' })
|
|
2634
2875
|
}
|
|
2635
2876
|
|
|
2636
|
-
/** Store the plugin version
|
|
2877
|
+
/** Store the plugin version and the last update check result of the hello envelope. */
|
|
2637
2878
|
function applyHelloInfo(data) {
|
|
2638
2879
|
if (data === null || typeof data !== 'object') return
|
|
2880
|
+
const patch = {}
|
|
2639
2881
|
if (typeof data.version === 'string' && data.version !== '') {
|
|
2640
|
-
if (store.get().pluginVersion !== data.version)
|
|
2882
|
+
if (store.get().pluginVersion !== data.version) patch.pluginVersion = data.version
|
|
2883
|
+
}
|
|
2884
|
+
// `update` is optional on the wire: only an explicit field (null included)
|
|
2885
|
+
// replaces what the store already knows.
|
|
2886
|
+
if (Object.prototype.hasOwnProperty.call(data, 'update')) {
|
|
2887
|
+
const update = normalizeUpdate(data.update)
|
|
2888
|
+
if (!sameUpdate(store.get().update, update)) patch.update = update
|
|
2641
2889
|
}
|
|
2890
|
+
if (Object.keys(patch).length > 0) store.set(patch)
|
|
2642
2891
|
}
|
|
2643
2892
|
|
|
2644
2893
|
/** Announce this browser half to the Host (also records the current provider). */
|
|
@@ -2772,6 +3021,11 @@ window.__ModuleLoader__.load({
|
|
|
2772
3021
|
// v0.10.0: learn the persisted chip label style + wire labels once, without
|
|
2773
3022
|
// touching the management page state (silent when an old Host answers nothing).
|
|
2774
3023
|
void bootstrapProviders()
|
|
3024
|
+
// The Host runs its optional startup update check in the background; the
|
|
3025
|
+
// hello / bootstrap answers above can win the race and carry no result yet.
|
|
3026
|
+
// Re-read the (local, no-network) providers payload shortly after so the
|
|
3027
|
+
// dialog footer can show the result without opening the management page.
|
|
3028
|
+
scheduleUpdateResultRefresh()
|
|
2775
3029
|
pollTimer = window.setInterval(() => {
|
|
2776
3030
|
void poll()
|
|
2777
3031
|
}, POLL_INTERVAL_MS)
|
|
@@ -2783,6 +3037,16 @@ window.__ModuleLoader__.load({
|
|
|
2783
3037
|
disposed = true
|
|
2784
3038
|
if (pollTimer !== null) window.clearInterval(pollTimer)
|
|
2785
3039
|
if (retryTimer !== null) window.clearTimeout(retryTimer)
|
|
3040
|
+
// Pending update-check timers must not fire into a disposed controller.
|
|
3041
|
+
updateResultRefreshAttempt = 0
|
|
3042
|
+
if (updateResultRefreshTimer !== null) {
|
|
3043
|
+
window.clearTimeout(updateResultRefreshTimer)
|
|
3044
|
+
updateResultRefreshTimer = null
|
|
3045
|
+
}
|
|
3046
|
+
if (updateCheckRetryTimer !== null) {
|
|
3047
|
+
window.clearTimeout(updateCheckRetryTimer)
|
|
3048
|
+
updateCheckRetryTimer = null
|
|
3049
|
+
}
|
|
2786
3050
|
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
2787
3051
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
2788
3052
|
}
|
|
@@ -3118,6 +3382,20 @@ window.__ModuleLoader__.load({
|
|
|
3118
3382
|
.dsh-tokenlens-settings-options .dsh-tokenlens-settings-option{display:inline-flex;align-items:center;gap:4px}
|
|
3119
3383
|
.dsh-tokenlens-settings-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px;min-width:0}
|
|
3120
3384
|
@media (max-width:560px){.dsh-tokenlens-settings-row{grid-template-columns:minmax(0,1fr)}}
|
|
3385
|
+
/* Update check block (update UI): toggle row, privacy hint, status line + 立即检查. */
|
|
3386
|
+
.dsh-tokenlens-update-section{display:flex;flex-direction:column;gap:6px;min-width:0;margin-top:2px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.18))}
|
|
3387
|
+
.dsh-tokenlens-update-hint{opacity:.8;font-size:11px}
|
|
3388
|
+
.dsh-tokenlens-update-status{align-items:center;flex-wrap:wrap;display:flex;gap:5px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;overflow-wrap:anywhere}
|
|
3389
|
+
.dsh-tokenlens-update-status[data-kind=error]{color:var(--dsw-alias-label-error,#d9534f)}
|
|
3390
|
+
.dsh-tokenlens-update-status[data-kind=available]{color:var(--dsw-alias-label-primary)}
|
|
3391
|
+
.dsh-tokenlens-update-error-detail{opacity:.85;min-width:0;overflow-wrap:anywhere}
|
|
3392
|
+
.dsh-tokenlens-update-retry{font:inherit;font-size:11px;line-height:inherit;padding:0;border:0;background:0 0;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
|
|
3393
|
+
.dsh-tokenlens-update-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;min-width:0}
|
|
3394
|
+
.dsh-tokenlens-update-link{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:inherit;text-decoration:underline;text-underline-offset:2px;min-width:0}
|
|
3395
|
+
.dsh-tokenlens-update-link:hover,.dsh-tokenlens-update-retry:hover{color:var(--dsw-alias-label-primary)}
|
|
3396
|
+
.dsh-tokenlens-update-link:focus-visible,.dsh-tokenlens-update-retry:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5));border-radius:2px}
|
|
3397
|
+
.dsh-tokenlens-update-line{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 6px;min-width:0;margin-right:auto;color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4}
|
|
3398
|
+
.dsh-tokenlens-update-line .dsh-tokenlens-update-link{color:inherit}
|
|
3121
3399
|
.dsh-tokenlens-provider-labels{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow-wrap:anywhere;display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px}
|
|
3122
3400
|
.dsh-tokenlens-provider-labels .dsh-tokenlens-icon{margin-top:1px}
|
|
3123
3401
|
/* Inline icons: currentColor stroke, never a text node (keeps textOf stable). */
|
|
@@ -5891,11 +6169,13 @@ window.__ModuleLoader__.load({
|
|
|
5891
6169
|
h(
|
|
5892
6170
|
'button',
|
|
5893
6171
|
{
|
|
5894
|
-
key: option.value,
|
|
6172
|
+
key: String(option.value),
|
|
5895
6173
|
type: 'button',
|
|
5896
6174
|
className: `dsh-tokenlens-scope-option dsh-tokenlens-settings-option${field === 'labelStyle' ? ' dsh-tokenlens-label-style-option' : ''}`,
|
|
5897
6175
|
'data-setting': field,
|
|
5898
|
-
|
|
6176
|
+
// A boolean row (updateCheck) is stringified so `data-*` matches
|
|
6177
|
+
// the real DOM; the string rows keep their wire value verbatim.
|
|
6178
|
+
'data-value': typeof option.value === 'string' ? option.value : String(option.value),
|
|
5899
6179
|
'data-label-style': field === 'labelStyle' ? option.value : undefined,
|
|
5900
6180
|
'data-active': active === option.value ? 'true' : 'false',
|
|
5901
6181
|
'aria-pressed': active === option.value ? 'true' : 'false',
|
|
@@ -5913,6 +6193,127 @@ window.__ModuleLoader__.load({
|
|
|
5913
6193
|
)
|
|
5914
6194
|
}
|
|
5915
6195
|
|
|
6196
|
+
/** The npm page the update links open (the Host-advertised one when present). */
|
|
6197
|
+
function updatePackageUrlOf(state) {
|
|
6198
|
+
return typeof state?.updatePackageUrl === 'string' && state.updatePackageUrl !== ''
|
|
6199
|
+
? state.updatePackageUrl
|
|
6200
|
+
: NPM_PACKAGE_URL
|
|
6201
|
+
}
|
|
6202
|
+
|
|
6203
|
+
/**
|
|
6204
|
+
* The 「更新检查」 status line: checking → failed → update available → up to
|
|
6205
|
+
* date → never checked. `data-update-status` names the branch for tests and
|
|
6206
|
+
* styling; the error branch keeps a 重试 action only when retryable.
|
|
6207
|
+
*/
|
|
6208
|
+
function buildUpdateStatusLine(state, t) {
|
|
6209
|
+
const update = state.update
|
|
6210
|
+
const packageUrl = updatePackageUrlOf(state)
|
|
6211
|
+
const line = (kind, children) =>
|
|
6212
|
+
h(
|
|
6213
|
+
'div',
|
|
6214
|
+
{
|
|
6215
|
+
key: 'update-status',
|
|
6216
|
+
className: 'dsh-tokenlens-update-status',
|
|
6217
|
+
'data-update-status': kind,
|
|
6218
|
+
'data-kind': kind,
|
|
6219
|
+
role: 'status',
|
|
6220
|
+
},
|
|
6221
|
+
Icon(kind === 'error' ? 'alert' : kind === 'available' ? 'bolt' : kind === 'latest' ? 'check' : 'info', 12),
|
|
6222
|
+
children,
|
|
6223
|
+
)
|
|
6224
|
+
if (state.updateChecking === true) {
|
|
6225
|
+
return line('checking', h('span', { key: 'text' }, t('updateChecking')))
|
|
6226
|
+
}
|
|
6227
|
+
if (update !== null && update.error !== null) {
|
|
6228
|
+
return line('error', [
|
|
6229
|
+
h('span', { key: 'text' }, t('updateCheckFailed')),
|
|
6230
|
+
h('span', { key: 'detail', className: 'dsh-tokenlens-update-error-detail', title: update.error.message }, managementErrorText(update.error, t)),
|
|
6231
|
+
update.error.retryable === true
|
|
6232
|
+
? h(
|
|
6233
|
+
'button',
|
|
6234
|
+
{
|
|
6235
|
+
key: 'retry',
|
|
6236
|
+
type: 'button',
|
|
6237
|
+
className: 'dsh-tokenlens-update-retry',
|
|
6238
|
+
'data-update-retry': 'true',
|
|
6239
|
+
onClick: () => {
|
|
6240
|
+
void requestUpdateCheck()
|
|
6241
|
+
},
|
|
6242
|
+
},
|
|
6243
|
+
t('retry'),
|
|
6244
|
+
)
|
|
6245
|
+
: null,
|
|
6246
|
+
])
|
|
6247
|
+
}
|
|
6248
|
+
if (update !== null && update.available === true) {
|
|
6249
|
+
return line('available', [
|
|
6250
|
+
h('span', { key: 'text' }, t('updateAvailable', { latest: update.latest, current: update.current })),
|
|
6251
|
+
h(
|
|
6252
|
+
'a',
|
|
6253
|
+
{
|
|
6254
|
+
key: 'link',
|
|
6255
|
+
className: 'dsh-tokenlens-update-link',
|
|
6256
|
+
'data-update-link': 'true',
|
|
6257
|
+
href: packageUrl,
|
|
6258
|
+
target: '_blank',
|
|
6259
|
+
rel: 'noreferrer noopener',
|
|
6260
|
+
title: packageUrl,
|
|
6261
|
+
onClick: (event) => openConsole(packageUrl, event),
|
|
6262
|
+
},
|
|
6263
|
+
Icon('external', 12),
|
|
6264
|
+
t('updateOpenNpm'),
|
|
6265
|
+
),
|
|
6266
|
+
])
|
|
6267
|
+
}
|
|
6268
|
+
if (update !== null && update.latest !== null && update.available === false) {
|
|
6269
|
+
return line('latest', h('span', { key: 'text' }, t('updateLatest', { version: update.latest })))
|
|
6270
|
+
}
|
|
6271
|
+
return line('never', h('span', { key: 'text' }, t('updateCheckNever')))
|
|
6272
|
+
}
|
|
6273
|
+
|
|
6274
|
+
/**
|
|
6275
|
+
* The 「更新检查」 block of 插件设置: the startup toggle (the shared settings
|
|
6276
|
+
* row), the registry-privacy hint, the last check result and 「立即检查」.
|
|
6277
|
+
* The check button works whether the startup toggle is on or off, and its
|
|
6278
|
+
* `updateChecking` flag is independent from `settingsBusy`.
|
|
6279
|
+
*/
|
|
6280
|
+
function buildUpdateSection(state, t) {
|
|
6281
|
+
const checking = state.updateChecking === true
|
|
6282
|
+
return h(
|
|
6283
|
+
'div',
|
|
6284
|
+
{ key: 'update-section', className: 'dsh-tokenlens-update-section', 'data-update-section': 'true' },
|
|
6285
|
+
buildSettingsRow(state, t, 'updateCheck', t('updateCheckLabel'), [
|
|
6286
|
+
{ value: false, label: t('updateCheckOff'), icon: 'ban' },
|
|
6287
|
+
{ value: true, label: t('updateCheckOn'), icon: 'check' },
|
|
6288
|
+
]),
|
|
6289
|
+
h(
|
|
6290
|
+
'div',
|
|
6291
|
+
{ key: 'hint', className: 'dsh-tokenlens-subtitle dsh-tokenlens-update-hint', 'data-update-hint': 'true' },
|
|
6292
|
+
t('updateCheckHint'),
|
|
6293
|
+
),
|
|
6294
|
+
buildUpdateStatusLine(state, t),
|
|
6295
|
+
h(
|
|
6296
|
+
'div',
|
|
6297
|
+
{ key: 'actions', className: 'dsh-tokenlens-update-actions' },
|
|
6298
|
+
h(
|
|
6299
|
+
Button,
|
|
6300
|
+
{
|
|
6301
|
+
variant: 'outline',
|
|
6302
|
+
className: 'dsh-tokenlens-update-check',
|
|
6303
|
+
'data-update-check-now': 'true',
|
|
6304
|
+
title: t('updateCheckNow'),
|
|
6305
|
+
disabled: checking,
|
|
6306
|
+
onClick: () => {
|
|
6307
|
+
void requestUpdateCheck()
|
|
6308
|
+
},
|
|
6309
|
+
},
|
|
6310
|
+
Icon('spinner', 12),
|
|
6311
|
+
checking ? t('updateChecking') : t('updateCheckNow'),
|
|
6312
|
+
),
|
|
6313
|
+
),
|
|
6314
|
+
)
|
|
6315
|
+
}
|
|
6316
|
+
|
|
5916
6317
|
/** The 插件设置 tab (v0.29.0): display defaults, reset button and store path. */
|
|
5917
6318
|
function buildSettingsPanel(state, t) {
|
|
5918
6319
|
const management = state.management
|
|
@@ -5937,6 +6338,9 @@ window.__ModuleLoader__.load({
|
|
|
5937
6338
|
{ value: 'collapsed', label: t('settingsDetailsCollapsed'), icon: 'list' },
|
|
5938
6339
|
]),
|
|
5939
6340
|
]
|
|
6341
|
+
// 「更新检查」 is its own block (own status + own in-flight flag), kept
|
|
6342
|
+
// apart from the display-defaults rows and their reset button.
|
|
6343
|
+
children.push(buildUpdateSection(state, t))
|
|
5940
6344
|
const actions = h(
|
|
5941
6345
|
'div',
|
|
5942
6346
|
{ key: 'actions', className: 'dsh-tokenlens-settings-actions' },
|
|
@@ -6381,6 +6785,46 @@ window.__ModuleLoader__.load({
|
|
|
6381
6785
|
})
|
|
6382
6786
|
}
|
|
6383
6787
|
|
|
6788
|
+
/**
|
|
6789
|
+
* The usage dialog's 「发现新版本」 footer line, or null when the last check
|
|
6790
|
+
* found no update (or never ran). The i18n sentence names 「打开 npm」 as its
|
|
6791
|
+
* tail; that tail becomes the anchor that opens the npm package page, so the
|
|
6792
|
+
* line itself stays a single sentence with one real link inside it.
|
|
6793
|
+
*/
|
|
6794
|
+
function buildUpdateFooterLine(state, t) {
|
|
6795
|
+
const update = state.update
|
|
6796
|
+
if (update === null || update.available !== true) return null
|
|
6797
|
+
const text = t('updateDialogLine', { latest: update.latest })
|
|
6798
|
+
const linkLabel = t('updateOpenNpm')
|
|
6799
|
+
const packageUrl = updatePackageUrlOf(state)
|
|
6800
|
+
const link = h(
|
|
6801
|
+
'a',
|
|
6802
|
+
{
|
|
6803
|
+
key: 'link',
|
|
6804
|
+
className: 'dsh-tokenlens-update-link dsh-tokenlens-update-footer-link',
|
|
6805
|
+
'data-update-link': 'true',
|
|
6806
|
+
href: packageUrl,
|
|
6807
|
+
target: '_blank',
|
|
6808
|
+
rel: 'noreferrer noopener',
|
|
6809
|
+
title: packageUrl,
|
|
6810
|
+
onClick: (event) => openConsole(packageUrl, event),
|
|
6811
|
+
},
|
|
6812
|
+
linkLabel,
|
|
6813
|
+
Icon('external', 12),
|
|
6814
|
+
)
|
|
6815
|
+
const index = text.indexOf(linkLabel)
|
|
6816
|
+
const children =
|
|
6817
|
+
index < 0
|
|
6818
|
+
? [h('span', { key: 'text' }, text), h('span', { key: 'sep' }, ' · '), link]
|
|
6819
|
+
: [h('span', { key: 'text' }, text.slice(0, index)), link]
|
|
6820
|
+
return h(
|
|
6821
|
+
'div',
|
|
6822
|
+
{ key: 'update-line', className: 'dsh-tokenlens-update-line', 'data-update-line': 'true' },
|
|
6823
|
+
Icon('bolt', 12),
|
|
6824
|
+
children,
|
|
6825
|
+
)
|
|
6826
|
+
}
|
|
6827
|
+
|
|
6384
6828
|
/** The dialog contents (body + footer); may throw, the caller guards it. */
|
|
6385
6829
|
function buildDialog(state, now, t) {
|
|
6386
6830
|
if (state.view === 'management') return buildManagementDialog(state, t)
|
|
@@ -6440,6 +6884,9 @@ window.__ModuleLoader__.load({
|
|
|
6440
6884
|
Icon('external', 12),
|
|
6441
6885
|
consoleLabel,
|
|
6442
6886
|
),
|
|
6887
|
+
// 「发现新版本」 line (only while an update is available): its 「打开 npm」
|
|
6888
|
+
// tail is the real npm-package link, next to the refresh/close buttons.
|
|
6889
|
+
buildUpdateFooterLine(state, t),
|
|
6443
6890
|
h(
|
|
6444
6891
|
Button,
|
|
6445
6892
|
{
|
package/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './lib/provider-store.mjs'
|
|
38
38
|
import { makeCustomAdapter, sanitizeUsageBaseURL, validateCustomProvider } from './lib/custom-provider.mjs'
|
|
39
39
|
import { redactEndpoint, redactPaths, redactTextPaths } from './lib/redact.mjs'
|
|
40
|
+
import { UPDATE_CHECK_URL, UPDATE_PACKAGE_URL, buildUpdateInfo, parseLatestVersion } from './lib/update-check.mjs'
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* The one browser-facing route this plugin owns.
|
|
@@ -60,10 +61,11 @@ export const ACTIONS = [
|
|
|
60
61
|
'settingsUpdate',
|
|
61
62
|
'customProviderSave',
|
|
62
63
|
'customProviderDelete',
|
|
64
|
+
'updateCheck',
|
|
63
65
|
]
|
|
64
66
|
|
|
65
67
|
/** The tool-facing subset (a model has no reason to announce a browser). */
|
|
66
|
-
export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status']
|
|
68
|
+
export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status', 'updateCheck']
|
|
67
69
|
|
|
68
70
|
/** Optional services; every one is probed with `ctx.get` at use time. */
|
|
69
71
|
export const inject = []
|
|
@@ -72,7 +74,7 @@ export const inject = []
|
|
|
72
74
|
export const PLUGIN_NAME = 'dsh-tokenlens'
|
|
73
75
|
|
|
74
76
|
/** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
|
|
75
|
-
export const PLUGIN_VERSION = '1.
|
|
77
|
+
export const PLUGIN_VERSION = '1.3.0'
|
|
76
78
|
|
|
77
79
|
/** Documented defaults; every field of the raw row config is optional. */
|
|
78
80
|
const DEFAULTS = {
|
|
@@ -282,6 +284,11 @@ export function apply(ctx, rawConfig) {
|
|
|
282
284
|
requestCounts: new Map(), // providerId 鈫?successful reads
|
|
283
285
|
subscribers: new Set(),
|
|
284
286
|
client: null, // last browser `hello`
|
|
287
|
+
// Optional startup update check (settings.updateCheck): the last result
|
|
288
|
+
// `{current, latest, available, checkedAt, error}` or null before the first
|
|
289
|
+
// check. Never fetched unless the user enables the setting.
|
|
290
|
+
update: null,
|
|
291
|
+
updateChecking: false,
|
|
285
292
|
bridge: { found: false, registered: false, path: BRIDGE, error: null },
|
|
286
293
|
poll: { timer: null },
|
|
287
294
|
status: { timer: null },
|
|
@@ -729,6 +736,11 @@ export function apply(ctx, rawConfig) {
|
|
|
729
736
|
providers,
|
|
730
737
|
dshProviders,
|
|
731
738
|
settings: sanitizeSettings(providerStore.settings),
|
|
739
|
+
// Optional startup update check (settings.updateCheck): null until the
|
|
740
|
+
// first check; never fetched unless the setting is enabled or the user
|
|
741
|
+
// presses "check now".
|
|
742
|
+
update: state.update,
|
|
743
|
+
updatePackageUrl: UPDATE_PACKAGE_URL,
|
|
732
744
|
storeFile: redactPaths(providerStoreFile),
|
|
733
745
|
storeFileDisplay: redactPaths(providerStoreFile),
|
|
734
746
|
}
|
|
@@ -876,6 +888,53 @@ export function apply(ctx, rawConfig) {
|
|
|
876
888
|
return requestJson(url, { bearer: keyValue })
|
|
877
889
|
}
|
|
878
890
|
|
|
891
|
+
// Single-flight guard for the optional update check (see runUpdateCheck).
|
|
892
|
+
let updateFlight = null
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Ask the npm registry for the published `latest` version and remember the
|
|
896
|
+
* comparison result. This is the plugin's only non-provider outbound request
|
|
897
|
+
* and it only ever happens when the user enabled `settings.updateCheck`
|
|
898
|
+
* (startup / toggling it on) or presses "check now" on the management page.
|
|
899
|
+
*
|
|
900
|
+
* Single-flight: a concurrent call awaits the same in-flight check instead of
|
|
901
|
+
* answering `null`, and the function never throws — an offline machine or an
|
|
902
|
+
* upstream failure is stored as an error state so the UI can explain it.
|
|
903
|
+
*/
|
|
904
|
+
async function runUpdateCheck() {
|
|
905
|
+
if (updateFlight !== null) return updateFlight
|
|
906
|
+
updateFlight = (async () => {
|
|
907
|
+
state.updateChecking = true
|
|
908
|
+
let latest = null
|
|
909
|
+
let failure = null
|
|
910
|
+
try {
|
|
911
|
+
const payload = await requestJson(UPDATE_CHECK_URL)
|
|
912
|
+
latest = parseLatestVersion(payload)
|
|
913
|
+
if (latest === null) {
|
|
914
|
+
throw new UsageError('parse', 'the npm registry response has no usable version', { retryable: false })
|
|
915
|
+
}
|
|
916
|
+
} catch (error) {
|
|
917
|
+
failure = errorToWire(error)
|
|
918
|
+
} finally {
|
|
919
|
+
state.updateChecking = false
|
|
920
|
+
}
|
|
921
|
+
state.update = buildUpdateInfo(PLUGIN_VERSION, latest, new Date().toISOString(), failure)
|
|
922
|
+
noteStatus('update-check')
|
|
923
|
+
announce()
|
|
924
|
+
if (failure !== null) {
|
|
925
|
+
warn(`update check failed: ${failure.code}: ${failure.message}`)
|
|
926
|
+
} else if (state.update.available === true) {
|
|
927
|
+
log('info', `update available: ${state.update.latest} (running ${PLUGIN_VERSION})`)
|
|
928
|
+
}
|
|
929
|
+
return state.update
|
|
930
|
+
})()
|
|
931
|
+
try {
|
|
932
|
+
return await updateFlight
|
|
933
|
+
} finally {
|
|
934
|
+
updateFlight = null
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
879
938
|
/** Read one provider's usage: resolve its key (when it has one), call its adapter. */
|
|
880
939
|
async function readProvider(providerId, now) {
|
|
881
940
|
const id = canonicalProviderId(providerId)
|
|
@@ -1391,6 +1450,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1391
1450
|
activeProvider: activeProviderId(),
|
|
1392
1451
|
client: state.client,
|
|
1393
1452
|
bridge: { ...state.bridge },
|
|
1453
|
+
update: state.update,
|
|
1394
1454
|
providers,
|
|
1395
1455
|
stats: statsService?.diagnostics?.() ?? null,
|
|
1396
1456
|
subscribers: state.subscribers.size,
|
|
@@ -1586,7 +1646,15 @@ export function apply(ctx, rawConfig) {
|
|
|
1586
1646
|
log('info', `browser half connected from ${String(state.client.origin ?? 'unknown origin')} (${providerId ?? 'default provider'})`)
|
|
1587
1647
|
// Answer with a snapshot too, so the first paint needs one round trip.
|
|
1588
1648
|
const first = await get(requestProvider(body))
|
|
1589
|
-
return json({
|
|
1649
|
+
return json({
|
|
1650
|
+
ok: true,
|
|
1651
|
+
plugin: PLUGIN_NAME,
|
|
1652
|
+
version: PLUGIN_VERSION,
|
|
1653
|
+
bridge: BRIDGE,
|
|
1654
|
+
pid: process.pid,
|
|
1655
|
+
update: state.update,
|
|
1656
|
+
...first,
|
|
1657
|
+
})
|
|
1590
1658
|
}
|
|
1591
1659
|
if (action === 'snapshot' || action === 'status') {
|
|
1592
1660
|
rememberRequestedProvider(body)
|
|
@@ -1598,6 +1666,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1598
1666
|
}
|
|
1599
1667
|
if (action === 'stats') return json(await getStats({ fresh: body?.fresh === true }))
|
|
1600
1668
|
if (action === 'providers') return json(await providersPayload())
|
|
1669
|
+
if (action === 'updateCheck') return json({ ok: true, update: await runUpdateCheck() })
|
|
1601
1670
|
if (action === 'customProviderSave') {
|
|
1602
1671
|
const def = validateCustomProvider(body?.provider ?? body)
|
|
1603
1672
|
if (PROVIDER_DEFAULTS[def.providerId] !== undefined || ADAPTERS[def.providerId] !== undefined) {
|
|
@@ -1668,11 +1737,23 @@ export function apply(ctx, rawConfig) {
|
|
|
1668
1737
|
}
|
|
1669
1738
|
next.detailsWindow = incoming.detailsWindow
|
|
1670
1739
|
}
|
|
1740
|
+
if (Object.prototype.hasOwnProperty.call(incoming, 'updateCheck')) {
|
|
1741
|
+
if (typeof incoming.updateCheck !== 'boolean') {
|
|
1742
|
+
throw new UsageError('unsupported-provider', 'settings.updateCheck must be a boolean', {
|
|
1743
|
+
retryable: false,
|
|
1744
|
+
})
|
|
1745
|
+
}
|
|
1746
|
+
next.updateCheck = incoming.updateCheck
|
|
1747
|
+
}
|
|
1671
1748
|
providerStore = saveProviderStore(providerStoreFile, {
|
|
1672
1749
|
...providerStore,
|
|
1673
1750
|
settings: next,
|
|
1674
1751
|
})
|
|
1675
1752
|
noteStatus('settings-update', { providerStoreFile })
|
|
1753
|
+
// Turning the optional update check ON runs one check right away, so the
|
|
1754
|
+
// management page can show a result instead of waiting for a restart. A
|
|
1755
|
+
// full-settings save while it is already on must not re-request npm.
|
|
1756
|
+
if (incoming.updateCheck === true && current.updateCheck !== true) void runUpdateCheck()
|
|
1676
1757
|
return json(await providersPayload())
|
|
1677
1758
|
}
|
|
1678
1759
|
if (action === 'providerUpdate') {
|
|
@@ -1765,6 +1846,12 @@ export function apply(ctx, rawConfig) {
|
|
|
1765
1846
|
}
|
|
1766
1847
|
|
|
1767
1848
|
noteStatus('activate', { pollIntervalMs: config.pollIntervalMs })
|
|
1849
|
+
// Optional startup update check: one plain npm-registry request, only when the
|
|
1850
|
+
// user enabled `settings.updateCheck`. Off by default to keep the plugin
|
|
1851
|
+
// local-only; failures are recorded and never break activation.
|
|
1852
|
+
if (sanitizeSettings(providerStore.settings).updateCheck === true) {
|
|
1853
|
+
void runUpdateCheck()
|
|
1854
|
+
}
|
|
1768
1855
|
log(
|
|
1769
1856
|
'info',
|
|
1770
1857
|
`ready (providers ${Object.keys(config.providers).filter((id) => config.providers[id].enabled).join(', ') || 'none'}, cache ${config.minRefreshIntervalMs} ms, poll ${config.pollIntervalMs} ms)`,
|
package/lib/provider-store.mjs
CHANGED
|
@@ -40,6 +40,9 @@ export const DEFAULT_SETTINGS = {
|
|
|
40
40
|
detailsExpanded: 'auto',
|
|
41
41
|
// v1.2.1 图表明细窗口默认值: 总计 (the last choice is persisted there).
|
|
42
42
|
detailsWindow: 'total',
|
|
43
|
+
// Optional startup update check (npm registry); off by default so the plugin
|
|
44
|
+
// stays local-only unless the user opts in.
|
|
45
|
+
updateCheck: false,
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** The default override file: `$DSH_HOME/dsh-tokenlens/providers.json`. */
|
|
@@ -126,6 +129,7 @@ export function sanitizeSettings(value) {
|
|
|
126
129
|
detailsWindow: DETAILS_WINDOWS.includes(source.detailsWindow)
|
|
127
130
|
? source.detailsWindow
|
|
128
131
|
: DEFAULT_SETTINGS.detailsWindow,
|
|
132
|
+
updateCheck: source.updateCheck === true,
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional startup update check.
|
|
3
|
+
*
|
|
4
|
+
* The plugin stays local-only by default: nothing is sent anywhere unless the
|
|
5
|
+
* user enables `settings.updateCheck` in the management page. When enabled, one
|
|
6
|
+
* HTTPS GET asks the npm registry for the published `latest` version and the
|
|
7
|
+
* result is surfaced on the management page (and as a line in the detail dialog
|
|
8
|
+
* footer). No identifiers, credentials or usage data are sent — only a plain
|
|
9
|
+
* version-metadata request with the plugin's user agent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The npm registry endpoint that answers the published `latest` version. */
|
|
13
|
+
export const UPDATE_CHECK_URL = 'https://registry.npmjs.org/dsh-tokenlens/latest'
|
|
14
|
+
|
|
15
|
+
/** The canonical npm page of the package (for the "open on npm" link). */
|
|
16
|
+
export const UPDATE_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse one version string into comparable parts.
|
|
20
|
+
*
|
|
21
|
+
* Accepts `1.2.3`, `v1.2.3` and an optional prerelease (`1.3.0-beta.1`). Junk,
|
|
22
|
+
* empty strings and absurdly long input return null.
|
|
23
|
+
*/
|
|
24
|
+
export function parseVersion(raw) {
|
|
25
|
+
if (typeof raw !== 'string') return null
|
|
26
|
+
const text = raw.trim()
|
|
27
|
+
if (text === '' || text.length > 64) return null
|
|
28
|
+
const match = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z.-]{1,40}))?$/.exec(text)
|
|
29
|
+
if (match === null) return null
|
|
30
|
+
return {
|
|
31
|
+
major: Number(match[1]),
|
|
32
|
+
minor: Number(match[2]),
|
|
33
|
+
patch: Number(match[3]),
|
|
34
|
+
pre: match[4] ?? '',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compare two versions (`> 0` when `left` is newer, `< 0` when older, `0` when
|
|
40
|
+
* equal). A release always outranks its prereleases; prereleases compare as
|
|
41
|
+
* strings, which is enough for this plugin's plain `x.y.z` history.
|
|
42
|
+
*/
|
|
43
|
+
export function compareVersions(left, right) {
|
|
44
|
+
const a = parseVersion(left)
|
|
45
|
+
const b = parseVersion(right)
|
|
46
|
+
if (a === null || b === null) return null
|
|
47
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
48
|
+
if (a[key] !== b[key]) return a[key] - b[key]
|
|
49
|
+
}
|
|
50
|
+
if (a.pre === b.pre) return 0
|
|
51
|
+
if (a.pre === '') return 1
|
|
52
|
+
if (b.pre === '') return -1
|
|
53
|
+
return a.pre < b.pre ? -1 : 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether `latest` is strictly newer than `current` (false for junk input). */
|
|
57
|
+
export function isNewerVersion(latest, current) {
|
|
58
|
+
const compared = compareVersions(latest, current)
|
|
59
|
+
return compared !== null && compared > 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `version` field of the npm `/latest` document, or null. */
|
|
63
|
+
export function parseLatestVersion(payload) {
|
|
64
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
65
|
+
const version = payload.version
|
|
66
|
+
if (typeof version !== 'string') return null
|
|
67
|
+
const trimmed = version.trim()
|
|
68
|
+
return parseVersion(trimmed) === null ? null : trimmed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The wire shape the client renders:
|
|
73
|
+
* `{ current, latest, available, checkedAt, error }`.
|
|
74
|
+
*
|
|
75
|
+
* `available` is only true for a well-formed, strictly newer non-errored answer;
|
|
76
|
+
* the check never invents a version and an upstream failure keeps `latest: null`.
|
|
77
|
+
*/
|
|
78
|
+
export function buildUpdateInfo(current, latest, checkedAt, error = null) {
|
|
79
|
+
const latestVersion = typeof latest === 'string' && parseVersion(latest) !== null ? latest.trim() : null
|
|
80
|
+
return {
|
|
81
|
+
current: typeof current === 'string' ? current : '',
|
|
82
|
+
latest: latestVersion,
|
|
83
|
+
available: error === null && latestVersion !== null && isNewerVersion(latestVersion, current),
|
|
84
|
+
checkedAt: typeof checkedAt === 'string' && checkedAt !== '' ? checkedAt : new Date().toISOString(),
|
|
85
|
+
error: error === null || error === undefined ? null : error,
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tokenlens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
|
|
6
6
|
"license": "MIT",
|