@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.
- package/AGENTS.md +4 -1
- package/dist/Provider.svelte +12 -2
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/components/admin/AgentSettingsShell.svelte +72 -4
- package/dist/components/admin/AgentSettingsShell.svelte.d.ts.map +1 -1
- package/dist/components/admin/__tests__/AgentSettingsShell.tablist.test.js +93 -0
- package/dist/components/forms/DateRangeInput.svelte +29 -5
- package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateTimeInput.svelte +34 -7
- package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/FileUpload.svelte +3 -1
- package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +72 -36
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/FormMicButton.svelte +14 -11
- package/dist/components/forms/FormMicButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/PhoneInput.svelte +29 -5
- package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextInput.svelte +35 -7
- package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextareaInput.svelte +29 -5
- package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +37 -0
- package/dist/components/forms/__tests__/Form.stt-error.test.js +57 -0
- package/dist/components/forms/__tests__/FormMicButton.test.js +7 -0
- package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +74 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte +25 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +15 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +1 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte +42 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +20 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +1 -0
- package/dist/hooks/__tests__/useSTT-ownership.test.js +102 -0
- package/dist/hooks/useSTT.svelte.d.ts.map +1 -1
- package/dist/hooks/useSTT.svelte.js +20 -6
- package/dist/hooks/useTTS.svelte.d.ts.map +1 -1
- package/dist/hooks/useTTS.svelte.js +20 -6
- package/dist/i18n/server.d.ts +5 -5
- package/dist/i18n/server.js +5 -5
- package/dist/internal/logger.d.ts +13 -0
- package/dist/internal/logger.d.ts.map +1 -0
- package/dist/internal/logger.js +12 -0
- package/dist/state/__tests__/app-state-ai-lifecycle.test.js +240 -0
- package/dist/state/app-state.svelte.d.ts +40 -8
- package/dist/state/app-state.svelte.d.ts.map +1 -1
- package/dist/state/app-state.svelte.js +224 -54
- 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
package/dist/i18n/server.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* languages-free
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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';
|
package/dist/i18n/server.js
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* languages-free
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
22
|
-
private
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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":"AAAA;;;;;GAKG;AAEH,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAIlB,KAAK,UAAU,EAEf,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,UAAU,EAChB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAE1B,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,IAAI,EACT,KAAK,WAAW,EACjB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"app-state.svelte.d.ts","sourceRoot":"","sources":["../../src/state/app-state.svelte.ts"],"names":[],"mappings":"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"}
|