dsh-connect-doubao 0.3.1 → 0.4.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 CHANGED
@@ -4,6 +4,103 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## 0.4.0 — 2026-11-05
8
+
9
+ > 稳定性与额度准确性批次:让「回答」在中断时说实话,让「停止」真的停下上游,让凭据不再经过
10
+ > 子进程环境变量,让设置卡片不再对任何本机页面敞开;额度读数改成看「还剩多少」,并把一直在
11
+ > 扣的活动赠送额度显示出来;卡片重做了一遍视觉。全部改动都带回归测试(新增 36 例,全仓
12
+ > 187 例)。
13
+
14
+ ### 修复
15
+
16
+ - **断流被当成完整回答** —— 豆包服务端提前断开连接时,插件只把缓冲区里已收到的那半截
17
+ 文本当结果返回,并照样写 `finish_reason: "stop"` 和 `[DONE]`。用户看到的是一个语法
18
+ 通顺、语义中断的回答,没有任何迹象表明它被截断了。现在以 `SSE_REPLY_END` 作为唯一
19
+ 的完成依据:连接先断而该帧未到,就报「回答不完整(已收到 N 字符)」而不是假装成功。
20
+ 特意**不可重试** —— 重试会新开一条线程,把这条线程上的上下文丢掉,比重打一次更糟。
21
+ - **停止按钮停不下上游** —— 客户端断开后没有任何东西通知豆包那边停止生成,那一轮会一直
22
+ 跑到结束,额度照扣,答案没人看。现在响应关闭即 `abort()`,并顺着 `fetch` 传到 socket。
23
+ 顺带补上空闲看门狗:`DOUBAO_STREAM_IDLE_TIMEOUT_MS`(300 秒)此前定义了却从没被使用,
24
+ 流静默这么久会主动中止,而不是把请求永远挂着。
25
+ - **所有认不出的失败都被说成网络问题** —— 分类器对未知错误一律 `network`,shim 又把它
26
+ 标成可重试的 `TIMEOUT`,于是 400、鉴权失败、插件内部异常都会触发宿主的整套重试,
27
+ 最后给出一句「检查网络」。现在按 HTTP 状态与失败来源分类(401/403 → 会话失效、
28
+ 429 → 额度耗尽、4xx → 上游错误不可重试、5xx 与连接失败 → 可重试、其余 → 上游错误),
29
+ 只有真正的传输失败才会被建议重试,错误信封里也带上 `x-should-retry`。
30
+ - **深度思考内容永远为空** —— 帧处理层把 `onThinking` 回调漏掉了(shim 侧的
31
+ `reasoning_content` 分支早就写好了),开启深度思考时只能看到答案、看不到推理过程。
32
+ - **凭据明文进了子进程环境变量** —— DPAPI 调用把整个会话以 base64 塞进
33
+ `DSH_DOUBAO_DPAPI_BLOB` 交给 PowerShell,存活了 `Add-Type` 编译那 0.5–1 秒;
34
+ `...process.env` 还把本机所有 API key 一起带了过去。现在改为 stdin 传入、环境变量走
35
+ 白名单,`powershell.exe` 也从 `System32` 绝对路径启动而不是靠 PATH 查找。
36
+ - **一个坏 profile 会让后面所有候选都不再尝试** —— 候选遍历里 `decryptFromProfile` 没有
37
+ 自己的 try/catch,某个目录的 `Local State` 损坏会直接冒到外层,把 D 盘上真正可用的
38
+ profile 全跳过。现在逐个隔离,失败信息里带上具体目录。
39
+
40
+ - **一次会话失效要等十二小时才自愈** —— 加密的凭据副本在磁盘上缓存 12 小时,服务端
41
+ 已经判定失效的 session 会被反复读回来,每次请求都以同一种方式失败;用户按提示重新
42
+ 登录,插件仍然拿旧副本,直到 TTL 到期。现在服务端说会话失效就删掉盘上副本,让重新
43
+ 登录立刻生效。顺带挡住时钟回拨:缓存时间在未来(改过系统时间、双系统、主板电池没电)
44
+ 一律视为过期,否则 TTL 永远算不出一个正数。
45
+ - **额度接口不可用时每一轮对话都要多等一次失败** —— 额度是对话前的前置检查,而失败的
46
+ 结果从来不进缓存,于是接口挂掉的每一轮都要再付一次注定失败的往返,正好压在首字节
47
+ 之前。失败结果现在缓存 30 秒:够短,恢复延迟只有一轮;够长,能把这种反复打穿。
48
+ - **设置卡片对本机任何网页敞开** —— 四条路由只校验 Origin 是不是 loopback,而浏览器
49
+ 在**同源**请求里根本不发 Origin 头,于是「没有 Origin」被当成可信 —— 本机另一个端口
50
+ 上的页面(或任何能解析到 127.0.0.1 的页面)都能改掉模型开关和数据目录。现在 Host
51
+ 必须是 loopback(挡住把域名解析到 127.0.0.1 的 DNS rebinding),带
52
+ `sec-fetch-site: cross-site` 的请求直接拒绝,写请求还必须带一个自定义头 —— 自定义头
53
+ 会触发 CORS 预检,而这几条路由从不应答预检,跨源页面因此发不出这个请求。同源正常
54
+ 使用完全不受影响。
55
+ - **声明了类型声明文件,但那文件从来不存在** —— `package.json` 的 `types` 指向
56
+ `lib/index.d.ts`,构建却关着 `dts`,任何按类型使用这个包的人都会碰到一个空引用。
57
+ 现在真的生成声明文件(`lib/index.d.ts` 等 5 个),`types` 与 `exports` 两条路径
58
+ 都对得上。同时把 `lib/` 移出版本管理:tsdown 用内容哈希给共享 chunk 命名,每构建
59
+ 一次整批文件就改名,diff 里看不出任何真实改动;发布不受影响(`prepack` 会构建,
60
+ `files` 从 tarball 里取 `lib`)。
61
+
62
+ - **额度卡片的「重置时间」是编出来的** —— 这台账号的套餐只返回一个周额度,且窗口的
63
+ `start_time`/`end_time` 都是 0(服务端不给时间戳)。旧代码于是拿「现在」当窗口起点、
64
+ 加上一个名义周期当重置时刻,卡片显示「6 天后重置」—— 真实重置可能就在二十分钟后。
65
+ 同一份载荷里还带着 `total_amount: "2,100"` / `used_amount: "0"` 这种带千分位的金额,
66
+ 旧代码完全没读,一旦 `used_percent` 缺失就只能显示 0%。现在:窗口还没开始计时就照实
67
+ 说「开始使用后计时」(豆包客户端本身就是这个措辞),有时间戳就显示倒计时,两者都没
68
+ 有才说「重置时间未知」;金额解析后作为百分比的兜底来源。顺带修掉 `doctor` 把「这个
69
+ 套餐没有 5 小时窗口」判成失败的误报 —— 账号形态不是插件故障,只有额度真正读不出来
70
+ 才算失败。
71
+ - **真正在消耗的那份额度没被显示** —— 额度接口返回三个 section,旧代码只读
72
+ `window_limit_section`。这台账号的「活动赠送额度」(`incentive_quota_section`,
73
+ 已用 782 / 1,600、11 月 21 日 09:46 到期)因此完全不出现在卡片上:用户看到「近 7 天
74
+ 已用 0%」,以为额度一点没动,实际上已经在扣赠送额度了。现在两个 section 一起读,
75
+ 赠送额度单独一行、用「已用 782 / 1,600 · 16d 后到期」这种带金额的写法,排在常规窗口
76
+ 前面。
77
+
78
+ ### 新增
79
+ - **卡片改成看「还剩多少」,并自己走动** —— 进度条过去画的是消耗比例(越用越满),
80
+ 与「还剩多少」的直觉相反;现在画剩余量,用完即为空,颜色阈值也按剩余量给。数字从
81
+ 「已用 782 / 1,600」改成「剩 818 / 1,600」,倒计时由本地时钟每秒从绝对时刻重算 ——
82
+ 既不用每秒请求,也不会因为标签页放久了而漂移。轮询从 5 分钟缩到 30 秒,且只在卡片
83
+ 可见时进行,切回标签页立刻刷一次;轮询走 POST 绕过宿主 5 分钟缓存,否则每 30 秒
84
+ 拿回的都是同一份读数。
85
+ - **卡片重做了一遍视觉** —— 原来五个分区的高度、字号、边框几乎一样,眼睛找不到落点。
86
+ 现在整张卡是一片带极淡高光的表面,分区之间用一条发丝线分隔而不是各自套框;每个额度
87
+ 窗口是一块独立的面板,一行的重点只有一个:右边那个「还剩下多少」。倒计时和「已用
88
+ X / Y」退到第二行,不再和主数字抢宽度。进度条改成带方向感的渐变并带一点辉光,数字
89
+ 改用等宽字形 —— 每秒跳动的倒计时不会再让整行左右抖。
90
+
91
+ ### 测试
92
+ - `tests/upstream-terminal.test.ts`(12 例):终止帧、跨 chunk 拆帧、截断判定、思考透传、
93
+ 失败分类与重试矩阵。
94
+ - `tests/shim-lifecycle.test.ts`(5 例):客户端断开、正常结束后关闭、空闲超时、活动续命、
95
+ 释放后不再触发。
96
+ - `tests/auth-dpapi.test.ts`(4 例):源码级守住「明文不进 env」「不走 PATH」两条约束。
97
+ - `tests/save-route.test.ts`(+4 例):缺写入头、非 loopback Host、跨站标记三种拒绝路径,
98
+ 以及「同源 GET 不带 Origin 仍然放行」这条必须保持可用的例外。
99
+ - `tests/quota.test.ts`(8 例):用真实载荷做固定数据 —— 只有周额度、时间戳为 0、
100
+ 另含「活动赠送额度」组(782 / 1,600、只有到期时间、window_type 3);覆盖「不伪造
101
+ 重置时刻」「未开始计时标注 startsOnUse」「赠送额度只报到期」「金额推算百分比」
102
+ 「真实时间戳原样透传」五条契约。
103
+
7
104
  ## 0.3.1 — 2026-11-02
