@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/README.md +17 -3
- package/client.js +561 -18
- package/index.js +808 -13
- package/package.json +9 -3
- package/typert.host.js +183 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
666
|
-
ctx.slots.
|
|
667
|
-
|
|
668
|
-
|
|
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;
|