@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.
package/lib/index.js CHANGED
@@ -27,6 +27,12 @@ const TOKEN_QUOTA_DEFAULT_BALANCE = {
27
27
  enabled: true,
28
28
  pollMinutes: 5
29
29
  };
30
+ const TOKEN_QUOTA_DEFAULT_TRANSCRIBE = {
31
+ enabled: false,
32
+ baseURL: "https://api.siliconflow.cn/v1",
33
+ apiKeyEnv: "SILICONFLOW_API_KEY",
34
+ model: "XingChenAGI/XingChenASR-V3.2-Ultra"
35
+ };
30
36
  /** Default music settings: silent until the user flips the panel switch. */
31
37
  const TOKEN_QUOTA_DEFAULT_MUSIC = {
32
38
  enabled: false,
@@ -244,7 +250,13 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
244
250
  balance: z.object({
245
251
  enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_BALANCE.enabled),
246
252
  pollMinutes: z.number().min(1).max(120).default(TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes)
247
- }).default({ ...TOKEN_QUOTA_DEFAULT_BALANCE })
253
+ }).default({ ...TOKEN_QUOTA_DEFAULT_BALANCE }),
254
+ transcribe: z.object({
255
+ enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.enabled),
256
+ baseURL: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.baseURL),
257
+ apiKeyEnv: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.apiKeyEnv),
258
+ model: z.string().default(TOKEN_QUOTA_DEFAULT_TRANSCRIBE.model)
259
+ }).default({ ...TOKEN_QUOTA_DEFAULT_TRANSCRIBE })
248
260
  });
249
261
  /** Two-digit zero-pad helper. */
250
262
  function pad2(value) {
@@ -300,7 +312,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
300
312
  music: { ...TOKEN_QUOTA_DEFAULT_MUSIC },
301
313
  order: [],
302
314
  notes: [],
303
- balance: { ...TOKEN_QUOTA_DEFAULT_BALANCE }
315
+ balance: { ...TOKEN_QUOTA_DEFAULT_BALANCE },
316
+ transcribe: { ...TOKEN_QUOTA_DEFAULT_TRANSCRIBE }
304
317
  });
305
318
  /** Whether update checks are enabled (mirrors the settings document). */
306
319
  checkUpdates = true;
@@ -338,6 +351,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
338
351
  disposeRouteClear;
339
352
  /** Disposer for the optional balance-refresh route (`POST /token-quota/refresh-balance`). */
340
353
  disposeRouteBalance;
354
+ /** Disposer for the voice transcription route (`POST /token-quota/transcribe`). */
355
+ disposeRouteTranscribe;
341
356
  /** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
342
357
  disposeRouteEvents;
343
358
  /** Live SSE subscribers receiving streamed music actions. */
@@ -368,6 +383,12 @@ var TokenQuotaService = class TokenQuotaService extends Service {
368
383
  enabled: doc.balance?.enabled ?? TOKEN_QUOTA_DEFAULT_BALANCE.enabled,
369
384
  pollMinutes: typeof doc.balance?.pollMinutes === "number" ? Math.max(1, Math.min(120, Math.round(doc.balance.pollMinutes))) : TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes
370
385
  },
386
+ transcribe: {
387
+ enabled: doc.transcribe?.enabled ?? TOKEN_QUOTA_DEFAULT_TRANSCRIBE.enabled,
388
+ baseURL: typeof doc.transcribe?.baseURL === "string" && doc.transcribe.baseURL !== "" ? doc.transcribe.baseURL.replace(/\/+$/, "") : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.baseURL,
389
+ apiKeyEnv: typeof doc.transcribe?.apiKeyEnv === "string" && doc.transcribe.apiKeyEnv !== "" ? doc.transcribe.apiKeyEnv : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.apiKeyEnv,
390
+ model: typeof doc.transcribe?.model === "string" && doc.transcribe.model !== "" ? doc.transcribe.model : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.model
391
+ },
371
392
  notes: Array.isArray(doc.notes) ? doc.notes.slice(0, 5).flatMap((note) => {
372
393
  if (typeof note.id !== "string") return [];
373
394
  return [{
@@ -521,6 +542,32 @@ var TokenQuotaService = class TokenQuotaService extends Service {
521
542
  });
522
543
  }
523
544
  });
