@jxgame2020/dsh-token-quota 0.1.39 → 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/client.js +466 -133
- package/lib/index.js +184 -29
- package/lib/types/client/TokenQuotaPanel.d.ts +4 -2
- package/lib/types/client/TokenQuotaPanel.js +10 -8
- package/lib/types/client/index.d.ts +1 -0
- package/lib/types/client/index.js +50 -1
- package/lib/types/client/locales.d.ts +26 -0
- package/lib/types/client/locales.js +26 -0
- package/lib/types/client/mic.d.ts +19 -0
- package/lib/types/client/mic.js +158 -0
- package/lib/types/client/store.d.ts +5 -1
- package/lib/types/client/store.js +2 -0
- package/lib/types/index.d.ts +19 -1
- package/lib/types/index.js +192 -28
- package/lib/types/types.d.ts +20 -4
- package/lib/types/types.js +6 -6
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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"
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -112,8 +114,16 @@ export declare class TokenQuotaService extends Service {
|
|
|
112
114
|
* the enabled-transition in `onChange` fetch immediately).
|
|
113
115
|
*/
|
|
114
116
|
private syncBalancePolling;
|
|
115
|
-
/** Refresh every provider
|
|
117
|
+
/** Refresh every DeepSeek-family provider found in the model directory. */
|
|
116
118
|
private refreshAllBalances;
|
|
119
|
+
/**
|
|
120
|
+
* Discover every provider in the model directory that looks like DeepSeek,
|
|
121
|
+
* together with its own credential reference and endpoint base. This is what
|
|
122
|
+
* decides which providers are queried: a provider qualifies when its base URL
|
|
123
|
+
* points at DeepSeek's API host or its credential reference is named with the
|
|
124
|
+
* `DEEPSEEK` prefix, and it must declare an `apiKeyEnv`.
|
|
125
|
+
*/
|
|
126
|
+
private discoverBalanceSources;
|
|
117
127
|
/**
|
|
118
128
|
* Refresh one provider's cached account balance. The API key is resolved
|
|
119
129
|
* through the harness credential seam (`credentials.resolve`) with the
|
|
@@ -121,6 +131,14 @@ export declare class TokenQuotaService extends Service {
|
|
|
121
131
|
* type. A failed fetch keeps the previous value and marks it as an error.
|
|
122
132
|
*/
|
|
123
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;
|
|
124
142
|
/** Read the full per-cycle usage history (log dialog data). */
|
|
125
143
|
readLog(): TokenQuotaLog;
|
|
126
144
|
/**
|
package/lib/types/index.js
CHANGED
|
@@ -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) {
|
|
@@ -271,21 +279,8 @@ function usageTokens(usage) {
|
|
|
271
279
|
+ (usage.cacheWriteTokens ?? 0)
|
|
272
280
|
+ usage.outputTokens;
|
|
273
281
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
*
|
|
277
|
-
* Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
|
|
278
|
-
* per-model limits through the `token-quota` settings namespace.
|
|
279
|
-
*/
|
|
280
|
-
/**
|
|
281
|
-
* Providers the host can query for account balance, keyed by route key. Each
|
|
282
|
-
* entry names the credential reference (environment-variable name), the
|
|
283
|
-
* balance endpoint base, and the optional `*_BASE_URL` override variable.
|
|
284
|
-
* Directory providers outside this map render as "not integrated for query".
|
|
285
|
-
*/
|
|
286
|
-
const BALANCE_SOURCES = {
|
|
287
|
-
deepseek: { keyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', baseEnv: 'DEEPSEEK_BASE_URL' },
|
|
288
|
-
};
|
|
282
|
+
/** DeepSeek's public API host; used to recognise DeepSeek-family providers. */
|
|
283
|
+
const DEEPSEEK_BALANCE_HOST = 'api.deepseek.com';
|
|
289
284
|
export class TokenQuotaService extends Service {
|
|
290
285
|
static Config = z.object({
|
|
291
286
|
storagePath: z.string().default(''),
|
|
@@ -301,7 +296,7 @@ export class TokenQuotaService extends Service {
|
|
|
301
296
|
history = {};
|
|
302
297
|
limits = {};
|
|
303
298
|
monitored = undefined;
|
|
304
|
-
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 } });
|
|
305
300
|
/** Whether update checks are enabled (mirrors the settings document). */
|
|
306
301
|
checkUpdates = true;
|
|
307
302
|
/** Cached upgrade availability; recomputed by {@link refreshUpgrade}. */
|
|
@@ -338,6 +333,8 @@ export class TokenQuotaService extends Service {
|
|
|
338
333
|
disposeRouteClear;
|
|
339
334
|
/** Disposer for the optional balance-refresh route (`POST /token-quota/refresh-balance`). */
|
|
340
335
|
disposeRouteBalance;
|
|
336
|
+
/** Disposer for the voice transcription route (`POST /token-quota/transcribe`). */
|
|
337
|
+
disposeRouteTranscribe;
|
|
341
338
|
/** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
|
|
342
339
|
disposeRouteEvents;
|
|
343
340
|
/** Live SSE subscribers receiving streamed music actions. */
|
|
@@ -382,6 +379,18 @@ export class TokenQuotaService extends Service {
|
|
|
382
379
|
? Math.max(1, Math.min(120, Math.round(doc.balance.pollMinutes)))
|
|
383
380
|
: TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes,
|
|
384
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
|
+
},
|
|
385
394
|
notes: Array.isArray(doc.notes)
|
|
386
395
|
? doc.notes.slice(0, TOKEN_QUOTA_MAX_NOTES).flatMap((note) => {
|
|
387
396
|
if (typeof note.id !== 'string')
|
|
@@ -551,7 +560,7 @@ export class TokenQuotaService extends Service {
|
|
|
551
560
|
catch {
|
|
552
561
|
// Malformed body: fall back to refreshing everything.
|
|
553
562
|
}
|
|
554
|
-
const task = provider !== undefined
|
|
563
|
+
const task = provider !== undefined
|
|
555
564
|
? this.refreshProviderBalance(provider)
|
|
556
565
|
: this.refreshAllBalances();
|
|
557
566
|
void task.finally(() => {
|
|
@@ -562,6 +571,30 @@ export class TokenQuotaService extends Service {
|
|
|
562
571
|
});
|
|
563
572
|
},
|
|
564
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
|
+
});
|
|
565
598
|
this.disposeRouteClear = server.register({
|
|
566
599
|
kind: 'exact',
|
|
567
600
|
path: '/token-quota/clear-log',
|
|
@@ -745,9 +778,66 @@ export class TokenQuotaService extends Service {
|
|
|
745
778
|
};
|
|
746
779
|
schedule();
|
|
747
780
|
}
|
|
748
|
-
/** Refresh every provider
|
|
781
|
+
/** Refresh every DeepSeek-family provider found in the model directory. */
|
|
749
782
|
refreshAllBalances() {
|
|
750
|
-
return Promise.all(
|
|
783
|
+
return Promise.all([...this.discoverBalanceSources().values()]
|
|
784
|
+
.map(source => this.refreshProviderBalance(source.provider, source))).then(() => undefined);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Discover every provider in the model directory that looks like DeepSeek,
|
|
788
|
+
* together with its own credential reference and endpoint base. This is what
|
|
789
|
+
* decides which providers are queried: a provider qualifies when its base URL
|
|
790
|
+
* points at DeepSeek's API host or its credential reference is named with the
|
|
791
|
+
* `DEEPSEEK` prefix, and it must declare an `apiKeyEnv`.
|
|
792
|
+
*/
|
|
793
|
+
discoverBalanceSources() {
|
|
794
|
+
const found = new Map();
|
|
795
|
+
const llm = this.ctx.get('llm');
|
|
796
|
+
const settings = this.ctx.get('settings');
|
|
797
|
+
const fallback = () => ({
|
|
798
|
+
provider: 'deepseek',
|
|
799
|
+
keyEnv: 'DEEPSEEK_API_KEY',
|
|
800
|
+
baseURL: process.env.DEEPSEEK_BASE_URL ?? `https://${DEEPSEEK_BALANCE_HOST}`,
|
|
801
|
+
});
|
|
802
|
+
if (llm?.listConfigurableProviders === undefined) {
|
|
803
|
+
found.set('deepseek', fallback());
|
|
804
|
+
return found;
|
|
805
|
+
}
|
|
806
|
+
for (const entry of llm.listConfigurableProviders()) {
|
|
807
|
+
try {
|
|
808
|
+
let profile = settings?.get(entry.settingsNs);
|
|
809
|
+
for (const segment of entry.settingsPath ?? []) {
|
|
810
|
+
profile = profile !== null && typeof profile === 'object'
|
|
811
|
+
? profile[segment]
|
|
812
|
+
: undefined;
|
|
813
|
+
}
|
|
814
|
+
if (profile === null || typeof profile !== 'object')
|
|
815
|
+
continue;
|
|
816
|
+
const record = profile;
|
|
817
|
+
const keyEnv = typeof record.apiKeyEnv === 'string' && record.apiKeyEnv !== ''
|
|
818
|
+
? record.apiKeyEnv
|
|
819
|
+
: undefined;
|
|
820
|
+
const baseURL = typeof record.baseURL === 'string' && record.baseURL !== ''
|
|
821
|
+
? record.baseURL
|
|
822
|
+
: undefined;
|
|
823
|
+
const isDeepSeek = (baseURL !== undefined && baseURL.includes(DEEPSEEK_BALANCE_HOST))
|
|
824
|
+
|| (keyEnv !== undefined && keyEnv.startsWith('DEEPSEEK'))
|
|
825
|
+
|| entry.provider.startsWith('deepseek');
|
|
826
|
+
if (!isDeepSeek || keyEnv === undefined)
|
|
827
|
+
continue;
|
|
828
|
+
found.set(entry.provider, {
|
|
829
|
+
provider: entry.provider,
|
|
830
|
+
keyEnv,
|
|
831
|
+
baseURL: baseURL ?? `https://${DEEPSEEK_BALANCE_HOST}`,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// Malformed provider entry — skip it and keep the others.
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (found.size === 0)
|
|
839
|
+
found.set('deepseek', fallback());
|
|
840
|
+
return found;
|
|
751
841
|
}
|
|
752
842
|
/**
|
|
753
843
|
* Refresh one provider's cached account balance. The API key is resolved
|
|
@@ -755,9 +845,9 @@ export class TokenQuotaService extends Service {
|
|
|
755
845
|
* environment as a fallback, so the Models page key is used with nothing to
|
|
756
846
|
* type. A failed fetch keeps the previous value and marks it as an error.
|
|
757
847
|
*/
|
|
758
|
-
async refreshProviderBalance(provider) {
|
|
759
|
-
const
|
|
760
|
-
if (
|
|
848
|
+
async refreshProviderBalance(provider, source) {
|
|
849
|
+
const resolved = source ?? this.discoverBalanceSources().get(provider);
|
|
850
|
+
if (resolved === undefined)
|
|
761
851
|
return;
|
|
762
852
|
const existing = this.balanceFetches.get(provider);
|
|
763
853
|
if (existing !== undefined)
|
|
@@ -769,14 +859,14 @@ export class TokenQuotaService extends Service {
|
|
|
769
859
|
let key;
|
|
770
860
|
if (credentials !== undefined) {
|
|
771
861
|
try {
|
|
772
|
-
key = (await credentials.resolve(
|
|
862
|
+
key = (await credentials.resolve(resolved.keyEnv))?.value;
|
|
773
863
|
}
|
|
774
864
|
catch {
|
|
775
865
|
// Seam unreachable: fall through to the environment.
|
|
776
866
|
}
|
|
777
867
|
}
|
|
778
868
|
if (key === undefined || key === '')
|
|
779
|
-
key = process.env[
|
|
869
|
+
key = process.env[resolved.keyEnv];
|
|
780
870
|
if (key === undefined || key === '') {
|
|
781
871
|
this.balances.set(provider, {
|
|
782
872
|
provider,
|
|
@@ -788,9 +878,7 @@ export class TokenQuotaService extends Service {
|
|
|
788
878
|
});
|
|
789
879
|
return;
|
|
790
880
|
}
|
|
791
|
-
const base =
|
|
792
|
-
? (process.env[source.baseEnv] ?? source.baseURL)
|
|
793
|
-
: source.baseURL;
|
|
881
|
+
const base = resolved.baseURL;
|
|
794
882
|
try {
|
|
795
883
|
const controller = new AbortController();
|
|
796
884
|
const timer = setTimeout(() => { controller.abort(); }, 10_000);
|
|
@@ -853,6 +941,80 @@ export class TokenQuotaService extends Service {
|
|
|
853
941
|
this.balanceFetches.set(provider, promise);
|
|
854
942
|
return promise;
|
|
855
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
|
+
}
|
|
856
1018
|
/** Read the full per-cycle usage history (log dialog data). */
|
|
857
1019
|
readLog() {
|
|
858
1020
|
this.rollCycleIfNeeded();
|
|
@@ -1189,6 +1351,8 @@ export class TokenQuotaService extends Service {
|
|
|
1189
1351
|
this.disposeRouteClear();
|
|
1190
1352
|
if (this.disposeRouteBalance !== undefined)
|
|
1191
1353
|
this.disposeRouteBalance();
|
|
1354
|
+
if (this.disposeRouteTranscribe !== undefined)
|
|
1355
|
+
this.disposeRouteTranscribe();
|
|
1192
1356
|
if (this.balanceTimer !== undefined) {
|
|
1193
1357
|
clearTimeout(this.balanceTimer);
|
|
1194
1358
|
this.balanceTimer = undefined;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -56,11 +56,22 @@ export interface TokenQuotaBalanceSettings {
|
|
|
56
56
|
}
|
|
57
57
|
export declare const TOKEN_QUOTA_DEFAULT_BALANCE: TokenQuotaBalanceSettings;
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
|
62
63
|
*/
|
|
63
|
-
export
|
|
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;
|
|
64
75
|
/** Fetch state of the provider account balance. */
|
|
65
76
|
export type TokenQuotaBalanceStatus = 'ok' | 'unconfigured' | 'error';
|
|
66
77
|
/**
|
|
@@ -217,6 +228,11 @@ export interface TokenQuotaSettings {
|
|
|
217
228
|
* often the host polls the balance endpoint.
|
|
218
229
|
*/
|
|
219
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;
|
|
220
236
|
/**
|
|
221
237
|
* Floating scratchpad notes owned by the panel. At most
|
|
222
238
|
* {@link TOKEN_QUOTA_MAX_NOTES} are kept; content, title, placement, size
|
package/lib/types/types.js
CHANGED
|
@@ -33,12 +33,12 @@ export const TOKEN_QUOTA_DEFAULT_BALANCE = {
|
|
|
33
33
|
enabled: true,
|
|
34
34
|
pollMinutes: 5,
|
|
35
35
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
};
|
|
42
42
|
/** Default music settings: silent until the user flips the panel switch. */
|
|
43
43
|
export const TOKEN_QUOTA_DEFAULT_MUSIC = {
|
|
44
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.
|
|
4
|
+
"version": "0.1.41",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|