8
105
 
9
106
  > 相对 `0.3.0` 的 3 个提交。修「User Data 不在 C 盘就读不到登录信息」,并补上手动指定目录的
package/lib/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,18 @@
1
+ import { T as classifyDoubaoFailure, f as DoubaoUpstreamError } from "./doubao-upstream-BdIak5wD.js";
2
+ //#region src/cli-doctor.d.ts
3
+ type CheckStatus = 'ok' | 'fail' | 'skip';
4
+ interface DoctorCheck {
5
+ name: string;
6
+ status: CheckStatus;
7
+ detail: string;
8
+ data?: Record<string, unknown>;
9
+ }
10
+ interface DoctorReport {
11
+ ok: boolean;
12
+ plugin: string;
13
+ checks: DoctorCheck[];
14
+ }
15
+ /** Run every check. Never throws: a failed check is data, not a crash. */
16
+ declare function runDoctor(): Promise<DoctorReport>;
17
+ //#endregion
18
+ export { CheckStatus, DoctorCheck, DoctorReport, DoubaoUpstreamError, classifyDoubaoFailure, runDoctor };
package/lib/cli-doctor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { A as probeAccount, B as PROFILE_ENV, F as REQUIRED_COOKIE_NAMES, V as describeProfileLocation, W as listDoubaoProfiles, at as DOUBAO_PROVIDER, ht as discoverDoubaoModels, it as DOUBAO_MODELS, m as DoubaoUpstreamError, n as fetchQuota, ot as DOUBAO_PROVIDER_DISPLAY_NAME, r as findWindow, w as classifyDoubaoFailure, z as loadDoubaoCredentials } from "./doubao-quota-SO5U9prN.js";
3
- import { g as createDoubaoAdapter, i as doubaoQuotaView, m as createDoubaoShim, n as DOUBAO_QUOTA_PATH, p as liveModels, u as discoverAllCachedModels } from "./doubao-status-BAUM_fsV.js";
2
+ import { H as PROFILE_ENV, I as REQUIRED_COOKIE_NAMES, K as listDoubaoProfiles, U as describeProfileLocation, V as loadDoubaoCredentials, _t as discoverDoubaoModels, ct as DOUBAO_PROVIDER_DISPLAY_NAME, i as formatDuration, j as probeAccount, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, ot as DOUBAO_MODELS, r as findWindow, st as DOUBAO_PROVIDER, w as classifyDoubaoFailure } from "./doubao-quota-Do37dMMj.js";
3
+ import { g as createDoubaoAdapter, i as doubaoQuotaView, m as createDoubaoShim, n as DOUBAO_QUOTA_PATH, p as liveModels, u as discoverAllCachedModels } from "./doubao-status-CeXUM4Cy.js";
4
4
  //#region src/cli-doctor.ts
