@jxgame2020/dsh-token-quota 0.1.44 → 0.1.50
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 +563 -170
- package/lib/index.js +93 -25
- package/lib/types/client/TokenQuotaPanel.js +51 -16
- package/lib/types/client/TokenQuotaStash.d.ts +10 -0
- package/lib/types/client/TokenQuotaStash.js +252 -0
- package/lib/types/client/index.js +17 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/locales.js +8 -0
- package/lib/types/client/mic.js +1 -1
- package/lib/types/client/stash.d.ts +37 -0
- package/lib/types/client/stash.js +47 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/index.js +110 -33
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -290,6 +290,75 @@ function usageTokens(usage) {
|
|
|
290
290
|
}
|
|
291
291
|
/** DeepSeek's public API host; used to recognise DeepSeek-family providers. */
|
|
292
292
|
const DEEPSEEK_BALANCE_HOST = "api.deepseek.com";
|
|
293
|
+
/** TeamoRouter's host; its model and billing endpoints share this domain. */
|
|
294
|
+
const TEAMOROUTER_HOST = "teamorouter.cn";
|
|
295
|
+
/** Fallback currency per family, used before the first successful fetch. */
|
|
296
|
+
const BALANCE_DEFAULT_CURRENCY = {
|
|
297
|
+
deepseek: "CNY",
|
|
298
|
+
teamorouter: "USD"
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Recognise which balance family a provider belongs to, from its endpoint, its
|
|
302
|
+
* credential reference, or its route id. Returns undefined for providers whose
|
|
303
|
+
* balance this plugin cannot query.
|
|
304
|
+
* @param provider - registered provider route id.
|
|
305
|
+
* @param keyEnv - the provider's declared `apiKeyEnv`, when it has one.
|
|
306
|
+
* @param baseURL - the provider's endpoint base, when it declares one.
|
|
307
|
+
* @returns the matching family, or undefined.
|
|
308
|
+
*/
|
|
309
|
+
function balanceKindOf(provider, keyEnv, baseURL) {
|
|
310
|
+
const host = (baseURL ?? "").toLowerCase();
|
|
311
|
+
const key = (keyEnv ?? "").toUpperCase();
|
|
312
|
+
const id = provider.toLowerCase();
|
|
313
|
+
if (host.includes(DEEPSEEK_BALANCE_HOST) || key.startsWith("DEEPSEEK") || id.startsWith("deepseek")) return "deepseek";
|
|
314
|
+
if (host.includes(TEAMOROUTER_HOST) || key.includes("TEAMO") || id.includes("teamo")) return "teamorouter";
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The balance endpoint for one family. TeamoRouter serves billing from the apex
|
|
318
|
+
* host while its model endpoint lives on the `api.` subdomain, so the host is
|
|
319
|
+
* rewritten rather than concatenated.
|
|
320
|
+
* @param kind - balance family.
|
|
321
|
+
* @param baseURL - the provider's model endpoint base.
|
|
322
|
+
* @returns the absolute balance URL.
|
|
323
|
+
*/
|
|
324
|
+
function balanceURLOf(kind, baseURL) {
|
|
325
|
+
const base = baseURL.replace(/\/+$/, "");
|
|
326
|
+
if (kind === "teamorouter") return `${base.replace("//api.teamorouter.cn", "//teamorouter.cn")}/billing/balance`;
|
|
327
|
+
return `${base}/user/balance`;
|
|
328
|
+
}
|
|
329
|
+
/** Read a number out of a wire field that may be a string or a number. */
|
|
330
|
+
function balanceNum(value, fallback = 0) {
|
|
331
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
|
|
332
|
+
return Number.isFinite(n) ? n : fallback;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* DeepSeek: `{ is_available, balance_infos: [{ currency, total_balance,
|
|
336
|
+
* granted_balance, topped_up_balance }] }`.
|
|
337
|
+
*/
|
|
338
|
+
function parseDeepseekBalance(data) {
|
|
339
|
+
const record = data ?? {};
|
|
340
|
+
const first = Array.isArray(record.balance_infos) ? record.balance_infos[0] : void 0;
|
|
341
|
+
return {
|
|
342
|
+
currency: typeof first?.currency === "string" && first.currency !== "" ? first.currency : "CNY",
|
|
343
|
+
total: balanceNum(first?.total_balance),
|
|
344
|
+
granted: balanceNum(first?.granted_balance),
|
|
345
|
+
toppedUp: balanceNum(first?.topped_up_balance),
|
|
346
|
+
isAvailable: record.is_available !== false
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* TeamoRouter: `{ balance: { value: "85.320000", currency: "USD" } }`. The
|
|
351
|
+
* account is identified by the API key, so there is no availability flag.
|
|
352
|
+
*/
|
|
353
|
+
function parseTeamorouterBalance(data) {
|
|
354
|
+
const record = data ?? {};
|
|
355
|
+
const balance = record.balance !== null && typeof record.balance === "object" ? record.balance : void 0;
|
|
356
|
+
return {
|
|
357
|
+
currency: typeof balance?.currency === "string" && balance.currency !== "" ? balance.currency : "USD",
|
|
358
|
+
total: balanceNum(balance?.value),
|
|
359
|
+
isAvailable: true
|
|
360
|
+
};
|
|
361
|
+
}
|
|
293
362
|
var TokenQuotaService = class TokenQuotaService extends Service {
|
|
294
363
|
static Config = z.object({ storagePath: z.string().default("") });
|
|
295
364
|
storagePath;
|
|
@@ -651,7 +720,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
651
720
|
return this.upgradeCheck;
|
|
652
721
|
}
|
|
653
722
|
async doRefreshUpgrade() {
|
|
654
|
-
const current = "0.1.
|
|
723
|
+
const current = "0.1.50";
|
|
655
724
|
let latest;
|
|
656
725
|
try {
|
|
657
726
|
const response = await fetch(REGISTRY_LATEST_URL, {
|
|
@@ -729,11 +798,11 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
729
798
|
return Promise.all([...this.discoverBalanceSources().values()].map((source) => this.refreshProviderBalance(source.provider, source))).then(() => void 0);
|
|
730
799
|
}
|
|
731
800
|
/**
|
|
732
|
-
* Discover every provider in the model directory
|
|
733
|
-
* together with its own credential reference and endpoint base.
|
|
734
|
-
* decides which providers are queried: a provider qualifies when
|
|
735
|
-
*
|
|
736
|
-
*
|
|
801
|
+
* Discover every provider in the model directory whose balance this plugin
|
|
802
|
+
* can query, together with its own credential reference and endpoint base.
|
|
803
|
+
* This is what decides which providers are queried: a provider qualifies when
|
|
804
|
+
* its base URL, credential reference, or route id matches a supported family
|
|
805
|
+
* (see {@link balanceKindOf}), and it must declare an `apiKeyEnv`.
|
|
737
806
|
*/
|
|
738
807
|
discoverBalanceSources() {
|
|
739
808
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -742,7 +811,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
742
811
|
const fallback = () => ({
|
|
743
812
|
provider: "deepseek",
|
|
744
813
|
keyEnv: "DEEPSEEK_API_KEY",
|
|
745
|
-
baseURL: process.env.DEEPSEEK_BASE_URL ?? `https://${DEEPSEEK_BALANCE_HOST}
|
|
814
|
+
baseURL: process.env.DEEPSEEK_BASE_URL ?? `https://${DEEPSEEK_BALANCE_HOST}`,
|
|
815
|
+
kind: "deepseek"
|
|
746
816
|
});
|
|
747
817
|
if (llm?.listConfigurableProviders === void 0) {
|
|
748
818
|
found.set("deepseek", fallback());
|
|
@@ -755,11 +825,13 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
755
825
|
const record = profile;
|
|
756
826
|
const keyEnv = typeof record.apiKeyEnv === "string" && record.apiKeyEnv !== "" ? record.apiKeyEnv : void 0;
|
|
757
827
|
const baseURL = typeof record.baseURL === "string" && record.baseURL !== "" ? record.baseURL : void 0;
|
|
758
|
-
|
|
828
|
+
const kind = balanceKindOf(entry.provider, keyEnv, baseURL);
|
|
829
|
+
if (kind === void 0 || keyEnv === void 0) continue;
|
|
759
830
|
found.set(entry.provider, {
|
|
760
831
|
provider: entry.provider,
|
|
761
832
|
keyEnv,
|
|
762
|
-
baseURL: baseURL ?? `https://${DEEPSEEK_BALANCE_HOST}`
|
|
833
|
+
baseURL: baseURL ?? (kind === "deepseek" ? `https://${DEEPSEEK_BALANCE_HOST}` : `https://${TEAMOROUTER_HOST}`),
|
|
834
|
+
kind
|
|
763
835
|
});
|
|
764
836
|
} catch {}
|
|
765
837
|
if (found.size === 0) found.set("deepseek", fallback());
|
|
@@ -787,7 +859,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
787
859
|
if (key === void 0 || key === "") {
|
|
788
860
|
this.balances.set(provider, {
|
|
789
861
|
provider,
|
|
790
|
-
currency:
|
|
862
|
+
currency: BALANCE_DEFAULT_CURRENCY[resolved.kind],
|
|
791
863
|
total: 0,
|
|
792
864
|
isAvailable: false,
|
|
793
865
|
fetchedAt: Date.now(),
|
|
@@ -795,7 +867,6 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
795
867
|
});
|
|
796
868
|
return;
|
|
797
869
|
}
|
|
798
|
-
const base = resolved.baseURL;
|
|
799
870
|
try {
|
|
800
871
|
const controller = new AbortController();
|
|
801
872
|
const timer = setTimeout(() => {
|
|
@@ -803,7 +874,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
803
874
|
}, 1e4);
|
|
804
875
|
let res;
|
|
805
876
|
try {
|
|
806
|
-
res = await fetch(
|
|
877
|
+
res = await fetch(balanceURLOf(resolved.kind, resolved.baseURL), {
|
|
807
878
|
headers: {
|
|
808
879
|
authorization: `Bearer ${key}`,
|
|
809
880
|
accept: "application/json"
|
|
@@ -815,26 +886,23 @@ var TokenQuotaService = class TokenQuotaService extends Service {
|
|
|
815
886
|
}
|
|
816
887
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
817
888
|
const data = await res.json();
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
const n = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
|
|
821
|
-
return Number.isFinite(n) ? n : 0;
|
|
822
|
-
};
|
|
823
|
-
this.balances.set(provider, {
|
|
889
|
+
const parsed = resolved.kind === "teamorouter" ? parseTeamorouterBalance(data) : parseDeepseekBalance(data);
|
|
890
|
+
const next = {
|
|
824
891
|
provider,
|
|
825
|
-
currency:
|
|
826
|
-
total:
|
|
827
|
-
|
|
828
|
-
toppedUp: num(first?.topped_up_balance),
|
|
829
|
-
isAvailable: data?.is_available !== false,
|
|
892
|
+
currency: parsed.currency,
|
|
893
|
+
total: parsed.total,
|
|
894
|
+
isAvailable: parsed.isAvailable,
|
|
830
895
|
fetchedAt: Date.now(),
|
|
831
896
|
status: "ok"
|
|
832
|
-
}
|
|
897
|
+
};
|
|
898
|
+
if (parsed.granted !== void 0) next.granted = parsed.granted;
|
|
899
|
+
if (parsed.toppedUp !== void 0) next.toppedUp = parsed.toppedUp;
|
|
900
|
+
this.balances.set(provider, next);
|
|
833
901
|
} catch (error) {
|
|
834
902
|
const previous = this.balances.get(provider);
|
|
835
903
|
const next = {
|
|
836
904
|
provider,
|
|
837
|
-
currency: previous?.currency ??
|
|
905
|
+
currency: previous?.currency ?? BALANCE_DEFAULT_CURRENCY[resolved.kind],
|
|
838
906
|
total: previous?.total ?? 0,
|
|
839
907
|
isAvailable: false,
|
|
840
908
|
fetchedAt: previous?.fetchedAt ?? Date.now(),
|
|
@@ -431,15 +431,45 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
|
|
|
431
431
|
};
|
|
432
432
|
// Track whether any text input (composer, search, dialogs) currently has
|
|
433
433
|
// focus, so the panel can dim while the user types elsewhere.
|
|
434
|
+
// Track whether the user is typing in a text field OUTSIDE this plugin's own
|
|
435
|
+
// surfaces, so the panel can dim while the chat behind it needs to stay
|
|
436
|
+
// readable.
|
|
437
|
+
//
|
|
438
|
+
// `focusout` matters as much as `focusin`: an earlier version observed only
|
|
439
|
+
// focusin, so once any field had ever received focus the flag stayed true for
|
|
440
|
+
// the rest of the session and the panel sat at 30% opacity — which reads as
|
|
441
|
+
// "the panel disappeared" until a reload reset the state. Recompute from
|
|
442
|
+
// document.activeElement rather than trusting the event, and also settle on
|
|
443
|
+
// pointer-down and window blur so a removed focused node cannot strand it.
|
|
434
444
|
useEffect(() => {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
445
|
+
const isEditable = (el) => el !== null && (el.matches('input, textarea, [contenteditable="true"]')
|
|
446
|
+
|| el.closest('input, textarea, [contenteditable="true"]') !== null);
|
|
447
|
+
const isOwnSurface = (el) => {
|
|
448
|
+
if (el === null)
|
|
449
|
+
return false;
|
|
450
|
+
for (const node of [panelRef.current, settingsRef.current, logRef.current]) {
|
|
451
|
+
if (node !== null && node.contains(el))
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
};
|
|
456
|
+
const sync = () => {
|
|
457
|
+
const active = document.activeElement;
|
|
458
|
+
setInputActive(!isOwnSurface(active) && isEditable(active));
|
|
459
|
+
};
|
|
460
|
+
// focusout/pointerdown run before the next element gains focus: settle next tick.
|
|
461
|
+
const deferredSync = () => { window.setTimeout(sync, 0); };
|
|
462
|
+
document.addEventListener('focusin', sync);
|
|
463
|
+
document.addEventListener('focusout', deferredSync);
|
|
464
|
+
document.addEventListener('pointerdown', deferredSync, true);
|
|
465
|
+
window.addEventListener('blur', sync);
|
|
466
|
+
sync();
|
|
467
|
+
return () => {
|
|
468
|
+
document.removeEventListener('focusin', sync);
|
|
469
|
+
document.removeEventListener('focusout', deferredSync);
|
|
470
|
+
document.removeEventListener('pointerdown', deferredSync, true);
|
|
471
|
+
window.removeEventListener('blur', sync);
|
|
440
472
|
};
|
|
441
|
-
window.addEventListener('focusin', onFocusIn);
|
|
442
|
-
return () => { window.removeEventListener('focusin', onFocusIn); };
|
|
443
473
|
}, []);
|
|
444
474
|
// When a NEW latest version appears (manual check or periodic poll):
|
|
445
475
|
// expand the panel (it may be collapsed), force it opaque, and flash the
|
|
@@ -552,14 +582,6 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
|
|
|
552
582
|
});
|
|
553
583
|
return () => { cancelled = true; };
|
|
554
584
|
}, [logOpen, actions]);
|
|
555
|
-
if (collapsed) {
|
|
556
|
-
return (_jsxs("div", { className: css.tab, role: "button", tabIndex: 0, title: t('expandHint'), "aria-label": t('expandHint'), onClick: () => { setCollapsed(false); }, onKeyDown: (event) => {
|
|
557
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
558
|
-
event.preventDefault();
|
|
559
|
-
setCollapsed(false);
|
|
560
|
-
}
|
|
561
|
-
}, children: [_jsx("span", { className: css.tabIcon, "aria-hidden": "true", children: "\u25B8" }), _jsx("span", { className: css.tabLabel, children: t('expand') })] }));
|
|
562
|
-
}
|
|
563
585
|
const openSettings = () => {
|
|
564
586
|
actions.setDialogOpen(true);
|
|
565
587
|
};
|
|
@@ -775,7 +797,20 @@ export function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored,
|
|
|
775
797
|
const panelDimClass = dimWhenIdle
|
|
776
798
|
? (hovered || upgradeFlash ? '' : inputActive ? css.panelDimStrong : css.panelDim)
|
|
777
799
|
: '';
|
|
778
|
-
|
|
800
|
+
// Collapsed tab. This early return MUST sit below every hook: React requires
|
|
801
|
+
// the hook count to be identical across renders, and returning above the
|
|
802
|
+
// balance/timestamp memos made it drop on collapse — React threw "Rendered
|
|
803
|
+
// fewer hooks than expected", the slot error boundary swallowed the entry,
|
|
804
|
+
// and the panel simply vanished until a page reload reset `collapsed`.
|
|
805
|
+
if (collapsed) {
|
|
806
|
+
return (_jsxs("div", { className: css.tab, role: "button", tabIndex: 0, title: t('expandHint'), "aria-label": t('expandHint'), onClick: () => { setCollapsed(false); }, onKeyDown: (event) => {
|
|
807
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
808
|
+
event.preventDefault();
|
|
809
|
+
setCollapsed(false);
|
|
810
|
+
}
|
|
811
|
+
}, children: [_jsx("span", { className: css.tabIcon, "aria-hidden": "true", children: "\u25B8" }), _jsx("span", { className: css.tabLabel, children: t('expand') })] }));
|
|
812
|
+
}
|
|
813
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: panelRef, "data-token-quota-panel": "", 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: [_jsx("div", { className: css.headerText, children: _jsxs("div", { className: css.title, children: [t('title'), __TOKEN_QUOTA_VERSION__ !== undefined && __TOKEN_QUOTA_VERSION__ !== '' && (upgrade !== null
|
|
779
814
|
? (_jsxs("span", { className: `${css.version} ${css.versionNew}`, title: t('upgradeAvailableHint'), children: ["v", __TOKEN_QUOTA_VERSION__, " \u2192 v", upgrade.latestVersion] }))
|
|
780
815
|
: (_jsxs("span", { className: css.version, children: ["v", __TOKEN_QUOTA_VERSION__] })))] }) }), _jsxs("div", { className: css.headerActions, onPointerDown: (event) => { event.stopPropagation(); }, children: [_jsx("button", { type: "button", "aria-pressed": musicEnabled, className: `${css.settingsBtn}${musicEnabled ? ` ${css.settingsBtnActive}` : ''}`, title: `${t('musicLabel')}:${musicEnabled ? t('musicOn') : t('musicOff')}`, onClick: () => { setMusicEnabled(!musicEnabled); }, children: "\u266A" }), _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.notesBar, onPointerDown: (event) => { event.stopPropagation(); }, children: [notes.map((note) => {
|
|
781
816
|
const open = note.visible && !note.collapsed;
|
|
@@ -0,0 +1,10 @@
|
|
|
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 { createTokenQuotaStashStore } from './stash.ts';
|
|
4
|
+
/** Full component props: runtime + per-session stash store + locale. */
|
|
5
|
+
export type TokenQuotaStashComponentProps = PropsRuntime<'conversation.input.overlay'> & PropsStore<ReturnType<typeof createTokenQuotaStashStore>> & PropsLocale<typeof TOKEN_QUOTA_NAMESPACE>;
|
|
6
|
+
/**
|
|
7
|
+
* Numbered stash chips plus the capture popup, rendered inside the composer.
|
|
8
|
+
*/
|
|
9
|
+
export declare function TokenQuotaStash({ t, useStore, actions }: TokenQuotaStashComponentProps): JSX.Element;
|
|
10
|
+
//# sourceMappingURL=TokenQuotaStash.d.ts.map
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Session-scoped stash UI, registered into `conversation.input.overlay` so it
|
|
4
|
+
* sits inside the composer card beside the mic.
|
|
5
|
+
*
|
|
6
|
+
* Capture (two ways, as requested):
|
|
7
|
+
* 1. select text — in a message OR in the composer draft — and use the small
|
|
8
|
+
* popup that offers 复制 / 临存;
|
|
9
|
+
* 2. press Cmd/Ctrl+Shift+S on any selection.
|
|
10
|
+
*
|
|
11
|
+
* Retrieval: one numbered chip per snippet to the left of the mic. Hovering a
|
|
12
|
+
* chip shows the whole text; clicking drops it into the composer at the caret
|
|
13
|
+
* and retires the chip.
|
|
14
|
+
*
|
|
15
|
+
* The snippets themselves live in a per-session persisted store (see stash.ts),
|
|
16
|
+
* so they follow their session across reloads and never reach another session.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useRef, useState } from 'react';
|
|
19
|
+
import css from './TokenQuotaStash.module.css';
|
|
20
|
+
/** The harness composer's editable surface. */
|
|
21
|
+
const COMPOSER_INPUT = '[data-composer-input]';
|
|
22
|
+
/** Anything matching this is "not capturable text": this plugin's own layers
|
|
23
|
+
and form fields — selecting text inside an input is editing, not quoting.
|
|
24
|
+
The composer is deliberately NOT excluded: stashing part of a draft is a
|
|
25
|
+
supported flow. */
|
|
26
|
+
const IGNORED_SELECTION = '[data-token-quota-stash], [data-token-quota-panel], input, textarea, select';
|
|
27
|
+
/** The composer's contentEditable, when the composer is on screen. */
|
|
28
|
+
function composerEditor() {
|
|
29
|
+
return document.querySelector(COMPOSER_INPUT);
|
|
30
|
+
}
|
|
31
|
+
/** Copy text, falling back to a hidden textarea when the clipboard API is denied. */
|
|
32
|
+
function copyText(text) {
|
|
33
|
+
void navigator.clipboard?.writeText(text).catch(() => {
|
|
34
|
+
const scratch = document.createElement('textarea');
|
|
35
|
+
scratch.value = text;
|
|
36
|
+
scratch.style.position = 'fixed';
|
|
37
|
+
scratch.style.opacity = '0';
|
|
38
|
+
document.body.appendChild(scratch);
|
|
39
|
+
scratch.select();
|
|
40
|
+
try {
|
|
41
|
+
document.execCommand('copy');
|
|
42
|
+
}
|
|
43
|
+
catch { /* give up quietly */ }
|
|
44
|
+
document.body.removeChild(scratch);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Drop text into the composer at the caret the user last had there. Falls back
|
|
49
|
+
* to the end of the editor when no caret was recorded, and to the clipboard
|
|
50
|
+
* when the composer is not on screen at all.
|
|
51
|
+
*/
|
|
52
|
+
function insertIntoComposer(text, caret) {
|
|
53
|
+
const editor = composerEditor();
|
|
54
|
+
if (editor === null) {
|
|
55
|
+
copyText(text);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
editor.focus();
|
|
59
|
+
const selection = window.getSelection();
|
|
60
|
+
if (selection !== null) {
|
|
61
|
+
const restore = caret !== null && editor.contains(caret.startContainer);
|
|
62
|
+
if (restore) {
|
|
63
|
+
selection.removeAllRanges();
|
|
64
|
+
selection.addRange(caret);
|
|
65
|
+
}
|
|
66
|
+
else if (!editor.contains(selection.anchorNode)) {
|
|
67
|
+
// No usable caret: land at the end so the insertion is always visible.
|
|
68
|
+
const range = document.createRange();
|
|
69
|
+
range.selectNodeContents(editor);
|
|
70
|
+
range.collapse(false);
|
|
71
|
+
selection.removeAllRanges();
|
|
72
|
+
selection.addRange(range);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let inserted = false;
|
|
76
|
+
try {
|
|
77
|
+
inserted = document.execCommand('insertText', false, text);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Some engines throw on execCommand; fall through to the manual path.
|
|
81
|
+
}
|
|
82
|
+
if (!inserted)
|
|
83
|
+
editor.appendChild(document.createTextNode(text));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Remove the live selection when it sits inside the composer: stashing part of
|
|
87
|
+
* a draft means MOVING it out of the way ("cut"), so the user does not have to
|
|
88
|
+
* delete it by hand and cannot accidentally send it. Selections anywhere else
|
|
89
|
+
* (a message) are left untouched.
|
|
90
|
+
*/
|
|
91
|
+
function cutComposerSelection() {
|
|
92
|
+
const editor = composerEditor();
|
|
93
|
+
const selection = window.getSelection();
|
|
94
|
+
if (editor === null || selection === null || selection.rangeCount === 0)
|
|
95
|
+
return;
|
|
96
|
+
const anchor = selection.anchorNode;
|
|
97
|
+
if (anchor === null || !editor.contains(anchor) || selection.isCollapsed)
|
|
98
|
+
return;
|
|
99
|
+
const before = editor.textContent ?? '';
|
|
100
|
+
// Route the removal through the editor's OWN input pipeline. The composer is
|
|
101
|
+
// a Lexical editor and only learns about changes it receives as input events;
|
|
102
|
+
// touching the DOM directly (Range.deleteContents) leaves Lexical's model
|
|
103
|
+
// holding the old text, so its next reconcile puts the text straight back —
|
|
104
|
+
// which is exactly why a full-selection cut used to come undone while a
|
|
105
|
+
// partial one appeared to work.
|
|
106
|
+
for (const attempt of ['cut', 'delete', 'insertText']) {
|
|
107
|
+
try {
|
|
108
|
+
if (attempt === 'insertText')
|
|
109
|
+
document.execCommand('insertText', false, '');
|
|
110
|
+
else
|
|
111
|
+
document.execCommand(attempt);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Fall through to the next strategy.
|
|
115
|
+
}
|
|
116
|
+
if ((editor.textContent ?? '') !== before)
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Keycap emoji for the chip badges; index 0 renders as 1️⃣. */
|
|
121
|
+
const STASH_BADGES = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'];
|
|
122
|
+
/** The badge for the n-th (0-based) chip, falling back to a plain digit. */
|
|
123
|
+
function stashBadge(index) {
|
|
124
|
+
return STASH_BADGES[index] ?? String(index + 1);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Numbered stash chips plus the capture popup, rendered inside the composer.
|
|
128
|
+
*/
|
|
129
|
+
export function TokenQuotaStash({ t, useStore, actions }) {
|
|
130
|
+
const items = useStore(s => s.items);
|
|
131
|
+
const [hovered, setHovered] = useState(null);
|
|
132
|
+
const [pending, setPending] = useState(null);
|
|
133
|
+
// The caret the user last had in the composer, restored on insertion so a
|
|
134
|
+
// snippet lands where they were typing rather than at the very end.
|
|
135
|
+
const caretRef = useRef(null);
|
|
136
|
+
const pendingRef = useRef(null);
|
|
137
|
+
pendingRef.current = pending;
|
|
138
|
+
// Capture: watch the document selection for the popup, and remember the
|
|
139
|
+
// composer caret while the user types.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const sync = () => {
|
|
142
|
+
const selection = window.getSelection();
|
|
143
|
+
if (selection === null || selection.rangeCount === 0) {
|
|
144
|
+
setPending(null);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const editor = composerEditor();
|
|
148
|
+
const anchor = selection.anchorNode;
|
|
149
|
+
if (editor !== null && anchor !== null && editor.contains(anchor)) {
|
|
150
|
+
// Selecting inside the composer is a first-class capture source too
|
|
151
|
+
// (write several thoughts, send one, stash the rest). Record the
|
|
152
|
+
// insertion point on the way through — but only for a collapsed caret,
|
|
153
|
+
// since a live selection would be replaced when a snippet lands.
|
|
154
|
+
if (selection.isCollapsed)
|
|
155
|
+
caretRef.current = selection.getRangeAt(0).cloneRange();
|
|
156
|
+
}
|
|
157
|
+
if (selection.isCollapsed) {
|
|
158
|
+
setPending(null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const text = selection.toString().trim();
|
|
162
|
+
if (text === '') {
|
|
163
|
+
setPending(null);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const range = selection.getRangeAt(0);
|
|
167
|
+
const container = range.commonAncestorContainer;
|
|
168
|
+
const element = container.nodeType === Node.ELEMENT_NODE
|
|
169
|
+
? container
|
|
170
|
+
: container.parentElement;
|
|
171
|
+
if (element !== null && element.closest(IGNORED_SELECTION) !== null) {
|
|
172
|
+
setPending(null);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const rects = range.getClientRects();
|
|
176
|
+
const rect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
|
|
177
|
+
if (rect === undefined) {
|
|
178
|
+
setPending(null);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const half = 70;
|
|
182
|
+
// The composer sits at the bottom of the window, so a popup below the
|
|
183
|
+
// selection would fall off-screen: flip it above when there is no room.
|
|
184
|
+
const POPUP_HEIGHT = 34;
|
|
185
|
+
const below = rect.bottom + 6;
|
|
186
|
+
const y = below + POPUP_HEIGHT > window.innerHeight
|
|
187
|
+
? Math.max(8, rect.top - POPUP_HEIGHT - 6)
|
|
188
|
+
: below;
|
|
189
|
+
setPending({
|
|
190
|
+
text,
|
|
191
|
+
x: Math.min(Math.max(rect.left + rect.width / 2, half + 8), window.innerWidth - half - 8),
|
|
192
|
+
y,
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
document.addEventListener('selectionchange', sync);
|
|
196
|
+
return () => { document.removeEventListener('selectionchange', sync); };
|
|
197
|
+
}, []);
|
|
198
|
+
// Capture by keyboard: Cmd/Ctrl+Shift+S on any selection — a message or the
|
|
199
|
+
// draft being written in the composer.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const onKeyDown = (event) => {
|
|
202
|
+
if (!(event.metaKey || event.ctrlKey) || !event.shiftKey)
|
|
203
|
+
return;
|
|
204
|
+
if (event.key.toLowerCase() !== 's')
|
|
205
|
+
return;
|
|
206
|
+
const selection = window.getSelection();
|
|
207
|
+
if (selection === null || selection.isCollapsed)
|
|
208
|
+
return;
|
|
209
|
+
const text = selection.toString().trim();
|
|
210
|
+
if (text === '')
|
|
211
|
+
return;
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
actions.add(text);
|
|
214
|
+
// Stashing a composer draft moves the text out of the way.
|
|
215
|
+
cutComposerSelection();
|
|
216
|
+
setPending(null);
|
|
217
|
+
};
|
|
218
|
+
window.addEventListener('keydown', onKeyDown);
|
|
219
|
+
return () => { window.removeEventListener('keydown', onKeyDown); };
|
|
220
|
+
}, [actions]);
|
|
221
|
+
const stash = () => {
|
|
222
|
+
const text = pendingRef.current?.text;
|
|
223
|
+
if (text === undefined)
|
|
224
|
+
return;
|
|
225
|
+
actions.add(text);
|
|
226
|
+
// In the composer this is a cut; in a message the text stays put.
|
|
227
|
+
cutComposerSelection();
|
|
228
|
+
setPending(null);
|
|
229
|
+
window.getSelection()?.removeAllRanges();
|
|
230
|
+
};
|
|
231
|
+
const hoveredItem = hovered === null ? undefined : items.find(item => item.id === hovered.id);
|
|
232
|
+
return (_jsxs(_Fragment, { children: [items.length > 0 && (_jsx("div", { className: css.stashRow, "data-token-quota-stash": "", children: items.map((item, index) => (_jsx("button", { type: "button", className: css.stashChip,
|
|
233
|
+
// Keeping focus (and the caret) in the composer is the whole point:
|
|
234
|
+
// the snippet must land where the user was typing.
|
|
235
|
+
onMouseDown: (event) => { event.preventDefault(); }, onMouseEnter: (event) => {
|
|
236
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
237
|
+
setHovered({ id: item.id, left: rect.left, top: rect.top });
|
|
238
|
+
}, onMouseLeave: () => { setHovered(null); }, onClick: () => {
|
|
239
|
+
insertIntoComposer(item.text, caretRef.current);
|
|
240
|
+
actions.remove(item.id);
|
|
241
|
+
setHovered(null);
|
|
242
|
+
}, "aria-label": t('stashChipHint').replace('{n}', String(index + 1)), children: stashBadge(index) }, item.id))) })), hovered !== null && hoveredItem !== undefined && (_jsx("div", { className: css.stashTip, "data-token-quota-stash": "", style: {
|
|
243
|
+
left: Math.min(hovered.left, Math.max(8, window.innerWidth - 368)),
|
|
244
|
+
bottom: window.innerHeight - hovered.top + 6,
|
|
245
|
+
}, children: hoveredItem.text })), pending !== null && (_jsxs("div", { className: css.selPopup, "data-token-quota-stash": "", style: { left: pending.x, top: pending.y },
|
|
246
|
+
// Keep the selection alive so the click can act on it.
|
|
247
|
+
onMouseDown: (event) => { event.preventDefault(); }, children: [_jsxs("button", { type: "button", className: css.selBtn, onClick: () => {
|
|
248
|
+
copyText(pending.text);
|
|
249
|
+
setPending(null);
|
|
250
|
+
}, children: [_jsxs("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: [_jsx("rect", { x: "5.6", y: "5.6", width: "7.8", height: "7.8", rx: "1.4", fill: "none", stroke: "currentColor", strokeWidth: "1.3" }), _jsx("path", { d: "M10.4 5.6V4a1.4 1.4 0 0 0-1.4-1.4H4A1.4 1.4 0 0 0 2.6 4v5A1.4 1.4 0 0 0 4 10.4h1.6", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" })] }), t('stashCopy')] }), _jsx("span", { className: css.selDivider, "aria-hidden": "true" }), _jsxs("button", { type: "button", className: css.selBtn, onClick: stash, children: [_jsx("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { d: "M9.3 1.5 3.8 8.7h3.4l-.9 5.8 5.5-7.3H8.4z", fill: "currentColor" }) }), t('stashSave')] })] }))] }));
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=TokenQuotaStash.js.map
|
|
@@ -3,6 +3,8 @@ import { createTokenQuotaPanelStore } from "./store.js";
|
|
|
3
3
|
import { TokenQuotaPanel } from "./TokenQuotaPanel.js";
|
|
4
4
|
import { TokenQuotaMusic } from "./music.js";
|
|
5
5
|
import { TokenQuotaMic } from "./mic.js";
|
|
6
|
+
import { createTokenQuotaStashStore } from "./stash.js";
|
|
7
|
+
import { TokenQuotaStash } from "./TokenQuotaStash.js";
|
|
6
8
|
import { en, zh } from "./locales.js";
|
|
7
9
|
export { mergeModelRows } from "./store.js";
|
|
8
10
|
/** Dictionary namespace owning the panel copy. */
|
|
@@ -635,5 +637,20 @@ export function apply(ctx) {
|
|
|
635
637
|
t: t,
|
|
636
638
|
}),
|
|
637
639
|
}, TokenQuotaMic));
|
|
640
|
+
// Stash: numbered snippets picked out of the conversation, dropped back into
|
|
641
|
+
// the composer on click. This entry carries its OWN store handle (never the
|
|
642
|
+
// panel's): registered on a session-scoped slot, the renderer builds one
|
|
643
|
+
// instance per session and the engine suffixes the persist key with that
|
|
644
|
+
// session's id — so snippets follow their session across reloads and cannot
|
|
645
|
+
// leak into another one.
|
|
646
|
+
const stashStore = createTokenQuotaStashStore();
|
|
647
|
+
ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
|
|
648
|
+
name: 'conversation.input.overlay',
|
|
649
|
+
id: 'token-quota-stash',
|
|
650
|
+
order: 90,
|
|
651
|
+
label: () => t('stashTitle'),
|
|
652
|
+
store: stashStore,
|
|
653
|
+
locale: NS,
|
|
654
|
+
}, TokenQuotaStash));
|
|
638
655
|
}
|
|
639
656
|
//# sourceMappingURL=index.js.map
|
|
@@ -49,6 +49,10 @@ export declare const zh: {
|
|
|
49
49
|
transcribeKeyConfigured: string;
|
|
50
50
|
transcribeKeyMissing: string;
|
|
51
51
|
transcribeKeyFailed: string;
|
|
52
|
+
stashTitle: string;
|
|
53
|
+
stashCopy: string;
|
|
54
|
+
stashSave: string;
|
|
55
|
+
stashChipHint: string;
|
|
52
56
|
transcribeModelLabel: string;
|
|
53
57
|
transcribeModelHint: string;
|
|
54
58
|
reorderHint: string;
|
|
@@ -190,6 +194,10 @@ export declare const en: {
|
|
|
190
194
|
transcribeKeyConfigured: string;
|
|
191
195
|
transcribeKeyMissing: string;
|
|
192
196
|
transcribeKeyFailed: string;
|
|
197
|
+
stashTitle: string;
|
|
198
|
+
stashCopy: string;
|
|
199
|
+
stashSave: string;
|
|
200
|
+
stashChipHint: string;
|
|
193
201
|
transcribeModelLabel: string;
|
|
194
202
|
transcribeModelHint: string;
|
|
195
203
|
reorderHint: string;
|
|
@@ -49,6 +49,10 @@ export const zh = {
|
|
|
49
49
|
transcribeKeyConfigured: '已配置',
|
|
50
50
|
transcribeKeyMissing: '未配置',
|
|
51
51
|
transcribeKeyFailed: '保存失败',
|
|
52
|
+
stashTitle: '临存',
|
|
53
|
+
stashCopy: '复制',
|
|
54
|
+
stashSave: '临存',
|
|
55
|
+
stashChipHint: '点击把第 {n} 条插入输入框',
|
|
52
56
|
transcribeModelLabel: '模型',
|
|
53
57
|
transcribeModelHint: '语音转文本模型名',
|
|
54
58
|
reorderHint: '拖动排序',
|
|
@@ -188,6 +192,10 @@ export const en = {
|
|
|
188
192
|
transcribeKeyConfigured: 'Configured',
|
|
189
193
|
transcribeKeyMissing: 'Not configured',
|
|
190
194
|
transcribeKeyFailed: 'Save failed',
|
|
195
|
+
stashTitle: 'Stash',
|
|
196
|
+
stashCopy: 'Copy',
|
|
197
|
+
stashSave: 'Stash',
|
|
198
|
+
stashChipHint: 'Insert snippet {n} into the composer',
|
|
191
199
|
transcribeModelLabel: 'Model',
|
|
192
200
|
transcribeModelHint: 'Speech-to-text model name',
|
|
193
201
|
reorderHint: 'Drag to reorder',
|
package/lib/types/client/mic.js
CHANGED
|
@@ -152,6 +152,6 @@ export function TokenQuotaMic({ t, transcribe, subscribeTranscribe, getTranscrib
|
|
|
152
152
|
if (!settings.enabled)
|
|
153
153
|
return null;
|
|
154
154
|
const state = transcribing ? 'busy' : recording ? 'rec' : 'idle';
|
|
155
|
-
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 })] }));
|
|
155
|
+
return (_jsxs("div", { className: css.micWrap, "data-token-quota-mic": "", 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 })] }));
|
|
156
156
|
}
|
|
157
157
|
//# sourceMappingURL=mic.js.map
|