@jxgame2020/dsh-token-quota 0.1.30 → 0.1.32

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 CHANGED
@@ -214,6 +214,7 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
214
214
  ]).default("stop"),
215
215
  checkUpdates: z.boolean().default(true),
216
216
  dimWhenIdle: z.boolean().default(false),
217
+ order: z.array(z.string()).default([]),
217
218
  reset: z.any().default(null),
218
219
  music: z.object({
219
220
  enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_MUSIC.enabled),
@@ -281,7 +282,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
281
282
  onFull: "stop",
282
283
  checkUpdates: true,
283
284
  dimWhenIdle: false,
284
- music: { ...TOKEN_QUOTA_DEFAULT_MUSIC }
285
+ music: { ...TOKEN_QUOTA_DEFAULT_MUSIC },
286
+ order: []
285
287
  });
286
288
  /** Whether update checks are enabled (mirrors the settings document). */
287
289
  checkUpdates = true;
@@ -334,6 +336,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
334
336
  onFull: doc.onFull === "switchPriority" ? "switchAll" : doc.onFull ?? "stop",
335
337
  checkUpdates: doc.checkUpdates ?? true,
336
338
  dimWhenIdle: doc.dimWhenIdle ?? false,
339
+ order: Array.isArray(doc.order) ? doc.order : [],
337
340
  reset: doc.reset ?? void 0,
338
341
  music: {
339
342
  enabled: doc.music?.enabled ?? TOKEN_QUOTA_DEFAULT_MUSIC.enabled,
@@ -515,7 +518,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
515
518
  return this.upgradeCheck;
516
519
  }