5
5
  /**
6
6
  * `doctor` — verify the whole stack end to end without spending inference quota.
@@ -124,18 +124,33 @@ async function runDoctor() {
124
124
  });
125
125
  else {
126
126
  const quota = await fetchQuota({ force: true });
127
+ const describeWindow = (window) => {
128
+ if (window === void 0) return "not offered";
129
+ if (window.startsOnUse === true && window.resetUnknown === true) return `${window.usedPercent}% used, starts counting on first use`;
130
+ if (window.resetUnknown === true) return `${window.usedPercent}% used, reset time unknown`;
131
+ return `${window.usedPercent}% used, ${formatDuration(millisUntilReset(window))} to reset`;
132
+ };
133
+ const describeGrant = (window) => {
134
+ if (window === void 0) return "none";
135
+ const amounts = window.usedAmount !== void 0 && window.totalAmount !== void 0 ? `${window.usedAmount}/${window.totalAmount}` : `${window.usedPercent}%`;
136
+ return `${window.label ?? "bonus"} ${amounts}, ${formatDuration(millisUntilReset(window))} to expiry`;
137
+ };
127
138
  const fiveHour = findWindow(quota, "five_hour");
128
139
  const weekly = findWindow(quota, "weekly");
140
+ const grant = findWindow(quota, "incentive");
129
141
  const view = await doubaoQuotaView();
130
142
  checks.push({
131
143
  name: "quota",
132
- status: quota.error === void 0 && fiveHour !== void 0 && weekly !== void 0 ? "ok" : "fail",
133
- detail: quota.error ?? `${quota.plan ?? "unknown plan"}; 5-hour=${fiveHour?.usedPercent ?? "n/a"}% weekly=${weekly?.usedPercent ?? "n/a"}%`,
144
+ status: quota.error === void 0 ? "ok" : "fail",
145
+ detail: quota.error ?? `${quota.plan ?? "unknown plan"}; 5-hour=${describeWindow(fiveHour)}; weekly=${describeWindow(weekly)}; bonus=${describeGrant(grant)}`,
134
146
  data: {
135
147
  plan: quota.plan ?? null,
136
148
  exhausted: quota.exhausted,
137
149
  fiveHourPercent: fiveHour?.usedPercent ?? null,
138
150
  weeklyPercent: weekly?.usedPercent ?? null,
151
+ grantPercent: grant?.usedPercent ?? null,
152
+ grantUsedAmount: grant?.usedAmount ?? null,
153
+ grantTotalAmount: grant?.totalAmount ?? null,
139
154
  viewHasNoSecrets: !JSON.stringify(view).match(/sessionid|sid_tt|msToken|odin_tt|ttwid|passport_csrf/i)
140
155
  }
141
156
  });
@@ -0,0 +1,4 @@
1
+ //#region src/cli-probe.d.ts
2
+ declare function main(): Promise<number>;
3
+ //#endregion
4
+ export { main };
package/lib/cli-probe.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as probeAccount, V as describeProfileLocation, i as formatDuration, it as DOUBAO_MODELS, j as streamChat, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, r as findWindow, vt as findDoubaoModel, yt as findReasoningLevel, z as loadDoubaoCredentials } from "./doubao-quota-SO5U9prN.js";
2
+ import { M as streamChat, U as describeProfileLocation, V as loadDoubaoCredentials, bt as findDoubaoModel, i as formatDuration, j as probeAccount, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, ot as DOUBAO_MODELS, r as findWindow, xt as findReasoningLevel } from "./doubao-quota-Do37dMMj.js";
3
3
  //#region src/cli-probe.ts
4
4
  /**
5
5
  * Doubao smoke probe.
@@ -79,8 +79,20 @@ async function main() {
79
79
  return 3;
80
80
  }
81
81
  const quota = await fetchQuota({ force: true });
82
+ const describeWindow = (window) => {
83
+ if (window === void 0) return "not offered by this plan";
84
+ if (window.startsOnUse === true && window.resetUnknown === true) return `${window.usedPercent}% used, starts counting on first use`;
85
+ if (window.resetUnknown === true) return `${window.usedPercent}% used, reset time unknown`;
86
+ return `${window.usedPercent}% used, ${formatDuration(millisUntilReset(window))} to reset`;
87
+ };
88
+ const describeGrant = (window) => {
89
+ if (window === void 0) return "none";
90
+ const amounts = window.usedAmount !== void 0 && window.totalAmount !== void 0 ? `${window.usedAmount} / ${window.totalAmount}` : `${window.usedPercent}%`;
91
+ return `${window.label ?? "bonus"}: ${amounts} used, ${formatDuration(millisUntilReset(window))} to expiry`;
92
+ };
82
93
  const fiveHour = findWindow(quota, "five_hour");
83
94
  const weekly = findWindow(quota, "weekly");
95
+ const grant = findWindow(quota, "incentive");
84
96
  out(options.json ? {
85
97
  event: "quota",
86
98
  plan: quota.plan ?? null,
@@ -91,6 +103,13 @@ async function main() {
91
103
  remainingPercent: fiveHour.remainingPercent,
92
104
  resetsIn: formatDuration(millisUntilReset(fiveHour))
93
105
  } : null,
106
+ grant: grant ? {
107
+ label: grant.label ?? null,
108
+ usedPercent: grant.usedPercent,
109
+ usedAmount: grant.usedAmount ?? null,
110
+ totalAmount: grant.totalAmount ?? null,
111
+ resetsIn: formatDuration(millisUntilReset(grant))
112
+ } : null,
94
113
  weekly: weekly ? {
95
114
  usedPercent: weekly.usedPercent,
96
115
  remainingPercent: weekly.remainingPercent,
@@ -99,8 +118,9 @@ async function main() {
99
118
  error: quota.error ?? null
100
119
  } : [
101
120
  `quota: ${quota.plan ?? "unknown plan"}${quota.planActive === false ? " (inactive)" : ""}`,
102
- ` 5-hour limit : ${fiveHour ? `${fiveHour.usedPercent}% used, resets in ${formatDuration(millisUntilReset(fiveHour))}` : "n/a"}`,
103
- ` weekly quota : ${weekly ? `${weekly.usedPercent}% used, resets in ${formatDuration(millisUntilReset(weekly))}` : "n/a"}`
121
+ ` 5-hour limit : ${describeWindow(fiveHour)}`,
122
+ ` weekly quota : ${describeWindow(weekly)}`,
123
+ ` bonus quota : ${describeGrant(grant)}`
104
124
  ].join("\n"));
105
125
  if (quota.exhausted) {
106
126
  out(options.json ? {
package/lib/client.js CHANGED
@@ -120,62 +120,105 @@ window.__ModuleLoader__.load({
120
120
  * Colours are checked for contrast against their own background (all text
121
121
  * pairs are >= 4.5:1, the WCAG AA threshold for body text).
122
122
  *
123
+ * Visual hierarchy: one soft surface for the card, one raised panel per quota
124
+ * window, a hairline between sections, and exactly one loud element per row
125
+ * (the "what is left" figure). Numbers use tabular figures so a countdown
126
+ * ticking every second does not make the line jitter.
127
+ *
123
128
  * @module dsh-connect-doubao/client-styles
124
129
  */
