@jxgame2020/dsh-token-quota 0.1.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/lib/index.js ADDED
@@ -0,0 +1,387 @@
1
+ import { assertTokenQuotaLimit, splitTokenQuotaKey } from "./invariant.js";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { Service } from "@deepseek-ai/cordis";
6
+ import z from "@deepseek-ai/schemastery";
7
+ import "@deepseek-ai/dsh-agent";
8
+ import { LlmError } from "@deepseek-ai/dsh-llm";
9
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
10
+ //#region lib/types/types.js
11
+ /**
12
+ * Shared wire/type vocabulary for the daily token-quota plugin.
13
+ *
14
+ * The Host half owns the durable per-model daily counter and the enforcement
15
+ * gate; the browser half renders a floating panel from the snapshot it pulls
16
+ * over a plugin-owned HTTP route, and writes per-model limits and panel
17
+ * preferences through the settings document. This module is the one meeting
18
+ * point of the two planes: it carries strings and shapes only, never a
19
+ * runtime import from the other half.
20
+ *
21
+ * @module @jxgame2020/dsh-token-quota/types
22
+ */
23
+ /** Settings namespace owning the per-model daily limits document. */
24
+ const TOKEN_QUOTA_NAMESPACE = "token-quota";
25
+ /** Stable machine code thrown from `agent/request` when a model is over its daily cap. */
26
+ const TOKEN_QUOTA_EXCEEDED_CODE = "TOKEN_QUOTA_EXCEEDED";
27
+ /** Build the stable per-model key shared by the counter, settings, and snapshot. */
28
+ function tokenQuotaKey(provider, model) {
29
+ return `${provider}/${model}`;
30
+ }
31
+ //#endregion
32
+ //#region lib/types/index.js
33
+ /**
34
+ * Daily per-model token quota enforcement and accounting for the harness.
35
+ *
36
+ * The service owns three responsibilities, all driven by the existing agent
37
+ * and session extension points — no loop modification:
38
+ *
39
+ * 1. **Accounting** — folds each live session's `request/header` to learn the
40
+ * `provider/model` in use, and credits the combined input + output + cache
41
+ * token count of every `assistant/message` that reports provider usage into
42
+ * that model's current-UTC-day bucket. Counters persist to a JSON file
43
+ * under the Harness home and roll over automatically at UTC midnight.
44
+ *
45
+ * 2. **Enforcement** — on the `agent/request` waterfall it awaits the resolved
46
+ * call config, and when the selected model's daily usage is at or above its
47
+ * configured cap (a positive limit), it throws an {@link LlmError} with
48
+ * `TOKEN_QUOTA_EXCEEDED`, ending the turn before any provider request is
49
+ * dispatched. A limit of `0` (or no entry) leaves the model unlimited.
50
+ *
51
+ * 3. **Limits + pull** — per-model limits live in the settings document
52
+ * (`token-quota` namespace, written by the Web panel through the settings
53
+ * scope); every change re-reads them. The current snapshot is served to
54
+ * consumers over a plugin-owned HTTP route (`GET /token-quota`, registered
55
+ * on the existing `webServer` service when one exists); the browser panel
56
+ * polls it, so no core-Harness wiring or generated RPC contract is needed.
57
+ *
58
+ * The browser half (`src/client/`) renders a floating panel from the polled
59
+ * snapshot and writes limits back through the settings scope; this module
60
+ * stays browser-free (the optional route is plain node:http).
61
+ *
62
+ * @module @jxgame2020/dsh-token-quota
63
+ */
64
+ /** Settings schema resolving the per-model daily caps document. */
65
+ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
66
+ limits: z.dict(z.number().step(1).min(0)).default({}),
67
+ monitored: z.array(z.string()).default([]),
68
+ onFull: z.union([
69
+ "stop",
70
+ "switchQuota",
71
+ "switchAll",
72
+ "switchPriority"
73
+ ]).default("stop"),
74
+ reset: z.any().default(null)
75
+ });
76
+ /** Two-digit zero-pad helper. */
77
+ function pad2(value) {
78
+ return String(value).padStart(2, "0");
79
+ }
80
+ /** The machine's own UTC offset in whole hours (default reset timezone). */
81
+ function localOffsetHours(now = /* @__PURE__ */ new Date()) {
82
+ return -now.getTimezoneOffset() / 60;
83
+ }
84
+ /**
85
+ * Resolve the reset-cycle key a timestamp belongs to: the `YYYY-MM-DD@HH:MM`
86
+ * label (in the configured timezone) of the cycle whose `hour:minute` reset
87
+ * moment contains the timestamp. With the default config (machine timezone,
88
+ * 00:00) this reproduces the historical "resets at machine midnight".
89
+ */
90
+ function cycleKey(now, reset) {
91
+ const offsetMs = reset.offsetHours * 36e5;
92
+ const zoned = new Date(now.getTime() + offsetMs);
93
+ const cycleStartToday = Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate()) - offsetMs + (reset.hour * 60 + reset.minute) * 6e4;
94
+ const cycleStartUtc = now.getTime() >= cycleStartToday ? cycleStartToday : cycleStartToday - 864e5;
95
+ const startZoned = new Date(cycleStartUtc + offsetMs);
96
+ return `${startZoned.getUTCFullYear()}-${pad2(startZoned.getUTCMonth() + 1)}-${pad2(startZoned.getUTCDate())}@${pad2(reset.hour)}:${pad2(reset.minute)}`;
97
+ }
98
+ /** Default counter file under the Harness home (overridable through config). */
99
+ function defaultStoragePath() {
100
+ const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
101
+ return join(home, "token-quota.json");
102
+ }
103
+ /** Combined input + output + cache token count of one provider usage report. */
104
+ function usageTokens(usage) {
105
+ return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + usage.outputTokens;
106
+ }
107
+ /**
108
+ * Daily token-quota service.
109
+ *
110
+ * Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
111
+ * per-model limits through the `token-quota` settings namespace.
112
+ */
113
+ var TokenQuotaService = class extends Service {
114
+ static Config = z.object({ storagePath: z.string().default("") });
115
+ storagePath;
116
+ reset = {
117
+ offsetHours: localOffsetHours(),
118
+ hour: 0,
119
+ minute: 0
120
+ };
121
+ cycle = cycleKey(/* @__PURE__ */ new Date(), this.reset);
122
+ usage = {};
123
+ history = {};
124
+ limits = {};
125
+ monitored = void 0;
126
+ settingsSource = () => ({
127
+ limits: {},
128
+ monitored: [],
129
+ onFull: "stop"
130
+ });
131
+ /** Per-session folded model key from the latest `request/header`. */
132
+ headerKeys = /* @__PURE__ */ new WeakMap();
133
+ writeTimer;
134
+ /** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
135
+ disposeRoute;
136
+ /** Disposer for the optional usage-history route (`GET /token-quota/log`). */
137
+ disposeRouteLog;
138
+ /** Disposer for the webServer-arrival watcher when the service mounts later. */
139
+ disposeRouteWatcher;
140
+ /**
141
+ * @param ctx - owning context (events and the settings section register on it).
142
+ * @param config - optional counter path.
143
+ */
144
+ constructor(ctx, config = {}) {
145
+ super(ctx, "tokenQuota");
146
+ this.storagePath = config.storagePath !== void 0 && config.storagePath.length > 0 ? config.storagePath : defaultStoragePath();
147
+ this.load();
148
+ installSettingsSection(ctx, settingsNamespace(TOKEN_QUOTA_NAMESPACE), TOKEN_QUOTA_SETTINGS_SCHEMA, { limits: {} }, {
149
+ setSource: (current) => {
150
+ this.settingsSource = () => {
151
+ const doc = current();
152
+ return {
153
+ limits: doc.limits ?? {},
154
+ monitored: doc.monitored ?? [],
155
+ onFull: doc.onFull ?? "stop",
156
+ reset: doc.reset ?? void 0
157
+ };
158
+ };
159
+ },
160
+ validate: (value) => {
161
+ for (const [key, limit] of Object.entries(value.limits ?? {})) {
162
+ assertTokenQuotaLimit(limit, key);
163
+ splitTokenQuotaKey(key);
164
+ }
165
+ },
166
+ onChange: () => {
167
+ const doc = this.settingsSource();
168
+ this.limits = { ...doc.limits };
169
+ this.monitored = doc.monitored !== void 0 && doc.monitored.length > 0 ? new Set(doc.monitored) : void 0;
170
+ if (doc.reset !== void 0 && typeof doc.reset === "object" && doc.reset !== null && typeof doc.reset.offsetHours === "number" && typeof doc.reset.hour === "number" && typeof doc.reset.minute === "number") {
171
+ const configured = doc.reset;
172
+ this.reset = {
173
+ offsetHours: Math.max(-12, Math.min(14, Math.round(configured.offsetHours))),
174
+ hour: Math.max(0, Math.min(23, Math.round(configured.hour))),
175
+ minute: Math.max(0, Math.min(59, Math.round(configured.minute)))
176
+ };
177
+ this.rollCycleIfNeeded();
178
+ } else {
179
+ this.reset = {
180
+ offsetHours: localOffsetHours(),
181
+ hour: 0,
182
+ minute: 0
183
+ };
184
+ this.rollCycleIfNeeded();
185
+ }
186
+ }
187
+ });
188
+ this.tryMountRoute();
189
+ if (this.disposeRoute === void 0) this.disposeRouteWatcher = ctx.on("internal/service", (name) => {
190
+ if (name === "webServer") this.tryMountRoute();
191
+ });
192
+ ctx.on("session/event", (session, event) => {
193
+ this.onSessionEvent(session, event);
194
+ });
195
+ ctx.on("agent/request", async (payload, next) => this.onRequest(payload, next));
196
+ ctx.effect(() => () => {
197
+ this.disposeLocal();
198
+ }, "token-quota: flush on unload");
199
+ }
200
+ /** Serve the current snapshot over the plugin-owned HTTP route. */
201
+ tryMountRoute() {
202
+ const server = this.ctx.get("webServer");
203
+ if (server === void 0 || this.disposeRoute !== void 0) return;
204
+ this.disposeRoute = server.register({
205
+ kind: "exact",
206
+ path: "/token-quota",
207
+ handler: (_req, res) => {
208
+ const body = JSON.stringify(this.readSnapshot());
209
+ res.writeHead(200, {
210
+ "content-type": "application/json",
211
+ "cache-control": "no-store"
212
+ });
213
+ res.end(body);
214
+ }
215
+ });
216
+ this.disposeRouteLog = server.register({
217
+ kind: "exact",
218
+ path: "/token-quota/log",
219
+ handler: (_req, res) => {
220
+ const body = JSON.stringify(this.readLog());
221
+ res.writeHead(200, {
222
+ "content-type": "application/json",
223
+ "cache-control": "no-store"
224
+ });
225
+ res.end(body);
226
+ }
227
+ });
228
+ }
229
+ /** Read the full per-cycle usage history (log dialog data). */
230
+ readLog() {
231
+ this.rollCycleIfNeeded();
232
+ const entries = [];
233
+ for (const [cycle, usageByKey] of Object.entries(this.history)) for (const [key, used] of Object.entries(usageByKey)) {
234
+ const parts = this.safeSplit(key);
235
+ if (parts === void 0 || used <= 0) continue;
236
+ entries.push({
237
+ day: cycle,
238
+ key,
239
+ provider: parts.provider,
240
+ model: parts.model,
241
+ used
242
+ });
243
+ }
244
+ for (const [key, used] of Object.entries(this.usage)) {
245
+ const parts = this.safeSplit(key);
246
+ if (parts === void 0 || used <= 0) continue;
247
+ entries.push({
248
+ day: this.cycle,
249
+ key,
250
+ provider: parts.provider,
251
+ model: parts.model,
252
+ used
253
+ });
254
+ }
255
+ entries.sort((left, right) => right.day.localeCompare(left.day) || left.key.localeCompare(right.key));
256
+ return { entries };
257
+ }
258
+ /** Read the current snapshot (also used by tests and inspection). */
259
+ readSnapshot() {
260
+ this.rollCycleIfNeeded();
261
+ const keys = /* @__PURE__ */ new Set([...Object.keys(this.usage), ...Object.keys(this.limits)]);
262
+ const entries = [];
263
+ for (const key of keys) {
264
+ const parts = this.safeSplit(key);
265
+ if (parts === void 0) continue;
266
+ entries.push({
267
+ key,
268
+ provider: parts.provider,
269
+ model: parts.model,
270
+ used: this.usage[key] ?? 0,
271
+ limit: this.limitOf(key)
272
+ });
273
+ }
274
+ entries.sort((left, right) => left.key.localeCompare(right.key));
275
+ return {
276
+ day: this.cycle,
277
+ entries
278
+ };
279
+ }
280
+ /** Today's used tokens for one model key, or `0`. */
281
+ usedToday(key) {
282
+ this.rollCycleIfNeeded();
283
+ return this.usage[key] ?? 0;
284
+ }
285
+ /** Whether a model is under active monitoring (undefined = every model). */
286
+ isMonitored(key) {
287
+ return this.monitored === void 0 || this.monitored.has(key);
288
+ }
289
+ /** Resolve one model's daily cap: positive = capped, `0` = unlimited. */
290
+ limitOf(key) {
291
+ const limit = this.limits[key];
292
+ return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : 0;
293
+ }
294
+ onSessionEvent(session, event) {
295
+ if (event.type === "request/header") {
296
+ const { provider, model } = event.data.header.config;
297
+ this.headerKeys.set(session, tokenQuotaKey(provider, model));
298
+ return;
299
+ }
300
+ if (event.type === "assistant/message" && event.data.usage !== void 0) {
301
+ const key = this.headerKeys.get(session);
302
+ if (key === void 0) return;
303
+ if (!this.isMonitored(key)) return;
304
+ const tokens = usageTokens(event.data.usage);
305
+ if (tokens <= 0) return;
306
+ this.rollCycleIfNeeded();
307
+ this.usage[key] = (this.usage[key] ?? 0) + tokens;
308
+ this.scheduleWrite();
309
+ }
310
+ }
311
+ async onRequest(payload, next) {
312
+ const config = await next();
313
+ const { provider, model } = config;
314
+ if (!provider || !model) return config;
315
+ const key = tokenQuotaKey(provider, model);
316
+ this.headerKeys.set(payload.agent.session, key);
317
+ if (!this.isMonitored(key)) return config;
318
+ const limit = this.limitOf(key);
319
+ if (limit <= 0) return config;
320
+ const used = this.usage[key] ?? 0;
321
+ if (used < limit) return config;
322
+ throw new LlmError(`Daily token limit reached for "${provider}/${model}": ${used}/${limit} tokens used today. Switch model in the quota panel or raise its limit.`, TOKEN_QUOTA_EXCEEDED_CODE);
323
+ }
324
+ /**
325
+ * Roll over to a new reset cycle, archiving the finished cycle's counters
326
+ * into the history log, when the cycle key changed.
327
+ * @returns whether a rollover happened.
328
+ */
329
+ rollCycleIfNeeded() {
330
+ const now = cycleKey(/* @__PURE__ */ new Date(), this.reset);
331
+ if (this.cycle === now) return false;
332
+ if (Object.keys(this.usage).length > 0) this.history[this.cycle] = { ...this.usage };
333
+ this.cycle = now;
334
+ this.usage = {};
335
+ this.scheduleWrite();
336
+ return true;
337
+ }
338
+ scheduleWrite() {
339
+ if (this.writeTimer !== void 0) return;
340
+ this.writeTimer = setTimeout(() => {
341
+ this.writeTimer = void 0;
342
+ this.flush();
343
+ }, 500);
344
+ }
345
+ flush() {
346
+ if (this.writeTimer !== void 0) {
347
+ clearTimeout(this.writeTimer);
348
+ this.writeTimer = void 0;
349
+ }
350
+ try {
351
+ mkdirSync(dirname(this.storagePath), { recursive: true });
352
+ writeFileSync(this.storagePath, JSON.stringify({
353
+ cycle: this.cycle,
354
+ usage: this.usage,
355
+ history: this.history
356
+ }, null, 2));
357
+ } catch (error) {
358
+ this.ctx.logger.warn("token-quota: failed to persist counters: %o", error);
359
+ }
360
+ }
361
+ load() {
362
+ try {
363
+ const candidate = JSON.parse(readFileSync(this.storagePath, "utf8"));
364
+ this.cycle = typeof candidate?.cycle === "string" && candidate.cycle.length > 0 ? candidate.cycle : typeof candidate?.day === "string" && candidate.day.length > 0 ? candidate.day : cycleKey(/* @__PURE__ */ new Date(), this.reset);
365
+ const usage = candidate?.usage;
366
+ this.usage = typeof usage === "object" && usage !== null && !Array.isArray(usage) ? usage : {};
367
+ const history = candidate?.history;
368
+ this.history = typeof history === "object" && history !== null && !Array.isArray(history) ? history : {};
369
+ this.rollCycleIfNeeded();
370
+ } catch {}
371
+ }
372
+ disposeLocal() {
373
+ if (this.disposeRouteWatcher !== void 0) this.disposeRouteWatcher();
374
+ if (this.disposeRoute !== void 0) this.disposeRoute();
375
+ if (this.disposeRouteLog !== void 0) this.disposeRouteLog();
376
+ this.flush();
377
+ }
378
+ safeSplit(key) {
379
+ try {
380
+ return splitTokenQuotaKey(key);
381
+ } catch {
382
+ return;
383
+ }
384
+ }
385
+ };
386
+ //#endregion
387
+ export { TokenQuotaService, TokenQuotaService as default };
@@ -0,0 +1,25 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Runtime guards for the token-quota plugin's public boundary.
4
+ *
5
+ * A separate companion module keeps these reachable from both the Host and
6
+ * Client build faces without dragging the service implementation into a
7
+ * browser bundle.
8
+ *
9
+ * @module @jxgame2020/dsh-token-quota/invariant
10
+ */
11
+ /** Accept one per-model daily limit: `0` (unlimited) or a positive integer. */
12
+ function assertTokenQuotaLimit(value, key) {
13
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new TypeError(`token-quota: limit for "${key}" must be a non-negative integer, received ${String(value)}`);
14
+ }
15
+ /** Resolve a model key of the form `provider/model` into its two parts. */
16
+ function splitTokenQuotaKey(key) {
17
+ const slash = key.indexOf("/");
18
+ if (slash <= 0 || slash === key.length - 1) throw new TypeError(`token-quota: invalid model key "${key}" — expected "provider/model"`);
19
+ return {
20
+ provider: key.slice(0, slash),
21
+ model: key.slice(slash + 1)
22
+ };
23
+ }
24
+ //#endregion
25
+ export { assertTokenQuotaLimit, splitTokenQuotaKey };
@@ -0,0 +1,28 @@
1
+ import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
3
+ import type { TokenQuotaFullAction, TokenQuotaReset } from '@jxgame2020/dsh-token-quota/types';
4
+ import type { createTokenQuotaPanelStore } from './store.ts';
5
+ /** Injected business face: data loading and mutations wired in `apply`. */
6
+ export interface TokenQuotaPanelInjected {
7
+ /** Refresh the model directory for one session. */
8
+ load: (sessionId: SessionId) => void;
9
+ /** Persist one model's daily cap (0 = unlimited) through the settings scope. */
10
+ setLimit: (key: string, limit: number) => void;
11
+ /** Switch the current session to one route through the Host RPC. */
12
+ selectModel: (sessionId: SessionId, provider: string, model: string) => void;
13
+ /** Persist the monitored-model selection (null = every model). */
14
+ setMonitored: (monitored: string[] | null) => void;
15
+ /** Persist the full-quota strategy. */
16
+ setOnFull: (action: TokenQuotaFullAction) => void;
17
+ /** Persist the daily reset moment (null = machine-local midnight). */
18
+ setReset: (reset: TokenQuotaReset | null) => void;
19
+ }
20
+ /** Full component props: runtime + store + locale + injected face. */
21
+ export type TokenQuotaPanelComponentProps = PropsRuntime<'shell.overlay'> & PropsStore<ReturnType<typeof createTokenQuotaPanelStore>> & PropsLocale<'token-quota'> & TokenQuotaPanelInjected;
22
+ /**
23
+ * Render the floating panel (or its collapsed tab).
24
+ * @param props - composed slot props.
25
+ * @returns the panel element tree.
26
+ */
27
+ export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
28
+ //# sourceMappingURL=TokenQuotaPanel.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Token-quota floating panel, registered into the frame-wide `shell.overlay`
4
+ * seat. Renders one row per MONITORED model of the current session's
5
+ * directory merged with the live quota snapshot: name, a compact `选择` action,
6
+ * `used / limit`, a progress bar, and an icon-folded limit editor. A settings
7
+ * dialog (header button) chooses which models are monitored and what to do
8
+ * when a capped model is full. The panel is pure presentation — every fact
9
+ * arrives through the props shares and every mutation through the injected
10
+ * callbacks.
11
+ */
12
+ import { useEffect, useMemo, useState } from 'react';
13
+ import { mergeModelRows } from "./store.js";
14
+ import css from './TokenQuotaPanel.module.css';
15
+ /** Compact a token count for display. */
16
+ function formatTokens(n) {
17
+ if (n >= 1_000_000)
18
+ return `${(n / 1_000_000).toFixed(1)}M`;
19
+ if (n >= 1_000)
20
+ return `${(n / 1_000).toFixed(1)}K`;
21
+ return String(n);
22
+ }
23
+ function barStateOf(row) {
24
+ if (row.limit <= 0)
25
+ return row.used > 0 ? 'warn' : 'idle';
26
+ if (row.used >= row.limit)
27
+ return 'over';
28
+ if (row.used / row.limit >= 0.8)
29
+ return 'warn';
30
+ return 'idle';
31
+ }
32
+ /** Full-quota strategy choices, in display order. */
33
+ const FULL_ACTIONS = [
34
+ { value: 'stop', labelKey: 'fullStop' },
35
+ { value: 'switchQuota', labelKey: 'fullSwitchQuota' },
36
+ { value: 'switchAll', labelKey: 'fullSwitchAll' },
37
+ { value: 'switchPriority', labelKey: 'fullSwitchPriority' },
38
+ ];
39
+ /**
40
+ * Render the floating panel (or its collapsed tab).
41
+ * @param props - composed slot props.
42
+ * @returns the panel element tree.
43
+ */
44
+ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, useStore, actions, useSessions, }) {
45
+ const [collapsed, setCollapsed] = useState(false);
46
+ const [drafts, setDrafts] = useState({});
47
+ const [editingKey, setEditingKey] = useState(null);
48
+ const sessionId = useSessions(s => s.current);
49
+ const snapshot = useStore(s => s.snapshot);
50
+ const groups = useStore(s => s.groups);
51
+ const current = useStore(s => s.current);
52
+ const monitored = useStore(s => s.monitored);
53
+ const onFull = useStore(s => s.onFull);
54
+ const reset = useStore(s => s.reset);
55
+ const dialogOpen = useStore(s => s.dialogOpen);
56
+ const logOpen = useStore(s => s.logOpen);
57
+ const log = useStore(s => s.log);
58
+ const fullNotice = useStore(s => s.fullNotice);
59
+ const loading = useStore(s => s.loading);
60
+ const error = useStore(s => s.error);
61
+ // Refresh the advisory model directory whenever the current session changes.
62
+ useEffect(() => {
63
+ if (sessionId !== undefined)
64
+ load(sessionId);
65
+ }, [sessionId, load]);
66
+ const rows = useMemo(() => mergeModelRows(groups, snapshot, current), [groups, snapshot, current]);
67
+ // All directory models, flattened for the monitoring picker.
68
+ const allModels = useMemo(() => groups.flatMap(group => group.models.map((model) => ({
69
+ key: `${group.id}/${model.id}`,
70
+ name: model.name,
71
+ }))).sort((a, b) => a.key.localeCompare(b.key)), [groups]);
72
+ const isMonitoredKey = (key) => monitored === null || monitored.includes(key);
73
+ if (collapsed) {
74
+ return (_jsx("div", { className: css.tab, role: "button", tabIndex: 0, title: t('title'), onClick: () => { setCollapsed(false); }, children: _jsx("span", { className: css.tabLabel, children: t('expand') }) }));
75
+ }
76
+ const openSettings = () => {
77
+ actions.setDialogOpen(true);
78
+ };
79
+ // Fetch the usage log whenever the log dialog opens.
80
+ useEffect(() => {
81
+ if (!logOpen)
82
+ return;
83
+ let cancelled = false;
84
+ void fetch('/token-quota/log', { headers: { accept: 'application/json' } }).then((response) => response.json(), () => null).then((data) => {
85
+ if (!cancelled)
86
+ actions.setLog(data);
87
+ });
88
+ return () => { cancelled = true; };
89
+ }, [logOpen, actions]);
90
+ const commitLimit = (row) => {
91
+ const raw = drafts[row.key]?.trim();
92
+ setDrafts((prev) => {
93
+ const { [row.key]: _dropped, ...rest } = prev;
94
+ return rest;
95
+ });
96
+ setEditingKey(null);
97
+ if (raw === undefined || raw === '')
98
+ return;
99
+ const parsed = Number(raw);
100
+ if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed))
101
+ return;
102
+ setLimit(row.key, parsed);
103
+ };
104
+ const visibleRows = rows.filter(row => isMonitoredKey(row.key) || row.current);
105
+ return (_jsxs("div", { className: css.panel, children: [_jsxs("div", { className: css.header, children: [_jsxs("div", { className: css.headerText, children: [_jsx("div", { className: css.title, children: t('title') }), _jsx("div", { className: css.subtitle, children: t('subtitle') })] }), _jsxs("div", { className: css.headerActions, children: [_jsx("button", { type: "button", className: css.settingsBtn, onClick: () => { actions.setLogOpen(true); }, children: t('logs') }), _jsx("button", { type: "button", className: css.settingsBtn, onClick: openSettings, children: t('settings') }), _jsx("button", { type: "button", className: css.collapse, onClick: () => { setCollapsed(true); }, children: t('collapse') })] })] }), _jsxs("div", { className: css.body, children: [loading && _jsx("div", { className: css.notice, children: t('loading') }), error !== null && _jsx("div", { className: css.noticeError, children: error }), fullNotice !== null && (_jsx("div", { className: css.fullNotice, role: "alert", children: fullNotice })), !loading && error === null && visibleRows.length === 0 && (_jsx("div", { className: css.notice, children: monitored !== null && monitored.length === 0 ? t('noMonitored') : t('waiting') })), visibleRows.map((row) => {
106
+ const state = barStateOf(row);
107
+ const pct = row.limit > 0 ? Math.min(100, Math.round((row.used / row.limit) * 100)) : 0;
108
+ const barClass = state === 'over'
109
+ ? css.fillOver
110
+ : state === 'warn'
111
+ ? css.fillWarn
112
+ : css.fillIdle;
113
+ const editing = editingKey === row.key;
114
+ return (_jsxs("div", { className: css.row, children: [_jsxs("div", { className: css.rowHeader, children: [_jsxs("span", { className: css.rowName, title: row.key, children: [row.name, row.current && _jsx("span", { className: css.currentBadge, children: t('current') }), sessionId !== undefined && !row.current && (_jsx("button", { type: "button", className: css.selectBtn, onClick: () => { selectModel(sessionId, row.provider, row.model); }, children: t('select') }))] }), _jsxs("span", { className: css.rowMeta, children: [row.limit > 0
115
+ ? `${formatTokens(row.used)} / ${formatTokens(row.limit)}`
116
+ : `${formatTokens(row.used)} · ${t('unlimited')}`, _jsx("button", { type: "button", className: css.gearBtn, title: t('setLimitHint'), onClick: () => { setEditingKey(editing ? null : row.key); }, children: "\u2699" })] })] }), _jsx("div", { className: css.bar, children: _jsx("div", { className: barClass, style: { width: `${pct}%` } }) }), editing && (_jsxs("div", { className: css.controls, children: [_jsx("input", { className: css.input, type: "number", min: 0, step: 10000, placeholder: t('limitPlaceholder'), value: drafts[row.key] ?? '', onChange: (event) => { setDrafts(prev => ({ ...prev, [row.key]: event.target.value })); }, onKeyDown: (event) => { if (event.key === 'Enter')
117
+ commitLimit(row); } }), _jsx("button", { type: "button", className: css.save, onClick: () => { commitLimit(row); }, children: t('save') })] }))] }, row.key));
118
+ })] }), dialogOpen && (_jsx("div", { className: css.dialogBackdrop, onClick: () => { actions.setDialogOpen(false); }, children: _jsxs("div", { className: css.dialog, onClick: (event) => { event.stopPropagation(); }, children: [_jsxs("div", { className: css.dialogHeader, children: [_jsx("div", { className: css.dialogTitle, children: t('settingsTitle') }), _jsx("button", { type: "button", className: css.dialogClose, title: t('close'), onClick: () => { actions.setDialogOpen(false); }, children: "\u00D7" })] }), _jsxs("div", { className: css.dialogSection, children: [_jsx("div", { className: css.dialogLabel, children: t('monitorLabel') }), _jsx("div", { className: css.monitorHint, children: t('monitorHint') }), _jsx("div", { className: css.monitorList, children: allModels.map(model => (_jsxs("label", { className: css.monitorRow, children: [_jsx("input", { type: "checkbox", checked: monitored === null || monitored.includes(model.key), onChange: (event) => {
119
+ const base = monitored === null ? allModels.map(m => m.key) : monitored;
120
+ const next = new Set(base);
121
+ if (event.target.checked)
122
+ next.add(model.key);
123
+ else
124
+ next.delete(model.key);
125
+ // Auto-save on every change; all selected stores as null.
126
+ setMonitored(next.size === allModels.length ? null : [...next]);
127
+ } }), _jsx("span", { className: css.monitorName, title: model.key, children: model.name })] }, model.key))) })] }), _jsxs("div", { className: css.dialogSection, children: [_jsx("div", { className: css.dialogLabel, children: t('fullActionLabel') }), FULL_ACTIONS.map(action => (_jsxs("label", { className: css.radioRow, children: [_jsx("input", { type: "radio", name: "token-quota-onfull", checked: onFull === action.value, onChange: () => { setOnFull(action.value); } }), _jsx("span", { children: t(action.labelKey) })] }, action.value)))] }), _jsxs("div", { className: css.dialogSection, children: [_jsx("div", { className: css.dialogLabel, children: t('resetLabel') }), _jsx("div", { className: css.monitorHint, children: t('resetHint') }), _jsxs("div", { className: css.resetRow, children: [_jsx("select", { className: css.resetSelect, value: reset?.offsetHours ?? -new Date().getTimezoneOffset() / 60, onChange: (event) => {
128
+ setReset({
129
+ offsetHours: Number(event.target.value),
130
+ hour: reset?.hour ?? 0,
131
+ minute: reset?.minute ?? 0,
132
+ });
133
+ }, children: Array.from({ length: 27 }, (_, i) => i - 12).map(offset => (_jsxs("option", { value: offset, children: ["UTC", offset >= 0 ? `+${offset}` : offset] }, offset))) }), _jsx("select", { className: css.resetSelect, value: reset?.hour ?? 0, onChange: (event) => {
134
+ setReset({
135
+ offsetHours: reset?.offsetHours ?? -new Date().getTimezoneOffset() / 60,
136
+ hour: Number(event.target.value),
137
+ minute: reset?.minute ?? 0,
138
+ });
139
+ }, children: Array.from({ length: 24 }, (_, i) => i).map(hour => (_jsxs("option", { value: hour, children: [String(hour).padStart(2, '0'), " \u65F6"] }, hour))) }), _jsx("select", { className: css.resetSelect, value: reset?.minute ?? 0, onChange: (event) => {
140
+ setReset({
141
+ offsetHours: reset?.offsetHours ?? -new Date().getTimezoneOffset() / 60,
142
+ hour: reset?.hour ?? 0,
143
+ minute: Number(event.target.value),
144
+ });
145
+ }, children: Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (_jsxs("option", { value: minute, children: [String(minute).padStart(2, '0'), " \u5206"] }, minute))) })] })] })] }) })), logOpen && (_jsx("div", { className: css.dialogBackdrop, onClick: () => { actions.setLogOpen(false); }, children: _jsxs("div", { className: css.dialog, onClick: (event) => { event.stopPropagation(); }, children: [_jsxs("div", { className: css.dialogHeader, children: [_jsx("div", { className: css.dialogTitle, children: t('logsTitle') }), _jsx("button", { type: "button", className: css.dialogClose, title: t('close'), onClick: () => { actions.setLogOpen(false); }, children: "\u00D7" })] }), log !== null && log.entries.length === 0 && (_jsx("div", { className: css.notice, children: t('logEmpty') })), _jsxs("table", { className: css.logTable, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t('logDay') }), _jsx("th", { children: t('logModel') }), _jsx("th", { className: css.logUsedCol, children: t('logUsed') })] }) }), _jsx("tbody", { children: log?.entries.map(entry => (_jsxs("tr", { children: [_jsx("td", { className: css.logDayCol, children: entry.day }), _jsx("td", { className: css.logModelCol, title: entry.key, children: entry.key }), _jsx("td", { className: css.logUsedCol, children: formatTokens(entry.used) })] }, `${entry.day}/${entry.key}`))) })] })] }) }))] }));
146
+ }
147
+ //# sourceMappingURL=TokenQuotaPanel.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Token-quota browser half: a floating panel over the frame-wide
3
+ * `shell.overlay` seat. It renders one row per MONITORED model (directory
4
+ * merged with the polled quota snapshot), edits per-model daily caps through
5
+ * the settings scope, and switches the current session's model through the
6
+ * Host RPC — so when one model is full the user flips to another in place.
7
+ *
8
+ * Data flows by pull: the panel polls the plugin's own HTTP snapshot route
9
+ * (`GET /token-quota`) on a short interval and syncs monitoring/strategy
10
+ * preferences from the `token-quota` settings namespace. When the current
11
+ * model is a monitored, capped model at its daily cap, the configured
12
+ * full-quota strategy runs: `stop` shows a notice, the other three
13
+ * auto-switch to a suitable model.
14
+ */
15
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
16
+ import { type TokenQuotaKey } from './locales.ts';
17
+ export type { TokenQuotaPanelInjected } from './TokenQuotaPanel.tsx';
18
+ export type { ModelQuotaRow, TokenQuotaPanelState } from './store.ts';
19
+ export { mergeModelRows } from './store.ts';
20
+ export type { TokenQuotaKey } from './locales.ts';
21
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
22
+ interface LocaleNamespaceMap {
23
+ /** The token-quota floating panel's copy. */
24
+ 'token-quota': TokenQuotaKey;
25
+ }
26
+ }
27
+ /** Required services: slot registry, connection RPC, locale, settings scope. */
28
+ export declare const inject: string[];
29
+ /**
30
+ * Client plugin body: poll the Host snapshot route, run the full-quota
31
+ * strategy, wire the injected face (directory load, limit write, model
32
+ * switch, preferences), and register the floating panel into `shell.overlay`.
33
+ * @param ctx - client root context.
34
+ */
35
+ export declare function apply(ctx: ClientContext): void;
36
+ //# sourceMappingURL=index.d.ts.map