@happyvertical/smrt-svelte 0.30.0 → 0.31.0

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
@@ -33,6 +33,10 @@ import { getAppStateContext } from '../state/context.js';
33
33
  */
34
34
  export function useSTT(options = {}) {
35
35
  const app = getAppStateContext();
36
+ // The STT manager is a Provider-level singleton shared by every useSTT()
37
+ // consumer. Track whether *this* hook started the current listening session
38
+ // so unmounting one <VoiceInput> doesn't abort another's recording (R4).
39
+ let startedByThisHook = false;
36
40
  // Auto-initialize if requested
37
41
  if (options.autoInit) {
38
42
  app.initializeSTT().catch(() => {
@@ -40,22 +44,32 @@ export function useSTT(options = {}) {
40
44
  });
41
45
  }
42
46
  const start = async (sttOptions) => {
43
- await app.startListening({
44
- ...options.defaultOptions,
45
- ...sttOptions,
46
- });
47
+ startedByThisHook = true;
48
+ try {
49
+ await app.startListening({
50
+ ...options.defaultOptions,
51
+ ...sttOptions,
52
+ });
53
+ }
54
+ catch (err) {
55
+ startedByThisHook = false;
56
+ throw err;
57
+ }
47
58
  };
48
59
  const stop = async () => {
60
+ startedByThisHook = false;
49
61
  await app.stopListening();
50
62
  };
51
63
  const initialize = async (initOptions) => {
52
64
  await app.initializeSTT(initOptions);
53
65
  };
54
- // Cleanup on destroy
66
+ // Cleanup on destroy: only stop the shared singleton if this hook owns the
67
+ // in-progress session (R4) — never stop a recording another hook started.
55
68
  onDestroy(() => {
56
- if (app.state.ai.stt.isListening) {
69
+ if (startedByThisHook && app.state.ai.stt.isListening) {
57
70
  app.stopListening().catch(() => { });
58
71
  }
72
+ startedByThisHook = false;
59
73
  });
60
74
  return {
61
75
  start,
@@ -1 +1 @@
1
- {"version":3,"file":"useTTS.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useTTS.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGnE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,iBAAiB;IACjB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,oBAAoB;IACpB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,qBAAqB;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,sBAAsB;IACtB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,gEAAgE;IAChE,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,2BAA2B;IAC3B,SAAS,EAAE,MAAM,QAAQ,EAAE,CAAC;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAsEhE"}
1
+ {"version":3,"file":"useTTS.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useTTS.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGnE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,iBAAiB;IACjB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,oBAAoB;IACpB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,qBAAqB;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,sBAAsB;IACtB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,gEAAgE;IAChE,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,2BAA2B;IAC3B,SAAS,EAAE,MAAM,QAAQ,EAAE,CAAC;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAoFhE"}
@@ -31,6 +31,10 @@ import { getAppStateContext } from '../state/context.js';
31
31
  */
32
32
  export function useTTS(options = {}) {
33
33
  const app = getAppStateContext();
34
+ // The TTS manager is a Provider-level singleton shared by every useTTS()
35
+ // consumer. Track whether *this* hook started the current speech so
36
+ // unmounting one component doesn't cut off another's playback (R4).
37
+ let startedByThisHook = false;
34
38
  // Auto-initialize if requested
35
39
  if (options.autoInit) {
36
40
  app.initializeTTS().catch(() => {
@@ -38,12 +42,20 @@ export function useTTS(options = {}) {
38
42
  });
39
43
  }
40
44
  const speak = async (text, ttsOptions) => {
41
- await app.speak(text, {
42
- ...options.defaultOptions,
43
- ...ttsOptions,
44
- });
45
+ startedByThisHook = true;
46
+ try {
47
+ await app.speak(text, {
48
+ ...options.defaultOptions,
49
+ ...ttsOptions,
50
+ });
51
+ }
52
+ finally {
53
+ // Speech has finished (or errored) — this hook no longer owns playback.
54
+ startedByThisHook = false;
55
+ }
45
56
  };
46
57
  const stop = () => {
58
+ startedByThisHook = false;
47
59
  app.stopSpeaking();
48
60
  };
49
61
  const pause = () => {
@@ -58,11 +70,13 @@ export function useTTS(options = {}) {
58
70
  const getVoices = () => {
59
71
  return app.getTTSVoices();
60
72
  };
61
- // Cleanup on destroy
73
+ // Cleanup on destroy: only stop the shared singleton if this hook owns the
74
+ // in-progress speech (R4) — never cut off speech another hook started.
62
75
  onDestroy(() => {
63
- if (app.state.ai.tts.isSpeaking) {
76
+ if (startedByThisHook && app.state.ai.tts.isSpeaking) {
64
77
  app.stopSpeaking();
65
78
  }
79
+ startedByThisHook = false;
66
80
  });
67
81
  return {
68
82
  speak,
@@ -9,11 +9,11 @@
9
9
  * A consumer's load function calls `buildI18nSnapshot` for the request locale
10
10
  * and passes the result to `<Provider i18n={snapshot}>`.
11
11
  *
12
- * Requires `@happyvertical/smrt-languages` to be installed. It is an *optional*
13
- * peer of smrt-svelte by design the client i18n layer (`/i18n`) is
14
- * languages-free, so apps that never use this server bridge are not forced to
15
- * pull the languages server dependency tree (smrt-core/sql/ai/jobs). Importing
16
- * this subpath without languages installed is a clear module-not-found error.
12
+ * `@happyvertical/smrt-languages` is a hard `dependency` of smrt-svelte (see
13
+ * `package.json`), so it is always installed. The client i18n layer (`/i18n`)
14
+ * stays languages-free regardless: it never imports the languages root, so the
15
+ * server tree (smrt-core/sql/ai/jobs) is tree-shaken out of the browser bundle
16
+ * only this Node-only `/i18n/server` subpath pulls it in.
17
17
  */
18
18
  import { type ResolveLanguageStringOptions } from '@happyvertical/smrt-languages';
19
19
  import { type I18nSnapshot } from '@happyvertical/smrt-ui/i18n';
@@ -9,11 +9,11 @@
9
9
  * A consumer's load function calls `buildI18nSnapshot` for the request locale
10
10
  * and passes the result to `<Provider i18n={snapshot}>`.
11
11
  *
12
- * Requires `@happyvertical/smrt-languages` to be installed. It is an *optional*
13
- * peer of smrt-svelte by design the client i18n layer (`/i18n`) is
14
- * languages-free, so apps that never use this server bridge are not forced to
15
- * pull the languages server dependency tree (smrt-core/sql/ai/jobs). Importing
16
- * this subpath without languages installed is a clear module-not-found error.
12
+ * `@happyvertical/smrt-languages` is a hard `dependency` of smrt-svelte (see
13
+ * `package.json`), so it is always installed. The client i18n layer (`/i18n`)
14
+ * stays languages-free regardless: it never imports the languages root, so the
15
+ * server tree (smrt-core/sql/ai/jobs) is tree-shaken out of the browser bundle
16
+ * only this Node-only `/i18n/server` subpath pulls it in.
17
17
  */
18
18
  import { defineLanguageString, LanguageRegistry, resolveLanguageString, } from '@happyvertical/smrt-languages';
19
19
  import { getRegisteredDefaults, } from '@happyvertical/smrt-ui/i18n';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared package logger for `@happyvertical/smrt-svelte`.
3
+ *
4
+ * Browser-safe: the `@happyvertical/logger` console adapter pulls no Node
5
+ * built-ins and its `@sentry/node` integration is an optional peer that is
6
+ * never imported here. Centralising the instance keeps voice/AI error reporting
7
+ * consistent across the form components (mic permission denials, STT init
8
+ * failures) instead of swallowing them in empty `catch` blocks or scattering
9
+ * raw `console.*` calls.
10
+ */
11
+ import { type Logger } from '@happyvertical/logger';
12
+ export declare const logger: Logger;
13
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/internal/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAElE,eAAO,MAAM,MAAM,EAAE,MAAwC,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared package logger for `@happyvertical/smrt-svelte`.
3
+ *
4
+ * Browser-safe: the `@happyvertical/logger` console adapter pulls no Node
5
+ * built-ins and its `@sentry/node` integration is an optional peer that is
6
+ * never imported here. Centralising the instance keeps voice/AI error reporting
7
+ * consistent across the form components (mic permission denials, STT init
8
+ * failures) instead of swallowing them in empty `catch` blocks or scattering
9
+ * raw `console.*` calls.
10
+ */
11
+ import { createLogger } from '@happyvertical/logger';
12
+ export const logger = createLogger({ level: 'warn' });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Regression tests for the browser-AI warm-cache lifecycle (T2 #1403):
3
+ *
4
+ * - R1 (listener leak): warm-cached adapters survive Provider remounts. Each
5
+ * manager that subscribes must capture + later remove its listeners on
6
+ * dispose(), and a shared adapter must never accumulate more than one live
7
+ * listener set across managers.
8
+ * - R2 (state lies): dispose() must not leave a disposed adapter cached as
9
+ * 'ready'; unloadLLM() must downgrade the warm-cache entry so the next init
10
+ * re-runs (reports progress) instead of cache-hitting a model-less adapter.
11
+ * - R3 (scheduler thrash): setAIConfig() must no-op on a deep-equal config
12
+ * (inline `ai={{…}}` literal => new identity every render), and concurrent
13
+ * executePreload() passes must be guarded.
14
+ * - R7 (nit): the empty-config early-return must reset loaded/failed too.
15
+ */
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+ // --- Fakes -----------------------------------------------------------------
18
+ /**
19
+ * A fake STT/TTS adapter whose event-subscription methods return real
20
+ * unsubscribe handles backed by Sets — so a test can read the live listener
21
+ * count and prove subscriptions are torn down (R1).
22
+ */
23
+ function makeFakeSTT(type = 'whisper-cpp') {
24
+ const sets = {
25
+ result: new Set(),
26
+ start: new Set(),
27
+ end: new Set(),
28
+ error: new Set(),
29
+ };
30
+ const sub = (set, cb) => {
31
+ set.add(cb);
32
+ return () => {
33
+ set.delete(cb);
34
+ };
35
+ };
36
+ return {
37
+ type,
38
+ dispose: vi.fn(async () => { }),
39
+ ensureInitialized: vi.fn(async () => { }),
40
+ start: vi.fn(async () => { }),
41
+ stop: vi.fn(async () => { }),
42
+ abort: vi.fn(),
43
+ isListening: () => false,
44
+ onResult: (cb) => sub(sets.result, cb),
45
+ onStart: (cb) => sub(sets.start, cb),
46
+ onEnd: (cb) => sub(sets.end, cb),
47
+ onError: (cb) => sub(sets.error, cb),
48
+ /** total live listeners across all event sets */
49
+ listenerCount: () => sets.result.size + sets.start.size + sets.end.size + sets.error.size,
50
+ };
51
+ }
52
+ function makeFakeTTS(type = 'browser-synthesis') {
53
+ const sets = {
54
+ start: new Set(),
55
+ end: new Set(),
56
+ error: new Set(),
57
+ };
58
+ const sub = (set, cb) => {
59
+ set.add(cb);
60
+ return () => {
61
+ set.delete(cb);
62
+ };
63
+ };
64
+ return {
65
+ type,
66
+ dispose: vi.fn(async () => { }),
67
+ ensureInitialized: vi.fn(async () => { }),
68
+ speak: vi.fn(async () => { }),
69
+ stop: vi.fn(),
70
+ pause: vi.fn(),
71
+ resume: vi.fn(),
72
+ getVoices: () => [],
73
+ onStart: (cb) => sub(sets.start, cb),
74
+ onEnd: (cb) => sub(sets.end, cb),
75
+ onError: (cb) => sub(sets.error, cb),
76
+ listenerCount: () => sets.start.size + sets.end.size + sets.error.size,
77
+ };
78
+ }
79
+ function makeFakeLLM(type = 'webllm', model = 'tiny-model') {
80
+ return {
81
+ type,
82
+ currentModel: model,
83
+ dispose: vi.fn(async () => { }),
84
+ ensureInitialized: vi.fn(async () => { }),
85
+ unloadModel: vi.fn(async () => { }),
86
+ message: vi.fn(async () => 'ok'),
87
+ };
88
+ }
89
+ // --- Module mocks ----------------------------------------------------------
90
+ // The manager imports the AI factories + capability helpers from the browser-ai
91
+ // barrel. Mock only those; keep everything else (types) real via importOriginal.
92
+ const fakeSTT = makeFakeSTT();
93
+ const fakeTTS = makeFakeTTS();
94
+ const fakeLLM = makeFakeLLM();
95
+ vi.mock('../../browser-ai/index.js', async (importOriginal) => {
96
+ const actual = await importOriginal();
97
+ return {
98
+ ...actual,
99
+ detectCapabilities: () => ({}),
100
+ canEnableSmrtMode: () => false,
101
+ getSTT: vi.fn(async () => fakeSTT),
102
+ getTTS: vi.fn(async () => fakeTTS),
103
+ getLLM: vi.fn(async () => fakeLLM),
104
+ };
105
+ });
106
+ import { createAppState, } from '../app-state.svelte.js';
107
+ import { clearAllCaches, getCachedLLM, getCachedSTT } from '../warm-clients.js';
108
+ beforeEach(async () => {
109
+ await clearAllCaches();
110
+ });
111
+ afterEach(async () => {
112
+ await clearAllCaches();
113
+ vi.clearAllMocks();
114
+ });
115
+ describe('R1: warm-adapter listener lifecycle', () => {
116
+ it("removes a manager's listeners on dispose()", async () => {
117
+ const m = createAppState();
118
+ await m.initializeSTT({ type: 'whisper-cpp' });
119
+ expect(fakeSTT.listenerCount()).toBeGreaterThan(0);
120
+ await m.dispose();
121
+ expect(fakeSTT.listenerCount()).toBe(0);
122
+ });
123
+ it('does not accumulate listeners across remounts of the same cached adapter', async () => {
124
+ // Manager 1 subscribes then disposes.
125
+ const m1 = createAppState();
126
+ await m1.initializeSTT({ type: 'whisper-cpp' });
127
+ const afterFirst = fakeSTT.listenerCount();
128
+ await m1.dispose();
129
+ // Manager 2 cache-hits the SAME warm adapter, subscribes, disposes.
130
+ const m2 = createAppState();
131
+ await m2.initializeSTT({ type: 'whisper-cpp' });
132
+ // Exactly one manager's worth of listeners while live — not doubled.
133
+ expect(fakeSTT.listenerCount()).toBe(afterFirst);
134
+ await m2.dispose();
135
+ expect(fakeSTT.listenerCount()).toBe(0);
136
+ });
137
+ it('keeps at most one live listener set when two managers coexist', async () => {
138
+ const m1 = createAppState();
139
+ await m1.initializeSTT({ type: 'whisper-cpp' });
140
+ const single = fakeSTT.listenerCount();
141
+ // A second live manager taking over the shared adapter must evict the
142
+ // first's listeners rather than stack a second set.
143
+ const m2 = createAppState();
144
+ await m2.initializeSTT({ type: 'whisper-cpp' });
145
+ expect(fakeSTT.listenerCount()).toBe(single);
146
+ await m1.dispose();
147
+ await m2.dispose();
148
+ expect(fakeSTT.listenerCount()).toBe(0);
149
+ });
150
+ it('removes TTS listeners on dispose()', async () => {
151
+ const m = createAppState();
152
+ await m.initializeTTS({ type: 'browser-synthesis' });
153
+ expect(fakeTTS.listenerCount()).toBeGreaterThan(0);
154
+ await m.dispose();
155
+ expect(fakeTTS.listenerCount()).toBe(0);
156
+ });
157
+ it('subscribing the same adapter twice on one manager is idempotent', async () => {
158
+ const m = createAppState();
159
+ await m.initializeSTT({ type: 'whisper-cpp' });
160
+ const once = fakeSTT.listenerCount();
161
+ // Second init cache-hits and re-enters subscribeToSTTEvents.
162
+ await m.initializeSTT({ type: 'whisper-cpp' });
163
+ expect(fakeSTT.listenerCount()).toBe(once);
164
+ await m.dispose();
165
+ });
166
+ });
167
+ describe('R2: dispose()/unloadLLM() do not leave the cache lying', () => {
168
+ it('dispose() leaves the warm STT adapter intact (not disposed) and cached ready', async () => {
169
+ const m = createAppState();
170
+ await m.initializeSTT({ type: 'whisper-cpp' });
171
+ await m.dispose();
172
+ // Cached adapter survives navigation and was NOT disposed — so a later
173
+ // init won't restore a dead engine reported as 'ready'.
174
+ const cached = getCachedSTT('whisper-cpp');
175
+ expect(cached?.initState).toBe('ready');
176
+ expect(cached?.adapter).toBe(fakeSTT);
177
+ expect(fakeSTT.dispose).not.toHaveBeenCalled();
178
+ });
179
+ it('unloadLLM() downgrades the warm-cache entry to uninitialized', async () => {
180
+ const m = createAppState();
181
+ await m.initializeLLM('tiny-model', { type: 'webllm' });
182
+ expect(getCachedLLM('webllm', 'tiny-model')?.initState).toBe('ready');
183
+ await m.unloadLLM();
184
+ // Next initializeLLM must re-run (report progress) rather than cache-hit a
185
+ // model-less adapter reported as 'ready'.
186
+ expect(getCachedLLM('webllm', 'tiny-model')?.initState).toBe('uninitialized');
187
+ await m.dispose();
188
+ });
189
+ });
190
+ describe('R3: setAIConfig scheduler thrash + preload re-entrancy', () => {
191
+ it('no-ops on a deep-equal config of a different identity', async () => {
192
+ const m = createAppState();
193
+ const spy = vi.spyOn(m, 'schedulePreload');
194
+ m.setAIConfig({ preload: 'none', stt: { type: 'whisper-cpp' } });
195
+ const callsAfterFirst = spy.mock.calls.length;
196
+ expect(callsAfterFirst).toBeGreaterThan(0);
197
+ // Same shape, brand-new object literal (what an inline `ai={{…}}` produces).
198
+ m.setAIConfig({ preload: 'none', stt: { type: 'whisper-cpp' } });
199
+ expect(spy.mock.calls.length).toBe(callsAfterFirst); // no re-schedule
200
+ });
201
+ it('re-schedules when the config actually changes', async () => {
202
+ const m = createAppState();
203
+ const spy = vi.spyOn(m, 'schedulePreload');
204
+ m.setAIConfig({ preload: 'none', stt: { type: 'whisper-cpp' } });
205
+ const before = spy.mock.calls.length;
206
+ m.setAIConfig({ preload: 'none', stt: { type: 'whisper-wasm' } });
207
+ expect(spy.mock.calls.length).toBe(before + 1);
208
+ });
209
+ it('guards executePreload against concurrent passes', async () => {
210
+ const m = createAppState();
211
+ m.setAIConfig({
212
+ preload: 'none',
213
+ stt: { type: 'whisper-cpp' },
214
+ });
215
+ const exec = m.executePreload.bind(m);
216
+ // Two overlapping passes: the second must early-return (guarded), so the
217
+ // STT factory is invoked once, not twice.
218
+ const { getSTT } = (await import('../../browser-ai/index.js'));
219
+ getSTT.mockClear();
220
+ await Promise.all([exec(), exec()]);
221
+ expect(getSTT).toHaveBeenCalledTimes(1);
222
+ await m.dispose();
223
+ });
224
+ });
225
+ describe('R7: empty-config preload early-return resets loaded/failed', () => {
226
+ it('clears stale loaded/failed entries', async () => {
227
+ const m = createAppState();
228
+ // Seed stale entries through a normal pass, then re-run with empty config.
229
+ m.setAIConfig({ preload: 'none', stt: { type: 'whisper-cpp' } });
230
+ await m.executePreload();
231
+ expect(m.aiLoading.loaded.length).toBeGreaterThan(0);
232
+ // Now an empty config — the early-return path must reset loaded/failed.
233
+ m.setAIConfig({ preload: 'none' });
234
+ await m.executePreload();
235
+ expect(m.aiLoading.phase).toBe('idle');
236
+ expect(m.aiLoading.loaded).toEqual([]);
237
+ expect(m.aiLoading.failed).toEqual([]);
238
+ await m.dispose();
239
+ });
240
+ });
@@ -15,11 +15,12 @@ export declare class SmrtAppStateManager {
15
15
  private _aiConfig;
16
16
  private _preloadScheduled;
17
17
  private _idleCallbackId;
18
+ private _preloadInFlight;
18
19
  private _socket;
19
20
  private _socketConfig;
20
21
  private _reconnectTimeout;
21
- private _subscribedSTTAdapters;
22
- private _subscribedTTSAdapters;
22
+ private _sttSubscriptions;
23
+ private _ttsSubscriptions;
23
24
  constructor(options?: CreateAppStateOptions);
24
25
  /**
25
26
  * Get the current socket configuration (for reconnection)
@@ -44,7 +45,15 @@ export declare class SmrtAppStateManager {
44
45
  */
45
46
  initialize(): Promise<void>;
46
47
  /**
47
- * Set or update AI configuration
48
+ * Set or update AI configuration.
49
+ *
50
+ * No-ops when the incoming config is deep-equal to the current one. The
51
+ * Provider's `ai` $effect depends on the prop's identity, and the documented
52
+ * usage passes an inline `ai={{…}}` object literal — a fresh identity on every
53
+ * parent render. Without this guard each render would cancel the idle
54
+ * callback, reset `_preloadScheduled`, and re-schedule (and, for `eager`,
55
+ * re-launch a full preload), thrashing the scheduler and double-downloading
56
+ * models (R3).
48
57
  */
49
58
  setAIConfig(config: AIConfig): void;
50
59
  /**
@@ -56,9 +65,18 @@ export declare class SmrtAppStateManager {
56
65
  */
57
66
  triggerPreload(): void;
58
67
  /**
59
- * Execute the preloading of configured adapters
68
+ * Execute the preloading of configured adapters.
69
+ *
70
+ * Re-entrancy guarded: an `idle`/`eager` strategy can be re-scheduled while a
71
+ * pass is still awaiting model downloads. Without the guard the overlapping
72
+ * passes interleave their aiLoading writes and double-download models (R3).
60
73
  */
61
74
  private executePreload;
75
+ /**
76
+ * The actual preload pass. Always invoked behind the `_preloadInFlight` guard
77
+ * in {@link executePreload}.
78
+ */
79
+ private runPreload;
62
80
  /**
63
81
  * Update the AI loading state
64
82
  */
@@ -118,8 +136,13 @@ export declare class SmrtAppStateManager {
118
136
  */
119
137
  initializeSTT(options?: GetSTTOptions): Promise<STTAdapter>;
120
138
  /**
121
- * Subscribe to STT adapter events
122
- * Only subscribes once per adapter instance to prevent duplicate listeners
139
+ * Subscribe to STT adapter events.
140
+ *
141
+ * Captures every unsubscribe handle the adapter returns and stores a single
142
+ * teardown both per-manager (called on dispose()) and at module scope keyed
143
+ * by adapter identity. Because warm adapters are shared singletons, any
144
+ * previous owner's listeners are torn down first — guaranteeing exactly one
145
+ * live listener set per adapter, with the latest manager owning it (R1).
123
146
  */
124
147
  private subscribeToSTTEvents;
125
148
  /**
@@ -136,8 +159,11 @@ export declare class SmrtAppStateManager {
136
159
  */
137
160
  initializeTTS(options?: GetTTSOptions): Promise<TTSAdapter>;
138
161
  /**
139
- * Subscribe to TTS adapter events
140
- * Only subscribes once per adapter instance to prevent duplicate listeners
162
+ * Subscribe to TTS adapter events.
163
+ *
164
+ * Mirrors {@link subscribeToSTTEvents}: captures unsubscribe handles, dedups
165
+ * per-manager, and evicts any prior owner so a shared warm adapter never
166
+ * pins more than one manager's `_state` proxy (R1).
141
167
  */
142
168
  private subscribeToTTSEvents;
143
169
  /**
@@ -176,6 +202,12 @@ export declare class SmrtAppStateManager {
176
202
  * Unload LLM model to free memory
177
203
  */
178
204
  unloadLLM(): Promise<void>;
205
+ /**
206
+ * Unsubscribe every adapter-event listener this manager owns (R1). Called on
207
+ * dispose() so a destroyed Provider stops being pinned by the shared warm
208
+ * adapters' module-surviving listener `Set`s.
209
+ */
210
+ private unsubscribeAllAdapterEvents;
179
211
  /**
180
212
  * Dispose of all resources
181
213
  */
@@ -1 +1 @@
1
- {"version":3,"file":"app-state.svelte.d.ts","sourceRoot":"","sources":["../../src/state/app-state.svelte.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app-state.svelte.d.ts","sourceRoot":"","sources":["../../src/state/app-state.svelte.ts"],"names":[],"mappings":"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"}