dsh-ds-balance 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,7 +136,8 @@ dsh plugin --profile web add "$PWD"
136
136
  但**手改配置文件写成的非法组合不会再报错** —— 宿主那一侧已经不再强制它,插件会在读到的那一刻把这一对**回落成默认值**并在日志里记一次。
137
137
 
138
138
  > **升级提示**:设置项存在插件那一行的配置里,键名跟着宿主升级变过一次,**旧值不会自动迁移** —— 升级后请照上表重填一次。
139
- - **刷新**:服务端刷新周期与界面轮询周期。
139
+ - **刷新**:服务端刷新周期、界面轮询周期与手动刷新冷却。冷却从**刷完那一刻**起算,
140
+ 浮层上的秒数就是真实剩余;后端说这次没触发,界面按它给的剩余时间收尾,不假装刷过。
140
141
 
141
142
  保存即生效,不必重启 DSH。
142
143
 
@@ -152,6 +153,14 @@ dsh plugin --profile web add "$PWD"
152
153
  密钥只经 DSH 的凭据通道解析,卡片里的 `API Key` 一栏默认继承官方模型页配好的那一份,且只读不可改。
153
154
  由启动环境(环境变量)提供时,字段只读,徽标写明来源。
154
155
 
156
+ **换了 key 多久生效,跟着 DSH 的层走 —— 本插件不额外追:**
157
+
158
+ - **启动环境(环境变量)给的 key:改了要重启 DSH。** 进程的环境在它启动那一刻就定死了,之后再改外面那个变量,跑着的插件看不到。
159
+ - **在官方模型页 / 凭据库换的 key:不必重启**,最迟下一个服务端刷新周期生效(连续失败退避期间会更久);想立刻看到就点浮层里的手动刷新(它带冷却)。
160
+ - 生效之前,界面显示的还是**旧账本**的数字 —— 账本标识按 key 派生,新旧不会混,但内存里那份要等下一次抓取成功才被换掉。
161
+
162
+ 为什么这么定 → [关键决策](docs/ARCHITECTURE.md#关键决策)。
163
+
155
164
  ## 安全与边界
156
165
 
157
166
  - **API Key 永不返回界面**:配置接口只回一个固定长度的掩码串,连末几位也不给。
package/README_en.md CHANGED
@@ -136,7 +136,7 @@ Open **Plugins → Installed** and step into the **DeepSeek Balance** (shown as
136
136
  but a hand-edited config file with an illegal pair **no longer errors** — the Host side stopped enforcing it, so the plugin **falls that pair back to its defaults** the moment it reads it, and logs one line.
137
137
 
138
138
  > **Upgrade note**: the settings live in this plugin's own entry config and the key it is filed under changed once; **old values are not migrated** — please fill the table above in once after upgrading.
139
- - **Refresh**: the server refresh interval and the UI poll interval.
139
+ - **Refresh**: the server refresh interval, the UI poll interval and the manual refresh cooldown. The cooldown counts from **the moment the refresh finishes**, so the seconds shown in the popover are the real ones left; if the backend says the refresh did not fire, the UI takes its remaining time instead of pretending otherwise.
140
140
 
141
141
  Saving applies immediately; there is no need to restart DSH.
142
142
 
@@ -152,6 +152,14 @@ Saving applies immediately; there is no need to restart DSH.
152
152
  The key is resolved only through DSH's credential channel; the card's "API key" inherits the one already configured on the official model settings page.
153
153
  When it comes from the launch environment (an environment variable) the field is read-only and the badge says where it came from.
154
154
 
155
+ **How long a key change takes to land follows DSH's own layers — this plugin deliberately adds nothing on top:**
156
+
157
+ - **A key from the launch environment (an environment variable): restart DSH to change it.** A process's environment is fixed the moment it starts; a later change to that variable outside is invisible to the running plugin.
158
+ - **A key changed on the official model settings page / in the credential store: no restart needed** — it lands by the next server refresh cycle at the latest (later while failure backoff is active); to see it at once, use the manual refresh in the popover (it is rate-limited).
159
+ - Until then the UI still shows the **previous ledger**: the ledger identifier is derived from the key, so the two never mix, but the in-memory copy is only replaced once the next fetch succeeds.
160
+
161
+ Why it is this way → [Key decisions](docs/ARCHITECTURE.md#关键决策).
162
+
155
163
  ## Security and boundaries
156
164
 
157
165
  - **The API key is never returned to the UI**: the config endpoint returns only a fixed-length mask, not even the last few characters.
package/lib/client.js CHANGED
@@ -411,16 +411,7 @@ function SelectorControl(props) {
411
411
  }
412
412
  function ActionRow(props) {
413
413
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
414
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: fields_default.actions, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
415
- import_dsh_client_ui_primitives.Button,
416
- {
417
- type: "button",
418
- variant: "outline",
419
- disabled: props.disabled,
420
- onClick: props.onClick,
421
- children: props.label
422
- }
423
- ) }),
414
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: fields_default.actions, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { type: "button", variant: "outline", disabled: props.disabled, onClick: props.onClick, children: props.label }) }),
424
415
  props.result === null ? null : (
425
416
  // 成功只是通报,用 polite 的 status;失败需要用户处理,按 06 §6.7 的「行内错误」用 alert。
426
417
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -1377,6 +1368,14 @@ function currentAgeMs(seenAt, seenAgeMs, now) {
1377
1368
  const base = Number.isFinite(seenAgeMs) ? Math.max(0, seenAgeMs) : 0;
1378
1369
  return Math.max(0, base + (now - seenAt));
1379
1370
  }
1371
+ var MS_PER_SECOND = 1e3;
1372
+ function cooldownUntilOf(now, configuredSeconds, rejectedRemainingMs) {
1373
+ const span = rejectedRemainingMs === null ? Math.max(0, configuredSeconds) * MS_PER_SECOND : Math.max(0, rejectedRemainingMs);
1374
+ return now + span;
1375
+ }
1376
+ function cooldownSecondsOf(until, now) {
1377
+ return Math.max(0, Math.ceil((until - now) / MS_PER_SECOND));
1378
+ }
1380
1379
 
1381
1380
  // src/client/mock/scenarios.ts
1382
1381
  var T0 = 176e10;
@@ -1868,8 +1867,8 @@ var REFRESH_SIMULATION_MS = 600;
1868
1867
  var PANEL_GAP = 8;
1869
1868
  var PANEL_MARGIN = 12;
1870
1869
  var MEASURE_STYLE = { visibility: "hidden", left: 0, top: 0 };
1871
- var MS_PER_SECOND = 1e3;
1872
- var TICK_MS = MS_PER_SECOND;
1870
+ var MS_PER_SECOND2 = 1e3;
1871
+ var TICK_MS = MS_PER_SECOND2;
1873
1872
  function useConfigSlotState(probe) {
1874
1873
  const [state, setState] = (0, import_react5.useState)(() => probe.getSnapshot());
1875
1874
  (0, import_react5.useEffect)(() => {
@@ -1975,7 +1974,7 @@ function SidebarBalance({
1975
1974
  () => {
1976
1975
  void load();
1977
1976
  },
1978
- Math.max(5, config.clientPollSeconds) * MS_PER_SECOND
1977
+ Math.max(5, config.clientPollSeconds) * MS_PER_SECOND2
1979
1978
  );
1980
1979
  return () => {
1981
1980
  cancelled = true;
@@ -2009,9 +2008,12 @@ function SidebarBalance({
2009
2008
  gap: PANEL_GAP,
2010
2009
  margin: PANEL_MARGIN
2011
2010
  });
2012
- (0, import_react5.useEffect)(() => {
2011
+ (0, import_react5.useLayoutEffect)(() => {
2013
2012
  if (!open) return;
2014
2013
  setNow(Date.now());
2014
+ }, [open]);
2015
+ (0, import_react5.useEffect)(() => {
2016
+ if (!open) return;
2015
2017
  const id = window.setInterval(() => {
2016
2018
  setNow(Date.now());
2017
2019
  }, TICK_MS);
@@ -2019,6 +2021,17 @@ function SidebarBalance({
2019
2021
  window.clearInterval(id);
2020
2022
  };
2021
2023
  }, [open]);
2024
+ (0, import_react5.useEffect)(() => {
2025
+ if (cooldownUntil === 0) return;
2026
+ const remaining = cooldownUntil - Date.now();
2027
+ if (remaining <= 0) return;
2028
+ const id = window.setTimeout(() => {
2029
+ setNow(Date.now());
2030
+ }, remaining);
2031
+ return () => {
2032
+ window.clearTimeout(id);
2033
+ };
2034
+ }, [cooldownUntil]);
2022
2035
  (0, import_react5.useEffect)(() => {
2023
2036
  if (!open) return;
2024
2037
  const onKeyDown = (event) => {
@@ -2034,30 +2047,34 @@ function SidebarBalance({
2034
2047
  if (refreshing) return;
2035
2048
  if (Date.now() < cooldownUntil) return;
2036
2049
  setRefreshing(true);
2037
- const finish = () => {
2050
+ const finish = (rejectedRemainingMs) => {
2051
+ const at = Date.now();
2038
2052
  setRefreshing(false);
2039
2053
  setCooldownUntil(
2040
- Date.now() + Math.max(0, config.manualRefreshCooldownSeconds) * MS_PER_SECOND
2054
+ cooldownUntilOf(at, config.manualRefreshCooldownSeconds, rejectedRemainingMs)
2041
2055
  );
2056
+ setNow(at);
2042
2057
  };
2043
2058
  if (mock) {
2044
2059
  refreshTimer.current = window.setTimeout(() => {
2045
2060
  refreshTimer.current = void 0;
2046
2061
  markFresh();
2047
- finish();
2062
+ finish(null);
2048
2063
  }, REFRESH_SIMULATION_MS);
2049
2064
  return;
2050
2065
  }
2051
2066
  void (async () => {
2067
+ let rejectedRemainingMs = null;
2052
2068
  try {
2053
- await requestRefresh({ reason: "manual" });
2069
+ const result = await requestRefresh({ reason: "manual" });
2070
+ if (!result.triggered) rejectedRemainingMs = Math.max(0, result.cooldownMs);
2054
2071
  const next = await requestBalance({ currency: preference });
2055
2072
  loadedRef.current = true;
2056
2073
  acceptBalance(next);
2057
2074
  } catch (error) {
2058
2075
  if (!loadedRef.current) showUnreachable(error);
2059
2076
  } finally {
2060
- finish();
2077
+ finish(rejectedRemainingMs);
2061
2078
  }
2062
2079
  })();
2063
2080
  }, [
@@ -2110,7 +2127,7 @@ function SidebarBalance({
2110
2127
  if (stateText !== null && stateText !== markerLabel) ariaParts.push(stateText);
2111
2128
  if (markerLabel !== null) ariaParts.push(markerLabel);
2112
2129
  const ariaLabel = ariaParts.join(" ");
2113
- const cooldownSeconds = Math.max(0, Math.ceil((cooldownUntil - now) / MS_PER_SECOND));
2130
+ const cooldownSeconds = cooldownSecondsOf(cooldownUntil, now);
2114
2131
  const ageMs = currentAgeMs(view.seenAt, view.seenAgeMs, now);
2115
2132
  const ringTitle = !wide && stateText !== null ? `${t("sidebar.aria.ring")} ${stateText}` : void 0;
2116
2133
  const hoverLabel = shown === null ? stateText : formatMoney(shown.total, shown.currency);
@@ -61,6 +61,8 @@ export declare class BalanceService {
61
61
  private error;
62
62
  private failures;
63
63
  private retryAfterMs;
64
+ /** 上一次真的触发过手动刷新的时刻;`null` 表示本进程还没有过。 */
65
+ private lastManualRefreshAt;
64
66
  private inflight;
65
67
  /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
66
68
  private persistWarned;
@@ -90,6 +92,13 @@ export declare class BalanceService {
90
92
  * 手动刷新。
91
93
  *
92
94
  * 冷却中不触发;已有请求在飞时合并并回报 `joined`。
95
+ *
96
+ * **冷却的锚点是「上一次手动刷新」,不是「上一次抓取」**:调度与轮询也在抓,
97
+ * 拿 `snapshot.fetchedAt` 当锚点的话,一次自动刷新就会把用户刚按下的一下吞掉 ——
98
+ * 界面转了圈、倒计时也走了,上游却一次没打。
99
+ *
100
+ * 时刻取**决定触发那一刻**(不是抓完那一刻):客户端从「拿到结果那一刻」起算,
101
+ * 两者相差一个往返 ⇒ 客户端更保守,按钮亮起时按下去一定真的会抓。
93
102
  */
94
103
  forceRefresh(reason: string): Promise<RefreshResult>;
95
104
  /** 快照是否还在 `serverRefreshSeconds` 窗口内。**只看时间,不看状态。** */
@@ -21,6 +21,8 @@ export class BalanceService {
21
21
  error = null;
22
22
  failures = 0;
23
23
  retryAfterMs = null;
24
+ /** 上一次真的触发过手动刷新的时刻;`null` 表示本进程还没有过。 */
25
+ lastManualRefreshAt = null;
24
26
  inflight = null;
25
27
  /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
26
28
  persistWarned = false;
@@ -92,15 +94,28 @@ export class BalanceService {
92
94
  * 手动刷新。
93
95
  *
94
96
  * 冷却中不触发;已有请求在飞时合并并回报 `joined`。
97
+ *
98
+ * **冷却的锚点是「上一次手动刷新」,不是「上一次抓取」**:调度与轮询也在抓,
99
+ * 拿 `snapshot.fetchedAt` 当锚点的话,一次自动刷新就会把用户刚按下的一下吞掉 ——
100
+ * 界面转了圈、倒计时也走了,上游却一次没打。
101
+ *
102
+ * 时刻取**决定触发那一刻**(不是抓完那一刻):客户端从「拿到结果那一刻」起算,
103
+ * 两者相差一个往返 ⇒ 客户端更保守,按钮亮起时按下去一定真的会抓。
95
104
  */
96
105
  async forceRefresh(reason) {
106
+ const now = this.options.clock.now();
97
107
  const cooldownMs = this.options.config.current().manualRefreshCooldownSeconds * 1000;
98
- const since = this.options.clock.now() - (this.snapshot?.fetchedAt ?? 0);
99
- if (this.state === 'ok' && since < cooldownMs) {
108
+ const last = this.lastManualRefreshAt;
109
+ if (last !== null && now - last < cooldownMs) {
110
+ const remaining = cooldownMs - (now - last);
100
111
  this.metrics.counter('force_rejected_total');
101
- this.options.logger?.debug('ds-balance: manual refresh rejected by cooldown', { reason });
102
- return { triggered: false, joined: false, cooldownMs: cooldownMs - since, state: this.state };
112
+ this.options.logger?.debug('ds-balance: manual refresh rejected by cooldown', {
113
+ reason,
114
+ cooldownMs: remaining,
115
+ });
116
+ return { triggered: false, joined: false, cooldownMs: remaining, state: this.state };
103
117
  }
118
+ this.lastManualRefreshAt = now;
104
119
  if (this.inflight !== null) {
105
120
  await this.inflight;
106
121
  return { triggered: true, joined: true, cooldownMs: 0, state: this.state };
@@ -19,6 +19,13 @@ export declare const MAX_BACKOFF_MS = 300000;
19
19
  export declare const JITTER_RATIO = 0.2;
20
20
  /** 延迟下限,避免忙转。 */
21
21
  export declare const MIN_DELAY_MS = 1000;
22
+ /**
23
+ * 健康态延迟给「这一轮抓取本身」留的余量。
24
+ *
25
+ * 延迟是从上一轮**抓完之后**起算的,而抓取要几百毫秒(本机实测到上游一个往返约 0.2 秒)。
26
+ * 延迟若正好等于缓存窗口,新快照就会晚于窗口过期落地,那个缝里又会出现一次轮询代打的抓取。
27
+ */
28
+ export declare const SCHEDULE_GUARD_MS = 1000;
22
29
  /** 定时器抽象,测试可注入。 */
23
30
  export interface SchedulerTimers {
24
31
  set(callback: () => void, ms: number): unknown;
@@ -49,6 +56,22 @@ export interface SchedulerOptions {
49
56
  * @returns 取整后的毫秒数。
50
57
  */
51
58
  export declare function jitter(base: number, ratio: number, random: () => number): number;
59
+ /**
60
+ * 健康态的单边抖动:落在 `[base * (1 - ratio), base - SCHEDULE_GUARD_MS]`。
61
+ *
62
+ * **为什么健康态不能用对称抖动**:缓存窗口就是 `serverRefreshSeconds`
63
+ * (见 `BalanceService.withinWindow`),对称抖动有一半轮次落在窗口**之后** ——
64
+ * 那段时间里界面一次普通轮询就会自己穿透上游抓一次,紧接着这一轮 tick 又抓一次。
65
+ * 本机 3608 个快照间隔里 158 对相隔不足 3 秒的连击,其中 117 对前面那格超过 60 秒(窗口已过),
66
+ * 就是这么来的。单边抖动把「tick 不越过窗口」变成结构保证。
67
+ *
68
+ * 退避与缺密钥重试仍走对称的 {@link jitter}:那些分支本来就在窗口外,没有这条不变量。
69
+ * @param base - 基准毫秒数。
70
+ * @param ratio - 抖动比例。
71
+ * @param random - 随机源,返回 `[0, 1)`。
72
+ * @returns 取整后的毫秒数。
73
+ */
74
+ export declare function jitterWithin(base: number, ratio: number, random: () => number): number;
52
75
  /**
53
76
  * 算下一轮该等多久。
54
77
  *
@@ -19,6 +19,13 @@ export const JITTER_RATIO = 0.2;
19
19
  const MAX_BACKOFF_EXPONENT = 10;
20
20
  /** 延迟下限,避免忙转。 */
21
21
  export const MIN_DELAY_MS = 1000;
22
+ /**
23
+ * 健康态延迟给「这一轮抓取本身」留的余量。
24
+ *
25
+ * 延迟是从上一轮**抓完之后**起算的,而抓取要几百毫秒(本机实测到上游一个往返约 0.2 秒)。
26
+ * 延迟若正好等于缓存窗口,新快照就会晚于窗口过期落地,那个缝里又会出现一次轮询代打的抓取。
27
+ */
28
+ export const SCHEDULE_GUARD_MS = 1000;
22
29
  /** 把值夹到区间内。 */
23
30
  function clamp(value, min, max) {
24
31
  return Math.min(max, Math.max(min, value));
@@ -35,6 +42,26 @@ export function jitter(base, ratio, random) {
35
42
  const span = base * ratio * 2;
36
43
  return Math.floor(min + random() * span);
37
44
  }
45
+ /**
46
+ * 健康态的单边抖动:落在 `[base * (1 - ratio), base - SCHEDULE_GUARD_MS]`。
47
+ *
48
+ * **为什么健康态不能用对称抖动**:缓存窗口就是 `serverRefreshSeconds`
49
+ * (见 `BalanceService.withinWindow`),对称抖动有一半轮次落在窗口**之后** ——
50
+ * 那段时间里界面一次普通轮询就会自己穿透上游抓一次,紧接着这一轮 tick 又抓一次。
51
+ * 本机 3608 个快照间隔里 158 对相隔不足 3 秒的连击,其中 117 对前面那格超过 60 秒(窗口已过),
52
+ * 就是这么来的。单边抖动把「tick 不越过窗口」变成结构保证。
53
+ *
54
+ * 退避与缺密钥重试仍走对称的 {@link jitter}:那些分支本来就在窗口外,没有这条不变量。
55
+ * @param base - 基准毫秒数。
56
+ * @param ratio - 抖动比例。
57
+ * @param random - 随机源,返回 `[0, 1)`。
58
+ * @returns 取整后的毫秒数。
59
+ */
60
+ export function jitterWithin(base, ratio, random) {
61
+ const upper = Math.max(MIN_DELAY_MS, base - SCHEDULE_GUARD_MS);
62
+ const lower = Math.min(upper, Math.max(MIN_DELAY_MS, base * (1 - ratio)));
63
+ return Math.floor(lower + (upper - lower) * random());
64
+ }
38
65
  /**
39
66
  * 算下一轮该等多久。
40
67
  *
@@ -55,7 +82,7 @@ export function nextDelayMs(status, options = {}) {
55
82
  const exponent = Math.min(Math.max(0, status.consecutiveFailures - 1), MAX_BACKOFF_EXPONENT);
56
83
  return jitter(Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** exponent), JITTER_RATIO, random);
57
84
  }
58
- return jitter(Math.max(MIN_DELAY_MS, status.serverRefreshSeconds * 1000), JITTER_RATIO, random);
85
+ return jitterWithin(Math.max(MIN_DELAY_MS, status.serverRefreshSeconds * 1000), JITTER_RATIO, random);
59
86
  }
60
87
  /** 真正的默认定时器。 */
61
88
  const defaultTimers = {
@@ -103,3 +103,28 @@ export declare function ageBucket(ageMs: number): {
103
103
  * @returns 毫秒计的当前年龄;基准缺失时按 0 算。
104
104
  */
105
105
  export declare function currentAgeMs(seenAt: number, seenAgeMs: number, now: number): number;
106
+ /**
107
+ * 一次手动刷新之后的冷却截止时刻。
108
+ *
109
+ * **基准是「拿到结果那一刻」**,不是点击那一刻:后端的手动冷却从它收到请求起算,
110
+ * 客户端晚一个往返才起算 ⇒ 客户端永远比后端保守,按钮亮起来时按下去一定真的会抓。
111
+ *
112
+ * 返回的是**精确时刻**;调用方必须把展示用的「现在」与它设成同一刻(见
113
+ * {@link cooldownSecondsOf})。
114
+ * @param now - 拿到结果那一刻的本地时刻(毫秒)。
115
+ * @param configuredSeconds - 配置里的手动刷新冷却秒数。
116
+ * @param rejectedRemainingMs - 后端拒绝(`triggered: false`)时给出的剩余毫秒;`null` 表示这次真的触发了。
117
+ * @returns 冷却截止的本地时刻(毫秒)。
118
+ */
119
+ export declare function cooldownUntilOf(now: number, configuredSeconds: number, rejectedRemainingMs: number | null): number;
120
+ /**
121
+ * 冷却剩余秒数,向上取整。
122
+ *
123
+ * **`now` 必须与设定截止时刻时用的是同一刻**:截止是精确时刻,而界面里的「现在」是每秒
124
+ * 采样一次的状态;拿一个最多旧一秒的值去取整,5 秒的冷却会显示成 6 秒 ——
125
+ * 更糟的是按钮的禁用判据也是这个值,等于真的多禁一秒。
126
+ * @param until - 冷却截止的本地时刻(毫秒);0 表示没有冷却。
127
+ * @param now - 当前本地时刻(毫秒)。
128
+ * @returns 剩余秒数;已过期或没有冷却时为 0。
129
+ */
130
+ export declare function cooldownSecondsOf(until: number, now: number): number;
package/lib/version.d.ts CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export declare const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export declare const PLUGIN_VERSION = "2.1.2";
11
+ export declare const PLUGIN_VERSION = "2.1.3";
12
12
  //# sourceMappingURL=version.d.ts.map
package/lib/version.js CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export const PLUGIN_VERSION = '2.1.2';
11
+ export const PLUGIN_VERSION = '2.1.3';
12
12
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ds-balance",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "icon": "./icon.svg",
5
5
  "description": "DSH plugin: shows the DeepSeek account balance at the bottom of the sidebar, with a configuration card on the Plugins page.",
6
6
  "keywords": [