@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/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.44";
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 that looks like DeepSeek,
733
- * together with its own credential reference and endpoint base. This is what
734
- * decides which providers are queried: a provider qualifies when its base URL
735
- * points at DeepSeek's API host or its credential reference is named with the
736
- * `DEEPSEEK` prefix, and it must declare an `apiKeyEnv`.
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
- if (!(baseURL !== void 0 && baseURL.includes(DEEPSEEK_BALANCE_HOST) || keyEnv !== void 0 && keyEnv.startsWith("DEEPSEEK") || entry.provider.startsWith("deepseek")) || keyEnv === void 0) continue;
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: "CNY",
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(`${base}/user/balance`, {
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 first = Array.isArray(data?.balance_infos) ? data.balance_infos[0] : void 0;
819
- const num = (value) => {
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: typeof first?.currency === "string" && first.currency !== "" ? first.currency : "CNY",
826
- total: num(first?.total_balance),
827
- granted: num(first?.granted_balance),
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 ?? "CNY",
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 onFocusIn = (event) => {
436
- const target = event.target;
437
- const isInput = target !== null && (target.matches('input, textarea, [contenteditable="true"]')
438
- || target.closest('input, textarea, [contenteditable="true"]') !== null);
439
- setInputActive(isInput);
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
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: panelRef, className: `${css.panel}${panelDimClass !== '' ? ` ${panelDimClass}` : ''}`, style: panelPos !== null ? { left: panelPos.x, top: panelPos.y, right: 'auto' } : undefined, onPointerEnter: () => { setHovered(true); }, onPointerLeave: () => { setHovered(false); }, children: [_jsxs("div", { className: css.header, onPointerDown: (event) => { beginDrag(event, panelRef.current, applyPanelPos); }, children: [_jsx("div", { className: css.headerText, children: _jsxs("div", { className: css.title, children: [t('title'), __TOKEN_QUOTA_VERSION__ !== undefined && __TOKEN_QUOTA_VERSION__ !== '' && (upgrade !== null
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',
@@ -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