@duke-dsh-plugins/dsh-token-stats 1.3.3 → 1.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/README.md CHANGED
@@ -27,8 +27,11 @@
27
27
  | 🗂 汇总卡片 | 区间总 Tokens / 输入(含缓存) / 输出 |
28
28
  | 💾 本地持久化 | 聚合结果落盘 `<DSH_HOME>/data/dsh-token-stats/stats.json`,冷启动只扫描新会话、秒开;删除该文件可强制全量重扫 |
29
29
  | ⏱ 自动刷新 | 页面打开期间每 30s 刷新;历史回填期间每 2s 轮询进度 |
30
+ | 💰 套餐余额 | 输入框工具行(model 选择器左侧)内联显示当前 provider 的**余额/套餐用量**,跟随当前模型自动切换;支持 minimax / deepseek / kimi / openrouter / zhipu;60s 轮询,点击立即刷新 |
30
31
  | 🌗 主题适配 | 全部使用 DSH 设计 token,明暗主题自动跟随 |
31
32
 
33
+ > 套餐余额功能参考自 [dsh-musage](https://github.com/Thedeergod666/dsh-musage)(MIT),API Key 直接复用 DSH 模型设置里已配置的凭据(`credentials` 服务),无需重复配置。
34
+
32
35
  ## 安装
33
36
 
34
37
  ### 标准安装(推荐)
@@ -40,7 +43,7 @@
40
43
  dsh plugin --profile web add /path/to/dsh-token-stats
41
44
 
42
45
  # 正式发布:从 GitHub Release tarball 安装
43
- dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-stats/releases/download/v1.2.0/dsh-token-stats-1.2.0.tgz
46
+ dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-stats/releases/download/v1.4.0/dsh-token-stats-1.4.0.tgz
44
47
  ```
45
48
 
46
49
  重启 DSH 后,打开 DSH Web UI 的设置(侧栏底部),左侧导航会出现 **Token 统计** 页。
@@ -55,6 +58,11 @@ dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-sta
55
58
  - 饼图展示区间内每个模型的总消耗占比。
56
59
  - 顶部卡片给出区间总 Tokens / 输入 / 输出。
57
60
  3. 下方 **每日活跃** 热力图展示更长时间范围:格子越多 = 容器越宽,最多覆盖近一年。
61
+ 4. **会话输入框工具行**(model 选择器左侧)内联显示当前 provider 的套餐余额:
62
+ - DeepSeek / OpenRouter 显示余额(如 `¥43.97` / `$12.50`);
63
+ - MiniMax / Kimi / 智谱 显示 `5h X% | 7d Y%`(5 小时 / 7 天窗口已用百分比,悬停查看重置时间);
64
+ - 切换模型时自动切换 provider;每 60s 自动刷新,**点击读数立即强制刷新**;
65
+ - 未配置对应 API Key 或拉取失败时显示 `⚠`(悬停查看原因);当前 provider 不在支持列表时不占位。
58
66
 
59
67
  ## 工作原理
60
68
 
@@ -67,14 +75,17 @@ DSH 会话日志(唯一权威数据源)
67
75
  │
68
76
  ▼
69
77
  TokenStatsService.getStats() ← ctx.remote.tokenStats.getStats()(Client 调用)
78
+ TokenStatsService.getQuota() ← ctx.remote.tokenStats.getQuota(provider, force)
70
79
  │
71
- ▼
72
- 设置面板「Token 统计」页(柱状图 / 饼图 / 热力图,全部由同一份数据派生)
80
+ ├─ getStats → 设置面板「Token 统计」页(柱状图 / 饼图 / 热力图)
81
+ └─ getQuota → composer 工具行内联读数(conversation.input.right,
82
+ 紧贴 model select 左侧;跟随当前会话模型自动切 provider)
73
83
  ```
74
84
 
75
85
  - 数据按**本地日历天** × **模型**(`provider::model`)聚合;`total = input + output + cacheRead + cacheWrite`。
76
86
  - 历史回填只统计插件启动前发生的调用,实时监听只统计启动后的,两者通过每个会话的事件序号水位线合并,不会重复。
77
87
  - 模型/模型来自 `assistant/message` 的 `message.source`(kind = 'model'),无需自行解析请求头。
88
+ - 套餐余额:Host 半经 `credentials` 服务解析用户在模型设置里已配置的 API Key(引用命名遵循 `<ROUTE>_API_KEY` 约定),GET provider 官方端点(宿主全局 fetch 优先,无 fetch 时回退 `subprocess` curl);30s 缓存 + 失败指数退避(5s→30min),不落盘。
78
89
 
79
90
  ## 目录结构
80
91
 
package/client.js CHANGED
@@ -9,12 +9,17 @@
9
9
  * - a GitHub-contributions-style heatmap whose day range adapts to the
10
10
  * container width (capped at one year / 365 days).
11
11
  *
12
+ * It also registers a compact 套餐余额/用量 readout in the composer tool row
13
+ * (`conversation.input.right`, immediately left of the model select — the
14
+ * same seat dsh-musage uses), following the session's active provider and
15
+ * polling `tokenStats.getQuota` (click forces a cache-bypassing refresh).
16
+ *
12
17
  * Host communication goes through the `tokenStats` Remote namespace
13
- * (`ctx.remote.tokenStats.getStats()`), published by the Host half in
14
- * `index.js`.
18
+ * (`ctx.remote.tokenStats.getStats()/getQuota()`), published by the Host half
19
+ * in `index.js`.
15
20
  */
16
21
  window.__ModuleLoader__.load({
17
- id: "dsh-token-stats",
22
+ id: "@duke-dsh-plugins/dsh-token-stats",
18
23
  factory: (require) => {
19
24
  var module = { exports: {} };
20
25
  var exports = module.exports;
@@ -49,6 +54,26 @@ window.__ModuleLoader__.load({
49
54
  .ts-legend-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
50
55
  .ts-legend-val{color:var(--dsw-alias-label-secondary);white-space:nowrap}
51
56
  .ts-heat{stroke:var(--dsw-alias-border-l1);stroke-width:0.5}
57
+ .ts-quota{position:relative;display:inline-flex}
58
+ .ts-quota-pop{position:absolute;bottom:calc(100% + 8px);right:0;width:230px;box-sizing:border-box;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;box-shadow:var(--dsw-elevation-soft,0 6px 24px rgba(0,0,0,0.18));padding:10px 12px;display:flex;flex-direction:column;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);z-index:60;pointer-events:none;animation:ts-pop-in .12s ease-out}
59
+ .ts-quota-pop-head{display:flex;align-items:center;justify-content:space-between;gap:12px;font-weight:600;font-size:12px}
60
+ .ts-quota-pop-sub{color:var(--dsw-alias-label-secondary);font-size:11px}
61
+ .ts-quota-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--dsw-alias-label-secondary)}
62
+ .ts-quota-pop-row b{color:var(--dsw-alias-label-primary);font-weight:600;font-variant-numeric:tabular-nums}
63
+ .ts-quota-pop-bar{height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
64
+ .ts-quota-pop-bar i{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,var(--dsh-accent,#4D6BFE))}
65
+ .ts-quota-pop-err{color:var(--dsw-alias-state-error-primary,#e5534b);word-break:break-all}
66
+ @keyframes ts-pop-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
67
+ .ts-quota-card{display:flex;flex-direction:column;gap:6px}
68
+ .ts-quota-list{display:flex;flex-direction:column;gap:10px}
69
+ .ts-quota-list .ts-card{width:100%;box-sizing:border-box}
70
+ .ts-quota-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
71
+ .ts-quota-card-name{font-size:13px;font-weight:600;line-height:20px}
72
+ .ts-quota-card-status{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
73
+ .ts-quota-card-status.ts-quota-err{color:var(--dsw-alias-state-error-primary,#e5534b)}
74
+ .ts-quota-card-value{font-size:18px;font-weight:600;line-height:26px;font-variant-numeric:tabular-nums}
75
+ .ts-quota-card-bar{height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
76
+ .ts-quota-card-bar i{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,var(--dsh-accent,#4D6BFE))}
52
77
  .ts-heat-0{fill:var(--dsw-alias-bg-layer-2);fill:color-mix(in srgb,var(--dsw-alias-border-l1) 45%,transparent);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-border-l1) 45%,transparent)}
53
78
  .ts-heat-1{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:0.3}
54
79
  .ts-heat-2{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:0.5}
@@ -134,6 +159,52 @@ window.__ModuleLoader__.load({
134
159
  schema: passthrough(),
135
160
  },
136
161
  },
162
+ {
163
+ id: "dsh-token-stats#tokenStats/getQuota",
164
+ service: "tokenStats",
165
+ namespace: "tokenStats",
166
+ method: "getQuota",
167
+ invocation: { kind: "direct" },
168
+ parameters: [
169
+ {
170
+ name: "provider",
171
+ wire: "provider",
172
+ source: "json",
173
+ codec: { mode: "strict", typeSymbol: "dsh-token-stats#tokenStats/getQuota:provider", schema: passthrough() },
174
+ },
175
+ {
176
+ name: "force",
177
+ wire: "force",
178
+ source: "json",
179
+ codec: { mode: "strict", typeSymbol: "dsh-token-stats#tokenStats/getQuota:force", schema: passthrough() },
180
+ },
181
+ ],
182
+ result: {
183
+ mode: "strict",
184
+ typeSymbol: "dsh-token-stats#TokenStatsQuotaResult",
185
+ schema: passthrough(),
186
+ },
187
+ },
188
+ {
189
+ id: "dsh-token-stats#tokenStats/getAllQuotas",
190
+ service: "tokenStats",
191
+ namespace: "tokenStats",
192
+ method: "getAllQuotas",
193
+ invocation: { kind: "direct" },
194
+ parameters: [
195
+ {
196
+ name: "force",
197
+ wire: "force",
198
+ source: "json",
199
+ codec: { mode: "strict", typeSymbol: "dsh-token-stats#tokenStats/getAllQuotas:force", schema: passthrough() },
200
+ },
201
+ ],
202
+ result: {
203
+ mode: "strict",
204
+ typeSymbol: "dsh-token-stats#TokenStatsAllQuotasResult",
205
+ schema: passthrough(),
206
+ },
207
+ },
137
208
  ],
138
209
  };
139
210
 
@@ -513,6 +584,365 @@ window.__ModuleLoader__.load({
513
584
  );
514
585
  }
515
586
 
587
+ // ---- Composer quota readout ------------------------------------------
588
+ // 套餐余额/用量读数, 显示位置与 dsh-musage 一致: `conversation.input.right`
589
+ // 槽位 (composer 卡内工具行, 紧贴 model select 左侧; margin-left:auto 在
590
+ // .trailing flex 容器内把读数推到最右). 跟随当前会话的模型选择
591
+ // (modelDirectories.directoryFor(sessionId).store), 切 provider 自动重取.
592
+ const QUOTA_REFRESH_MS = 60000;
593
+
594
+ // DSH provider route id → 内部 provider key (与 host 半 QUOTA_PROVIDERS 对应).
595
+ const PROVIDER_ALIASES = {
596
+ "minimax-cn": "minimax",
597
+ "minimax-en": "minimax",
598
+ "minimax": "minimax",
599
+ "deepseek": "deepseek",
600
+ "deepseek-official": "deepseek",
601
+ "kimi-coding": "kimi",
602
+ "kimi": "kimi",
603
+ "openrouter": "openrouter",
604
+ "zai-coding-cn": "zhipu",
605
+ "zhipu": "zhipu",
606
+ };
607
+ const QUOTA_PROVIDER_ORDER = ["minimax", "deepseek", "kimi", "openrouter", "zhipu"];
608
+
609
+ function quotaProviderLabel(p) {
610
+ if (p === "minimax") return "MiniMax";
611
+ if (p === "deepseek") return "DeepSeek";
612
+ if (p === "kimi") return "Kimi";
613
+ if (p === "openrouter") return "OpenRouter";
614
+ if (p === "zhipu") return "Zhipu";
615
+ return p;
616
+ }
617
+
618
+ /** 双层信封解包: remote 网关返回 res.value = host 方法的 { ok, value }. */
619
+ function unwrapRemote(res) {
620
+ const v = res && res.value;
621
+ if (v && typeof v === "object" && v.ok === true && v.value && typeof v.value === "object") return v.value;
622
+ if (v && typeof v === "object" && v.ok === false && v.error) {
623
+ return { ok: false, kind: "other", message: (v.error && (v.error.message || v.error.code)) || "获取失败" };
624
+ }
625
+ return v;
626
+ }
627
+
628
+ function quotaValueSpans(provider, d) {
629
+ const strong = { fontWeight: 600, color: "var(--dsw-alias-label-primary, #eee)" };
630
+ const labelEl = React.createElement("span", { key: "p", style: { fontWeight: 500 } }, quotaProviderLabel(provider));
631
+ if (provider === "deepseek" || provider === "openrouter") {
632
+ const txt = d.balanceText || (typeof d.balanceUsd === "number" ? "$" + d.balanceUsd.toFixed(2) : "—");
633
+ return [labelEl, React.createElement("span", { key: "b", style: strong }, txt)];
634
+ }
635
+ // minimax / kimi / zhipu: 5h + 7d 窗口已用百分比
636
+ const five = typeof d.fiveHrPct === "number" ? d.fiveHrPct + "%" : "—";
637
+ const week = typeof d.weeklyPct === "number" ? d.weeklyPct + "%" : "—";
638
+ return [
639
+ labelEl,
640
+ React.createElement("span", { key: "5", style: strong }, "5h " + five),
641
+ React.createElement("span", { key: "sep", style: { opacity: 0.5, fontSize: 10 } }, "|"),
642
+ React.createElement("span", { key: "7", style: strong }, "7d " + week),
643
+ ];
644
+ }
645
+
646
+ /** 自定义悬停面板 (替代原生 title): 结构化展示余额/窗口用量 + 进度条. */
647
+ function QuotaHoverCard(props) {
648
+ const label = quotaProviderLabel(props.provider);
649
+ const rows = [];
650
+ if (props.ok && props.display) {
651
+ const d = props.display;
652
+ if (typeof d.balanceText === "string" && d.balanceText) {
653
+ rows.push(
654
+ React.createElement(
655
+ "div", { key: "bal", className: "ts-quota-pop-row" },
656
+ React.createElement("span", null, "账户余额" + (d.currency ? " (" + d.currency + ")" : "")),
657
+ React.createElement("b", null, d.balanceText),
658
+ ),
659
+ );
660
+ }
661
+ const windows = [
662
+ ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
663
+ ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
664
+ ];
665
+ for (const w of windows) {
666
+ if (typeof w[1] !== "number") continue;
667
+ rows.push(
668
+ React.createElement(
669
+ "div", { key: w[0], style: { display: "flex", flexDirection: "column", gap: 4 } },
670
+ React.createElement(
671
+ "div", { className: "ts-quota-pop-row" },
672
+ React.createElement("span", null, w[0] + "已用"),
673
+ React.createElement("b", null, w[1] + "%" + (w[2] ? " · " + w[2] : "")),
674
+ ),
675
+ React.createElement(
676
+ "div", { className: "ts-quota-pop-bar" },
677
+ React.createElement("i", { style: { width: Math.max(0, Math.min(100, w[1])) + "%" } }),
678
+ ),
679
+ ),
680
+ );
681
+ }
682
+ } else {
683
+ rows.push(React.createElement("div", { key: "err", className: "ts-quota-pop-err" }, props.message || "获取失败"));
684
+ }
685
+ return React.createElement(
686
+ "div", { className: "ts-quota-pop" },
687
+ React.createElement(
688
+ "div", { className: "ts-quota-pop-head" },
689
+ React.createElement("span", null, label),
690
+ React.createElement("span", { className: "ts-quota-pop-sub" }, "套餐余额"),
691
+ ),
692
+ ...rows,
693
+ );
694
+ }
695
+
696
+ function QuotaReadout(props) {
697
+ const models = props.models;
698
+ const sessionId = props.sessionId;
699
+ const [provider, setProvider] = React.useState(null);
700
+ const [state, setState] = React.useState({ loaded: false, ok: false });
701
+ const [hover, setHover] = React.useState(false);
702
+ const loadRef = React.useRef(null);
703
+
704
+ // 订阅会话级 model directory, 提取 active provider route id.
705
+ React.useEffect(() => {
706
+ if (!models || !sessionId) {
707
+ setProvider(null);
708
+ return undefined;
709
+ }
710
+ let directory = null;
711
+ try {
712
+ directory = models.directoryFor(sessionId);
713
+ } catch (e) {
714
+ directory = null;
715
+ }
716
+ if (!directory || !directory.store) {
717
+ setProvider(null);
718
+ return undefined;
719
+ }
720
+ const update = () => {
721
+ try {
722
+ const snap = directory.store.getSnapshot();
723
+ const route = snap && snap.current && snap.current.provider;
724
+ setProvider(route ? PROVIDER_ALIASES[route] || null : null);
725
+ } catch (e) {
726
+ setProvider(null);
727
+ }
728
+ };
729
+ update();
730
+ const stop = directory.store.subscribe(update);
731
+ return () => {
732
+ try {
733
+ stop();
734
+ } catch (e) { /* ignore */ }
735
+ };
736
+ }, [models, sessionId]);
737
+
738
+ // provider 切换时重取; 60s 轮询; 点击 = 绕过缓存强制刷新.
739
+ React.useEffect(() => {
740
+ loadRef.current = null;
741
+ if (!provider) {
742
+ setState({ loaded: false, ok: false });
743
+ return undefined;
744
+ }
745
+ let alive = true;
746
+ const load = async (force) => {
747
+ try {
748
+ const payload = unwrapRemote(await remote.getQuota(provider, force === true));
749
+ if (!alive) return;
750
+ if (payload && typeof payload === "object" && typeof payload.ok === "boolean") {
751
+ setState({ loaded: true, ...payload });
752
+ } else {
753
+ setState({ loaded: true, ok: false, kind: "other", message: "响应格式异常" });
754
+ }
755
+ } catch (e) {
756
+ if (alive) setState({ loaded: true, ok: false, kind: "network", message: String((e && e.message) || e) });
757
+ }
758
+ };
759
+ loadRef.current = load;
760
+ load(false);
761
+ const t = setInterval(() => load(false), QUOTA_REFRESH_MS);
762
+ return () => {
763
+ alive = false;
764
+ clearInterval(t);
765
+ };
766
+ }, [provider]);
767
+
768
+ // 当前路由不在支持列表 → 完全不占位.
769
+ if (!provider) return null;
770
+
771
+ const label = quotaProviderLabel(provider);
772
+ const containerStyle = {
773
+ display: "inline-flex",
774
+ alignItems: "center",
775
+ marginLeft: "auto",
776
+ gap: 4,
777
+ padding: "2px 8px",
778
+ fontSize: 11,
779
+ fontVariantNumeric: "tabular-nums",
780
+ userSelect: "none",
781
+ cursor: "pointer",
782
+ color: "var(--dsw-alias-label-secondary, #888)",
783
+ };
784
+ const hoverProps = {
785
+ onMouseEnter: () => setHover(true),
786
+ onMouseLeave: () => setHover(false),
787
+ };
788
+ const onRefresh = () => {
789
+ const fn = loadRef.current;
790
+ if (fn) fn(true);
791
+ };
792
+ const card = hover
793
+ ? React.createElement(QuotaHoverCard, {
794
+ provider,
795
+ ok: state.ok === true,
796
+ display: state.display || null,
797
+ message: state.message || null,
798
+ })
799
+ : null;
800
+
801
+ if (!state.loaded) {
802
+ return React.createElement(
803
+ "div",
804
+ Object.assign({ className: "ts-quota", style: containerStyle }, hoverProps),
805
+ React.createElement("span", { style: { fontWeight: 500 } }, label),
806
+ React.createElement("span", { style: { opacity: 0.6, fontSize: 10 } }, "···"),
807
+ );
808
+ }
809
+
810
+ if (!state.ok) {
811
+ return React.createElement(
812
+ "div",
813
+ Object.assign(
814
+ {
815
+ className: "ts-quota",
816
+ style: Object.assign({}, containerStyle, {
817
+ color: "var(--dsw-alias-state-warning-primary, var(--dsh-text-warning, #f5a623))",
818
+ }),
819
+ onClick: onRefresh,
820
+ },
821
+ hoverProps,
822
+ ),
823
+ React.createElement("span", { style: { fontWeight: 500 } }, label),
824
+ React.createElement("span", { style: { fontWeight: 600 } }, "⚠"),
825
+ card,
826
+ );
827
+ }
828
+
829
+ const d = state.display || {};
830
+ return React.createElement(
831
+ "div",
832
+ Object.assign({ className: "ts-quota", style: containerStyle, onClick: onRefresh }, hoverProps),
833
+ ...quotaValueSpans(provider, d),
834
+ card,
835
+ );
836
+ }
837
+
838
+ // ---- Settings 页余额区块 ----------------------------------------------
839
+ // 展示所有已配置 API Key 的 provider 余额卡片; 全部未配置时整块不渲染.
840
+
841
+ function QuotaCard(props) {
842
+ const p = props.provider;
843
+ const v = props.value;
844
+ const label = quotaProviderLabel(p);
845
+ if (!v || v.ok !== true) {
846
+ const msg = (v && v.message) || "获取失败";
847
+ return React.createElement(
848
+ "div", { className: "ts-card ts-quota-card" },
849
+ React.createElement(
850
+ "div", { className: "ts-quota-card-head" },
851
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
852
+ React.createElement("span", { className: "ts-quota-card-status ts-quota-err" }, "获取失败"),
853
+ ),
854
+ React.createElement("div", { className: "ts-card-label", title: msg }, msg),
855
+ );
856
+ }
857
+ const d = v.display || {};
858
+ if (typeof d.balanceText === "string" && d.balanceText) {
859
+ return React.createElement(
860
+ "div", { className: "ts-card ts-quota-card" },
861
+ React.createElement(
862
+ "div", { className: "ts-quota-card-head" },
863
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
864
+ React.createElement("span", { className: "ts-quota-card-status" }, "账户余额" + (d.currency ? " · " + d.currency : "")),
865
+ ),
866
+ React.createElement("div", { className: "ts-quota-card-value" }, d.balanceText),
867
+ );
868
+ }
869
+ const windows = [
870
+ ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
871
+ ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
872
+ ];
873
+ return React.createElement(
874
+ "div", { className: "ts-card ts-quota-card" },
875
+ React.createElement(
876
+ "div", { className: "ts-quota-card-head" },
877
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
878
+ React.createElement("span", { className: "ts-quota-card-status" }, "套餐用量"),
879
+ ),
880
+ windows.map((w) =>
881
+ typeof w[1] !== "number"
882
+ ? null
883
+ : React.createElement(
884
+ "div", { key: w[0], style: { display: "flex", flexDirection: "column", gap: 4 } },
885
+ React.createElement(
886
+ "div", { className: "ts-quota-pop-row" },
887
+ React.createElement("span", null, w[0] + "已用"),
888
+ React.createElement("b", null, w[1] + "%" + (w[2] ? " · " + w[2] : "")),
889
+ ),
890
+ React.createElement(
891
+ "div", { className: "ts-quota-card-bar" },
892
+ React.createElement("i", { style: { width: Math.max(0, Math.min(100, w[1])) + "%" } }),
893
+ ),
894
+ ),
895
+ ),
896
+ );
897
+ }
898
+
899
+ function QuotaSection() {
900
+ const [quotas, setQuotas] = React.useState(null);
901
+ const load = React.useCallback(async (force) => {
902
+ try {
903
+ const payload = unwrapRemote(await remote.getAllQuotas(force === true));
904
+ if (payload && typeof payload === "object" && payload.quotas && typeof payload.quotas === "object") {
905
+ setQuotas(payload.quotas);
906
+ }
907
+ } catch (e) { /* 余额区块失败不影响统计页 */ }
908
+ }, []);
909
+ React.useEffect(() => {
910
+ load(false);
911
+ const t = setInterval(() => load(false), QUOTA_REFRESH_MS);
912
+ return () => clearInterval(t);
913
+ }, [load]);
914
+
915
+ if (!quotas) return null;
916
+ const names = QUOTA_PROVIDER_ORDER.filter((p) => quotas[p]);
917
+ // 只展示已配置 API Key 的 provider; 全部未配置 → 整块不渲染.
918
+ const configured = names.filter((p) => !(quotas[p] && quotas[p].ok === false && quotas[p].kind === "unconfigured"));
919
+ if (configured.length === 0) return null;
920
+ return React.createElement(
921
+ "div", { className: "ts-block" },
922
+ React.createElement(
923
+ "div", { className: "ts-block-title" },
924
+ "套餐余额 · 已配置 provider",
925
+ " ",
926
+ React.createElement(
927
+ "a",
928
+ {
929
+ href: "#",
930
+ style: { color: "inherit", textDecoration: "none", cursor: "pointer" },
931
+ onClick: (e) => {
932
+ e.preventDefault();
933
+ load(true);
934
+ },
935
+ },
936
+ "(刷新)",
937
+ ),
938
+ ),
939
+ React.createElement(
940
+ "div", { className: "ts-quota-list" },
941
+ configured.map((p) => React.createElement(QuotaCard, { key: p, provider: p, value: quotas[p] })),
942
+ ),
943
+ );
944
+ }
945
+
516
946
  function TokenStatsPage(props) {
517
947
  const [data, setData] = React.useState(null);
518
948
  const [tab, setTab] = React.useState(7);
@@ -614,6 +1044,7 @@ window.__ModuleLoader__.load({
614
1044
  body = React.createElement(
615
1045
  "div", { className: "ts-body" },
616
1046
  React.createElement(SummaryCards, { sum: range.sum }),
1047
+ React.createElement(QuotaSection, null),
617
1048
  React.createElement(
618
1049
  "div", { className: "ts-block" },
619
1050
  React.createElement("div", { className: "ts-block-title" }, "每日消耗 · 按模型"),
@@ -668,6 +1099,26 @@ window.__ModuleLoader__.load({
668
1099
  TokenStatsPage,
669
1100
  ),
670
1101
  );
1102
+
1103
+ // Composer quota readout (余额/套餐用量), same seat as dsh-musage:
1104
+ // `conversation.input.right`, immediately left of the model select. Only
1105
+ // registered while the model-selection service is mounted (it owns the
1106
+ // per-session model directory the readout follows); scoped inject keeps
1107
+ // deployments without it completely unaffected.
1108
+ if (typeof ctx.inject === "function") {
1109
+ ctx.inject(["slots", "modelDirectories"], (scope) => {
1110
+ scope.slots.inject("conversation.input.right", () =>
1111
+ scope.slots.register(
1112
+ { name: "conversation.input.right", id: "token-stats-quota", order: 0, label: "Token quota" },
1113
+ (props) =>
1114
+ React.createElement(QuotaReadout, {
1115
+ sessionId: props && props.sessionId,
1116
+ models: scope.modelDirectories,
1117
+ }),
1118
+ ),
1119
+ );
1120
+ });
1121
+ }
671
1122
  }
672
1123
 
673
1124
  exports.apply = apply;