125
130
  const DOUBAO_CARD_CSS = `
126
- .dbq-card { display: flex; flex-direction: column; gap: 14px; padding: 14px;
127
- border: 1px solid #2b2f36; border-radius: 10px; background: #16181d; color: #e8eaed;
128
- font-size: 13px; line-height: 1.5; }
129
- .dbq-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
130
- .dbq-title { font-weight: 600; font-size: 14px; }
131
+ .dbq-card { display: flex; flex-direction: column; gap: 16px; padding: 16px 18px;
132
+ border: 1px solid #262b33; border-radius: 14px; color: #e8eaed;
133
+ background:
134
+ radial-gradient(120% 150% at 0% 0%, rgba(74, 158, 255, .12), transparent 58%),
135
+ linear-gradient(180deg, #191c22 0%, #14161b 100%);
136
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04), 0 12px 30px -14px rgba(0, 0, 0, .8);
137
+ font-size: 13px; line-height: 1.55; font-variant-numeric: tabular-nums; }
138
+
139
+ .dbq-head { display: flex; align-items: baseline; justify-content: space-between;
140
+ gap: 12px; padding-bottom: 2px; }
141
+ .dbq-title { font-weight: 600; font-size: 15px; letter-spacing: .01em; }
131
142
  .dbq-sub { color: #9aa0a6; font-size: 12px; }
132
143
 
133
- .dbq-section { display: flex; flex-direction: column; gap: 8px; }
134
- .dbq-section-title { font-weight: 600; font-size: 13px; display: flex;
135
- align-items: baseline; justify-content: space-between; gap: 8px; }
136
- .dbq-count { color: #9aa0a6; font-size: 12px; font-weight: 400; }
144
+ .dbq-section { display: flex; flex-direction: column; gap: 10px; }
145
+ /* Hairline instead of a box per section: five bordered boxes read as a form,
146
+ one card with dividers reads as a panel. */
147
+ .dbq-section + .dbq-section { padding-top: 16px; border-top: 1px solid #21252c; }
148
+ .dbq-section-title { font-weight: 600; font-size: 12px; letter-spacing: .05em;
149
+ color: #b6bdc7; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
150
+ /* Counts and the plan name double as a pill, so they stop competing with the
151
+ section label for the same visual weight. */
152
+ .dbq-count { color: #a8bdd4; font-size: 11px; font-weight: 500; letter-spacing: 0;
153
+ background: #1e242c; border: 1px solid #2b323c; border-radius: 999px; padding: 1px 8px; }
137
154
  .dbq-note { color: #9aa0a6; font-size: 12px; }
138
155
 
139
- .dbq-row { display: flex; flex-direction: column; gap: 4px; }
140
- .dbq-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
141
- .dbq-label { font-weight: 500; }
142
- .dbq-meta { color: #9aa0a6; font-size: 12px; white-space: nowrap; }
156
+ /* One raised panel per window: the row is the unit of the quota area, so it
157
+ carries the surface instead of sitting bare on the card. */
158
+ .dbq-row { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px;
159
+ background: #13161b; border: 1px solid #20242b; border-radius: 10px;
160
+ transition: border-color .15s ease, background .15s ease; }
161
+ .dbq-row:hover { border-color: #2c333d; background: #151920; }
162
+ .dbq-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
163
+ .dbq-label { font-weight: 500; font-size: 12.5px; color: #dfe3e8; }
164
+ /* The loud element of the row: what is left, at a size the eye lands on first. */
165
+ .dbq-figure { font-size: 14px; font-weight: 600; color: #eef2f7; white-space: nowrap; }
166
+ .dbq-figure-sub { color: #9aa0a6; font-size: 12px; font-weight: 400; }
167
+ .dbq-row-foot { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
168
+ .dbq-meta { color: #9aa0a6; font-size: 11.5px; white-space: nowrap; }
143
169
  /* Candidate list: one path per line, and a long path must wrap instead of
144
170
  pushing the card wider than the settings panel. */
145
- .dbq-candidates { color: #9aa0a6; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
171
+ .dbq-candidates { color: #8b929b; font-size: 11.5px; white-space: pre-wrap; word-break: break-all;
172
+ background: #12151a; border: 1px solid #1e222a; border-radius: 8px; padding: 8px 10px; }
146
173
 
147
- .dbq-bar { height: 8px; border-radius: 999px; background: #2b2f36; overflow: hidden; }
148
- .dbq-fill { height: 100%; border-radius: 999px; background: #4a9eff; transition: width .25s ease; }
149
- .dbq-fill.dbq-warn { background: #f5a623; }
150
- .dbq-fill.dbq-full { background: #e5534b; }
174
+ .dbq-bar { height: 8px; border-radius: 999px; background: #1c2027; overflow: hidden;
175
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03); }
176
+ .dbq-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #3b82f6, #60a5fa);
177
+ box-shadow: 0 0 10px rgba(74, 158, 255, .4);
178
+ transition: width .5s cubic-bezier(.22, .61, .36, 1), background .2s ease; }
179
+ .dbq-fill.dbq-warn { background: linear-gradient(90deg, #d97706, #fbbf24);
180
+ box-shadow: 0 0 10px rgba(245, 166, 35, .38); }
181
+ .dbq-fill.dbq-full { background: linear-gradient(90deg, #dc2626, #ef4444);
182
+ box-shadow: 0 0 10px rgba(229, 83, 75, .42); }
151
183
 
152
- .dbq-state { display: flex; align-items: flex-start; gap: 6px; }
153
- .dbq-dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 5px; flex: none; background: #4a9eff; }
154
- .dbq-dot.dbq-warn { background: #f5a623; }
155
- .dbq-dot.dbq-bad { background: #e5534b; }
184
+ .dbq-state { display: flex; align-items: center; gap: 8px; }
185
+ .dbq-dot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: #4a9eff;
186
+ box-shadow: 0 0 0 3px rgba(74, 158, 255, .18); }
187
+ .dbq-dot.dbq-warn { background: #f5a623; box-shadow: 0 0 0 3px rgba(245, 166, 35, .18); }
188
+ .dbq-dot.dbq-bad { background: #e5534b; box-shadow: 0 0 0 3px rgba(229, 83, 75, .2); }
156
189
  .dbq-state-text { color: #e8eaed; }
157
- .dbq-profile { color: #9aa0a6; font-size: 11px; word-break: break-all; }
190
+ .dbq-profile { color: #8b929b; font-size: 11.5px; word-break: break-all; }
158
191
 
159
192
  .dbq-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
160
- .dbq-btn { appearance: none; border: 1px solid #3a3f47; background: #22252b; color: #e8eaed;
161
- border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer; }
162
- .dbq-btn:hover:not(:disabled) { background: #2b2f36; }
163
- .dbq-btn:disabled { opacity: .5; cursor: not-allowed; }
164
- .dbq-btn.dbq-primary { border-color: #4a9eff; color: #cfe4ff; }
165
- .dbq-btn.dbq-dirty { border-color: #f5a623; color: #ffd79a; }
193
+ .dbq-btn { appearance: none; border: 1px solid #333a44; background: #20242b; color: #e8eaed;
194
+ border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 500; cursor: pointer;
195
+ transition: background .15s ease, border-color .15s ease, transform .06s ease; }
196
+ .dbq-btn:hover:not(:disabled) { background: #272d36; border-color: #3f4956; }
197
+ .dbq-btn:active:not(:disabled) { transform: translateY(1px); }
198
+ .dbq-btn:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
199
+ .dbq-btn:disabled { opacity: .45; cursor: not-allowed; }
200
+ .dbq-btn.dbq-primary { border-color: #3b82f6; background: #2563eb; color: #fff; }
201
+ .dbq-btn.dbq-primary:hover:not(:disabled) { background: #2f6ff0; border-color: #60a5fa; }
202
+ .dbq-btn.dbq-dirty { border-color: #f5a623; background: #3a2c15; color: #ffd79a; }
166
203
  .dbq-dirty-flag { color: #f5a623; font-size: 12px; }
167
204
 
168
205
  .dbq-models { display: flex; flex-direction: column; gap: 6px; }
169
- .dbq-model { display: flex; align-items: flex-start; gap: 8px; padding: 6px 8px;
170
- border: 1px solid #262a30; border-radius: 6px; }
171
- .dbq-model input[type=checkbox] { margin-top: 3px; flex: none; }
206
+ .dbq-model { display: flex; align-items: flex-start; gap: 9px; padding: 7px 10px; cursor: pointer;
207
+ border: 1px solid #20242b; border-radius: 8px; background: #13161b;
208
+ transition: background .15s ease, border-color .15s ease; }
209
+ .dbq-model:hover { background: #161a20; border-color: #2b323c; }
210
+ .dbq-model input[type=checkbox] { margin: 2px 0 0; flex: none; accent-color: #4a9eff; }
172
211
  .dbq-model-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
173
- .dbq-model-name { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
174
- .dbq-badge { font-size: 11px; color: #9aa0a6; border: 1px solid #33383f;
175
- border-radius: 4px; padding: 0 4px; }
212
+ .dbq-model-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
213
+ .dbq-badge { font-size: 11px; color: #9fb2c8; background: #1e242c; border: 1px solid #2f3641;
214
+ border-radius: 999px; padding: 0 7px; }
176
215
  .dbq-model-desc { color: #9aa0a6; font-size: 11px; }
177
216
  .dbq-saved { color: #7fd18a; font-size: 12px; }
178
217
  .dbq-error { color: #ff9d99; font-size: 12px; }
218
+
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .dbq-fill, .dbq-btn, .dbq-row, .dbq-model { transition: none; }
221
+ }
179
222
  `;
