@jxgame2020/dsh-token-quota 0.1.22 → 0.1.23

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
@@ -23,6 +23,12 @@ import { LlmError } from "@deepseek-ai/dsh-llm";
23
23
  const TOKEN_QUOTA_NAMESPACE = "token-quota";
24
24
  /** Stable machine code thrown from `agent/request` when a model is over its daily cap. */
25
25
  const TOKEN_QUOTA_EXCEEDED_CODE = "TOKEN_QUOTA_EXCEEDED";
26
+ /** Default music settings: silent until the user flips the panel switch. */
27
+ const TOKEN_QUOTA_DEFAULT_MUSIC = {
28
+ enabled: false,
29
+ volume: .5,
30
+ style: "major"
31
+ };
26
32
  /** Build the stable per-model key shared by the counter, settings, and snapshot. */
27
33
  function tokenQuotaKey(provider, model) {
28
34
  return `${provider}/${model}`;
@@ -207,7 +213,16 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
207
213
  ]).default("stop"),
208
214
  checkUpdates: z.boolean().default(true),
209
215
  dimWhenIdle: z.boolean().default(false),
210
- reset: z.any().default(null)
216
+ reset: z.any().default(null),
217
+ music: z.object({
218
+ enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_MUSIC.enabled),
219
+ volume: z.number().min(0).max(1).default(TOKEN_QUOTA_DEFAULT_MUSIC.volume),
220
+ style: z.union([
221
+ "major",
222
+ "minor",
223
+ "pentatonic"
224
+ ]).default(TOKEN_QUOTA_DEFAULT_MUSIC.style)
225
+ }).default({ ...TOKEN_QUOTA_DEFAULT_MUSIC })
211
226
  });
212
227
  /** Two-digit zero-pad helper. */
213
228
  function pad2(value) {
@@ -263,7 +278,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
263
278
  monitored: [],
264
279
  onFull: "stop",
265
280
  checkUpdates: true,
266
- dimWhenIdle: false
281
+ dimWhenIdle: false,
282
+ music: { ...TOKEN_QUOTA_DEFAULT_MUSIC }
267
283
  });
268
284
  /** Whether update checks are enabled (mirrors the settings document). */
269
285
  checkUpdates = true;