545
+ this.disposeRouteTranscribe = server.register({
546
+ kind: "exact",
547
+ path: "/token-quota/transcribe",
548
+ handler: (req, res) => {
549
+ if (req.method !== "POST") {
550
+ res.writeHead(405);
551
+ res.end();
552
+ return;
553
+ }
554
+ this.handleTranscribe(req).then((result) => {
555
+ if (result.ok) {
556
+ res.writeHead(200, {
557
+ "content-type": "application/json",
558
+ "cache-control": "no-store"
559
+ });
560
+ res.end(JSON.stringify({ text: result.text }));
561
+ } else {
562
+ res.writeHead(result.status);
563
+ res.end(result.message);
564
+ }
565
+ }, (error) => {
566
+ res.writeHead(500);
567
+ res.end(error instanceof Error ? error.message : String(error));
568
+ });
569
+ }
570
+ });
524
571
  this.disposeRouteClear = server.register({
525
572
  kind: "exact",
526
573
  path: "/token-quota/clear-log",
@@ -604,7 +651,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
604
651
  return this.upgradeCheck;
605
652
  }
606
653
  async doRefreshUpgrade() {
607
- const current = "0.1.40";
654
+ const current = "0.1.41";
608
655
  let latest;
609
656
  try {
610
657
  const response = await fetch(REGISTRY_LATEST_URL, {
@@ -805,6 +852,91 @@ var TokenQuotaService = class TokenQuotaService extends Service {
805
852
  this.balanceFetches.set(provider, promise);
806
853
  return promise;
807
854
  }
855
+ /**
856
+ * Forward a recorded audio blob to the configured OpenAI-compatible
857
+ * `/audio/transcriptions` endpoint and return the recognised text. The API
858
+ * key resolves through the credential seam (Models page) with an environment
859
+ * fallback; the request body is the raw audio and the content type carries
860
+ * the filename for the multipart form.
861
+ */
862
+ async handleTranscribe(req) {
863
+ const settings = this.settingsSource().transcribe;
864
+ if (!settings.enabled) return {
865
+ ok: false,
866
+ status: 403,
867
+ message: "transcription is disabled"
868
+ };
869
+ const chunks = [];
870
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
871
+ const audio = Buffer.concat(chunks);
872
+ if (audio.length === 0) return {
873
+ ok: false,
874
+ status: 400,
875
+ message: "empty audio payload"
876
+ };
877
+ const credentials = this.ctx.get("credentials");
878
+ let key;
879
+ if (credentials !== void 0) try {
880
+ key = (await credentials.resolve(settings.apiKeyEnv))?.value;
881
+ } catch {}
882
+ if (key === void 0 || key === "") key = process.env[settings.apiKeyEnv];
883
+ if (key === void 0 || key === "") return {
884
+ ok: false,
885
+ status: 400,
886
+ message: `no API key for credential reference "${settings.apiKeyEnv}"`
887
+ };
888
+ const contentType = req.headers["content-type"] ?? "application/octet-stream";
889
+ const disposition = typeof req.headers["content-disposition"] === "string" ? req.headers["content-disposition"] : "";
890
+ const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? "recording.webm";
891
+ const form = new FormData();
892
+ form.append("model", settings.model);
893
+ form.append("file", new Blob([new Uint8Array(audio)], { type: contentType }), filename);
894
+ try {
895
+ const controller = new AbortController();
896
+ const timer = setTimeout(() => {
897
+ controller.abort();
898
+ }, 6e4);
899
+ let res;
900
+ try {
901
+ res = await fetch(`${settings.baseURL}/audio/transcriptions`, {
902
+ method: "POST",
903
+ headers: {
904
+ authorization: `Bearer ${key}`,
905
+ accept: "application/json"
906
+ },
907
+ body: form,
908
+ signal: controller.signal
909
+ });
910
+ } finally {
911
+ clearTimeout(timer);
912
+ }
913
+ if (!res.ok) {
914
+ const detail = await res.text().catch(() => "");
915
+ return {
916
+ ok: false,
917
+ status: res.status,
918
+ message: `transcription endpoint HTTP ${res.status}${detail !== "" ? `: ${detail.slice(0, 300)}` : ""}`
919
+ };
920
+ }
921
+ const data = await res.json();
922
+ const text = typeof data?.text === "string" ? data.text : "";
923
+ if (text === "") return {
924
+ ok: false,
925
+ status: 502,
926
+ message: "transcription endpoint returned no text"
927
+ };
928
+ return {
929
+ ok: true,
930
+ text
931
+ };
932
+ } catch (error) {
933
+ return {
934
+ ok: false,
935
+ status: 502,
936
+ message: error instanceof Error ? error.message : String(error)
937
+ };
938
+ }
939
+ }
808
940
  /** Read the full per-cycle usage history (log dialog data). */
809
941
  readLog() {
810
942
  this.rollCycleIfNeeded();
@@ -1109,6 +1241,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
1109
1241
  if (this.disposeRouteCheck !== void 0) this.disposeRouteCheck();
1110
1242
  if (this.disposeRouteClear !== void 0) this.disposeRouteClear();
1111
1243
  if (this.disposeRouteBalance !== void 0) this.disposeRouteBalance();
1244
+ if (this.disposeRouteTranscribe !== void 0) this.disposeRouteTranscribe();
1112
1245
  if (this.balanceTimer !== void 0) {
1113
1246
  clearTimeout(this.balanceTimer);
1114
1247
  this.balanceTimer = void 0;
@@ -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, TokenQuotaMusicStyle, TokenQuotaNote, TokenQuotaReset } from '@jxgame2020/dsh-token-quota/types';
3
+ import type { TokenQuotaFullAction, TokenQuotaMusicStyle, TokenQuotaNote, TokenQuotaReset, TokenQuotaTranscribeSettings } 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 {
@@ -36,6 +36,8 @@ export interface TokenQuotaPanelInjected {
36
36
  setMusicStyle: (style: TokenQuotaMusicStyle) => void;
37
37
  /** Toggle whether the soundtrack follows only the current session. */
38
38
  setMusicOnlyCurrentSession: (only: boolean) => void;
39
+ /** Persist the voice transcription settings (endpoint, key ref, model). */
40
+ setTranscribeSettings: (settings: TokenQuotaTranscribeSettings) => void;
39
41
  /** Ask the Host to check the registry right now, then refresh the snapshot. */
40
42
  checkUpdatesNow: () => void;
41
43
  /** Ask the Host to clear log entries before a date, then refresh the log. */
@@ -48,5 +50,5 @@ export type TokenQuotaPanelComponentProps = PropsRuntime<'shell.overlay'> & Prop
48
50
  * @param props - composed slot props.
49
51
  * @returns the panel element tree.
50
52
  */
51
- export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setBalanceSettings, refreshBalance, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
53
+ export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setBalanceSettings, refreshBalance, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, setTranscribeSettings, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
52
54
  //# sourceMappingURL=TokenQuotaPanel.d.ts.map
@@ -149,7 +149,7 @@ function loadPanelPos() {
149
149
  * @param props - composed slot props.
150
150
  * @returns the panel element tree.
151
151
  */
152
- export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setBalanceSettings, refreshBalance, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }) {
152
+ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setBalanceSettings, refreshBalance, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, setTranscribeSettings, useStore, actions, useSessions, }) {
153
153
  const [collapsed, setCollapsed] = useState(false);
154
154
  const [drafts, setDrafts] = useState({});
155
155
  const [editingKey, setEditingKey] = useState(null);
@@ -357,6 +357,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
357
357
  const musicVolume = useStore(s => s.musicVolume);
358
358
  const musicStyle = useStore(s => s.musicStyle);
359
359
  const musicOnlyCurrentSession = useStore(s => s.musicOnlyCurrentSession);
360
+ const transcribe = useStore(s => s.transcribe);
360
361
  const upgrade = useStore(s => s.upgrade);
361
362
  const upgradeDismissed = useStore(s => s.upgradeDismissed);
362
363
  const checkingUpdates = useStore(s => s.checkingUpdates);
@@ -790,7 +791,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
790
791
  : _jsx(_Fragment, { children: quotaText(row) });
791
792
  })(), _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')
792
793
  commitLimit(row); } }), _jsx("button", { type: "button", className: css.save, onClick: () => { commitLimit(row); }, children: t('save') })] }))] }, row.key));