180
223
  //#endregion
181
224
  //#region src/client/DoubaoQuotaCard.tsx
@@ -202,8 +245,17 @@ window.__ModuleLoader__.load({
202
245
  *
203
246
  * @module dsh-connect-doubao/client-card
204
247
  */
205
- /** How often the quota is re-read without the user asking. */
206
- const POLL_INTERVAL_MS = 3e5;
248
+ /**
249
+ * How often the quota is re-read while the card is on screen.
250
+ *
251
+ * The point of the card is "how much is left", and that number changes as the
252
+ * user works — a five-minute-old "782" is indistinguishable from a frozen
253
+ * panel. Thirty seconds costs one small summary call and only runs while the
254
+ * tab is visible.
255
+ */
256
+ const POLL_INTERVAL_MS = 3e4;
257
+ /** How often the local clock ticks, so countdowns move without any request. */
258
+ const TICK_MS = 1e3;
207
259
  /**
208
260
  * Fallback translator: renders the key itself.
209
261
  *
@@ -225,6 +277,15 @@ window.__ModuleLoader__.load({
225
277
  }, []);
226
278
  }
227
279
  /**
280
+ * Header every write carries.
281
+ *
282
+ * Not a secret: it makes the request non-simple, so a hostile page cannot send
283
+ * it without a CORS preflight — which these routes never approve. Without it the
284
+ * host rejects the write, which is exactly what should happen to a cross-site
285
+ * request that has no business flipping settings.
286
+ */
287
+ const WRITE_HEADERS = { "x-dsh-doubao-write": "1" };
288
+ /**
228
289
  * GET the quota view. Never throws: every failure becomes `undefined`, which
229
290
  * classifies as `unreadable` rather than unmounting the card.
230
291
  */
@@ -239,11 +300,15 @@ window.__ModuleLoader__.load({
239
300
  return await response.json();
240
301
  }
241
302
  /** POST forces a host-side cache bypass. Same error contract as {@link fetchQuota}. */
242
- async function refreshQuota() {
303
+ async function refreshQuota(signal) {
243
304
  const response = await fetch(DOUBAO_QUOTA_PATH, {
244
305
  method: "POST",
245
306
  credentials: "same-origin",
246
- headers: { accept: "application/json" }
307
+ ...signal === void 0 ? {} : { signal },
308
+ headers: {
309
+ accept: "application/json",
310
+ ...WRITE_HEADERS
311
+ }
247
312
  });
248
313
  if (!response.ok) return void 0;
249
314
  return await response.json();
@@ -259,9 +324,21 @@ window.__ModuleLoader__.load({
259
324
  if (!response.ok) return void 0;
260
325
  return await response.json();
261
326
  }
327
+ /**
328
+ * The trailing half of a bar's meta line: when the instant is known, when it
329
+ * is not, or why it is not.
330
+ *
331
+ * "Not started yet" is not the same statement as "unknown": Doubao opens a
332
+ * weekly window on first use, and saying 「开始使用后计时」 is the truth the app
333
+ * itself shows, while 「重置时间未知」 would read as a server problem.
334
+ */
335
+ function instantLabel(t, resetsInMs, kind) {
336
+ if (resetsInMs !== null && resetsInMs !== void 0) return kind === "expire" ? t("quota.expiresIn", { time: formatCountdown(resetsInMs) }) : t("quota.resetsIn", { time: formatCountdown(resetsInMs) });
337
+ return t("quota.resetUnknown");
338
+ }
262
339
  /** One quota bar: label, used percent, bar, reset countdown. */
263
340
  function QuotaBar(props) {
264
- const { label, usedPercent, resetsInMs, t, now } = props;
341
+ const { label, usedPercent, resetsAt, resetsInMs, startsOnUse, usedAmount, totalAmount, instantKind, t, now } = props;
265
342
  if (usedPercent === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
266
343
  className: "dbq-row",
267
344
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -275,8 +352,11 @@ window.__ModuleLoader__.load({
275
352
  })]
276
353
  })
277
354
  });
278
- const percent = clampPercent(usedPercent);
279
- const tone = percent >= 100 ? " dbq-full" : percent >= 80 ? " dbq-warn" : "";
355
+ const used = clampPercent(usedPercent);
356
+ const percent = clampPercent(100 - used);
357
+ const liveMs = (resetsAt !== null && resetsAt !== void 0 && resetsAt > 0 ? Math.max(0, resetsAt - now) : null) ?? resetsInMs;
358
+ const remainingAmount = totalAmount !== null && totalAmount !== void 0 && totalAmount > 0 ? Math.max(0, totalAmount - (usedAmount ?? 0)) : null;
359
+ const tone = percent <= 0 ? " dbq-full" : percent <= 20 ? " dbq-warn" : "";
280
360
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
281
361
  className: "dbq-row",
282
362
  children: [
@@ -285,19 +365,19 @@ window.__ModuleLoader__.load({
285
365
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
286
366
  className: "dbq-label",
287
367
  children: label
288
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
289
- className: "dbq-meta",
290
- children: [
291
- t("quota.used", { percent }),
292
- " · ",
293
- t("quota.resetsIn", { time: formatCountdown(resetsInMs ?? 0) })
294
- ]
368
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
369
+ className: "dbq-figure",
370
+ children: remainingAmount !== null ? t("quota.remainingAmount", {
371
+ remaining: remainingAmount,
372
+ total: totalAmount ?? 0
373
+ }) : t("quota.remainingPercent", { percent })
295
374
  })]
296
375
  }),
297
376
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
298
377
  className: "dbq-bar",
299
378
  role: "progressbar",
300
379
  "aria-valuenow": percent,
380
+ "aria-valuetext": t("quota.remainingPercent", { percent }),
301
381
  "aria-valuemin": 0,
302
382
  "aria-valuemax": 100,
303
383
  "aria-label": label,
@@ -306,6 +386,19 @@ window.__ModuleLoader__.load({
306
386
  style: { width: `${percent}%` }
307
387
  })
308
388
  }),
