@duke-dsh-plugins/dsh-token-stats 1.3.4 → 1.5.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/client.js CHANGED
@@ -9,9 +9,14 @@
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
22
  id: "@duke-dsh-plugins/dsh-token-stats",
@@ -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,25 +159,105 @@ 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
 
140
211
  async function apply(ctx) {
212
+ // 0.1.7-rc.1 hardening: a client bundle that rejects can keep the whole
213
+ // web app from reaching the conversation page. Every optional step below
214
+ // degrades independently so this plugin never blocks DSH boot; the last
215
+ // resort catch keeps apply() itself from rejecting.
216
+ try {
217
+ await applyInner(ctx);
218
+ } catch (e) {
219
+ // eslint-disable-next-line no-console
220
+ (typeof console !== "undefined" && console.error ? console.error : () => {})(
221
+ "[dsh-token-stats] client apply failed; plugin disabled for this session:",
222
+ e,
223
+ );
224
+ }
225
+ }
226
+
227
+ async function applyInner(ctx) {
141
228
  // Mount the tokenStats namespace before anything touches it; the mount's
142
- // lifetime is bound to this plugin's context by $mount itself.
143
- await ctx.remote.$mount(CLIENT_REMOTE);
229
+ // lifetime is bound to this plugin's context by $mount itself. A mount
230
+ // failure (descriptor schema drift in a future dsh) downgrades the UI to
231
+ // an error state instead of failing the whole bundle.
232
+ let remote = null;
233
+ try {
234
+ await ctx.remote.$mount(CLIENT_REMOTE);
235
+ remote = ctx.get("remote.tokenStats") || null;
236
+ } catch (e) {
237
+ // eslint-disable-next-line no-console
238
+ (typeof console !== "undefined" && console.warn ? console.warn : () => {})(
239
+ "[dsh-token-stats] remote.$mount failed; stats page will show an error state:",
240
+ e,
241
+ );
242
+ }
144
243
 
145
- const styleTag = document.createElement("style");
146
- styleTag.textContent = CSS;
147
- document.head.appendChild(styleTag);
148
- ctx.effect(() => () => styleTag.remove());
244
+ let styleTag = null;
245
+ try {
246
+ styleTag = document.createElement("style");
247
+ styleTag.textContent = CSS;
248
+ document.head.appendChild(styleTag);
249
+ ctx.effect(() => () => styleTag && styleTag.remove());
250
+ } catch (e) {
251
+ /* cosmetic only */
252
+ }
149
253
 
150
254
  // Mark our settings-nav row so the CSS above replaces the shell's
151
255
  // fallback gear (no icon field exists in settings.section yet).
152
- ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
153
-
154
- // ctx.get() reads the service without the property-accessor inject guard.
155
- const remote = ctx.get("remote.tokenStats");
256
+ try {
257
+ ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
258
+ } catch (e) {
259
+ /* cosmetic only */
260
+ }
156
261
 
157
262
  const PALETTE = [
158
263
  "#5b8cff", "#f26d7a", "#3ecf8e", "#f5b942", "#a78bfa", "#38bdf8",
@@ -513,6 +618,395 @@ window.__ModuleLoader__.load({
513
618
  );
514
619
  }
515
620
 
621
+ // ---- Composer quota readout ------------------------------------------
622
+ // 套餐余额/用量读数, 显示位置与 dsh-musage 一致: `conversation.input.right`
623
+ // 槽位 (composer 卡内工具行, 紧贴 model select 左侧; margin-left:auto 在
624
+ // .trailing flex 容器内把读数推到最右). 跟随当前会话的模型选择
625
+ // (modelDirectories.directoryFor(sessionId).store), 切 provider 自动重取.
626
+ const QUOTA_REFRESH_MS = 60000;
627
+
628
+ // DSH provider route id → 内部 provider key (与 host 半 QUOTA_PROVIDERS 对应).
629
+ const PROVIDER_ALIASES = {
630
+ "minimax-cn": "minimax",
631
+ "minimax-en": "minimax",
632
+ "minimax": "minimax",
633
+ "deepseek": "deepseek",
634
+ "deepseek-official": "deepseek",
635
+ "kimi-coding": "kimi",
636
+ "kimi": "kimi",
637
+ "openrouter": "openrouter",
638
+ "zai-coding-cn": "zhipu",
639
+ "zhipu": "zhipu",
640
+ // Xiaomi MiMo:route id 由用户添加 provider 时命名,覆盖常见拼法。
641
+ "xiaomi-mimo": "mimo",
642
+ "xiaomimimo": "mimo",
643
+ "mimo": "mimo",
644
+ };
645
+ const QUOTA_PROVIDER_ORDER = ["minimax", "deepseek", "kimi", "openrouter", "zhipu", "mimo"];
646
+
647
+ function quotaProviderLabel(p) {
648
+ if (p === "minimax") return "MiniMax";
649
+ if (p === "deepseek") return "DeepSeek";
650
+ if (p === "kimi") return "Kimi";
651
+ if (p === "openrouter") return "OpenRouter";
652
+ if (p === "zhipu") return "Zhipu";
653
+ if (p === "mimo") return "MiMo";
654
+ return p;
655
+ }
656
+
657
+ /** 双层信封解包: remote 网关返回 res.value = host 方法的 { ok, value }. */
658
+ function unwrapRemote(res) {
659
+ const v = res && res.value;
660
+ if (v && typeof v === "object" && v.ok === true && v.value && typeof v.value === "object") return v.value;
661
+ if (v && typeof v === "object" && v.ok === false && v.error) {
662
+ return { ok: false, kind: "other", message: (v.error && (v.error.message || v.error.code)) || "获取失败" };
663
+ }
664
+ return v;
665
+ }
666
+
667
+ function quotaValueSpans(provider, d) {
668
+ const strong = { fontWeight: 600, color: "var(--dsw-alias-label-primary, #eee)" };
669
+ const labelEl = React.createElement("span", { key: "p", style: { fontWeight: 500 } }, quotaProviderLabel(provider));
670
+ if (provider === "deepseek" || provider === "openrouter") {
671
+ const txt = d.balanceText || (typeof d.balanceUsd === "number" ? "$" + d.balanceUsd.toFixed(2) : "—");
672
+ return [labelEl, React.createElement("span", { key: "b", style: strong }, txt)];
673
+ }
674
+ if (provider === "mimo") {
675
+ // MiMo Token Plan:套餐已用% + 本月总额度%(detail 缺失时可能只有一行)
676
+ const parts = [labelEl];
677
+ if (typeof d.fiveHrPct === "number") {
678
+ parts.push(React.createElement("span", { key: "plan", style: strong }, "套餐 " + d.fiveHrPct + "%"));
679
+ }
680
+ if (typeof d.weeklyPct === "number") {
681
+ if (parts.length > 1) parts.push(React.createElement("span", { key: "sep", style: { opacity: 0.5, fontSize: 10 } }, "|"));
682
+ parts.push(React.createElement("span", { key: "month", style: strong }, "本月 " + d.weeklyPct + "%"));
683
+ }
684
+ if (parts.length === 1) parts.push(React.createElement("span", { key: "na", style: strong }, "—"));
685
+ return parts;
686
+ }
687
+ // minimax / kimi / zhipu: 5h + 7d 双窗口套餐
688
+ const five = typeof d.fiveHrPct === "number" ? d.fiveHrPct + "%" : "—";
689
+ const week = typeof d.weeklyPct === "number" ? d.weeklyPct + "%" : "—";
690
+ return [
691
+ labelEl,
692
+ React.createElement("span", { key: "5", style: strong }, "5h " + five),
693
+ React.createElement("span", { key: "sep", style: { opacity: 0.5, fontSize: 10 } }, "|"),
694
+ React.createElement("span", { key: "7", style: strong }, "7d " + week),
695
+ ];
696
+ }
697
+
698
+ /** 自定义悬停面板 (替代原生 title): 结构化展示余额/窗口用量 + 进度条. */
699
+ function QuotaHoverCard(props) {
700
+ const label = quotaProviderLabel(props.provider);
701
+ const rows = [];
702
+ if (props.ok && props.display) {
703
+ const d = props.display;
704
+ if (typeof d.balanceText === "string" && d.balanceText) {
705
+ rows.push(
706
+ React.createElement(
707
+ "div", { key: "bal", className: "ts-quota-pop-row" },
708
+ React.createElement("span", null, "账户余额" + (d.currency ? " (" + d.currency + ")" : "")),
709
+ React.createElement("b", null, d.balanceText),
710
+ ),
711
+ );
712
+ }
713
+ const windows =
714
+ props.provider === "mimo"
715
+ ? [
716
+ ["套餐已用", d.fiveHrPct, d.fiveHrResetsIn],
717
+ ["本月总额度", d.weeklyPct, d.weeklyResetsIn],
718
+ ]
719
+ : [
720
+ ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
721
+ ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
722
+ ];
723
+ for (const w of windows) {
724
+ if (typeof w[1] !== "number") continue;
725
+ rows.push(
726
+ React.createElement(
727
+ "div", { key: w[0], style: { display: "flex", flexDirection: "column", gap: 4 } },
728
+ React.createElement(
729
+ "div", { className: "ts-quota-pop-row" },
730
+ React.createElement("span", null, w[0] + "已用"),
731
+ React.createElement("b", null, w[1] + "%" + (w[2] ? " · " + w[2] : "")),
732
+ ),
733
+ React.createElement(
734
+ "div", { className: "ts-quota-pop-bar" },
735
+ React.createElement("i", { style: { width: Math.max(0, Math.min(100, w[1])) + "%" } }),
736
+ ),
737
+ ),
738
+ );
739
+ }
740
+ } else {
741
+ rows.push(React.createElement("div", { key: "err", className: "ts-quota-pop-err" }, props.message || "获取失败"));
742
+ }
743
+ return React.createElement(
744
+ "div", { className: "ts-quota-pop" },
745
+ React.createElement(
746
+ "div", { className: "ts-quota-pop-head" },
747
+ React.createElement("span", null, label),
748
+ React.createElement("span", { className: "ts-quota-pop-sub" }, "套餐余额"),
749
+ ),
750
+ ...rows,
751
+ );
752
+ }
753
+
754
+ function QuotaReadout(props) {
755
+ const models = props.models;
756
+ const sessionId = props.sessionId;
757
+ const [provider, setProvider] = React.useState(null);
758
+ const [state, setState] = React.useState({ loaded: false, ok: false });
759
+ const [hover, setHover] = React.useState(false);
760
+ const loadRef = React.useRef(null);
761
+
762
+ // 订阅会话级 model directory, 提取 active provider route id.
763
+ React.useEffect(() => {
764
+ if (!models || !sessionId) {
765
+ setProvider(null);
766
+ return undefined;
767
+ }
768
+ let directory = null;
769
+ try {
770
+ directory = models.directoryFor(sessionId);
771
+ } catch (e) {
772
+ directory = null;
773
+ }
774
+ if (!directory || !directory.store) {
775
+ setProvider(null);
776
+ return undefined;
777
+ }
778
+ const update = () => {
779
+ try {
780
+ const snap = directory.store.getSnapshot();
781
+ const route = snap && snap.current && snap.current.provider;
782
+ setProvider(route ? PROVIDER_ALIASES[route] || null : null);
783
+ } catch (e) {
784
+ setProvider(null);
785
+ }
786
+ };
787
+ update();
788
+ const stop = directory.store.subscribe(update);
789
+ return () => {
790
+ try {
791
+ stop();
792
+ } catch (e) { /* ignore */ }
793
+ };
794
+ }, [models, sessionId]);
795
+
796
+ // provider 切换时重取; 60s 轮询; 点击 = 绕过缓存强制刷新.
797
+ React.useEffect(() => {
798
+ loadRef.current = null;
799
+ if (!provider) {
800
+ setState({ loaded: false, ok: false });
801
+ return undefined;
802
+ }
803
+ let alive = true;
804
+ const load = async (force) => {
805
+ try {
806
+ const payload = unwrapRemote(await remote.getQuota(provider, force === true));
807
+ if (!alive) return;
808
+ if (payload && typeof payload === "object" && typeof payload.ok === "boolean") {
809
+ setState({ loaded: true, ...payload });
810
+ } else {
811
+ setState({ loaded: true, ok: false, kind: "other", message: "响应格式异常" });
812
+ }
813
+ } catch (e) {
814
+ if (alive) setState({ loaded: true, ok: false, kind: "network", message: String((e && e.message) || e) });
815
+ }
816
+ };
817
+ loadRef.current = load;
818
+ load(false);
819
+ const t = setInterval(() => load(false), QUOTA_REFRESH_MS);
820
+ return () => {
821
+ alive = false;
822
+ clearInterval(t);
823
+ };
824
+ }, [provider]);
825
+
826
+ // 当前路由不在支持列表 → 完全不占位.
827
+ if (!provider) return null;
828
+
829
+ const label = quotaProviderLabel(provider);
830
+ const containerStyle = {
831
+ display: "inline-flex",
832
+ alignItems: "center",
833
+ marginLeft: "auto",
834
+ gap: 4,
835
+ padding: "2px 8px",
836
+ fontSize: 11,
837
+ fontVariantNumeric: "tabular-nums",
838
+ userSelect: "none",
839
+ cursor: "pointer",
840
+ color: "var(--dsw-alias-label-secondary, #888)",
841
+ };
842
+ const hoverProps = {
843
+ onMouseEnter: () => setHover(true),
844
+ onMouseLeave: () => setHover(false),
845
+ };
846
+ const onRefresh = () => {
847
+ const fn = loadRef.current;
848
+ if (fn) fn(true);
849
+ };
850
+ const card = hover
851
+ ? React.createElement(QuotaHoverCard, {
852
+ provider,
853
+ ok: state.ok === true,
854
+ display: state.display || null,
855
+ message: state.message || null,
856
+ })
857
+ : null;
858
+
859
+ if (!state.loaded) {
860
+ return React.createElement(
861
+ "div",
862
+ Object.assign({ className: "ts-quota", style: containerStyle }, hoverProps),
863
+ React.createElement("span", { style: { fontWeight: 500 } }, label),
864
+ React.createElement("span", { style: { opacity: 0.6, fontSize: 10 } }, "···"),
865
+ );
866
+ }
867
+
868
+ if (!state.ok) {
869
+ return React.createElement(
870
+ "div",
871
+ Object.assign(
872
+ {
873
+ className: "ts-quota",
874
+ style: Object.assign({}, containerStyle, {
875
+ color: "var(--dsw-alias-state-warning-primary, var(--dsh-text-warning, #f5a623))",
876
+ }),
877
+ onClick: onRefresh,
878
+ },
879
+ hoverProps,
880
+ ),
881
+ React.createElement("span", { style: { fontWeight: 500 } }, label),
882
+ React.createElement("span", { style: { fontWeight: 600 } }, "⚠"),
883
+ card,
884
+ );
885
+ }
886
+
887
+ const d = state.display || {};
888
+ return React.createElement(
889
+ "div",
890
+ Object.assign({ className: "ts-quota", style: containerStyle, onClick: onRefresh }, hoverProps),
891
+ ...quotaValueSpans(provider, d),
892
+ card,
893
+ );
894
+ }
895
+
896
+ // ---- Settings 页余额区块 ----------------------------------------------
897
+ // 展示所有已配置 API Key 的 provider 余额卡片; 全部未配置时整块不渲染.
898
+
899
+ function QuotaCard(props) {
900
+ const p = props.provider;
901
+ const v = props.value;
902
+ const label = quotaProviderLabel(p);
903
+ if (!v || v.ok !== true) {
904
+ const msg = (v && v.message) || "获取失败";
905
+ return React.createElement(
906
+ "div", { className: "ts-card ts-quota-card" },
907
+ React.createElement(
908
+ "div", { className: "ts-quota-card-head" },
909
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
910
+ React.createElement("span", { className: "ts-quota-card-status ts-quota-err" }, "获取失败"),
911
+ ),
912
+ React.createElement("div", { className: "ts-card-label", title: msg }, msg),
913
+ );
914
+ }
915
+ const d = v.display || {};
916
+ if (typeof d.balanceText === "string" && d.balanceText) {
917
+ return React.createElement(
918
+ "div", { className: "ts-card ts-quota-card" },
919
+ React.createElement(
920
+ "div", { className: "ts-quota-card-head" },
921
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
922
+ React.createElement("span", { className: "ts-quota-card-status" }, "账户余额" + (d.currency ? " · " + d.currency : "")),
923
+ ),
924
+ React.createElement("div", { className: "ts-quota-card-value" }, d.balanceText),
925
+ );
926
+ }
927
+ const windows =
928
+ p === "mimo"
929
+ ? [
930
+ ["套餐已用", d.fiveHrPct, d.fiveHrResetsIn],
931
+ ["本月总额度", d.weeklyPct, d.weeklyResetsIn],
932
+ ]
933
+ : [
934
+ ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
935
+ ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
936
+ ];
937
+ return React.createElement(
938
+ "div", { className: "ts-card ts-quota-card" },
939
+ React.createElement(
940
+ "div", { className: "ts-quota-card-head" },
941
+ React.createElement("span", { className: "ts-quota-card-name" }, label),
942
+ React.createElement("span", { className: "ts-quota-card-status" }, "套餐用量"),
943
+ ),
944
+ windows.map((w) =>
945
+ typeof w[1] !== "number"
946
+ ? null
947
+ : React.createElement(
948
+ "div", { key: w[0], style: { display: "flex", flexDirection: "column", gap: 4 } },
949
+ React.createElement(
950
+ "div", { className: "ts-quota-pop-row" },
951
+ React.createElement("span", null, w[0] + "已用"),
952
+ React.createElement("b", null, w[1] + "%" + (w[2] ? " · " + w[2] : "")),
953
+ ),
954
+ React.createElement(
955
+ "div", { className: "ts-quota-card-bar" },
956
+ React.createElement("i", { style: { width: Math.max(0, Math.min(100, w[1])) + "%" } }),
957
+ ),
958
+ ),
959
+ ),
960
+ );
961
+ }
962
+
963
+ function QuotaSection() {
964
+ const [quotas, setQuotas] = React.useState(null);
965
+ const load = React.useCallback(async (force) => {
966
+ try {
967
+ const payload = unwrapRemote(await remote.getAllQuotas(force === true));
968
+ if (payload && typeof payload === "object" && payload.quotas && typeof payload.quotas === "object") {
969
+ setQuotas(payload.quotas);
970
+ }
971
+ } catch (e) { /* 余额区块失败不影响统计页 */ }
972
+ }, []);
973
+ React.useEffect(() => {
974
+ load(false);
975
+ const t = setInterval(() => load(false), QUOTA_REFRESH_MS);
976
+ return () => clearInterval(t);
977
+ }, [load]);
978
+
979
+ if (!quotas) return null;
980
+ const names = QUOTA_PROVIDER_ORDER.filter((p) => quotas[p]);
981
+ // 只展示已配置 API Key 的 provider; 全部未配置 → 整块不渲染.
982
+ const configured = names.filter((p) => !(quotas[p] && quotas[p].ok === false && quotas[p].kind === "unconfigured"));
983
+ if (configured.length === 0) return null;
984
+ return React.createElement(
985
+ "div", { className: "ts-block" },
986
+ React.createElement(
987
+ "div", { className: "ts-block-title" },
988
+ "套餐余额 · 已配置 provider",
989
+ " ",
990
+ React.createElement(
991
+ "a",
992
+ {
993
+ href: "#",
994
+ style: { color: "inherit", textDecoration: "none", cursor: "pointer" },
995
+ onClick: (e) => {
996
+ e.preventDefault();
997
+ load(true);
998
+ },
999
+ },
1000
+ "(刷新)",
1001
+ ),
1002
+ ),
1003
+ React.createElement(
1004
+ "div", { className: "ts-quota-list" },
1005
+ configured.map((p) => React.createElement(QuotaCard, { key: p, provider: p, value: quotas[p] })),
1006
+ ),
1007
+ );
1008
+ }
1009
+
516
1010
  function TokenStatsPage(props) {
517
1011
  const [data, setData] = React.useState(null);
518
1012
  const [tab, setTab] = React.useState(7);
@@ -522,6 +1016,10 @@ window.__ModuleLoader__.load({
522
1016
 
523
1017
  const load = React.useCallback(async () => {
524
1018
  try {
1019
+ if (!remote) {
1020
+ setError("tokenStats Remote 未就绪(宿主插件可能加载失败)");
1021
+ return;
1022
+ }
525
1023
  const res = await remote.getStats();
526
1024
  if (res && res.ok) {
527
1025
  // The Remote gateway returns `res.value` = the Host method's full
@@ -614,6 +1112,7 @@ window.__ModuleLoader__.load({
614
1112
  body = React.createElement(
615
1113
  "div", { className: "ts-body" },
616
1114
  React.createElement(SummaryCards, { sum: range.sum }),
1115
+ React.createElement(QuotaSection, null),
617
1116
  React.createElement(
618
1117
  "div", { className: "ts-block" },
619
1118
  React.createElement("div", { className: "ts-block-title" }, "每日消耗 · 按模型"),
@@ -662,12 +1161,56 @@ window.__ModuleLoader__.load({
662
1161
  }
663
1162
 
664
1163
  // Settings entry: a full page under the sidebar Settings panel.
665
- ctx.slots.inject("settings.section", () =>
666
- ctx.slots.register(
667
- { name: "settings.section", id: "token-stats", order: 25, label: () => SETTINGS_LABEL },
668
- TokenStatsPage,
669
- ),
670
- );
1164
+ try {
1165
+ ctx.slots.inject("settings.section", () =>
1166
+ ctx.slots.register(
1167
+ { name: "settings.section", id: "token-stats", order: 25, label: () => SETTINGS_LABEL },
1168
+ TokenStatsPage,
1169
+ ),
1170
+ );
1171
+ } catch (e) {
1172
+ // eslint-disable-next-line no-console
1173
+ (typeof console !== "undefined" && console.warn ? console.warn : () => {})(
1174
+ "[dsh-token-stats] settings.section registration failed:",
1175
+ e,
1176
+ );
1177
+ }
1178
+
1179
+ // Composer quota readout (余额/套餐用量), same seat as dsh-musage:
1180
+ // `conversation.input.right`, immediately left of the model select. Only
1181
+ // registered while the model-selection service is mounted (it owns the
1182
+ // per-session model directory the readout follows); scoped inject keeps
1183
+ // deployments without it completely unaffected.
1184
+ if (typeof ctx.inject === "function") {
1185
+ try {
1186
+ ctx.inject(["slots", "modelDirectories"], (scope) => {
1187
+ try {
1188
+ scope.slots.inject("conversation.input.right", () =>
1189
+ scope.slots.register(
1190
+ { name: "conversation.input.right", id: "token-stats-quota", order: 0, label: "Token quota" },
1191
+ (props) =>
1192
+ React.createElement(QuotaReadout, {
1193
+ sessionId: props && props.sessionId,
1194
+ models: scope.modelDirectories,
1195
+ }),
1196
+ ),
1197
+ );
1198
+ } catch (e) {
1199
+ // eslint-disable-next-line no-console
1200
+ (typeof console !== "undefined" && console.warn ? console.warn : () => {})(
1201
+ "[dsh-token-stats] conversation.input.right registration failed:",
1202
+ e,
1203
+ );
1204
+ }
1205
+ });
1206
+ } catch (e) {
1207
+ // eslint-disable-next-line no-console
1208
+ (typeof console !== "undefined" && console.warn ? console.warn : () => {})(
1209
+ "[dsh-token-stats] modelDirectories scoped inject failed:",
1210
+ e,
1211
+ );
1212
+ }
1213
+ }
671
1214
  }
672
1215
 
673
1216
  exports.apply = apply;