793
- })] })] }), 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) => {
794
+ })] })] }), 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 === 'transcribe', className: `${css.dialogTab}${settingsTab === 'transcribe' ? ` ${css.dialogTabActive}` : ''}`, onClick: () => { setSettingsTab('transcribe'); }, children: t('settingsTabTranscribe') }), _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) => {
794
795
  const base = monitored === null ? allModels.map(m => m.key) : monitored;
795
796
  const next = new Set(base);
796
797
  if (event.target.checked)
@@ -817,7 +818,7 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
817
818
  hour: reset?.hour ?? 0,
818
819
  minute: Number(event.target.value),
819
820
  });
820
- }, 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' && (_jsxs(_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
821
+ }, 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 === 'transcribe' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("input", { type: "checkbox", checked: transcribe.enabled, onChange: (event) => { setTranscribeSettings({ ...transcribe, enabled: event.target.checked }); } }), _jsx("span", { children: t('transcribeEnabledLabel') })] }), _jsx("div", { className: css.dialogHint, children: t('transcribeEnabledHint') })] }), transcribe.enabled && (_jsxs("div", { className: css.dialogSection, children: [_jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('transcribeBaseURLLabel') }), _jsx("input", { className: css.resetSelect, type: "text", value: transcribe.baseURL, onChange: (event) => { setTranscribeSettings({ ...transcribe, baseURL: event.target.value }); } })] }), _jsx("div", { className: css.dialogHint, children: t('transcribeBaseURLHint') }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('transcribeApiKeyEnvLabel') }), _jsx("input", { className: css.resetSelect, type: "text", value: transcribe.apiKeyEnv, onChange: (event) => { setTranscribeSettings({ ...transcribe, apiKeyEnv: event.target.value }); } })] }), _jsx("div", { className: css.dialogHint, children: t('transcribeApiKeyEnvHint') }), _jsxs("label", { className: css.checkUpdatesLabel, children: [_jsx("span", { children: t('transcribeModelLabel') }), _jsx("input", { className: css.resetSelect, type: "text", value: transcribe.model, onChange: (event) => { setTranscribeSettings({ ...transcribe, model: event.target.value }); } })] }), _jsx("div", { className: css.dialogHint, children: t('transcribeModelHint') })] }))] })), settingsTab === 'other' && (_jsxs(_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
821
822
  ? t('checkingUpdates')
822
823
  : lastCheckResult === 'up-to-date'
823
824
  ? t('checkUpToDate')
@@ -15,6 +15,7 @@
15
15
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
16
16
  import { type TokenQuotaKey } from './locales.ts';
17
17
  export type { TokenQuotaPanelInjected } from './TokenQuotaPanel.tsx';
18
+ export type { TokenQuotaMicInjected } from './mic.tsx';
18
19
  export type { ModelQuotaRow, TokenQuotaPanelState } from './store.ts';
19
20
  export { mergeModelRows } from './store.ts';
20
21
  export type { TokenQuotaKey } from './locales.ts';
@@ -1,7 +1,8 @@
1
- import { TOKEN_QUOTA_MAX_NOTES } from '@jxgame2020/dsh-token-quota/types';
1
+ import { TOKEN_QUOTA_MAX_NOTES, TOKEN_QUOTA_DEFAULT_TRANSCRIBE } from '@jxgame2020/dsh-token-quota/types';
2
2
  import { createTokenQuotaPanelStore } from "./store.js";
3
3
  import { TokenQuotaPanel } from "./TokenQuotaPanel.js";
4
4
  import { TokenQuotaMusic } from "./music.js";
5
+ import { TokenQuotaMic } from "./mic.js";
5
6
  import { en, zh } from "./locales.js";
6
7
  export { mergeModelRows } from "./store.js";
7
8
  /** Dictionary namespace owning the panel copy. */
@@ -121,6 +122,13 @@ export function apply(ctx) {
121
122
  style: raw?.style ?? 'pentatonic',
122
123
  onlyCurrentSession: raw?.onlyCurrentSession ?? true,
123
124
  });
125
+ /** Build a writable transcription-settings clone with defaults applied. */
126
+ const transcribeDefaults = (raw) => ({
127
+ enabled: raw?.enabled ?? TOKEN_QUOTA_DEFAULT_TRANSCRIBE.enabled,
128
+ baseURL: typeof raw?.baseURL === 'string' && raw.baseURL !== '' ? raw.baseURL : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.baseURL,
129
+ apiKeyEnv: typeof raw?.apiKeyEnv === 'string' && raw.apiKeyEnv !== '' ? raw.apiKeyEnv : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.apiKeyEnv,
130
+ model: typeof raw?.model === 'string' && raw.model !== '' ? raw.model : TOKEN_QUOTA_DEFAULT_TRANSCRIBE.model,
131
+ });
124
132
  const keyOf = (selection) => {
125
133
  if (selection === null)
126
134
  return undefined;
@@ -291,6 +299,7 @@ export function apply(ctx) {
291
299
  bound?.setMusicVolume(musicSettings.volume);
292
300
  bound?.setMusicStyle(musicSettings.style);
293
301
  bound?.setMusicOnlyCurrentSession(musicSettings.onlyCurrentSession);
302
+ bound?.setTranscribeSettings(transcribeDefaults(doc?.transcribe));
294
303
  void fetch('/token-quota', { headers: { accept: 'application/json' } }).then((response) => {
295
304
  if (!response.ok) {
296
305
  bound?.setError(`quota route: ${String(response.status)}`);
@@ -378,6 +387,29 @@ export function apply(ctx) {
378
387
  bound?.setBalanceSettings(enabled, pollMinutes);
379
388
  void scope.set('balance', { enabled, pollMinutes });
380
389
  };
390
+ /** Persist the voice transcription settings and mirror them into the store. */
391
+ const setTranscribeSettings = (settings) => {
392
+ bound?.setTranscribeSettings(settings);
393
+ void scope.set('transcribe', settings);
394
+ };
395
+ /**
396
+ * Send a recorded audio blob to the Host, which forwards it to the
397
+ * configured OpenAI-compatible transcription endpoint. Resolves with the
398
+ * recognised text.
399
+ */
400
+ const transcribe = async (blob) => {
401
+ const res = await fetch('/token-quota/transcribe', {
402
+ method: 'POST',
403
+ headers: { 'content-type': blob.type !== '' ? blob.type : 'application/octet-stream' },
404
+ body: blob,
405
+ });
406
+ if (!res.ok) {
407
+ const detail = await res.text().catch(() => '');
408
+ throw new Error(detail !== '' ? detail : `transcription HTTP ${res.status}`);
409
+ }
410
+ const data = await res.json();
411
+ return typeof data.text === 'string' ? data.text : '';
412
+ };
381
413
  /**
382
414
  * Ask the Host to refresh account balances now and adopt the result.
383
415
  * With a provider key only that provider is refreshed; otherwise all of
@@ -514,6 +546,7 @@ export function apply(ctx) {
514
546
  setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle,
515
547
  setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession,
516
548
  setModelOrder, saveNotes, setBalanceSettings, refreshBalance,
549
+ setTranscribeSettings,
517
550
  };
518
551
  };
519
552
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -525,5 +558,21 @@ export function apply(ctx) {
525
558
  locale: NS,
526
559
  inject: injected,
527
560
  }, TokenQuotaPanel));
561
+ // Mic button inside the chat composer (top-right corner). Only renders when
562
+ // transcription is enabled in settings; a press starts recording, a second
563
+ // press stops and sends the audio to the Host for transcription, then the
564
+ // recognised text is appended to the composer.
565
+ ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
566
+ name: 'conversation.input.overlay',
567
+ id: 'token-quota-mic',
568
+ order: 100,
569
+ label: () => t('transcribeTitle'),
570
+ store,
571
+ locale: NS,
572
+ inject: (_sessionId, micActions) => {
573
+ bound = micActions;
574
+ return { transcribe, setTranscribeSettings };
575
+ },
576
+ }, TokenQuotaMic));
528
577
  }
529
578
  //# sourceMappingURL=index.js.map
@@ -27,7 +27,20 @@ export declare const zh: {
27
27
  settingsTitle: string;
28
28
  settingsTabQuota: string;
29
29
  settingsTabMusic: string;
30
+ settingsTabTranscribe: string;
30
31
  settingsTabOther: string;
32
+ transcribeTitle: string;
33
+ transcribeHint: string;
34
+ transcribeMicDenied: string;
35
+ transcribeEmpty: string;
36
+ transcribeEnabledLabel: string;
37
+ transcribeEnabledHint: string;
38
+ transcribeBaseURLLabel: string;
39
+ transcribeBaseURLHint: string;
40
+ transcribeApiKeyEnvLabel: string;
41
+ transcribeApiKeyEnvHint: string;
42
+ transcribeModelLabel: string;
43
+ transcribeModelHint: string;
31
44
  reorderHint: string;
32
45
  balanceLabel: string;
33
46
  balanceUnconfigured: string;
@@ -145,7 +158,20 @@ export declare const en: {
145
158
  settingsTitle: string;
146
159
  settingsTabQuota: string;
147
160
  settingsTabMusic: string;
161
+ settingsTabTranscribe: string;
148
162
  settingsTabOther: string;
163
+ transcribeTitle: string;
164
+ transcribeHint: string;
165
+ transcribeMicDenied: string;
166
+ transcribeEmpty: string;
167
+ transcribeEnabledLabel: string;
168
+ transcribeEnabledHint: string;
169
+ transcribeBaseURLLabel: string;
170
+ transcribeBaseURLHint: string;
171
+ transcribeApiKeyEnvLabel: string;
172
+ transcribeApiKeyEnvHint: string;
173
+ transcribeModelLabel: string;
174
+ transcribeModelHint: string;
149
175
  reorderHint: string;
150
176
  balanceLabel: string;
151
177
  balanceUnconfigured: string;
@@ -27,7 +27,20 @@ export const zh = {
27
27
  settingsTitle: '设置',
28
28
  settingsTabQuota: '限额设置',
29
29
  settingsTabMusic: '音乐设置',
30
+ settingsTabTranscribe: '转录',
30
31
  settingsTabOther: '其它',
32
+ transcribeTitle: '语音转录',
33
+ transcribeHint: '按下麦克风录音,再按一次停止并转录为文字',
34
+ transcribeMicDenied: '未获得麦克风权限',
35
+ transcribeEmpty: '录音为空',
36
+ transcribeEnabledLabel: '启用语音转录',
37
+ transcribeEnabledHint: '在对话框右上角显示麦克风按钮',
38
+ transcribeBaseURLLabel: '接口地址',
39
+ transcribeBaseURLHint: 'OpenAI 兼容的转录接口 base URL',
40
+ transcribeApiKeyEnvLabel: 'API Key 环境变量',
41
+ transcribeApiKeyEnvHint: '存有 API Key 的凭证变量名(在 Models 页或 credentials 配置)',
42
+ transcribeModelLabel: '模型',
43
+ transcribeModelHint: '语音转文本模型名',
31
44
  reorderHint: '拖动排序',
32
45
  balanceLabel: '余额',
33
46
  balanceUnconfigured: '未配置 DeepSeek API Key',
@@ -143,7 +156,20 @@ export const en = {
143
156
  settingsTitle: 'Settings',
144
157
  settingsTabQuota: 'Quota',
145
158
  settingsTabMusic: 'Music',
159
+ settingsTabTranscribe: 'Transcribe',
146
160
  settingsTabOther: 'Other',
161
+ transcribeTitle: 'Voice transcription',
162
+ transcribeHint: 'Press the mic to record; press again to stop and transcribe to text',
163
+ transcribeMicDenied: 'Microphone access denied',
164
+ transcribeEmpty: 'Recording was empty',
165
+ transcribeEnabledLabel: 'Enable voice transcription',
166
+ transcribeEnabledHint: 'Show a mic button in the top-right of the composer',
167
+ transcribeBaseURLLabel: 'Base URL',
168
+ transcribeBaseURLHint: 'OpenAI-compatible transcription endpoint base URL',
169
+ transcribeApiKeyEnvLabel: 'API key env var',
170
+ transcribeApiKeyEnvHint: 'Credential variable name holding the API key (Models page or credentials)',
171
+ transcribeModelLabel: 'Model',
172
+ transcribeModelHint: 'Speech-to-text model name',
147
173
  reorderHint: 'Drag to reorder',
148
174
  balanceLabel: 'Balance',
149
175
  balanceUnconfigured: 'No DeepSeek API key',
@@ -0,0 +1,19 @@
1
+ import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import { TOKEN_QUOTA_NAMESPACE } from '@jxgame2020/dsh-token-quota/types';
3
+ import type { TokenQuotaTranscribeSettings } from '@jxgame2020/dsh-token-quota/types';
4
+ import type { createTokenQuotaPanelStore } from './store.ts';
5
+ /** Injected business face: transcription send + settings persistence. */
6
+ export interface TokenQuotaMicInjected {
7
+ /** Send a recorded audio blob to the Host for transcription. */
8
+ transcribe: (blob: Blob) => Promise<string>;
9
+ /** Persist the transcription settings (the panel owns the settings UI). */
10
+ setTranscribeSettings: (settings: TokenQuotaTranscribeSettings) => void;
11
+ }
12
+ /** Full mic component props: runtime + store + locale + injected face. */
13
+ export type TokenQuotaMicComponentProps = PropsRuntime<'conversation.input.overlay'> & PropsStore<ReturnType<typeof createTokenQuotaPanelStore>> & PropsLocale<typeof TOKEN_QUOTA_NAMESPACE> & TokenQuotaMicInjected;
14
+ /**
15
+ * Mic button rendered inside the composer card. Manages its own MediaRecorder
16
+ * lifecycle; only the button is shown — no floating panel UI.
17
+ */
18
+ export declare function TokenQuotaMic({ t, useStore, transcribe, }: TokenQuotaMicComponentProps): JSX.Element | null;
19
+ //# sourceMappingURL=mic.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Composer mic button: registered into `conversation.input.overlay` so it sits
4
+ * inside the chat composer card (top-right corner). A press starts recording
5
+ * from the microphone; a second press stops it and sends the audio blob to the
6
+ * Host, which forwards it to the configured OpenAI-compatible transcription
7
+ * endpoint. The returned text is appended to the composer. The button only
8
+ * renders while transcription is enabled in the token-quota settings.
9
+ */
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import css from './mic.module.css';
12
+ /**
13
+ * Append recognised text to the contentEditable composer. Focuses the editor,
14
+ * moves the caret to the end, and uses `insertText` so the host editor's own
15
+ * input handling (undo, model binding) sees it as a typed insertion.
16
+ */
17
+ function appendToComposer(text) {
18
+ const editor = document.querySelector('[data-composer-input]');
19
+ if (editor === null) {
20
+ // No composer on this page — copy to the clipboard as a fallback.
21
+ void navigator.clipboard?.writeText(text).catch(() => { });
22
+ return;
23
+ }
24
+ editor.focus();
25
+ // Move the caret to the end of the editor's text content.
26
+ const selection = window.getSelection();
27
+ if (selection !== null) {
28
+ const range = document.createRange();
29
+ range.selectNodeContents(editor);
30
+ range.collapse(false);
31
+ selection.removeAllRanges();
32
+ selection.addRange(range);
33
+ }
34
+ // execCommand is deprecated but remains the only cross-browser way to trigger
35
+ // a contentEditable editor's own "typed text" handling (undo stack, lexical
36
+ // binding). The host composer listens to it.
37
+ let inserted = false;
38
+ try {
39
+ inserted = document.execCommand('insertText', false, text);
40
+ }
41
+ catch {
42
+ // Some engines throw on execCommand; fall through to the manual path.
43
+ }
44
+ if (!inserted) {
45
+ editor.appendChild(document.createTextNode(text));
46
+ }
47
+ }
48
+ /**
49
+ * Mic button rendered inside the composer card. Manages its own MediaRecorder
50
+ * lifecycle; only the button is shown — no floating panel UI.
51
+ */
52
+ export function TokenQuotaMic({ t, useStore, transcribe, }) {
53
+ const enabled = useStore(s => s.transcribe.enabled);
54
+ const [recording, setRecording] = useState(false);
55
+ const [transcribing, setTranscribing] = useState(false);
56
+ const [error, setError] = useState(null);
57
+ const mediaRecorderRef = useRef(null);
58
+ const chunksRef = useRef([]);
59
+ const streamRef = useRef(null);
60
+ const errorTimerRef = useRef(undefined);
61
+ // Clean up the recorder / stream on unmount so a navigation while recording
62
+ // stops the mic tracks rather than leaving them open.
63
+ useEffect(() => {
64
+ return () => {
65
+ if (errorTimerRef.current !== undefined)
66
+ clearTimeout(errorTimerRef.current);
67
+ cleanupRecording();
68
+ };
69
+ }, []);
70
+ const cleanupRecording = () => {
71
+ if (streamRef.current !== null) {
72
+ for (const track of streamRef.current.getTracks())
73
+ track.stop();
74
+ streamRef.current = null;
75
+ }
76
+ mediaRecorderRef.current = null;
77
+ chunksRef.current = [];
78
+ };
79
+ const showError = (message) => {
80
+ setError(message);
81
+ if (errorTimerRef.current !== undefined)
82
+ clearTimeout(errorTimerRef.current);
83
+ errorTimerRef.current = setTimeout(() => {
84
+ setError(null);
85
+ errorTimerRef.current = undefined;
86
+ }, 4000);
87
+ };
88
+ const startRecording = async () => {
89
+ let stream;
90
+ try {
91
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
92
+ }
93
+ catch {
94
+ showError(t('transcribeMicDenied'));
95
+ return;
96
+ }
97
+ streamRef.current = stream;
98
+ chunksRef.current = [];
99
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
100
+ ? 'audio/webm;codecs=opus'
101
+ : MediaRecorder.isTypeSupported('audio/webm')
102
+ ? 'audio/webm'
103
+ : '';
104
+ const recorder = mimeType !== ''
105
+ ? new MediaRecorder(stream, { mimeType })
106
+ : new MediaRecorder(stream);
107
+ mediaRecorderRef.current = recorder;
108
+ recorder.ondataavailable = (event) => {
109
+ if (event.data.size > 0)
110
+ chunksRef.current.push(event.data);
111
+ };
112
+ recorder.onstop = () => {
113
+ const type = mimeType !== '' ? mimeType : 'audio/webm';
114
+ const blob = new Blob(chunksRef.current, { type });
115
+ cleanupRecording();
116
+ if (blob.size === 0) {
117
+ showError(t('transcribeEmpty'));
118
+ return;
119
+ }
120
+ setTranscribing(true);
121
+ void transcribe(blob).then((text) => {
122
+ setTranscribing(false);
123
+ if (text.trim() !== '')
124
+ appendToComposer(text);
125
+ }, (err) => {
126
+ setTranscribing(false);
127
+ showError(err instanceof Error ? err.message : String(err));
128
+ });
129
+ };
130
+ recorder.start();
131
+ setRecording(true);
132
+ };
133
+ const stopAndTranscribe = () => {
134
+ const recorder = mediaRecorderRef.current;
135
+ if (recorder !== null && recorder.state !== 'inactive') {
136
+ recorder.stop();
137
+ }
138
+ else {
139
+ cleanupRecording();
140
+ }
141
+ setRecording(false);
142
+ };
143
+ const onToggle = () => {
144
+ if (transcribing)
145
+ return;
146
+ if (recording) {
147
+ stopAndTranscribe();
148
+ }
149
+ else {
150
+ void startRecording();
151
+ }
152
+ };
153
+ if (!enabled)
154
+ return null;
155
+ const state = transcribing ? 'busy' : recording ? 'rec' : 'idle';
156
+ return (_jsxs("div", { className: css.micWrap, title: error ?? t('transcribeHint'), children: [_jsx("button", { type: "button", className: `${css.micBtn}${state === 'rec' ? ` ${css.micRec}` : ''}${state === 'busy' ? ` ${css.micBusy}` : ''}${error !== null ? ` ${css.micErr}` : ''}`, disabled: transcribing, onClick: (event) => { event.stopPropagation(); onToggle(); }, onPointerDown: (event) => { event.stopPropagation(); }, "aria-pressed": recording, "aria-label": error ?? t('transcribeTitle'), children: transcribing ? '…' : '🎤' }), error !== null && _jsx("span", { className: css.micErrTip, children: error })] }));
157
+ }
158
+ //# sourceMappingURL=mic.js.map