389
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
390
+ className: "dbq-row-foot",
391
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
392
+ className: "dbq-meta",
393
+ children: startsOnUse === true && (liveMs === null || liveMs === void 0) ? t("quota.startsOnUse") : instantLabel(t, liveMs, instantKind === "expire" ? "expire" : "reset")
394
+ }), remainingAmount !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
395
+ className: "dbq-figure-sub",
396
+ children: t("quota.usedAmounts", {
397
+ used: usedAmount ?? used,
398
+ total: totalAmount ?? 0
399
+ })
400
+ })]
401
+ }),
309
402
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
310
403
  className: "dbq-meta",
311
404
  hidden: true,
@@ -372,23 +465,36 @@ window.__ModuleLoader__.load({
372
465
  if (scope === void 0) return;
373
466
  return scope.subscribe(() => setSettingsRevision((value) => value + 1));
374
467
  }, [scope]);
468
+ const [now, setNow] = (0, react.useState)(() => Date.now());
469
+ (0, react.useEffect)(() => {
470
+ const timer = setInterval(() => setNow(Date.now()), TICK_MS);
471
+ return () => clearInterval(timer);
472
+ }, []);
375
473
  (0, react.useEffect)(() => {
376
474
  let alive = true;
377
475
  const controller = new AbortController();
378
- const load = async () => {
476
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
477
+ const load = async (force) => {
379
478
  try {
380
- const view = await fetchQuota(controller.signal);
479
+ const view = force ? await refreshQuota(controller.signal) : await fetchQuota(controller.signal);
381
480
  if (alive) setQuota(view);
382
481
  } catch {
383
482
  if (alive) setQuota(void 0);
384
483
  }
385
484
  };
386
- load();
387
- const timer = setInterval(() => void load(), POLL_INTERVAL_MS);
485
+ load(false);
486
+ const timer = setInterval(() => {
487
+ if (!hidden()) load(true);
488
+ }, POLL_INTERVAL_MS);
489
+ const onVisible = () => {
490
+ if (!hidden()) load(true);
491
+ };
492
+ document.addEventListener("visibilitychange", onVisible);
388
493
  return () => {
389
494
  alive = false;
390
495
  controller.abort();
391
496
  clearInterval(timer);
497
+ document.removeEventListener("visibilitychange", onVisible);
392
498
  };
393
499
  }, []);
394
500
  const onRefresh = (0, react.useCallback)(async () => {
@@ -488,7 +594,10 @@ window.__ModuleLoader__.load({
488
594
  try {
489
595
  response = await fetch(DOUBAO_SAVE_PATH, {
490
596
  method: "POST",
491
- headers: { "Content-Type": "application/json" },
597
+ headers: {
598
+ "Content-Type": "application/json",
599
+ ...WRITE_HEADERS
600
+ },
492
601
  body: JSON.stringify({
493
602
  field,
494
603
  value
@@ -649,19 +758,38 @@ window.__ModuleLoader__.load({
649
758
  children: quota?.plan ?? "—"
650
759
  })]
651
760
  }),
761
+ (quota?.windows.incentive ?? []).map((window, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaBar, {
762
+ label: window.label ?? t("quota.incentive"),
763
+ usedPercent: window.usedPercent,
764
+ resetsInMs: window.resetsInMs,
765
+ resetsAt: window.resetsAt,
766
+ usedAmount: window.usedAmount,
767
+ totalAmount: window.totalAmount,
768
+ instantKind: "expire",
769
+ t,
770
+ now
771
+ }, `incentive-${index}`)),
652
772
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaBar, {
653
773
  label: t("quota.fiveHour"),
654
774
  usedPercent: quota?.windows.fiveHour?.usedPercent,
655
775
  resetsInMs: quota?.windows.fiveHour?.resetsInMs,
776
+ resetsAt: quota?.windows.fiveHour?.resetsAt,
777
+ startsOnUse: quota?.windows.fiveHour?.startsOnUse,
778
+ usedAmount: quota?.windows.fiveHour?.usedAmount,
779
+ totalAmount: quota?.windows.fiveHour?.totalAmount,
656
780
  t,
657
- now: 0
781
+ now
658
782
  }),
659
783
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaBar, {
660
784
  label: t("quota.weekly"),
661
785
  usedPercent: quota?.windows.weekly?.usedPercent,
662
786
  resetsInMs: quota?.windows.weekly?.resetsInMs,
787
+ resetsAt: quota?.windows.weekly?.resetsAt,
788
+ startsOnUse: quota?.windows.weekly?.startsOnUse,
789
+ usedAmount: quota?.windows.weekly?.usedAmount,
790
+ totalAmount: quota?.windows.weekly?.totalAmount,
663
791
  t,
664
- now: 0
792
+ now
665
793
  }),
666
794
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
667
795
  className: "dbq-actions",
@@ -912,7 +1040,14 @@ window.__ModuleLoader__.load({
912
1040
  "quota.refreshing": "刷新中…",
913
1041
  "quota.autoNote": "每 5 分钟自动刷新",
914
1042
  "quota.updatedAt": "更新于 {time}",
915
- "quota.windowMissing": "未返回该额度窗口",
1043
+ "quota.windowMissing": "本套餐不含此额度",
1044
+ "quota.resetUnknown": "重置时间未知",
1045
+ "quota.startsOnUse": "开始使用后计时",
1046
+ "quota.expiresIn": "{time} 后到期",
1047
+ "quota.incentive": "赠送额度",
1048
+ "quota.usedAmounts": "已用 {used} / {total}",
1049
+ "quota.remainingAmount": "剩 {remaining} / {total}",
1050
+ "quota.remainingPercent": "剩 {percent}%",
916
1051
  "account.title": "账号状态",
917
1052
  "account.ok": "已登录",
918
1053
  "account.signed_out": "未登录:请在豆包客户端登录后重试",
@@ -977,7 +1112,14 @@ window.__ModuleLoader__.load({
977
1112
  "quota.refreshing": "Refreshing…",
978
1113
  "quota.autoNote": "Refreshes automatically every 5 minutes",
979
1114
  "quota.updatedAt": "updated {time}",
980
- "quota.windowMissing": "window not reported",
1115
+ "quota.windowMissing": "not part of this plan",
1116
+ "quota.resetUnknown": "reset time unknown",
1117
+ "quota.startsOnUse": "starts counting on first use",
1118
+ "quota.expiresIn": "expires in {time}",
1119
+ "quota.incentive": "Bonus quota",
1120
+ "quota.usedAmounts": "{used} / {total} used",
1121
+ "quota.remainingAmount": "{remaining} of {total} left",
1122
+ "quota.remainingPercent": "{percent}% left",
981
1123
  "account.title": "Account status",
982
1124
  "account.ok": "Signed in",
983
1125
  "account.signed_out": "Not signed in: sign in to the Doubao app and retry",