@@ -291,6 +307,10 @@ var TokenQuotaService = class TokenQuotaService extends Service {
291
307
  disposeRouteCheck;
292
308
  /** Disposer for the optional clear-log route (`POST /token-quota/clear-log`). */
293
309
  disposeRouteClear;
310
+ /** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
311
+ disposeRouteEvents;
312
+ /** Live SSE subscribers receiving streamed music actions. */
313
+ musicClients = /* @__PURE__ */ new Set();
294
314
  /** Disposer for the webServer-arrival watcher when the service mounts later. */
295
315
  disposeRouteWatcher;
296
316
  /**
@@ -312,7 +332,12 @@ var TokenQuotaService = class TokenQuotaService extends Service {
312
332
  onFull: doc.onFull === "switchPriority" ? "switchAll" : doc.onFull ?? "stop",
313
333
  checkUpdates: doc.checkUpdates ?? true,
314
334
  dimWhenIdle: doc.dimWhenIdle ?? false,
315
- reset: doc.reset ?? void 0
335
+ reset: doc.reset ?? void 0,
336
+ music: {
337
+ enabled: doc.music?.enabled ?? TOKEN_QUOTA_DEFAULT_MUSIC.enabled,
338
+ volume: doc.music?.volume ?? TOKEN_QUOTA_DEFAULT_MUSIC.volume,
339
+ style: doc.music?.style ?? TOKEN_QUOTA_DEFAULT_MUSIC.style
340
+ }
316
341
  };
317
342
  };
318
343
  },
@@ -439,6 +464,32 @@ var TokenQuotaService = class TokenQuotaService extends Service {
439
464
  });
440
465
  }
441
466
  });
467
+ this.disposeRouteEvents = server.register({
468
+ kind: "exact",
469
+ path: "/token-quota/events",
470
+ handler: (req, res) => {
471
+ res.writeHead(200, {
472
+ "content-type": "text/event-stream",
473
+ "cache-control": "no-store",
474
+ "connection": "keep-alive"
475
+ });
476
+ this.musicClients.add(res);
477
+ res.write(": token-quota music stream\n\n");
478
+ req.on("close", () => {
479
+ this.musicClients.delete(res);
480
+ });
481
+ }
482
+ });
483
+ }
484
+ /** Broadcast one action to every open SSE music subscriber. */
485
+ pushMusicAction(action) {
486
+ if (this.musicClients.size === 0) return;
487
+ const data = `event: action\ndata: ${JSON.stringify(action)}\n\n`;
488
+ for (const res of this.musicClients) try {
489
+ res.write(data);
490
+ } catch {
491
+ this.musicClients.delete(res);
492
+ }
442
493
  }
443
494
  /** Query the npm registry and rebuild the cached upgrade info (coalesced). */
444
495
  refreshUpgrade() {
@@ -453,7 +504,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
453
504
  return this.upgradeCheck;
454
505
  }
455
506
  async doRefreshUpgrade() {
456
- const current = "0.1.22";
507
+ const current = "0.1.23";
457
508
  let latest;
458
509
  try {
459
510
  const response = await fetch(REGISTRY_LATEST_URL, {
@@ -585,20 +636,69 @@ var TokenQuotaService = class TokenQuotaService extends Service {
585
636
  return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : 0;
586
637
  }
587
638
  onSessionEvent(session, event) {
588
- if (event.type === "request/header") {
589
- const { provider, model } = event.data.header.config;
590
- this.headerKeys.set(session, tokenQuotaKey(provider, model));
591
- return;
592
- }
593
- if (event.type === "assistant/message" && event.data.usage !== void 0) {
594
- const key = this.headerKeys.get(session);
595
- if (key === void 0) return;
596
- if (!this.isMonitored(key)) return;
597
- const tokens = usageTokens(event.data.usage);
598
- if (tokens <= 0) return;
599
- this.rollCycleIfNeeded();
600
- this.usage[key] = (this.usage[key] ?? 0) + tokens;
601
- this.scheduleWrite();
639
+ const sessionId = String(session.id);
640
+ switch (event.type) {
641
+ case "turn/start":
642
+ this.pushMusicAction({
643
+ type: "turn/start",
644
+ sessionId
645
+ });
646
+ break;
647
+ case "turn/end":
648
+ this.pushMusicAction({
649
+ type: "turn/end",
650
+ sessionId,
651
+ reason: event.data.reason.kind
652
+ });
653
+ break;
654
+ case "step/start":
655
+ this.pushMusicAction({
656
+ type: "step/start",
657
+ sessionId
658
+ });
659
+ break;
660
+ case "request/header": {
661
+ const { provider, model } = event.data.header.config;
662
+ this.headerKeys.set(session, tokenQuotaKey(provider, model));
663
+ this.pushMusicAction({
664
+ type: "request/header",
665
+ sessionId,
666
+ provider,
667
+ model
668
+ });
669
+ return;
670
+ }
671
+ case "tool/call":
672
+ this.pushMusicAction({
673
+ type: "tool/call",
674
+ sessionId,
675
+ name: event.data.name
676
+ });
677
+ break;
678
+ case "tool/result":
679
+ this.pushMusicAction({
680
+ type: "tool/result",
681
+ sessionId,
682
+ error: event.data.error !== void 0
683
+ });
684
+ break;
685
+ case "assistant/message":
686
+ this.pushMusicAction({
687
+ type: "assistant/message",
688
+ sessionId,
689
+ interrupted: event.data.interrupted === true
690
+ });
691
+ if (event.data.usage !== void 0) {
692
+ const key = this.headerKeys.get(session);
693
+ if (key === void 0) return;
694
+ if (!this.isMonitored(key)) return;
695
+ const tokens = usageTokens(event.data.usage);
696
+ if (tokens <= 0) return;
697
+ this.rollCycleIfNeeded();
698
+ this.usage[key] = (this.usage[key] ?? 0) + tokens;
699
+ this.scheduleWrite();
700
+ }
701
+ break;
602
702
  }
603
703
  }
604
704
  /**
@@ -750,6 +850,11 @@ var TokenQuotaService = class TokenQuotaService extends Service {
750
850
  if (this.disposeRouteLog !== void 0) this.disposeRouteLog();
751
851
  if (this.disposeRouteCheck !== void 0) this.disposeRouteCheck();
752
852
  if (this.disposeRouteClear !== void 0) this.disposeRouteClear();
853
+ if (this.disposeRouteEvents !== void 0) this.disposeRouteEvents();
854
+ for (const res of this.musicClients) try {
855
+ res.end();
856
+ } catch {}
857
+ this.musicClients.clear();
753
858
  this.flush();
754
859
  }
755
860
  safeSplit(key) {
@@ -1,6 +1,6 @@
1
1
  import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
3
- import type { TokenQuotaFullAction, TokenQuotaReset } from '@jxgame2020/dsh-token-quota/types';
3
+ import type { TokenQuotaFullAction, TokenQuotaMusicStyle, TokenQuotaReset } from '@jxgame2020/dsh-token-quota/types';
4
4
  import type { createTokenQuotaPanelStore } from './store.ts';
5
5
  /** Injected business face: data loading and mutations wired in `apply`. */
6
6
  export interface TokenQuotaPanelInjected {
@@ -20,6 +20,12 @@ 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
+ /** Toggle the live music output (requires a user gesture to start audio). */
24
+ setMusicEnabled: (enabled: boolean) => void;
25
+ /** Change the live-music master volume 0..1. */
26
+ setMusicVolume: (volume: number) => void;
27
+ /** Change the live-music harmonic style. */
28
+ setMusicStyle: (style: TokenQuotaMusicStyle) => void;
23
29
  /** Ask the Host to check the registry right now, then refresh the snapshot. */
24
30
  checkUpdatesNow: () => void;
25
31
  /** Ask the Host to clear log entries before a date, then refresh the log. */
@@ -32,5 +38,5 @@ export type TokenQuotaPanelComponentProps = PropsRuntime<'shell.overlay'> & Prop
32
38
  * @param props - composed slot props.
33
39
  * @returns the panel element tree.
34
40
  */
35
- export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
41
+ export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setMusicEnabled, setMusicVolume, setMusicStyle, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
36
42
  //# sourceMappingURL=TokenQuotaPanel.d.ts.map
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Token-quota floating panel, registered into the frame-wide `shell.overlay`
4
4
  * seat. Renders one row per MONITORED model of the current session's
@@ -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, useStore, actions, useSessions, }) {
89
+ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setMusicEnabled, setMusicVolume, setMusicStyle, useStore, actions, useSessions, }) {
90
90
  const [collapsed, setCollapsed] = useState(false);
91
91
  const [drafts, setDrafts] = useState({});
92
92
  const [editingKey, setEditingKey] = useState(null);
@@ -129,6 +129,9 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
129
129
  const onFull = useStore(s => s.onFull);
130
130
  const checkUpdates = useStore(s => s.checkUpdates);
131
131
  const dimWhenIdle = useStore(s => s.dimWhenIdle);
132
+ const musicEnabled = useStore(s => s.musicEnabled);
133
+ const musicVolume = useStore(s => s.musicVolume);
134
+ const musicStyle = useStore(s => s.musicStyle);
132
135
  const upgrade = useStore(s => s.upgrade);
133
136
  const upgradeDismissed = useStore(s => s.upgradeDismissed);
134
137
  const checkingUpdates = useStore(s => s.checkingUpdates);
@@ -294,7 +297,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
294
297
  : '';
295
298
  return (_jsxs("div", { ref: panelRef, className: `${css.panel}${panelDimClass !== '' ? ` ${panelDimClass}` : ''}`, style: panelPos !== null ? { left: panelPos.x, top: panelPos.y, right: 'auto' } : undefined, onPointerEnter: () => { setHovered(true); }, onPointerLeave: () => { setHovered(false); }, children: [_jsxs("div", { className: css.header, onPointerDown: (event) => { beginDrag(event, panelRef.current, applyPanelPos); }, children: [_jsxs("div", { className: css.headerText, children: [_jsxs("div", { className: css.title, children: [t('title'), __TOKEN_QUOTA_VERSION__ !== undefined && __TOKEN_QUOTA_VERSION__ !== '' && (upgrade !== null
296
299
  ? (_jsxs("span", { className: `${css.version} ${css.versionNew}`, title: t('upgradeAvailableHint'), children: ["v", __TOKEN_QUOTA_VERSION__, " \u2192 v", upgrade.latestVersion] }))
297
- : (_jsxs("span", { className: css.version, children: ["v", __TOKEN_QUOTA_VERSION__] })))] }), _jsx("div", { className: css.subtitle, children: t('subtitle') })] }), _jsxs("div", { className: css.headerActions, onPointerDown: (event) => { event.stopPropagation(); }, 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 })), upgrade !== null && !upgradeDismissed && (_jsxs("div", { className: `${css.upgradeBanner}${upgradeFlash ? ` ${css.upgradeBannerFlash}` : ''}`, role: "alert", children: [_jsx("div", { className: css.upgradeBannerText, children: t('upgradeAvailable').replace('{version}', upgrade.latestVersion) }), _jsx("div", { className: css.upgradeCommands, children: upgrade.commands.map(cmd => (_jsxs("div", { className: css.upgradeCommandRow, children: [_jsx("code", { className: css.upgradeCommand, children: cmd }), _jsx("button", { type: "button", className: `${css.copyBtn}${copiedCmd === cmd ? ` ${css.copyBtnDone}` : ''}`, onClick: () => { copyUpgradeCommand(cmd); }, children: copiedCmd === cmd ? t('copied') : t('copy') })] }, cmd))) }), _jsx("div", { className: css.upgradeHint, children: t('upgradeHint') }), _jsx("button", { type: "button", className: css.upgradeClose, onClick: () => { actions.setUpgradeDismissed(true); }, "aria-label": t('close'), children: "\u00D7" })] })), !loading && error === null && visibleRows.length === 0 && (_jsx("div", { className: css.notice, children: monitored !== null && monitored.length === 0 ? t('noMonitored') : t('waiting') })), visibleRows.map((row) => {
300
+ : (_jsxs("span", { className: css.version, children: ["v", __TOKEN_QUOTA_VERSION__] })))] }), _jsx("div", { className: css.subtitle, children: t('subtitle') })] }), _jsxs("div", { className: css.headerActions, onPointerDown: (event) => { event.stopPropagation(); }, children: [_jsx("button", { type: "button", className: `${css.settingsBtn}${musicEnabled ? ` ${css.settingsBtnActive}` : ''}`, title: t('musicHint'), onClick: () => { setMusicEnabled(!musicEnabled); }, children: musicEnabled ? '🔊' : '♪' }), _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 })), upgrade !== null && !upgradeDismissed && (_jsxs("div", { className: `${css.upgradeBanner}${upgradeFlash ? ` ${css.upgradeBannerFlash}` : ''}`, role: "alert", children: [_jsx("div", { className: css.upgradeBannerText, children: t('upgradeAvailable').replace('{version}', upgrade.latestVersion) }), _jsx("div", { className: css.upgradeCommands, children: upgrade.commands.map(cmd => (_jsxs("div", { className: css.upgradeCommandRow, children: [_jsx("code", { className: css.upgradeCommand, children: cmd }), _jsx("button", { type: "button", className: `${css.copyBtn}${copiedCmd === cmd ? ` ${css.copyBtnDone}` : ''}`, onClick: () => { copyUpgradeCommand(cmd); }, children: copiedCmd === cmd ? t('copied') : t('copy') })] }, cmd))) }), _jsx("div", { className: css.upgradeHint, children: t('upgradeHint') }), _jsx("button", { type: "button", className: css.upgradeClose, onClick: () => { actions.setUpgradeDismissed(true); }, "aria-label": t('close'), children: "\u00D7" })] })), !loading && error === null && visibleRows.length === 0 && (_jsx("div", { className: css.notice, children: monitored !== null && monitored.length === 0 ? t('noMonitored') : t('waiting') })), visibleRows.map((row) => {
298
301
  const state = barStateOf(row);
299
302
  const pct = row.limit > 0 ? Math.min(100, Math.round((row.used / row.limit) * 100)) : 0;
300
303
  const barClass = state === 'over'
@@ -334,7 +337,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
334
337
  hour: reset?.hour ?? 0,
335
338
  minute: Number(event.target.value),
336
339
  });
337
- }, 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') })] }), _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
340
+ }, 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') })] }), _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("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
338
341
  ? t('checkingUpdates')
339
342
  : lastCheckResult === 'up-to-date'
340
343
  ? t('checkUpToDate')
@@ -1,5 +1,6 @@
1
1
  import { createTokenQuotaPanelStore } from "./store.js";
2
2
  import { TokenQuotaPanel } from "./TokenQuotaPanel.js";
3
+ import { TokenQuotaMusic } from "./music.js";
3
4
  import { en, zh } from "./locales.js";
4
5
  export { mergeModelRows } from "./store.js";
5
6
  /** Dictionary namespace owning the panel copy. */
@@ -35,6 +36,28 @@ export function apply(ctx) {
35
36
  let lastMonitored = null;
36
37
  let lastOnFull = 'stop';
37
38
  let lastReset = null;
39
+ // Live music engine: subscribes to the plugin's SSE action stream and
40
+ // composes from host events. Web Audio plays by default; a detected MIDI
41
+ // output is driven simultaneously.
42
+ const music = new TokenQuotaMusic();
43
+ const sse = new EventSource('/token-quota/events');
44
+ sse.addEventListener('action', (event) => {
45
+ let action;
46
+ try {
47
+ action = JSON.parse(event.data);
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ // Only play actions for the currently-visible session; a root overlay
53
+ // panel isn't bound to a specific session, but its display tracks
54
+ // useSessions(s => s.current), so keeping the soundtrack on the same
55
+ // session avoids a cacophony from background conversations.
56
+ if (lastSessionId !== undefined && action.sessionId !== String(lastSessionId))
57
+ return;
58
+ music.onAction(action);
59
+ });
60
+ ctx.effect(() => () => { sse.close(); }, 'token-quota-ui: music SSE');
38
61
  const keyOf = (selection) => {
39
62
  if (selection === null)
40
63
  return undefined;
@@ -190,6 +213,13 @@ export function apply(ctx) {
190
213
  bound?.setCheckUpdates(doc?.checkUpdates ?? true);
191
214
  bound?.setDimWhenIdle(doc?.dimWhenIdle ?? false);
192
215
  bound?.setReset(lastReset);
216
+ const musicSettings = { ...{ enabled: false, volume: 0.5, style: 'major' }, ...(doc?.music ?? {}) };
217
+ music.setEnabled(musicSettings.enabled);
218
+ music.setVolume(musicSettings.volume);
219
+ music.setStyle(musicSettings.style);
220
+ bound?.setMusicEnabled(musicSettings.enabled);
221
+ bound?.setMusicVolume(musicSettings.volume);
222
+ bound?.setMusicStyle(musicSettings.style);
193
223
  void fetch('/token-quota', { headers: { accept: 'application/json' } }).then((response) => {
194
224
  if (!response.ok) {
195
225
  bound?.setError(`quota route: ${String(response.status)}`);
@@ -257,6 +287,29 @@ export function apply(ctx) {
257
287
  bound?.setDimWhenIdle(enabled);
258
288
  void scope.set('dimWhenIdle', enabled);
259
289
  };
290
+ const setMusicEnabled = (enabled) => {
291
+ const doc = scope.getSnapshot().value;
292
+ const current = { ...{ enabled: false, volume: 0.5, style: 'major' }, ...(doc?.music ?? {}) };
293
+ current.enabled = enabled;
294
+ void scope.set('music', current);
295
+ // First enable: start audio inside this user gesture (the panel switch
296
+ // click) so the AudioContext can resume; the settings pull that follows
297
+ // applies enabled/volume/style to the engine.
298
+ if (enabled)
299
+ void music.ensureStarted();
300
+ };
301
+ const setMusicVolume = (volume) => {
302
+ const doc = scope.getSnapshot().value;
303
+ const current = { ...{ enabled: false, volume: 0.5, style: 'major' }, ...(doc?.music ?? {}) };
304
+ current.volume = Math.max(0, Math.min(1, volume));
305
+ void scope.set('music', current);
306
+ };
307
+ const setMusicStyle = (style) => {
308
+ const doc = scope.getSnapshot().value;
309
+ const current = { ...{ enabled: false, volume: 0.5, style: 'major' }, ...(doc?.music ?? {}) };
310
+ current.style = style;
311
+ void scope.set('music', current);
312
+ };
260
313
  /** Ask the Host to check the registry right now, then refresh the snapshot. */
261
314
  const checkUpdatesNow = () => {
262
315
  bound?.setCheckingUpdates(true);
@@ -344,6 +397,7 @@ export function apply(ctx) {
344
397
  return {
345
398
  load, setLimit, selectModel, setMonitored, setOnFull, setReset,
346
399
  setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle,
400
+ setMusicEnabled, setMusicVolume, setMusicStyle,
347
401
  };
348
402
  };
349
403
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -66,6 +66,13 @@ export declare const zh: {
66
66
  checkUpdatesLabel: string;
67
67
  dimWhenIdleLabel: string;
68
68
  dimWhenIdleHint: string;
69
+ musicLabel: string;
70
+ musicHint: string;
71
+ musicVolume: string;
72
+ musicStyle: string;
73
+ musicStyleMajor: string;
74
+ musicStyleMinor: string;
75
+ musicStylePentatonic: string;
69
76
  checkUpdatesNow: string;
70
77
  checkingUpdates: string;
71
78
  checkUpToDate: string;
@@ -135,6 +142,13 @@ export declare const en: {
135
142
  checkUpdatesLabel: string;
136
143
  dimWhenIdleLabel: string;
137
144
  dimWhenIdleHint: string;
145
+ musicLabel: string;
146
+ musicHint: string;
147
+ musicVolume: string;
148
+ musicStyle: string;
149
+ musicStyleMajor: string;
150
+ musicStyleMinor: string;
151
+ musicStylePentatonic: string;
138
152
  checkUpdatesNow: string;
139
153
  checkingUpdates: string;
140
154
  checkUpToDate: string;
@@ -66,6 +66,13 @@ export const zh = {
66
66
  checkUpdatesLabel: '自动检查更新',
67
67
  dimWhenIdleLabel: '失焦时窗口透明',
68
68
  dimWhenIdleHint: '鼠标移开面板时降低不透明度,输入文字时更透明;鼠标悬停恢复',
69
+ musicLabel: '动作音乐',
70
+ musicHint: '根据 Harness 动作实时作曲:请求开始、工具调用、回合结束等(需点击开关启动音频;检测到 MIDI 输出时同步发送 MIDI)',
71
+ musicVolume: '音量',
72
+ musicStyle: '风格',
73
+ musicStyleMajor: '大调',
74
+ musicStyleMinor: '小调',
75
+ musicStylePentatonic: '五声',
69
76
  checkUpdatesNow: '立即检查',
70
77
  checkingUpdates: '检查中…',
71
78
  checkUpToDate: '已是最新',
@@ -133,6 +140,13 @@ export const en = {
133
140
  checkUpdatesLabel: 'Check for updates automatically',
134
141
  dimWhenIdleLabel: 'Dim panel when unfocused',
135
142
  dimWhenIdleHint: 'Fade the panel out when the pointer is away (more while typing); hover to restore',
143
+ musicLabel: 'Action music',
144
+ musicHint: 'Live-composes from Harness actions: request starts, tool calls, turn ends, etc. (click the switch to start audio; a detected MIDI output is driven as well)',
145
+ musicVolume: 'Volume',
146
+ musicStyle: 'Style',
147
+ musicStyleMajor: 'Major',
148
+ musicStyleMinor: 'Minor',
149
+ musicStylePentatonic: 'Pentatonic',
136
150
  checkUpdatesNow: 'Check now',
137
151
  checkingUpdates: 'Checking…',
138
152
  checkUpToDate: 'Up to date',
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Live-composed soundtrack driven by host actions (turns, steps, tool calls,
3
+ * etc.). Outputs through Web Audio (built-in synthesis) by default, and also
4
+ * sends MIDI NoteOn/NoteOff to the first available MIDI output when one is
5
+ * detected (both play simultaneously when MIDI is present — use the panel
6
+ * volume for overall level, the MIDI output has its own gain on the receiver).
7
+ *
8
+ * The composition is intentionally simple: a fixed chord progression under a
9
+ * scale-constrained melody, with one short musical gesture per host action.
10
+ * It is meant as gentle ambience while waiting for LLM responses, not as a
11
+ * full piece — everything stays in key so you can never hit a wrong note.
12
+ *
13
+ * @module @jxgame2020/dsh-token-quota/music
14
+ */
15
+ import type { TokenQuotaMusicAction, TokenQuotaMusicStyle } from '../types.ts';
16
+ /** Live-composed music engine driven by host actions. */
17
+ export declare class TokenQuotaMusic {
18
+ private audio;
19
+ private midiOutput;
20
+ private midiDetected;
21
+ private enabled;
22
+ private volume;
23
+ private style;
24
+ /** Current position in the chord progression (advances per step). */
25
+ private chordIndex;
26
+ /** Step counter — used for melody degree selection. */
27
+ private stepCount;
28
+ /** Tool counter — used for tool-note pitch. */
29
+ private toolCount;
30
+ /** Base MIDI note of the current key. C4 = middle C. */
31
+ private static readonly BASE_MIDI;
32
+ /** True once the audio context has been started (needs a user gesture). */
33
+ get started(): boolean;
34
+ /**
35
+ * Start the audio context and try to find a MIDI output.
36
+ * Must be called inside a user gesture (the panel switch click) so the
37
+ * AudioContext can resume. Safe to call multiple times.
38
+ */
39
+ ensureStarted(): Promise<boolean>;
40
+ setEnabled(value: boolean): void;
41
+ setVolume(value: number): void;
42
+ setStyle(value: TokenQuotaMusicStyle): void;
43
+ /** Handle one incoming host action and turn it into sound. */
44
+ onAction(action: TokenQuotaMusicAction): void;
45
+ private currentChordRoot;
46
+ private currentChordQuality;
47
+ /** Intervals of an ascending arpeggio from the chord root. */
48
+ private arpeggioDegrees;
49
+ /** Play one note: Web Audio + MIDI simultaneously when available. */
50
+ private playNote;
51
+ /** Play a three-note chord plus a bass octave below. */
52
+ private playChord;
53
+ }
54
+ //# sourceMappingURL=music.d.ts.map