@eddyskywalker/dsh-chatgpt-subscription 0.36.0 → 0.36.2

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.36.2 - 2026-10-22
6
+ - **[claude] 设置卡不再拿一枚已死的 access token 请求上游:status / 额度 / 测试连接 / 目录刷新统一走号池续期,托管账号闲置一夜后不再诱导用户重新登录(#66)**
7
+ - **现象**:Claude 订阅的 access token 只有约 8 小时寿命。卡片上的「令牌到期」、额度读取与「测试连接」在托管(managed)账号闲置一夜后全部出错——到期时间显示为过去时间、额度区报错、连接测试失败,看起来像「登录每天失效」,用户于是每天重新登录一次;而重登只是把到期时间刷成新的 8 小时,第二天照旧。
8
+ - **根因**:续期只发生在模型请求路径(`adapter` → `pool.getEffectiveAccount()` → `freshAccount()`,由 `claudeNeedsRefresh()` 在到期前 5 分钟内触发)。`/claude/api` 的 `status`、`quota`、`connection/test`、`catalog/refresh` 则直接用 `activeAccount()` 从号池读出的**存储值**请求上游,从不续期:卡片永远只看到「最后一枚被存下来的 access token」,8 小时后必然过期。相邻的 Kimi Code 线路早有 `effectiveCredentials()` 解决此事,Claude 线路缺这一段。
9
+ - **改法**:`registerClaudeRoutes()` 新增与 Kimi 同形的 `effectiveCredentials(target)` —— 无 pool 或无 `target.id` 时返回存储凭据,否则经 `pool.getFreshCredential(target.id, fetchFn)` 续期,续期失败时回落存储凭据(卡片仍能渲染账号列表,真实原因由那次请求报告)。四条卡片路径统一改用它;`status` 先续期、再读额度、再构建响应,所以 `accounts[].expiresAt`(读自号池行,续期会写回)就是续期后的新值。`readStatus()` 增加可选第二参,让额度读取、catalog 加载与 `accounts[]` 描述同一枚已消费的凭据。
10
+ - **不变量**:adopted(Claude Code 快照)仍然永不刷新——号池对 adopted 恒不续期、并在发网络请求前就拒绝,正好落进上面的回落分支,卡片只展示快照自身的 `authStatus`;只有续期被上游**拒绝**(`refreshFailureStatus` 仅对 `ClaudeUnauthorizedError` 写入)时,账号行才显示「需重新登录」,临时网络失败不会把账号标死。单飞由号池内核 `freshAccount()` 负责,同一枚 refresh token 不会被花两次,路由层没有另做互斥。
11
+ - **测试**:`test/claude-routes.test.ts` 新增 `describe('credential renewal on the card paths')`(6 例,注入假 refresh seam 与记录 `Authorization` 的 fetch double,全程无网络)——`GET /status` 恰好续期 1 次且池内 `expiresAt` 变未来、不写 `authStatus`、usage 请求带新 bearer、`accounts[].expiresAt` 与池内一致、`quotaError: null`;`POST /quota` 同理;`connection/test` 与 `catalog/refresh` 分别以新 bearer 请求 `/v1/messages` 与 `/v1/models`;过期 adopted 快照刷新 0 次且仍渲染 `authStatus='expired'`;卡片与模型请求并发、两次卡片轮询并发都只续期一次。反向验证:把 `routes.ts` 退回基线后 6 条中 5 条失败(adopted 那条按设计通过)。
12
+ - **验证**:`npm run typecheck` 三个工程 0 错误;全量 `npm test` 275 文件通过 / 1 跳过,4477 passed / 7 skipped / 0 failed;`npm run build` 通过。
13
+ - **已知边界**:卡片路径的续期与模型请求共用同一续期入口,因此一次续期失败(例如离线)会让下一次状态轮询再次尝试续期;这与模型请求路径的既有行为一致,未在路由层另加冷却。
14
+ ## 0.36.1 - 2026-10-22
15
+ - **[cline-pass] 额度不再只在手动刷新时动:状态读取按 TTL 在答背后刷新,设置卡与输入框徽标 60 秒自动更新**
16
+ - **现象**:Cline Pass 设置页打开后额度数字就定住了——同一次会话里消耗掉的部分,要重新打开页面或点一次「刷新额度」才会反映;输入框旁的额度徽标同样停在首次读到的数字。
17
+ - **根因**:两层各缺一半。宿主侧 `GET /cline-pass/api/status` 只答 `store.usage()` 的内存快照,只有显式 `POST /usage` 才真正向上游读(模块注释还写着「读取不触发上游调用」);客户端侧设置页只在挂载时读一次 status,而共享徽标虽然自带 60 秒轮询,cline 这一行的 `readStatus` 却把 `refresh` 参数丢掉了,点击于是退化成又一次廉价 GET。
18
+ - **改法(宿主)**:新增 `CLINE_PASS_QUOTA_TTL_MS`(5 分钟)与 `usageStale()`(`fetchedAt <= 0` 同样算过期);`GET /status` 先答缓存,快照缺失或过期时用 `QuotaRefresh` 在答背后起一轮刷新,并把 `quotaRefreshing` 放进状态 DTO,让客户端知道该立刻补读;扇出逻辑抽成 `refreshUsage()`,显式 `POST /usage` 与后台刷新共用同一份实现,同源校验、400 文案、凭据零外泄全部保持不变。单飞由 `start()` 的 `pending` 保证:N 次轮询只扇出一轮。另给路由选项加了 `quotaTtlMs` 测试缝。
19
+ - **改法(客户端)**:`ClinePassApi.status(refresh)` 分流——`false` 走 `GET /status`,`true` 走 `POST /usage` 并只取其中的 status 半边;设置页补上 60 秒可见轮询 + `visibilitychange` + `createQuotaFollowUp`(读到 `quotaRefreshing: true` 就 1.5 秒后补读,最多 3 次);轮询是静默的——失败既不刷红字也不清掉用户正在看的红字,半输入的草稿由既有两道闸保护;徽标把 `refresh` 转发出去,点击才真正强制刷新。
20
+ - **测试**:新增 `test/cline-pass-quota-poll.test.ts`(9 例)与 `test/cline-pass-quota-poll.test.tsx`(10 例)——冷缓存即排刷新并回 `quotaRefreshing`、新鲜快照 0 次上游调用、假 Date 拨过 TTL 后缓存照答而背后替换、2 账号 3 次轮询只扇出 2 次、显式动作立即刷新且 400/403 原样、徽标点击走 POST /usage、隐藏标签页不读、轮询失败静默、卸载清理定时器与监听。
21
+ - **验证**:`npm run typecheck` 三工程 0 错误;`npm test` 274 文件 / 4470 例通过(1 条 `minimax-code-video-history` 的 Windows `rename EPERM` 抖动,单跑 22/22 通过,与本改动无关);`npm run build` 通过。
22
+ - **已知边界**:`QuotaRefresh.run()` 不与正在跑的 `start()` 合流,所以「手动刷新正好撞上后台刷新」这一步仍会多扇出一轮;两轮写入形状相同、后写的 `setUsage` 生效,代价是一次多余上游请求。真正合流要给共享 helper 加 `wait`,会牵动另外六条线路,本次不做,代码里按此写明。
5
23
  ## 0.36.0 - 2026-10-22
6
24
  - **[client] 修复俄语语言包与 `@linxin666/dsh-i18n` 等已安装语言包冲突导致插件客户端激活失败崩溃的问题**
7
25
  - **现象**:Web 界面启动失败,提示 `web boot: 1 entry did not activate`,`@eddyskywalker/dsh-chatgpt-subscription: failed`。
package/lib/client.js CHANGED
@@ -12801,14 +12801,37 @@ window.__ModuleLoader__.load({
12801
12801
  //#region src/client/cline-pass/api.ts
12802
12802
  const api$7 = createLineApi(CLINE_PASS_ROUTE_PREFIX, "Cline Pass");
12803
12803
  /**
12804
+ * Whether the host is refreshing the quota behind this answer.
12805
+ *
12806
+ * The field is optional by contract, because a host built before it existed
12807
+ * answers without it. Every caller only ever compares it to `true`, so "absent"
12808
+ * and "false" read the same — nothing is in flight — and a stale host degrades to
12809
+ * plain polling instead of crashing. Kept as one reader so that convention lives
12810
+ * in a single place rather than being re-derived at each call site.
12811
+ */
12812
+ function quotaRefreshingOf(status) {
12813
+ return status.quotaRefreshing === true;
12814
+ }
12815
+ /**
12804
12816
  * The Cline Pass settings API, as the card uses it.
12805
12817
  *
12806
12818
  * Deliberately the same envelope and the same fetch shape every other provider
12807
12819
  * section uses, so the shared account-pool card and this helper read alike.
12808
12820
  */
12809
12821
  var ClinePassApi = class {
12810
- status() {
12811
- return api$7.get("/status");
12822
+ /**
12823
+ * One status read.
12824
+ *
12825
+ * A plain call is the GET the first paint, the 60 s poll and the composer
12826
+ * badge use: it is cheap and never fans out one upstream request per account.
12827
+ * `refresh` is the forced path — the same POST /usage the card's own button
12828
+ * drives — and the host answers it with `{ usage, status }`; only the status
12829
+ * half is returned, because every caller renders a status card rather than the
12830
+ * raw quota payload.
12831
+ */
12832
+ status(refresh = false) {
12833
+ if (!refresh) return api$7.get("/status");
12834
+ return api$7.post("/usage", {}).then((outcome) => outcome.status);
12812
12835
  }
12813
12836
  updateSettings(patch) {
12814
12837
  return api$7.post("/settings", patch).then(() => this.status());
@@ -13186,6 +13209,14 @@ window.__ModuleLoader__.load({
13186
13209
  //#endregion
13187
13210
  //#region src/client/cline-pass/ClinePassSection.tsx
13188
13211
  const API$3 = new ClinePassApi();
13212
+ /**
13213
+ * How often an open card re-reads the quota.
13214
+ *
13215
+ * The same 60 s every other line's card and composer badge uses: a window that
13216
+ * moves on a multi-hour scale does not need a faster poll, and a slower one
13217
+ * would leave a card visibly stale after the user spends a request.
13218
+ */
13219
+ const QUOTA_POLL_MS = 6e4;
13189
13220
  function detail$1(error) {
13190
13221
  return error instanceof Error ? error.message : String(error);
13191
13222
  }
@@ -13231,19 +13262,46 @@ window.__ModuleLoader__.load({
13231
13262
  const [settingsForm, setSettingsForm] = (0, react.useState)(null);
13232
13263
  const [history, setHistory] = (0, react.useState)(null);
13233
13264
  const [loginFlow, setLoginFlow] = (0, react.useState)(null);
13234
- const reload = (0, react.useCallback)(async () => {
13265
+ /** Follow-up poll owed while the host refreshes the quota behind an answer. */
13266
+ const quotaFollowUp = (0, react.useRef)(null);
13267
+ /**
13268
+ * One status read; `quiet` is every read nobody pressed a button for.
13269
+ *
13270
+ * A quiet read is silent on purpose: it never paints an error and never clears
13271
+ * one, so a poll that fails while a user reads the page, or types into a
13272
+ * settings field, does not change what is on screen. The manual actions below
13273
+ * still report their own failures loudly. The draft guards inside the two
13274
+ * setters are what keep a background answer from overwriting typed text.
13275
+ */
13276
+ const loadStatus = (0, react.useCallback)(async (quiet = false) => {
13235
13277
  try {
13236
13278
  const next = await API$3.status();
13237
13279
  setStatus(next);
13238
13280
  setSettingsForm((current) => current ?? (next.settings === void 0 ? null : settingsFormOf$1(next)));
13239
- setError(null);
13281
+ if (!quiet) setError(null);
13282
+ quotaFollowUp.current ??= createQuotaFollowUp();
13283
+ quotaFollowUp.current.observe(quotaRefreshingOf(next), () => {
13284
+ loadStatus(true);
13285
+ });
13240
13286
  } catch (cause) {
13241
- setError(detail$1(cause));
13287
+ if (!quiet) setError(detail$1(cause));
13242
13288
  }
13243
13289
  }, []);
13244
13290
  (0, react.useEffect)(() => {
13245
- reload();
13246
- }, [reload]);
13291
+ loadStatus();
13292
+ }, [loadStatus]);
13293
+ (0, react.useEffect)(() => {
13294
+ const refreshWhenVisible = () => {
13295
+ if (document.visibilityState === "visible") loadStatus(true);
13296
+ };
13297
+ document.addEventListener("visibilitychange", refreshWhenVisible);
13298
+ const timer = window.setInterval(refreshWhenVisible, QUOTA_POLL_MS);
13299
+ return () => {
13300
+ window.clearInterval(timer);
13301
+ document.removeEventListener("visibilitychange", refreshWhenVisible);
13302
+ quotaFollowUp.current?.cancel();
13303
+ };
13304
+ }, [loadStatus]);
13247
13305
  (0, react.useEffect)(() => {
13248
13306
  if (status === null) return;
13249
13307
  setDrafts((current) => {
@@ -13271,12 +13329,12 @@ window.__ModuleLoader__.load({
13271
13329
  if (next.status === "complete") {
13272
13330
  setNotice(zh$4.loginComplete.replace("{account}", next.accountAlias === "" ? "Cline Pass" : next.accountAlias));
13273
13331
  setShowKeyForm(false);
13274
- reload();
13332
+ loadStatus();
13275
13333
  } else if (next.status === "failed") setError(zh$4.loginFailed.replace("{detail}", next.error));
13276
13334
  }).catch((cause) => setError(detail$1(cause)));
13277
13335
  }, Math.max(2, flow.intervalSeconds) * 1e3);
13278
13336
  return () => window.clearInterval(timer);
13279
- }, [loginFlow, reload]);
13337
+ }, [loginFlow, loadStatus]);
13280
13338
  const startLogin = (0, react.useCallback)(() => {
13281
13339
  setBusy("login");
13282
13340
  setError(null);
@@ -21467,8 +21525,11 @@ window.__ModuleLoader__.load({
21467
21525
  *
21468
21526
  * Quota follows the ACCOUNT, and a pool has several. The badge therefore shows
21469
21527
  * the most-consumed window across the accounts the host has already read, which
21470
- * is the one that will stop the next request first; it never triggers a quota
21471
- * read of its own, so a poll cannot fan out one upstream call per account.
21528
+ * is the one that will stop the next request first. Its own 60 s poll is a plain
21529
+ * read, so a poll never fans out one upstream call per account; only an explicit
21530
+ * click on the badge asks the host for the forced refresh, the same POST the
21531
+ * card's own "refresh usage" button drives — which is exactly what the shared
21532
+ * badge's `refresh` argument is for, and what this line used to drop.
21472
21533
  */
21473
21534
  function ClinePassComposerQuota(props) {
21474
21535
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposerQuotaBadge, {
@@ -21476,7 +21537,7 @@ window.__ModuleLoader__.load({
21476
21537
  loadModelDirectory: props.loadModelDirectory,
21477
21538
  providerId: "cline-pass",
21478
21539
  label: zh$4.composerLabel,
21479
- readStatus: () => API$1.status(),
21540
+ readStatus: (refresh) => API$1.status(refresh),
21480
21541
  selectFacts: selectClinePassFacts
21481
21542
  });
21482
21543
  }