517
520
  async doRefreshUpgrade() {
518
- const current = "0.1.30";
521
+ const current = "0.1.32";
519
522
  let latest;
520
523
  try {
521
524
  const response = await fetch(REGISTRY_LATEST_URL, {
@@ -20,6 +20,8 @@ export interface TokenQuotaPanelInjected {
20
20
  setCheckUpdates: (enabled: boolean) => void;
21
21
  /** Persist whether the panel dims while the pointer is away / typing. */
22
22
  setDimWhenIdle: (enabled: boolean) => void;
23
+ /** Persist the drag-to-reorder display order of model rows. */
24
+ setModelOrder: (order: string[]) => void;
23
25
  /** Toggle the live music output (requires a user gesture to start audio). */
24
26
  setMusicEnabled: (enabled: boolean) => void;
25
27
  /** Change the live-music master volume 0..1. */
@@ -40,5 +42,5 @@ export type TokenQuotaPanelComponentProps = PropsRuntime<'shell.overlay'> & Prop
40
42
  * @param props - composed slot props.
41
43
  * @returns the panel element tree.
42
44
  */
43
- export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
45
+ export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
44
46
  //# sourceMappingURL=TokenQuotaPanel.d.ts.map
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  * callbacks.
11
11
  */
12
12
  import { useEffect, useMemo, useRef, useState } from 'react';
13
- import { mergeModelRows } from "./store.js";
13
+ import { mergeModelRows, reorderKeys } from "./store.js";
14
14
  import css from './TokenQuotaPanel.module.css';
15
15
  /** Compact a token count for display. */
16
16
  function formatTokens(n) {
@@ -86,7 +86,7 @@ function loadPanelPos() {
86
86
  * @param props - composed slot props.
87
87
  * @returns the panel element tree.
88
88
  */
89
- export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }) {
89
+ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }) {
90
90
  const [collapsed, setCollapsed] = useState(false);
91
91
  const [drafts, setDrafts] = useState({});
92
92
  const [editingKey, setEditingKey] = useState(null);
@@ -95,8 +95,12 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
95
95
  // so the chat content behind it stays readable; hovering restores it.
96
96
  const [hovered, setHovered] = useState(false);
97
97
  const [inputActive, setInputActive] = useState(false);
98
- // Active settings-dialog tab: quota limits vs. live-music options.
98
+ // Active settings-dialog tab: quota limits vs. live-music vs. misc.
99
99
  const [settingsTab, setSettingsTab] = useState('quota');
100
+ // Drag-to-reorder state for the model rows: the key being dragged and
101
+ // the key currently hovered as the drop target.
102
+ const [dragKey, setDragKey] = useState(null);
103
+ const [dropKey, setDropKey] = useState(null);
100
104
  // Copy-button feedback: which command was just copied (label flips to
101
105
  // 「已复制」for a moment so the click is perceivable).
102
106
  const [copiedCmd, setCopiedCmd] = useState(null);
@@ -131,6 +135,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
131
135
  const onFull = useStore(s => s.onFull);
132
136
  const checkUpdates = useStore(s => s.checkUpdates);
133
137
  const dimWhenIdle = useStore(s => s.dimWhenIdle);
138
+ const order = useStore(s => s.order);
134
139
  const musicEnabled = useStore(s => s.musicEnabled);
135
140
  const musicVolume = useStore(s => s.musicVolume);
136
141
  const musicStyle = useStore(s => s.musicStyle);
@@ -198,12 +203,27 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
198
203
  clearTimeout(copyTimerRef.current);
199
204
  };
200
205
  }, []);
201
- const rows = useMemo(() => mergeModelRows(groups, snapshot, current), [groups, snapshot, current]);
202
- // All directory models, flattened for the monitoring picker.
203
- const allModels = useMemo(() => groups.flatMap(group => group.models.map((model) => ({
204
- key: `${group.id}/${model.id}`,
205
- name: model.name,
206
- }))).sort((a, b) => a.key.localeCompare(b.key)), [groups]);
206
+ const rows = useMemo(() => mergeModelRows(groups, snapshot, current, order), [groups, snapshot, current, order]);
207
+ // All directory models, flattened for the monitoring picker — same drag
208
+ // order as the panel rows so both lists read identically.
209
+ const allModels = useMemo(() => {
210
+ const list = groups.flatMap(group => group.models.map((model) => ({
211
+ key: `${group.id}/${model.id}`,
212
+ name: model.name,
213
+ })));
214
+ const rank = new Map(order.map((key, index) => [key, index]));
215
+ return list.sort((left, right) => {
216
+ const leftRank = rank.get(left.key);
217
+ const rightRank = rank.get(right.key);
218
+ if (leftRank !== undefined && rightRank !== undefined)
219
+ return leftRank - rightRank;
220
+ if (leftRank !== undefined)
221
+ return -1;
222
+ if (rightRank !== undefined)
223
+ return 1;
224
+ return left.key.localeCompare(right.key);
225
+ });
226
+ }, [groups, order]);
207
227
  const isMonitoredKey = (key) => monitored === null || monitored.includes(key);
208
228
  // Log dialog: filter by search, then paginate.
209
229
  const filteredLogEntries = useMemo(() => {
@@ -309,11 +329,28 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
309
329
  ? css.fillWarn
310
330
  : css.fillIdle;
311
331
  const editing = editingKey === row.key;
312
- 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
332
+ return (_jsxs("div", { className: `${css.row}${dragKey === row.key ? ` ${css.rowDragging}` : ''}${dropKey === row.key && dragKey !== row.key ? ` ${css.rowDropTarget}` : ''}`, onDragOver: (event) => {
333
+ if (dragKey === null || dragKey === row.key)
334
+ return;
335
+ event.preventDefault();
336
+ setDropKey(row.key);
337
+ }, onDrop: (event) => {
338
+ event.preventDefault();
339
+ if (dragKey !== null && dragKey !== row.key) {
340
+ setModelOrder(reorderKeys(rows.map(r => r.key), dragKey, row.key));
341
+ }
342
+ setDragKey(null);
343
+ setDropKey(null);
344
+ }, onDragEnd: () => { setDragKey(null); setDropKey(null); }, children: [_jsxs("div", { className: css.rowHeader, children: [_jsx("span", { className: css.rowDrag, draggable: true, title: t('reorderHint'), onDragStart: (event) => {
345
+ setDragKey(row.key);
346
+ event.dataTransfer.effectAllowed = 'move';
347
+ // Firefox only starts a drag when some data is set.
348
+ event.dataTransfer.setData('text/plain', row.key);
349
+ }, children: "\u283F" }), _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
313
350
  ? `${formatTokens(row.used)} / ${formatTokens(row.limit)}`
314
351
  : `${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')
315
352
  commitLimit(row); } }), _jsx("button", { type: "button", className: css.save, onClick: () => { commitLimit(row); }, children: t('save') })] }))] }, row.key));
316
- })] }), dialogOpen && (_jsxs("div", { ref: settingsRef, className: css.dialog, style: settingsPos !== null ? { left: settingsPos.x, top: settingsPos.y, transform: 'none' } : undefined, children: [_jsxs("div", { className: css.dialogHeader, onPointerDown: (event) => { beginDrag(event, settingsRef.current, setSettingsPos); }, children: [_jsx("div", { className: css.dialogTitle, children: t('settingsTitle') }), _jsx("button", { type: "button", className: css.dialogClose, title: t('close'), onClick: () => { actions.setDialogOpen(false); }, onPointerDown: (event) => { event.stopPropagation(); }, children: "\u00D7" })] }), _jsxs("div", { className: css.dialogTabs, role: "tablist", children: [_jsx("button", { type: "button", role: "tab", "aria-selected": settingsTab === 'quota', className: `${css.dialogTab}${settingsTab === 'quota' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('quota'); }, children: t('settingsTabQuota') }), _jsx("button", { type: "button", role: "tab", "aria-selected": settingsTab === 'music', className: `${css.dialogTab}${settingsTab === 'music' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('music'); }, children: t('settingsTabMusic') })] }), settingsTab === 'quota' && (_jsxs(_Fragment, { children: [_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) => {
353
+ })] }), dialogOpen && (_jsxs("div", { ref: settingsRef, className: `${css.dialog} ${css.settingsDialog}`, style: settingsPos !== null ? { left: settingsPos.x, top: settingsPos.y, transform: 'none' } : undefined, children: [_jsxs("div", { className: css.dialogHeader, onPointerDown: (event) => { beginDrag(event, settingsRef.current, setSettingsPos); }, children: [_jsx("div", { className: css.dialogTitle, children: t('settingsTitle') }), _jsx("button", { type: "button", className: css.dialogClose, title: t('close'), onClick: () => { actions.setDialogOpen(false); }, onPointerDown: (event) => { event.stopPropagation(); }, children: "\u00D7" })] }), _jsxs("div", { className: css.dialogTabs, role: "tablist", children: [_jsx("button", { type: "button", role: "tab", "aria-selected": settingsTab === 'quota', className: `${css.dialogTab}${settingsTab === 'quota' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('quota'); }, children: t('settingsTabQuota') }), _jsx("button", { type: "button", role: "tab", "aria-selected": settingsTab === 'music', className: `${css.dialogTab}${settingsTab === 'music' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('music'); }, children: t('settingsTabMusic') }), _jsx("button", { type: "button", role: "tab", "aria-selected": settingsTab === 'other', className: `${css.dialogTab}${settingsTab === 'other' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('other'); }, children: t('settingsTabOther') })] }), settingsTab === 'quota' && (_jsxs(_Fragment, { children: [_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) => {
317
354
  const base = monitored === null ? allModels.map(m => m.key) : monitored;
318
355
  const next = new Set(base);
319
356
  if (event.target.checked)
@@ -322,7 +359,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
322
359
  next.delete(model.key);
323
360
  // Auto-save on every change; all selected stores as null.
324
361
  setMonitored(next.size === allModels.length ? null : [...next]);
325
- } }), _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) => {
362
+ } }), _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)))] })] })), settingsTab === 'other' && (_jsxs(_Fragment, { children: [_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) => {
326
363
  setReset({
327
364
  offsetHours: Number(event.target.value),
328
365
  hour: reset?.hour ?? 0,
@@ -340,7 +377,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
340
377
  hour: reset?.hour ?? 0,
341
378
  minute: Number(event.target.value),
342
379
  });
343
- }, children: Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (_jsxs("option", { value: minute, children: [String(minute).padStart(2, '0'), " \u5206"] }, minute))) })] })] }), _jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: dimWhenIdle, onChange: (event) => { setDimWhenIdle(event.target.checked); } }), _jsx("span", { children: t('dimWhenIdleLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('dimWhenIdleHint') })] })] })), settingsTab === 'music' && (_jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: musicEnabled, onChange: (event) => { setMusicEnabled(event.target.checked); } }), _jsx("span", { children: t('musicLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('musicHint') }), musicEnabled && (_jsxs(_Fragment, { children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('musicVolume') }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.05, value: musicVolume, onChange: (event) => { setMusicVolume(Number(event.target.value)); } })] }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('musicStyle') }), _jsxs("select", { value: musicStyle, onChange: (event) => { setMusicStyle(event.target.value); }, children: [_jsx("option", { value: "major", children: t('musicStyleMajor') }), _jsx("option", { value: "minor", children: t('musicStyleMinor') }), _jsx("option", { value: "pentatonic", children: t('musicStylePentatonic') })] })] }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: musicOnlyCurrentSession, onChange: (event) => { setMusicOnlyCurrentSession(event.target.checked); } }), _jsx("span", { children: t('musicOnlyCurrentSessionLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('musicOnlyCurrentSessionHint') })] }))] })), settingsTab === 'quota' && (_jsx(_Fragment, { children: _jsxs("div", { className: css.dialogSection, children: [_jsxs("div", { className: css.checkUpdatesRow, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: checkUpdates, onChange: (event) => { setCheckUpdates(event.target.checked); } }), _jsx("span", { children: t('checkUpdatesLabel') })] }), _jsx("button", { type: "button", className: css.checkBtn, disabled: checkingUpdates, onClick: checkUpdatesNow, children: checkingUpdates
380
+ }, children: Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (_jsxs("option", { value: minute, children: [String(minute).padStart(2, '0'), " \u5206"] }, minute))) })] })] }), _jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: dimWhenIdle, onChange: (event) => { setDimWhenIdle(event.target.checked); } }), _jsx("span", { children: t('dimWhenIdleLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('dimWhenIdleHint') })] })] })), settingsTab === 'music' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: musicEnabled, onChange: (event) => { setMusicEnabled(event.target.checked); } }), _jsx("span", { children: t('musicLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('musicHint') })] }), musicEnabled && (_jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('musicVolume') }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.05, value: musicVolume, onChange: (event) => { setMusicVolume(Number(event.target.value)); } })] }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('musicStyle') }), _jsxs("select", { value: musicStyle, onChange: (event) => { setMusicStyle(event.target.value); }, children: [_jsx("option", { value: "major", children: t('musicStyleMajor') }), _jsx("option", { value: "minor", children: t('musicStyleMinor') }), _jsx("option", { value: "pentatonic", children: t('musicStylePentatonic') })] })] }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: musicOnlyCurrentSession, onChange: (event) => { setMusicOnlyCurrentSession(event.target.checked); } }), _jsx("span", { children: t('musicOnlyCurrentSessionLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('musicOnlyCurrentSessionHint') })] }))] })), settingsTab === 'other' && (_jsx(_Fragment, { children: _jsxs("div", { className: css.dialogSection, children: [_jsxs("div", { className: css.checkUpdatesRow, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: checkUpdates, onChange: (event) => { setCheckUpdates(event.target.checked); } }), _jsx("span", { children: t('checkUpdatesLabel') })] }), _jsx("button", { type: "button", className: css.checkBtn, disabled: checkingUpdates, onClick: checkUpdatesNow, children: checkingUpdates
344
381
  ? t('checkingUpdates')
345
382
  : lastCheckResult === 'up-to-date'
346
383
  ? t('checkUpToDate')
@@ -66,6 +66,29 @@ export function apply(ctx) {
66
66
  music.onAction(action);
67
67
  });
68
68
  ctx.effect(() => () => { sse.close(); }, 'token-quota-ui: music SSE');
69
+ // Browser autoplay policy: an AudioContext only starts inside a user
70
+ // gesture. When the switch was left ON and the page reloads, the setting
71
+ // is enabled but no gesture has happened yet — so the engine stays silent
72
+ // until the user toggles it off/on. Arm a one-shot gesture listener that
73
+ // starts the engine on the first click / keypress instead.
74
+ const autoStartMusic = () => {
75
+ if (!music.isEnabled || music.started) {
76
+ if (music.started) {
77
+ window.removeEventListener('pointerdown', autoStartMusic);
78
+ window.removeEventListener('keydown', autoStartMusic);
79
+ }
80
+ return;
81
+ }
82
+ void music.ensureStarted();
83
+ window.removeEventListener('pointerdown', autoStartMusic);
84
+ window.removeEventListener('keydown', autoStartMusic);
85
+ };
86
+ window.addEventListener('pointerdown', autoStartMusic);
87
+ window.addEventListener('keydown', autoStartMusic);
88
+ ctx.effect(() => () => {
89
+ window.removeEventListener('pointerdown', autoStartMusic);
90
+ window.removeEventListener('keydown', autoStartMusic);
91
+ }, 'token-quota-ui: music autostart gestures');
69
92
  /** Build a writable music-settings clone with defaults applied. */
70
93
  const musicDefaults = (raw) => ({
71
94
  enabled: raw?.enabled ?? false,
@@ -227,6 +250,7 @@ export function apply(ctx) {
227
250
  bound?.setSettings(lastMonitored, lastOnFull);
228
251
  bound?.setCheckUpdates(doc?.checkUpdates ?? true);
229
252
  bound?.setDimWhenIdle(doc?.dimWhenIdle ?? false);
253
+ bound?.setOrder(Array.isArray(doc?.order) ? doc.order : []);
230
254
  bound?.setReset(lastReset);
231
255
  const musicSettings = musicDefaults(doc?.music);
232
256
  lastOnlyCurrentSession = musicSettings.onlyCurrentSession;
@@ -309,6 +333,11 @@ export function apply(ctx) {
309
333
  bound?.setDimWhenIdle(enabled);
310
334
  void scope.set('dimWhenIdle', enabled);
311
335
  };
336
+ /** Persist the drag-to-reorder display order of model rows. */
337
+ const setModelOrder = (order) => {
338
+ bound?.setOrder(order);
339
+ void scope.set('order', order);
340
+ };
312
341
  const setMusicEnabled = (enabled) => {
313
342
  const doc = scope.getSnapshot().value;
314
343
  const current = musicDefaults(doc?.music);
@@ -426,6 +455,7 @@ export function apply(ctx) {
426
455
  load, setLimit, selectModel, setMonitored, setOnFull, setReset,
427
456
  setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle,
428
457
  setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession,
458
+ setModelOrder,
429
459
  };
430
460
  };
431
461
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -27,6 +27,8 @@ export declare const zh: {
27
27
  settingsTitle: string;
28
28
  settingsTabQuota: string;
29
29
  settingsTabMusic: string;
30
+ settingsTabOther: string;
31
+ reorderHint: string;
30
32
  monitorLabel: string;
31
33
  monitorHint: string;
32
34
  monitorAll: string;
@@ -109,6 +111,8 @@ export declare const en: {
109
111
  settingsTitle: string;
110
112
  settingsTabQuota: string;
111
113
  settingsTabMusic: string;
114
+ settingsTabOther: string;
115
+ reorderHint: string;
112
116
  monitorLabel: string;
113
117
  monitorHint: string;
114
118
  monitorAll: string;
@@ -27,6 +27,8 @@ export const zh = {
27
27
  settingsTitle: '设置',
28
28
  settingsTabQuota: '限额设置',
29
29
  settingsTabMusic: '音乐设置',
30
+ settingsTabOther: '其它',
31
+ reorderHint: '拖动排序',
30
32
  monitorLabel: '监控模型',
31
33
  monitorHint: '未勾选的模型不显示、不计入用量、不受限额限制',
32
34
  monitorAll: '全部',
@@ -107,6 +109,8 @@ export const en = {
107
109
  settingsTitle: 'Settings',
108
110
  settingsTabQuota: 'Quota',
109
111
  settingsTabMusic: 'Music',
112
+ settingsTabOther: 'Other',
113
+ reorderHint: 'Drag to reorder',
110
114
  monitorLabel: 'Monitored models',
111
115
  monitorHint: 'Unchecked models are hidden, not metered, and never capped',
112
116
  monitorAll: 'All',
@@ -56,6 +56,8 @@ export declare class TokenQuotaMusic {
56
56
  private static readonly STREAM_TIMEOUT_MS;
57
57
  /** True once the audio context has been started (needs a user gesture). */
58
58
  get started(): boolean;
59
+ /** True when the master switch is on (mirrors the settings document). */
60
+ get isEnabled(): boolean;
59
61
  /**
60
62
  * Start the audio context and probe for a MIDI output (once). Must be
61
63
  * called inside a user gesture so the AudioContext can resume.
@@ -98,6 +98,10 @@ export class TokenQuotaMusic {
98
98
  get started() {
99
99
  return this.audio !== null;
100
100
  }
101
+ /** True when the master switch is on (mirrors the settings document). */
102
+ get isEnabled() {
103
+ return this.enabled;
104
+ }
101
105
  /**
102
106
  * Start the audio context and probe for a MIDI output (once). Must be
103
107
  * called inside a user gesture so the AudioContext can resume.
@@ -50,6 +50,11 @@ export interface TokenQuotaPanelState {
50
50
  checkUpdates: boolean;
51
51
  /** Whether the panel dims while the pointer is away / while typing. */
52
52
  dimWhenIdle: boolean;
53
+ /**
54
+ * Preferred display order of model keys, written by dragging panel rows.
55
+ * Keys not listed keep their catalog order after the listed ones.
56
+ */
57
+ order: string[];
53
58
  /** Live music settings (mirrored from the settings document). */
54
59
  musicEnabled: boolean;
55
60
  musicVolume: number;
@@ -97,6 +102,8 @@ export type TokenQuotaPanelActions = {
97
102
  setSettings: (d: TokenQuotaPanelState, monitored: string[] | null, onFull: TokenQuotaFullAction) => void;
98
103
  setCheckUpdates: (d: TokenQuotaPanelState, checkUpdates: boolean) => void;
99
104
  setDimWhenIdle: (d: TokenQuotaPanelState, dimWhenIdle: boolean) => void;
105
+ /** Replace the drag-to-reorder model display order. */
106
+ setOrder: (d: TokenQuotaPanelState, order: string[]) => void;
100
107
  setMusicEnabled: (d: TokenQuotaPanelState, enabled: boolean) => void;
101
108
  setMusicVolume: (d: TokenQuotaPanelState, volume: number) => void;
102
109
  setMusicStyle: (d: TokenQuotaPanelState, style: TokenQuotaMusicStyle) => void;
@@ -131,7 +138,14 @@ export declare function createTokenQuotaPanelStore(): EngineStoreHandle<TokenQuo
131
138
  * @param groups - advisory provider groups of the current session.
132
139
  * @param snapshot - latest quota snapshot, or null before the first one.
133
140
  * @param current - current model selection reported by the Host.
134
- * @returns rows sorted by key.
141
+ * @param order - user's drag-to-reorder preference (model keys); listed keys
142
+ * come first in that exact order, the rest follow in catalog order.
143
+ * @returns rows in display order.
144
+ */
145
+ export declare function mergeModelRows(groups: readonly ModelProviderGroup[], snapshot: TokenQuotaSnapshot | null, current: ModelSelection | null, order?: readonly string[]): ModelQuotaRow[];
146
+ /**
147
+ * Move one key to another key's position, returning the full reordered key
148
+ * list (the shape persisted in the settings document's `order`).
135
149
  */
136
- export declare function mergeModelRows(groups: readonly ModelProviderGroup[], snapshot: TokenQuotaSnapshot | null, current: ModelSelection | null): ModelQuotaRow[];
150
+ export declare function reorderKeys(keys: readonly string[], from: string, to: string): string[];
137
151
  //# sourceMappingURL=store.d.ts.map
@@ -28,6 +28,7 @@ export function createTokenQuotaPanelStore() {
28
28
  onFull: 'stop',
29
29
  checkUpdates: true,
30
30
  dimWhenIdle: false,
31
+ order: [],
31
32
  musicEnabled: false,
32
33
  musicVolume: 0.5,
33
34
  musicStyle: 'pentatonic',
@@ -57,6 +58,7 @@ export function createTokenQuotaPanelStore() {
57
58
  setSettings: (d, monitored, onFull) => { d.monitored = monitored; d.onFull = onFull; },
58
59
  setCheckUpdates: (d, checkUpdates) => { d.checkUpdates = checkUpdates; },
59
60
  setDimWhenIdle: (d, dimWhenIdle) => { d.dimWhenIdle = dimWhenIdle; },
61
+ setOrder: (d, order) => { d.order = order; },
60
62
  setMusicEnabled: (d, enabled) => { d.musicEnabled = enabled; },
61
63
  setMusicVolume: (d, volume) => { d.musicVolume = volume; },
62
64
  setMusicStyle: (d, style) => { d.musicStyle = style; },
@@ -104,9 +106,11 @@ export function createTokenQuotaPanelStore() {
104
106
  * @param groups - advisory provider groups of the current session.
105
107
  * @param snapshot - latest quota snapshot, or null before the first one.
106
108
  * @param current - current model selection reported by the Host.
107
- * @returns rows sorted by key.
109
+ * @param order - user's drag-to-reorder preference (model keys); listed keys
110
+ * come first in that exact order, the rest follow in catalog order.
111
+ * @returns rows in display order.
108
112
  */
109
- export function mergeModelRows(groups, snapshot, current) {
113
+ export function mergeModelRows(groups, snapshot, current, order = []) {
110
114
  const entryByKey = new Map((snapshot?.entries ?? []).map(entry => [entry.key, entry]));
111
115
  const rows = new Map();
112
116
  for (const group of groups) {
@@ -137,6 +141,35 @@ export function mergeModelRows(groups, snapshot, current) {
137
141
  current: current !== null && current.provider === entry.provider && current.model === entry.model,
138
142
  });
139
143
  }
140
- return [...rows.values()].sort((left, right) => left.key.localeCompare(right.key));
144
+ // Drag preference first (in its own order), everything else after it,
145
+ // alphabetically — so a newly discovered model still lands somewhere stable.
146
+ const rank = new Map(order.map((key, index) => [key, index]));
147
+ return [...rows.values()].sort((left, right) => {
148
+ const leftRank = rank.get(left.key);
149
+ const rightRank = rank.get(right.key);
150
+ if (leftRank !== undefined && rightRank !== undefined)
151
+ return leftRank - rightRank;
152
+ if (leftRank !== undefined)
153
+ return -1;
154
+ if (rightRank !== undefined)
155
+ return 1;
156
+ return left.key.localeCompare(right.key);
157
+ });
158
+ }
159
+ /**
160
+ * Move one key to another key's position, returning the full reordered key
161
+ * list (the shape persisted in the settings document's `order`).
162
+ */
163
+ export function reorderKeys(keys, from, to) {
164
+ const next = [...keys];
165
+ const fromIndex = next.indexOf(from);
166
+ const toIndex = next.indexOf(to);
167
+ if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex)
168
+ return next;
169
+ const [moved] = next.splice(fromIndex, 1);
170
+ if (moved === undefined)
171
+ return next;
172
+ next.splice(toIndex, 0, moved);
173
+ return next;
141
174
  }
142
175
  //# sourceMappingURL=store.js.map
@@ -202,6 +202,8 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
202
202
  checkUpdates: z.boolean().default(true),
203
203
  // Client-side appearance preference; the host only stores it.
204
204
  dimWhenIdle: z.boolean().default(false),
205
+ // Drag-to-reorder: display order of model keys, written by the panel.
206
+ order: z.array(z.string()).default([]),
205
207
  // `reset` is a user-facing preference outside the validated surface: the
206
208
  // panel writes it and the host validates the shape at runtime. `z.any` with
207
209
  // a null default keeps it out of the strict fields above.
@@ -273,7 +275,7 @@ export class TokenQuotaService extends Service {
273
275
  history = {};
274
276
  limits = {};
275
277
  monitored = undefined;
276
- settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false, music: { ...TOKEN_QUOTA_DEFAULT_MUSIC } });
278
+ settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false, music: { ...TOKEN_QUOTA_DEFAULT_MUSIC }, order: [] });
277
279
  /** Whether update checks are enabled (mirrors the settings document). */
278
280
  checkUpdates = true;
279
281
  /** Cached upgrade availability; recomputed by {@link refreshUpgrade}. */
@@ -337,6 +339,7 @@ export class TokenQuotaService extends Service {
337
339
  onFull: doc.onFull === 'switchPriority' ? 'switchAll' : (doc.onFull ?? 'stop'),
338
340
  checkUpdates: doc.checkUpdates ?? true,
339
341
  dimWhenIdle: doc.dimWhenIdle ?? false,
342
+ order: Array.isArray(doc.order) ? doc.order : [],
340
343
  reset: doc.reset ?? undefined,
341
344
  music: {
342
345
  enabled: doc.music?.enabled ?? TOKEN_QUOTA_DEFAULT_MUSIC.enabled,
@@ -143,6 +143,12 @@ export interface TokenQuotaSettings {
143
143
  * an attached MIDI output. Off by default.
144
144
  */
145
145
  music: TokenQuotaMusicSettings;
146
+ /**
147
+ * Preferred display order of model keys (`provider/model`), set by dragging
148
+ * rows in the panel. Keys not listed here keep their catalog order after the
149
+ * listed ones. Empty by default (pure catalog/alphabetical order).
150
+ */
151
+ order: string[];
146
152
  }
147
153
  /** Token-quota plugin configuration. */
148
154
  export interface TokenQuotaConfig {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jxgame2020/dsh-token-quota",
3
3
  "description": "Daily per-model token quota accounting, enforcement, and a floating quota panel for the DeepSeek Harness — a fully self-contained plugin (no core-Harness wiring, no generated RPC contract)",
4
- "version": "0.1.30",
4
+ "version": "0.1.32",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },