@jxgame2020/dsh-token-quota 0.1.40 → 0.1.41

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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
11
11
  import type { ModelProviderGroup, ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types';
12
- import type { TokenQuotaFullAction, TokenQuotaLog, TokenQuotaBalance, TokenQuotaMusicStyle, TokenQuotaNote, TokenQuotaReset, TokenQuotaSnapshot, TokenQuotaUpgrade } from '@jxgame2020/dsh-token-quota/types';
12
+ import type { TokenQuotaFullAction, TokenQuotaLog, TokenQuotaBalance, TokenQuotaMusicStyle, TokenQuotaNote, TokenQuotaReset, TokenQuotaSnapshot, TokenQuotaTranscribeSettings, TokenQuotaUpgrade } from '@jxgame2020/dsh-token-quota/types';
13
13
  /** One rendered model row: directory identity merged with the quota snapshot. */
14
14
  export interface ModelQuotaRow {
15
15
  /** Stable key `provider/model`. */
@@ -74,6 +74,8 @@ export interface TokenQuotaPanelState {
74
74
  musicVolume: number;
75
75
  musicStyle: TokenQuotaMusicStyle;
76
76
  musicOnlyCurrentSession: boolean;
77
+ /** Voice transcription settings (mirrored from the settings document). */
78
+ transcribe: TokenQuotaTranscribeSettings;
77
79
  /** Latest upgrade availability from the Host; null = up to date or unknown. */
78
80
  upgrade: TokenQuotaUpgrade | null;
79
81
  /** Whether the one-shot upgrade banner was dismissed for the current latest version. */
@@ -128,6 +130,8 @@ export type TokenQuotaPanelActions = {
128
130
  setMusicVolume: (d: TokenQuotaPanelState, volume: number) => void;
129
131
  setMusicStyle: (d: TokenQuotaPanelState, style: TokenQuotaMusicStyle) => void;
130
132
  setMusicOnlyCurrentSession: (d: TokenQuotaPanelState, only: boolean) => void;
133
+ /** Adopt the voice transcription settings. */
134
+ setTranscribeSettings: (d: TokenQuotaPanelState, settings: TokenQuotaTranscribeSettings) => void;
131
135
  setUpgrade: (d: TokenQuotaPanelState, upgrade: TokenQuotaUpgrade | null) => void;
132
136
  setUpgradeDismissed: (d: TokenQuotaPanelState, dismissed: boolean) => void;
133
137
  setCheckingUpdates: (d: TokenQuotaPanelState, checking: boolean) => void;
@@ -38,6 +38,7 @@ export function createTokenQuotaPanelStore() {
38
38
  musicVolume: 0.5,
39
39
  musicStyle: 'pentatonic',
40
40
  musicOnlyCurrentSession: true,
41
+ transcribe: { enabled: false, baseURL: 'https://api.siliconflow.cn/v1', apiKeyEnv: 'SILICONFLOW_API_KEY', model: 'XingChenAGI/XingChenASR-V3.2-Ultra' },
41
42
  upgrade: null,
42
43
  upgradeDismissed: false,
43
44
  checkingUpdates: false,
@@ -71,6 +72,7 @@ export function createTokenQuotaPanelStore() {
71
72
  setMusicVolume: (d, volume) => { d.musicVolume = volume; },
72
73
  setMusicStyle: (d, style) => { d.musicStyle = style; },
73
74
  setMusicOnlyCurrentSession: (d, only) => { d.musicOnlyCurrentSession = only; },
75
+ setTranscribeSettings: (d, settings) => { d.transcribe = settings; },
74
76
  setUpgrade: (d, upgrade) => {
75
77
  // Guard against host/client version skew (e.g. the host process still
76
78
  // runs an older bundle after an upgrade): when the reported "latest"
@@ -85,6 +85,8 @@ export declare class TokenQuotaService extends Service {
85
85
  private disposeRouteClear;
86
86
  /** Disposer for the optional balance-refresh route (`POST /token-quota/refresh-balance`). */
87
87
  private disposeRouteBalance;
88
+ /** Disposer for the voice transcription route (`POST /token-quota/transcribe`). */
89
+ private disposeRouteTranscribe;
88
90
  /** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
89
91
  private disposeRouteEvents;
90
92
  /** Live SSE subscribers receiving streamed music actions. */
@@ -129,6 +131,14 @@ export declare class TokenQuotaService extends Service {
129
131
  * type. A failed fetch keeps the previous value and marks it as an error.
130
132
  */
131
133
  private refreshProviderBalance;
134
+ /**
135
+ * Forward a recorded audio blob to the configured OpenAI-compatible
136
+ * `/audio/transcriptions` endpoint and return the recognised text. The API
137
+ * key resolves through the credential seam (Models page) with an environment
138
+ * fallback; the request body is the raw audio and the content type carries
139
+ * the filename for the multipart form.
140
+ */
141
+ private handleTranscribe;
132
142
  /** Read the full per-cycle usage history (log dialog data). */
133
143
  readLog(): TokenQuotaLog;
134
144
  /**
@@ -38,7 +38,7 @@ import z from '@deepseek-ai/schemastery';
38
38
  // the service listens to without importing any runtime value.
39
39
  import '@deepseek-ai/dsh-agent';
40
40
  import { LlmError } from '@deepseek-ai/dsh-llm';
41
- import { TOKEN_QUOTA_EXCEEDED_CODE, TOKEN_QUOTA_NAMESPACE, tokenQuotaKey, TOKEN_QUOTA_DEFAULT_MUSIC, TOKEN_QUOTA_MAX_NOTES, TOKEN_QUOTA_DEFAULT_BALANCE, } from "./types.js";
41
+ import { TOKEN_QUOTA_EXCEEDED_CODE, TOKEN_QUOTA_NAMESPACE, tokenQuotaKey, TOKEN_QUOTA_DEFAULT_MUSIC, TOKEN_QUOTA_MAX_NOTES, TOKEN_QUOTA_DEFAULT_BALANCE, TOKEN_QUOTA_DEFAULT_TRANSCRIBE, } from "./types.js";
42
42
  import { assertTokenQuotaLimit, splitTokenQuotaKey } from "./invariant.js";
43
43
  /** The plugin's package name, used to locate the profile dependency and the registry endpoint. */
44
44
  const PACKAGE_NAME = '@jxgame2020/dsh-token-quota';
@@ -232,6 +232,14 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
232
232
  enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_BALANCE.enabled),
233
233
  pollMinutes: z.number().min(1).max(120).default(TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes),
234
234
  }).default({ ...TOKEN_QUOTA_DEFAULT_BALANCE }),
235
+ // Voice transcription: the browser records and the host forwards the audio
236
+ // to an OpenAI-compatible endpoint.
237
+ transcribe: z.object({
238
+ enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.enabled),
239
+ baseURL: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.baseURL),
240
+ apiKeyEnv: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.apiKeyEnv),
241
+ model: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.model),
242
+ }).default({ ...TOKEN_QUOTA_DEFAULT_TRANSCRIBE }),
235
243
  });
236
244
  /** Two-digit zero-pad helper. */
237
245
  function pad2(value) {
@@ -288,7 +296,7 @@ export class TokenQuotaService extends Service {
288
296
  history = {};
289
297
  limits = {};
290
298
  monitored = undefined;
291
- settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false, music: { ...TOKEN_QUOTA_DEFAULT_MUSIC }, order: [], notes: [], balance: { ...TOKEN_QUOTA_DEFAULT_BALANCE } });
299
+ settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false, music: { ...TOKEN_QUOTA_DEFAULT_MUSIC }, order: [], notes: [], balance: { ...TOKEN_QUOTA_DEFAULT_BALANCE }, transcribe: { ...TOKEN_QUOTA_DEFAULT_TRANSCRIBE } });
292
300
  /** Whether update checks are enabled (mirrors the settings document). */
293
301
  checkUpdates = true;
294
302
  /** Cached upgrade availability; recomputed by {@link refreshUpgrade}. */
@@ -325,6 +333,8 @@ export class TokenQuotaService extends Service {
325
333
  disposeRouteClear;
326
334
  /** Disposer for the optional balance-refresh route (`POST /token-quota/refresh-balance`). */
327
335
  disposeRouteBalance;
336
+ /** Disposer for the voice transcription route (`POST /token-quota/transcribe`). */
337
+ disposeRouteTranscribe;
328
338
  /** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
329
339
  disposeRouteEvents;
330
340
  /** Live SSE subscribers receiving streamed music actions. */
@@ -369,6 +379,18 @@ export class TokenQuotaService extends Service {
369
379
  ? Math.max(1, Math.min(120, Math.round(doc.balance.pollMinutes)))
370
380
  : TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes,
371
381
  },
382
+ transcribe: {
383
+ enabled: doc.transcribe?.enabled ?? TOKEN_QUOTA_DEFAULT_TRANSCRIBE.enabled,
384
+ baseURL: typeof doc.transcribe?.baseURL === 'string' && doc.transcribe.baseURL !== ''
385
+ ? doc.transcribe.baseURL.replace(/\/+$/, '')
386
+ : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.baseURL,
387
+ apiKeyEnv: typeof doc.transcribe?.apiKeyEnv === 'string' && doc.transcribe.apiKeyEnv !== ''
388
+ ? doc.transcribe.apiKeyEnv
389
+ : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.apiKeyEnv,
390
+ model: typeof doc.transcribe?.model === 'string' && doc.transcribe.model !== ''
391
+ ? doc.transcribe.model
392
+ : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.model,
393
+ },
372
394
  notes: Array.isArray(doc.notes)
373
395
  ? doc.notes.slice(0, TOKEN_QUOTA_MAX_NOTES).flatMap((note) => {
374
396
  if (typeof note.id !== 'string')
@@ -549,6 +571,30 @@ export class TokenQuotaService extends Service {
549
571
  });
550
572
  },
551
573
  });
574
+ this.disposeRouteTranscribe = server.register({
575
+ kind: 'exact',
576
+ path: '/token-quota/transcribe',
577
+ handler: (req, res) => {
578
+ if (req.method !== 'POST') {
579
+ res.writeHead(405);
580
+ res.end();
581
+ return;
582
+ }
583
+ void this.handleTranscribe(req).then((result) => {
584
+ if (result.ok) {
585
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
586
+ res.end(JSON.stringify({ text: result.text }));
587
+ }
588
+ else {
589
+ res.writeHead(result.status);
590
+ res.end(result.message);
591
+ }
592
+ }, (error) => {
593
+ res.writeHead(500);
594
+ res.end(error instanceof Error ? error.message : String(error));
595
+ });
596
+ },
597
+ });
552
598
  this.disposeRouteClear = server.register({
553
599
  kind: 'exact',
554
600
  path: '/token-quota/clear-log',
@@ -895,6 +941,80 @@ export class TokenQuotaService extends Service {
895
941
  this.balanceFetches.set(provider, promise);
896
942
  return promise;
897
943
  }
944
+ /**
945
+ * Forward a recorded audio blob to the configured OpenAI-compatible
946
+ * `/audio/transcriptions` endpoint and return the recognised text. The API
947
+ * key resolves through the credential seam (Models page) with an environment
948
+ * fallback; the request body is the raw audio and the content type carries
949
+ * the filename for the multipart form.
950
+ */
951
+ async handleTranscribe(req) {
952
+ const settings = this.settingsSource().transcribe;
953
+ if (!settings.enabled) {
954
+ return { ok: false, status: 403, message: 'transcription is disabled' };
955
+ }
956
+ const chunks = [];
957
+ for await (const chunk of req) {
958
+ chunks.push(Buffer.from(chunk));
959
+ }
960
+ const audio = Buffer.concat(chunks);
961
+ if (audio.length === 0) {
962
+ return { ok: false, status: 400, message: 'empty audio payload' };
963
+ }
964
+ const credentials = this.ctx.get('credentials');
965
+ let key;
966
+ if (credentials !== undefined) {
967
+ try {
968
+ key = (await credentials.resolve(settings.apiKeyEnv))?.value;
969
+ }
970
+ catch {
971
+ // Seam unreachable: fall through to the environment.
972
+ }
973
+ }
974
+ if (key === undefined || key === '')
975
+ key = process.env[settings.apiKeyEnv];
976
+ if (key === undefined || key === '') {
977
+ return { ok: false, status: 400, message: `no API key for credential reference "${settings.apiKeyEnv}"` };
978
+ }
979
+ const contentType = req.headers['content-type'] ?? 'application/octet-stream';
980
+ const disposition = typeof req.headers['content-disposition'] === 'string'
981
+ ? req.headers['content-disposition']
982
+ : '';
983
+ const filenameMatch = /filename="([^"]+)"/.exec(disposition);
984
+ const filename = filenameMatch?.[1] ?? 'recording.webm';
985
+ const form = new FormData();
986
+ form.append('model', settings.model);
987
+ form.append('file', new Blob([new Uint8Array(audio)], { type: contentType }), filename);
988
+ try {
989
+ const controller = new AbortController();
990
+ const timer = setTimeout(() => { controller.abort(); }, 60_000);
991
+ let res;
992
+ try {
993
+ res = await fetch(`${settings.baseURL}/audio/transcriptions`, {
994
+ method: 'POST',
995
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
996
+ body: form,
997
+ signal: controller.signal,
998
+ });
999
+ }
1000
+ finally {
1001
+ clearTimeout(timer);
1002
+ }
1003
+ if (!res.ok) {
1004
+ const detail = await res.text().catch(() => '');
1005
+ return { ok: false, status: res.status, message: `transcription endpoint HTTP ${res.status}${detail !== '' ? `: ${detail.slice(0, 300)}` : ''}` };
1006
+ }
1007
+ const data = (await res.json());
1008
+ const text = typeof data?.text === 'string' ? data.text : '';
1009
+ if (text === '') {
1010
+ return { ok: false, status: 502, message: 'transcription endpoint returned no text' };
1011
+ }
1012
+ return { ok: true, text };
1013
+ }
1014
+ catch (error) {
1015
+ return { ok: false, status: 502, message: error instanceof Error ? error.message : String(error) };
1016
+ }
1017
+ }
898
1018
  /** Read the full per-cycle usage history (log dialog data). */
899
1019
  readLog() {
900
1020
  this.rollCycleIfNeeded();
@@ -1231,6 +1351,8 @@ export class TokenQuotaService extends Service {
1231
1351
  this.disposeRouteClear();
1232
1352
  if (this.disposeRouteBalance !== undefined)
1233
1353
  this.disposeRouteBalance();
1354
+ if (this.disposeRouteTranscribe !== undefined)
1355
+ this.disposeRouteTranscribe();
1234
1356
  if (this.balanceTimer !== undefined) {
1235
1357
  clearTimeout(this.balanceTimer);
1236
1358
  this.balanceTimer = undefined;
@@ -55,6 +55,23 @@ export interface TokenQuotaBalanceSettings {
55
55
  pollMinutes: number;
56
56
  }
57
57
  export declare const TOKEN_QUOTA_DEFAULT_BALANCE: TokenQuotaBalanceSettings;
58
+ /**
59
+ * Voice transcription settings: when enabled the browser shows a mic button
60
+ * inside the chat composer; a press records the voice, a second press stops
61
+ * and sends the audio to an OpenAI-compatible `/audio/transcriptions`
62
+ * endpoint, and the returned text is inserted into the composer.
63
+ */
64
+ export interface TokenQuotaTranscribeSettings {
65
+ /** Whether the mic button appears in the composer and transcription is active. */
66
+ enabled: boolean;
67
+ /** API base URL of the OpenAI-compatible transcription endpoint. */
68
+ baseURL: string;
69
+ /** Credential reference (environment-variable name) holding the API key. */
70
+ apiKeyEnv: string;
71
+ /** Speech-to-text model name sent as the `model` field. */
72
+ model: string;
73
+ }
74
+ export declare const TOKEN_QUOTA_DEFAULT_TRANSCRIBE: TokenQuotaTranscribeSettings;
58
75
  /** Fetch state of the provider account balance. */
59
76
  export type TokenQuotaBalanceStatus = 'ok' | 'unconfigured' | 'error';
60
77
  /**
@@ -211,6 +228,11 @@ export interface TokenQuotaSettings {
211
228
  * often the host polls the balance endpoint.
212
229
  */
213
230
  balance: TokenQuotaBalanceSettings;
231
+ /**
232
+ * Voice transcription: mic in the composer plus the OpenAI-compatible
233
+ * endpoint used to turn a recording into text. Disabled by default.
234
+ */
235
+ transcribe: TokenQuotaTranscribeSettings;
214
236
  /**
215
237
  * Floating scratchpad notes owned by the panel. At most
216
238
  * {@link TOKEN_QUOTA_MAX_NOTES} are kept; content, title, placement, size
@@ -33,6 +33,12 @@ export const TOKEN_QUOTA_DEFAULT_BALANCE = {
33
33
  enabled: true,
34
34
  pollMinutes: 5,
35
35
  };
36
+ export const TOKEN_QUOTA_DEFAULT_TRANSCRIBE = {
37
+ enabled: false,
38
+ baseURL: 'https://api.siliconflow.cn/v1',
39
+ apiKeyEnv: 'SILICONFLOW_API_KEY',
40
+ model: 'XingChenAGI/XingChenASR-V3.2-Ultra',
41
+ };
36
42
  /** Default music settings: silent until the user flips the panel switch. */
37
43
  export const TOKEN_QUOTA_DEFAULT_MUSIC = {
38
44
  enabled: false,
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.40",
4
+ "version": "0.1.41",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },