@happyvertical/smrt-svelte 0.30.0 → 0.31.1

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.
Files changed (47) hide show
  1. package/AGENTS.md +4 -1
  2. package/dist/Provider.svelte +12 -2
  3. package/dist/Provider.svelte.d.ts.map +1 -1
  4. package/dist/components/admin/AgentSettingsShell.svelte +72 -4
  5. package/dist/components/admin/AgentSettingsShell.svelte.d.ts.map +1 -1
  6. package/dist/components/admin/__tests__/AgentSettingsShell.tablist.test.js +93 -0
  7. package/dist/components/forms/DateRangeInput.svelte +29 -5
  8. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  9. package/dist/components/forms/DateTimeInput.svelte +34 -7
  10. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  11. package/dist/components/forms/FileUpload.svelte +3 -1
  12. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/Form.svelte +72 -36
  14. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  15. package/dist/components/forms/FormMicButton.svelte +14 -11
  16. package/dist/components/forms/FormMicButton.svelte.d.ts.map +1 -1
  17. package/dist/components/forms/PhoneInput.svelte +29 -5
  18. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  19. package/dist/components/forms/TextInput.svelte +35 -7
  20. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/TextareaInput.svelte +29 -5
  22. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +37 -0
  24. package/dist/components/forms/__tests__/Form.stt-error.test.js +57 -0
  25. package/dist/components/forms/__tests__/FormMicButton.test.js +7 -0
  26. package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +74 -0
  27. package/dist/hooks/__tests__/stt-consumer.fixture.svelte +25 -0
  28. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +15 -0
  29. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +1 -0
  30. package/dist/hooks/__tests__/stt-ownership-harness.svelte +42 -0
  31. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +20 -0
  32. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +1 -0
  33. package/dist/hooks/__tests__/useSTT-ownership.test.js +102 -0
  34. package/dist/hooks/useSTT.svelte.d.ts.map +1 -1
  35. package/dist/hooks/useSTT.svelte.js +20 -6
  36. package/dist/hooks/useTTS.svelte.d.ts.map +1 -1
  37. package/dist/hooks/useTTS.svelte.js +20 -6
  38. package/dist/i18n/server.d.ts +5 -5
  39. package/dist/i18n/server.js +5 -5
  40. package/dist/internal/logger.d.ts +13 -0
  41. package/dist/internal/logger.d.ts.map +1 -0
  42. package/dist/internal/logger.js +12 -0
  43. package/dist/state/__tests__/app-state-ai-lifecycle.test.js +240 -0
  44. package/dist/state/app-state.svelte.d.ts +40 -8
  45. package/dist/state/app-state.svelte.d.ts.map +1 -1
  46. package/dist/state/app-state.svelte.js +224 -54
  47. package/package.json +6 -5
@@ -7,6 +7,34 @@
7
7
  import { canEnableSmrtMode, detectCapabilities, getLLM, getSTT, getTTS, } from '../browser-ai/index.js';
8
8
  import { createInitialState, } from './app-state.js';
9
9
  import { getCachedLLM, getCachedSTT, getCachedTTS, setCachedLLM, setCachedSTT, setCachedTTS, updateLLMCacheState, updateSTTCacheState, updateTTSCacheState, } from './warm-clients.js';
10
+ /**
11
+ * Module-scope record of the single active listener teardown for each warm
12
+ * adapter. Warm adapters live in the module-level cache and survive Provider
13
+ * remounts, so their event-listener `Set`s would otherwise accumulate one
14
+ * closure set per manager that ever subscribed (R1 leak). Keying the teardown
15
+ * by **adapter identity at module scope** (not a per-manager `WeakSet`)
16
+ * guarantees at most one live subscription per shared adapter: before a new
17
+ * manager subscribes, the previous owner's listeners are removed.
18
+ */
19
+ const sttAdapterTeardowns = new WeakMap();
20
+ const ttsAdapterTeardowns = new WeakMap();
21
+ /**
22
+ * Structural equality for two AI configs (R3). Used to no-op `setAIConfig` when
23
+ * an inline `ai={{…}}` literal re-renders with the same effective settings but a
24
+ * new object identity. A shallow JSON compare is sufficient: AIConfig is a flat
25
+ * tree of plain primitives/objects with no functions or class instances.
26
+ */
27
+ function configsEqual(a, b) {
28
+ if (a === b)
29
+ return true;
30
+ try {
31
+ return JSON.stringify(a) === JSON.stringify(b);
32
+ }
33
+ catch {
34
+ // Non-serializable (shouldn't happen for AIConfig) — treat as different.
35
+ return false;
36
+ }
37
+ }
10
38
  /**
11
39
  * Reactive app state manager for Svelte 5
12
40
  */
@@ -19,13 +47,23 @@ export class SmrtAppStateManager {
19
47
  _aiConfig = null;
20
48
  _preloadScheduled = false;
21
49
  _idleCallbackId = null;
50
+ // Guards a single executePreload() pass from running concurrently. The
51
+ // Provider's `ai` $effect can re-fire setAIConfig on every parent render
52
+ // (inline `ai={{…}}` literal => new identity), and an `idle`/`eager` strategy
53
+ // would otherwise launch overlapping preloads that interleave aiLoading
54
+ // writes and double-download models (R3).
55
+ _preloadInFlight = false;
22
56
  // Socket management
23
57
  _socket = null;
24
58
  _socketConfig = null;
25
59
  _reconnectTimeout = null;
26
- // Track adapters we've already subscribed to (prevents duplicate listeners)
27
- _subscribedSTTAdapters = new WeakSet();
28
- _subscribedTTSAdapters = new WeakSet();
60
+ // Adapters this manager currently owns a subscription on, mapped to the
61
+ // teardown that removes its listeners. Used both for dedup (skip re-subscribe
62
+ // when this manager already owns the adapter) and to unsubscribe on dispose()
63
+ // so a destroyed Provider stops pinning its `_state` proxy via the adapter's
64
+ // module-surviving listener `Set`s (R1).
65
+ _sttSubscriptions = new Map();
66
+ _ttsSubscriptions = new Map();
29
67
  constructor(options = {}) {
30
68
  this.options = options;
31
69
  this._aiConfig = options.ai ?? null;
@@ -98,9 +136,22 @@ export class SmrtAppStateManager {
98
136
  }
99
137
  // === AI Preloading Methods ===
100
138
  /**
101
- * Set or update AI configuration
139
+ * Set or update AI configuration.
140
+ *
141
+ * No-ops when the incoming config is deep-equal to the current one. The
142
+ * Provider's `ai` $effect depends on the prop's identity, and the documented
143
+ * usage passes an inline `ai={{…}}` object literal — a fresh identity on every
144
+ * parent render. Without this guard each render would cancel the idle
145
+ * callback, reset `_preloadScheduled`, and re-schedule (and, for `eager`,
146
+ * re-launch a full preload), thrashing the scheduler and double-downloading
147
+ * models (R3).
102
148
  */
103
149
  setAIConfig(config) {
150
+ if (this._aiConfig && configsEqual(this._aiConfig, config)) {
151
+ // Same effective config (different object identity) — nothing to do.
152
+ this._aiConfig = config;
153
+ return;
154
+ }
104
155
  this._aiConfig = config;
105
156
  // Cancel any pending preload scheduling so we can re-schedule with new config
106
157
  if (this._idleCallbackId !== null &&
@@ -154,9 +205,30 @@ export class SmrtAppStateManager {
154
205
  this.executePreload();
155
206
  }
156
207
  /**
157
- * Execute the preloading of configured adapters
208
+ * Execute the preloading of configured adapters.
209
+ *
210
+ * Re-entrancy guarded: an `idle`/`eager` strategy can be re-scheduled while a
211
+ * pass is still awaiting model downloads. Without the guard the overlapping
212
+ * passes interleave their aiLoading writes and double-download models (R3).
158
213
  */
159
214
  async executePreload() {
215
+ if (!this._aiConfig)
216
+ return;
217
+ if (this._preloadInFlight)
218
+ return;
219
+ this._preloadInFlight = true;
220
+ try {
221
+ await this.runPreload();
222
+ }
223
+ finally {
224
+ this._preloadInFlight = false;
225
+ }
226
+ }
227
+ /**
228
+ * The actual preload pass. Always invoked behind the `_preloadInFlight` guard
229
+ * in {@link executePreload}.
230
+ */
231
+ async runPreload() {
160
232
  if (!this._aiConfig)
161
233
  return;
162
234
  const adaptersToLoad = [];
@@ -174,7 +246,10 @@ export class SmrtAppStateManager {
174
246
  adaptersToLoad.push(`llm:${modelKey}`);
175
247
  }
176
248
  if (adaptersToLoad.length === 0) {
177
- this.updateLoadingState({ phase: 'idle' });
249
+ // Reset loaded/failed too (R7) the main path clears them via the
250
+ // 'checking' update below, so the early-return must not leave stale
251
+ // entries from a prior pass behind.
252
+ this.updateLoadingState({ phase: 'idle', loaded: [], failed: [] });
178
253
  return;
179
254
  }
180
255
  this.updateLoadingState({
@@ -482,41 +557,61 @@ export class SmrtAppStateManager {
482
557
  }
483
558
  }
484
559
  /**
485
- * Subscribe to STT adapter events
486
- * Only subscribes once per adapter instance to prevent duplicate listeners
560
+ * Subscribe to STT adapter events.
561
+ *
562
+ * Captures every unsubscribe handle the adapter returns and stores a single
563
+ * teardown both per-manager (called on dispose()) and at module scope keyed
564
+ * by adapter identity. Because warm adapters are shared singletons, any
565
+ * previous owner's listeners are torn down first — guaranteeing exactly one
566
+ * live listener set per adapter, with the latest manager owning it (R1).
487
567
  */
488
568
  subscribeToSTTEvents(adapter) {
489
- // Prevent duplicate subscriptions
490
- if (this._subscribedSTTAdapters.has(adapter)) {
569
+ // Dedup: this manager already owns the adapter's subscription.
570
+ if (this._sttSubscriptions.has(adapter)) {
491
571
  return;
492
572
  }
493
- this._subscribedSTTAdapters.add(adapter);
494
- adapter.onResult((result) => {
495
- if (result.isFinal) {
496
- // Accumulate final results (for continuous mode where multiple phrases are spoken)
497
- const existing = this._state.ai.stt.lastResult;
498
- if (existing) {
499
- this._state.ai.stt.lastResult = `${existing} ${result.text}`;
573
+ // Evict any prior owner (e.g. a destroyed Provider that failed to dispose)
574
+ // so the adapter never fires more than one manager's listeners.
575
+ sttAdapterTeardowns.get(adapter)?.();
576
+ const unsubs = [
577
+ adapter.onResult((result) => {
578
+ if (result.isFinal) {
579
+ // Accumulate final results (continuous mode emits multiple phrases)
580
+ const existing = this._state.ai.stt.lastResult;
581
+ if (existing) {
582
+ this._state.ai.stt.lastResult = `${existing} ${result.text}`;
583
+ }
584
+ else {
585
+ this._state.ai.stt.lastResult = result.text;
586
+ }
587
+ this._state.ai.stt.interimResult = '';
500
588
  }
501
589
  else {
502
- this._state.ai.stt.lastResult = result.text;
590
+ // Interim result - update live
591
+ this._state.ai.stt.interimResult = result.text;
503
592
  }
504
- this._state.ai.stt.interimResult = '';
593
+ }),
594
+ adapter.onStart(() => {
595
+ this._state.ai.stt.isListening = true;
596
+ }),
597
+ adapter.onEnd(() => {
598
+ this._state.ai.stt.isListening = false;
599
+ }),
600
+ adapter.onError((error) => {
601
+ this._state.ai.stt.error = error;
602
+ }),
603
+ ];
604
+ const teardown = () => {
605
+ for (const unsub of unsubs) {
606
+ unsub();
505
607
  }
506
- else {
507
- // Interim result - update live
508
- this._state.ai.stt.interimResult = result.text;
608
+ this._sttSubscriptions.delete(adapter);
609
+ if (sttAdapterTeardowns.get(adapter) === teardown) {
610
+ sttAdapterTeardowns.delete(adapter);
509
611
  }
510
- });
511
- adapter.onStart(() => {
512
- this._state.ai.stt.isListening = true;
513
- });
514
- adapter.onEnd(() => {
515
- this._state.ai.stt.isListening = false;
516
- });
517
- adapter.onError((error) => {
518
- this._state.ai.stt.error = error;
519
- });
612
+ };
613
+ this._sttSubscriptions.set(adapter, teardown);
614
+ sttAdapterTeardowns.set(adapter, teardown);
520
615
  }
521
616
  /**
522
617
  * Start STT listening
@@ -585,27 +680,44 @@ export class SmrtAppStateManager {
585
680
  }
586
681
  }
587
682
  /**
588
- * Subscribe to TTS adapter events
589
- * Only subscribes once per adapter instance to prevent duplicate listeners
683
+ * Subscribe to TTS adapter events.
684
+ *
685
+ * Mirrors {@link subscribeToSTTEvents}: captures unsubscribe handles, dedups
686
+ * per-manager, and evicts any prior owner so a shared warm adapter never
687
+ * pins more than one manager's `_state` proxy (R1).
590
688
  */
591
689
  subscribeToTTSEvents(adapter) {
592
- // Prevent duplicate subscriptions
593
- if (this._subscribedTTSAdapters.has(adapter)) {
690
+ // Dedup: this manager already owns the adapter's subscription.
691
+ if (this._ttsSubscriptions.has(adapter)) {
594
692
  return;
595
693
  }
596
- this._subscribedTTSAdapters.add(adapter);
597
- adapter.onStart(() => {
598
- this._state.ai.tts.isSpeaking = true;
599
- this._state.ai.tts.isPaused = false;
600
- });
601
- adapter.onEnd(() => {
602
- this._state.ai.tts.isSpeaking = false;
603
- this._state.ai.tts.isPaused = false;
604
- });
605
- adapter.onError((error) => {
606
- this._state.ai.tts.error = error;
607
- this._state.ai.tts.isSpeaking = false;
608
- });
694
+ // Evict any prior owner so the adapter fires only this manager's listeners.
695
+ ttsAdapterTeardowns.get(adapter)?.();
696
+ const unsubs = [
697
+ adapter.onStart(() => {
698
+ this._state.ai.tts.isSpeaking = true;
699
+ this._state.ai.tts.isPaused = false;
700
+ }),
701
+ adapter.onEnd(() => {
702
+ this._state.ai.tts.isSpeaking = false;
703
+ this._state.ai.tts.isPaused = false;
704
+ }),
705
+ adapter.onError((error) => {
706
+ this._state.ai.tts.error = error;
707
+ this._state.ai.tts.isSpeaking = false;
708
+ }),
709
+ ];
710
+ const teardown = () => {
711
+ for (const unsub of unsubs) {
712
+ unsub();
713
+ }
714
+ this._ttsSubscriptions.delete(adapter);
715
+ if (ttsAdapterTeardowns.get(adapter) === teardown) {
716
+ ttsAdapterTeardowns.delete(adapter);
717
+ }
718
+ };
719
+ this._ttsSubscriptions.set(adapter, teardown);
720
+ ttsAdapterTeardowns.set(adapter, teardown);
609
721
  }
610
722
  /**
611
723
  * Speak text using TTS
@@ -726,12 +838,44 @@ export class SmrtAppStateManager {
726
838
  * Unload LLM model to free memory
727
839
  */
728
840
  async unloadLLM() {
729
- await this._state.ai.llm.adapter?.unloadModel();
841
+ const adapter = this._state.ai.llm.adapter;
842
+ // Capture the model id BEFORE unloadModel(): a real WebLLM adapter clears
843
+ // `currentModel` on unload, so reading it afterward would key the cache
844
+ // downgrade off `undefined` and leave the actual `'ready'` entry stale (R2).
845
+ const unloadedModel = adapter?.currentModel ?? undefined;
846
+ await adapter?.unloadModel();
847
+ // Downgrade (or remove) the warm-cache entry for the unloaded model so a
848
+ // later initializeLLM() re-runs ensureInitialized() — re-downloading and
849
+ // reporting progress — instead of cache-hitting a `'ready'` entry whose
850
+ // model is now gone (R2). `unloadModel()` keeps the adapter instance
851
+ // reusable, so downgrade to `'uninitialized'` rather than dispose it.
852
+ if (adapter) {
853
+ updateLLMCacheState(adapter.type, unloadedModel, {
854
+ initState: 'uninitialized',
855
+ downloadProgress: null,
856
+ error: null,
857
+ });
858
+ }
730
859
  this._state.ai.llm.adapter = null;
731
860
  this._state.ai.llm.initState = 'uninitialized';
732
861
  this._state.ai.llm.currentModel = null;
733
862
  }
734
863
  // === Cleanup ===
864
+ /**
865
+ * Unsubscribe every adapter-event listener this manager owns (R1). Called on
866
+ * dispose() so a destroyed Provider stops being pinned by the shared warm
867
+ * adapters' module-surviving listener `Set`s.
868
+ */
869
+ unsubscribeAllAdapterEvents() {
870
+ for (const teardown of [...this._sttSubscriptions.values()]) {
871
+ teardown();
872
+ }
873
+ for (const teardown of [...this._ttsSubscriptions.values()]) {
874
+ teardown();
875
+ }
876
+ this._sttSubscriptions.clear();
877
+ this._ttsSubscriptions.clear();
878
+ }
735
879
  /**
736
880
  * Dispose of all resources
737
881
  */
@@ -742,12 +886,38 @@ export class SmrtAppStateManager {
742
886
  cancelIdleCallback(this._idleCallbackId);
743
887
  this._idleCallbackId = null;
744
888
  }
889
+ // Stop any in-flight preload so it can't write to a torn-down state (R3).
890
+ this._preloadInFlight = false;
745
891
  // Disconnect socket
746
892
  this.disconnectSocket();
747
- // Dispose AI adapters (but don't clear the cache - they survive navigation)
748
- await this._state.ai.stt.adapter?.dispose();
749
- await this._state.ai.tts.adapter?.dispose();
750
- await this._state.ai.llm.adapter?.dispose();
893
+ // Remove this manager's adapter-event listeners (R1).
894
+ this.unsubscribeAllAdapterEvents();
895
+ // Adapter lifecycle is owned by the warm cache (it survives navigation by
896
+ // design). For adapters this manager holds that the cache no longer tracks
897
+ // — e.g. an instance orphaned by a mid-session type switch — dispose them
898
+ // directly so they don't leak. Adapters still backed by a warm-cache entry
899
+ // are left intact and genuinely `'ready'`; previously they were disposed
900
+ // here yet left cached as `'ready'`, so the next init cache-hit restored a
901
+ // dead engine with no download progress (R2). Full teardown remains
902
+ // available via `clearAllCaches()`.
903
+ //
904
+ // NOTE: state adapters are `$state` proxies, so identity (`!==`) comparison
905
+ // against the raw cached instance is unreliable (Svelte proxy-equality
906
+ // gotcha). Gate on whether the cache *has* a live entry for the adapter's
907
+ // type/model instead of comparing instances.
908
+ const sttAdapter = this._state.ai.stt.adapter;
909
+ if (sttAdapter && !getCachedSTT(sttAdapter.type)) {
910
+ await sttAdapter.dispose?.();
911
+ }
912
+ const ttsAdapter = this._state.ai.tts.adapter;
913
+ if (ttsAdapter && !getCachedTTS(ttsAdapter.type)) {
914
+ await ttsAdapter.dispose?.();
915
+ }
916
+ const llmAdapter = this._state.ai.llm.adapter;
917
+ if (llmAdapter &&
918
+ !getCachedLLM(llmAdapter.type, llmAdapter.currentModel ?? undefined)) {
919
+ await llmAdapter.dispose?.();
920
+ }
751
921
  this._state = createInitialState();
752
922
  }
753
923
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-svelte",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -81,11 +81,12 @@
81
81
  "access": "public"
82
82
  },
83
83
  "dependencies": {
84
+ "@happyvertical/logger": "^0.74.7",
84
85
  "esm-env": "^1.2.2",
85
- "@happyvertical/smrt-languages": "0.30.0",
86
- "@happyvertical/smrt-types": "0.30.0",
87
- "@happyvertical/smrt-ui": "0.30.0",
88
- "@happyvertical/smrt-agents": "0.30.0"
86
+ "@happyvertical/smrt-agents": "0.31.1",
87
+ "@happyvertical/smrt-types": "0.31.1",
88
+ "@happyvertical/smrt-languages": "0.31.1",
89
+ "@happyvertical/smrt-ui": "0.31.1"
89
90
  },
90
91
  "peerDependencies": {
91
92
  "@huggingface/transformers": ">=3.0.0",